/** * Theme/Dark Mode Tests * * Tests for theme switching functionality in app.js * Covers localStorage persistence, DOM attribute changes, and icon updates */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * Mock implementation of the theme-related methods from app.js * This simulates the actual app behavior for testing */ class ThemeManager { initTheme() { const savedTheme = localStorage.getItem('theme') || 'light'; this.setTheme(savedTheme); } setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); const themeIcon = document.querySelector('#theme-toggle i'); if (themeIcon) { themeIcon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun'; } } toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; const newTheme = currentTheme === 'light' ? 'dark' : 'light'; this.setTheme(newTheme); } } describe('Theme Management', () => { let themeManager; beforeEach(() => { // Reset DOM document.body.innerHTML = ` `; document.documentElement.removeAttribute('data-theme'); // Clear localStorage localStorage.clear(); // Create fresh instance themeManager = new ThemeManager(); }); afterEach(() => { localStorage.clear(); document.documentElement.removeAttribute('data-theme'); }); describe('Theme Initialization', () => { it('should initialize with light theme by default when no saved preference', () => { themeManager.initTheme(); expect(document.documentElement.getAttribute('data-theme')).toBe('light'); expect(localStorage.getItem('theme')).toBe('light'); }); it('should load saved theme from localStorage', () => { localStorage.setItem('theme', 'dark'); themeManager.initTheme(); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); it('should update theme icon on initialization', () => { localStorage.setItem('theme', 'dark'); themeManager.initTheme(); const icon = document.querySelector('#theme-toggle i'); expect(icon.className).toBe('fas fa-sun'); }); it('should handle missing localStorage gracefully', () => { // Simulate localStorage not available const originalGetItem = Storage.prototype.getItem; Storage.prototype.getItem = vi.fn(() => null); themeManager.initTheme(); expect(document.documentElement.getAttribute('data-theme')).toBe('light'); Storage.prototype.getItem = originalGetItem; }); }); describe('Theme Setting', () => { it('should set light theme correctly', () => { themeManager.setTheme('light'); expect(document.documentElement.getAttribute('data-theme')).toBe('light'); expect(localStorage.getItem('theme')).toBe('light'); }); it('should set dark theme correctly', () => { themeManager.setTheme('dark'); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); expect(localStorage.getItem('theme')).toBe('dark'); }); it('should update icon to moon for light theme', () => { themeManager.setTheme('light'); const icon = document.querySelector('#theme-toggle i'); expect(icon.className).toBe('fas fa-moon'); }); it('should update icon to sun for dark theme', () => { themeManager.setTheme('dark'); const icon = document.querySelector('#theme-toggle i'); expect(icon.className).toBe('fas fa-sun'); }); it('should persist theme to localStorage', () => { themeManager.setTheme('dark'); expect(localStorage.getItem('theme')).toBe('dark'); themeManager.setTheme('light'); expect(localStorage.getItem('theme')).toBe('light'); }); it('should handle missing theme icon element gracefully', () => { document.body.innerHTML = ''; // Remove theme toggle button expect(() => { themeManager.setTheme('dark'); }).not.toThrow(); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); }); describe('Theme Toggling', () => { it('should toggle from light to dark', () => { themeManager.setTheme('light'); themeManager.toggleTheme(); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); expect(localStorage.getItem('theme')).toBe('dark'); }); it('should toggle from dark to light', () => { themeManager.setTheme('dark'); themeManager.toggleTheme(); expect(document.documentElement.getAttribute('data-theme')).toBe('light'); expect(localStorage.getItem('theme')).toBe('light'); }); it('should update icon when toggling', () => { themeManager.setTheme('light'); let icon = document.querySelector('#theme-toggle i'); expect(icon.className).toBe('fas fa-moon'); themeManager.toggleTheme(); icon = document.querySelector('#theme-toggle i'); expect(icon.className).toBe('fas fa-sun'); }); it('should handle multiple toggles correctly', () => { themeManager.setTheme('light'); themeManager.toggleTheme(); // light -> dark expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); themeManager.toggleTheme(); // dark -> light expect(document.documentElement.getAttribute('data-theme')).toBe('light'); themeManager.toggleTheme(); // light -> dark expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); it('should default to light when no theme attribute exists', () => { document.documentElement.removeAttribute('data-theme'); themeManager.toggleTheme(); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); }); describe('Theme Persistence', () => { it('should persist theme across page reloads', () => { themeManager.setTheme('dark'); // Simulate page reload by creating new instance const newThemeManager = new ThemeManager(); newThemeManager.initTheme(); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); it('should maintain theme when toggling multiple times', () => { themeManager.initTheme(); themeManager.toggleTheme(); themeManager.toggleTheme(); themeManager.toggleTheme(); // Should be dark after 3 toggles (light -> dark -> light -> dark) expect(localStorage.getItem('theme')).toBe('dark'); // Reload and verify const newThemeManager = new ThemeManager(); newThemeManager.initTheme(); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); }); describe('Theme Button Click Handler', () => { it('should toggle theme when button is clicked', () => { themeManager.initTheme(); const button = document.getElementById('theme-toggle'); button.addEventListener('click', () => { themeManager.toggleTheme(); }); expect(document.documentElement.getAttribute('data-theme')).toBe('light'); button.click(); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); button.click(); expect(document.documentElement.getAttribute('data-theme')).toBe('light'); }); }); describe('DOM Attribute Application', () => { it('should apply data-theme attribute to document root', () => { themeManager.setTheme('dark'); const root = document.documentElement; expect(root.hasAttribute('data-theme')).toBe(true); expect(root.getAttribute('data-theme')).toBe('dark'); }); it('should update existing data-theme attribute', () => { document.documentElement.setAttribute('data-theme', 'light'); themeManager.setTheme('dark'); expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); }); it('should work with CSS attribute selectors', () => { themeManager.setTheme('dark'); // CSS would use: [data-theme="dark"] { ... } const root = document.documentElement; const themValue = root.getAttribute('data-theme'); expect(themValue).toBe('dark'); }); }); describe('Edge Cases', () => { it('should handle invalid theme values gracefully', () => { themeManager.setTheme('invalid-theme'); // Should still set the attribute and localStorage expect(document.documentElement.getAttribute('data-theme')).toBe('invalid-theme'); expect(localStorage.getItem('theme')).toBe('invalid-theme'); }); it('should handle empty string theme', () => { themeManager.setTheme(''); expect(document.documentElement.getAttribute('data-theme')).toBe(''); expect(localStorage.getItem('theme')).toBe(''); }); it('should handle rapid theme changes', () => { for (let i = 0; i < 10; i++) { themeManager.toggleTheme(); } // After 10 toggles (even number), should be back to light expect(document.documentElement.getAttribute('data-theme')).toBe('light'); }); it('should work when localStorage is full', () => { // Fill localStorage (though this is hard to truly test) const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); setItemSpy.mockImplementation(() => { throw new Error('QuotaExceededError'); }); expect(() => { themeManager.setTheme('dark'); }).toThrow(); setItemSpy.mockRestore(); }); }); }); describe('Theme Icon Updates', () => { let themeManager; beforeEach(() => { document.body.innerHTML = ` `; localStorage.clear(); themeManager = new ThemeManager(); }); it('should use moon icon for light theme', () => { themeManager.setTheme('light'); const icon = document.querySelector('#theme-toggle i'); expect(icon.className).toContain('fa-moon'); expect(icon.className).not.toContain('fa-sun'); }); it('should use sun icon for dark theme', () => { themeManager.setTheme('dark'); const icon = document.querySelector('#theme-toggle i'); expect(icon.className).toContain('fa-sun'); expect(icon.className).not.toContain('fa-moon'); }); it('should update icon class completely (no class accumulation)', () => { themeManager.setTheme('light'); const icon = document.querySelector('#theme-toggle i'); expect(icon.className).toBe('fas fa-moon'); themeManager.setTheme('dark'); expect(icon.className).toBe('fas fa-sun'); expect(icon.className.split(' ').length).toBe(2); // Only 'fas' and 'fa-sun' }); });