Files
MailUI4Agents/client/electron/test/appearance-defaults.test.mjs
JianFeeeee 93697c4061 test(工具链): ★★★ stripComments 两趟正则把真代码当注释吃掉 137 行 —— 改单趟扫描
**现象**:harmony-admin 那条「服务端要注册 GET /auth/me」报红,而
server/cmd/server/main.go:194 **明明写着** r.Get("/auth/me", handler.Me)。

**真因**(不是服务端写错,是读取器错了):
    main.go:78   // 与 /api/v1/agent/* 完全同一份代码
                                          ↑ 这个 /* 在 // 里面
stripComments 原来是**两趟正则**(先块 {/\*[\s\S]*?\*\//g}、后行),
两趟**互相看不见对方** ⇒ 块注释那趟在**还没删行注释**的文本上看到那个 /*,
当块注释开头,一路找下一个 */(在 :214)⇒ **137 行 / 37 条路由注册**
被当注释抹掉,含它正在断言的 r.Get("/auth/me", …)。
全仓另有 12 处同样写法(/me/*、/assets/*、plugins/*…)。

⇒ 失效形状是「**读取器静默少给一段真代码**」(不抛错、不警告),
  症状却出现在**被测对象**上 —— 看起来像"服务端把路由删了"。

**修法**:单趟字符扫描,且状态只用源码(注释内部不参与字符串状态)。
并**补上正则字面量**这一条 —— 漏认的方向是**假绿**:
harmony-device.mjs:59 的 /"bundleName"\s*:\s*"([^"]+)"/ 有 4 个引号(奇数),
打开的"字符串"永不闭合 ⇒ 后面所有注释被当字符串跳过。

五条性质逐条实测:① 行号不变 ② 'http://…' 字符串不被腰斩
③ 注释里的引号不污染状态 ④ 正则字面量被当正则 ⑤ 块注释连文本一起删。
变异测试:把旧实现放回去 ⇒ harmony-admin 确实变红(确认真修好了,
而不是"改的东西恰好没人用")。

**顺带修的三处判据自身缺陷**(都不是源码问题):
· inbox-fallback-poll:原断言钉的是**行尾注释里的字**
  (删掉注释照样绿、塞进 await fetchInbox() 也照样绿)⇒ 改为取
  if (lastTotal === null) { … } 整个分支做结构断言
· inbox-fallback-poll / sse-credentials:裸 readFileSync ⇒ 具名 code()
  (换成更严格的读取后变红,暴露的是判据本来就在判错的对象)
· appearance-defaults:写死包名 ⇒ ourBundle()(deviceprobe 那条在盯这个)
· criteria-hygiene:自检样本「以 // 开头的字面量」被判成写死路径 ⇒
  按**形状**排除,**不按文件/变量名豁免**(该文件自己的注释已写过
  「豁免按名字或目录裁 = 给逃逸指路」)。变异验证:改成真路径仍红。

边界 / 未做:本函数**不区分模板串里的 ${…} 与字符类里的 /**,
失效方向是假绿(少剥注释),与 stripStrings 记的方向一致。
2026-10-03 10:43:11 +08:00

486 lines
30 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.

// 外观的**服务端契约**与**本机缓存的键**:两个客户端的默认值必须等于服务端那三个数,
// 缓存键必须按账号分。
//
// 为什么值得单独一个判据文件:
// 1. 服务端 `DefaultAppearance()` 的注释宣称"与客户端 backgroundStore / themeStore 的
// 默认值一致" —— 在 WebUI 用 24/8 时那句话**是假的**(pi 2026-09-14 更正了自己
// 上一封"数值是审美"的说法:它是契约问题)。
// **措辞要准**(pi 同封指出):这条判据核对的是"**与这份服务端源码的契约一致**",
// **不是**"在跑的那个服务端二进制是 12/4" —— 与"dist 是产物、源码修好≠用户手上的包修好"
// 同构。若服务端由别的流水线构建部署,这条判据对运行时**没有**发言权。
// 2. 默认值决定"新账号的初始外观":服务端"没有记录"时客户端以本地为准推上去,
// 于是**谁先同步谁决定**。24/8 与 12/4 的差别不是审美,是同一个账号在不同客户端
// 登录会得到不同的压暗强度。
// 3. 缓存键是"换账号串味"的成因(全局键 → saved=false 时把上一个账号的外观推上去)。
import { code, prose } from './lib/read.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
// 与其它判据同一个约定:HERE = client/electron/test,ROOT = 仓库根
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const SERVER_MODELS = join(ROOT, 'server/internal/models/models.go');
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
/** 从 Go 源码里读服务端契约(不复制一份数字到判据里 —— 那又会变成"两处各写一套") */
function serverDefaults() {
const src = prose(SERVER_MODELS);
// 只取 DefaultAppearance 函数体,避免匹配到别的结构体字面量
const at = src.indexOf('func DefaultAppearance()');
assert.ok(at > 0, '服务端要有 DefaultAppearance()');
let depth = 0;
let end = at;
for (let i = src.indexOf('{', at); i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
const body = src.slice(at, end + 1);
/*
* pi 2026-09-14:这类"去源码里读值"的判据必须能区分三种情况,且都不许静默放行:
* ① 切不出函数体(改名/挪位置)→ 红(上面的 `at > 0` 已经挡了,这里再加一道空体检查);
* ② 字段**在**但值不是字面量(例如 `BgDim: defaultDim`)→ 报"**读不懂**",红;
* ③ 字段**根本不在**(重排/删掉)→ 红。
* ②③ 分开报,因为修法不一样:②是要人去看服务端怎么算的,③是判据要跟着字段走。
* 静默绿是这类判据最危险的失败方式 —— 与"自报条数 < 登记条数"同族。
*/
assert.ok(body.replace(/[\s{}]/g, '').length > 0,
'DefaultAppearance 的函数体切出来是空的(函数被改名/挪走了?判据要跟着改,别静默放行)');
/*
* pi 2026-09-14 补充的一条:**标识符应该能解析一层**,否则「读不懂就红」的长期结局是
* 有人做一次无害重构(`BgDim: defaultDim`)→ 判据红 → 唯一出路是把判据改宽 →
* 下一步通常是"少核一个字段" → 契约又漂了。
* 所以:字面量直接用;标识符在同一文件里查 `NAME = <字面量>`;查不到(跨包 / 计算 / iota)
* 才报"读不懂"。**只解析一层**:再深就不是"读一个常量"而是"执行 Go"了,那时该报读不懂。
*/
const resolve = (name, reLiteral, identRe, what) => {
assert.ok(new RegExp(`\\b${name}\\s*:`).test(body), `DefaultAppearance 里没有 ${name} 字段了(判据要跟着服务端改)`);
const lit = reLiteral.exec(body);
if (lit) return lit;
const ident = identRe.exec(body);
assert.ok(ident, `${name} 在,但值既不是${what}也不是标识符 —— **判据读不懂**,请人工核对接线。函数体:${body.replace(/\s+/g, ' ')}`);
const identName = ident[1];
/*
* 同一文件里找 `NAME = <字面量>`(也覆盖 const 组里的 `NAME = 12`)。
* **只解析一层**:再深就不是"读一个常量"而是"执行 Go"了 —— 那时该报读不懂,
* 让人来看,而不是判据自己猜。
*/
const declRe = new RegExp(`\\b${identName}\\s*=\\s*([^\\n]+)`, 'g');
const decls = [...src.matchAll(declRe)].map(m => m[1].replace(/\/\/.*$/, '').trim());
const literalOf = what === '数字字面量' ? /^(\d+)$/ : /^"([^"]+)"$/;
for (const v of decls) {
const hit = literalOf.exec(v);
if (hit) return hit;
}
assert.fail(
`${name} 指向标识符 \`${identName}\`,但同一文件里找不到它的字面量定义 —— ` +
`**判据读不懂**(跨包 / 计算 / iota?),请人工核对。找到的声明:${decls.join('、') || '(无)'}`
);
};
const dim = resolve('BgDim', /BgDim:\s*(\d+)/, /BgDim:\s*([A-Za-z_]\w*)/, '数字字面量');
const blur = resolve('BgBlur', /BgBlur:\s*(\d+)/, /BgBlur:\s*([A-Za-z_]\w*)/, '数字字面量');
const theme = resolve('Theme', /Theme:\s*"([^"]+)"/, /Theme:\s*([A-Za-z_]\w*)/, '字符串字面量');
const kind = resolve('BgKind', /BgKind:\s*"([^"]+)"/, /BgKind:\s*([A-Za-z_]\w*)/, '字符串字面量');
const preset = resolve('BgPresetID', /BgPresetID:\s*"([^"]+)"/, /BgPresetID:\s*([A-Za-z_]\w*)/, '字符串字面量');
return {
dim: Number(dim[1]),
blur: Number(blur[1]),
theme: theme[1],
kind: kind[1],
preset: preset[1]
};
}
test('★ 默认外观 = 服务端契约(去 Go 源码里读,不在判据里写死数字)', () => {
const s = serverDefaults();
assert.deepEqual([s.dim, s.blur], [12, 4], '服务端 DefaultAppearance 是 12/4(权威值)');
// WebUI:默认值必须来自共享常量,且等于服务端
const defaults = code(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts'));
assert.match(defaults, new RegExp(`DEFAULT_DIM = ${s.dim}\\b`), `DEFAULT_DIM 要等于服务端的 BgDim=${s.dim}`);
assert.match(defaults, new RegExp(`DEFAULT_BLUR = ${s.blur}\\b`), `DEFAULT_BLUR 要等于服务端的 BgBlur=${s.blur}`);
// 严格:store 里不许再自写一套数字(原来这里写的是 24/8,与服务端不一致)
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
const defBlock = /export const DEFAULT_BACKGROUND: BackgroundState = \{[\s\S]*?\};/.exec(store);
assert.ok(defBlock, 'DEFAULT_BACKGROUND 要能取到');
assert.match(defBlock[0], /dim:\s*DEFAULT_DIM/, 'dim 要引用共享常量,不许自写字面量');
assert.match(defBlock[0], /blur:\s*DEFAULT_BLUR/, 'blur 要引用共享常量');
assert.ok(!/dim:\s*\d/.test(defBlock[0]), `DEFAULT_BACKGROUND 里不许再出现数字字面量(现在:${defBlock[0].replace(/\s+/g, ' ')})`);
// 同步层的兜底值也要引用同一常量(原来 clamp(..., 12, 4) 是另写的一份)
const lib = code(join(ROOT, 'client/electron/src/lib/appearance.ts'));
const clamps = [...lib.matchAll(/clamp\([^)]*\)/g)].map(m => m[0]);
assert.ok(clamps.length >= 4, `clamp 调用要能取到(实际 ${clamps.length} 处)`);
for (const c of clamps) {
assert.ok(!/,\s*\d+\s*\)$/.test(c), `clamp 的兜底值要引用常量,不许写死:${c}`);
}
// 鸿蒙:默认值(在 AppearanceResponse 的字段默认值里)与预设也要与服务端对得上
const ap = code(join(ETS, 'model/Appearance.ts'));
assert.match(ap, new RegExp(`bg_dim: number = ${s.dim}\\b`), `鸿蒙默认压暗要等于服务端 BgDim=${s.dim}`);
assert.match(ap, new RegExp(`bg_blur: number = ${s.blur}\\b`), `鸿蒙默认模糊要等于服务端 BgBlur=${s.blur}`);
assert.match(ap, new RegExp(`theme: string = '${s.theme}'`), `鸿蒙默认主题要等于服务端 Theme=${s.theme}`);
const wp = code(join(ETS, 'model/Wallpaper.ts'));
assert.match(wp, new RegExp(`'${s.preset}'`), `服务端默认预设 ${s.preset} 要在鸿蒙的预设清单里`);
// 这句注释在**这份源码**里现在是事实(它宣称"与客户端默认值一致")—— 这条就是它的核对器。
// (运行时是否一致不由此判据保证:见文件头第 1 条的措辞说明。)
const models = prose(SERVER_MODELS);
const comment = /\/\/ DefaultAppearance[\s\S]{0,200}?func DefaultAppearance/.exec(models);
assert.ok(comment, 'DefaultAppearance 上面要有说明注释');
assert.match(comment[0], /一致/, '注释仍在宣称"与客户端默认值一致"(本判据负责让它为真)');
});
test('★ 缓存键按账号分:两端的键都带账号,且都不许退回全局键', () => {
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
// 键函数:必须把 accountId 拼进去(按块取函数体,不看调用点 —— "配对/解析"那条)
const at = store.indexOf('export function storageKey(');
assert.ok(at > 0, '要有 storageKey 函数');
/*
* ⚠️ 只取**函数体**(第一个 `{` 到配对的 `}`),不能把签名一起算进来 ——
* 我第一版就是从 `export function` 开始切、然后在整段里找 `accountId … STORAGE_KEY_PREFIX`,
* 结果**变异测试抓出了这个判据是假的**:把实现改成 `return STORAGE_KEY_PREFIX;`(退回全局键)
* 时它依然绿,因为参数表里的 `accountId` 已经满足了那个正则。
* 这正是本仓规范第 1 条(判结构要配对/解析,不要靠邻接与窗口)—— 我写规范时没想到
* 它的适用对象也包括"这个函数自己有没有把账号拼进去"。
*/
const bodyStart = store.indexOf('{', at);
let depth = 0;
let end = at;
for (let i = bodyStart; i < store.length; i++) {
if (store[i] === '{') depth++;
else if (store[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
const body = store.slice(bodyStart + 1, end);
/*
* ⚠️ **这里原来有一条正则断言,已删除**(pi 2026-09-14 用反例钉死):
* `const k = STORAGE_KEY_PREFIX + accountId; return STORAGE_KEY_PREFIX;`
* ——"常量 + 账号拼接"确实**在函数体里**,但 `return` 的是全局键。
* 实测:第三版判据对这种写法**仍然全绿**(我跑过),退化和第一版一样完整。
* 教训:**判据的作用对象是"值/行为"时,不要退化成对源码形状的匹配** ——
* 这一族"源码里有没有那个形状"的判据永远差一个反例。
* 权威已经交给行为判据:`test/stores/background.test.ts` 直接断言
* storageKey('acct-a') !== storageKey('acct-b')
* storageKey('acct-a') === 'agentmail.background.acct-a'
* storageKey('') !== 'agentmail.background'(不退回全局键)
* 那三条对"拼了没用上""拼了又丢掉""换个名字的退化"都会红,且不误伤合法写法。
*
* 下面保留的是**别的东西**:不许写死键名(键必须来自 storageKey())。
* 那是"来源"约束,不是"值对不对",正则在这里是合适工具。
*/
/*
* 行为面由 vitest 兜底(`test/stores/background.test.ts` 直接断言
* storageKey('acct-a') === 'agentmail.background.acct-a' 且两个账号不相等)——
* 静态判据只负责"形状",值对不对由真跑一遍的函数说了算。
*/
/*
* 不许写死键名:写入的键只能是 `storageKey()`,或由它算出来的变量(迁移时写 `key`)。
* 我第一版写成"只允许字面量 setItem(storageKey(" —— 结果把迁移那次合法写入
* (`setItem(key, legacy)`,key 就是按账号的键)也判红了;判据要钉**键的来源**,
* 不是调用的字面形状。
*/
const badWrites = [...store.matchAll(/setItem\(([^,)]+)/g)]
.map(m => m[1].trim())
/*
* `key` = 按账号算出来的键;`LEGACY_BACKUP_KEY` = 迁移前留的手工恢复备份
* (第三次被这类"合法写入"误伤:判据钉的是**键的来源**,不是调用的字面形状)。
*/
.filter(k => !/^storageKey\(/.test(k) && k !== 'key' && k !== 'LEGACY_BACKUP_KEY');
assert.deepEqual(badWrites, [], `写入的键必须来自 storageKey()(现在这些不是:${badWrites.join('、')})`);
assert.match(store, /removeItem\(LEGACY_STORAGE_KEY\)/, '旧全局键要被删除(否则下一个账号继续从它继承)');
/*
* 备份键(`LEGACY_BACKUP_KEY`)**只许写、不许读** —— 那是它无害的全部理由:
* 一旦有人读它,它就变成了第二个"继承源",刚修掉的串味会从这条路回来。
*/
const backupReads = [...store.matchAll(/getItem\(([^,)]+)\)/g)].map(m => m[1].trim())
.filter(k => k === 'LEGACY_BACKUP_KEY');
assert.deepEqual(backupReads, [], '备份键只能写不能读(否则它就成了新的"继承源")');
// 读/写都要走按账号的键(`key` 由 storageKey() 算出,见函数开头)
assert.match(store, /const key = storageKey\(accountId\)/, '键要先按账号算出来');
assert.match(store, /let raw = localStorage\.getItem\(key\)/, '读缓存要按账号的键');
assert.match(store, /localStorage\.setItem\(storageKey\(\), JSON\.stringify\(state\)\)/, '写缓存要按账号的键');
// 鸿蒙侧:键同样带账号(两边形状一致,这条差异已经消除)
const ets = code(join(ETS, 'common/AppearanceStore.ets'));
const eAt = ets.indexOf('prefKey(accountId: string)');
assert.ok(eAt > 0, '鸿蒙要有按账号取键的函数');
let d2 = 0;
let e2 = eAt;
for (let i = ets.indexOf('{', eAt); i < ets.length; i++) {
if (ets[i] === '{') d2++;
else if (ets[i] === '}') { d2--; if (d2 === 0) { e2 = i; break; } }
}
/*
* 鸿蒙这半**只能**静态判:`.ets` 在本机跑不起来(编译要 hvigorw,运行要设备,
* 而设备/模拟器在这条链上不可用,见计划文档 §7.21 的实测阻塞)。
* 所以上面那条"值/行为优先"的规则在这里让位于正则 —— 但要把限制写明:
* 这条只证明"函数体里有把账号拼进键的形状",**不证明**拼出来的值对。
*/
assert.match(ets.slice(eAt, e2 + 1), /KEY_PREFIX\s*\+\s*accountId/, '鸿蒙的键也要拼账号(静态判据,值未经运行验证)');
});
test('★ 切账号的顺序:**先按新账号重读本地**,再拉服务端', () => {
/*
* 服务端"没有记录"时 `pull()` 会"以本地为准推上去"——所以重读必须在前,
* 否则推上去的是上一个账号的外观(全局键时代就是这个现象,而且写进了服务端)。
*/
const sync = code(join(ROOT, 'client/electron/src/stores/appearanceSync.ts'));
const sub = /activeId !== prev\.activeId\)[\s\S]{0,400}?\}\);/.exec(sync);
assert.ok(sub, '要能取到账号切换的处理块');
const block = sub[0];
const reloadAt = block.indexOf('reloadForAccount()');
const pullAt = block.indexOf('.pull()');
assert.ok(reloadAt > 0, `切换账号要先重读本账号的缓存:${block.replace(/\s+/g, ' ')}`);
assert.ok(pullAt > 0, '切换账号仍然要拉服务端');
assert.ok(reloadAt < pullAt, 'reloadForAccount() 必须在 pull() 之前(顺序就是这条判据的全部意义)');
// 重读本身不许落盘(它只是把本账号已有的值读回来,不是用户的改动)
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
const rAt = store.indexOf('reloadForAccount: () => {');
assert.ok(rAt > 0, 'store 要提供 reloadForAccount');
const rBlock = store.slice(rAt, store.indexOf('}', store.indexOf('set(next)', rAt)));
assert.ok(!/persist\(/.test(rBlock), '重读不许写盘');
});
/**
* ★ 预设清单的 **id 与顺序** 必须两边一致(pi 2026-09-14 提的跨端耦合)。
*
* 原先只钉了"服务端默认预设要在鸿蒙清单里"(下面那条)与"预设色板的值两边相同",
* 但**id 集合与顺序**没钉:鸿蒙漏一档 → 用户在那个档上看到的是"无背景"(信息对等缺口,
* 不是入口缺口);顺序不同 → 两端的选图/选预设界面顺序不一致,用户以为选错了。
* 这与 `WorkCard` 字段集、`TABS` 顺序是同一类耦合:**一边改要同时改另一边**。
*/
test('★ 预设:鸿蒙 PRESET_IDS 与 WebUI PRESETS 的 id 与顺序逐项一致', () => {
const wp = code(join(ETS, 'model/Wallpaper.ts'));
const listM = /export const PRESET_IDS: string\[\] = \[([^\]]*)\]/.exec(wp);
assert.ok(listM, '找不到 PRESET_IDS(鸿蒙的预设清单)');
const harmonyIds = [...listM[1].matchAll(/'([^']+)'/g)].map(m => m[1]);
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
const presetBlock = /export const PRESETS[^=]*=\s*\[([\s\S]*?)\];/.exec(store);
assert.ok(presetBlock, '找不到 WebUI 的 PRESETS');
const webIds = [...presetBlock[1].matchAll(/id:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(harmonyIds, webIds,
`两端的预设清单不一致。\n 鸿蒙:${harmonyIds.join('、')}\n WebUI:${webIds.join('、')}\n` +
'顺序也是契约:顺序不同会让两端的选择界面看起来"选错了"。');
});
/* ═══════════ 三个设备实测撞出来的真 bug 的**回锚**判据 ═══════════ */
/*
* 这三条都是 2026-09-19 在真机上撞出来的,且**静态判据全都绿**:
* 静态层判的是"字段对不对""逻辑算得对不对",而它们坏在
* 「数据对、界面不动」「请求被服务端拒收」「内容被顶出屏幕」这三种形状上。
*
* 每条都锚在**能复现该 bug 的那个具体形状**上,不是为了凑数。
*/
test('★ 回锚:PUT 上去的 payload 不得带服务端不认识的字段(响应类型 ≠ 请求类型)', () => {
/*
* 真 bug:`payloadFromLocal` 原先返回 `AppearanceResponse` —— 那个类型是 **GET 的响应**,
* 带着 `has_image` / `image_bytes` / `saved` 三个字段。而服务端的 `Decode()`
* 是 `DisallowUnknownFields()`(严格,有意为之)⇒ **每一次外观保存都 400**。
*
* 症状极隐蔽:本地 @State 立刻变 ⇒ 肉眼看着像成功了;
* 只有看 hilog 的 HTTP 状态码才发现 400。(修好后服务端访问日志是 200。)
*
* 判据形状:请求类型里**不许**出现任何只属于响应的字段。
* ★ 为什么不是"断言有 AppearancePayload 这个类"那种弱判据:
* 那样只要类名还在就绿,而"payload 又用回响应类型"照样漏。
*/
const src = code(join(ETS, 'model/Appearance.ts'));
const payloadBlock = src.match(/export class AppearancePayload \{([\s\S]*?)\n\}/);
assert.ok(payloadBlock, '要有 `AppearancePayload`(PUT 请求体专用类型)');
const fields = [...payloadBlock[1].matchAll(/^\s{2}(\w+):/gm)].map(m => m[1]);
assert.deepEqual(fields.sort(), ['bg_blur', 'bg_dim', 'bg_kind', 'bg_preset_id', 'theme'],
'★ PUT 的字段集必须**恰好**是服务端 `models.Appearance` 认识的那五个 —— ' +
'多一个就被 DisallowUnknownFields 拒收(400)。' +
`实际:${fields.join('、')}`);
/* 反向:那三个响应专属字段不许回到 payload 里 */
for (const bad of ['has_image', 'image_bytes', 'saved']) {
assert.ok(!new RegExp(`AppearancePayload[\\s\\S]{0,300}?\\b${bad}\\b`).test(src),
`★ \`${bad}\` 是 GET 响应字段,绝不能出现在 PUT 的 payload 类型里(会导致 400)`);
}
/* 函数签名也要跟上(返回类型写错同样会漏字段) */
assert.match(src, /export function payloadFromLocal\([^)]*\): AppearancePayload/,
'★ `payloadFromLocal` 必须返回 `AppearancePayload`(不是 `AppearanceResponse`)');
});
test('★ 回锚:改了外观必须**发布变更事件**(否则 MainPage 的画布不重算)', () => {
/*
* 真 bug:在「我的」页切档位 → 色板出现、状态显示「已同步」、服务端也真存了,
* 而**页面背景一点没变**。因为 `MainPage` 的 `bgPlan` 只在启动时算一次。
*
* 修法:`SettingsPage` 用 `AppStorage` 发布 revision,`MainPage` 用
* `@StorageProp + @Watch` 接住并重算。
*
* 判据断**两端都接上了**(只写发布方或只写订阅方,功能都是死的):
*/
const settings = code(join(ETS, 'pages/SettingsPage.ets'));
const main = code(join(ETS, 'pages/MainPage.ets'));
const KEY = 'agentmail.appearance.revision';
assert.ok(settings.includes(KEY) && /AppStorage\.setOrCreate/.test(settings),
'★ 改外观后要**发布**变更(`SettingsPage` 写 AppStorage)—— 不发布则 MainPage 永远不知道');
assert.ok(main.includes(KEY) && /@StorageProp\([^)]*\)\s*@Watch\(/.test(main),
'★ `MainPage` 要**订阅**该变更并带 `@Watch` —— 只发布不订阅等于没做');
/* 计数而不是布尔:连改两次也要各触发一次(布尔 true→true 不发通知) */
assert.match(settings, /prev\s*\+\s*1|appearanceRev\s*\+\s*1/,
'★ 必须是**计数器**(每次 +1):布尔从 true 再设 true 不产生变化通知,' +
'那正是"改了没反应"的经典形状');
});
test('★ 回锚:`@State` 持有对象时,改内部字段必须另加原始类型触发器', () => {
/*
* 真 bug(最隐蔽的一个):`bgPlan` 是 `@State BackgroundPlan`,但 ArkTS 的
* `@State` **观察不到类内部字段**的变化 —— 而 `WallpaperLayer()` 读的正是
* `this.bgPlan.kind` / `.layers`。于是 `applyAppearance()` 赋了新 plan 之后,
* **壁纸层不重渲染**,屏幕一直停在最初的 `kind=none`。
*
* 实测证据(hilog,同一次启动相差 100ms):
* `Appearance: sync: bgKind=preset … hasImg=true` ← 数据是对的
* `Wallpaper: kind=none layers=0 active=false` ← 渲染读到的还是旧值
*
* 修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,
* 并在 `WallpaperLayer()` 的**条件表达式里消费它**。
*
* ★ 判据必须同时断两半 —— 这是本条的全部价值所在:
* 只加计数器而渲染不读它 ⇒ 仍然不重渲染(我第一版就差点这样);
* 只读它而没人 +1 ⇒ 永远同一值,也白搭。
*/
const main = code(join(ETS, 'pages/MainPage.ets'));
assert.match(main, /@State bgContentRev: number/,
'要有原始类型的触发器(`@State bgContentRev: number`)—— ' +
'ArkTS 的 @State 观察不到对象内部字段的变化');
assert.match(main, /this\.bgContentRev = this\.bgContentRev \+ 1/,
'★ 每次算完壁纸 plan 必须 +1(只声明不加,触发器永远不变)');
assert.match(main, /this\.bgPlan\.kind === 'preset'[^)]*&&[^)]*bgContentRev/,
'★ **渲染处必须读它**(写在条件表达式里)—— 不读则 Builder 不会重跑,' +
'加了计数器也没用。ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染。');
});
/* ═══════════ 设备层:上面那条正则**证明不了**的东西 ═══════════ */
/*
* ★★ 2026-10-01 真机补上(MatePad Pro / MRDI-W00,HarmonyOS NEXT API 26)。
*
* 上面「缓存键按账号分」那条的**鸿蒙半边只能正则**:
* `assert.match(..., /KEY_PREFIX\s*\+\s*accountId/)` —— 它证明的是
* **函数体里有把账号拼进键的形状**,不证明**真的写出了带账号的键**。
* 那行注释自己就写明了这个限制("值未经运行验证"),本条来把它兑现。
*
* ★ 为什么这条值得存在,而不是把 `prefKey` 抄进 node 里跑:
* 正则要判对的是**`.ets` 里那个函数的真实行为**。抄一份到 node 里,
* 验的是抄件,`.ets` 改坏了它照样绿 —— 正是本文件第 2 条注释里
* 那个"退化"的教训("判据的作用对象是值/行为时,不要退化成对源码形状的匹配")。
* 所以直接读**设备上真写出来的那个文件**。
*
* 真机实测(本条判的就是这个形状):
* /data/app/el2/100/base/com.jianf.agentmail/haps/entry/preferences/agentmail_appearance
* <preferences version="1.0"><string key="appearance.acct_1789713971667_3997">{"theme":"light",…}</string></preferences>
* ⇒ 键 = `appearance.` + **真实账号 id**,账号确实在键里。
*/
test('★ 设备:外观缓存**真的**按账号落了盘(不是只看着像)', async (t) => {
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条不跑(上面静态层仍把住形状与接线)');
}
const CRIT = 'appearance-cache-key-on-device';
if (D.busyStreak(CRIT) >= D.busyLimit()) {
return t.skip(`设备连续 ${D.busyLimit()} 轮忙 —— 本条本次不跑(别无限礼貌下去)`);
}
/*
* ★★ 2026-09-19 补:**锁屏**也要走"忙"这条路,不能变成硬红。
*
* 起因(真机上撞到的):平板息屏后再来跑本条,`launchOurApp` 返回 false,
* 原来那句 `assert.ok(await D.launchOurApp(hdc), '要能拉起应用')` 直接红 ——
* 而**设备锁着跟本应用的外观缓存毫无关系**,它是"要人输密码"的环境状态。
* 判据去红一个跟它无关、且**只有人能解**的东西,是假红;
* 而且这类假红会把到期闸的名声搞坏(真出事时没人当真)。
*
* ⚠️ 但**不许无界礼貌**:跟"设备忙"同一条纪律 —— 连续 K 轮拉不起来就变红,
* 提醒人来接管(解锁,或显式决定这台设备上不跑行为判据)。
* 判据在 K 轮之内是礼貌,K 轮之外是闹钟。
*/
if (!await D.launchOurApp(hdc)) {
const { streak, over } = D.noteBusySkip(CRIT);
if (over) {
assert.fail(
`设备连续 ${streak} 轮拉不起应用(本条行为判据**连续 ${D.busyLimit()} 轮以上没真跑**)。\n` +
` 先分清是**锁屏**还是真的坏了:锁屏时 aa start 会报 ` +
`'10106102 … The device screen is locked',要人上滑解锁后再跑。\n` +
` 若解锁后仍然起不来 ⇒ 那才是本应用的问题,不该再当环境跳过。\n` +
` (账本:.tmp/harmony-busy-skips.json,已连续 ${streak} 轮;跑成一次即清零。)`);
}
return t.skip(`这次拉不起应用(设备多半锁着,需人解锁)—— 本条本次不跑` +
`(连续第 ${streak}/${D.busyLimit()} 轮,超过就要变红)`);
}
D.noteRan(CRIT);
assert.ok(await D.backToMain(hdc), '要能回到主界面');
/*
* ★ 键的位置**别猜**:Preferences 的落盘路径随 haps 层级而变
* (`/data/app/el2/100/base/<bundle>/preferences/` 与
* `/data/app/el2/100/base/<bundle>/haps/entry/preferences/` 都见过),
* 而 `find` 一次就拿到真实位置。猜路径的那版在真机上扑空过。
*/
/*
* ★ 2026-10-03 包名改走 `ourBundle()`(读 AppScope/app.json5),不再写死。
* 起因:`harmony-deviceprobe` 那条「包名不许在判据里四处漂移」对此报红 ——
* 它防的是**一次真实事故**(包名改过一次,几处判据各写各的,有一处没跟)。
* ⇒ 这里正是它要拦的形状,不是误报。
* 下游 `${BUNDLE}` 用于拼 find 路径,所以取不到时必须当场说清(不能静默拼出
* `/base/null/…` —— 那会变成"设备上找不到文件"这种看不出原因的红)。
*/
const BUNDLE = D.ourBundle();
assert.ok(BUNDLE, 'ourBundle() 读不到 AppScope/app.json5 里的 bundleName(判据形状不对)');
const found = D.shellOn(hdc, `find /data/app/el2/100/base/${BUNDLE} -name 'agentmail_appearance' -type f 2>/dev/null | head -1`);
const path = (found.stdout || '').trim().split('\n').pop();
if (!path) {
const { over } = D.noteBusySkip(CRIT);
if (over) assert.fail('设备上始终没有 agentmail_appearance —— 改外观后本机缓存没落盘?');
return t.skip('设备上还没有 agentmail_appearance(还没改过外观?)—— 本次不跑');
}
const cat = D.shellOn(hdc, `cat '${path}'`);
assert.equal(cat.status, 0, `读 ${path} 失败:${(cat.stderr || '').trim()}`);
const xml = cat.stdout || '';
/*
* ★ 从**落盘的键**里取账号段,而不是从别处抄一个账号 id 来比对 ——
* 判据要验的是"键里有账号",它唯一可靠的观察点就是键本身。
* (去 /me 拿当前账号再断言"键里含它"是另一件事:那还掺了"当前登录的是谁"。)
*/
const keys = [...xml.matchAll(/<string key="([^"]*)"/g)].map(m => m[1]);
assert.ok(keys.length > 0,
`${path} 里一个 <string key=…> 都没有 —— 改外观后本机缓存没落盘?读到:${xml.slice(0, 300)}`);
const appearanceKeys = keys.filter(k => k.startsWith('appearance.'));
assert.ok(appearanceKeys.length > 0,
`落盘的键里没有 appearance.* 前缀的(键必须以 appearance. 开头):${keys.join('、')}`);
for (const k of appearanceKeys) {
const account = k.slice('appearance.'.length);
assert.ok(account.length > 0,
`★ 键 ${k} 的账号段是**空的** ⇒ 这就是"全局键"退化(换账号会串味,` +
'WebUI 侧那个 bug 的同一形状)');
// 账号段必须像个 id,而不是前缀/空串/布尔
assert.ok(/^[A-Za-z0-9_.:-]+$/.test(account),
`键 ${k} 的账号段 ${JSON.stringify(account)} 不像个账号 id(键的形状变了?)`);
}
D.noteRan(CRIT);
/*
* ★ 结算记账:这条判据已从 `STATIC_ONLY` 的到期点名里划掉
* (理由与 `harmony-admin` 那次一样:设备可用 ⇒ 前提成立 ⇒ 必须当场还)。
* 但"划掉"本身是纯记账动作,行为条在不在跑就没人管了 ——
* 所以这里只在**全部断言之后**留一条本机记录,供 `run-all.mjs` 的
* 闸 (ii) 出示(键名必须与那边 `SETTLED_FROM_STATIC` 里写的同串)。
* ⚠️ 位置要紧:红了也记 = 把没验过的当成验过了,比不记更糟。
*/
D.noteBehavioralRan(CRIT);
});