import React from 'react';
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import {
ThemeProvider,
ThemeToggle,
useTheme,
getThemeFoucScript,
THEME_STORAGE_KEY,
} from './index';
/**
* These tests run in jsdom, which implements localStorage but not
* matchMedia by default. We install a minimal shim that records the media
* query and fires change listeners on demand so we can assert behavior
* under both light- and dark-preferring systems.
*/
type MqlShim = {
matches: boolean;
media: string;
listeners: Array<(e: MediaQueryListEvent) => void>;
addEventListener: (type: string, cb: (e: MediaQueryListEvent) => void) => void;
removeEventListener: (type: string, cb: (e: MediaQueryListEvent) => void) => void;
dispatchEvent: (e: MediaQueryListEvent) => boolean;
fire: (matches: boolean) => void;
};
function installMatchMedia(initial: boolean) {
const shim: MqlShim = {
matches: initial,
media: '(prefers-color-scheme: dark)',
listeners: [],
addEventListener(_type, cb) {
this.listeners.push(cb);
},
removeEventListener(_type, cb) {
this.listeners = this.listeners.filter((l) => l !== cb);
},
dispatchEvent: () => true,
fire(matches: boolean) {
this.matches = matches;
for (const cb of this.listeners) {
cb({ matches, media: this.media } as MediaQueryListEvent);
}
},
};
window.matchMedia = vi.fn(() => shim as unknown as MediaQueryList);
return shim;
}
describe('ThemeProvider', () => {
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
});
afterEach(() => {
// restore to avoid cross-test leakage since we stub window.matchMedia
// @ts-expect-error — resetting between tests
delete window.matchMedia;
});
it('defaults to "system" preference and resolves from prefers-color-scheme', () => {
installMatchMedia(true); // system prefers dark
render(
,
);
expect(screen.getByTestId('preference')).toHaveTextContent('system');
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('reads stored preference on mount and applies it', () => {
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark');
installMatchMedia(false); // system would be light, but stored wins
render(
,
);
expect(screen.getByTestId('preference')).toHaveTextContent('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('setPreference updates DOM and persists to localStorage', async () => {
installMatchMedia(false);
render(
,
);
await userEvent.click(screen.getByTestId('set-dark'));
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark');
});
it('follows OS changes while preference is "system"', () => {
const mql = installMatchMedia(false);
render(
,
);
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
act(() => mql.fire(true));
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('ignores OS changes when a hard preference is set', async () => {
const mql = installMatchMedia(false);
render(
,
);
await userEvent.click(screen.getByTestId('set-light'));
act(() => mql.fire(true));
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
});
it('useTheme throws outside a provider so misuse is loud', () => {
// React logs the error to console; we silence it for a clean test output.
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render()).toThrow(/useTheme/);
spy.mockRestore();
});
});
describe('ThemeToggle', () => {
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
installMatchMedia(false);
});
it('cycles light → dark → system → light', async () => {
window.localStorage.setItem(THEME_STORAGE_KEY, 'light');
render(
,
);
const btn = screen.getByRole('button');
expect(btn).toHaveAttribute('data-theme-toggle', 'light');
await userEvent.click(btn);
expect(btn).toHaveAttribute('data-theme-toggle', 'dark');
await userEvent.click(btn);
expect(btn).toHaveAttribute('data-theme-toggle', 'system');
await userEvent.click(btn);
expect(btn).toHaveAttribute('data-theme-toggle', 'light');
});
it('exposes an accessible label that names the current preference', () => {
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark');
render(
,
);
const btn = screen.getByRole('button');
expect(btn.getAttribute('aria-label')).toContain('Dark');
});
});
describe('getThemeFoucScript', () => {
it('returns an IIFE string that reads storage and sets data-theme', () => {
const script = getThemeFoucScript();
expect(script).toContain("setAttribute('data-theme'");
expect(script).toContain('prefers-color-scheme');
expect(script).toContain(THEME_STORAGE_KEY);
expect(script.length).toBeLessThan(600); // keep it light
});
it('honours a custom storage key', () => {
expect(getThemeFoucScript('my-key')).toContain('"my-key"');
});
});
// Helper consumer that exposes context values via the DOM for assertions.
function Consumer() {
const { preference, theme, setPreference } = useTheme();
return (
{preference}
{theme}
);
}