Files
MailUI4Agents/client/electron/test/harmony-appearance.test.mjs
JianFeeeee f811c9887a 跨端: 三个真 bug(外观保存 400 / 改档位界面不动 / 内容列溢出屏幕)+ 设备判据
这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。

## 一、外观保存从来就没成功过(PUT 400)

`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。

症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。

★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。

## 二、改了档位,页面背景一点不变(两层原因)

**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。

**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:

    Appearance: sync: bgKind=preset … hasImg=true    ← 数据是对的
    Wallpaper: kind=none layers=0 active=false       ← 渲染读到的还是旧值

修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。

★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。

## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)

真相有**两层,两层都值得记**:

1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
   —— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
   ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。

根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。

★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。

## 四、设备判据基建(这一轮加的能力)

- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
  `swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
  只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
  只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
  (拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
  `harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
  · `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
  · 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」

## 验证

`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。

设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
  库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。

**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
2026-09-19 15:03:40 +08:00

1293 lines
76 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}?已随/,
'碑文仍在原处:删除理由必须写在它被删掉的地方(否则下一个人看不出这里曾经有过什么)');
});
/* ═══════════════ 设备层:壁纸/外观**真的画在屏幕上** ═══════════════ */
/*
* 上面全部判据的最后一层都停在"数据/接线",从没验过 **屏幕上真的出现了那个东西**。
*
* ★ 这不是可有可无的一层 —— 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);
return x2 <= scrW * 0.08 && y1 > screenH * 0.5 && (x2 - x1) > 80 && (y2 - y1) > 80;
});
assert.ok(avatar,
'宽屏侧栏底部要有可点的头像方块(「我的」的入口)—— 找不到它说明入口没了');
const ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像');
} else {
assert.ok(D.tapText(hdc, '我的'), '要能点到「我的」窗格(底栏第 4 项)');
}
/*
* ★ 等页面真的切过去(**轮询**,不是睡固定时长)。
* 我第一版睡 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;
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; }
}
assert.ok(landed && meRoot !== null,
'点完之后应真的**在「我的」页**(要能看到「用户名」这类只属于该页的字段)—— ' +
'只断言"点到了坐标"证明不了页面切过去了');
/* 「我的」页里找背景开关:WebUI 那边叫「背景」,鸿蒙侧同名 */
const texts0 = [...D.walk(meRoot)]
.map((n) => (n.attributes?.text || '').trim())
.filter(Boolean);
assert.ok(texts0.some((x) => x.includes('背景') || x.includes('壁纸')),
'「我的」页里要能找到背景/壁纸那一节(这是本判据的定位锚点)——' +
`实际读到:${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), '要能拉起应用');
/*
* 逐个窗格检查。宽屏下「我的」用侧栏头像进,窄屏用底栏 —— 这里只跑宽屏那份
* (窄屏的几何不同,若要覆盖应单独一条,不要在一个判据里塞两种形态)。
*/
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 [, , y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80;
});
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)`(与侧栏相加必溢出)');
});