/**
* 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')
})
})
})