Files
MailUI4Agents/client/electron/test/harmony-presets.test.mjs
JianFeeeee 4af083e8b8 test(判据): 4 个判据读的是**另一棵树** —— 修掉硬编码的仓库根,并让 stripComments 保持行号
pi 2026-09-15 实测出来的,**这次长在判据自己身上** —— 正是我们前几轮一直在消的那个形状。

## 一、`const ROOT = '/home/program/agentmail'`:规则进来了,对象没进来

`harmony-arkts.test.mjs` 把仓库根写成了绝对路径。后果我按 pi 的步骤亲手复现了:

```
$ git worktree add --detach /tmp/wt-verify 7f4fa26      # 那个检出里 import 顺序**确实**违规
  (核对:最后 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF…`)
$ cd /tmp/wt-verify/client/electron && node --test test/harmony-arkts.test.mjs
ok 1 / ok 2 / ok 3   # pass 3  # fail 0      ← **在一个明显违规的检出上 3/3 全绿**
```

因为它读的不是 `/tmp/wt-verify`,是 `/home/program/agentmail`(那份早已修好)。
两层后果,第二层最糟:
① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换目录不是"红",是 `readdirSync` 直接抛;
② 在本机做 worktree 复核时,它**静默读另一棵树并报绿**。
**判据的逻辑是对的、对象是错的** —— 这比"判据写错了"更难发现,因为它在原地永远是绿的。

同一个毛病在 4 个文件里,**恰好全是最近这几笔新写的**(另 10 个鸿蒙判据写法是对的):

```
harmony-admin / harmony-imageprep / harmony-presets / harmony-arkts  →  const ROOT = '/home/program/agentmail';
其余 10 个                                                          →  const ROOT = join(HERE, '..', '..', '..');
```

已全部照邻居改掉。**修好之后在同一个违规检出上:`# fail 1`** —— 它终于会红了。

## 二、修这条时又牵出一个:`stripComments` **改变了行号**

修好路径后,判据报出"最后一个 import 在第 64 行、第 47 行已是语句",
而**真实文件里是第 80 / 63 行**。成因:`stripComments` 把块注释整块抹成 `''`,
而块注释**自带换行** ⇒ 它之后所有行号整体前移。

这不是小节:全仓判据都用 `文件:行号` 定位(`grep -n`、编辑器跳转、`git show` 核对),
**报出来的行号必须能直接用**,否则读者第一步得先猜"这是剥过的还是没剥的"。
改成"块注释里的每个换行换成等量空行"。修完报的就是 **80 / 63**,与文件逐字对上。

## 三、新增两条判据,让这两个形状不能再回来

1. **`★ 判据不许把仓库根硬编码成绝对路径`** —— 扫判据目录里**真代码**
   (`code()` 剥注释,否则本文件自己的说明文字就会误报),找
   `const X = '/绝对路径'` 且**看着像仓库内**的声明。
   **例外按名字放行**(含 `TOOLCHAIN`/`SDK`/`HDC` 的常量)—— 工具链本来就不在仓库里、推不出来;
   按**值**做白名单会逼着下一个人为了过判据去改那个路径的写法。
2. **`★ stripComments 必须保持行号`** —— 造含多行块注释的样本,断言剥完
   **行数不变**、且第 N 行仍是原来的第 N 行;**同时**断言注释内容确实被去掉了
   (别为了保行号把注释留下)。

两条都做了**变异验证**:
- 把 `harmony-admin` 的 ROOT 改回硬编码 ⇒ 新判据**红**,并点名那个文件;还原后绿。
- 在 `MainPage.ets` **import 之前**插一条语句 ⇒ `harmony-arkts` **红**
  (第 79 行 vs 第 1 行);还原后绿。**这条同时证明了"读的是自己那棵树"** ——
  同样这个变异,在修路径**之前**是绿的。

## 四、未做 / 未验

- 到期闸门那 7 条**没动**(要真装真点,是另一件活)。
- **"把 build 做成一条判据"我探了,两个硬障碍**(详见给 pi 的回信):
  ① `client/harmony/oh_modules` 被 `.gitignore` 排除且未入库 ⇒ **全新检出没有它**,
     构建会先死在装依赖上;② 本沙箱**拒写 `/root/.hvigor`**(`mkdir` Permission denied),
     `hvigorw` 在 worktree 里直接 `EACCES: mkdir '/root/.hvigor/project_caches/…'`。
  所以它在本仓能编过、在干净检出编不过 —— 作为判据它现在会**假红**。
2026-09-15 12:06:15 +08:00

