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(); 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(); expect(currentTheme()).toBe('dark'); expect(visibleIcon()).toBe('icon-monitor'); expect(screen.getByRole('button')).toHaveAttribute( 'data-theme-preference', 'system', ); }); it('系统为浅色且未选过时,应用 light', () => { mockMatchMedia(false); render(); expect(currentTheme()).toBe('light'); expect(visibleIcon()).toBe('icon-monitor'); }); it('未选过时不写 localStorage,避免把默认偏好固化成显式选择', () => { mockMatchMedia(true); render(); expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBeNull(); }); }); describe('循环切换', () => { it('按 system → light → dark → system 循环,并同步 DOM 与存储', async () => { const user = userEvent.setup(); mockMatchMedia(true); render(); 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(); 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(); expect(currentTheme()).toBe(expected); }); it('存储值被污染时回退到跟随系统', () => { window.localStorage.setItem(THEME_STORAGE_KEY, 'not-a-theme'); mockMatchMedia(true); render(); expect(currentTheme()).toBe('dark'); expect(visibleIcon()).toBe('icon-monitor'); }); }); describe('实时跟随系统偏好', () => { it('system 档下系统切换为浅色时立即反色,无需刷新', () => { const media = mockMatchMedia(true); render(); expect(currentTheme()).toBe('dark'); act(() => media.emitSystemChange(false)); expect(currentTheme()).toBe('light'); expect(visibleIcon()).toBe('icon-monitor'); }); it('system 档下系统切换为深色时立即反色', () => { const media = mockMatchMedia(false); render(); expect(currentTheme()).toBe('light'); act(() => media.emitSystemChange(true)); expect(currentTheme()).toBe('dark'); }); it('已显式选择的用户不受系统偏好变化干扰', async () => { const user = userEvent.setup(); const media = mockMatchMedia(false); render(); 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(); expect(media.listenerCount()).toBeGreaterThan(0); unmount(); expect(media.listenerCount()).toBe(0); }); }); describe('跨标签页同步', () => { it('其他标签页修改偏好后本页同步生效', () => { mockMatchMedia(false); render(); 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(); act(() => { window.dispatchEvent( new StorageEvent('storage', { key: 'unrelated', newValue: 'dark' }), ); }); expect(currentTheme()).toBe('light'); }); }); describe('可访问性', () => { it('按钮标签同时说明当前设置与生效主题', () => { mockMatchMedia(true); render(); const label = screen.getByRole('button').getAttribute('aria-label') ?? ''; expect(label).toContain('跟随系统'); expect(label).toContain('深色'); }); it('每次切换后标签同步更新', async () => { const user = userEvent.setup(); mockMatchMedia(false); render(); const button = screen.getByRole('button'); await user.click(button); expect(button.getAttribute('aria-label')).toContain('浅色'); }); }); });