/** * 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\('\/me\/appearance'\)/, 'GET 路径'); assert.match(api, /put\('\/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'];