服务端 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 与命名已就位。 **未验**:壁纸在真机上的渲染 —— 需真机或模拟器。
106 lines
4.9 KiB
JavaScript
106 lines
4.9 KiB
JavaScript
/**
|
||
* 判据总入口 —— **全部跑完再算退出码**。
|
||
*
|
||
* 为什么不再用 `&&` 串起来:
|
||
*
|
||
* 原先 `npm test` 是 `a && b && c …`。这种行为有个不起眼但很贵的后果 ——
|
||
* **前面红一条,后面全部不跑**。于是"只红了一条"看起来像"只有一个问题",
|
||
* 实际上后面那些判据连跑都没跑(这次就真发生了:`background` 红着,
|
||
* `packaging` 从来没跑到过,而它正是能发现"界面改了没重打包"的那条)。
|
||
* 换句话说:`&&` 链下的"全绿"是可信的,**"红"是不可信的**。
|
||
*
|
||
* 现在:每条判据都跑,红的收集起来,最后一起报、一起退出。
|
||
*
|
||
* 另外两条防"判据自己不会跑"的自检(与 process.exit 之后写判据是同一族问题):
|
||
* 1. 清单里的文件必须存在(名字写错 = 静默跳过一条判据);
|
||
* 2. `test/` 下的每个 `*.test.mjs` 都必须在清单里
|
||
* —— 这次 `cross-client-theme.test.mjs` 就是"写好了但没接进套件",
|
||
* 在它进套件之前一直是隐身状态。加了这条,**新增判据忘了接线会直接红**。
|
||
*/
|
||
import { spawnSync } from 'node:child_process';
|
||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(HERE, '..');
|
||
|
||
/**
|
||
* 判据清单:[文件, 额外 node 参数]。
|
||
*
|
||
* `--test` 给用 node:test 写的判据;鸿蒙那条要 `--experimental-strip-types`
|
||
* 才能直接执行 `client/harmony/.../MailGrouping.ts`(判据跑的是客户端真正引用的那份逻辑)。
|
||
*/
|
||
const SUITE = [
|
||
['test/markdown-xss.test.mjs', []],
|
||
['test/narrow-layout.test.mjs', []],
|
||
['test/nav-merge.test.mjs', ['--test']],
|
||
['test/theme.test.mjs', []],
|
||
['test/background.test.mjs', []],
|
||
['test/cross-client-theme.test.mjs', ['--test']],
|
||
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings', '--test']],
|
||
['test/harmony-system-api.test.mjs', ['--test']],
|
||
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
|
||
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings', '--test']],
|
||
['test/build-stamp.test.mjs', ['--test']],
|
||
['test/packaging.test.mjs', []]
|
||
];
|
||
|
||
// 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑)
|
||
const ghosts = SUITE.map(([f]) => f).filter((f) => !existsSync(join(ROOT, f)));
|
||
// 自检 2:test/ 下每个 *.test.mjs 都要在清单里(防"写好了没接线")
|
||
const onDisk = readdirSync(join(ROOT, 'test'))
|
||
.filter((f) => f.endsWith('.test.mjs'))
|
||
.map((f) => `test/${f}`);
|
||
const unwired = onDisk.filter((f) => !SUITE.some(([s]) => s === f));
|
||
|
||
if (ghosts.length || unwired.length) {
|
||
if (ghosts.length) console.error(`清单里的判据文件不存在:${ghosts.join('、')}`);
|
||
if (unwired.length) {
|
||
console.error(`这些判据文件没接进套件(写了却不会跑):${unwired.join('、')}`);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
|
||
/*
|
||
* 自检 3:判据不得写在 `process.exit()` **之后**(pi 提议,2026-09-14)。
|
||
*
|
||
* 自检 1/2 管的是"文件没接线",管不到"检查写在了退出之后" —— 而那正是实际发生过的
|
||
* 第 4 例:4 条玻璃判据被并发写入落到了文件末尾、`process.exit()` 后面,
|
||
* 于是**一条都不执行、也不计入通过/失败**,输出看起来完全正常。
|
||
* 这种事的成因是结构性的(并发写入总是往文件末尾追加),所以它一定会再发生,
|
||
* 而它下一次仍然不报错 —— 静态扫一遍最省事。
|
||
*/
|
||
const buried = [];
|
||
for (const [file, flags] of SUITE) {
|
||
if (flags.includes('--test')) {
|
||
continue; // node:test 那几条没有 process.exit,结构上不会踩这个
|
||
}
|
||
const src = readFileSync(join(ROOT, file), 'utf8');
|
||
const exitAt = src.lastIndexOf('process.exit(');
|
||
if (exitAt >= 0 && /(^|\n)\s*check\(/.test(src.slice(exitAt))) {
|
||
buried.push(file);
|
||
}
|
||
}
|
||
if (buried.length) {
|
||
console.error(`判据写在 process.exit() 之后,永远不会跑(挪到汇总之前):${buried.join('、')}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const reds = [];
|
||
for (const [file, flags] of SUITE) {
|
||
console.log(`\n========== ${file} ==========`);
|
||
const r = spawnSync(process.execPath, [...flags, join(ROOT, file)], { stdio: 'inherit' });
|
||
// 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的
|
||
if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`);
|
||
}
|
||
|
||
console.log(`\n========== 判据汇总 ==========`);
|
||
if (reds.length === 0) {
|
||
console.log(`全部通过(${SUITE.length} 个判据文件:${SUITE.map(([f]) => f.replace('test/', '').replace('.test.mjs', '')).join('、')})`);
|
||
process.exit(0);
|
||
}
|
||
console.error(`红的判据(${reds.length}/${SUITE.length}):`);
|
||
for (const r of reds) console.error(` - ${r}`);
|
||
process.exit(1);
|