import { renderHook, act } from '@testing-library/react'; import { useTheme } from '../useTheme'; import { useLocalStorage } from '../useLocalStorage'; // Mock the useLocalStorage hook jest.mock('../useLocalStorage'); const mockUseLocalStorage = useLocalStorage as jest.MockedFunction< typeof useLocalStorage >; // Mock window.matchMedia const mockMatchMedia = jest.fn(); Object.defineProperty(window, 'matchMedia', { writable: true, value: mockMatchMedia, }); // Mock document.documentElement const mockDocumentElement = { classList: { remove: jest.fn(), add: jest.fn(), }, }; Object.defineProperty(document, 'documentElement', { writable: true, value: mockDocumentElement, }); describe('useTheme', () => { let mockSetTheme: jest.Mock; let mockMediaQueryList: { matches: boolean; addEventListener: jest.Mock; removeEventListener: jest.Mock; }; beforeEach(() => { jest.clearAllMocks(); mockSetTheme = jest.fn(); mockMediaQueryList = { matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn(), }; mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]); mockMatchMedia.mockReturnValue(mockMediaQueryList); }); afterEach(() => { jest.restoreAllMocks(); }); describe('initialization', () => { test('should initialize with system theme by default', () => { const { result } = renderHook(() => useTheme()); expect(mockUseLocalStorage).toHaveBeenCalledWith('theme', 'system'); expect(result.current.theme).toBe('system'); expect(typeof result.current.setTheme).toBe('function'); }); test('should apply theme on mount', () => { renderHook(() => useTheme()); expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith( 'light', 'dark' ); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light'); }); }); describe('theme application', () => { test('should apply light theme when theme is light', () => { mockUseLocalStorage.mockReturnValue(['light', mockSetTheme]); renderHook(() => useTheme()); expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith( 'light', 'dark' ); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light'); }); test('should apply dark theme when theme is dark', () => { mockUseLocalStorage.mockReturnValue(['dark', mockSetTheme]); renderHook(() => useTheme()); expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith( 'light', 'dark' ); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark'); }); test('should apply system theme when theme is system and system prefers light', () => { mockMediaQueryList.matches = false; mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]); renderHook(() => useTheme()); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light'); }); test('should apply system theme when theme is system and system prefers dark', () => { mockMediaQueryList.matches = true; mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]); renderHook(() => useTheme()); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark'); }); }); describe('system theme detection', () => { test('should detect system theme as light when prefers-color-scheme is light', () => { mockMediaQueryList.matches = false; const { result } = renderHook(() => useTheme()); expect(mockMatchMedia).toHaveBeenCalledWith( '(prefers-color-scheme: dark)' ); expect(result.current.theme).toBe('system'); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light'); }); test('should detect system theme as dark when prefers-color-scheme is dark', () => { mockMediaQueryList.matches = true; renderHook(() => useTheme()); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark'); }); test('should fallback to light theme when window.matchMedia is not available', () => { // Mock window as undefined const originalWindow = global.window; delete (global as any).window; mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]); const { result } = renderHook(() => useTheme()); expect(result.current.theme).toBe('system'); // Restore window global.window = originalWindow; }); test('should fallback to light theme when matchMedia is not available', () => { Object.defineProperty(window, 'matchMedia', { writable: true, value: undefined, }); mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]); renderHook(() => useTheme()); // Should still work and default to light theme for system expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light'); }); }); describe('media query listener', () => { test('should add event listener for system theme changes', () => { mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]); renderHook(() => useTheme()); expect(mockMediaQueryList.addEventListener).toHaveBeenCalledWith( 'change', expect.any(Function) ); }); test('should remove event listener on unmount', () => { const { unmount } = renderHook(() => useTheme()); unmount(); expect(mockMediaQueryList.removeEventListener).toHaveBeenCalledWith( 'change', expect.any(Function) ); }); test('should update theme when system preference changes and using system theme', () => { mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]); mockMediaQueryList.matches = false; renderHook(() => useTheme()); // Get the change handler const changeHandler = mockMediaQueryList.addEventListener.mock.calls[0][1]; // Clear previous calls mockDocumentElement.classList.remove.mockClear(); mockDocumentElement.classList.add.mockClear(); // Simulate system theme change mockMediaQueryList.matches = true; act(() => { changeHandler(); }); expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith( 'light', 'dark' ); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark'); }); test('should not update theme when system preference changes but not using system theme', () => { mockUseLocalStorage.mockReturnValue(['light', mockSetTheme]); renderHook(() => useTheme()); // Get the change handler const changeHandler = mockMediaQueryList.addEventListener.mock.calls[0][1]; // Clear previous calls mockDocumentElement.classList.remove.mockClear(); mockDocumentElement.classList.add.mockClear(); // Simulate system theme change act(() => { changeHandler(); }); // Should not trigger theme change since we're using explicit light theme expect(mockDocumentElement.classList.remove).not.toHaveBeenCalled(); expect(mockDocumentElement.classList.add).not.toHaveBeenCalled(); }); }); describe('theme updates', () => { test('should update theme when setTheme is called', () => { const { result, rerender } = renderHook(() => useTheme()); // Change to dark theme mockUseLocalStorage.mockReturnValue(['dark', mockSetTheme]); rerender(); expect(result.current.theme).toBe('dark'); }); test('should apply new theme when theme changes', () => { const { rerender } = renderHook(() => useTheme()); // Clear initial calls mockDocumentElement.classList.remove.mockClear(); mockDocumentElement.classList.add.mockClear(); // Change to dark theme mockUseLocalStorage.mockReturnValue(['dark', mockSetTheme]); rerender(); expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith( 'light', 'dark' ); expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark'); }); }); describe('edge cases', () => { test('should handle invalid theme values gracefully', () => { mockUseLocalStorage.mockReturnValue(['invalid' as any, mockSetTheme]); const { result } = renderHook(() => useTheme()); expect(result.current.theme).toBe('invalid'); // Should still try to apply the theme expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('invalid'); }); test('should handle missing document.documentElement', () => { const originalDocumentElement = document.documentElement; delete (document as any).documentElement; expect(() => { renderHook(() => useTheme()); }).not.toThrow(); // Restore Object.defineProperty(document, 'documentElement', { value: originalDocumentElement, writable: true, configurable: true, }); }); test('should handle classList methods not being available', () => { const originalClassList = mockDocumentElement.classList; mockDocumentElement.classList = undefined as any; expect(() => { renderHook(() => useTheme()); }).not.toThrow(); // Restore mockDocumentElement.classList = originalClassList; }); }); describe('return value', () => { test('should return current theme and setTheme function', () => { const { result } = renderHook(() => useTheme()); expect(result.current).toEqual({ theme: 'system', setTheme: mockSetTheme, }); }); test('should maintain stable setTheme reference', () => { const { result, rerender } = renderHook(() => useTheme()); const firstSetTheme = result.current.setTheme; rerender(); const secondSetTheme = result.current.setTheme; expect(firstSetTheme).toBe(secondSetTheme); }); }); });