Files
frontend-svelte/src/features/ChangeAppTheme/model/store/ThemeManager/ThemeManager.test.ts

727 lines
25 KiB
TypeScript

/** @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<string, Set<MediaQueryListCallback>> = new Map();
let matchMediaSpy: ReturnType<typeof vi.fn>;
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();
});
});
});