/** * Tests for ThemeContext - theme-context.tsx * * Tests theme switching, system theme detection, and localStorage persistence. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { ThemeProvider, useTheme, type ThemeContextValue } from '../../context/theme-context' // Helper component to consume theme context function ThemeConsumer({ onTheme }: { onTheme: (theme: ThemeContextValue) => void }) { const themeCtx = useTheme() onTheme(themeCtx) return (
{themeCtx.theme} {themeCtx.resolvedTheme}
) } // Mock matchMedia for system theme detection function createMatchMediaMock(prefersDark: boolean) { return vi.fn().mockImplementation((query: string) => ({ matches: query === '(prefers-color-scheme: dark)' && prefersDark, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })) } describe('ThemeContext', () => { let originalMatchMedia: typeof window.matchMedia let localStorageMock: { [key: string]: string } beforeEach(() => { // Mock localStorage localStorageMock = {} vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => localStorageMock[key] || null) vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => { localStorageMock[key] = value }) vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => { delete localStorageMock[key] }) // Mock matchMedia originalMatchMedia = window.matchMedia window.matchMedia = createMatchMediaMock(false) // Default to light mode // Mock document.documentElement document.documentElement.classList.remove('light', 'dark') }) afterEach(() => { window.matchMedia = originalMatchMedia vi.restoreAllMocks() }) describe('ThemeProvider', () => { it('should provide theme state to children', () => { let themeValue: ThemeContextValue | undefined render( { themeValue = t }} /> ) expect(themeValue).toBeDefined() expect(screen.getByTestId('theme').textContent).toBe('light') expect(screen.getByTestId('resolved-theme').textContent).toBe('light') }) it('should use defaultTheme when no localStorage value', () => { render( {}} /> ) expect(screen.getByTestId('theme').textContent).toBe('dark') expect(screen.getByTestId('resolved-theme').textContent).toBe('dark') }) it('should read theme from localStorage on mount', () => { localStorageMock['mdxui-app-theme'] = 'dark' render( {}} /> ) expect(screen.getByTestId('theme').textContent).toBe('dark') }) it('should use custom storageKey', () => { localStorageMock['my-custom-theme'] = 'dark' render( {}} /> ) expect(screen.getByTestId('theme').textContent).toBe('dark') }) it('should apply theme class to document root', async () => { render( {}} /> ) await waitFor(() => { expect(document.documentElement.classList.contains('dark')).toBe(true) }) }) }) describe('theme switching', () => { it('should switch from light to dark', async () => { render( {}} /> ) expect(screen.getByTestId('theme').textContent).toBe('light') act(() => { fireEvent.click(screen.getByTestId('set-dark-btn')) }) await waitFor(() => { expect(screen.getByTestId('theme').textContent).toBe('dark') expect(screen.getByTestId('resolved-theme').textContent).toBe('dark') }) }) it('should switch from dark to light', async () => { render( {}} /> ) expect(screen.getByTestId('theme').textContent).toBe('dark') act(() => { fireEvent.click(screen.getByTestId('set-light-btn')) }) await waitFor(() => { expect(screen.getByTestId('theme').textContent).toBe('light') expect(screen.getByTestId('resolved-theme').textContent).toBe('light') }) }) it('should persist theme to localStorage', async () => { render( {}} /> ) act(() => { fireEvent.click(screen.getByTestId('set-dark-btn')) }) await waitFor(() => { expect(localStorageMock['mdxui-app-theme']).toBe('dark') }) }) it('should update document class on theme change', async () => { render( {}} /> ) await waitFor(() => { expect(document.documentElement.classList.contains('light')).toBe(true) }) act(() => { fireEvent.click(screen.getByTestId('set-dark-btn')) }) await waitFor(() => { expect(document.documentElement.classList.contains('dark')).toBe(true) expect(document.documentElement.classList.contains('light')).toBe(false) }) }) }) describe('toggleTheme', () => { it('should toggle from light to dark', async () => { render( {}} /> ) expect(screen.getByTestId('resolved-theme').textContent).toBe('light') act(() => { fireEvent.click(screen.getByTestId('toggle-btn')) }) await waitFor(() => { expect(screen.getByTestId('resolved-theme').textContent).toBe('dark') }) }) it('should toggle from dark to light', async () => { render( {}} /> ) expect(screen.getByTestId('resolved-theme').textContent).toBe('dark') act(() => { fireEvent.click(screen.getByTestId('toggle-btn')) }) await waitFor(() => { expect(screen.getByTestId('resolved-theme').textContent).toBe('light') }) }) it('should toggle from system (light) to dark', async () => { window.matchMedia = createMatchMediaMock(false) // System prefers light render( {}} /> ) await waitFor(() => { expect(screen.getByTestId('theme').textContent).toBe('system') expect(screen.getByTestId('resolved-theme').textContent).toBe('light') }) act(() => { fireEvent.click(screen.getByTestId('toggle-btn')) }) await waitFor(() => { // Toggle should switch resolved theme, changing actual theme from system expect(screen.getByTestId('theme').textContent).toBe('dark') expect(screen.getByTestId('resolved-theme').textContent).toBe('dark') }) }) }) describe('system theme detection', () => { it('should resolve to light when system prefers light', async () => { window.matchMedia = createMatchMediaMock(false) render( {}} /> ) await waitFor(() => { expect(screen.getByTestId('theme').textContent).toBe('system') expect(screen.getByTestId('resolved-theme').textContent).toBe('light') }) }) it('should resolve to dark when system prefers dark', async () => { window.matchMedia = createMatchMediaMock(true) render( {}} /> ) await waitFor(() => { expect(screen.getByTestId('theme').textContent).toBe('system') expect(screen.getByTestId('resolved-theme').textContent).toBe('dark') }) }) it('should register listener for system theme changes', async () => { let addEventListenerCalled = false const mockMatchMedia = vi.fn().mockImplementation((query: string) => ({ matches: query === '(prefers-color-scheme: dark)' && false, // Start light media: query, addEventListener: vi.fn((event: string) => { if (event === 'change') addEventListenerCalled = true }), removeEventListener: vi.fn(), })) window.matchMedia = mockMatchMedia render( {}} /> ) await waitFor(() => { expect(screen.getByTestId('resolved-theme').textContent).toBe('light') }) // Verify the event listener was registered expect(addEventListenerCalled).toBe(true) }) }) describe('useTheme', () => { it('should throw error when used outside ThemeProvider', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => { render( {}} />) }).toThrow('useTheme must be used within a ThemeProvider') consoleError.mockRestore() }) }) describe('edge cases', () => { it('should handle rapid theme changes', async () => { render( {}} /> ) // Rapidly change themes act(() => { fireEvent.click(screen.getByTestId('set-dark-btn')) fireEvent.click(screen.getByTestId('set-light-btn')) fireEvent.click(screen.getByTestId('set-dark-btn')) }) await waitFor(() => { expect(screen.getByTestId('theme').textContent).toBe('dark') expect(localStorageMock['mdxui-app-theme']).toBe('dark') }) }) it('should handle invalid localStorage value gracefully', () => { localStorageMock['mdxui-app-theme'] = 'invalid-theme' // Should use localStorage value as-is since it's typed as Theme render( {}} /> ) // The implementation reads from localStorage as Theme type // This tests that no crash occurs with unexpected values expect(screen.getByTestId('theme')).toBeDefined() // The invalid value is read as-is from localStorage expect(screen.getByTestId('theme').textContent).toBe('invalid-theme') }) }) })