/** @vitest-environment jsdom */ // ============================================================ // Mock MediaQueryListEvent for system theme change simulations // Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts // ============================================================ class MockMediaQueryListEvent extends Event { matches: boolean; media: string; constructor(type: string, eventInitDict: { matches: boolean; media: string }) { super(type); this.matches = eventInitDict.matches; this.media = eventInitDict.media; } } // ============================================================ // NOW IT'S SAFE TO IMPORT // ============================================================ import { afterEach, beforeEach, describe, expect, it, vi, } from 'vitest'; import { ThemeManager } from './ThemeManager.svelte'; /** * Test Suite for ThemeManager * * Tests theme management functionality including: * - Initial state from localStorage or system preference * - Theme setting and persistence * - Toggle functionality * - System preference detection and following * - DOM manipulation for theme application * - MediaQueryList listener management */ // Storage key used by ThemeManager const STORAGE_KEY = 'glyphdiff:theme'; // Helper type for MediaQueryList event handler type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void; // Helper to flush Svelte effects (they run in microtasks) async function flushEffects() { await Promise.resolve(); } describe('ThemeManager', () => { let classListMock: DOMTokenList; let darkClassAdded = false; let mediaQueryListeners: Map> = new Map(); let matchMediaSpy: ReturnType; beforeEach(() => { // Reset tracking variables darkClassAdded = false; mediaQueryListeners.clear(); // Clear localStorage before each test localStorage.clear(); // Mock documentElement.classList classListMock = { contains: (className: string) => className === 'dark' ? darkClassAdded : false, add: vi.fn((..._classNames: string[]) => { darkClassAdded = true; }), remove: vi.fn((..._classNames: string[]) => { darkClassAdded = false; }), toggle: vi.fn((className: string, force?: boolean) => { if (className === 'dark') { if (force !== undefined) { darkClassAdded = force; } else { darkClassAdded = !darkClassAdded; } return darkClassAdded; } return false; }), supports: vi.fn(() => true), entries: vi.fn(() => []), forEach: vi.fn(), keys: vi.fn(() => []), values: vi.fn(() => []), length: 0, item: vi.fn(() => null), replace: vi.fn(() => false), } as unknown as DOMTokenList; // Mock document.documentElement if (typeof document !== 'undefined' && document.documentElement) { Object.defineProperty(document.documentElement, 'classList', { configurable: true, get: () => classListMock, }); } // Mock window.matchMedia with spy to track listeners matchMediaSpy = vi.fn((query: string) => { // Default to light theme (matches = false) const mediaQueryList = { matches: false, media: query, onchange: null, addListener: vi.fn(), // Deprecated removeListener: vi.fn(), // Deprecated addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => { if (!mediaQueryListeners.has(query)) { mediaQueryListeners.set(query, new Set()); } mediaQueryListeners.get(query)!.add(listener); }), removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => { if (mediaQueryListeners.has(query)) { mediaQueryListeners.get(query)!.delete(listener); } }), dispatchEvent: vi.fn(), }; return mediaQueryList; }); Object.defineProperty(window, 'matchMedia', { writable: true, value: matchMediaSpy, }); }); afterEach(() => { vi.restoreAllMocks(); }); /** * Helper to trigger a MediaQueryList change event */ function triggerSystemThemeChange(isDark: boolean) { const query = '(prefers-color-scheme: dark)'; const listeners = mediaQueryListeners.get(query); if (listeners) { const event = new MockMediaQueryListEvent('change', { matches: isDark, media: query, }); listeners.forEach(listener => listener.call({ matches: isDark, media: query } as MediaQueryList, event)); } } describe('Constructor - Initial State', () => { it('should initialize with light theme when localStorage is empty and system prefers light', () => { const manager = new ThemeManager(); expect(manager.value).toBe('light'); expect(manager.isDark).toBe(false); expect(manager.source).toBe('system'); }); it('should initialize with system dark theme when localStorage is empty', () => { // Mock system prefers dark theme matchMediaSpy.mockImplementation((query: string) => ({ matches: query === '(prefers-color-scheme: dark)', media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })); const manager = new ThemeManager(); expect(manager.value).toBe('dark'); expect(manager.isDark).toBe(true); expect(manager.source).toBe('system'); }); it('should initialize with stored light theme from localStorage', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('light')); const manager = new ThemeManager(); expect(manager.value).toBe('light'); expect(manager.isDark).toBe(false); expect(manager.source).toBe('user'); }); it('should initialize with stored dark theme from localStorage', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); expect(manager.value).toBe('dark'); expect(manager.isDark).toBe(true); expect(manager.source).toBe('user'); }); it('should ignore invalid values in localStorage and use system theme', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('invalid')); const manager = new ThemeManager(); expect(manager.value).toBe('light'); expect(manager.source).toBe('system'); }); it('should handle null in localStorage as system theme', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(null)); const manager = new ThemeManager(); expect(manager.value).toBe('light'); expect(manager.source).toBe('system'); }); it('should be in user-controlled mode when localStorage has a valid theme', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); expect(manager.isUserControlled).toBe(true); }); it('should not be in user-controlled mode when following system', () => { const manager = new ThemeManager(); expect(manager.isUserControlled).toBe(false); expect(manager.source).toBe('system'); }); }); describe('init() - Initialization', () => { it('should apply initial theme to DOM on init', () => { const manager = new ThemeManager(); manager.init(); expect(classListMock.toggle).toHaveBeenCalledWith('dark', false); }); it('should apply dark theme to DOM when initialized with dark theme', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.init(); expect(classListMock.toggle).toHaveBeenCalledWith('dark', true); }); it('should set up MediaQueryList listener on init', () => { const manager = new ThemeManager(); manager.init(); expect(matchMediaSpy).toHaveBeenCalledWith('(prefers-color-scheme: dark)'); }); it('should not fail if called multiple times', () => { const manager = new ThemeManager(); expect(() => { manager.init(); manager.init(); }).not.toThrow(); }); }); describe('destroy() - Cleanup', () => { it('should remove MediaQueryList listener on destroy', () => { const manager = new ThemeManager(); manager.init(); manager.destroy(); const listeners = mediaQueryListeners.get('(prefers-color-scheme: dark)'); expect(listeners?.size ?? 0).toBe(0); }); it('should not fail if destroy is called before init', () => { const manager = new ThemeManager(); expect(() => { manager.destroy(); }).not.toThrow(); }); it('should not fail if destroy is called multiple times', () => { const manager = new ThemeManager(); manager.init(); expect(() => { manager.destroy(); manager.destroy(); }).not.toThrow(); }); }); describe('setTheme() - Set Explicit Theme', () => { it('should set theme to light and update source to user', () => { const manager = new ThemeManager(); manager.setTheme('light'); expect(manager.value).toBe('light'); expect(manager.isDark).toBe(false); expect(manager.source).toBe('user'); }); it('should set theme to dark and update source to user', () => { const manager = new ThemeManager(); manager.setTheme('dark'); expect(manager.value).toBe('dark'); expect(manager.isDark).toBe(true); expect(manager.source).toBe('user'); }); it('should save theme to localStorage when set', async () => { const manager = new ThemeManager(); manager.setTheme('dark'); await flushEffects(); expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark')); }); it('should apply theme to DOM when set', () => { const manager = new ThemeManager(); manager.setTheme('dark'); expect(classListMock.toggle).toHaveBeenCalledWith('dark', true); }); it('should overwrite existing localStorage value', async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('light')); const manager = new ThemeManager(); manager.setTheme('dark'); await flushEffects(); expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark')); }); it('should handle switching from light to dark', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('light')); const manager = new ThemeManager(); manager.init(); manager.setTheme('dark'); expect(manager.value).toBe('dark'); expect(manager.source).toBe('user'); }); it('should handle switching from dark to light', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.init(); manager.setTheme('light'); expect(manager.value).toBe('light'); expect(manager.source).toBe('user'); }); }); describe('toggle() - Toggle Between Themes', () => { it('should toggle from light to dark', () => { const manager = new ThemeManager(); manager.toggle(); expect(manager.value).toBe('dark'); expect(manager.isDark).toBe(true); expect(manager.source).toBe('user'); }); it('should toggle from dark to light', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.toggle(); expect(manager.value).toBe('light'); expect(manager.isDark).toBe(false); expect(manager.source).toBe('user'); }); it('should save toggled theme to localStorage', async () => { const manager = new ThemeManager(); manager.toggle(); await flushEffects(); expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark')); }); it('should apply toggled theme to DOM', () => { const manager = new ThemeManager(); manager.toggle(); expect(classListMock.toggle).toHaveBeenCalledWith('dark', true); }); it('should handle multiple rapid toggles', () => { const manager = new ThemeManager(); manager.toggle(); expect(manager.value).toBe('dark'); manager.toggle(); expect(manager.value).toBe('light'); manager.toggle(); expect(manager.value).toBe('dark'); }); }); describe('resetToSystem() - Reset to System Preference', () => { it('should clear localStorage when resetting to system', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.resetToSystem(); expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); it('should set source to system after reset', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.resetToSystem(); expect(manager.source).toBe('system'); expect(manager.isUserControlled).toBe(false); }); it('should detect and apply light system theme', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.resetToSystem(); expect(manager.value).toBe('light'); expect(classListMock.toggle).toHaveBeenCalledWith('dark', false); }); it('should detect and apply dark system theme', () => { // Override matchMedia to return dark preference matchMediaSpy.mockImplementation((query: string) => ({ matches: query === '(prefers-color-scheme: dark)', media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })); const manager = new ThemeManager(); manager.resetToSystem(); expect(manager.value).toBe('dark'); expect(classListMock.toggle).toHaveBeenCalledWith('dark', true); }); it('should apply system theme to DOM on reset', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.resetToSystem(); expect(classListMock.toggle).toHaveBeenCalled(); }); }); describe('System Theme Change Handling', () => { it('should update theme when system changes to dark while following system', () => { const manager = new ThemeManager(); manager.init(); triggerSystemThemeChange(true); expect(manager.value).toBe('dark'); expect(manager.isDark).toBe(true); }); it('should update theme when system changes to light while following system', () => { // Start with dark system theme // Keep the listener tracking while overriding matches behavior matchMediaSpy.mockImplementation((query: string) => ({ matches: query === '(prefers-color-scheme: dark)', media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => { if (!mediaQueryListeners.has(query)) { mediaQueryListeners.set(query, new Set()); } mediaQueryListeners.get(query)!.add(listener); }), removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => { if (mediaQueryListeners.has(query)) { mediaQueryListeners.get(query)!.delete(listener); } }), dispatchEvent: vi.fn(), })); const manager = new ThemeManager(); manager.init(); expect(manager.value).toBe('dark'); // Now change to light triggerSystemThemeChange(false); expect(manager.value).toBe('light'); expect(manager.isDark).toBe(false); }); it('should update DOM when system theme changes while following system', () => { const manager = new ThemeManager(); manager.init(); triggerSystemThemeChange(true); expect(classListMock.toggle).toHaveBeenCalledWith('dark', true); }); it('should NOT update theme when system changes if user has set theme', () => { const manager = new ThemeManager(); manager.setTheme('light'); // User explicitly sets light manager.init(); // Simulate system changing to dark triggerSystemThemeChange(true); // Theme should remain light because user set it expect(manager.value).toBe('light'); expect(manager.source).toBe('user'); }); it('should respond to system changes after resetToSystem', () => { // Start with user-controlled dark theme localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.init(); expect(manager.value).toBe('dark'); expect(manager.source).toBe('user'); // Reset to system (which is light) manager.resetToSystem(); expect(manager.value).toBe('light'); expect(manager.source).toBe('system'); // Now system changes to dark triggerSystemThemeChange(true); // Should update because we're back to following system expect(manager.value).toBe('dark'); expect(manager.source).toBe('system'); }); it('should stop responding to system changes after setTheme is called', () => { const manager = new ThemeManager(); manager.init(); // System changes to dark triggerSystemThemeChange(true); expect(manager.value).toBe('dark'); expect(manager.source).toBe('system'); // User explicitly sets light manager.setTheme('light'); expect(manager.value).toBe('light'); expect(manager.source).toBe('user'); // System changes again triggerSystemThemeChange(false); // Should stay light because user set it expect(manager.value).toBe('light'); }); it('should not trigger updates after destroy is called', () => { const manager = new ThemeManager(); manager.init(); manager.destroy(); // This should not cause any updates since listener was removed expect(() => { triggerSystemThemeChange(true); }).not.toThrow(); }); }); describe('DOM Interaction', () => { it('should add dark class when applying dark theme', () => { const manager = new ThemeManager(); manager.init(); manager.setTheme('dark'); // Check toggle was called with force=true for dark expect(classListMock.toggle).toHaveBeenCalledWith('dark', true); }); it('should remove dark class when applying light theme', () => { // Start with dark localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); manager.init(); // Switch to light manager.setTheme('light'); expect(classListMock.toggle).toHaveBeenCalledWith('dark', false); }); it('should not add dark class when system prefers light', () => { const manager = new ThemeManager(); manager.init(); expect(classListMock.toggle).toHaveBeenCalledWith('dark', false); }); }); describe('Getter Properties', () => { it('value getter should return current theme', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify('dark')); const manager = new ThemeManager(); expect(manager.value).toBe('dark'); }); it('source getter should return "user" when theme is user-controlled', () => { const manager = new ThemeManager(); manager.setTheme('dark'); expect(manager.source).toBe('user'); }); it('source getter should return "system" when following system', () => { const manager = new ThemeManager(); expect(manager.source).toBe('system'); }); it('isDark getter should return true for dark theme', () => { const manager = new ThemeManager(); manager.setTheme('dark'); expect(manager.isDark).toBe(true); }); it('isDark getter should return false for light theme', () => { const manager = new ThemeManager(); expect(manager.isDark).toBe(false); }); it('isUserControlled getter should return true when source is user', () => { const manager = new ThemeManager(); manager.setTheme('light'); expect(manager.isUserControlled).toBe(true); }); it('isUserControlled getter should return false when source is system', () => { const manager = new ThemeManager(); expect(manager.isUserControlled).toBe(false); }); }); describe('Edge Cases', () => { it('should handle rapid setTheme calls', async () => { const manager = new ThemeManager(); manager.setTheme('dark'); manager.setTheme('light'); manager.setTheme('dark'); manager.setTheme('light'); await flushEffects(); expect(manager.value).toBe('light'); expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light')); }); it('should handle toggle immediately followed by setTheme', async () => { const manager = new ThemeManager(); manager.toggle(); manager.setTheme('light'); await flushEffects(); expect(manager.value).toBe('light'); expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light')); }); it('should handle setTheme immediately followed by resetToSystem', () => { const manager = new ThemeManager(); manager.setTheme('dark'); manager.resetToSystem(); expect(manager.value).toBe('light'); expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); it('should handle resetToSystem when already following system', () => { const manager = new ThemeManager(); manager.resetToSystem(); expect(manager.value).toBe('light'); expect(manager.source).toBe('system'); expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); }); describe('Type Safety', () => { it('should accept "light" as valid theme', () => { const manager = new ThemeManager(); expect(() => manager.setTheme('light')).not.toThrow(); }); it('should accept "dark" as valid theme', () => { const manager = new ThemeManager(); expect(() => manager.setTheme('dark')).not.toThrow(); }); }); });