Files
MyGoNavi/frontend/src/customThemeStore.test.ts
Syngnat ef5e344c4f feat(theme): 新增自定义主题与多套低刺激预设
- 支持导入、管理和持久化自定义 CSS 主题
- 新增六套内置主题并提供低刺激舒适暗色
- 完善 V2 主题令牌、多语言、可访问性与回归测试
2026-07-11 23:50:24 +08:00

209 lines
7.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
class MemoryStorage implements Storage {
data = new Map<string, string>();
failWrites = false;
get length() { return this.data.size; }
clear() { this.data.clear(); }
getItem(key: string) { return this.data.get(key) ?? null; }
key(index: number) { return Array.from(this.data.keys())[index] ?? null; }
removeItem(key: string) { this.data.delete(key); }
setItem(key: string, value: string) {
if (this.failWrites) throw new DOMException('Quota exceeded', 'QuotaExceededError');
this.data.set(key, String(value));
}
}
const importThemeStore = async () => import('./customThemeStore');
describe('custom theme store', () => {
let storage: MemoryStorage;
beforeEach(() => {
storage = new MemoryStorage();
vi.stubGlobal('localStorage', storage);
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it('imports, selects, updates, persists and removes custom themes independently', async () => {
let module = await importThemeStore();
const imported = module.useCustomThemeStore.getState().importCustomTheme({
name: 'Purple Night',
sourceFileName: 'purple.css',
baseMode: 'dark',
css: 'body[data-custom-theme] { --gn-accent: #8b5cf6; }',
});
expect(imported.ok).toBe(true);
const themeId = imported.ok ? imported.theme?.id : undefined;
expect(themeId).toBeTruthy();
expect(module.useCustomThemeStore.getState().activeThemeId).toBeNull();
expect(module.useCustomThemeStore.getState().selectCustomTheme(themeId!)).toEqual(
expect.objectContaining({ ok: true }),
);
expect(module.useCustomThemeStore.getState().updateCustomTheme(themeId!, { baseMode: 'system' })).toEqual(
expect.objectContaining({ ok: true }),
);
vi.resetModules();
module = await importThemeStore();
expect(module.useCustomThemeStore.getState().activeThemeId).toBe(themeId);
expect(module.useCustomThemeStore.getState().themes[0].baseMode).toBe('system');
expect(module.useCustomThemeStore.getState().removeCustomTheme(themeId!)).toEqual(
expect.objectContaining({ ok: true }),
);
expect(module.useCustomThemeStore.getState().themes).toEqual([]);
expect(module.useCustomThemeStore.getState().activeThemeId).toBeNull();
});
it('selects and restores built-in themes without consuming the custom theme quota', async () => {
let module = await importThemeStore();
const selected = module.useCustomThemeStore.getState().selectCustomTheme('builtin-comfort-dark');
expect(selected).toEqual(expect.objectContaining({
ok: true,
theme: expect.objectContaining({
id: 'builtin-comfort-dark',
baseMode: 'dark',
}),
}));
expect(module.useCustomThemeStore.getState().themes).toEqual([]);
expect(module.useCustomThemeStore.getState().activeThemeId).toBe('builtin-comfort-dark');
const imported = module.useCustomThemeStore.getState().importCustomTheme({
name: 'User while built-in is active',
sourceFileName: 'user.css',
css: 'body { color: red; }',
});
expect(imported.ok).toBe(true);
const userThemeId = imported.ok ? imported.theme?.id : '';
expect(module.useCustomThemeStore.getState().activeThemeId).toBe('builtin-comfort-dark');
expect(module.useCustomThemeStore.getState().removeCustomTheme(userThemeId!)).toEqual({ ok: true });
expect(module.useCustomThemeStore.getState().activeThemeId).toBe('builtin-comfort-dark');
vi.resetModules();
module = await importThemeStore();
expect(module.useCustomThemeStore.getState().themes).toEqual([]);
expect(module.useCustomThemeStore.getState().activeThemeId).toBe('builtin-comfort-dark');
storage.clear();
module.useCustomThemeStore.getState().reloadCustomThemes();
expect(module.useCustomThemeStore.getState().activeThemeId).toBeNull();
});
it('reserves built-in IDs when hydrating untrusted user theme data', async () => {
storage.setItem('gonavi-custom-themes-v1', JSON.stringify({
version: 1,
activeThemeId: 'builtin-warm-paper',
themes: [{
schemaVersion: 1,
id: 'builtin-warm-paper',
name: 'Shadow copy',
sourceFileName: 'shadow.css',
baseMode: 'dark',
css: 'body { color: red; }',
createdAt: 1,
updatedAt: 1,
}],
}));
const { useCustomThemeStore } = await importThemeStore();
expect(useCustomThemeStore.getState().themes).toEqual([]);
expect(useCustomThemeStore.getState().activeThemeId).toBe('builtin-warm-paper');
expect(useCustomThemeStore.getState().selectCustomTheme('builtin-warm-paper')).toEqual(
expect.objectContaining({
ok: true,
theme: expect.objectContaining({ name: 'Warm Paper', baseMode: 'light' }),
}),
);
});
it('drops an invalid persisted active id and unsafe persisted CSS', async () => {
storage.setItem('gonavi-custom-themes-v1', JSON.stringify({
version: 1,
activeThemeId: 'missing',
themes: [
{
schemaVersion: 1,
id: 'unsafe-theme',
name: 'Unsafe',
sourceFileName: 'unsafe.css',
baseMode: 'dark',
css: 'body { background: url(https://example.com/pixel); }',
createdAt: 1,
updatedAt: 1,
},
],
}));
const { useCustomThemeStore } = await importThemeStore();
expect(useCustomThemeStore.getState().themes).toEqual([]);
expect(useCustomThemeStore.getState().activeThemeId).toBeNull();
});
it('does not interpret a future persisted schema as the current format', async () => {
storage.setItem('gonavi-custom-themes-v1', JSON.stringify({
version: 2,
activeThemeId: 'theme-future',
themes: [{
schemaVersion: 2,
id: 'theme-future',
name: 'Future',
sourceFileName: 'future.css',
baseMode: 'dark',
css: 'body { color: red; }',
createdAt: 1,
updatedAt: 1,
}],
}));
const { useCustomThemeStore } = await importThemeStore();
expect(useCustomThemeStore.getState().themes).toEqual([]);
expect(useCustomThemeStore.getState().activeThemeId).toBeNull();
});
it('reports storage failures without claiming that an import succeeded', async () => {
storage.failWrites = true;
const { useCustomThemeStore } = await importThemeStore();
const result = useCustomThemeStore.getState().importCustomTheme({
name: 'No space',
sourceFileName: 'no-space.css',
css: 'body { color: red; }',
});
expect(result).toEqual({ ok: false, reason: 'storage-failed' });
expect(useCustomThemeStore.getState().themes).toEqual([]);
});
it('reports unavailable browser storage instead of creating a temporary theme', async () => {
vi.stubGlobal('window', {});
vi.stubGlobal('localStorage', undefined);
vi.resetModules();
const { useCustomThemeStore } = await importThemeStore();
const result = useCustomThemeStore.getState().importCustomTheme({
name: 'Unavailable storage',
sourceFileName: 'unavailable.css',
css: 'body { color: red; }',
});
expect(result).toEqual({ ok: false, reason: 'storage-failed' });
expect(useCustomThemeStore.getState().themes).toEqual([]);
});
it('keeps the in-memory escape hatch when persistence is unavailable', async () => {
const { useCustomThemeStore } = await importThemeStore();
const imported = useCustomThemeStore.getState().importCustomTheme({
name: 'Escape hatch',
sourceFileName: 'escape.css',
css: 'body { color: red; }',
});
expect(imported.ok).toBe(true);
const themeId = imported.ok ? imported.theme?.id : '';
useCustomThemeStore.getState().selectCustomTheme(themeId!);
storage.failWrites = true;
const result = useCustomThemeStore.getState().selectCustomTheme(null);
expect(result).toEqual({ ok: false, reason: 'storage-failed' });
expect(useCustomThemeStore.getState().activeThemeId).toBeNull();
});
});