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); }); });