import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { bootstrapTheme, setTheme, getThemePreference } from './theme' // ── localStorage stub ──────────────────────────────────────────────────────── // Node 25 ships a built-in `localStorage` that is a stub with no methods. // Replace it with a real in-memory implementation for our tests. function makeLocalStorageStub(): Storage { const store: Record = {} return { getItem: (key: string) => store[key] ?? null, setItem: (key: string, val: string) => { store[key] = val }, removeItem: (key: string) => { delete store[key] }, clear: () => { Object.keys(store).forEach(k => delete store[k]) }, key: (index: number) => Object.keys(store)[index] ?? null, get length() { return Object.keys(store).length }, } as Storage } // ── matchMedia stub helpers ────────────────────────────────────────────────── type MediaQueryListener = (e: MediaQueryListEvent) => void interface MockMQL { matches: boolean _listeners: MediaQueryListener[] addEventListener(type: 'change', fn: MediaQueryListener): void removeEventListener(type: 'change', fn: MediaQueryListener): void dispatchChange(matches: boolean): void } function makeMockMQL(matches: boolean): MockMQL { const mql: MockMQL = { matches, _listeners: [], addEventListener(_type: 'change', fn: MediaQueryListener) { mql._listeners.push(fn) }, removeEventListener(_type: 'change', fn: MediaQueryListener) { mql._listeners = mql._listeners.filter(l => l !== fn) }, dispatchChange(newMatches: boolean) { mql.matches = newMatches const ev = { matches: newMatches } as MediaQueryListEvent mql._listeners.forEach(fn => fn(ev)) }, } return mql } function stubMatchMedia(mql: MockMQL) { Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: vi.fn().mockReturnValue(mql), }) } // ── Setup / teardown ───────────────────────────────────────────────────────── let lsStub: Storage beforeEach(() => { // Install a fresh localStorage stub before each test lsStub = makeLocalStorageStub() vi.stubGlobal('localStorage', lsStub) // Reset data-theme before each test delete document.documentElement.dataset['theme'] }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) // ── Tests ──────────────────────────────────────────────────────────────────── describe('bootstrapTheme', () => { it('no localStorage.theme + OS=dark → data-theme=dark', () => { const mql = makeMockMQL(true) stubMatchMedia(mql) bootstrapTheme() expect(document.documentElement.dataset['theme']).toBe('dark') }) it('localStorage.theme=light + OS=dark → data-theme=light', () => { const mql = makeMockMQL(true) stubMatchMedia(mql) lsStub.setItem('theme', 'light') bootstrapTheme() expect(document.documentElement.dataset['theme']).toBe('light') }) it('OS theme changes while no override → flips data-theme', () => { const mql = makeMockMQL(false) // starts light stubMatchMedia(mql) bootstrapTheme() expect(document.documentElement.dataset['theme']).toBe('light') mql.dispatchChange(true) // OS → dark expect(document.documentElement.dataset['theme']).toBe('dark') mql.dispatchChange(false) // OS → light expect(document.documentElement.dataset['theme']).toBe('light') }) it('OS theme changes while override is set → data-theme does NOT change', () => { const mql = makeMockMQL(false) // starts light stubMatchMedia(mql) lsStub.setItem('theme', 'light') bootstrapTheme() expect(document.documentElement.dataset['theme']).toBe('light') mql.dispatchChange(true) // OS → dark, but override active expect(document.documentElement.dataset['theme']).toBe('light') }) }) describe('setTheme', () => { it('setTheme(null) re-syncs with current OS preference (dark)', () => { const mql = makeMockMQL(true) // OS = dark stubMatchMedia(mql) lsStub.setItem('theme', 'light') document.documentElement.dataset['theme'] = 'light' setTheme(null) expect(lsStub.getItem('theme')).toBeNull() expect(document.documentElement.dataset['theme']).toBe('dark') }) it('setTheme(null) re-syncs with current OS preference (light)', () => { const mql = makeMockMQL(false) // OS = light stubMatchMedia(mql) lsStub.setItem('theme', 'dark') document.documentElement.dataset['theme'] = 'dark' setTheme(null) expect(lsStub.getItem('theme')).toBeNull() expect(document.documentElement.dataset['theme']).toBe('light') }) it('setTheme("dark") writes localStorage and updates data-theme', () => { const mql = makeMockMQL(false) stubMatchMedia(mql) setTheme('dark') expect(lsStub.getItem('theme')).toBe('dark') expect(document.documentElement.dataset['theme']).toBe('dark') }) it('setTheme("light") writes localStorage and updates data-theme', () => { const mql = makeMockMQL(true) stubMatchMedia(mql) setTheme('light') expect(lsStub.getItem('theme')).toBe('light') expect(document.documentElement.dataset['theme']).toBe('light') }) }) describe('getThemePreference', () => { it('no stored key → "system"', () => { expect(getThemePreference()).toBe('system') }) it('stored "light" → "light"', () => { lsStub.setItem('theme', 'light') expect(getThemePreference()).toBe('light') }) it('stored "dark" → "dark"', () => { lsStub.setItem('theme', 'dark') expect(getThemePreference()).toBe('dark') }) })