import { beforeEach, describe, expect, it } from 'vitest'; import { DEFAULT_BACKGROUND, MAX_DATA_URL_BYTES, STORAGE_KEY, 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('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(STORAGE_KEY)!).presetId).toBe('sand'); }); it('setImage 记录图片并落盘', () => { useBackgroundStore.getState().setImage('data:image/jpeg;base64,DDDD'); expect(useBackgroundStore.getState().kind).toBe('image'); expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).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(STORAGE_KEY)!).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); }); });