Files
MailUI4Agents/client/electron/test/harmony-appearance.test.mjs
JianFeeeee 2e53871a22 fix(harmony): 底栏真正浮起 + 内容穿过 + 加回文字标签
用户(2026-09-17):「底栏不是玻璃质感,滑动内容无法穿过底栏」,
随后「还是在导航栏加上文字吧,没有文字还是不好看」。

── ① 底栏没浮起:`width('100%') + margin` 在 ArkUI 里不缩宽 ──
NavBar 原来是 `Row().width('100%').margin({left:16,right:16,bottom:12})`。
ArkUI 的 margin 加在宽度**外面**:100% 再配左右 margin 不会缩到
「100% − margin」,而是整个顶出父容器、两侧被裁。
实测底栏左缘 x=0、右缘贴满 1256(应各留 16vp=56px)⇒ 看起来是贴边通栏,
不是浮在壁纸上的胶囊。这与收件箱头卡片修过的是同一个坑。
改法:外层 Column 带 padding(与 WebUI `.narrow-nav` 的 margin 同一几何)。
实测修复后左缘 x=56 = **16.1vp**(期望 16)。

── ② 内容穿不过去:让位加错了层 ──
原来把 `NAV_CONTENT_RESERVE` 加在**窗格的 `padding({bottom})`** 上。
那不只"让最后一行滚得出来",还**缩短了窗格本身** ⇒ 内容永远到不了条底下
⇒ 玻璃条背后只剩一张已经被壁纸层模糊过的壁纸 ⇒ 系统材质无东西可糊
⇒ 看起来就是一块普通浅色面板。**这就是"不是玻璃质感"的真正原因**。
改法:窗格满高(内容滑得到条底下,真正穿过),让位改到各滚动容器的
**内容末尾** `contentEndOffset(this.navReserve)`(5 个 List + 日历日程),
与 WebUI 同构(`.narrow-shell` 是 flex-col,列表满高、`.narrow-nav` 叠在上面)。
实测:条内高频细节 0.21 vs 条外正文 6.04 ⇒ **28.8×** 衰减,模糊确实生效。
连带修:FAB / 回复球原来靠窗格 padding 躲开条,现在自己按 navReserve 抬
(否则压在条上),实测 FAB 底距条顶 24.4vp。

── ③ 加回文字:并纠正一处**事实错误** ──
代码注释里写着「WebUI 的底部导航是**纯图标**」,并据此去掉了文字。
那句是**错的**:`NarrowNav.tsx:83-86` 每个导航项是
`flex flex-col items-center justify-center gap-0.5`,里面先 `<Icon />`
再 `<span className="text-3xs leading-none">{short}</span>`
—— WebUI **一直有文字**(通信 / 日历 / 联系人 / 我的)。
那条判据把一个假事实固化成了规则,还反过来挡住了正确做法。
现在按真实契约钉:图标(24vp,与 WebUI 24×24 同几何)+ 文字,
两者过**同一个**选中三元式(只换颜色,不加背景/指示条)。

判据(全部含变异自检):
· ⑤ 重写:两处选中三元式(图标+文字各一)、必须有 Text(item.label)、
  图标 24vp;变异自检覆盖"缺文字 / 文字不换色 / 图标不上色"。
· ④ 改:留白走外层 padding(并反向禁止 margin 做留白);
  让位改为 ≥5 处 `contentEndOffset`,并禁止退回窗格 padding 让位。
· 连带更新 ②/⑤ 与 appearance/logic/widescreen 的组件签名断言
  (组件多接一个 navReserve)。
· 删掉一段会假红的"从内容层 `}` 往后扫"的窗口式断言:它靠数花括号定界,
  而注释里就有大量 `.padding({` 字面量,切片能长到 1700+ 字符,
  一路扫进 `onAreaChange`(那里合法地出现 NAV_CONTENT_RESERVE)——
  正是本仓库反复记的窗口式判据,故改为纯负向断言。

测试:212 passed / 0 failed;`devecocli build` 通过;
模拟器截图与像素测量逐项核对。
2026-09-17 18:44:36 +08:00

