import { afterEach, beforeEach, expect, it, vi } from 'vitest'; const hooks = vi.hoisted(() => ({cleanups: [] as (() => void)[], notify: vi.fn()})); vi.mock('react', async original => ({ ...await original(), useSyncExternalStore(subscribe: (fn: () => void) => () => void, snapshot: () => unknown) { hooks.cleanups.push(subscribe(hooks.notify)); return snapshot(); }, })); import { initializeTheme, setThemePreference, themeStorageKey, useNightMode, useThemePreference } from './nightMode'; import { ThemeSettings } from './ThemeSettings'; let cleanup: (() => void) | undefined; let media: EventTarget & {matches: boolean}; let win: EventTarget & {localStorage: {getItem: ReturnType; setItem: ReturnType}; matchMedia: ReturnType}; let dataset: {theme?: string}; let meta: {setAttribute: ReturnType}; let saved: string | null; beforeEach(() => { saved = null; media = Object.assign(new EventTarget(), {matches: false}); win = Object.assign(new EventTarget(), {localStorage: {getItem: vi.fn(() => saved), setItem: vi.fn((_key, value) => {saved = value;})}, matchMedia: vi.fn(() => media)}); dataset = {}; meta = {setAttribute: vi.fn()}; vi.stubGlobal('window', win); vi.stubGlobal('document', {documentElement: {dataset}, querySelector: vi.fn(() => meta)}); hooks.notify.mockClear(); }); afterEach(() => {cleanup?.(); hooks.cleanups.splice(0).forEach(fn => fn()); vi.unstubAllGlobals();}); function start() {cleanup = initializeTheme();} function os(dark: boolean) {media.matches = dark; media.dispatchEvent(new Event('change'));} function storage(value: string | null, key: string | null = themeStorageKey, area: unknown = win.localStorage) { win.dispatchEvent(Object.assign(new Event('storage'), {key, newValue: value, storageArea: area})); } it('defaults to auto and applies OS changes to DOM, metadata and Monaco', () => { start(); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(false); os(true); expect(useNightMode()).toBe(true); expect(hooks.notify).toHaveBeenCalled(); expect(dataset.theme).toBe('dark'); expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#101c22'); os(false); expect(dataset.theme).toBe('light'); expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#152b36'); }); it.each(['light', 'dark'] as const)('persists %s and overrides either OS scheme across initialization', value => { start(); setThemePreference(value); for (const system of [true, false]) {os(system); expect(useNightMode()).toBe(value === 'dark');} expect(win.localStorage.setItem).toHaveBeenCalledWith(themeStorageKey, value); cleanup!(); start(); expect(useThemePreference()).toBe(value); expect(dataset.theme).toBe(value); setThemePreference('auto'); os(true); expect(useNightMode()).toBe(true); expect(saved).toBe('auto'); }); it.each([null, '', 'invalid', 'DARK', 'auto'])('treats stored %s as auto', value => { saved = value; media.matches = true; start(); expect(useThemePreference()).toBe('auto'); expect(dataset.theme).toBe('dark'); }); it('synchronizes tabs including removal, clear and invalid values without writing back', () => { start(); storage('dark'); expect(useNightMode()).toBe(true); storage('light'); expect(useThemePreference()).toBe('light'); os(true); expect(useNightMode()).toBe(false); for (const value of [null, 'bad', 'auto']) {storage(value); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(true);} storage('light'); storage(null, null); expect(useThemePreference()).toBe('auto'); storage('light', 'unrelated'); storage('light', themeStorageKey, {}); expect(useThemePreference()).toBe('auto'); expect(win.localStorage.setItem).not.toHaveBeenCalled(); }); it('keeps manual choices usable with blocked storage', () => { Object.defineProperty(win, 'localStorage', {get() {throw new Error('blocked');}}); start(); expect(useThemePreference()).toBe('auto'); setThemePreference('dark'); os(false); expect(useNightMode()).toBe(true); setThemePreference('light'); os(true); expect(useNightMode()).toBe(false); setThemePreference('auto'); expect(useNightMode()).toBe(true); }); it('keeps a selection when storage writes fail', () => { start(); win.localStorage.setItem.mockImplementation(() => {throw new Error('quota');}); setThemePreference('dark'); expect(dataset.theme).toBe('dark'); }); it('initializes once and cleans up global listeners and hook subscriptions', () => { const removeMedia = vi.spyOn(media, 'removeEventListener'); const removeWindow = vi.spyOn(win, 'removeEventListener'); start(); expect(initializeTheme()).toBe(cleanup); expect(win.matchMedia).toHaveBeenCalledOnce(); useNightMode(); hooks.cleanups.splice(0).forEach(fn => fn()); hooks.notify.mockClear(); setThemePreference('light'); expect(hooks.notify).not.toHaveBeenCalled(); cleanup!(); cleanup = undefined; expect(removeMedia).toHaveBeenCalledWith('change', expect.any(Function)); expect(removeWindow).toHaveBeenCalledWith('storage', expect.any(Function)); os(true); storage('dark'); expect(dataset.theme).toBe('light'); }); it('settings buttons expose one pressed choice and explain DSH independence', () => { start(); const buttons = () => ThemeSettings().props.children[0].props.children; expect(buttons().map((b: any) => b.props['aria-pressed'])).toEqual([false, false, true]); for (const [index, value] of ['light', 'dark', 'auto'].entries()) { buttons()[index].props.onClick(); expect(useThemePreference()).toBe(value); expect(buttons().filter((b: any) => b.props['aria-pressed'])).toHaveLength(1); expect(buttons()[index].props['aria-pressed']).toBe(true); } expect(JSON.stringify(ThemeSettings())).toContain('内嵌 DSH 的主题独立管理'); });