Files
MailUI4Agents/client/electron/test/harmony-appearance.test.mjs
JianFeeeee f0dc342c1f 鸿蒙|图标 fill 型分族 + 页签条阴影根因修复 + 生成器入库
用户逐条报的观感问题(都在"视觉观感"那一栏,不是功能缺失):

① 侧边栏图标"莫名其妙的加粗"
   根因:43 个图标里**只有品牌标 `brandMark` 是 fill 型**
   (`fill="currentColor"` + 两个实心 `<circle r=".9">`),
   它自己的注释就写着「fill 型,与上面的描边图标集**不同族**,所以不包 Svg」。
   而 `AmIcon` 一律 `fill(Transparent) + stroke()` ⇒ 实心块只剩轮廓、
   实心眼变成小圆环、圆头端点在小尺寸下糊成一坨。
   修法:生成器自动判定两族(`is_fill_family`),产出 `FILL_ICONS` 名单,
   fill 族整块填色、不加描边。

② 详情页权限面板"左边被截断"
   `PermissionPanel` 挂在详情页外层,根部只有 `padding({top:10})` ——
   而正文 `Markdown` 与附件块**各自自带** `left/right:16`。补上同档 16。

③ 顶栏"莫名其妙的底部阴影"
   ★ 这条查错了两轮,记下来:
   · 先以为是窗格投影从半透明玻璃透上来 → 补底边、去材质 —— 都没用;
   · 几何取证才定位真因:`InboxTab` 根容器与页签条是 `Column` 里的**兄弟**
     且**绘制在后**(页签条 y 142→271,InboxTab y 271→2202),
     它的 `PaneModifier` 投影向上扩散 ~30px,正好压住页签条
     (观测到的渐变区 y 240→268,完全吻合)。
   · 双向验证:关掉所有窗格投影 → 全平(证明确实是投影);
     只把 `InboxTab` 换 `plain` → 落差 21→8(证明是**这一层**)。
   修法:`PaneModifier` 加 `plain()`(只要背景语义、不投投影)。
   WebUI 的 `box-shadow` 只给 `.app-shell > *`(三个**并列**面板),
   面板内部不投 —— 这个结构差异就是原因。

   ★ 同时保留页签条的**悬浮玻璃**(用户 09-20 点名要的):
   我中途一度按"跟 webui 同步"把它改成不透明实体面,
   那是**读错了**——用户指的是页面结构对齐,而玻璃是他自己定过的;
   两者不冲突(玻璃是观感选择,阴影是 bug)。已恢复并留注释。

④ 生成器入库(`client/harmony/tools/gen-icons.py`)
   它原先在仓库外(`/root/gotmp`),于是**落后生成物三次提交**没人发现:
   我手改 `Icons.ets` 修 fill 图标后重跑它,修改被**冲掉**(退回旧实现)。
   现在:路径按 `__file__` 解析(任何检出目录都能跑)、
   `is_fill_family` 自动分族、输出段与正确实现逐字一致(diff 为空)。
   `Icons.ets` 由它生成,不再手改。

判据:
· `harmony-appearance` 29 条(+1):窗格投影只给并列窗格,
  面板内部的子 tab 不得再投。按 **struct 归属**判(第一版计数有洞,
  变异①抓不到 —— 改回 `of` 时同文件别处的 `plain` 仍让它绿)。
  两个变异都能红:① InboxTab 改回 of;② 让 plain 也投投影。
  另修一条既有判据的假红:它数 `PaneModifier.of` 出现次数,
  加 `plain` 后误报("让出页面底"的两种合法写法都要算)。
· 套件:harmony-nav 22/22、harmony-appearance 28/28、
  harmony-logic 34/34、harmony-contacts 5/5、animation-audit 15/15。
**功能对齐状态**(回答用户"功能对齐没有"):
  对着 `docs/DEBTS.json` 逐条核过 —— 鸿蒙侧**没有缺页面、没有缺功能**。
  授权栏历史(`harmony-permission-history`)✅ 已做、
  详情页转发/改名建议/预算(`harmony-maildetail-missing-three`)✅ 三块全做完。
  仅剩两条,都不在鸿蒙:`harmony-p4c-boundary-decls` 卡在"需真人操作系统选图器"、
  `permission-expires-at-unused` 缺的是 **WebUI 那一半**。
2026-09-24 13:13:46 +08:00

