现象:点「图片」后背景反而被关掉,上传控件永远不出现 ⇒ 自定义图片在 UI 上
完全不可达(用户看到的正是"自定义背景不正常")。
根因:`normalizeBackground` 把「kind=image 但还没有图片数据」折叠成 `none`
(这条判据本身是对的 —— 读盘时那确实是脏数据),但 store 的 `commit()` 每次
patch 都要过一遍它,于是 `setKind('image')` 这一瞬间就被折叠回去;而上传控件
只在 `kind === 'image'` 下渲染 ⇒ 鸡生蛋问题,用户永远走不到选文件那一步。
修法:给归一化加 `keepEmptyImage`。
- 读盘(`readStored`)保持严格:空图片状态是脏数据,退回 none。
- 交互(`commit`)保留瞬态:允许"已选图片档、还没挑文件"这个中间状态存在。
静止态的不变量没有放松,放松的只是正在选图的那一瞬间;`applyBackground` 对
空图片本来就不铺开(不会出现 `url("")`)。
判据(都验过"修复前会红"):
· 新增 `test/components/BackgroundPicker.test.tsx` —— 点「图片」后选文件控件
必须出现、选完图背景必须亮、失败必须说原因、选「无」必须能关掉。
**扰动验证**:把修复撤掉 → 组件 3 红 + store 1 红;恢复 → 22 全绿。
· `test/stores/background.test.ts` 补 2 条:交互进入图片档要留住 /
瞬态落盘后重读必须退回 none。
线上验证(真浏览器,部署后):线上 bundle 换成 index-CSFGa8wa.js 后 ——
点「图片」→ `kind=image` 保持 → 上传 16KB 小图与 9MB 大图都成功
(大图压缩到 1790KB)→ 刷新后仍在 → 全程无页面错误。
顺带:应用内**从未出现**过品牌图标。`BrandMarkIcon` 只用在登录页与首启页
(都是登录前界面),登录后的日常界面里一处都没有。侧栏顶端加上品牌标记
(点它回收件箱)。favicon 那条链本来就是好的(3 个 link 都 200、类型正确、
图标内容正确),已在验证中确认。
194 lines
8.0 KiB
TypeScript
194 lines
8.0 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
||
import {
|
||
DEFAULT_BACKGROUND,
|
||
MAX_DATA_URL_BYTES,
|
||
STORAGE_KEY,
|
||
applyBackground,
|
||
clampBlur,
|
||
clampDim,
|
||
normalizeBackground,
|
||
readStored,
|
||
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(STORAGE_KEY, 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(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);
|
||
});
|
||
});
|