Files
MailUI4Agents/client/electron/test/run-all.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

106 lines
4.9 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.

/**
* 判据总入口 —— **全部跑完再算退出码**。
*
* 为什么不再用 `&&` 串起来:
*
* 原先 `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)));
// 自检 2test/ 下每个 *.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);