Files
MailUI4Agents/client/electron/test/stores/background.test.ts
JianFeeeee 71a3c35610 按 pi 复核改五处:判据的"值/行为"原则、真正的构建门、typecheck 进门、Go 判据零匹配、迁移归属
pi 用反例与变异逐条点了五处,全部**先跑变异再改**(结论都写在原地)。

## 1 判据③:形状正则已删(它永远差一个反例)

实测 pi 的反例 —— `const k = PREFIX + accountId; return PREFIX;`(拼了但没返回)——
对第三版判据**仍然全绿**:第三版锚的是"**函数体里存在**这样的表达式",不是"**返回的**表达式"。
三版的骗法各一个(签名里的参数 / 提了一下没用 / 拼了没返回),都在判"源码里有没有那个形状",
而缺陷是"算出来的值对不对" ⇒ **权威交给行为判据**(`test/stores/background.test.ts` 直接断言
`storageKey('a') !== storageKey('b')`、`=== 'agentmail.background.acct-a'`、不退回全局键),
正则那条删掉并把反例写在原地(否则下一个人会好心加回来)。
保留"键必须来自 storageKey()"那条:那是**来源**约束,不是值对不对,正则在这里合适。
鸿蒙侧只能静态判(`.ets` 本机没有运行时),已把这条限制写进判据说明。
推广进规范:CRITERIA.md §6.7 + run-all 自检关键词(10 → 11)。

## 2 真正的门:`scripts/release-linux.sh`

- 实测 pi 提的变异:**`touch dist/index.html` 时 stamp 判据是绿的** —— 它抓不到"构建失败但碰过 dist"。
  stamp 是**探测器**(抓"src 改了产物没跟上"),门是**喂退出码**,两者不互替(§6.7.1)。
- `build:linux` 里**没有管道**(`&&` 链,退出码本来就传),但那次的哑巴失败是我在命令行手打
  `npm run build 2>&1 | tail -4 && …` 造成的;同时发现它跑的是**裸 `vite build`,跳过 `gen:bg`**。
- 于是把配方收成 `scripts/release-linux.sh`:`set -euo pipefail` + 走 `npm run build` + 再打包。
- 判据是**行为**的:注入失败的构建(`AGENTMAIL_BUILD_CMD='exit 7'`)→ 断言退出码非 0
  **且打包那步没跑**(标记文件不存在)。变异:脚本改成 `|| true` → 红 ✓。

## 3 typecheck 进 `npm test` 链

`npm run typecheck` 原本就有,但没人跑。先修掉它唯一的报错(我自己留下的未使用 import),现在干净;
`test` = run-all + vitest + typecheck。它恰好检查**没被任何测试 import 的文件**(vitest 只解析被测到的图)——
也就是那次 `is not exported by` 的形状。

## 4 Go 源码判据:零匹配 / 读不懂 都要红

改成三分:切不出函数体 → 红;字段在但值不是字面量 → 报「**判据读不懂**」(变异:`BgDim: defaultDim` → 红 ✓);
字段不在 → 红。静默放行是这类判据最危险的失败方式。
**措辞更正**:这条核对的是"与**这份服务端源码**的契约",不是"在跑的服务端二进制是 12/4"
(与"dist 是产物"同构);文档同步改。

## 5 迁移归属:定向做不到,就把"不可恢复"降级成"可恢复"

查实:`accountStore` 的 `activeId` 是**派生视图状态、不落盘**(`persist(accounts)` 只存数组),
所以**本机没有"上次活跃账号"标记可定向** —— 旧值的作者事后无法还原,定向迁移在原理上做不到。
升级前用 B、升级后先登录 A ⇒ A 接管 B 的外观,**一次性错档**,触发条件就这一条。
两件能做的都做了:**写了回读校验**(写不进去就不删旧键,避免净损失;变异:改成先删后写 → 2 条红 ✓)、
**删前另存** `agentmail.background.legacy.bak`(只写不读 ⇒ 不引入新的继承源,判据钉"只写不读")。
§7.12 把本地这半与显形方式写进同一行。

## 验证

`npm test` 退出码 0:14 个判据文件全绿 + vitest **265** 通过(+2 迁移行为测试)+ typecheck 干净;
`hvigorw assembleHap` BUILD SUCCESSFUL;安装包经新脚本重打(dist 与包同批)。
2026-09-14 15:41:52 +08:00

284 lines
13 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 { 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 = '';
});
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);
});
});