Files
MailUI4Agents/client/electron/test/appearance-defaults.test.mjs
JianFeeeee 05e9583fe5 test(预设): 钉住两端预设清单的 id 与顺序;更正"模糊只由壁纸层负责"这条被撤回的口径
pi 2026-09-14 的三处答复,逐条处理:

1. **§1 预设渲染(他担心的信息对等缺口)实际不存在**:鸿蒙侧 `model/Wallpaper.ts` 已有
   `PRESET_IDS = ['aurora','dusk','mint','sand','ink','mesh']` + 标签 + 归一化 + `layersFor`,
   计划文档 §P4 行也早写着"预设 6 档都能画出来 ✅"。所以 P4c(上传入口)不必让位。
   **但缺口在判据上**:原先只钉了"服务端默认预设要在鸿蒙清单里"与"预设色板的值两边相同",
   **id 集合与顺序没钉**。新增判据:`PRESET_IDS` 必须与 WebUI `PRESETS` 的 id **与顺序**逐项一致
   (顺序也是契约:顺序不同会让两端的选择界面看起来"选错了")。变异验证:把 aurora/dusk 对调 → 红。

2. **§3 模糊口径**:他说判据"现在是后者"(只有导航必须有材质)—— 实际**两条都在**
   (`harmony-appearance.test.mjs`:壁纸层不许有 `backgroundBlurStyle`、也不许有任何 `blur(`;
   导航条必须有 `.backgroundBlurStyle(Theme.navMaterial)`)。所以这里不需要改代码。
   需要改的是**文档里的规则原文**:`docs/HARMONY-ALIGN-PLAN.md` 第 9 行那句
   "模糊只由壁纸层负责"是**被撤回的原话**(它其实是 WebUI 的架构结论:WebUI 壁纸自带
   `filter: blur()`,所以浮在它上面的面再 `backdrop-filter` 就是糊第二遍),已换成两段式规则
   "同一张底只许被模糊一次 / 模糊该出现在背后是可变内容的层",并把撤回本身留痕。

3. **§3 补的第二件(语义转换)**:§7.12 的表里新增一行 —— WebUI 的 `bg_blur` 是**像素半径**、
   作用在壁纸图层;鸿蒙**没有对应物**,栏上的系统材质档位是唯一一次模糊,壁纸层不糊。
   写清"不是同一个物理量,`bg_blur=8px` 与档位对不上不是 bug",否则以后必被当 bug 报。
2026-09-14 16:59:16 +08:00

266 lines
17 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` +
'顺序也是契约:顺序不同会让两端的选择界面看起来"选错了"。');
});