Files
MailUI4Agents/client/electron/test/harmony-appearance.test.mjs
JianFeeeee c12744e8c3 跨端: 导航条材质选 (a) 固定档(推翻我的 (b))—— 并修掉"注释说 (a)、代码是 (b)"的自相矛盾
pi 2026-09-15 裁定:**推翻 (b),选 (a)**。我原先给 (b) 的理由不成立,他逐条驳了:

1. **WebUI 的导航条根本不读 `--bg-blur`**:`index.css:1114` 的 `.narrow-nav` 是硬编码
   `backdrop-filter: blur(18px) saturate(1.5)`。我引这条支持"两个量不同",
   而同一条也说明**它不由用户偏好驱动**。
2. **WebUI 那个滑杆的语义是"背景"**:`BackgroundPicker.tsx:183` —— `label="模糊"`、
   `hint="虚化细节,避免背景与正文抢注意力"`、`min=0 max=24`,只作用在
   `.app-backdrop{filter:blur(var(--bg-blur))}` 上。
3. WebUI 自己留了**分开的**令牌 `--bg-blur-panel`(`index.css:267`,注释写明
   "与壁纸自身的 `--bg-blur` 分开")—— 它的词汇表本身就拒绝把两者等同。
4. **★ 我给 (b) 的理由不成立**:我写"(a) 会让那个滑杆在导航条上变成死控件",
   可那个滑杆**已经**被壁纸消费了(`MainPage.ets` 壁纸层的 `.blur(bgPlan.blurPx)`)——
   它从来**不是**导航条的控件。(a) 之下它照样是活的。
5. §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。

产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换张壁纸而变厚变薄。

## 最该记的是:我的注释和代码**互相矛盾**

`MainPage.ets` 里那段注释论证的是 (a)、并明确写着"跟随是错的,pi 抓出来了",
而它下面那一行代码是 (b)。**下一个读者会照注释把代码改回去,并引我那句话当权威。**
这是这一路反复在消的形状(说的与做的不一致、而判据看不见),这次落在**注释**上 ——
而注释正是"理由要写清"那条纪律的证据源。已整段重写为真实的 (a) 版本,并把
"(a) 会让滑杆变死控件"这个**错的理由**连同它为什么错一起留在注释里。

## 做掉的东西

- `MainPage.ets`:导航条回到 `.backgroundBlurStyle(Theme.navMaterial)`;
  删掉 `NAV_MATERIAL_OF` 表与 `navMaterialFor` 的 import((a) 之下都是孤儿)。
- `model/Appearance.ts`:删 `navMaterialFor`(它存在的唯一理由就是方案 (b))。
  `blurStyleFor` 现在**没有任何调用点** —— 如实登记在它的文档注释里
  ("有测试"不等于"有人用",上一轮我刚因同形状被抓过),不假装它活着。
- **五处"整条字面表达式"断言改成语义断言**(pi §四):`cross-client-theme`、`harmony-appearance`、
  `harmony-nav`(3 处)此前都在钉
  `/\.backgroundBlurStyle\(NAV_MATERIAL_OF\[navMaterialFor\(…\)\] \?\? Theme\.navMaterial\)/`
  —— 字面换字面,正是 `CRITERIA.md` 不许的"对源码形状的匹配"。
  现在判:① 那一处的材质**来自 `Theme.navMaterial` 这个系统令牌**;
  ② **不许跟随** `bg_blur`(NavBar 真代码里不许出现 `blurPx`/`blurStyleFor`);
  ③ 令牌是 `BlurStyle` 枚举值、不是 `NONE`、且**成员名真实存在于 SDK 枚举**。
- "可达性"那条判据**随契约作废**(它守的是方案 (b)):换成判 (a) 的契约。
  **判据随契约走,不随实现走。**
- `harmony-appearance`:原先判"页面里那张表的键必须是 SDK 成员"。表删了,
  改成**枚举 `blurStyleFor` 的整个值域**(0..40 + 界外 + NaN),逐个核 SDK 成员 ——
  比原来只核表里那三行**更严**。

## 判据自己先错了一次,记下来

新判据第一版**没剥注释**就断言"NavBar 里不许出现 `blurPx`",当场红了 ——
而红的原因不是代码错,是 `NavBar` 的**文档注释**里正好写着
"我一度把档位接过用户偏好(`navMaterialFor(this.bgPlan.blurPx)`)"这句历史说明。
**注释说明禁令 ≠ 违反禁令**;不剥注释,这条判据就会变成"逼人删掉解释",
恰好与本仓库"理由要写清"的纪律相反。改成 `stripComments(bar)` 后再判,并加了一条
"注释里确实留着那处说明"的自检前提。

## 变异体:45 个全部被抓(含 3 个新判据的专属变异)

方案 (b) 落地时配的 13 个变异体**整体作废**(它们锚的代码被删了),标注 `retired`
并写清理由 —— 不是"没跑成"。另有 5 个锚点漂移(我在注释里逐字引用了被锚的那句代码,
污染了通用正则)的**重锚**到真代码;注释里那句逐字引用也一并去掉了
(**注释里逐字抄代码**正是让"按字面锚定"的变异体反复失效的根因)。
新增 3 个针对 (a) 契约的变异体(绕过令牌 / 又跟随 `bg_blur` / 令牌变 `NONE`),全部被抓。

## 未验

- **真机观感仍未验**:三档材质在真机上能不能看出差别、滑杆手感、管理页布局,
  只有真机能答。本机模拟器已起(`hdc list targets` 有目标),但
  `run-all.mjs` 的**设备闸已经到期**(8 个静态判据的前提成立)⇒ 套件现在会挡在
  那道闸上、不打 `RESULT`。**这不是本笔引入的**(前提是环境变了),已单独报给 pi。
- Go 侧 `debt_registry_test.go` 仍未在本机跑(无 Go 模块缓存);pi 已在别处跑过,绿。
2026-09-15 11:58:19 +08:00

955 lines
60 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.

