Files
MailUI4Agents/client/electron/test/harmony-appearance.test.mjs
JianFeeeee 2a6ad93ec0 feat(harmony): P4 —— 外观(主题 + 壁纸)跟着账号走
服务端 2026-09-13 起就是外观的权威(账号级 `/api/v1/me/appearance`),WebUI 接好了,
**鸿蒙这边此前完全没接**。这一期补上,并把"谁覆盖谁"的规则做成可判据的纯逻辑。

## 改了什么

- `api/AppearanceApi.ets`:`GET/PUT /me/appearance`、`POST /me/appearance/image`、
  `GET /me/appearance/image`(图片带认证取回本体:不用 `?token=`,也不让 Image 直连 http)。
- `api/ApiClient.ets`:新增 `getBytes()`(按 ARRAY_BUFFER 收)—— 复用 `request<T>` 会当场炸,
  因为它假定响应是 JSON(`JSON.parse`)。
- `model/Appearance.ts`(纯逻辑,判据直接执行):归一化 / PUT 报文 / **合并决策** /
  模糊值→系统材质档次 / 主题→系统色彩模式 / 遮罩浓度 / 状态文案。
- `common/AppearanceStore.ets`:落地副作用 —— 主题交给**系统**(`setColorMode`,不自己维护
  深色色值)、壁纸取回 `PixelMap`、缓存**按账号**分键(`appearance.<accountId>`)。
- 入口两处:`MainPage`(进主界面就应用 —— 只在设置页生效的话"一进主界面就变回去",
  WebUI 侧踩过)与 `SettingsPage` 新增「外观」段(三档主题 + 同步状态「已同步 / 仅本机」)。

## 为什么这么写(两条最贵的规则)

1. **服务端"没有记录"时以本地为准**(`saved === false`):服务端这时回的是一份*默认值*,
   拿它覆盖本地 = 把用户已有的主题/壁纸抹掉(WebUI 原话:每个老用户升级后第一次登录
   都会发现被重置)。正确动作是把本地那份推上去。
2. **降级必须可见**(`local-only` → 显示「仅本机」):否则用户以为换设备也能带走。

## 判据(新增 11 条,已接进 run-all;套件 10 → 11 个判据文件)

归一化(脏值/越界/小数/NaN 退回默认);`image` 档无图 → 退回 `none`;PUT 报文蛇形字段名;
★服务端无记录 → 以本地为准且**一个字段都不能被默认值顶掉**;服务端有记录 → 以服务端为准但
**不擦掉**本地那张服务端还没有的图;离线状态可见且三种状态文案互不相同;
★模糊值→系统材质档次(与 SDK 的 `BlurStyle` 成员**逐一比对**);
主题→色彩模式(数值与 SDK 的 `ConfigurationConstant.ColorMode` **逐一比对**);
★路径必须**相对基地址**(WebUI 那条"整套同步从来没生效过而单测全绿"的坑);
缓存键**带账号**;两处入口都真的应用。

变异验证(6 种,均判红):默认值覆盖本地 / 不管"image 档但服务端无图" / 材质档次自造名字 /
路径多写 `/api/v1` / 缓存键不带账号 / 深浅色彩模式数值写反。

## 判据抓到的两个真 bug

- `snapshotFromResponse` 在字段缺失时给 `bgDim = 0`,而 WebUI 语义是退回 12 ——
  ArkTS 反序列化把缺失字段留成**类里写的默认值**,"字段不在"与"字段是 0"分不开。
  已把默认值对齐 WebUI 的 `clamp(..., dflt)` 语义(并让 `saved` 默认 false = 安全的那一侧)。
- 主题落地按"0=浅色、1=深色"写的 `setColorMode` —— **正好反了**
  (SDK:`COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`),选深色会切成浅色。
  靠判据去 SDK 枚举文件读数比对发现;映射已搬进纯逻辑 `colorModeValue`,
  从"某处有个 setColorMode 调用"变成"可判据的行为"。

## 验证 / 未验

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0(11 个判据文件全绿 + vitest 258/258)。
套件自检又抓到一次"判据写好没接进套件"(新文件第一版漏了 run-all),已修。

**未做**:壁纸**上传**入口(选图 → `POST /me/appearance/image`)—— 需要 picker,API 与命名已就位。
**未验**:壁纸在真机上的渲染 —— 需真机或模拟器。
2026-09-14 14:19:26 +08:00

248 lines
15 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 { 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');
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'];