918 lines
58 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'), /同步/);
});
// ───────────────────── 系统方案:数字 → 系统材质 / 色彩模式 ─────────────────────
/*
* ── 这里原有的一条判据已删除(2026-09-15)──
*
* 它判的是 `blurStyleFor`:`bg_blur` px → `BlurStyle` 档位名的映射(分档边界 0/8/20、
* 单调性、NaN、以及"产出的名字必须是 SDK 成员")。**函数本身已按 pi 的裁定删除** ——
* 它没有任何调用点,唯一的计划消费者(导航条档位跟随 `bg_blur`)已被否决,
* 所以不是"暂时没消费者"而是**不该有**。理由碑文在 `Appearance.ts` 的 `blurStyleFor` 位置。
*
* ★ 删掉它**没有留下覆盖空洞**,这一点是我删之前专门核过的:
* "档位名必须是 SDK 的 `BlurStyle` 成员、且不是 NONE"这条性质,现在由
* `harmony-nav.test.mjs` 的「导航条材质是**固定系统档**」语义 C 直接判 ——
* 它读 `Theme.ets` 里 `navMaterial` 的**声明**,把档位名对着 SDK 枚举成员核。
* 而 `Theme.navMaterial` 正是**唯一剩下的**档位名产生者(原来那个函数没了)。
* ⇒ 覆盖面从"核一个没人调的函数的产出"变成"核真正在用的那个令牌",
* **判的还是同一件事,但对象终于是活的**。
*
* 教训留在这里(比删掉的那几条断言值钱):**"有测试"不等于"有人用"**。
* 这条判据全绿、逻辑正确,守的却是一个没有消费者的函数 ——
* 与 `Theme.navMaterial` 那个"只被不可达 `??` 分支引用"的死令牌是同一个形状。
*/
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']) {
// 组件现在多接一个 navReserve(底部条高度 → 列表末尾让位),所以判"收到了 bgActive"而不是整行字面量
const at = main.indexOf(`${comp}({`);
assert.ok(at >= 0, `主界面要渲染 ${comp}`);
const call = main.slice(at, main.indexOf('})', at) + 2);
assert.match(call, /bgActive:\s*this\.bgActive/, `主界面要把 bgActive 传给 ${comp}`);
}
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
// 参数已是多行形式(InboxTab/SentTab 还接了 onOpenMail),所以判"这个组件收到了 bgActive"
// 而不是要求整行字面量 —— 只判字面量的话,加点别的参数就会假红。
const at = main.indexOf(`${pane}({`);
assert.ok(at >= 0, `通信页要渲染 ${pane}`);
const call = main.slice(at, main.indexOf('})', at) + 2);
assert.match(call, /bgActive:\s*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` 不许回来 + 理由必须留在原处(旧的三条行为性质随主语一起作废)', async () => {
const { pathToFileURL } = await import('node:url');
const A = await import(pathToFileURL(join(HARMONY_ETS, 'model', 'Appearance.ts')).href);
// ★ 原先这里断言"必须存在且可跑"。事实相反:它是**有意删除**的(碑文 Wallpaper.ts:246)。
// ⇒ 断言改成**反回归**:不许回来。("标签必须等于断言范围":这条测试的标题也一并改了。)
assert.equal(typeof A.blurStyleFor, 'undefined',
'`blurStyleFor` 已被有意删除(2026-09-15):它没有、也不该有消费者 —— 不许加回来');
/*
* 这一段(cases + 单调性 + NaN 方向)**整体作废,而且不是"改一改还能用"**:
* 它们的**主语**是一个已被删除的映射函数。常数没有"单调性",也没有"越界归到最近档",
* 更没有"NaN 掉到哪一档" —— 三条性质**随主语一起消失**了。
*
* 所以这里**不重新发明**它们,只留两句钉住"去哪了"与"为什么":
* · 那句碑文还在(理由必须留在原处,否则下一个人只会看到"这里什么都没有");
* · 常量本身仍被钉着(固定档 = COMPONENT_THICK,见本文件前半段那条现居地判据)。
* 判据**变少是对的**:性质没了就该少,硬造一条等价的只会是假判据。
*/
assert.match(prose(WALL_TS), /blurStyleFor[\s\S]{0,200}?已随/,
'碑文仍在原处:删除理由必须写在它被删掉的地方(否则下一个人看不出这里曾经有过什么)');
});