Files
MailUI4Agents/client/electron/test/harmony-appearance.test.mjs
JianFeeeee d1e0ba32f9 跨端: 壁纸遮盖色改用页面底色系(mask 两套主题都深,浅色下方向与 WebUI 相反);模态遮罩保持 mask
pi 提了一个**方向性**疑问,让我先把值读出来再定 —— 读完确认**他是对的**,而且这是"机制上确定不同"。

## 实测(两张 SDK 表交叉验证,不靠记忆)

`ets/build-tools/ets-loader/sysResource.js` 给名字→id,`previewer/.../resources.txt` 给 id→值:

| 令牌 | 浅色主题 | 深色主题 |
|---|---|---|
| `ohos_id_color_mask_regular`(原 `Theme.overlay`) | `#99182431` **深蓝灰** | `#b2000000` 黑 |
| `ohos_id_color_background` | `#ffffffff` **白** | `#ff18181a` 近黑 |

⇒ mask 的 light/regular/thick 是**浓度档**(同一深色的三个 alpha),不是深浅两套值:
它在**两套主题下都是深色**(模态遮罩语义)。而 WebUI 的 `--bg-scrim` 浅色是**白**
(原话「浅色下用白把花哨的图案洗淡」)—— 壁纸遮盖用 mask 就是**反方向**:
浅色主题下把预设压暗,而 WebUI 是把它洗淡。

## 改法

- 新增 `Theme.wallpaperScrim = $r('sys.color.ohos_id_color_background')`(页面底色系:
  浅色白、深色近黑,**自动换向**,与"朝底色淡化"同一意图);预设档与图片档的遮盖层都用它。
- `Theme.overlay`(mask)**只留给模态弹层**(那里语义确实是压暗背后)。
- 不复用 `pageBg` 的原因写进注释:页面底在背景开启时会被换成**透明**
  (`bgActive ? Color.Transparent : Theme.pageBg`),而遮盖层**永远要一个真实颜色** ——
  两个用途生命周期不同,共用一个名字迟早坏一头(这个仓库撞过四次的模式)。

## 判据(从 SDK 读真值再判,不写死结论)

断言「mask 两套主题都深」「页面底色系浅白深黑」「WebUI 的 `--bg-scrim` 浅色是白」,
再落到代码:两处遮盖层必须用 `wallpaperScrim`、弹层仍用 `overlay`、两个令牌不许混用。
**这样"令牌选错方向"以后不能靠记性避免。**

变异:遮盖退回 mask → 红 1 条;只改一层 → 红 2 条。

## 文档

§7.17b-2 记这次读数(表格 + 两张表怎么读 + 为什么不复用 pageBg + 变异结果);
§7.12 有意差异表加一行(遮盖色令牌:两个语义两个令牌,"不允许混用")。

## 验证

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0
(12 个判据文件全绿 + vitest 258/258;harmony-appearance 23 → 24 条)。
2026-09-14 15:10:15 +08:00