/**
* P4 判据:外观(主题 + 壁纸)在服务端与本地之间的搬运。
*
* 被测对象是鸿蒙客户端**真正引用的那份逻辑**(`model/Appearance.ts`,纯逻辑无 UI 依赖),
* 用 node 的 `--experimental-strip-types` 直接执行 —— 断言的是行为,不是源码字符串;
* 只有"接线"那几条读源码(因为"逻辑写好了没人用"正是要防的)。
*
* 这一期为什么值得单独一组判据:WebUI 侧这套同步**曾经整整一段时间没生效过**
* 而单测全绿 —— 因为测试只断言了方法与报文,没断言 URL(路径多写了一层 `/api/v1`)。
* 所以这里有一条判据专门钉路径,而且钉的是**相对基地址**的形状。
*/
import { code, prose } from './lib/read.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
/** SDK 位置(与 harmony-system-api 同一套默认值/环境变量) */
const CLT = process.env.HARMONY_CLT || '/opt/huawei/command-line-tools';
const MODULE_TS = join(HARMONY_ETS, 'model/Appearance.ts');
const A = await import(pathToFileURL(MODULE_TS).href);
/** 剥注释读源码:注释里出现某个调用恰恰说明不了那个调用存在 */
const read = (rel) => code(join(HARMONY_ETS, rel));
const snap = (over = {}) => Object.assign(new A.AppearanceSnapshot(), over);
const resp = (over = {}) => Object.assign(new A.AppearanceResponse(), over);
// ───────────────────── 归一层 ─────────────────────
test('服务端回包 → 快照:认不出的值退回默认,不做半信半疑的处理', () => {
// 全空(服务端新字段/老数据):每一项都要有安全的默认
const empty = A.snapshotFromResponse(resp());
assert.equal(empty.theme, 'system', '认不出的主题要跟随系统,而不是硬选一个');
assert.equal(empty.bgKind, 'none');
assert.equal(empty.bgPresetId, 'aurora');
assert.equal(empty.bgDim, 12);
assert.equal(empty.bgBlur, 4);
// 脏值(拼错的枚举、越界数字、小数、NaN)不该被照单全收
const dirty = A.snapshotFromResponse(resp({ theme: 'drak', bg_kind: 'IMAGE', bg_dim: 999, bg_blur: -5 }));
assert.equal(dirty.theme, 'system');
assert.equal(dirty.bgKind, 'none', '枚举大小写不同也是认不出(不做模糊匹配)');
assert.equal(dirty.bgDim, 90, '压暗值上限 90');
assert.equal(dirty.bgBlur, 0, '模糊值下限 0');
const frac = A.snapshotFromResponse(resp({ bg_dim: 12.6, bg_blur: 4.4 }));
assert.equal(frac.bgDim, 13, '小数要取整(渲染值不能是半个像素)');
assert.equal(frac.bgBlur, 4);
assert.equal(A.snapshotFromResponse(resp({ bg_dim: NaN })).bgDim, 12, 'NaN 退回默认');
});
test('本地快照 → PUT 报文:选了图片档却没有图,要退回 none', () => {
const noImage = A.payloadFromLocal(snap({ bgKind: 'image' }), false);
assert.equal(noImage.bg_kind, 'none', '否则服务端会存一个指向空图的记录');
const withImage = A.payloadFromLocal(snap({ bgKind: 'image' }), true);
assert.equal(withImage.bg_kind, 'image');
// 字段名要与服务端 JSON 一致(蛇形);混进驼峰名服务端只会静默用默认值
const payload = A.payloadFromLocal(snap(), false);
for (const k of ['theme', 'bg_kind', 'bg_preset_id', 'bg_dim', 'bg_blur']) {
assert.ok(k in payload, `PUT 报文要有 ${k}`);
}
for (const bad of ['bgKind', 'bgDim', 'bgBlur', 'bgPresetId']) {
assert.ok(!(bad in payload), `PUT 报文不该出现驼峰名 ${bad}`);
}
});
// ───────────────────── 合并决策(这一期的验收核心) ─────────────────────
test('★ 服务端没有记录时:以**本地**为准并推上去,绝不拿默认值覆盖本地', () => {
/*
* 这是两条最贵的规则之一。服务端在没有记录时回的是一份**默认值**,
* 拿它覆盖本地等于把用户已有的外观(尤其是本地缓存的壁纸)抹掉 ——
* WebUI 侧漏了这条时,"每个老用户升级后第一次登录都会发现主题被重置"。
*/
const local = snap({ theme: 'dark', bgKind: 'image', bgPresetId: 'ocean', bgDim: 40, bgBlur: 30 });
const m = A.mergeAppearance(local, resp({ saved: false }), false);
assert.equal(m.action, 'push-local', '谁覆盖谁:本地覆盖服务端');
assert.equal(m.shouldPush, true, '要把本地这份推上去作为账号的初始外观');
assert.equal(m.status, 'pending', '状态要能看出"正在同步到账号"');
assert.deepEqual(
{ theme: m.snapshot.theme, bgKind: m.snapshot.bgKind, bgPresetId: m.snapshot.bgPresetId, bgDim: m.snapshot.bgDim, bgBlur: m.snapshot.bgBlur },
{ theme: 'dark', bgKind: 'image', bgPresetId: 'ocean', bgDim: 40, bgBlur: 30 },
'本地那份必须原样保留(一个字段都不能被默认值顶掉)'
);
// 反例:这是变异测试要打的那一枪 —— 拿默认值覆盖会立刻丢主题与壁纸
assert.notEqual(m.snapshot.theme, 'system');
});
test('★ 服务端有记录时:以服务端为准,但**不擦掉**本地那张服务端还没有的图', () => {
// 正常情形:服务端说了算
const applied = A.mergeAppearance(snap({ theme: 'light', bgKind: 'preset', bgPresetId: 'x', bgDim: 5, bgBlur: 5 }),
resp({ saved: true, theme: 'dark', bg_kind: 'preset', bg_preset_id: 'aurora', bg_dim: 30, bg_blur: 20 }), false);
assert.equal(applied.action, 'apply-remote');
assert.equal(applied.shouldPush, false);
assert.equal(applied.status, 'synced');
assert.equal(applied.snapshot.theme, 'dark', '服务端说了算');
assert.equal(applied.snapshot.bgPresetId, 'aurora');
assert.equal(applied.snapshot.bgDim, 30);
// 服务端记着 image 档、但**本体不在**(本地还没推上去 / 图被清过):
// 不能照着 image 档渲染一块空地,也不能把本地那张擦掉
const keepLocal = A.mergeAppearance(snap({ bgKind: 'image' }), resp({ saved: true, bg_kind: 'image' }), false);
assert.equal(keepLocal.snapshot.bgKind, 'image', '本地有图 → 先按本地算');
const noLocal = A.mergeAppearance(snap({ bgKind: 'none' }), resp({ saved: true, bg_kind: 'image' }), false);
assert.equal(noLocal.snapshot.bgKind, 'none', '本地也没图 → 不能渲染一块空地');
// 服务端真有图:照服务端
const remoteImage = A.mergeAppearance(snap({ bgKind: 'none' }), resp({ saved: true, bg_kind: 'image' }), true);
assert.equal(remoteImage.snapshot.bgKind, 'image');
});
test('离线/未登录:本地就是全部,而且**状态要看得见**(降级不可见 = 用户以为能带走)', () => {
const only = A.localOnly(snap({ theme: 'dark' }));
assert.equal(only.status, 'local-only');
assert.equal(only.snapshot.theme, 'dark');
assert.equal(only.shouldPush, false, '离线时不该尝试推');
// 三个状态文案要分得开
const labels = ['synced', 'pending', 'local-only'].map(A.statusLabel);
assert.equal(new Set(labels).size, 3, '三种状态要有不同文案');
assert.match(A.statusLabel('local-only'), /仅本机/);
assert.match(A.statusLabel('pending'), /同步/);
});
// ───────────────────── 系统方案:数字 → 系统材质 / 色彩模式 ─────────────────────
test('★ 模糊值映射到**系统材质档次**(不是把 40 当半径塞给某个 API)', () => {
/*
* 服务端存的是 WebUI 的 `bg_blur`(模糊像素半径,0~40),鸿蒙这边"模糊"由系统材质提供
* (`BlurStyle`)。同一个数字两边含义不同,必须显式映射 —— 这条判据钉住映射关系,
* 顺带钉住"没有 0~40 档全开"(材料只有几档,落不到档上的数字要归到最近的档)。
*/
assert.equal(A.blurStyleFor(0), 'NONE', '不模糊就是不用材质');
assert.equal(A.blurStyleFor(4), 'COMPONENT_THIN');
assert.equal(A.blurStyleFor(8), 'COMPONENT_THIN');
assert.equal(A.blurStyleFor(9), 'COMPONENT_REGULAR');
assert.equal(A.blurStyleFor(20), 'COMPONENT_REGULAR');
assert.equal(A.blurStyleFor(21), 'COMPONENT_THICK');
assert.equal(A.blurStyleFor(40), 'COMPONENT_THICK');
assert.equal(A.blurStyleFor(999), 'COMPONENT_THICK', '越界要归到最近的档,不能返回空');
assert.equal(A.blurStyleFor(-3), 'NONE');
// 档次必须来自系统枚举(写成自造名字会编译不过/不生效)
const sdk = A.blurStyleFor(12);
// SDK 的 .d.ts 是**源码**(判它的枚举成员),所以走 code():剥掉注释,
// 免得注释里举例的枚举名被当成员读进来
const commonDts = code(process.env.HARMONY_COMMON_DTS
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts');
const enumBlock = commonDts.slice(commonDts.indexOf('declare enum BlurStyle'));
const members = [...enumBlock.slice(0, enumBlock.indexOf('}')).matchAll(/^\s{2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1]);
assert.ok(members.length > 3, '要从 SDK 里读到 BlurStyle 成员');
for (const tier of ['NONE', 'COMPONENT_THIN', 'COMPONENT_REGULAR', 'COMPONENT_THICK']) {
assert.ok(members.includes(tier), `${tier} 必须是系统 BlurStyle 的成员`);
}
assert.ok(members.includes(sdk));
/*
* 另一半:**`blurStyleFor` 在整段可达输入上返回的每个值都必须是 SDK 的成员名**。
*
* 这里原先判的是页面里那张档位名→枚举的表(`NAV_MATERIAL_OF`,已随方案 (b) 删除)。
* 那张表随"导航条改回固定档"(pi 2026-09-15 的裁定,见 `harmony-nav` 的契约判据)
* 一起删了 —— 导航条现在直接用 `Theme.navMaterial`,不再经档位名中转。
*
* 但"**别自己发明档位名**"这件事仍然要判,而且现在**只有这一条在判它**:
* `blurStyleFor` 是档位名的唯一产生者(它现在是孤岛:判据在跑、页面没人调,
* 见 `Appearance.ts` 里那段如实说明)。所以改成枚举**它的整个值域**,
* 逐个对着 SDK 成员核 —— 比原来只核表里那三行**更严**(原来漏判的点现在覆盖了)。
*/
const produced = new Set();
for (let px = 0; px <= 40; px++) produced.add(A.blurStyleFor(px));
produced.add(A.blurStyleFor(-3));
produced.add(A.blurStyleFor(999));
produced.add(A.blurStyleFor(NaN));
assert.ok(produced.size >= 3, `可达输入上应当产出多档(实际 ${produced.size} 档:${[...produced].join('、')})`);
for (const tier of produced) {
assert.ok(members.includes(tier),
`\`blurStyleFor\` 产出了 \`${tier}\`,它不是 SDK 的 BlurStyle 成员` +
`(自造档位名 = 编译不过或静默不生效)。SDK 成员:${members.join('、')}`);
}
/*
* 而"通用映射允许 NONE"这一半必须**留着**:`0 px ⇒ 不模糊`是它的正确语义
* (用户要壁纸清晰是合法选择)。这条曾经被我写错过一次 —— 当时我断言
* "`blurStyleFor` 在 0..40 上不许返回 NONE",那条**恒红**,错的是判据不是代码。
*/
assert.ok(produced.has('NONE'),
'`blurStyleFor` 必须保留 `NONE`(0 px ⇒ 不模糊是合法语义)—— 导航条那边的下限不是靠篡改它实现的');
for (const tier of ['COMPONENT_THIN', 'COMPONENT_REGULAR', 'COMPONENT_THICK']) {
assert.ok(produced.has(tier),
`可达输入上应当覆盖 ${tier}(漏一档说明分档表被改坏了)`);
}
});
test('主题 → **系统色彩模式**(深浅两套颜色由系统给,不自己维护一套色值)', () => {
assert.equal(A.colorModeFor('system'), 'COLOR_MODE_NOT_SET', '跟随系统是默认档');
assert.equal(A.colorModeFor('light'), 'COLOR_MODE_LIGHT');
assert.equal(A.colorModeFor('dark'), 'COLOR_MODE_DARK');
assert.equal(A.colorModeFor('乱七八糟'), 'COLOR_MODE_NOT_SET', '认不出就跟随系统');
/*
* 数值必须与 SDK 的 `ConfigurationConstant.ColorMode` 一致 —— 这**容易记反**:
* `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`。判据直接读 SDK 的枚举文件比对,
* 不凭印象(我第一版就是按 0=浅色 写的,选深色会切成浅色)。
*/
const constDts = code(process.env.HARMONY_CONFIG_CONSTANT_DTS
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts', 'utf8');
const valueOf = (name) => {
const m = new RegExp(`${name}\\s*=\\s*(-?\\d+)`).exec(constDts);
assert.ok(m, `SDK 里要有 ${name}`);
return Number(m[1]);
};
assert.equal(A.colorModeValue('dark'), valueOf('COLOR_MODE_DARK'), '深色的数值要跟 SDK 一致');
assert.equal(A.colorModeValue('light'), valueOf('COLOR_MODE_LIGHT'), '浅色的数值要跟 SDK 一致');
assert.equal(A.colorModeValue('system'), valueOf('COLOR_MODE_NOT_SET'), '跟随系统的数值要跟 SDK 一致');
// 三个数值必须互不相同(写反了这里也能看出来)
assert.equal(new Set(['dark', 'light', 'system'].map(A.colorModeValue)).size, 3);
// 落地处必须真的调系统 API,而且用同一个映射(免得两边各有一套判断)
const store = read('common/AppearanceStore.ets');
assert.match(store, /app\.setColorMode\(colorModeValue\(theme\)\)/, '主题要交给系统色彩模式,数值走纯逻辑');
assert.ok(!/setColorMode\(\s*-?\d\s*\)/.test(store), '页面/store 里不该自己写死色彩模式数值(容易写反)');
// 遮罩浓度:0~90 → 0~1
assert.equal(A.scrimOpacity(0), 0);
assert.equal(A.scrimOpacity(90), 0.9);
assert.equal(A.scrimOpacity(500), 0.9, '越界要夹住');
});
// ───────────────────── 接线("逻辑写好了没人用"是这一期要防的) ─────────────────────
test('★ 路径是相对基地址的(WebUI 那条"整套同步从来没生效过"的坑)', () => {
const api = read('api/AppearanceApi.ets');
assert.match(api, /get<AppearanceApiResponse>\('\/me\/appearance'\)/, 'GET 路径');
assert.match(api, /put<AppearanceResponse>\('\/me\/appearance'/, 'PUT 路径');
assert.match(api, /uploadFile\('\/me\/appearance\/image'/, '上传壁纸路径');
assert.match(api, /getBytes\('\/me\/appearance\/image'\)/, '取壁纸路径');
// base 已经含 /api/v1:再写一层就是 /api/v1/api/v1/... (WebUI 侧真发生过)
assert.ok(!/\/api\/v1\//.test(api), 'AppearanceApi 里不该出现 /api/v1 前缀');
// 图片必须带认证取回来:不能用 ?token=(进日志与历史),也不该让 Image 直接加载 http
assert.ok(!/\?token=/.test(api), '不接受把密钥写进 URL');
const client = read('api/ApiClient.ets');
assert.match(client, /expectDataType: http\.HttpDataType\.ARRAY_BUFFER/, '取图要按二进制收,不能当 JSON 解析');
});
test('缓存键**带账号**(多账号共用一份 = WebUI 的原始缺陷)', () => {
const store = read('common/AppearanceStore.ets');
assert.match(store, /const KEY_PREFIX: string = 'appearance\.'/, '缓存键要有账号前缀');
assert.match(store, /return KEY_PREFIX \+ accountId;/, '键必须拼上账号 id');
assert.match(store, /prefKey\(accountId\)/, '读缓存要按账号取键');
// 换账号后外观要跟着走:设置页与主界面都要用**当前激活账号**去读
const settings = read('pages/SettingsPage.ets');
assert.match(settings, /store\.loadLocal\(ctx, this\.activeId\)/, '设置页按激活账号读缓存');
const main = read('pages/MainPage.ets');
assert.match(main, /store\.loadLocal\(ctx, acctMgr\.getActiveId\(\)\)/, '主界面按激活账号读缓存');
});
test('两处入口都真的应用了外观(只有设置页生效 = 一进主界面就变回去)', () => {
const main = read('pages/MainPage.ets');
assert.match(main, /AppearanceStore\.getInstance\(\)/, '主界面要用同一个 store');
assert.match(main, /store\.syncFromServer\(ctx, client\)/, '主界面进入时要拉一次');
const settings = read('pages/SettingsPage.ets');
assert.match(settings, /await store\.syncFromServer\(ctx, client\)/, '设置页要拉一次');
assert.match(settings, /new AppearanceApi\(client\)\.put\(snap, store\.wallpaper !== null\)/, '改主题要写回服务端');
// 降级要显示给人看("仅本机"),而不是只在内部变量里
assert.match(settings, /statusLabel\(this\.appearanceStatus\)/, '状态要渲染出来');
assert.match(settings, /this\.appearanceStatus = 'local-only'/, '写服务端失败时要如实降级');
// 主题切换三档要齐(跟随系统 / 浅色 / 深色)
assert.match(settings, /\['system', 'light', 'dark'\]/, '三档主题');
});
test('判据自检:把「服务端没记录」判成覆盖本地,必须判红', () => {
/*
* 自检不重跑源码,而是**验证这条判据真的能区分两种行为**:
* 手工构造"错误实现"的输出,确认断言会拒绝它。
* (只断言"看起来能红"是不够的 —— 变异测试在下一层做,见提交信息。)
*/
const wrong = { action: 'apply-remote', status: 'synced', shouldPush: false, snapshot: snap() };
const right = A.mergeAppearance(snap({ theme: 'dark' }), resp({ saved: false }), false);
assert.notDeepEqual(
{ action: wrong.action, shouldPush: wrong.shouldPush },
{ action: right.action, shouldPush: right.shouldPush },
'自检:错误实现与正确实现的这三个字段必须不同,否则判据区分不出行为'
);
});
export const __coverage = ['snapshotFromResponse', 'payloadFromLocal', 'mergeAppearance', 'localOnly', 'blurStyleFor', 'colorModeFor', 'scrimOpacity', 'statusLabel'];
// ───────────────────── P4b:预设档的画法(能力对等,pi 指出的信息对等缺口) ─────────────────────
const WALL_TS = join(HARMONY_ETS, 'model/Wallpaper.ts');
const W = await import(pathToFileURL(WALL_TS).href);
const webCss = code(join(ROOT, 'client/electron/src/index.css'));
const webBgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
test('★ 预设清单与 WebUI 一致(id + 顺序 + 中文标签)—— 少一个档就是"用户设了在鸿蒙看不到"', () => {
/*
* pi 的原话(2026-09-14):WebUI 的背景有预设渐变,若鸿蒙只认 image/none,
* 那"换账号后外观跟随"对预设档就是**不成立**的 —— 用户设了预设,
* 在鸿蒙看到的是没有背景。这是**信息对等**缺口,比"能不能上传壁纸"更基础。
* 所以判据从 WebUI 的源码里抽 id 与标签来比,不在这边再抄一遍。
*/
const block = webBgStore.slice(webBgStore.indexOf('export const PRESETS'), webBgStore.indexOf('];', webBgStore.indexOf('export const PRESETS')));
const webIds = [...block.matchAll(/id:\s*'([a-z]+)'/g)].map(m => m[1]);
const webLabels = [...block.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.ok(webIds.length >= 4, `WebUI 的 PRESETS 要能抽出 id,实际 ${webIds.length} 个`);
assert.deepEqual(W.PRESET_IDS, webIds, '预设 id 与顺序必须与 WebUI 一致');
assert.deepEqual(W.PRESET_IDS.map(W.presetLabel), webLabels, '预设标签必须与 WebUI 一致(用户看到的就是这两个字)');
// 每一个预设都要真能画出层来("有 id 但画不出东西"就是这个缺口的原始形态)
for (const id of W.PRESET_IDS) {
const layers = W.layersFor(id, false);
assert.ok(layers.length >= 1, `预设 ${id} 至少要有一层`);
assert.ok(layers.every(l => l.colors.length > 0 || l.kind === 'grid'), `预设 ${id} 的层要有色标`);
assert.ok(layers.some(l => l.kind === 'grid' || l.colors.length >= 2), `预设 ${id} 要能画出渐变`);
}
});
test('★ 预设色值与 WebUI 的调色板变量逐个对照(不是凭印象写的)', () => {
/*
* 这类"看起来差不多"的色值是跨端最容易悄悄分叉的东西:两边各写一遍十六进制,
* 谁也说不清哪个是当前的。所以从 CSS 的调色板变量里读出 RGB,再比到这边写死的色值。
*/
const cssVar = (name) => {
const m = new RegExp(`${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+);`).exec(webCss);
assert.ok(m, `CSS 里要有 ${name}`);
return '#' + [m[1], m[2], m[3]]
.map(n => Number(n).toString(16).padStart(2, '0').toUpperCase())
.join('');
};
const used = new Set();
for (const id of W.PRESET_IDS) {
for (const l of W.layersFor(id, false)) {
for (const c of l.colors) if (c !== W.TRANSPARENT) used.add(c);
if (l.lineColor) used.add(l.lineColor);
}
}
const palette = {
'#F3F4F6': cssVar('--c-gray-100'),
'#EAECF1': cssVar('--c-gray-200'),
'#DBEAFE': cssVar('--c-blue-100'),
'#BFDBFE': cssVar('--c-blue-200'),
'#DCFCE7': cssVar('--c-green-100'),
'#FEF3C7': cssVar('--c-amber-100'),
'#FFEDD5': cssVar('--c-orange-100')
};
for (const c of used) {
assert.ok(palette[c] !== undefined, `预设里出现了不在登记色板内的色:${c}`);
assert.equal(c, palette[c], `预设色 ${c} 与 WebUI 调色板不一致(CSS 里是 ${palette[c]})`);
}
// 反向:登记色板里的每个色都要真被用上(登记了不用 = 名单在过期)
for (const c of Object.keys(palette)) {
assert.ok(used.has(c), `色板里 ${c} 没有被任何预设使用(色板过期了)`);
}
// 透明必须用关键字而不是 8 位色值(8 位色值在本仓是"手写玻璃"的证据,另有一条判据禁)
for (const id of W.PRESET_IDS) {
for (const l of W.layersFor(id, false)) {
for (const c of l.colors) {
assert.ok(!/^#[0-9A-Fa-f]{8}$/.test(c), `预设里的透明要用 Color.Transparent 语义(${c} 是 8 位色值)`);
}
}
}
// 层数/层序有据可依:每个预设的层数与 CSS 里的渐变段数对应
for (const id of W.PRESET_IDS) {
const cssBlock = webCss.slice(webCss.indexOf(`.bg-preset-${id}`));
const cssBody = cssBlock.slice(0, cssBlock.indexOf('}'));
const cssSegments = (cssBody.match(/(radial|linear)-gradient/g) || []).length;
const mine = W.layersFor(id, false).filter(l => l.kind !== 'grid').length + (W.layersFor(id, false).some(l => l.kind === 'grid') ? 1 : 0);
assert.ok(cssSegments >= 1, `CSS 里 ${id} 要有渐变段`);
assert.equal(mine >= cssSegments, true, `${id}:CSS 有 ${cssSegments} 段,鸿蒙只画了 ${mine} 层`);
}
});
test('画什么:none / preset / image 三档,图没取回来不许画空白', () => {
// 认不出的 preset → 默认档(与 WebUI 的 normalize 同一规则),不是"没有背景"
assert.equal(W.normalizePreset('不认识'), 'aurora');
assert.equal(W.normalizePreset(''), 'aurora');
assert.equal(W.layersFor('不认识', false).length, W.layersFor('aurora', false).length, '认不出要走默认档的画法');
const none = W.resolveBackground('none', 'aurora', 0.2, false, false);
assert.equal(none.kind, 'none');
assert.deepEqual(none.layers, [], 'none 档不该画任何层');
const preset = W.resolveBackground('preset', 'mint', 0.2, false, false);
assert.equal(preset.kind, 'preset');
assert.ok(preset.layers.length >= 1, 'preset 档要真的画出层来(这就是那个缺口的判据)');
/*
* ⚠️ 这里原来断言的是"preset 档不压暗"。pi 2026-09-14 读 WebUI 源码后指出
* **两边不一致**:WebUI 的 `applyBackground()` 无条件写 `--bg-dim`(默认 24),
* `.app-backdrop::after` 是 `rgb(var(--bg-scrim) / var(--bg-dim))` —— 遮罩不区分档位;
* 它的注释写着目的「背景越花,正文越需要一层遮罩才读得动」(可读性,不是装饰)。
* 而鸿蒙当时只在 image 档压,且我们已经让出页面底 → 正文直接压在原色渐变上,比 WebUI 更艳更亮。
* 现在两档都压(同一个浓度),判据按"两边一致"钉住。
*/
assert.equal(preset.scrim, 0.2, 'preset 档**也要**压暗,浓度与 image 档一致(WebUI 的 --bg-dim 不区分档位)');
const img = W.resolveBackground('image', 'aurora', 0.24, true, false);
assert.equal(img.kind, 'image');
assert.equal(img.scrim, 0.24, '压暗浓度要传给画的那一层');
const imgMissing = W.resolveBackground('image', 'aurora', 0.24, false, false);
assert.equal(imgMissing.kind, 'none', 'image 档但图没取回来 → 什么都不画(画一块空白会被当成"壁纸坏了")');
});
test('★ 页面真的把背景画出来了(这一条是补漏:P4 第一版只取回了图,没有任何东西去画)', () => {
/*
* P4 第一版的实际状态:`AppearanceStore` 取回了 `PixelMap`、算好了快照,
* 但**没有任何组件去画它** —— 也就是说壁纸只有数据没有画面。
* 当时的提交信息没写错("取回 PixelMap"),但文档里把它列成"未验渲染",
* 听着像已经画出来了 —— 那是我说得比证据强。这条判据盯的就是这一层不许再缺。
*/
const main = read('pages/MainPage.ets');
assert.match(main, /WallpaperLayer\(\)/, '主界面要真的铺一层背景');
assert.match(main, /resolveBackground\(/, '画什么由纯逻辑决定(不是页面里现编)');
assert.match(main, /radialGradient\(\{/, '预设档用系统径向渐变');
assert.match(main, /linearGradient\(\{/, '预设档用系统线性渐变');
assert.match(main, /Canvas\(this\.gridCtx\)/, '网格档用系统 Canvas 画线(没有对应的系统渐变原语)');
assert.match(main, /Image\(this\.wallpaperImage\)/, '图片档要真的把图渲染出来');
assert.match(main, /\.objectFit\(ImageFit\.Cover\)/, '图片要铺满(不是拉伸变形或留白)');
// 压暗用系统遮罩色 + 服务端浓度
/*
* 遮盖层:用**页面底色系**的 `Theme.wallpaperScrim`(不是模态遮罩 mask)——
* 浅色主题下 mask 是深色(#99182431),会把预设压暗,而 WebUI 是把预设洗淡(--bg-scrim 是白)。
* 这一条与下面那条"遮盖色方向"是同一件事的两个面:这里钉"层在不在、浓度接没接上"。
*/
assert.match(main, /\.backgroundColor\(Theme\.wallpaperScrim\)[\s\S]{0,80}?\.opacity\(this\.bgPlan\.scrim\)/, '遮盖层要用页面底色系令牌与算出来的浓度');
/*
* 背景在主界面这一层:内容之前不该再有不透明的底色把背景盖死。
* ⚠️ P5 起根 `Stack` 带了构造参数(`{ alignContent: Alignment.Bottom }`,浮动条贴底用),
* 所以这里不能写死 `Stack() {` —— 判据钉的是"壁纸是**第一个**子节点",不是 Stack 的写法。
*/
const rootFrom = main.indexOf('this.WallpaperLayer()');
assert.ok(rootFrom > 0, '根里要渲染壁纸层');
const stackOpen = [...main.matchAll(/Stack\([^)]*\)\s*\{/g)].map(m => m.index).filter(i => i < rootFrom).pop();
assert.ok(stackOpen !== undefined, '壁纸层要挂在某个 Stack 里(浮在最底)');
const between = main.slice(stackOpen, rootFrom);
assert.ok(!/[A-Za-z]+\(/.test(between.replace(/Stack\([^)]*\)/, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')),
`壁纸层必须是 Stack 的**第一个**子节点(中间不该有别的组件):${between.replace(/\s+/g, ' ').slice(0, 120)}`);
});
test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统材质(互斥形式,pi 修正后的口径)', () => {
/*
* pi 撤回了他原来那句"模糊只由壁纸层负责":那是 WebUI 的架构结论
* (它的壁纸图层自带 `filter: blur()`,浮在它上面的面再 backdrop-filter 就是把
* 同一张糊过的底糊第二遍),不是通用规则。正确的形式是两条:
* ① 同一张底只许被模糊**一次**;
* ② 模糊该出现在"背后是**可变内容**"的层(导航条背后是滚动内容,壁纸层背后什么都没有)。
* 于是判据写成互斥/分工,而不是"归谁"。
*/
const main = read('pages/MainPage.ets');
/*
* 取"壁纸层 builder 的正文"要**按行**截:用 `indexOf('build() {')` 两头夹,
* 要么撞上文件里更早的那个 build()(切片成空串),要么一路跨到后面的
* 底栏 builder(P5 起叫 `NavBar`,那里**正当地**有 `.backgroundBlurStyle`)——于是判据会误报。
* 这是我自己的切片毛病,和 pi 指出 §三 那条是同一类。
*/
const mainLines = main.split('\n');
const start = mainLines.findIndex(l => l.includes('WallpaperLayer() {'));
assert.ok(start > 0, '要能找到壁纸层的 builder');
let stop = mainLines.length;
for (let i = start + 1; i < mainLines.length; i++) {
if (/^ (@Builder|build\()/.test(mainLines[i])) { stop = i; break; }
}
const wallpaperBuilder = mainLines.slice(start, stop).join('\n');
assert.ok(!/NavBar/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去');
assert.ok(wallpaperBuilder.length > 200, '要能取到壁纸层的 builder 正文');
/*
* ── 这两条 2026-09-15 修正过,理由值得留在这里 ──
*
* 原来是「壁纸层不许出现**任何**模糊调用」(`!/blur\(/i`)。那条**太宽**:
* 它把两种**不同的物理量**当成了一件事,而 WebUI 侧的源码证明它们是分开的
* (`client/electron/src/index.css`):
* · `.app-backdrop`(z-index:-1,背后什么都没有)吃 `filter: blur(var(--bg-blur))`
* ⇒ **图片内容模糊**(服务端 `bg_blur` 那个 px 值,作用对象是壁纸自己);
* · `.app-backdrop` 之上的面吃 `backdrop-filter: blur(8px)`
* ⇒ **背后内容模糊**(面板材质)。
* "同一张底被糊两遍"指的是**后者在一张已经糊过的底上再做一次**,不是"壁纸自己不许糊"。
*
* 所以正确的互斥形式是:**壁纸层只许做图片内容模糊,不许做面板材质模糊**;
* 面板材质只许出现在导航条那种"背后是可变内容"的层。两条分别钉住。
*
* (触发这次修正的是 P4c:加背景选择器时滑杆能拖、`bg_blur` 能存,
* 但壁纸一点没糊 —— 而计划文档 §7.12 恰好写着"若将来鸿蒙开始消费它,
* 那时必须补一条映射判据,并更新本行"。)
*/
assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder),
'★ 壁纸层不许用**面板材质**(`backgroundBlurStyle` 作用在背景=背后的内容上;' +
'壁纸层背后什么都没有,那是"给一张糊过的底再糊一遍"的形状)');
assert.match(wallpaperBuilder, /\.blur\(this\.bgPlan\.blurPx\)/,
'壁纸层要按服务端 `bg_blur` 的**px 原值**做图片内容模糊(与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量)');
const imgBlur = [...wallpaperBuilder.matchAll(/\.blur\(/g)];
assert.equal(imgBlur.length, 1, `壁纸层的图片内容模糊只许一次(实际 ${imgBlur.length} 次)`);
// 导航条的**面板材质**仍然在(背后是会滚动的内容,遮蔽有意义),且档位来自用户偏好
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/,
'导航条的面板材质用**固定系统档**(`Theme.navMaterial`)—— 不跟随 `bg_blur`');
// 材料档次由用户偏好映射而来(不是写死的半径)
const store = read('common/AppearanceStore.ets');
assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式');
/*
* "理由写清了没"要读**原文**(`prose()`;`code()`/`read()` 会剥注释 —— 而理由就在注释里)。
* 剥注释读源码是为了防"注释里的调用被当成真调用",但断言"注释里写了理由"时正好相反。
*/
const wallRaw = prose(join(HARMONY_ETS, 'model/Wallpaper.ts')); // 理由在注释里 → prose(见上)
assert.match(wallRaw, /系统没有对应的渐变原语/, '网格档为什么用 Canvas 要写清(否则以后会被当成绕开系统方案)');
assert.match(wallRaw, /repeating-linear-gradient/, '要指名道姓写出 CSS 用的是哪个原语(后人查得到)');
});
test('判据自检:预设清单少一档必须判红', () => {
// 自检:把 id 列表裁掉一个,确认"与 WebUI 一致"那条会红
const block = webBgStore.slice(webBgStore.indexOf('export const PRESETS'), webBgStore.indexOf('];', webBgStore.indexOf('export const PRESETS')));
const webIds = [...block.matchAll(/id:\s*'([a-z]+)'/g)].map(m => m[1]);
const trimmed = webIds.slice(0, -1);
assert.notDeepEqual(trimmed, W.PRESET_IDS, '自检:裁掉一档后必须与实现不一致(否则这条判据没有分辨力)');
});
test('★ 背景画出来了还不够:每个页面要**让出**页面底,否则壁纸全被盖住', () => {
/*
* 这一条是"渲染"这句话的另一半。只把壁纸铺在最底层、而每个页面自己又刷一层
* **不透明**的系统页面底,壁纸就等于没画(用户看到的仍然是纯色页面)。
* WebUI 侧的原话:「页面底 → 完全透明,让出背景;不改 27 个组件的 class,
* 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。
*
* 所以判据钉的是"没有一处页面底还在用不透明的系统页面底"——
* 漏掉任何一个页面,就是那一页看不到壁纸。
*/
const main = read('pages/MainPage.ets');
const opaqueRoots = [...main.matchAll(/\.backgroundColor\(Theme\.pageBg\)/g)].length;
assert.equal(opaqueRoots, 0,
`还有 ${opaqueRoots} 处页面底用不透明的 Theme.pageBg —— 那几页看不到壁纸`);
const yielded = [...main.matchAll(/\.backgroundColor\(this\.bgActive \? Color\.Transparent : Theme\.pageBg\)/g)].length;
assert.ok(yielded >= 5, `要让出页面底的页面至少 5 个(通信/联系人 + 三个 pane),实际 ${yielded} 处`);
// 每个页面都要**收到**这个开关:漏传 = 该页恒为不透明(等于没有让出)
for (const comp of ['CommPage', 'ContactsTab']) {
assert.match(main, new RegExp(`${comp}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `主界面要把 bgActive 传给 ${comp}`);
}
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
assert.match(main, new RegExp(`${pane}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `通信页要把 bgActive 传给 ${pane}`);
}
// 开关必须由**背景计划**驱动(不是写死的 true/false)
assert.match(main, /this\.bgActive = this\.bgPlan\.kind !== 'none';/, 'bgActive 要由背景计划决定');
// 每个组件都要声明这个 @Prop(漏一个就编译不过,但判据先钉住意图)
for (const comp of ['CommPage', 'ContactsTab', 'InboxTab', 'SentTab', 'PermissionTab']) {
const at = main.indexOf(`struct ${comp} {`);
const head = main.slice(at, at + 400);
assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`);
}
});
// ───────────── 深色色板:pi 指出这是"机制上确定不同",不是"观感未验" ─────────────
/** 从 index.css 的某个段(:root 或 .dark)里读出调色板变量的 RGB */
function cssPalette(selector) {
const at = selector === ':root' ? webCss.indexOf(':root') : webCss.indexOf('.dark {');
assert.ok(at >= 0, `CSS 里要有 ${selector} 段`);
let depth = 0;
let end = at;
for (let i = webCss.indexOf('{', at); i < webCss.length; i++) {
if (webCss[i] === '{') depth++;
else if (webCss[i] === '}') {
depth--;
if (depth === 0) { end = i; break; }
}
}
const body = webCss.slice(webCss.indexOf('{', at), end);
const read = (name) => {
const m = new RegExp(`${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+);`).exec(body);
assert.ok(m, `${selector} 里要有 ${name}`);
return '#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0').toUpperCase()).join('');
};
return {
gray100: read('--c-gray-100'), gray200: read('--c-gray-200'),
blue100: read('--c-blue-100'), blue200: read('--c-blue-200'),
green100: read('--c-green-100'), amber100: read('--c-amber-100'), orange100: read('--c-orange-100')
};
}
test('★ 预设色板**两套**、且深色那套与 CSS 的 `.dark` 段逐个相等(不是"观感未验",是机制)', () => {
/*
* pi 的原话(2026-09-14):WebUI 的 `.bg-preset-*` 写的是 `rgb(var(--c-blue-100))`,
* 而 `--c-*` 在 `.dark` 里整体换了一套(blue-100 → 30 43 67),
* 所以 WebUI 的预设**自动随主题变**。这边若只有浅色那套,
* 深色主题下就是"浅色渐变垫在深色系统表面之下" —— 同一个病。
* **它不需要真机就能判**:机制写在代码里。所以从"未验"改成"判"。
*/
const lightCss = cssPalette(':root');
const darkCss = cssPalette('.dark');
// 两套必须真的不同(否则"两套"是假的:同一套抄了两遍)
for (const k of Object.keys(lightCss)) {
assert.notEqual(lightCss[k], darkCss[k], `CSS 里 ${k} 的深浅两套应当不同(判据前提)`);
}
const light = W.paletteFor(false);
const dark = W.paletteFor(true);
assert.equal(light.dark, false);
assert.equal(dark.dark, true);
for (const k of Object.keys(lightCss)) {
assert.equal(light[k], lightCss[k], `浅色色板 ${k} 与 CSS :root 不一致`);
assert.equal(dark[k], darkCss[k], `深色色板 ${k} 与 CSS .dark 不一致(深色档漏了一个色就是"半深不浅")`);
}
// 每个预设的层在两种主题下都要能画出来,且**颜色确实换了**
for (const id of W.PRESET_IDS) {
const l = W.layersFor(id, false);
const d = W.layersFor(id, true);
assert.equal(l.length, d.length, `${id} 深浅两套的层数要一致`);
assert.notDeepEqual(
l.map(x => x.colors.join(',') + '|' + x.lineColor),
d.map(x => x.colors.join(',') + '|' + x.lineColor),
`${id} 的深色档与浅色档颜色相同 —— 预设没有真的跟着主题走`
);
}
// 网格档的线色也要换
const meshL = W.layersFor('mesh', false).find(x => x.kind === 'grid');
const meshD = W.layersFor('mesh', true).find(x => x.kind === 'grid');
assert.equal(meshL.lineColor, lightCss.gray200);
assert.equal(meshD.lineColor, darkCss.gray200, '网格线色也要跟着主题(否则深色下是一张白网格)');
});
test('★ isDarkMode:system 要看系统当时的深浅,读不到时按浅色(与 WebUI 默认一致)', () => {
assert.equal(A.isDarkMode('dark', 1), true, '用户选了深色就照办(即使系统是浅色)');
assert.equal(A.isDarkMode('light', 0), false, '用户选了浅色就照办');
assert.equal(A.isDarkMode('system', 0), true, 'system + 系统深色 → 深色色板');
assert.equal(A.isDarkMode('system', 1), false, 'system + 系统浅色 → 浅色色板');
assert.equal(A.isDarkMode('system', -1), false, '系统还没定(NOT_SET)→ 按浅色,与 WebUI 的 :root 默认一致');
// 数值锚到 SDK:0=深、1=浅
const sdkConst = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts'));
assert.match(sdkConst, /COLOR_MODE_DARK = 0/, 'SDK 里深色是 0(别记反)');
assert.match(sdkConst, /COLOR_MODE_LIGHT = 1/, 'SDK 里浅色是 1');
// 页面真的按主题选色板(不是写死 false)
const main = read('pages/MainPage.ets');
assert.match(main, /isDarkMode\(snap\.theme, systemMode\)/, '主界面要按当前主题算深浅色');
// 取调用点的正文要按**括号配对**,别用 `[^)]*` —— 里面还套着 `scrimOpacity(...)`,
// 一遇到内层 `)` 就断(这正是"邻接/窗口不是结构"那条规则,我自己先守)
const callAt = main.indexOf('resolveBackground(');
assert.ok(callAt > 0, '要能找到 resolveBackground 调用');
let depth = 0;
let callEnd = callAt;
for (let i = main.indexOf('(', callAt); i < main.length; i++) {
if (main[i] === '(') depth++;
else if (main[i] === ')') { depth--; if (depth === 0) { callEnd = i; break; } }
}
const callText = main.slice(callAt, callEnd + 1);
/*
* ★ 这条原来写的是 `/\bdark\b\s*\)/`(要求 dark 是**最后一个**实参)。
* 那句断的是"参数顺序",不是"深浅色有没有传过去" —— P4c 在 `dark` 后面加了
* `blurPx` 入参,它立刻红了,而传给计划的东西一个没少。**邻接不是语义**
* (与文件里取调用点正文要按括号配对是同一条规则)。改成"dark 确实在实参里"。
*/
assert.ok(/\bdark\b\s*[,)]/.test(callText), `深浅色要传给背景计划:${callText}`);
assert.ok(/\bsnap\.bgBlur\b/.test(callText),
`模糊档也要传给背景计划(否则滑杆能拖、壁纸不糊):${callText}`);
});
test('★ 预设档的遮盖:两档同一个浓度(WebUI 的 --bg-dim 不区分档位)+ 遮盖层用系统遮罩色', () => {
/*
* pi 的原话:「这个遮罩在 WebUI 那里服务的是**可读性**,不是装饰。」
* 所以判据钉三件事:浓度来自同一个入参(不是各写一个数)、preset 也要有、
* 遮盖层用的是**系统遮罩色**(深浅换向由系统负责,不是我们写 alpha)。
*/
const same = W.resolveBackground('preset', 'aurora', 0.3, false, false);
const img = W.resolveBackground('image', 'aurora', 0.3, true, false);
assert.equal(same.scrim, 0.3);
assert.equal(img.scrim, 0.3, '两档要用同一个浓度(同一个服务端字段)');
for (const id of W.PRESET_IDS) {
for (const dark of [false, true]) {
assert.equal(W.resolveBackground('preset', id, 0.15, false, dark).scrim, 0.15,
`${id}(dark=${dark})预设档也要压暗`);
}
}
// WebUI 侧的前提:遮罩真的不区分档位(否则"对齐"就没有依据)
const bgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
assert.match(bgStore, /--bg-dim|setProperty\('--bg-dim'/, 'WebUI 要无条件写 --bg-dim(这是"两档都压"的依据)');
const main = read('pages/MainPage.ets');
// 两档各有一处遮盖层(都用系统遮罩色 + 算出来的浓度)
const scrims = [...main.matchAll(/\.backgroundColor\(Theme\.wallpaperScrim\)\s*\n\s*\.opacity\(this\.bgPlan\.scrim\)/g)];
assert.ok(scrims.length >= 2, `预设档与图片档各要有一层遮盖(实际 ${scrims.length} 处)`);
});
test('★ 多账号缓存键:**按账号**分(鸿蒙是对的,不许为"对齐 WebUI"退回全局键)', () => {
/*
* pi 2026-09-14:WebUI 的缓存键是**全局常量** `agentmail.background`,
* 后果是切到一个服务端没有记录的账号时 `saved=false` 分支会把**上一个账号的外观**
* push 上去(于是新账号"继承"了外观,而且写进了服务端)。
* 鸿蒙这边按账号分键是对的 —— 所以这条判据**防的是将来有人为了"两边一致"把它改回去**。
*/
const store = read('common/AppearanceStore.ets');
/*
* 键由 `prefKey(accountId)` 拼 —— 所以判据要**取出这个函数的正文**再断言
* (`prefKey` 存在不等于它带账号;这正是"判结构要配对/解析"那条),
* 而不是看调用点有没有出现 `accountId` 就当数。
*/
const at = store.indexOf('prefKey(accountId: string)');
assert.ok(at > 0, '要有一个按账号取键的函数');
let depth = 0;
let end = at;
for (let i = store.indexOf('{', at); i < store.length; i++) {
if (store[i] === '{') depth++;
else if (store[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
const body = store.slice(at, end + 1);
assert.match(body, /KEY_PREFIX\s*\+\s*accountId/, `取键函数必须把账号拼进去(现在:${body.replace(/\s+/g, ' ')})`);
assert.match(store, /loadLocal\([^)]*accountId/, '读缓存要按账号');
assert.ok(!/AGENTMAIL_BACKGROUND|'agentmail\.background'/.test(store),
'不要退回 WebUI 那个全局键(那正是"换账号继承上一个人的外观"的成因)');
assert.match(store, /loadLocal\([^)]*accountId/, '读缓存要按账号');
/*
* ⚠️ 这条判据原来还断言"WebUI 是全局键"(作为差异记录的依据)。
* 2026-09-14 WebUI 侧也按账号分键了(dsh 接手 pi 的两个开项),**差异已消除** ——
* 所以现在断言的是"两端都是按账号的键",而且不允许任何一端退回全局键。
*/
const webStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
assert.match(webStore, /export function storageKey\(/, 'WebUI 也要有按账号取键的函数');
assert.match(webStore, /LEGACY_STORAGE_KEY/, '旧全局键只作为迁移源存在');
assert.ok(!/setItem\('agentmail\.background'/.test(webStore), 'WebUI 也不许再往全局键写');
});
test('★ 主题变化时**我们自己算的值**要跟着重算(pi 的规则:系统只跟它自己那部分)', () => {
/*
* pi 的规则:「系统自动跟随的东西(语义色、材质)不会顺带把"我们自己算出来的值"
* 一起更新 —— 凡是我们计算/缓存且随主题变化的值,都必须挂在**同一个主题变化事件**上重算,
* 否则它迟早是那唯一一处不跟随的。」
* 这里"我们自己算的"就是预设色板(`layersFor(id, dark)`):系统 surface/文字/材质会立刻换,
* 色板不重算 → 界面上一部分跟随、一部分不跟随(撕裂)。
*/
const main = read('pages/MainPage.ets');
assert.match(main, /getApplicationContext\(\)\.on\('environment'/, '要订阅系统环境变化');
assert.match(main, /onConfigurationUpdated/, '要处理配置变化回调');
assert.match(main, /config\.colorMode !== this\.lastColorMode/, '只在深浅色真的换了时才重算');
assert.match(main, /off\('environment'/, '页面销毁要退订(否则回调挂在已销毁的页面上)');
// 重算的必须是那份"我们算的值",而不是重新读一遍系统色
const watchAt = main.indexOf("on('environment'");
let depth = 0;
let end = watchAt;
for (let i = main.indexOf('(', watchAt); i < main.length; i++) {
if (main[i] === '(') depth++;
else if (main[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
assert.ok(/applyAppearance\(\)/.test(main.slice(watchAt, end + 400)), '回调里要重算外观(色板属于"我们算的")');
// SDK 锚点:这个 API 与回调形状不能凭记忆写
const sdk = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts'));
assert.match(sdk, /onConfigurationUpdated\(config: Configuration\): void/, 'SDK 里回调是 onConfigurationUpdated(别写错名字)');
});
// ───────── 遮盖色方向:从 SDK 两张表读出真值再判(pi 2026-09-14 的方向性疑问) ─────────
/** 从 SDK 读出 `sys.color.ohos_id_color_*` 的真实值(名字→id 取编译器那张表,id→值取预览器那张) */
function sdkSystemColors() {
const sysRes = code(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js'));
const resTxt = code(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt'));
const name2id = new Map();
for (const m of sysRes.matchAll(/'?(ohos_id_color_[a-z_]+)'?:\s*(\d+)/g)) {
if (!name2id.has(m[1])) name2id.set(m[1], Number(m[2]));
}
const id2val = new Map();
for (const m of resTxt.matchAll(/id:(\d+),\s*'([^']*)'/g)) {
const id = Number(m[1]);
if (!id2val.has(id)) id2val.set(id, m[2]);
}
const out = {};
for (const [name, id] of name2id) if (id2val.has(id)) out[name] = id2val.get(id);
return out;
}
/** '#AARRGGBB' → 亮度(0=黑 1=白);只用于判"深还是浅" */
function luminanceOf(argb) {
const r = parseInt(argb.slice(3, 5), 16) / 255;
const g = parseInt(argb.slice(5, 7), 16) / 255;
const b = parseInt(argb.slice(7, 9), 16) / 255;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮罩),壁纸遮盖必须用**页面底色系**', () => {
/*
* pi 的疑问原话:「系统那三个 mask —— 名字里的 light/regular/thick 是**浓度档**
* (同一个色的三个 alpha),不是深浅主题的两套值。它们的用途是**模态遮罩**(弹层背后压暗),
* 所以两套主题下通常都是深色。如果预设/图片的遮盖层用 mask 色,**浅色主题下会把预设压暗,
* 而 WebUI 是把预设洗淡**:方向相反,而且这是"机制上确定不同",不是观感。」
*
* 他让我先把值读出来再定 —— 这里就是那次读数,做成判据(免得以后凭记忆选令牌)。
*/
let colors;
try {
colors = sdkSystemColors();
} catch (e) {
assert.fail(`读不到 SDK 的系统色表(这条判据无从判起):${e.message}`);
}
const maskRegular = colors['ohos_id_color_mask_regular'];
const maskDark = colors['ohos_id_color_mask_regular_dark'];
const bg = colors['ohos_id_color_background'];
const bgDark = colors['ohos_id_color_background_dark'];
assert.ok(maskRegular && maskDark && bg && bgDark, '系统色表里这几个令牌都要有值');
// 事实一:mask 在浅色主题下也是深色(#99182431)—— 所以它是模态遮罩语义
assert.ok(luminanceOf(maskRegular) < 0.2,
`mask_regular 在浅色主题下应当是深色(实测 ${maskRegular})—— 若是浅色,这条判据的前提要重写`);
assert.ok(luminanceOf(maskDark) < 0.2, `mask 深色主题下也应当是深色(实测 ${maskDark})`);
// 事实二:页面底色系随主题换向(浅色白、深色近黑)—— 这才是"朝底色淡化"
assert.ok(luminanceOf(bg) > 0.8, `ohos_id_color_background 浅色主题下应当是白(实测 ${bg})`);
assert.ok(luminanceOf(bgDark) < 0.2, `ohos_id_color_background_dark 应当近黑(实测 ${bgDark})`);
// 事实三:WebUI 的遮罩方向是"朝底色淡化"(浅色白、深色黑)—— 与页面底色系同向、与 mask 反向
const webCss = code(join(ROOT, 'client/electron/src/index.css'));
const scrimLight = /--bg-scrim:\s*(\d+)\s+(\d+)\s+(\d+);/.exec(webCss);
assert.ok(scrimLight, 'CSS 里要有 --bg-scrim');
assert.deepEqual([scrimLight[1], scrimLight[2], scrimLight[3]], ['255', '255', '255'],
'WebUI 浅色下的遮罩是**白**(把图案洗淡);这是"必须用页面底色系"的依据');
// 结论落到代码:壁纸遮盖用页面底色系令牌,且**不是** mask
const theme = code(join(HARMONY_ETS, 'common/Theme.ets'));
assert.match(theme, /static readonly wallpaperScrim: Resource = \$r\('sys\.color\.ohos_id_color_background'\)/,
'壁纸遮盖色要用页面底色系(ohos_id_color_background)');
assert.match(theme, /static readonly overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/,
'overlay(模态遮罩)保持 mask');
const main = read('pages/MainPage.ets');
const scrims = [...main.matchAll(/\.backgroundColor\(Theme\.(\w+)\)\s*\n\s*\.opacity\(this\.bgPlan\.scrim\)/g)];
assert.equal(scrims.length, 2, '预设档与图片档各一层遮盖');
for (const s of scrims) {
assert.equal(s[1], 'wallpaperScrim',
'壁纸遮盖层的颜色必须用 wallpaperScrim(页面底色系)。用 mask 会在浅色主题下把预设压暗,与 WebUI 反向');
}
// 两个语义不许共用一个令牌(这个仓库撞过四次的那个模式)
assert.notEqual('wallpaperScrim', 'overlay');
// 模态弹层仍然用 mask(那里的语义确实是"压暗背后")
const settings = read('pages/SettingsPage.ets');
assert.match(settings, /Theme\.overlay/, '自绘弹层的遮罩仍然用 mask(那里的语义是压暗背后)');
assert.ok(!/wallpaperScrim/.test(settings), '弹层不该用壁纸遮盖色(两个语义别混)');
});
/**
* ★ 模糊字段的**消费侧必须逐文件登记 + 计数**(pi 2026-09-14 指出同一形状只有一边有判据)。
*
* ── 2026-09-15 状态变了(原来是"只写不读 ⇒ 消费侧必须为 0")──
*
* P4c 补上了消费点(见下面登记表的理由),所以 0 这个值**不再成立**,
* 判据的**形状**保留(逐文件 + 计数 + 写理由),值改准。
* 标题与断言里原来那句"消费侧出现次数必须为 0"如果留着,就会变成**假话**——
* 而假话比没有判据更糟:下一个人会以为"这里登记 0 是真的"。
*
* WebUI 的 `LEGACY_BACKUP_KEY` 早就钉着"只写不读,否则它会变成新的继承源";
* 而鸿蒙侧 `bgBlur`(`Appearance.ts` clamp 存入、`AppearanceStore` 同步)**只有文档**。
* 同一个形状只有一边有判据 ⇒ 这一边补上,不必等一张新的字段清册:
* **登记表本身就是清册**(与 `server/internal/repo/mail_status_readers_test.go` 的
* "文件 + 出现次数"同一个模板,只是这里的登记值是 0)。
*
* 消费侧一旦出现(有人按 px 选档位/设模糊半径)本条就红 —— 那是**必须停下来**的时刻:
* 那时要补的是"px ↔ 材质档位"的**映射判据**(见 CRITERIA.md §10 与计划文档 §7.12),
* 而不是把次数从 0 改成 1 了事。
*/
test('★ 模糊字段的消费侧:逐文件登记 + 计数(P4c 起不再是「只写不读」,登记值已随之改准)', () => {
const files = [];
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) { walk(p); continue; }
if (!/\.(ets|ts)$/.test(e.name)) continue;
files.push(p);
}
};
walk(HARMONY_ETS);
/*
* 豁免**按文件登记 + 写理由**(不是"凡是这几个目录都放行"):这三处是**搬运/传输**,
* 不是消费 —— 判据要挡的是"有人拿这个值去决定画什么"。
* 与 `server/internal/repo/mail_status_readers_test.go` 的豁免同一个形状。
*/
/*
* 豁免必须**按文件 + 次数**(pi 2026-09-14 交叉提醒,与 migrate.go 那处同一条):
* 只按文件放行 ⇒ "在已允许的文件里顺手再读一下 bgBlur 做别的事"会被静默吞掉
* (例如有人在 DTO 文件里拿它算点别的)。次数写死在这里,多一次即红。
*/
/*
* ── 2026-09-15:登记值从"没有这一项(即 0)"改成 1 处 —— 按本条判据自己的要求做的 ──
*
* 这条判据的注释写着「消费侧一旦出现就红 —— 那是**必须停下来**的时刻:
* 那时要补的是"px ↔ 材质档位"的**映射判据**,而不是把次数从 0 改成 1 了事」。
* P4c 加背景选择器时确实踩到了:滑杆能拖、`bg_blur` 能存,但壁纸一点没糊。
*
* 停下来核完之后,**映射判据早就在了**(`blurStyleFor` 的分档边界/单调性/NaN 那条),
* 缺的是**调用点**。所以这次补的是调用点,并把下面这条登记改准:
* · `pages/MainPage.ets` 2 处:壁纸层 `.blur(this.bgPlan.blurPx)`(**图片内容模糊**,
* 与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量)+ 导航条
* `backgroundBlurStyle(blurStyleFor(this.bgPlan.blurPx))`(**面板材质**)。
* 两处都只是**把用户那个数用出去**,不在这里做分档判断(分档在 Appearance.ts)。
* · `common/BackgroundPicker.ets` 2 处:选择器的滑杆(`@Link bgBlur` 的绑定与 onChange)
* —— 那是**输入**,不是消费。
* ⇒ 这是"消费点出现时按判据要求补判据"的正常流程走完一遍,不是把 0 改成 1 了事。
*/
const plumbing = new Map([
['model/Appearance.ts', { max: 12, why: '域模型:声明 + clamp + 合并 + 一个映射函数(`blurStyleFor`,现为孤岛:判据在跑、页面无人调,见其文档)—— 搬运与映射,都不是消费' }],
['pages/MainPage.ets', { max: 3, why: 'P4c 补上的两个**消费点**(映射判据早已存在,见本段说明):壁纸层图片内容模糊 + 导航条面板材质;第 3 处是同文件里说明这件事的注释' }],
['common/BackgroundPicker.ets', { max: 6, why: '选择器的滑杆:**输入**(@Link 声明 + 上报 + 显示 + Slider 值 + onChange + 一处注释),不是"拿这个值决定画什么"' }],
['model/Wallpaper.ts', { max: 1, why: '计划只**搬运**这个值(`blurPx`)+ 一处注释;分档判断不在这里(在 Appearance.ts 的 blurStyleFor)' }],
['pages/SettingsPage.ets', { max: 4, why: '页面持有该值的 @State(声明 + 推服务端时写入 + 从快照复制回 + 用 `$bgBlur` 传给选择器)—— 全是搬运/传参' }],
['common/AppearanceStore.ets', { max: 4, why: '状态同步:与快照互转(搬运)' }],
['api/AppearanceApi.ets', { max: 1, why: '线上 DTO 声明 bg_blur(传输格式,不是消费)' }]
]);
const plumbingSeen = new Map();
const consumers = [];
for (const f of files) {
const src = prose(f);
const rel = f.slice(HARMONY_ETS.length + 1);
src.split('\n').forEach((line, i) => {
if (!/\bbgBlur\b|\bbg_blur\b/.test(line)) return;
const where = rel + ':' + (i + 1);
const rule = plumbing.get(rel);
if (rule) {
const n = (plumbingSeen.get(rel) || 0) + 1;
plumbingSeen.set(rel, n);
if (n <= rule.max) return;
consumers.push(`${where} **超出豁免上限**(${rel} 上限 ${rule.max} 处,理由:${rule.why})—— ${line.trim()}`);
return;
}
consumers.push(`${where} ${line.trim()}`);
});
}
assert.deepEqual(consumers, [],
`模糊字段出现了**未登记**的读取点。要么它是消费(那就要先补"px ↔ 材质档位"的映射判据,` +
`映射表见 model/Appearance.ts 的 blurStyleFor;分档边界 0/8/20 已有行为判据),` +
`要么它是搬运/输入(那就按文件登记次数并写清理由)—— 但**不许不声不响地多一处**:\n ${consumers.join('\n ')}`);
});
/**
* ★ `blurStyleFor`:**px → 系统材质档** 的映射是**行为**,不是注释(可以真跑)。
*
* 这条的来历值得记:我先前把"鸿蒙没有消费点"登记成"**不存在映射表 ⇒ 钉映射判据是假判据**",
* 那个结论**是错的** —— 正是 pi 要求把豁免改成"文件 + 次数"之后,逐处核对出现次数
* 才把 `model/Appearance.ts` 里的 `blurStyleFor` 翻出来:**映射表早就写了**
* (文件里的注释还写着「判据可以直接跑它」),只是**没有任何调用点**。
*
* 所以正确的登记是:**映射表存在且可判(本判据);缺的是调用点**。
* "有没有人用它"是另一件事,由上面那条消费侧计数判据管(登记值为 0)。
*/
test('★ blurStyleFor:分档边界、单调性、NaN 都是行为(0/8/20 是契约的一部分)', async () => {
const { pathToFileURL } = await import('node:url');
const A = await import(pathToFileURL(join(HARMONY_ETS, 'model', 'Appearance.ts')).href);
assert.equal(typeof A.blurStyleFor, 'function', 'blurStyleFor 必须存在且可跑');
const cases = [[0, 'NONE'], [-5, 'NONE'], [1, 'COMPONENT_THIN'], [8, 'COMPONENT_THIN'],
[9, 'COMPONENT_REGULAR'], [20, 'COMPONENT_REGULAR'], [21, 'COMPONENT_THICK'],
[40, 'COMPONENT_THICK'], [999, 'COMPONENT_THICK']];
for (const [px, want] of cases) {
assert.equal(A.blurStyleFor(px), want, `bg_blur=${px} 应当映射到 ${want}`);
}
// 单调性:px 变大,档次不许倒退(分档写反是最容易犯的错)
const order = ['NONE', 'COMPONENT_THIN', 'COMPONENT_REGULAR', 'COMPONENT_THICK'];
let last = -1;
for (let px = 0; px <= 40; px++) {
const i = order.indexOf(A.blurStyleFor(px));
assert.ok(i >= last, `bg_blur=${px} 的档次倒退了(${order[last]} → ${order[i]})`);
last = i;
}
// NaN 不许落到最厚那一档:它只能来自坏数据,不该被解释成"最模糊"
assert.notEqual(A.blurStyleFor(Number.NaN), 'COMPONENT_THICK',
'NaN 不许落到最厚那一档(比较全 false 时掉到最后一档 —— 那是最坏的方向)');
});