Files
MailUI4Agents/client/electron/test/appearance-defaults.test.mjs
JianFeeeee 456ae5a66d 跨端: pi 五条评审落地 —— 产物自证替代时间戳代理、静态判据可到期、判据能读一层标识符、值/来源配对规则、归因不许动别人的树
pi 2026-09-14 的评审(`9839f8a9`)五条,逐条落地;其中 §6 的两条是**核对后已成立**,不重复劳动。

## 1 产物自证:`dist/BUILD_INFO.json`(pi §1)

原来那条判据是"`dist` 比 `src` 新"——**代理变量**,pi 指出两层都靠不住:看不见"构建是否
成功"(实测过),而"`dist` 比 `src` 新"也不等于"dist 是从这份 src 构建的"(`checkout`/`cp`/时钟
都骗得过 mtime;我确实用 checkout 造过一次假红)。现在改成**内容自证**:
`scripts/build-info.mjs` 在构建最后一步写 `{gitRev, gitDirty, srcHash, srcFiles, buildCmd, builtAt}`,
判据重算当前指纹再**精确比对**(`test/build-stamp.test.mjs`)——"代理"两个字没有了,
报错能直接读出两边指纹。`release-linux.sh` 打完包把同一份信息打进日志(一个包自带
"它对应哪个源码状态")。`gitDirty` **只展示不判定**:共享工作区常年是脏的,拿它当红/绿依据
会天天误报。

实测三变异:改 src 内容不重建 → 红(两个指纹都打出来);BUILD_INFO 记成别的提交 → 红;
**`touch`(只动 mtime)→ 绿** —— 旧判据在这里是**假红**,新判据不误伤,这是它严格更好的地方。

## 2 静态判据的**欠账**与到期(pi §5)

"暂时"不是状态、是待办,规范里写下的"暂时"没有任何机制回来读它。改成可机检的形状:
`run-all.mjs` 登记 `STATIC_ONLY`(5 条:`.ets` 只能验形态)+ **必填到期前提**(探针,真跑
`hdc list targets`);汇总打 `RESULT static=N`(**欠账余额**);**前提一旦为真,这些判据当场
变红**并要求"改成行为判据或换更准的前提"。实测:探针恒真 → 5 条同时报"到期";把前提写成
`'vibes'`(未知探针/陈述)→ 套件红。这是"自报条数 < 登记条数"的**时间版本**。

## 3 判据能解析一层标识符(pi §3)

Go 默认值判据原来"值不是字面量 → 判据读不懂 → 红",长期结局是有人做一次无害重构
(`BgDim: defaultDim`)就把判据逼宽、再下一步少核一个字段。现在:字面量直接用;
标识符在**同一文件**查 `NAME = <字面量>`;查不到(跨包/计算/iota)才报"读不懂"。
实测:`BgDim: defaultDim` + `const defaultDim = 12` → **绿**(无害重构不再误伤);
`const defaultDim = baseDim + 0` → 红且报文说"读不懂"。**只解析一层**:再深就是"执行 Go"了。

## 4 §6.7 的可机检分流规则(pi §2)

新增 §6.7.0:**值 → 行为判据,来源 → 静态判据**,理由写成覆盖问题(行为判据只覆盖它跑到的
路径 ⇒ 原理上判不了"有没有别的路绕过去";来源约束要的是全程序可达性 ⇒ 只有读代码能答)。
两条推论:静态判据判值永远差一个反例、行为判据判来源永远差一条路径;**不是强弱,是分工**,
缺任一条那一对就是假判据。附本仓已有的完整样例(P5 命中区:值判据"≥44"+ 来源判据
"应用点必须引用常量"),新增 §6.8 记欠账机制、§8 记共享树归因纪律。

## 5 值/来源配对补齐 + 让位派生(pi §6 的两条)

- 命中区原来只有值那一半:补**来源**判据(`.ets` 里出现 `minHeight: 44` 这类裸数字 → 红,
  报文点出"值判据管数字够不够大、来源判据管用的是不是同一个数字")。变异:写死 44 → 红。