741 lines
46 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 { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
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 code = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const read = (rel) => code(readFileSync(join(HARMONY_ETS, rel), 'utf8'));
const snap = (over = {}) => Object.assign(new A.AppearanceSnapshot(), over);
const resp = (over = {}) => Object.assign(new A.AppearanceResponse(), over);
// ───────────────────── 归一层 ─────────────────────
test('服务端回包 → 快照:认不出的值退回默认,不做半信半疑的处理', () => {
// 全空(服务端新字段/老数据):每一项都要有安全的默认
const empty = A.snapshotFromResponse(resp());
assert.equal(empty.theme, 'system', '认不出的主题要跟随系统,而不是硬选一个');
assert.equal(empty.bgKind, 'none');
assert.equal(empty.bgPresetId, 'aurora');
assert.equal(empty.bgDim, 12);
assert.equal(empty.bgBlur, 4);
// 脏值拼错的枚举、越界数字、小数、NaN不该被照单全收
const dirty = A.snapshotFromResponse(resp({ theme: 'drak', bg_kind: 'IMAGE', bg_dim: 999, bg_blur: -5 }));
assert.equal(dirty.theme, 'system');
assert.equal(dirty.bgKind, 'none', '枚举大小写不同也是认不出(不做模糊匹配)');
assert.equal(dirty.bgDim, 90, '压暗值上限 90');
assert.equal(dirty.bgBlur, 0, '模糊值下限 0');
const frac = A.snapshotFromResponse(resp({ bg_dim: 12.6, bg_blur: 4.4 }));
assert.equal(frac.bgDim, 13, '小数要取整(渲染值不能是半个像素)');
assert.equal(frac.bgBlur, 4);
assert.equal(A.snapshotFromResponse(resp({ bg_dim: NaN })).bgDim, 12, 'NaN 退回默认');
});
test('本地快照 → PUT 报文:选了图片档却没有图,要退回 none', () => {
const noImage = A.payloadFromLocal(snap({ bgKind: 'image' }), false);
assert.equal(noImage.bg_kind, 'none', '否则服务端会存一个指向空图的记录');
const withImage = A.payloadFromLocal(snap({ bgKind: 'image' }), true);
assert.equal(withImage.bg_kind, 'image');
// 字段名要与服务端 JSON 一致(蛇形);混进驼峰名服务端只会静默用默认值
const payload = A.payloadFromLocal(snap(), false);
for (const k of ['theme', 'bg_kind', 'bg_preset_id', 'bg_dim', 'bg_blur']) {
assert.ok(k in payload, `PUT 报文要有 ${k}`);
}
for (const bad of ['bgKind', 'bgDim', 'bgBlur', 'bgPresetId']) {
assert.ok(!(bad in payload), `PUT 报文不该出现驼峰名 ${bad}`);
}
});
// ───────────────────── 合并决策(这一期的验收核心) ─────────────────────
test('★ 服务端没有记录时:以**本地**为准并推上去,绝不拿默认值覆盖本地', () => {
/*
* 这是两条最贵的规则之一。服务端在没有记录时回的是一份**默认值**
* 拿它覆盖本地等于把用户已有的外观(尤其是本地缓存的壁纸)抹掉 ——
* WebUI 侧漏了这条时,"每个老用户升级后第一次登录都会发现主题被重置"。
*/
const local = snap({ theme: 'dark', bgKind: 'image', bgPresetId: 'ocean', bgDim: 40, bgBlur: 30 });
const m = A.mergeAppearance(local, resp({ saved: false }), false);
assert.equal(m.action, 'push-local', '谁覆盖谁:本地覆盖服务端');
assert.equal(m.shouldPush, true, '要把本地这份推上去作为账号的初始外观');
assert.equal(m.status, 'pending', '状态要能看出"正在同步到账号"');
assert.deepEqual(
{ theme: m.snapshot.theme, bgKind: m.snapshot.bgKind, bgPresetId: m.snapshot.bgPresetId, bgDim: m.snapshot.bgDim, bgBlur: m.snapshot.bgBlur },
{ theme: 'dark', bgKind: 'image', bgPresetId: 'ocean', bgDim: 40, bgBlur: 30 },
'本地那份必须原样保留(一个字段都不能被默认值顶掉)'
);
// 反例:这是变异测试要打的那一枪 —— 拿默认值覆盖会立刻丢主题与壁纸
assert.notEqual(m.snapshot.theme, 'system');
});
test('★ 服务端有记录时:以服务端为准,但**不擦掉**本地那张服务端还没有的图', () => {
// 正常情形:服务端说了算
const applied = A.mergeAppearance(snap({ theme: 'light', bgKind: 'preset', bgPresetId: 'x', bgDim: 5, bgBlur: 5 }),
resp({ saved: true, theme: 'dark', bg_kind: 'preset', bg_preset_id: 'aurora', bg_dim: 30, bg_blur: 20 }), false);
assert.equal(applied.action, 'apply-remote');
assert.equal(applied.shouldPush, false);
assert.equal(applied.status, 'synced');
assert.equal(applied.snapshot.theme, 'dark', '服务端说了算');
assert.equal(applied.snapshot.bgPresetId, 'aurora');
assert.equal(applied.snapshot.bgDim, 30);
// 服务端记着 image 档、但**本体不在**(本地还没推上去 / 图被清过):
// 不能照着 image 档渲染一块空地,也不能把本地那张擦掉
const keepLocal = A.mergeAppearance(snap({ bgKind: 'image' }), resp({ saved: true, bg_kind: 'image' }), false);
assert.equal(keepLocal.snapshot.bgKind, 'image', '本地有图 → 先按本地算');
const noLocal = A.mergeAppearance(snap({ bgKind: 'none' }), resp({ saved: true, bg_kind: 'image' }), false);
assert.equal(noLocal.snapshot.bgKind, 'none', '本地也没图 → 不能渲染一块空地');
// 服务端真有图:照服务端
const remoteImage = A.mergeAppearance(snap({ bgKind: 'none' }), resp({ saved: true, bg_kind: 'image' }), true);
assert.equal(remoteImage.snapshot.bgKind, 'image');
});
test('离线/未登录:本地就是全部,而且**状态要看得见**(降级不可见 = 用户以为能带走)', () => {
const only = A.localOnly(snap({ theme: 'dark' }));
assert.equal(only.status, 'local-only');
assert.equal(only.snapshot.theme, 'dark');
assert.equal(only.shouldPush, false, '离线时不该尝试推');
// 三个状态文案要分得开
const labels = ['synced', 'pending', 'local-only'].map(A.statusLabel);
assert.equal(new Set(labels).size, 3, '三种状态要有不同文案');
assert.match(A.statusLabel('local-only'), /仅本机/);
assert.match(A.statusLabel('pending'), /同步/);
});
// ───────────────────── 系统方案:数字 → 系统材质 / 色彩模式 ─────────────────────
test('★ 模糊值映射到**系统材质档次**(不是把 40 当半径塞给某个 API', () => {
/*
* 服务端存的是 WebUI 的 `bg_blur`模糊像素半径0~40鸿蒙这边"模糊"由系统材质提供
* `BlurStyle`)。同一个数字两边含义不同,必须显式映射 —— 这条判据钉住映射关系,
* 顺带钉住"没有 0~40 档全开"(材料只有几档,落不到档上的数字要归到最近的档)。
*/
assert.equal(A.blurStyleFor(0), 'NONE', '不模糊就是不用材质');
assert.equal(A.blurStyleFor(4), 'COMPONENT_THIN');
assert.equal(A.blurStyleFor(8), 'COMPONENT_THIN');
assert.equal(A.blurStyleFor(9), 'COMPONENT_REGULAR');
assert.equal(A.blurStyleFor(20), 'COMPONENT_REGULAR');
assert.equal(A.blurStyleFor(21), 'COMPONENT_THICK');
assert.equal(A.blurStyleFor(40), 'COMPONENT_THICK');
assert.equal(A.blurStyleFor(999), 'COMPONENT_THICK', '越界要归到最近的档,不能返回空');
assert.equal(A.blurStyleFor(-3), 'NONE');
// 档次必须来自系统枚举(写成自造名字会编译不过/不生效)
const sdk = A.blurStyleFor(12);
const commonDts = readFileSync(process.env.HARMONY_COMMON_DTS
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts', 'utf8');
const enumBlock = commonDts.slice(commonDts.indexOf('declare enum BlurStyle'));
const members = [...enumBlock.slice(0, enumBlock.indexOf('}')).matchAll(/^\s{2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1]);
assert.ok(members.length > 3, '要从 SDK 里读到 BlurStyle 成员');
for (const tier of ['NONE', 'COMPONENT_THIN', 'COMPONENT_REGULAR', 'COMPONENT_THICK']) {
assert.ok(members.includes(tier), `${tier} 必须是系统 BlurStyle 的成员`);
}
assert.ok(members.includes(sdk));
});
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 = readFileSync(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 = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8');
const webBgStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
test('★ 预设清单与 WebUI 一致id + 顺序 + 中文标签)—— 少一个档就是"用户设了在鸿蒙看不到"', () => {
/*
* pi 的原话2026-09-14WebUI 的背景有预设渐变,若鸿蒙只认 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\)/, '遮盖层要用页面底色系令牌与算出来的浓度');
// 背景在主界面这一层Tabs 之上不该再有不透明的底色把背景盖死
assert.match(main, /Stack\(\) \{[\s\S]{0,120}?this\.WallpaperLayer\(\)/, '背景要铺在内容之下Stack 的底层)');
});
test('★ 模糊归属:壁纸层**不许**再模糊导航条必须有系统材质互斥形式pi 修正后的口径)', () => {
/*
* pi 撤回了他原来那句"模糊只由壁纸层负责":那是 WebUI 的架构结论
* (它的壁纸图层自带 `filter: blur()`,浮在它上面的面再 backdrop-filter 就是把
* 同一张糊过的底糊第二遍),不是通用规则。正确的形式是两条:
* ① 同一张底只许被模糊**一次**
* ② 模糊该出现在"背后是**可变内容**"的层(导航条背后是滚动内容,壁纸层背后什么都没有)。
* 于是判据写成互斥/分工,而不是"归谁"。
*/
const main = read('pages/MainPage.ets');
/*
* 取"壁纸层 builder 的正文"要**按行**截:用 `indexOf('build() {')` 两头夹,
* 要么撞上文件里更早的那个 build()(切片成空串),要么一路跨到后面的
* TabBarBuilder那里**正当地**有 `.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(!/TabBarBuilder/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去');
assert.ok(wallpaperBuilder.length > 200, '要能取到壁纸层的 builder 正文');
assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder), '壁纸层不许再用材质(同一张底糊两遍 = 更脏更掉帧)');
assert.ok(!/blur\(/i.test(wallpaperBuilder), '壁纸层不许出现任何模糊调用');
// 导航条那一次模糊仍然在(背后是会滚动的内容,遮蔽有意义)
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条必须有系统材质');
// 材料档次由用户偏好映射而来(不是写死的半径)
const store = read('common/AppearanceStore.ets');
assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式');
/*
* "理由写清了没"要读**原文**`read()` 会剥注释 —— 而理由就在注释里)。
* 剥注释读源码是为了防"注释里的调用被当成真调用",但断言"注释里写了理由"时正好相反。
*/
const wallRaw = readFileSync(join(HARMONY_ETS, 'model/Wallpaper.ts'), 'utf8');
assert.match(wallRaw, /系统没有对应的渐变原语/, '网格档为什么用 Canvas 要写清(否则以后会被当成绕开系统方案)');
assert.match(wallRaw, /repeating-linear-gradient/, '要指名道姓写出 CSS 用的是哪个原语(后人查得到)');
});
test('判据自检:预设清单少一档必须判红', () => {
// 自检:把 id 列表裁掉一个,确认"与 WebUI 一致"那条会红
const block = webBgStore.slice(webBgStore.indexOf('export const PRESETS'), webBgStore.indexOf('];', webBgStore.indexOf('export const PRESETS')));
const webIds = [...block.matchAll(/id:\s*'([a-z]+)'/g)].map(m => m[1]);
const trimmed = webIds.slice(0, -1);
assert.notDeepEqual(trimmed, W.PRESET_IDS, '自检:裁掉一档后必须与实现不一致(否则这条判据没有分辨力)');
});
test('★ 背景画出来了还不够:每个页面要**让出**页面底,否则壁纸全被盖住', () => {
/*
* 这一条是"渲染"这句话的另一半。只把壁纸铺在最底层、而每个页面自己又刷一层
* **不透明**的系统页面底,壁纸就等于没画(用户看到的仍然是纯色页面)。
* WebUI 侧的原话:「页面底 → 完全透明,让出背景;不改 27 个组件的 class
* 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。
*
* 所以判据钉的是"没有一处页面底还在用不透明的系统页面底"——
* 漏掉任何一个页面,就是那一页看不到壁纸。
*/
const main = read('pages/MainPage.ets');
const opaqueRoots = [...main.matchAll(/\.backgroundColor\(Theme\.pageBg\)/g)].length;
assert.equal(opaqueRoots, 0,
`还有 ${opaqueRoots} 处页面底用不透明的 Theme.pageBg —— 那几页看不到壁纸`);
const yielded = [...main.matchAll(/\.backgroundColor\(this\.bgActive \? Color\.Transparent : Theme\.pageBg\)/g)].length;
assert.ok(yielded >= 5, `要让出页面底的页面至少 5 个(通信/联系人 + 三个 pane实际 ${yielded}`);
// 每个页面都要**收到**这个开关:漏传 = 该页恒为不透明(等于没有让出)
for (const comp of ['CommPage', 'ContactsTab']) {
assert.match(main, new RegExp(`${comp}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `主界面要把 bgActive 传给 ${comp}`);
}
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
assert.match(main, new RegExp(`${pane}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `通信页要把 bgActive 传给 ${pane}`);
}
// 开关必须由**背景计划**驱动(不是写死的 true/false
assert.match(main, /this\.bgActive = this\.bgPlan\.kind !== 'none';/, 'bgActive 要由背景计划决定');
// 每个组件都要声明这个 @Prop漏一个就编译不过但判据先钉住意图
for (const comp of ['CommPage', 'ContactsTab', 'InboxTab', 'SentTab', 'PermissionTab']) {
const at = main.indexOf(`struct ${comp} {`);
const head = main.slice(at, at + 400);
assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`);
}
});
// ───────────── 深色色板pi 指出这是"机制上确定不同",不是"观感未验" ─────────────
/** 从 index.css 的某个段(:root 或 .dark里读出调色板变量的 RGB */
function cssPalette(selector) {
const at = selector === ':root' ? webCss.indexOf(':root') : webCss.indexOf('.dark {');
assert.ok(at >= 0, `CSS 里要有 ${selector}`);
let depth = 0;
let end = at;
for (let i = webCss.indexOf('{', at); i < webCss.length; i++) {
if (webCss[i] === '{') depth++;
else if (webCss[i] === '}') {
depth--;
if (depth === 0) { end = i; break; }
}
}
const body = webCss.slice(webCss.indexOf('{', at), end);
const read = (name) => {
const m = new RegExp(`${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+);`).exec(body);
assert.ok(m, `${selector} 里要有 ${name}`);
return '#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0').toUpperCase()).join('');
};
return {
gray100: read('--c-gray-100'), gray200: read('--c-gray-200'),
blue100: read('--c-blue-100'), blue200: read('--c-blue-200'),
green100: read('--c-green-100'), amber100: read('--c-amber-100'), orange100: read('--c-orange-100')
};
}
test('★ 预设色板**两套**、且深色那套与 CSS 的 `.dark` 段逐个相等(不是"观感未验",是机制)', () => {
/*
* pi 的原话2026-09-14WebUI 的 `.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('★ isDarkModesystem 要看系统当时的深浅,读不到时按浅色(与 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 默认一致');
// 数值锚到 SDK0=深、1=浅
const sdkConst = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts'), 'utf8');
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);
assert.ok(/\bdark\b\s*\)/.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 = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
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-14WebUI 的缓存键是**全局常量** `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 侧的前提也钉一下:它确实是全局键(这条差异有据可查)
const webLib = readFileSync(join(ROOT, 'client/electron/src/lib/appearance.ts'), 'utf8');
assert.match(webLib, /agentmail\.background/, '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 = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts'), 'utf8');
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 = readFileSync(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js'), 'utf8');
const resTxt = readFileSync(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt'), 'utf8');
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 = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8');
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 = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8');
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), '弹层不该用壁纸遮盖色(两个语义别混)');
});