/** * Fase 9.1 — theme controller unit tests. * * `theme.ts` owns the source of truth for the user's UI theme. It exposes * a Svelte store + helpers that: * - persist the chosen preference (`light` | `dark` | `system`) to * localStorage so the anti-FOUC inline script in app.html reads it on * next load; * - reflect the *resolved* theme onto so Tailwind's * `dark:` selectors and the :root[data-theme=...] CSS tokens both fire; * - re-resolve when the user picks `system` and the OS preference flips. * * Test environment is jsdom (vitest config). We stub `matchMedia` per test. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { get } from 'svelte/store'; import { themePreference, resolvedTheme, setThemePreference, initTheme, type ThemePreference } from './theme'; function mockMatchMedia(prefersDark: boolean) { const listeners = new Set<(e: MediaQueryListEvent) => void>(); const mql = { matches: prefersDark, media: '(prefers-color-scheme: dark)', addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn), removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn), // Legacy APIs Svelte does not use but jsdom may probe addListener: (fn: (e: MediaQueryListEvent) => void) => listeners.add(fn), removeListener: (fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn), dispatchEvent: (e: MediaQueryListEvent) => { listeners.forEach((l) => l(e)); return true; }, onchange: null }; vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(mql)); (window as unknown as { matchMedia: typeof window.matchMedia }).matchMedia = globalThis.matchMedia; return mql; } beforeEach(() => { localStorage.clear(); document.documentElement.removeAttribute('data-theme'); // Default the matchMedia mock to "system prefers light" unless a test // overrides it. Without this, jsdom would throw on the first read. mockMatchMedia(false); }); afterEach(() => { vi.unstubAllGlobals(); }); describe('themePreference store + setThemePreference', () => { it('TH-01: defaults to "system" when nothing is stored', () => { initTheme(); expect(get(themePreference)).toBe('system'); }); it('TH-02: setting "light" writes localStorage and data-theme=light', () => { initTheme(); setThemePreference('light'); expect(localStorage.getItem('theme')).toBe('light'); expect(document.documentElement.getAttribute('data-theme')).toBe('light'); expect(get(themePreference)).toBe('light'); expect(get(resolvedTheme)).toBe('light'); }); it('TH-03: setting "dark" writes localStorage and data-theme=dark', () => { initTheme(); setThemePreference('dark'); expect(localStorage.getItem('theme')).toBe('dark'); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); expect(get(themePreference)).toBe('dark'); expect(get(resolvedTheme)).toBe('dark'); }); it('TH-04: setting "system" resolves to dark when prefers-color-scheme: dark', () => { mockMatchMedia(true); // OS says dark initTheme(); setThemePreference('system'); expect(localStorage.getItem('theme')).toBe('system'); expect(get(themePreference)).toBe('system'); expect(get(resolvedTheme)).toBe('dark'); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); it('TH-05: setting "system" resolves to light when prefers-color-scheme: light', () => { mockMatchMedia(false); // OS says light initTheme(); setThemePreference('system'); expect(get(resolvedTheme)).toBe('light'); expect(document.documentElement.getAttribute('data-theme')).toBe('light'); }); it('TH-06: initTheme picks up an existing localStorage value on mount', () => { localStorage.setItem('theme', 'dark'); initTheme(); expect(get(themePreference)).toBe('dark'); expect(get(resolvedTheme)).toBe('dark'); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); it('TH-07: rejects unknown preference values (falls back to "system")', () => { initTheme(); // @ts-expect-error testing the runtime guard setThemePreference('sepia' as ThemePreference); expect(get(themePreference)).toBe('system'); }); it('TH-08: "system" updates resolved theme when matchMedia fires "change"', () => { const mql = mockMatchMedia(false); initTheme(); setThemePreference('system'); expect(get(resolvedTheme)).toBe('light'); // OS switches to dark. mql.matches = true; mql.dispatchEvent(new Event('change') as MediaQueryListEvent); expect(get(resolvedTheme)).toBe('dark'); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); });