import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { bootstrapTheme, setTheme } 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<string, string> = {}
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')
})
})