Files
MailUI4Agents/client/electron/test/stores/background.test.ts
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

316 lines
14 KiB
TypeScript
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.

import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_BACKGROUND,
LEGACY_STORAGE_KEY,
LEGACY_BACKUP_KEY,
MAX_DATA_URL_BYTES,
readStored,
storageKey,
applyBackground,
clampBlur,
clampDim,
normalizeBackground,
useBackgroundStore
} from '../../src/stores/backgroundStore';
/**
* 背景 store 的行为锁。
*
* 这里测的是**纯逻辑与 DOM 写入**(不需要真浏览器):
* - 脏数据不能让界面白屏localStorage 可能被手改、也可能来自旧版本)
* - 数值必须被夹在合法区间(滑杆之外的写入路径只有代码,但状态可能来自磁盘)
* - CSS 变量的写入/清理必须成对 —— 残留的 `--bg-image` 会让「关掉背景」
* 之后仍然显示上一张图
*/
const presetClasses = () =>
Array.from(document.documentElement.classList).filter(c => c.startsWith('bg-preset-'));
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-bg');
document.documentElement.removeAttribute('style');
document.documentElement.className = '';
});
/** 从当前工作目录往上找仓内文件vitest 里 `import.meta.url` 不是 file: 方案,别指望它) */
function findDoc(rel: string): string {
let d = process.cwd();
for (let i = 0; i < 6; i++) {
const p = join(d, rel);
if (existsSync(p)) return p;
const up = dirname(d);
if (up === d) break;
d = up;
}
throw new Error(`找不到 ${rel}(从 ${process.cwd()} 往上找了 6 层)—— 判据要能自己说清找的是哪个文件`);
}
describe('数值夹取', () => {
it('压暗与模糊都被夹在声明区间内', () => {
expect(clampDim(-10)).toBe(0);
expect(clampDim(999)).toBe(80);
expect(clampDim(30)).toBe(30);
expect(clampBlur(-1)).toBe(0);
expect(clampBlur(100)).toBe(24);
expect(clampBlur(10)).toBe(10);
});
it('非数值退回默认值而不是 NaN', () => {
// NaN 会写成 `--bg-dim: NaN%`,整条声明失效 —— 表现为滑杆无效但不报错
expect(clampDim(Number.NaN)).toBe(DEFAULT_BACKGROUND.dim);
expect(clampBlur(Number.NaN)).toBe(DEFAULT_BACKGROUND.blur);
});
});
describe('脏数据归一化', () => {
it('非对象输入退回默认', () => {
for (const bad of [null, undefined, 42, 'x', []]) {
const n = normalizeBackground(bad);
expect(n.kind).toBe('none');
expect(n.presetId).toBe(DEFAULT_BACKGROUND.presetId);
}
});
it('未知预设 id 退回默认预设', () => {
expect(normalizeBackground({ presetId: 'no-such-preset' }).presetId).toBe(
DEFAULT_BACKGROUND.presetId
);
});
it('★ 交互进入「图片」档时必须留住(否则上传控件永远不出现)', () => {
// 这是线上真实缺陷:点「图片」→ commit 里那次严格归一化把 kind 折叠回 none
// → 上传控件(只在 kind==='image' 下渲染)从不出现,自定义图片完全不可达,
// 而且背景被顺手关掉(用户看到的正是"点了没反应、背景还没了")。
useBackgroundStore.getState().reset();
useBackgroundStore.getState().setKind('image');
expect(useBackgroundStore.getState().kind).toBe('image');
// 反向对照:没有图片数据时**不该**真的铺开背景(静止态的不变量仍在)
expect(document.documentElement.dataset.bg).toBe('off');
expect(document.documentElement.style.getPropertyValue('--bg-image')).toBe('');
});
it('★ 瞬态里的空图片状态被持久化后,重读必须退回 none静止态不放松', () => {
localStorage.setItem(storageKey(''), JSON.stringify({ kind: 'image', imageDataUrl: '', dim: 30, blur: 10 }));
const back = readStored();
expect(back.kind).toBe('none');
// 用户接着选图片仍能成功(不是"坏状态卡死"
useBackgroundStore.getState().setImage('data:image/jpeg;base64,EEEE');
expect(useBackgroundStore.getState().kind).toBe('image');
expect(document.documentElement.dataset.bg).toBe('on');
});
it('kind=image 但没有可用图片时退回 none', () => {
// 图片可能被浏览器清理或写坏。留一个空壳 image 状态会让界面显示「已选图片」
// 却什么都没有 —— 不如退回 none至少状态是诚实的
expect(normalizeBackground({ kind: 'image', imageDataUrl: '' }).kind).toBe('none');
expect(normalizeBackground({ kind: 'image', imageDataUrl: 'https://x/y.png' }).kind).toBe('none');
});
it('合法 data URL 被保留', () => {
const url = 'data:image/jpeg;base64,AAAA';
const n = normalizeBackground({ kind: 'image', imageDataUrl: url });
expect(n.kind).toBe('image');
expect(n.imageDataUrl).toBe(url);
});
it('越界数值被夹回区间', () => {
const n = normalizeBackground({ dim: 500, blur: -5 });
expect(n.dim).toBe(80);
expect(n.blur).toBe(0);
});
});
describe('写 DOM', () => {
it('预设背景写入 data-bg、预设类与两个数值变量', () => {
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'dusk', dim: 40, blur: 12 });
const root = document.documentElement;
expect(root.dataset.bg).toBe('on');
expect(presetClasses()).toEqual(['bg-preset-dusk']);
expect(root.style.getPropertyValue('--bg-dim')).toBe('40%');
expect(root.style.getPropertyValue('--bg-blur')).toBe('12px');
// 预设的渐变由 class 提供,不得写 --bg-image否则会盖住 class 的定义)
expect(root.style.getPropertyValue('--bg-image')).toBe('');
});
it('切换预设时清掉上一个预设类', () => {
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'aurora' });
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'mint' });
expect(presetClasses()).toEqual(['bg-preset-mint']);
});
it('图片背景写入 --bg-image 并清掉预设类', () => {
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'preset', presetId: 'aurora' });
applyBackground({
...DEFAULT_BACKGROUND,
kind: 'image',
imageDataUrl: 'data:image/png;base64,BBBB'
});
const root = document.documentElement;
expect(root.style.getPropertyValue('--bg-image')).toContain('data:image/png;base64,BBBB');
expect(presetClasses()).toEqual([]);
});
it('关闭背景时清掉图片与预设,且 data-bg 变 off', () => {
// 这一条守的是「关掉背景后仍显示上一张图」:只把 data-bg 设成 off 而不清
// --bg-image切换回来的瞬间会闪出旧图
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'image', imageDataUrl: 'data:image/png;base64,CCCC' });
applyBackground({ ...DEFAULT_BACKGROUND, kind: 'none' });
const root = document.documentElement;
expect(root.dataset.bg).toBe('off');
expect(root.style.getPropertyValue('--bg-image')).toBe('');
expect(presetClasses()).toEqual([]);
});
});
describe('store 动作', () => {
it('setPreset 同时把 kind 切到 preset 并落盘', () => {
useBackgroundStore.getState().setPreset('sand');
const s = useBackgroundStore.getState();
expect(s.kind).toBe('preset');
expect(s.presetId).toBe('sand');
expect(JSON.parse(localStorage.getItem(storageKey(''))!).presetId).toBe('sand');
});
it('setImage 记录图片并落盘', () => {
useBackgroundStore.getState().setImage('data:image/jpeg;base64,DDDD');
expect(useBackgroundStore.getState().kind).toBe('image');
expect(JSON.parse(localStorage.getItem(storageKey(''))!).imageDataUrl).toContain('DDDD');
});
it('reset 回到默认并落盘', () => {
useBackgroundStore.getState().setPreset('ink');
useBackgroundStore.getState().reset();
const s = useBackgroundStore.getState();
expect(s.kind).toBe('none');
expect(s.presetId).toBe(DEFAULT_BACKGROUND.presetId);
expect(JSON.parse(localStorage.getItem(storageKey(''))!).kind).toBe('none');
});
it('localStorage 抛异常时不打断操作', () => {
// 隐私模式 / 配额满:背景是装饰,绝不能让存储失败冒泡成一次崩溃
const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(() => useBackgroundStore.getState().setDim(55)).not.toThrow();
expect(useBackgroundStore.getState().dim).toBe(55);
spy.mockRestore();
});
});
describe('上限常量', () => {
it('图片上限留足 localStorage 余量', () => {
// 典型 localStorage 配额 5MB超过一半就很容易与其它键一起写爆
expect(MAX_DATA_URL_BYTES).toBeGreaterThan(100_000);
expect(MAX_DATA_URL_BYTES).toBeLessThan(5_000_000 / 2);
});
});
describe('按账号分键(★ 这条修的是"新账号继承上一个账号的外观"', () => {
it('键按账号区分,互不串味', () => {
// pi 2026-09-14 复核时点名:全局键会让"切到服务端没有记录的账号"时,
// saved=false 分支把**上一个账号的外观**推上去(而且写进了服务端)。
expect(storageKey('acct-a')).not.toBe(storageKey('acct-b'));
expect(storageKey('acct-a')).toBe('agentmail.background.acct-a');
// 未登录/首屏(取不到账号)时用匿名键 —— 但**不能退回全局键**,那正是要修的东西
expect(storageKey('')).toBe('agentmail.background.anonymous');
expect(storageKey('')).not.toBe('agentmail.background');
});
it('两个账号的缓存互相看不见', () => {
localStorage.setItem(storageKey('acct-a'), JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 30, presetId: 'sand' }));
localStorage.setItem(storageKey('acct-b'), JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 5, presetId: 'ink' }));
expect(readStored('acct-a').presetId).toBe('sand');
expect(readStored('acct-a').dim).toBe(30);
expect(readStored('acct-b').presetId).toBe('ink');
expect(readStored('acct-b').dim).toBe(5);
});
it('没有本账号缓存时,旧全局键被迁移一次并立刻删除(不留"继承源"', () => {
// 迁移的意义:老用户升级后观感不凭空消失;删除的意义:下一个账号不会再从它继承
localStorage.clear();
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 42, presetId: 'dusk' }));
const first = readStored('acct-a');
expect(first.dim).toBe(42);
expect(first.presetId).toBe('dusk');
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull();
expect(JSON.parse(localStorage.getItem(storageKey('acct-a'))!).dim).toBe(42);
// 第二个账号拿不到那份旧值,得到的是默认外观(不是继承来的)
expect(readStored('acct-b')).toEqual(DEFAULT_BACKGROUND);
});
it('写入失败时**不删旧键**(先删再写会让值净损失)', () => {
/*
* pi 2026-09-14迁移的顺序必须是"写新键 → 回读校验 → 再删旧键"。
* 隐私模式 / 配额满时 `setItem` 可能抛异常或不落盘;那时若已经删了旧键,
* 用户的外观就是**净损失**(不可恢复)。这里模拟"写不进去",断言旧键还在。
*/
localStorage.clear();
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 42 }));
const real = Storage.prototype.setItem;
Storage.prototype.setItem = function (k: string, v: string) {
if (k === storageKey('acct-a')) throw new Error('QuotaExceededError');
return real.call(this, k, v);
};
try {
expect(readStored('acct-a')).toEqual(DEFAULT_BACKGROUND); // 本次拿不到(等于没有缓存)
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull(); // 但旧值**没丢**
expect(localStorage.getItem(storageKey('acct-a'))).toBeNull();
} finally {
Storage.prototype.setItem = real;
}
// 存储恢复后再读一次:仍然迁移得到("下次还有机会"
expect(readStored('acct-a').dim).toBe(42);
});
it('迁移前把旧值另存一份(接管错了还能手工改回来)', () => {
/*
* 归属在原理上做不到定向activeId 是派生视图状态、不落盘,见 readStored 的说明),
* 所以至少要让"接管错了"可恢复:旧值删之前留一份备份,且**没有任何代码读它**。
*/
localStorage.clear();
const legacy = JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 42, presetId: 'dusk' });
localStorage.setItem(LEGACY_STORAGE_KEY, legacy);
readStored('acct-a');
expect(localStorage.getItem(LEGACY_BACKUP_KEY)).toBe(legacy);
// 备份键不参与读取:另一个账号读到的仍是默认外观(不是从备份继承来的)
expect(readStored('acct-b')).toEqual(DEFAULT_BACKGROUND);
});
it('未登录时不迁移(旧值不能送给一个还不知道是谁的账号)', () => {
localStorage.clear();
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ ...DEFAULT_BACKGROUND, dim: 42 }));
expect(readStored('')).toEqual(DEFAULT_BACKGROUND);
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull();
});
});
describe('默认值 = 服务端契约', () => {
it('默认压暗/模糊与服务端 DefaultAppearance 的 12/4 一致', () => {
// 权威值在服务端server/internal/models/models.go 的 DefaultAppearance
// 跨端那条判据会直接去 Go 源码里读这两个数比对,这里守住"本文件不再自写一套"
expect(DEFAULT_BACKGROUND.dim).toBe(12);
expect(DEFAULT_BACKGROUND.blur).toBe(4);
});
});
/*
* `legacy.bak` 的生命周期必须**落在审计的人会看的那一行**pi 2026-09-14 追问)。
*
* 这条判的是"决定有没有被记下来",不是"决定对不对" —— 弱判据,但它是必要的:
* 这个键被另一条判据钉成"只写不读"(⇒ 永远删不掉),于是下一个审计 localStorage 的人
* 一定会问"这谁写的、能删吗"。答案必须就在他会看的地方,而不是藏在某个人的记忆里。
*/
it('★ legacy.bak 的生命周期写在文档里(谁写的、为什么不删、怎么删)', () => {
const doc = readFileSync(findDoc('docs/HARMONY-ALIGN-PLAN.md'), 'utf8');
const row = doc.split('\n').find(l => l.includes('agentmail.background.legacy.bak'));
expect(row, '§7.12 里要有一行讲这个键').toBeTruthy();
expect(row).toContain('有意永久残留');
expect(row).toContain('没有任何代码路径能判定何时安全删除');
expect(row, '要给出删除方法,否则审计的人只能猜').toMatch(/removeItem\('agentmail\.background\.legacy\.bak'\)/);
});