pi 读完 `model/Wallpaper.ts` 后指出四处,全部处理。这一提交同时改了
`client/harmony/` 与 `client/electron/`(跨端改动),所以 subject 按新约定自报家门。
## 1 预设色板不随主题 —— **类别判错了:不是"未验",是机制上确定不同**
我上一版把"深色档预设"记成"观感未验"。pi 指出:WebUI 的 `.bg-preset-*` 写的是
`rgb(var(--c-blue-100))`,而 `--c-*` 在 `.dark` 里整体换了一套(blue-100 → `30 43 67`),
所以 **WebUI 的预设自动随主题变**;这边只有浅色那套 = 深色主题下"浅色渐变垫在深色系统表面之下",
正是这一整轮在治的病。**它不需要真机就能判**(机制写在代码里)—— 我把可判的东西
记成了"未验",这跟上一轮把"没做"写成"没验"是同一类错。
选 pi 倾向的那条(跟随主题,与 WebUI 一致):
- 色板两套:`LIGHT_*` 取 CSS `:root`、`DARK_*` 取 CSS `.dark`;`paletteFor(dark)` 选一套,
`layersFor(id, dark)` 按主题出层;
- `isDarkMode(theme, systemColorMode)` 放在纯逻辑里:选了 dark/light 就照办,
`system` 看系统当时的 `colorMode`(锚到 SDK:`COLOR_MODE_DARK = 0` / `COLOR_MODE_LIGHT = 1`;
读不到按浅色,与 WebUI 的 `:root` 默认一致);
- 系统深浅从 `resourceManager.getConfigurationSync().colorMode` 读
(`Context` 基类没有 `config`;`UIAbilityContext.config` 要转型;两个枚举取值一致,都核过 SDK);
- 判据:两套值与 `:root`/`.dark` **逐个相等**;每个预设的深浅两套**必须真的不同**
(否则"两套"是抄了两遍);网格线色也要换;`isDarkMode` 五种输入。
- **未做**:运行期间改系统深浅色不会自动重算(要重进页面)——系统侧正确做法是订阅
`applicationContext.on('environment', …)`,记在 §7.17b。
变异:`DARK_BLUE_100` 偏一位 → 红;`paletteFor` 永远返回浅色(= 我原来那个状态)→ 红;
`isDarkMode` 把系统深浅记反 → 红;页面把深浅写死成 false → 红。
## 2 手写色清册**跨文件按类扫**(原 A2 只保护 `Theme.ets`)
`Wallpaper.ts` 也有手写色。若对照是"按名字枚举"的,第 15 个色就会逃掉 ——
与 A2 要防的是同一件事,只是换了文件。现在一份清册按类扫:全 `ets/` 树里每个
`X: string = '#RRGGBB'` 都必须登记(Theme 的品牌/业务语义色,或预设色板 ——
后者常量名必须带 `LIGHT_`/`DARK_` 前缀,值由 CSS 两段比对负责)。反向也判清册过期。
变异:`Theme.ets` 加未登记色 → 红;`Wallpaper.ts` 加未登记色 → 红;
加一个"看着合规"的 `DARK_EXTRA` → 红。
## 3 `TMPDIR` 互踩(pi 提出)
这个 worktree 可能同时有多个 agent 跑构建,而 fpm 会把 291MB 的 `linux-unpacked`
**整份复制**进 `TMPDIR` —— 撞车就是随机的产物损坏。`whoami` 区分不开(大家都是 root),
所以按**会话**分家:`TMPDIR=$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}`
(进了 `npm run build:linux` 与 BUILD.md 的手敲命令;普通终端退化成"用户+PID")。
## 4 提交归属变成**跑判据就看得出来**(pi 给的形状)
新的 `test/commit-hygiene.test.mjs`:扫最近 40 条提交,**同时改两侧目录**的提交
必须在 subject 里自报家门(`跨端:`)。两条防腐:基线 = 该判据文件自己的引入提交
(**历史不改**,规则管从今往后);分类逻辑拿合成输入自检
(未标注的混合提交必须判红、标注过的不许红)——否则"解析没跑起来"时它会全绿。
变异:`COMMIT_HYGIENE_BASELINE` 指到老提交 → 历史里那两个被卷进去的提交立刻判红。
## 验证
`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0
(12 个判据文件全绿 + vitest 258/258;`commit-hygiene` 在本提交落地后基线生效)。
558 lines
34 KiB
JavaScript
558 lines
34 KiB
JavaScript
/**
|
||
* 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-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 档要真的画出层来(这就是那个缺口的判据)');
|
||
assert.equal(preset.scrim, 0, 'preset 档不压暗(压暗是给图片用的)');
|
||
|
||
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\)/, '图片要铺满(不是拉伸变形或留白)');
|
||
// 压暗用系统遮罩色 + 服务端浓度
|
||
assert.match(main, /\.backgroundColor\(Theme\.overlay\)[\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-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 = 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}`);
|
||
});
|