diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..6adb0cf --- /dev/null +++ b/jest.config.js @@ -0,0 +1,27 @@ +module.exports = { + testEnvironment: 'jsdom', + roots: ['/tests'], + testMatch: [ + '**/__tests__/**/*.js', + '**/?(*.)+(spec|test).js' + ], + collectCoverageFrom: [ + 'src/**/*.js', + '!src/**/*.spec.js', + '!src/**/*.test.js', + '!**/node_modules/**', + '!**/vendor/**' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + setupFilesAfterEnv: ['/tests/setup.js'], + moduleNameMapper: { + '\\.(css|less|scss|sass)$': '/tests/__mocks__/styleMock.js', + '\\.(jpg|jpeg|png|gif|svg)$': '/tests/__mocks__/fileMock.js' + }, + transform: { + '^.+\\.js$': 'babel-jest' + }, + testTimeout: 10000, + verbose: true +}; diff --git a/tests/__mocks__/fileMock.js b/tests/__mocks__/fileMock.js new file mode 100644 index 0000000..86059f3 --- /dev/null +++ b/tests/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/tests/__mocks__/styleMock.js b/tests/__mocks__/styleMock.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/tests/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..58e8e51 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,60 @@ +/** + * Jest Setup File + * Configures test environment and global utilities + */ + +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +// Mock localStorage +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), +}; +global.localStorage = localStorageMock; + +// Mock sessionStorage +const sessionStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), +}; +global.sessionStorage = sessionStorageMock; + +// Mock console methods to reduce noise in tests +global.console = { + ...console, + error: jest.fn(), + warn: jest.fn(), +}; + +// Add custom matchers +expect.extend({ + toBeVisible(received) { + const pass = received.style.display !== 'none' && + received.style.visibility !== 'hidden' && + received.style.opacity !== '0'; + + return { + pass, + message: () => pass + ? `expected element not to be visible` + : `expected element to be visible`, + }; + }, +}); diff --git a/tests/unit/theme.test.js b/tests/unit/theme.test.js new file mode 100644 index 0000000..765bb1b --- /dev/null +++ b/tests/unit/theme.test.js @@ -0,0 +1,285 @@ +/** + * Theme Utility Tests + * Tests for dark/light theme switching and color management + */ + +import Theme from '../../src/assets/scripts/utils/theme.js'; + +describe('Theme Utility', () => { + beforeEach(() => { + // Clear localStorage + localStorage.clear(); + // Reset document theme + document.documentElement.removeAttribute('data-theme'); + // Clear any theme-related CSS variables + document.documentElement.style.cssText = ''; + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe('Theme Application', () => { + it('should apply light theme', () => { + Theme.apply('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + + it('should apply dark theme', () => { + Theme.apply('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + it('should save theme to localStorage', () => { + Theme.apply('dark'); + expect(localStorage.getItem('adminator-theme')).toBe('dark'); + }); + + it('should dispatch theme changed event', () => { + const handler = jest.fn(); + window.addEventListener('adminator:themeChanged', handler); + + Theme.apply('dark'); + + expect(handler).toHaveBeenCalled(); + expect(handler.mock.calls[0][0].detail.theme).toBe('dark'); + + window.removeEventListener('adminator:themeChanged', handler); + }); + + it('should handle localStorage errors gracefully', () => { + // Mock localStorage.setItem to throw + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = jest.fn(() => { + throw new Error('QuotaExceededError'); + }); + + expect(() => Theme.apply('dark')).not.toThrow(); + + Storage.prototype.setItem = originalSetItem; + }); + }); + + describe('Theme Toggle', () => { + it('should toggle from light to dark', () => { + Theme.apply('light'); + Theme.toggle(); + expect(Theme.current()).toBe('dark'); + }); + + it('should toggle from dark to light', () => { + Theme.apply('dark'); + Theme.toggle(); + expect(Theme.current()).toBe('light'); + }); + + it('should toggle multiple times', () => { + Theme.apply('light'); + Theme.toggle(); // dark + expect(Theme.current()).toBe('dark'); + Theme.toggle(); // light + expect(Theme.current()).toBe('light'); + Theme.toggle(); // dark + expect(Theme.current()).toBe('dark'); + }); + }); + + describe('Current Theme', () => { + it('should return light as default', () => { + expect(Theme.current()).toBe('light'); + }); + + it('should return saved theme', () => { + localStorage.setItem('adminator-theme', 'dark'); + expect(Theme.current()).toBe('dark'); + }); + + it('should handle localStorage errors', () => { + const originalGetItem = Storage.prototype.getItem; + Storage.prototype.getItem = jest.fn(() => { + throw new Error('SecurityError'); + }); + + expect(Theme.current()).toBe('light'); + + Storage.prototype.getItem = originalGetItem; + }); + }); + + describe('Theme Initialization', () => { + it('should initialize with saved theme', () => { + localStorage.setItem('adminator-theme', 'dark'); + Theme.init(); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + it('should detect OS dark mode preference', () => { + // Mock matchMedia to prefer dark + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(prefers-color-scheme: dark)', + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + Theme.init(); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + it('should detect OS light mode preference', () => { + // Mock matchMedia to prefer light + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + Theme.init(); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + + it('should not override existing theme', () => { + localStorage.setItem('adminator-theme', 'light'); + + // Mock matchMedia to prefer dark + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(prefers-color-scheme: dark)', + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + Theme.init(); + // Should use saved 'light', not OS preference 'dark' + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + }); + + describe('CSS Variable Access', () => { + it('should get CSS variable value', () => { + document.documentElement.style.setProperty('--test-color', '#FF0000'); + const value = Theme.getCSSVar('--test-color'); + expect(value).toBe('#FF0000'); + }); + + it('should trim whitespace from CSS variable', () => { + document.documentElement.style.setProperty('--test-color', ' #FF0000 '); + const value = Theme.getCSSVar('--test-color'); + expect(value).toBe('#FF0000'); + }); + + it('should return empty string for non-existent variable', () => { + const value = Theme.getCSSVar('--non-existent'); + expect(value).toBe(''); + }); + }); + + describe('Color Getters', () => { + beforeEach(() => { + // Set up CSS variables + document.documentElement.style.setProperty('--vmap-bg-color', '#FFFFFF'); + document.documentElement.style.setProperty('--vmap-border-color', '#CCCCCC'); + document.documentElement.style.setProperty('--sparkline-success', '#28A745'); + document.documentElement.style.setProperty('--sparkline-purple', '#6F42C1'); + }); + + describe('getVectorMapColors', () => { + it('should return vector map colors object', () => { + const colors = Theme.getVectorMapColors(); + expect(colors).toHaveProperty('backgroundColor'); + expect(colors).toHaveProperty('borderColor'); + expect(colors).toHaveProperty('regionColor'); + }); + + it('should return correct color values', () => { + const colors = Theme.getVectorMapColors(); + expect(colors.backgroundColor).toBe('#FFFFFF'); + expect(colors.borderColor).toBe('#CCCCCC'); + }); + }); + + describe('getSparklineColors', () => { + it('should return sparkline colors object', () => { + const colors = Theme.getSparklineColors(); + expect(colors).toHaveProperty('success'); + expect(colors).toHaveProperty('purple'); + expect(colors).toHaveProperty('info'); + expect(colors).toHaveProperty('danger'); + expect(colors).toHaveProperty('light'); + }); + + it('should return correct color values', () => { + const colors = Theme.getSparklineColors(); + expect(colors.success).toBe('#28A745'); + expect(colors.purple).toBe('#6F42C1'); + }); + }); + + describe('getChartColors', () => { + it('should return light theme chart colors', () => { + Theme.apply('light'); + const colors = Theme.getChartColors(); + expect(colors.textColor).toBe('#212529'); + expect(colors.mutedColor).toBe('#6C757D'); + }); + + it('should return dark theme chart colors', () => { + Theme.apply('dark'); + const colors = Theme.getChartColors(); + expect(colors.textColor).toBe('#FFFFFF'); + expect(colors.mutedColor).toBe('#D1D5DB'); + }); + + it('should have all required color properties', () => { + const colors = Theme.getChartColors(); + expect(colors).toHaveProperty('textColor'); + expect(colors).toHaveProperty('mutedColor'); + expect(colors).toHaveProperty('borderColor'); + expect(colors).toHaveProperty('gridColor'); + expect(colors).toHaveProperty('tooltipBg'); + }); + }); + }); + + describe('Theme Persistence', () => { + it('should persist theme across page reloads', () => { + Theme.apply('dark'); + expect(localStorage.getItem('adminator-theme')).toBe('dark'); + + // Simulate page reload + const savedTheme = localStorage.getItem('adminator-theme'); + expect(savedTheme).toBe('dark'); + }); + + it('should maintain theme after toggle', () => { + Theme.apply('light'); + Theme.toggle(); + expect(localStorage.getItem('adminator-theme')).toBe('dark'); + }); + }); + + describe('Edge Cases', () => { + it('should handle invalid theme values', () => { + Theme.apply('invalid-theme'); + expect(document.documentElement.getAttribute('data-theme')).toBe('invalid-theme'); + }); + + it('should handle null theme', () => { + expect(() => Theme.apply(null)).not.toThrow(); + }); + + it('should handle undefined theme', () => { + expect(() => Theme.apply(undefined)).not.toThrow(); + }); + + it('should handle missing matchMedia', () => { + const originalMatchMedia = window.matchMedia; + delete window.matchMedia; + + expect(() => Theme.init()).not.toThrow(); + + window.matchMedia = originalMatchMedia; + }); + }); +});