服务端 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 与命名已就位。 **未验**:壁纸在真机上的渲染 —— 需真机或模拟器。
248 lines
15 KiB
JavaScript
248 lines
15 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 { 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'];
|