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` 正是为此留的,
复核时先看那一行。
316 lines
14 KiB
TypeScript
316 lines
14 KiB
TypeScript
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'\)/);
|
||
});
|