- "`76` 应从条高派生":**核对后已成立**(`NAV_CONTENT_RESERVE = NAV_BAR_HEIGHT +
  NAV_BAR_BOTTOM + 8`,判据也按常量算)。顺手把裸的 `8` 起名 `NAV_CONTENT_GAP`
  (这一族里唯一还需要人判断的数),并加判据钉住**派生关系**:写回字面量 `76` → 红。

## 6 `legacy.bak` 的生命周期(pi §4):**有意永久残留**,并说清代价

pi 质疑成立:判据钉死"没有任何代码读它"⇒ 也没有任何代码能删它。**否决了"点重置外观时删"**:
最可能点重置的人正是外观被接管的那个人,而这份备份是他唯一的旧值,那时删等于把恢复数据
毁在最需要的时刻。所以定为"有意永久残留"(没有代码路径能判定何时安全删除——那取决于人),
并把代价量出来写进 §7.12:值受 `MAX_DATA_URL_BYTES`(2.4MB)约束,**最坏是一张用户原图的
整份副本**,共享机器上即一份别人的外观;要清就手工 `removeItem`。判据钉住这行**同时**给出
"为什么不删"与"怎么删"(变异:删掉删除方法 → 红)。

## 7 归因方法(pi §6 第三条):认错并写成纪律

我当天归因 `narrow-layout` 的 3 条红时用了 `git stash push -- MailView.tsx`,动的是**并发写
作者的未提交改动**。结论对、方法不行:它写共享工作区(别人崩溃/`git add -A` 就丢他的活),
而且只影响 tracked 文件(untracked 的 WIP 还在 ⇒ "干净了"是假干净,结论也可能假)。
纪律写进 CRITERIA.md §8:只读手段(`git show HEAD:path > /tmp/...`、`git worktree add`)或直接问作者。

## 验证

`npm test` 全绿(14 个判据文件 + vitest 266 + typecheck),`RESULT static=5`;
`hvigorw assembleHap` BUILD SUCCESSFUL;`scripts/release-linux.sh` 重打 deb(日志带产物指纹)。
提交前两道**真红**按预期挡了我:改了 src 未重建 → build-stamp 红;重建了 dist 未重打包 →
packaging 红。**未验**(照旧不写成已完成):鸿蒙侧视觉/交互观感、运行期换肤重算(静态判据,
到期前提见 `RESULT static=5`)。

注:本次 `dist`/deb 是在**共享工作区**上构建的,树里含并发写作者未提交的
`CalendarView.tsx`/`index.css` —— 产物里的 `gitDirty: true` + `gitRev` 正是为此留的,
复核时先看那一行。
2026-09-14 16:00:34 +08:00

243 lines
16 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 { readFileSync } from 'node:fs';
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/testROOT = 仓库根
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 = readFileSync(SERVER_MODELS, 'utf8');
// 只取 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 = readFileSync(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts'), 'utf8');
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 = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
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 = readFileSync(join(ROOT, 'client/electron/src/lib/appearance.ts'), 'utf8');
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 = readFileSync(join(ETS, 'model/Appearance.ts'), 'utf8');
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 = readFileSync(join(ETS, 'model/Wallpaper.ts'), 'utf8');
assert.match(wp, new RegExp(`'${s.preset}'`), `服务端默认预设 ${s.preset} 要在鸿蒙的预设清单里`);
// 这句注释在**这份源码**里现在是事实(它宣称"与客户端默认值一致")—— 这条就是它的核对器。
// (运行时是否一致不由此判据保证:见文件头第 1 条的措辞说明。)
const models = readFileSync(SERVER_MODELS, 'utf8');
const comment = /\/\/ DefaultAppearance[\s\S]{0,200}?func DefaultAppearance/.exec(models);
assert.ok(comment, 'DefaultAppearance 上面要有说明注释');
assert.match(comment[0], /一致/, '注释仍在宣称"与客户端默认值一致"(本判据负责让它为真)');
});
test('★ 缓存键按账号分:两端的键都带账号,且都不许退回全局键', () => {
const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
// 键函数:必须把 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 = readFileSync(join(ETS, 'common/AppearanceStore.ets'), 'utf8');
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 = readFileSync(join(ROOT, 'client/electron/src/stores/appearanceSync.ts'), 'utf8');
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 = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
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), '重读不许写盘');
});