1659 lines
98 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');
assert.equal([...main.matchAll(/\.backgroundColor\(Theme\.pageBg\)/g)].length, 0,
'还有页面底用不透明的 Theme.pageBg —— 那几页看不到壁纸');
/*
* ★★ 2026-09-19 改成判**不变式**而不是判**字面量**。
*
* 原来断言的是"内联三元 `.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)`
* 至少出现 5 次" —— 那是在数**一种写法**,不是数"页面有没有让出底"。
* 而本次把这条三连收敛进了 `PaneModifier.of(this.bgActive)`
* (用户:「应该定义一个基础玻璃容器给各个组件引用」)⇒ 写法变了、行为没变,
* 判据却红了。这是典型的"判据锚在实现上、不是锚在它声称的东西上"。
*
* 现在判两件事,都是**不变式**:
* ① 让出页面底只有一条路径(`PaneModifier`),且它真的会因 `bgActive` 变透明;
* ② 每个窗格都**收到**这个开关(漏传 = 该页恒不透明,等于没让出)。
*/
const paneMod = read('common/Surface.ets'); /* 相对 HARMONY_ETS(= .../ets),不是相对 pages/ */
assert.match(paneMod, /class PaneModifier implements AttributeModifier/,
'页面底要让位给壁纸 ⇒ 必须有一个统一的窗格属性集(不是每页各写三元)');
assert.match(paneMod, /backgroundColor\(this\.active \? Color\.Transparent : Theme\.pageBg\)/,
'PaneModifier 必须在 active 时返回透明(否则"让出"这件事根本没发生)');
/*
* ★★ 2026-09-24 改:判"让出页面底"的**两种入口**,而不是数一种写法。
*
* 原断言:`\.attributeModifier\(PaneModifier\.of\(this\.bgActive\)\)` 至少出现 5 次。
*
* 本次加了 `PaneModifier.plain(...)`(同背景语义、**不投投影**)——
* 它同样“让出页面底”(`backgroundColor` 那段是共用的),
* 而旧断言只数 `of(` ⇒ 当场假红(实测:`实际 1 处`、`fail 1`)。
*
* 这正是本文件自己反复记过的形状:**判据锚在实现的一种写法上,
* 而不是锚在它声称的那件事上**。"让出页面底"的两种合法写法都要算。
* (`plain` 的出现理由:同一窗格被多层嵌套时,只有**最外层的并列窗格**
* 该投投影,内层用 `plain` —— 见 `Surface.ets` 的 `shadowed` 注释。)
*/
const yielded = [...main.matchAll(/\.attributeModifier\(PaneModifier\.(?:of|plain)\(this\.bgActive\)\)/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(漏一个就编译不过,但判据先钉住意图)。
*
* ★★ 2026-09-23 修:`PermissionTab` **已从 `MainPage.ets` 抽成独立组件**
* (`pages/PermissionTab.ets`)。原来这份名单一律在 `main` 里找
* `struct X {` —— 抽出后 `PermissionTab` 不在 `main` 里了,
* `indexOf` 返回 -1 ⇒ `main.slice(-1, 399)` 得到**空串** ⇒ 断言红。
*
* ★ 值得记的是这个红**是好红**:它立刻指出了"有个组件搬家了"。
* 而 `harmony-logic` 那条同形状的判据当时**静默变成了空断言**
* (它用的是 `slice(indexOf(...))`,-1 会变成"从末尾取一个字符")
* —— 两者差别只在于 `slice` 的参数形状。**同一类搬家,一个红一个哑。**
* ⇒ 这次把"组件在哪个文件"显式写进名单,而不是靠"它恰好在 main 里"。
*/
const PANE_SOURCES = {
CommPage: main, ContactsTab: main, InboxTab: main, SentTab: main,
/*
* ★ 已抽出,读它们自己的文件(搬家的地方只在这里写一次)。
*
* 2026-09-23:按用户「把组件按页面封装以便与 WebUI 一一对应」的要求,
* `PermissionTab` 与 `ContactsTab` 都从 `MainPage.ets` 抽成了独立文件。
* 这份名单必须跟着改 —— 否则判据报"找不到",而组件其实好好的。
*/
PermissionTab: read('pages/PermissionTab.ets'),
ContactsTab: read('pages/ContactsTab.ets')
};
for (const comp of Object.keys(PANE_SOURCES)) {
const src = PANE_SOURCES[comp];
const at = src.indexOf(`struct ${comp} {`);
assert.ok(at >= 0, `${comp} 要能在它该在的文件里找到(组件搬家要一起改本判据)`);
const head = src.slice(at, at + 400);
assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`);
}
});
/*
* ═══════════════════════════════════════════════════════════════════
* 窗格投影:**只有"并列窗格"该投**,面板内部不投
* ═══════════════════════════════════════════════════════════════════
*
* 用户 2026-09-24:「顶栏莫名其妙的底部阴影」+「跟 webui 同步」。
*
* ── 症状 ──
* 页签条(收件箱/发件箱/授权)内部从上到下 254→234 **单调递减**一条暗带,
* 像素实测贯通整条(x 250→1150 亮度恒 224-227)。
*
* ── 根因(几何取证,不是猜)──
* `InboxTab` 的根容器与页签条是 `Column` 里的**兄弟**,且**绘制在后**
* (页签条 y 142→271,InboxTab y 271→2202)。它的 `PaneModifier` 投影向四周扩散
* ⇒ 向上扩散的那 ~30px 正好压住页签条(观测到的渐变区 y 240→268,吻合)。
*
* 实测验证(只把 `InboxTab` 改 `plain`,其余不动):
* 修前落差 21(254→233) → 修后落差 8(255→247)✅
* 而窗格自己那圈投影(x=230 处 176-180)**仍在** —— 没把该有的层次感一起删掉。
*
* ── WebUI 为什么没这问题 ──
* `box-shadow` 只挂在 `.app-shell > *`(`index.css:980`)——
* 那是**三个并列面板**(Sidebar / list / main),**没有嵌套**。
* 面板**内部**(列表、卡片)不再投投影。
*
* ── 为什么不只盯 `InboxTab` 一个名字 ──
* 同一个形状在仓里有**多处**(`SentTab`/`PermissionTab`/`ContactsTab` 的 navBar 内容)。
* 只钉一个名字的话,下次改另一个 tab 就会把同一条阴影带回来。
* 所以判的是**结构不变式**:
* · `Navigation` 壳(并列窗格)→ `PaneModifier.of`(带投影);
* · 壳**内部**那几层 → `PaneModifier.plain`(不投)。
*/
test('★ 窗格投影只给并列窗格;面板内部的子 tab 不得再投一次(顶栏阴影的根因)', () => {
const surface = read('common/Surface.ets');
/* 前提:两类工厂都得存在,且 plain 真的不投投影 */
assert.match(surface, /static plain\(active: boolean\): PaneModifier/,
'要有一个"只要背景语义、不投投影"的入口(面板内部用它)');
assert.match(surface, /private shadowed: boolean = true;/,
'PaneModifier 要有一个显式开关区分"投/不投",而不是靠调用点自己记');
assert.match(surface, /if \(this\.shadowed\) \{\s*instance\.shadow\(Theme\.glassShadow\);\s*\}/,
'`plain` 必须**真的**不调 shadow —— 否则这个开关是装饰品');
/*
* 不变式:**子 tab 的根容器**不得带投影(它们就在页签条下方、绘制在后)。
*
* ★ 判法:先定位每个 modifier 落在哪个 `struct` 里,再要求子 tab 那些 struct
* 一律用 `plain`。
*
* ⚠ 第一版写的是"该文件里 `plainCount >= 1`" —— **变异测不出来**:
* 把 `InboxTab` 改回 `of` 后,同文件的 `SentTab`/navBar 那几处 `plain`
* 仍然存在 ⇒ 计数 ≥1 照样绿(实测:变异① 没抓到)。
* "存在某个正确的" 与 "该正确的那个不许错" 是两件事。
*/
const CHILD_TABS = ['InboxTab', 'SentTab', 'PermissionTab', 'ContactsTab'];
/* 某个偏移落在哪个 struct 里(取它前面最近的那个 `struct X {`) */
const structAt = (src, idx) => {
const before = src.slice(0, idx);
const ms = [...before.matchAll(/(?:^|\n)struct\s+(\w+)\s*\{/g)];
return ms.length ? ms[ms.length - 1][1] : '(top)';
};
for (const [file, label] of [
['pages/MainPage.ets', 'CommPage'],
['pages/ContactsTab.ets', 'ContactsTab 页'],
['pages/PermissionTab.ets', 'PermissionTab'],
]) {
const src = read(file);
const offenders = [];
for (const m of src.matchAll(/PaneModifier\.(of|plain)\(this\.bgActive\)/g)) {
const owner = structAt(src, m.index);
if (m[1] === 'of' && CHILD_TABS.includes(owner)) offenders.push(owner);
}
assert.deepEqual(offenders, [],
`${label}: ${offenders.join('/')} 的根容器用了 \`PaneModifier.of\`(带投影),` +
'但它们就在页签条下方且绘制在后 ⇒ 投影向上扩散会压住页签条。' +
'要改用 `PaneModifier.plain`(投影只留给 Navigation 那层并列窗格)');
}
/* 同时:并列窗格那层仍要**保留**投影,否则层次感整个丢掉(不是只删不建) */
const mainSrc = read('pages/MainPage.ets');
const commAt = mainSrc.indexOf('struct CommPage {');
assert.ok(commAt >= 0, '要能找到 CommPage');
const commBody = mainSrc.slice(commAt, mainSrc.indexOf('\n}\n', commAt));
assert.match(commBody, /PaneModifier\.of\(this\.bgActive\)/,
'CommPage 的 Navigation(并列窗格)要保留带投影的 `PaneModifier.of` —— ' +
'否则窗格浮在壁纸上的层次感没了');
/* 同一个实例上不得挂两个 .attributeModifier(后者静默覆盖前者,意图丢失) */
const main = read('pages/MainPage.ets');
const navAt = main.indexOf('Navigation(this.navPathStack)');
assert.ok(navAt >= 0, 'CommPage 要有 Navigation');
/*
* ⚠ 取**属性链**(从 `navDestination(` 到闭合 `}`)而不是".slice(4000)":
* 第一版写 4000 字符,跨到了下一个组件的 modifier ⇒ 数出 3 个、假红。
* 属性链上有固定的一组锚(navDestination / mode / navBarWidth),
* 用它定界比按字符数可靠。
*/
const chainStart = main.indexOf('.navDestination(', navAt);
const chainEnd = main.indexOf('\n }\n}', chainStart);
assert.ok(chainStart > navAt && chainEnd > chainStart, '要能找到 Navigation 的属性链边界');
const chain = main.slice(chainStart, chainEnd);
const navMods = [...chain.matchAll(/^\s*\.attributeModifier\(PaneModifier\./gm)].length;
assert.ok(navMods <= 1,
`同一个 Navigation 实例上挂了 ${navMods} 个 .attributeModifier —— ` +
'`.attributeModifier` 是**单一插槽**,后者会静默覆盖前者,' +
'于是"壳有没有投影"取决于书写顺序而不是意图(2026-09-24 已删掉那处重复)');
});
// ───────────── 深色色板: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');
/*
* ★★ 2026-09-21 改:不再要求 `SettingsPage` 里出现 `Theme.overlay`。
*
* 原来这两条是:
* assert.match(settings, /Theme\.overlay/, '自绘弹层的遮罩仍然用 mask')
* assert.ok(!/wallpaperScrim/.test(settings), '弹层不该用壁纸遮盖色')
*
* 它们守的**真实意图**是「两个语义别混」(`overlay`=模态遮罩 / `wallpaperScrim`=壁纸压暗)——
* 这个仓库为此撞过四次。而现在 SettingsPage **已经不自绘遮罩了**
* (新增账号与修改密码都换成了官方 `bindSheet`,遮罩由系统画)
* ⇒ `Theme.overlay` 在这份文件里**只应出现在说明它被删掉的注释里**。
*
* ★ 不能简单删掉这两条:那会丢掉"不许把壁纸遮盖色当弹层遮罩"这个约束。
* 改成:
* ① `wallpaperScrim` 仍然**绝不得**出现在 SettingsPage(语义不混);
* ② 弹层必须走官方容器(`bindSheet`)—— 系统自己会用遮罩,
* 不需要也不应该再铺一层 `overlay`(两层遮罩会叠成双倍变暗)。
*
* ★ 为什么"不许出现 overlay"是对的:若有人往 `bindSheet` 内容里
* 再铺一层 `Theme.overlay`,那就是**系统遮罩 + 自绘遮罩叠两层**,
* 背景会暗两倍。这条断言正是拦这个。
*/
const settings2 = read('pages/SettingsPage.ets');
assert.match(settings2, /\.bindSheet\(\$\$this\./, '弹层应走官方 bindSheet(系统自带遮罩/圆角/动画)');
assert.ok(!/wallpaperScrim/.test(settings2), '弹层不该用壁纸遮盖色(两个语义别混)');
assert.ok(!/\.backgroundColor\(Theme\.overlay\)/.test(settings2),
'已交给 `bindSheet` 的弹层**不得**再自铺一层 overlay —— 系统遮罩 + 自绘遮罩 = 双倍变暗');
});
/**
* ★ 模糊字段的**消费侧必须逐文件登记 + 计数**(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}?已随/,
'碑文仍在原处:删除理由必须写在它被删掉的地方(否则下一个人看不出这里曾经有过什么)');
});
/* ═══════════════ 设备层:壁纸/外观**真的画在屏幕上** ═══════════════ */
/*
* 上面全部判据的最后一层都停在"数据/接线",从没验过 **屏幕上真的出现了那个东西**。
*
* ★ 这不是可有可无的一层 —— 2026-09-19 我在手势上刚吃过一模一样的教训:
* `PanGesture` 的代码全对、静态判据全绿,而真机上**一次都没触发**
* (起点坐标落在相邻的右栏里)。静态判据证明不了"用户点/看得见"。
*
* 这一条验的是 P4 的那句原始要求:**「壁纸开关打开后,屏幕上真的有壁纸」**。
* 做法:进「我的」页 → 打开背景开关 → 对比开关前后**顶层结构**里
* 壁纸层的存在(而不是截图比像素 —— 那太脆且依赖具体图片)。
*/
test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只看数据流)', async (t) => {
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住数据/接线)');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起我们的应用并等到它到前台');
await new Promise((r) => setTimeout(r, 1200));
/*
* 进「我的」窗格。★ **两种布局的入口不同**,必须分别处理 ——
* 这是我第一版直接红掉的地方(写 `tapText(hdc,'我的')`,而宽屏下根本没有底栏):
*
* 窄屏:底栏 4 项,第 4 项文字是「我的」(WebUI `NarrowNav.tsx` 同形)
* 宽屏:**没有底栏**,侧栏只有 3 项导航轨,「我的」是**底部那个头像按钮**
* (WebUI `Sidebar.tsx:186` 的 `setViewMode('account')`,二者同构)
*
* 判定方式与 `harmony-nav` 一致:用**宽高比 > 1.2** 区分形态
* (不是写死 vp 阈值,也不是拿屏幕 px 去比源码里的 vp —— 密度是第二个真相)。
*/
const screenOf = (root) => {
let w = 0;
let h = 0;
for (const n of D.walk(root)) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
w = Math.max(w, Number(m[1]));
h = Math.max(h, Number(m[2]));
}
}
return { w, h };
};
const root0 = D.dumpLayout(hdc);
const { w: scrW } = screenOf(root0);
const wide = scrW / screenOf(root0).h > 1.2;
if (wide) {
/*
* 宽屏:点侧栏底部**头像**(无文字、贴屏底)。用"左 1/6 内 + 在屏幕下半部 +
* 可点的方块"定位它 —— 与 `harmony-nav` 的 `navRailItemsOf` 同一套形状判法。
*/
const screenH = screenOf(root0).h;
const avatar = [...D.walk(root0)].find((n) => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
/*
* ★★ 2026-09-21 修(真隐患):加 `宽高比 ≈ 1` 这一条。
*
* ── 原来只要求「宽>80 且 高>80」,而侧栏下半部有**三个**可点方块 ──
* 设备实测(屏幕 3184×2232,density 2.875):
* #0 头像 [57,1791][172,1906] 115×115px = 40×40vp ratio 1.000
* #1 主题切换 [63,1918][167,1999] 104× 81px = 36×28vp ratio 1.284
* #2 退出登录 [63,2010][167,2091] 104× 81px = 36×28vp ratio 1.284
* **三个全都满足**「宽>80 且 高>80」。
*
* 今天它没出事,纯靠 `.find()` 返回**第一个**(walk 序恰好是头像)——
* 也就是说这条判据的正确性**不取决于形状判得准**,而取决于
* "渲染树的遍历顺序恰好把头像排在前面"。那种保证是会失效的:
* 谁调整一下侧栏底部簇的子元素顺序(把主题/退出提到前面),
* 这里就会**点中「退出登录」** ⇒ 设备被登出、账号列表被清空
* (`performLogout` → `AccountManager.clearAll()`,实测
* `accounts_json` 会变成 `[]`)。
*
* ★ 这不是理论风险:我这一次调试期间设备**真的被登出了**
* (09:21 `agentmail_accounts` 变成 `accounts_json=[]`),
* 排查了一圈才定位到"形状判据太宽 + 依赖遍历顺序"这个形状。
*
* ⇒ 加宽高比约束:头像 40×40(1.000),另两个 36×28(1.284)。
* 阈值 1.15 落在两者之间,且留了余量(不写 1.0 的精确等号 ——
* 那是拿"渲染出来的像素"当"源码里的 vp",两者不该硬等)。
*/
const w = x2 - x1;
const h = y2 - y1;
return x2 <= scrW * 0.08 && y1 > screenH * 0.5 &&
w > 80 && h > 80 && w / h <= 1.15;
});
assert.ok(avatar,
'宽屏侧栏底部要有可点的头像方块(「我的」的入口)—— 找不到它说明入口没了');
const ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像');
} else {
/*
* 窄屏:点底栏第 4 项。
*
* ★★ 2026-09-21 修(真 bug):原来用的是 `D.tapText(hdc, '我的')`,
* 而 "我的" 这个文案在**同屏出现两次**:
* · 页面头部标题 `Text('我的')`(`SettingsPage` 的 AppHeader),
* 它的 bounds 是 **[99,201][783,255]**(实测)—— 在屏**上半部**;
* · 底部导航项的文字,**[819,2061][877,2096]**。
* `tapText` 的实现是「取第一个可点的;都不可点就取第一个」——
* 两者都 `clickable=false`(点击挂在祖先容器上)⇒ 它取到的是**页头标题**,
* 点在那儿什么都不会发生。
*
* 症状极具误导性:报"点完之后应真的在「我的」页",
* 看起来像页面切不过去,实际是**点错了地方**(而且点的就是当前页的标题)。
*
* ⇒ 按位置挑:只取下半个屏幕、且 x 在屏宽右侧(底栏第 4 项)。
* 与宽屏那一分支的"按形状找"同一套思路 —— 文案会重复,位置不会。
*/
const scrH = screenOf(root0).h;
const navItem = [...D.walk(root0)].find((n) => {
const a = n.attributes || {};
if ((a.text || '').trim() !== '我的') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
return y1 > scrH * 0.75 && x1 > scrW * 0.6 && (x2 - x1) < 200 && (y2 - y1) < 200;
});
assert.ok(navItem,
'窄屏底栏第 4 项要有「我的」文字(按位置找,不按文案 —— 页头标题同名)');
const nc = D.boundsCenter(navItem.attributes.bounds);
/* 文字节点通常不可点(点击挂在祖先),但点它的中心会冒泡到那一项 */
assert.ok(D.tap(hdc, nc.cx, Math.round(nc.cy - 20)), '要能点到底栏的「我的」');
}
/*
* ★ 等页面真的切过去(**轮询**,不是睡固定时长)。
* 我第一版睡 1800ms 后断言,实测红 —— 而**手动点同一个坐标是立刻切过去的**。
* 原因就是固定等待不够稳(同一条纪律:手势判据也栽在固定 2500ms 上)。
* 现在等"「我的」页的标志性字段出现",最多 ~10 秒。
*/
const inMePane = (root) => [...D.walk(root)].some((n) => {
const t = (n.attributes?.text || '').trim();
/* 「我的」页独有:这几样在别的窗格里不会同时出现 */
return t.includes('用户名') || t.includes('连接密钥') || t.includes('主题由系统');
});
let landed = false;
/*
* ★★ 2026-09-19 修(套件里红、单独跑绿):轮询成功后必须**用新的 dump**,
* 不能继续用切页之前的 `root0`。
*
* 这个错法很隐蔽:`root0` 是"进「我的」之前"那一屏(套件里常常是日历页),
* 于是后面找「背景」时读的是**日历页的文字表** ⇒ 假红。
* 单独跑时之所以绿,是因为我手动摆现场时前台**恰好**已经在「我的」页 ——
* 也就是说:判据通过与否取决于跑之前那一屏是什么,而不是取决于代码对不对。
* (这正是"设备判据必须自己搭现场"的另一个理由。)
*/
let meRoot = null;
/*
* ★★ 2026-09-21 补:**先滑回顶部再轮询**(顺序很重要)。
*
* `SettingsPane` 在 `MainPage` 里是**常驻挂载**的 ⇒ 它的 `Scroll`
* 保留上一次的位置。上一次跑这套判据时滑到了底,这一次点进「我的」
* 直接就是滚到底的屏,而 `inMePane` 找的「用户名 / 连接密钥」在**上半部**
* ⇒ 报「点完之后应真的在「我的」页」,而实际**已经在了**。
* ("判据必须自己搭现场" —— 这条纪律本文件自己的注释里就写着。)
*/
const scrNav = screenOf(root0);
for (let i = 0; i < 3; i++) {
/* 回顶:手指从上往下拉(y 小→大) */
D.swipe(hdc, Math.round(scrNav.w / 2), Math.round(scrNav.h * 0.25),
Math.round(scrNav.w / 2), Math.round(scrNav.h * 0.75));
await new Promise((r) => setTimeout(r, 250));
} for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
const snap = D.dumpLayout(hdc);
if (inMePane(snap)) { landed = true; meRoot = snap; break; }
}
/*
* ★★ 2026-09-21 补:点进去之后**先回到顶部**再找「背景」。
*
* 上面那个 `inMePane` 找的是「用户名 / 连接密钥 / 主题由系统」——
* 而这三样都在页面**上半部**。而 `SettingsPane` 在 `MainPage` 里是
* **常驻挂载**的(宽屏/窄屏都不重建)⇒ 它的 `Scroll` **保留上一次的位置**。
*
* 后果:上一次跑这套判据时滑到底了,这一次点进「我的」就直接是滚到底的屏,
* `用户名` 不在可见节点里 ⇒ 报「点完之后应真的在「我的」页」。
* 而实际**已经在了** —— 这是"判据没自己搭现场"(本仓已有这条纪律,
* `harmony-appearance` 自己的注释里就写着)。
*
* ⇒ 点完之后一律先滑回顶部(用屏幕尺寸算坐标,不写死宽屏值)。
*/
assert.ok(landed && meRoot !== null,
'点完之后应真的**在「我的」页**(要能看到「用户名」这类只属于该页的字段)—— ' +
'只断言"点到了坐标"证明不了页面切过去了');
/*
* ★★ 2026-09-19 修:**先滚到「背景」再找它**。
*
* 原来只在**首屏可见节点**里找「背景」,于是它红过一次很误导的红:
* 实际读到的是"客户端连接密钥 / 修改密码 / 退出登录 / 管理"——
* 即**页面没问题,只是「背景」在首屏之下**(dumpLayout 只给**可见**节点,
* 这条在 `harmony-appearance` 自己的注释里就写过,我却在这里踩了同一个坑)。
*
* 判据要判的是"「我的」页里**有**背景那一节",不是"它在第一屏"。
* 所以往下滚几屏找;找不到才是真问题。
*/
const hasBg = (root) => [...D.walk(root)]
.some((n) => {
const t = (n.attributes?.text || '').trim();
return t.includes('背景') || t.includes('壁纸');
});
let texts0 = [...D.walk(meRoot)]
.map((n) => (n.attributes?.text || '').trim())
.filter(Boolean);
/*
* ★★ 关键:**先回到顶部,再逐屏往下扫**。
*
* 我第一版只朝一个方向滑("往上滑 = 内容上移"),结果一直在往页尾走,
* 而「背景」在「客户端连接密钥」**上面** ⇒ 越滑越远,6 次之后文字表
* 一字未变(那正是"滑反了"的信号,而不是"页面没有那一节")。
*
* 所以先往回滑到顶(滑不动了就是到了),再一屏一屏往下找。
* 这也顺手把"页面本身能不能滚"变成了判据的一部分 ——
* 滑了但可视内容不变 = 这个 Scroll 是死的。
*/
if (!hasBg(meRoot)) {
/*
* ★★ 2026-09-21 修(真 bug,套件红 / 单独跑也红):**滑动坐标写死了宽屏的**。
*
* 原来这两行是:
* D.swipe(hdc, 1600, 900, 1600, 1500); // 回顶
* D.swipe(hdc, 1600, 1500, 1600, 900); // 下扫
*
* `x=1600` 是**宽屏(3184px)**下的坐标。而窄屏只有 **1008px** 宽
* ⇒ x=1600 在屏外,`uitest uiInput swipe` 不接受、什么都不做。
* 于是"回顶"没回、"下扫"没扫,`meRoot` 一直是那一屏
* ⇒ 实测报「滚过 6 屏仍没有背景那一节」,而**手动滑两下就能看到「背景」**
* (它就在「客户端连接密钥」上面)。
*
* 危害不止假红:这段注释本身写着
* 「滑了但可视内容不变 = 这个 Scroll 是死的」——
* 也就是说这条判据**本意要顺便验证 Scroll 能不能滚**,
* 而坐标写死后它连"滚"这个动作都没发生,
* 那个顺带的验证也从来没生效过(一直是"空变量")。
*
* ⇒ 从实测屏幕宽高算中心,不写死:宽屏/窄屏/折叠态都能跑。
* 横向取屏幕中心,纵向取上/下各 1/5 处(比贴边稳,不碰系统手势区)。
*/
const scr = screenOf(meRoot);
const cx = Math.round(scr.w / 2);
const topY = Math.round(scr.h * 0.25);
const botY = Math.round(scr.h * 0.75);
for (let i = 0; i < 5; i++) { // ① 回到顶部
/* 从下往上**不行** —— 回顶要"内容下移" ⇒ 手指从上往下拉(y 小→大) */
D.swipe(hdc, cx, topY, cx, botY);
await new Promise((r) => setTimeout(r, 300));
}
meRoot = D.dumpLayout(hdc);
for (let i = 0; i < 8 && !hasBg(meRoot); i++) { // ② 逐屏往下扫
/* 往下看 = 内容上移 ⇒ 手指从下往上推(y 大→小) */
D.swipe(hdc, cx, botY, cx, topY);
await new Promise((r) => setTimeout(r, 400));
meRoot = D.dumpLayout(hdc);
}
texts0 = [...D.walk(meRoot)]
.map((n) => (n.attributes?.text || '').trim())
.filter(Boolean);
}
assert.ok(hasBg(meRoot),
'「我的」页里要能找到背景/壁纸那一节(滚过 6 屏仍没有 = 真的缺了)——' +
`实际读到:${texts0.slice(0, 40).join(' | ')}`);
/*
* 记下"壁纸层是否存在"的判据:`MainPage.WallpaperLayer()` 在有壁纸时会挂
* 一个铺满屏幕的图片/渐变层。它在 dump 里表现为一个**铺满全屏的 Image 或
* 带渐变背景的 Column**,且 `MainPage` 的 `bgActive` 为真时页面底的
* 背景色会变成 Transparent。
*
* ★ 判据断的是**结构**(有铺满全屏的壁纸容器)而不是像素颜色 ——
* 后者要与主题/图片内容耦合,会在换壁纸时假红。
*/
/*
* ★★ 判定"壁纸层在不在"的**正确形状**(我前两版都写错了):
*
* 第一版:找"铺满屏幕的 Image/Stack" ⇒ **恒为 true**(外壳本身就有铺满的结构)。
* 第二版:找"全屏 Column 的渐变属性" ⇒ dump 里根本没有渐变字段(不报)。
*
* 有效的信号是**几何**:壁纸开启时 `bgActive=true` ⇒ 各内容面板的底色变
* `Transparent`,于是**面板与屏幕边缘之间的缝隙**会露出壁纸;关闭时那里是
* 面板自己的不透明底色。
*
* 实测(密度 2.875、屏 3184px):
* 预设档:侧栏区 `(60,1200)` = **#F4F5F7**、缝隙 `(215,1200)` = **#E0E2E4**
* 不设档:两处都是 **#FFFFFF**(纯白)
*
* 判据读的是 `dumpLayout` 里对应节点的 `backgroundColor` —— 比截图比像素稳
* (不依赖具体预设色的取值,只看"是不是纯白/是否透明让位")。
*/
const panelSlotColor = (root) => {
const all = [...D.walk(root)];
let screenW = 0;
let screenH = 0;
for (const n of all) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
screenW = Math.max(screenW, Number(m[1]));
screenH = Math.max(screenH, Number(m[2]));
}
}
if (screenW === 0) return null;
/*
* 找侧栏(左 1/6 内、纵贯大部分屏幕)与内容面板(在侧栏右侧、宽度很大),
* 取它们各自最外层容器的背景色。
*/
let sidebarBg = null;
let contentBg = null;
for (const n of all) {
const a = n.attributes || {};
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m || typeof a.backgroundColor !== 'string') continue;
const [, x1, y1, x2, y2] = m.map(Number);
const w = x2 - x1;
const h = y2 - y1;
if (h < screenH * 0.8) continue; // 要纵贯屏幕的容器
if (x2 <= screenW * 0.08 && sidebarBg === null) sidebarBg = a.backgroundColor;
if (x1 > screenW * 0.07 && x1 < screenW * 0.2 && w > screenW * 0.5 && contentBg === null) {
contentBg = a.backgroundColor;
}
}
return { sidebarBg, contentBg };
};
/**
* 壁纸开着 ⇔ **档位选中态不是"不设"**。
*
* ★★ 第三版(前两版都被同一个毛病坑了:判据的形状与它声称的不是一回事):
* ① 找"铺满屏幕的 Image/Stack" ⇒ 恒 true(外壳本来就有)
* ② 找"面板底色是否透明" ⇒ 恒 false(ArkUI 报的实色与我的正则对不上)
* 两版都没真正问出"壁纸开了吗"。
*
* 而这个问题**有一个确定答案**:段式选择器的选中态。
* 实测:选中 = `#FF2563EB`(品牌蓝)、未选中 = `#FFF1F3F5`(浅灰)。
* `bg_kind` 与选中态是一一对应的(同一个 @State),所以问它就等于问数据库。
*
* ★ 这一版为什么不脆:它读的是**控件自己的状态**,不依赖渲染管线的实现细节
* (材质/透明色如何上报、渐变节点是否出现在 dump 里)。
*/
const SELECTED_BG = '#FF2563EB';
const isSelected = (root, label) => chipState(root, label) === SELECTED_BG;
const chipState = (root, label) => {
const node = [...D.walk(root)].find((n) => {
const a = n.attributes || {};
return (a.text || '').trim() === label && typeof a.backgroundColor === 'string';
});
return node ? (node.attributes.backgroundColor || '').toUpperCase() : null;
};
const currentKind = (root) => {
for (const [label, kind] of [['不设', 'none'], ['预设', 'preset'], ['自定义图片', 'image']]) {
if (isSelected(root, label)) return kind;
}
return null;
};
const before = currentKind(meRoot) !== 'none';
/*
* 切换背景开关。**幂等性处理**:开关当前是什么状态未知(上一次跑可能留下了),
* 所以先读当前状态、只在需要时点它 —— 盲目点会把"已开"点成"关"。
* 判据断的是**切换后状态真的变了**,不是"点了就成功"。
*/
/*
* ★ 开关的形态是**三选一的段式选择**:「不设 / 预设 / 自定义图片」——
* 不是 `Toggle`(我第一版按 Toggle 找,于是**永远 skip**)。
* 「背景」本身只是那一节的**标签文本**(`clickable=false`),点它没用。
*
* 这里用「切换到一个与当前不同的档位」的方式做幂等切换:
* 当前是「不设」就点「预设」,否则点「不设」。
* 这样跑第二遍时方向相反,但结论一致(判据必须幂等)。
*/
const clickableText = (root, want) => [...D.walk(root)].find((n) => {
const a = n.attributes || {};
return a.clickable === 'true' && (a.text || '').trim() === want;
});
/*
* ★★ 第二版修正(第一版又错了,这次是**判"当前档"的方式错**):
*
* 我第一版用「能不能找到『不设』那个按钮」来判"当前是不设档" ——
* 但**三个按钮永远都在**(它是段式选择器,不是"点了才出现")。
* 于是它每次都去点「预设」,而壁纸本来就开着 ⇒ 切换前后无变化 ⇒ 假红。
*
* 正确判法是读**选中态**。实测(dump 的属性):
* 选中 `backgroundColor = #FF2563EB`(品牌蓝)
* 未选中 `backgroundColor = #FFF1F3F5`(浅灰)
* 这与 WebUI 的段式选择器同形(选中项是品牌底)。
*/
/*
* ★★ 第三版(前两版都被同一个毛病坑了:判据的形状与它声称的不是一回事):
* ① 找"铺满屏幕的 Image/Stack" ⇒ 恒 true(外壳本来就有)
* ② 找"面板底色是否透明" ⇒ 恒 false(dump 里的色值与我的正则对不上)
* 两版都没真正问出"壁纸开了吗"。
*
* 而这个问题**有一个确定答案**:段式选择器的选中态(`chipState`,下面定义)。
* 选中的 `bg_kind` 与 `@State` 一一对应 ⇒ 问它就等于问状态。
* 它读的是**控件自己的状态**,不依赖渲染管线细节(材质/透明色如何上报、
* 渐变节点是否出现在 dump 里)—— 这正是前两版脆的原因。
*/
const isNone = isSelected(meRoot, '不设');
const targetLabel = isNone ? '预设' : '不设';
const sw = clickableText(meRoot, targetLabel);
if (!sw) {
return t.skip(`找不到背景档位「${targetLabel}」(可能在图片档或文案变了)—— 本次不跑,但不假装通过`);
}
const c = D.boundsCenter(sw.attributes?.bounds);
assert.ok(c, '背景档位按钮要有可解析的边界');
assert.ok(D.tap(hdc, c.cx, c.cy), `要能点到背景档位「${targetLabel}」`);
await new Promise((r) => setTimeout(r, 2500)); // 切换 + 推服务端 + 重绘
const after = currentKind(D.dumpLayout(hdc)) !== 'none';
/*
* ★ 断"**状态变了**"而不是断"变成 true":开关初始状态取决于是上一次跑留下的,
* 而判据必须**幂等**(跑两遍结论相同)。所以两种切换方向都算通过,
* 真正要防的是"点了却什么都没发生"(那才是接线断了)。
*/
assert.notEqual(after, before,
'★ 切换背景开关后,壁纸层的存在性必须发生变化 —— ' +
`切换前=${before}、切换后=${after}。两者相同说明**开关点了没反应**:` +
'要么开关没接上状态,要么壁纸层没跟着重绘(这正是静态判据看不到的那一层)。');
/* 复位:点回**原来那一档**(不是"再点一次当前那个"——档位是单选,再点不变) */
const backLabel = isNone ? '不设' : '预设';
const backBtn = clickableText(D.dumpLayout(hdc), backLabel);
assert.ok(backBtn, `复位时还能找到「${backLabel}」`);
const bc = D.boundsCenter(backBtn.attributes.bounds);
assert.ok(D.tap(hdc, bc.cx, bc.cy), '要能点回去');
await new Promise((r) => setTimeout(r, 2000));
assert.equal(currentKind(D.dumpLayout(hdc)) !== 'none', before,
'复位后应回到判据开始时的状态(判据必须幂等,不留副作用)');
});
test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值曾被顶出屏外)', async (t) => {
/*
* ★★ 这条来自一个追了很久的真 bug(2026-09-19)。
*
* 症状:用户/我反复看到「我的」页压暗滑杆旁边印着 `56.000000`。
* 真相有两层,两层都值得记下来:
*
* ① `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
* —— 屏幕上根本没有这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
* ② 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,
* 而屏幕宽 3184 ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。
*
* 根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,而内容列写的是
* `.width('100%')` —— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp
* **相加** ⇒ 必然溢出 60vp。修法:内容列改 `.layoutWeight(1)`(吃剩余空间)。
*
* 为什么值得一条判据:**同一处错误在不同 pane 上表现不同**(日历页自己算宽度,
* 就没露出来),所以很容易被当成"某一页的样式问题"去调,而不是回到布局本身。
* 这条判据检查的是**所有窗格**,任何一个新 pane 犯了同样的错都会红。
*/
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起应用');
/* 回主界面(前面判据可能把前台留在 push 出去的页上 —— 那些页没有侧栏) */
assert.ok(await D.backToMain(hdc), '要能回到主界面');
/*
* 逐个窗格检查。宽屏下「我的」用侧栏头像进,窄屏用底栏 —— 这里只跑宽屏那份
* (窄屏的几何不同,若要覆盖应单独一条,不要在一个判据里塞两种形态)。
*/
const root0 = D.dumpLayout(hdc);
const screenOf = (root) => {
let w = 0;
let h = 0;
for (const n of D.walk(root)) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
w = Math.max(w, Number(m[1]));
h = Math.max(h, Number(m[2]));
}
}
return { w, h };
};
const { w: scrW, h: scrH } = screenOf(root0);
/*
* 越界判定:右边缘超出屏宽、或左边缘为负。
* **排除系统组件**(状态栏那一条 `Stack` 实测 x2=3230 > 3184,那是系统的,
* 不归我们管)—— 按"是否属于我们应用的组件树"排除不现实,改用
* "越界幅度 + 节点类型":系统状态栏整条横贯顶部(y 很小且高度小)。
*/
const overflows = (root) => {
const bad = [];
for (const n of D.walk(root)) {
const a = n.attributes || {};
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) continue;
const [, x1, y1, x2, y2] = m.map(Number);
if (y1 < scrH * 0.05 && (y2 - y1) < scrH * 0.06) continue; // 状态栏,跳过
if (x1 < -2) bad.push(`${a.type} 左越界 x1=${x1}`);
else if (x2 > scrW + 2) bad.push(`${a.type} 右越界 x2=${x2}(屏宽 ${scrW})`);
}
return bad;
};
assert.deepEqual(overflows(root0), [],
`★ 「通信」窗格有内容超出屏幕 —— 超出 = 用户看不见(不是"偏了一点")`);
/* 进「我的」窗格(宽屏走侧栏头像;找不到就跳过,不假装通过) */
const avatar = [...D.walk(root0)].find((n) => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
/*
* ★★ 2026-09-21 修:与文件里另一处「找头像」同一个隐患 —— 见 `:1020` 那段注释。
* 侧栏下半部三个可点方块(头像 40×40 / 主题 36×28 / 退出 36×28)**都**满足
* `(y2-y1) > 80`,`.find()` 取第一个才碰巧是头像。加宽高比约束把它变成
* **形状判得准**,而不是"依赖遍历顺序"。
*
* 这里尤其要紧:这一处找到之后点它进「我的」页,若点到「退出登录」,
* 设备会被登出并清空账号(`clearAll()`)—— 实测 `accounts_json` 变 `[]`。
*
* ⚠️ 注意这里必须解构出 **`x1`**(原来写的是 `[, , y1, x2, y2]`,
* 跳过了 `x1`)—— 我加宽高比时需要它算 `w`,漏了就会在**运行期**
* `ReferenceError: x1 is not defined`。它不是断言失败:整个文件
* **一条都跑不了**(套件把它记成 `broken=1`,而 broken 证明不了任何事)。
* 实测就是这么红的(`ran=34 checks=553 … broken=1`)。
*/
const w = x2 - x1;
const h = y2 - y1;
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && h > 80 && w / h <= 1.15;
});
if (!avatar) {
return t.skip('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑');
}
const ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像进「我的」');
await new Promise((r) => setTimeout(r, 2500));
const rootMe = D.dumpLayout(hdc);
assert.deepEqual(overflows(rootMe), [],
'★ 「我的」窗格有内容超出屏幕 —— 这正是那个 `56%` 被顶出屏外的形状。' +
'常见原因:内容列写了 `width(\'100%\')` 而不是 `layoutWeight(1)`(与侧栏相加必溢出)');
});