- 新增 src/lib/theme.ts:三态偏好单一真源
(system/light/dark,无存储值默认跟随系统)
- ThemeToggle 改三态循环(Monitor→Sun→Moon):
实时监听 prefers-color-scheme(仅 system 档生效)
+ storage 跨标签页同步;尺寸位置不变
- layout.tsx FOUC 内联脚本支持 system 档,
首帧前解析避免闪烁
- e2e 视觉回归 L2 改用 test.use({ colorScheme }) 驱动
(事后 setAttribute 存在被挂载效果覆盖的竞态)
- header.test.tsx lucide 白名单补 Monitor
- CLAUDE.md 同步三态语义与 variant 踩坑
- 新增 scripts/audit/theme-system-verify.mjs
(55 断言:首帧无闪烁/循环/实时跟随/旧值兼容/
污染回退/6 路由冒烟)
验证:单测 131 套件 1662 通过 0 失败;
tsc 0 错;eslint 0 错;next build 通过;
运行时 55/55 PASS(dogfood-output/theme-system-verify/)
250 lines
7.5 KiB
TypeScript
250 lines
7.5 KiB
TypeScript
import { render, screen, act } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { ThemeToggle } from '@/components/theme/theme-toggle';
|
|
import {
|
|
SYSTEM_PREFERENCE_QUERY,
|
|
THEME_STORAGE_KEY,
|
|
type ThemePreference,
|
|
} from '@/lib/theme';
|
|
|
|
type ChangeListener = (event: MediaQueryListEvent) => void;
|
|
|
|
/**
|
|
* 可控的 matchMedia 桩:除 matches 外,捕获 change 监听以便测试中模拟
|
|
* 用户在操作系统里切换浅/深色。
|
|
*/
|
|
function mockMatchMedia(matches: boolean) {
|
|
const listeners = new Set<ChangeListener>();
|
|
const mql = {
|
|
matches,
|
|
media: SYSTEM_PREFERENCE_QUERY,
|
|
onchange: null,
|
|
addListener: jest.fn(),
|
|
removeListener: jest.fn(),
|
|
addEventListener: jest.fn((type: string, cb: ChangeListener) => {
|
|
if (type === 'change') listeners.add(cb);
|
|
}),
|
|
removeEventListener: jest.fn((type: string, cb: ChangeListener) => {
|
|
if (type === 'change') listeners.delete(cb);
|
|
}),
|
|
dispatchEvent: jest.fn(),
|
|
};
|
|
|
|
window.matchMedia = jest.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia;
|
|
|
|
return {
|
|
mql,
|
|
/** 模拟操作系统主题切换 */
|
|
emitSystemChange(next: boolean) {
|
|
mql.matches = next;
|
|
listeners.forEach((cb) => {
|
|
cb({ matches: next, media: SYSTEM_PREFERENCE_QUERY } as MediaQueryListEvent);
|
|
});
|
|
},
|
|
listenerCount: () => listeners.size,
|
|
};
|
|
}
|
|
|
|
function currentTheme(): string | null {
|
|
return document.documentElement.getAttribute('data-theme');
|
|
}
|
|
|
|
function visibleIcon(): string | null {
|
|
const icon = document.querySelector('[data-testid^="icon-"]');
|
|
return icon?.getAttribute('data-testid') ?? null;
|
|
}
|
|
|
|
describe('ThemeToggle 三态循环', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
document.documentElement.removeAttribute('data-theme');
|
|
});
|
|
|
|
describe('默认跟随系统', () => {
|
|
it('系统为深色且未选过时,首帧即应用 dark 并显示"跟随系统"图标', () => {
|
|
mockMatchMedia(true);
|
|
render(<ThemeToggle />);
|
|
|
|
expect(currentTheme()).toBe('dark');
|
|
expect(visibleIcon()).toBe('icon-monitor');
|
|
expect(screen.getByRole('button')).toHaveAttribute(
|
|
'data-theme-preference',
|
|
'system',
|
|
);
|
|
});
|
|
|
|
it('系统为浅色且未选过时,应用 light', () => {
|
|
mockMatchMedia(false);
|
|
render(<ThemeToggle />);
|
|
|
|
expect(currentTheme()).toBe('light');
|
|
expect(visibleIcon()).toBe('icon-monitor');
|
|
});
|
|
|
|
it('未选过时不写 localStorage,避免把默认偏好固化成显式选择', () => {
|
|
mockMatchMedia(true);
|
|
render(<ThemeToggle />);
|
|
|
|
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('循环切换', () => {
|
|
it('按 system → light → dark → system 循环,并同步 DOM 与存储', async () => {
|
|
const user = userEvent.setup();
|
|
mockMatchMedia(true);
|
|
render(<ThemeToggle />);
|
|
const button = screen.getByRole('button');
|
|
|
|
const expectations: Array<[ThemePreference, string, string]> = [
|
|
['light', 'light', 'icon-sun'],
|
|
['dark', 'dark', 'icon-moon'],
|
|
['system', 'dark', 'icon-monitor'],
|
|
];
|
|
|
|
for (const [preference, theme, icon] of expectations) {
|
|
await user.click(button);
|
|
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe(preference);
|
|
expect(currentTheme()).toBe(theme);
|
|
expect(visibleIcon()).toBe(icon);
|
|
expect(button).toHaveAttribute('data-theme-preference', preference);
|
|
}
|
|
});
|
|
|
|
it('显式浅色会覆盖深色系统偏好', async () => {
|
|
const user = userEvent.setup();
|
|
mockMatchMedia(true);
|
|
render(<ThemeToggle />);
|
|
|
|
await user.click(screen.getByRole('button')); // system → light
|
|
|
|
expect(currentTheme()).toBe('light');
|
|
});
|
|
});
|
|
|
|
describe('向后兼容旧存储值', () => {
|
|
it.each<[ThemePreference, string]>([
|
|
['light', 'light'],
|
|
['dark', 'dark'],
|
|
])('旧值 %s 仍被当作显式覆盖(系统为深色)', (stored, expected) => {
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, stored);
|
|
mockMatchMedia(true);
|
|
render(<ThemeToggle />);
|
|
|
|
expect(currentTheme()).toBe(expected);
|
|
});
|
|
|
|
it('存储值被污染时回退到跟随系统', () => {
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, 'not-a-theme');
|
|
mockMatchMedia(true);
|
|
render(<ThemeToggle />);
|
|
|
|
expect(currentTheme()).toBe('dark');
|
|
expect(visibleIcon()).toBe('icon-monitor');
|
|
});
|
|
});
|
|
|
|
describe('实时跟随系统偏好', () => {
|
|
it('system 档下系统切换为浅色时立即反色,无需刷新', () => {
|
|
const media = mockMatchMedia(true);
|
|
render(<ThemeToggle />);
|
|
expect(currentTheme()).toBe('dark');
|
|
|
|
act(() => media.emitSystemChange(false));
|
|
|
|
expect(currentTheme()).toBe('light');
|
|
expect(visibleIcon()).toBe('icon-monitor');
|
|
});
|
|
|
|
it('system 档下系统切换为深色时立即反色', () => {
|
|
const media = mockMatchMedia(false);
|
|
render(<ThemeToggle />);
|
|
expect(currentTheme()).toBe('light');
|
|
|
|
act(() => media.emitSystemChange(true));
|
|
|
|
expect(currentTheme()).toBe('dark');
|
|
});
|
|
|
|
it('已显式选择的用户不受系统偏好变化干扰', async () => {
|
|
const user = userEvent.setup();
|
|
const media = mockMatchMedia(false);
|
|
render(<ThemeToggle />);
|
|
|
|
await user.click(screen.getByRole('button')); // system → light
|
|
await user.click(screen.getByRole('button')); // light → dark
|
|
expect(currentTheme()).toBe('dark');
|
|
|
|
act(() => media.emitSystemChange(false));
|
|
|
|
expect(currentTheme()).toBe('dark');
|
|
expect(visibleIcon()).toBe('icon-moon');
|
|
});
|
|
|
|
it('卸载后移除监听,避免内存泄漏', () => {
|
|
const media = mockMatchMedia(true);
|
|
const { unmount } = render(<ThemeToggle />);
|
|
expect(media.listenerCount()).toBeGreaterThan(0);
|
|
|
|
unmount();
|
|
|
|
expect(media.listenerCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('跨标签页同步', () => {
|
|
it('其他标签页修改偏好后本页同步生效', () => {
|
|
mockMatchMedia(false);
|
|
render(<ThemeToggle />);
|
|
expect(currentTheme()).toBe('light');
|
|
|
|
act(() => {
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark');
|
|
window.dispatchEvent(
|
|
new StorageEvent('storage', {
|
|
key: THEME_STORAGE_KEY,
|
|
newValue: 'dark',
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(currentTheme()).toBe('dark');
|
|
expect(visibleIcon()).toBe('icon-moon');
|
|
});
|
|
|
|
it('忽略无关 key 的 storage 事件', () => {
|
|
mockMatchMedia(false);
|
|
render(<ThemeToggle />);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new StorageEvent('storage', { key: 'unrelated', newValue: 'dark' }),
|
|
);
|
|
});
|
|
|
|
expect(currentTheme()).toBe('light');
|
|
});
|
|
});
|
|
|
|
describe('可访问性', () => {
|
|
it('按钮标签同时说明当前设置与生效主题', () => {
|
|
mockMatchMedia(true);
|
|
render(<ThemeToggle />);
|
|
|
|
const label = screen.getByRole('button').getAttribute('aria-label') ?? '';
|
|
expect(label).toContain('跟随系统');
|
|
expect(label).toContain('深色');
|
|
});
|
|
|
|
it('每次切换后标签同步更新', async () => {
|
|
const user = userEvent.setup();
|
|
mockMatchMedia(false);
|
|
render(<ThemeToggle />);
|
|
const button = screen.getByRole('button');
|
|
|
|
await user.click(button);
|
|
expect(button.getAttribute('aria-label')).toContain('浅色');
|
|
});
|
|
});
|
|
});
|