178 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 预设的**行为**判据:每一档都真的画得出来。
*
* 为什么需要它(pi 2026-09-14 指出):`appearance-defaults.test.mjs` 那条
* "鸿蒙 `PRESET_IDS` 与 WebUI `PRESETS` 的 id 与顺序逐项一致" 只挡得住**一半** ——
* 它挡"清单漏一档",**挡不住"清单有、渲染器没有"**:将来两边都加了第 7 档、
* 却忘了给 `layersFor` 加分支,那条判据**全绿**,而用户看到的是"无背景"
* ——正是"信息对等缺口"的同一个症状,只是路径换了。
*
* 所以这里是**两条配对**:值判据管"清单对不对",行为判据管"每一档真的画得出来"。
* 这与命中区那处(数值在 `NavItems.ts`、判据直接 import)是同一个配对方式。
*
* **能真跑**,不是形态判据:`model/Wallpaper.ts` 是纯逻辑(无 `@ohos` 依赖、
* 类型可擦除),用 node 的 `--experimental-strip-types` 直接执行它 —— 与
* `harmony-logic.test.mjs` 同一个先例。因此它**不需要设备**,
* 也**不该**登记进 `static` 欠账(那会虚增余额)。
*/
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
/*
* ★ 仓库根必须**从本文件的位置推**,不许硬编码绝对路径。
*
* 原来这里写的是 `const ROOT = '/home/program/agentmail';` —— pi 2026-09-15 实测出后果:
* 把带违规的提交检出到**别的目录**再跑,判据**读的仍是 `/home/program/agentmail`**,
* 于是"在一个 import 顺序明显违规的检出上 3/3 全绿"。
* 两层后果,第二层最糟:
* ① 它**永远无法验证任何别的 checkout / CI / 镜像**(换目录不是"红",是 readdirSync 直接抛);
* ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿** —— 正是我们这几轮在消的形状,
* 这次长在判据自己身上。**"规则进来了,对象没进来"**。
* 修法照邻居(10 个鸿蒙判据都是 `join(HERE, '..', '..', '..')`)。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const WALLPAPER_TS = join(ROOT, 'client/harmony/entry/src/main/ets/model/Wallpaper.ts');
// 直接执行 `.ets` 树里的**纯逻辑** `.ts`(有 `@ohos` 依赖的文件不能这样跑)
const W = await import(pathToFileURL(WALLPAPER_TS).href);
const IDS = W.PRESET_IDS;
const THEMES = [false, true];
const themeName = d => (d ? '深色' : '浅色');
/** 层栈的稳定序列化(只取有意义字段,便于比较与报错) */
const shapeOf = layers => JSON.stringify(layers.map(l => ({
kind: l.kind,
cx: l.cx, cy: l.cy, radius: l.radius, angle: l.angle,
colors: l.colors, stops: l.stops,
lineColor: l.lineColor, lineAlpha: l.lineAlpha, step: l.step
})));
test('六档 × 两种主题:都画得出来(不抛异常、非空、每层字段有效)', () => {
assert.ok(Array.isArray(IDS) && IDS.length >= 6, `预设清单要能取到(实际 ${IDS?.length})`);
for (const id of IDS) {
for (const dark of THEMES) {
let layers;
assert.doesNotThrow(() => { layers = W.layersFor(id, dark); },
`${id}(${themeName(dark)})调用就抛了 —— 用户看到的是"无背景"`);
assert.ok(Array.isArray(layers) && layers.length >= 1,
`${id}(${themeName(dark)})返回空层栈 ⇒ 这一档画出来是空白`);
for (const [i, l] of layers.entries()) {
assert.ok(['radial', 'linear', 'grid'].includes(l.kind),
`${id}(${themeName(dark)})第 ${i} 层的 kind 认不出:${l.kind}`);
if (l.kind === 'grid') {
assert.ok(typeof l.step === 'number' && l.step > 0, `${id} 网格档缺 step`);
assert.ok(typeof l.lineColor === 'string' && l.lineColor.length > 0, `${id} 网格档缺线色`);
} else {
assert.ok(Array.isArray(l.colors) && l.colors.length >= 2 && l.colors.every(c => typeof c === 'string' && c.length > 0),
`${id}(${themeName(dark)})第 ${i} 层的色标不完整:${JSON.stringify(l.colors)}`);
}
}
}
}
});
/*
* ★ 这条是 pi 那半边的直接对应物:**"清单里有、渲染器里没有"必须红。**
*
* 手法:拿一个**不存在的 id** 调一次 —— `normalizePreset` 会把它落到默认档
* (aurora),于是"兜底长什么样"就有了可比较的对象。任何一档如果**丢了分支**,
* 它会静默落到同一个兜底上 ⇒ 与兜底逐字节相同 ⇒ 红。
*
* **已知边界(写出来,不假装全覆盖)**:`aurora` 自己**就是**那个兜底档,
* 所以它"丢了分支"在行为上不可观测(结果一样)。这条判据能测到的是
* **另外五档**;aurora 的存在性由值判据(清单相等)与下面那条"六档两两不同"兜住。
*/
test('★ 清单里有、渲染器里没有 → 红(每一档都必须与"兜底档"不同)', () => {
for (const dark of THEMES) {
const fallback = shapeOf(W.layersFor('__这个 id 不存在__', dark));
const aurora = shapeOf(W.layersFor('aurora', dark));
assert.equal(fallback, aurora, `自检:认不出的 id 应当落到默认档 aurora(${themeName(dark)})`);
for (const id of IDS) {
if (id === 'aurora') continue; // 见上面的"已知边界"
assert.notEqual(shapeOf(W.layersFor(id, dark)), fallback,
`${id}(${themeName(dark)})画出来的和"兜底档"一模一样 ⇒ 它没有自己的渲染分支` +
`(清单里有、渲染器没有:用户看到的是 aurora,不是他选的那档)`);
}
}
});
test('六档两两不同(复制粘贴分支没改参数 → 红)', () => {
for (const dark of THEMES) {
const seen = new Map();
for (const id of IDS) {
const s = shapeOf(W.layersFor(id, dark));
assert.ok(!seen.has(s),
`${id} 与 ${seen.get(s)} 在${themeName(dark)}下画出来完全相同 —— 复制分支后忘了改参数`);
seen.set(s, id);
}
}
});
test('深色档必须与浅色档不同("深色没换色" → 红)', () => {
for (const id of IDS) {
assert.notEqual(shapeOf(W.layersFor(id, true)), shapeOf(W.layersFor(id, false)),
`${id} 的深色档与浅色档完全一样 ⇒ 深色模式下这一档没有换色`);
}
});
/*
* ★ 静默兜底必须留下**可观测的痕迹**(pi 2026-09-14)。
*
* §10 登记了"未知 id 静默换成 aurora"这条缺省语义,但**登记只防"被误报成 bug"**,
* 防不住"没人知道它正在发生" —— 真实环境里它不留任何痕迹。所以返回值里带
* `presetSubstitutedFrom`:换过就是原来那个 id,没换过是空串,页面据此打一行日志。
*
* 这条判据同时钉住两件事:① 替换**必须**留痕;② 没替换时**不许**留痕
* (否则痕迹会退化成噪声,等于没有)。
*/
test('★ 静默兜底留痕:换过要带出原 id,没换过必须是空串', () => {
const unknown = 'ocean-v2-这个档还不存在';
const bad = W.resolveBackground('preset', unknown, 0.2, false, false);
assert.equal(bad.presetId, 'aurora', `自检:未知 id 应当被换成默认档(实际 ${bad.presetId})`);
assert.equal(bad.presetSubstitutedFrom, unknown,
'认不出的 id 被静默替换了,却没留下痕迹 ⇒ 版本偏移时无法确认它在发生(只能猜)');
for (const id of IDS) {
for (const dark of THEMES) {
const plan = W.resolveBackground('preset', id, 0.2, false, dark);
assert.equal(plan.presetSubstitutedFrom, '',
`${id}(${themeName(dark)})是已知档,不该留下"替换过"的痕迹(痕迹变成噪声就等于没有)`);
}
}
});
/* ── 模糊档的搬运与归一(P4c 起壁纸真的会糊了) ── */
test('模糊档:计划**搬得动**这个值,且 0~40 归一(边界与 Appearance.ts 的 clamp 同值)', () => {
/*
* ★ 这条是变异测试补上的:把 `plan.blurPx = normalizeBlur(blurPx)` 改成 `= 0`
* (计划根本不搬运模糊值)时,appearance 那几条**全绿** ——
* 它们判的是"页面把值传进来了"和"页面拿它去糊了",中间这一段没人判。
*/
assert.equal(typeof W.normalizeBlur, 'function', 'Wallpaper 要提供 normalizeBlur');
assert.equal(W.MAX_BLUR_PX, 40, '上限 40(与 model/Appearance.ts 的 clampNumber(bgBlur,0,40,4) 同值)');
for (const [input, want] of [[0, 0], [4, 4], [40, 40], [41, 40], [999, 40], [-5, 0], [-0.4, 0]]) {
assert.equal(W.normalizeBlur(input), want, `normalizeBlur(${input}) 应当是 ${want}`);
}
assert.equal(W.normalizeBlur(4.6), 5, '小数要四舍五入到整数 px');
assert.equal(W.normalizeBlur(NaN), 0, 'NaN 要归 0(不能是 NaN,否则喂给 blur() 是未定义行为)');
// 搬运:三档都要带上,而且值就是传进去的那个
for (const kind of ['preset', 'image', 'none']) {
const plan = W.resolveBackground(kind, 'aurora', 0.2, kind === 'image', false, 12);
assert.equal(plan.blurPx, 12, `\`${kind}\` 档没有把模糊值搬进计划(计划里是 ${plan.blurPx})`);
}
// 缺省入参:老调用点(不传第 6 个参数)语义不变 ⇒ 0
const legacy = W.resolveBackground('preset', 'aurora', 0.2, false, false);
assert.equal(legacy.blurPx, 0, '不传模糊档时应当是 0(老调用点语义不变)');
// 越界要**归一**再搬,而不是把 999 原样带出去
assert.equal(W.resolveBackground('image', 'aurora', 0.2, true, false, 999).blurPx, 40,
'越界的模糊值要在搬运时就归一(否则页面会把 999 直接喂给 blur())');
});