Files
MailUI4Agents/client/electron/test/stores/background.test.ts
JianFeeeee e94313f66a 跨端: 接手 pi 的两个 WebUI 开项——默认值统一到服务端契约 12/4;缓存键按账号(含一次性迁移)
pi 问"这两个开项谁执行",我接了(他那边无 shell,我这边改过 WebUI)。两件都是他读源码读出来的实缺陷。

## 1 默认值:不是审美,是**服务端契约**(pi 更正了自己上一封)

`server/internal/models/models.go` 的 `DefaultAppearance()` 明写 `BgDim: 12, BgBlur: 4`,
且注释宣称"与客户端 backgroundStore / themeStore 的默认值一致"——而 WebUI 的
`backgroundStore.ts` 是 `dim: 24, blur: 8`,**那句注释是假的**;`lib/appearance.ts`
的 `clamp(..., 12, 4)` 又是另一套。**同一份代码里两个"默认值"**,走哪条路就落哪个数。

后果不是"两处代码不一样"这么轻:服务端"没有记录"时客户端以本地为准推上去,
于是**新账号的初始外观由第一个同步它的客户端决定**(先 WebUI 登录存 24/8,
先鸿蒙登录存 12/4)——同一个账号,压暗强度取决于谁先到。

改法:新增 `src/lib/appearanceDefaults.ts` 作为**唯一来源**(DEFAULT_DIM/DEFAULT_BLUR/
上限),`backgroundStore` 与 `lib/appearance` 都引用它,字面量全部消失。

## 2 缓存键按账号(含旧全局键的一次性迁移)

`STORAGE_KEY = 'agentmail.background'` → `storageKey(accountId)` = 前缀 + 账号;
写盘只走 `storageKey()`;旧全局键**只作为迁移源**:当前账号首次读到它时接管并存进自己的键,
然后**立刻删除**(否则下一个账号继续从它"继承",等于把刚修的缺陷留在原地);
未登录时不迁移(旧值不能送给一个还不知道是谁的账号)。

配套顺序:`appearanceSync` 在账号切换时**先 `reloadForAccount()` 再 `pull()`** ——
服务端"没有记录"时 `pull()` 会"以本地为准推上去",那时"本地"必须已经是本账号的值。

## 3 判据(新增第 13 个判据文件 appearance-defaults)

`test/appearance-defaults.test.mjs`:**去 Go 源码里读** `DefaultAppearance()` 的四个字段,
再比对三处(WebUI 常量、store 的 DEFAULT_BACKGROUND 不许有字面量、鸿蒙 Appearance 的字段默认值);
另两条钉"键按账号、不许退回全局键、旧键必须被删除"与"重读在 pull 之前"。
这样服务端那句注释是**可核对**的,不是承诺。

顺带更正:`Wallpaper.ts` 里"WebUI 默认 24"的注释已过时 → 改 12 并写明缘由;
`harmony-appearance` 里"WebUI 是全局键"的前提失效 → 改为断言两端都按账号分键。

## 验证

`npm test` 退出码 0(13 个判据文件全绿 + vitest 263 passed,原 258 + 新增 5 条行为测试:
键隔离、迁移一次并删除、未登录不迁移、默认值=12/4)。
2026-09-14 15:17:01 +08:00

245 lines
11 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,
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('未登录时不迁移(旧值不能送给一个还不知道是谁的账号)', () => {
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);
});
});