| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| import { theme as antdThemeImport } from 'antd'; |
| import { |
| type AnyThemeConfig, |
| type SupersetThemeConfig, |
| Theme, |
| ThemeAlgorithm, |
| ThemeMode, |
| } from '@superset-ui/core'; |
| import type { |
| BootstrapThemeDataConfig, |
| CommonBootstrapData, |
| } from 'src/types/bootstrapTypes'; |
| import getBootstrapData from 'src/utils/getBootstrapData'; |
| import { LocalStorageAdapter, ThemeController } from '../ThemeController'; |
| |
| jest.mock('../../utils/getBootstrapData'); |
| const mockGetBootstrapData = getBootstrapData as jest.MockedFunction< |
| typeof getBootstrapData |
| >; |
| |
| const mockLocalStorage = { |
| getItem: jest.fn(), |
| setItem: jest.fn(), |
| removeItem: jest.fn(), |
| clear: jest.fn(), |
| }; |
| |
| const mockMatchMedia = jest.fn(); |
| const mockThemeFromConfig = jest.fn(); |
| const mockSetConfig = jest.fn(); |
| |
| // Mock data constants |
| const DEFAULT_THEME: AnyThemeConfig = { |
| token: { |
| colorBgBase: '#ededed', |
| colorTextBase: '#120f0f', |
| colorLink: '#0062ec', |
| colorPrimary: '#c96f0f', |
| colorInfo: '#c96f0f', |
| colorSuccess: '#3c7c1b', |
| colorWarning: '#dc9811', |
| }, |
| }; |
| |
| const DARK_THEME: AnyThemeConfig = { |
| token: { |
| colorBgBase: '#141118', |
| colorTextBase: '#fdc7c7', |
| colorLink: '#0062ec', |
| colorPrimary: '#c96f0f', |
| colorInfo: '#c96f0f', |
| colorSuccess: '#3c7c1b', |
| colorWarning: '#dc9811', |
| }, |
| algorithm: ThemeAlgorithm.DARK, |
| }; |
| |
| // BootstrapData common template generator |
| const createMockBootstrapData = ( |
| themeConfig: BootstrapThemeDataConfig = { |
| default: DEFAULT_THEME, |
| dark: DARK_THEME, |
| }, |
| ): { common: CommonBootstrapData } => ({ |
| common: { |
| application_root: '/', |
| static_assets_prefix: '/static/assets/', |
| flash_messages: [], |
| conf: {}, |
| locale: 'en', |
| feature_flags: {}, |
| language_pack: {}, |
| extra_categorical_color_schemes: [], |
| extra_sequential_color_schemes: [], |
| theme: themeConfig, |
| menu_data: {}, |
| d3_format: {}, |
| d3_time_format: {}, |
| } as any as CommonBootstrapData, |
| }); |
| |
| const mockThemeObject = { |
| setConfig: mockSetConfig, |
| theme: DEFAULT_THEME, |
| } as unknown as Theme; |
| |
| describe('LocalStorageAdapter', () => { |
| let adapter: LocalStorageAdapter; |
| const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); |
| |
| beforeEach(() => { |
| adapter = new LocalStorageAdapter(); |
| jest.clearAllMocks(); |
| Object.defineProperty(window, 'localStorage', { |
| value: mockLocalStorage, |
| writable: true, |
| }); |
| }); |
| |
| afterAll(() => { |
| consoleSpy.mockRestore(); |
| }); |
| |
| it('should return item from localStorage', () => { |
| mockLocalStorage.getItem.mockReturnValue('test-value'); |
| |
| const result = adapter.getItem('test-key'); |
| |
| expect(mockLocalStorage.getItem).toHaveBeenCalledTimes(1); |
| expect(mockLocalStorage.getItem).toHaveBeenCalledWith('test-key'); |
| expect(result).toBe('test-value'); |
| }); |
| |
| it('should set item in localStorage', () => { |
| adapter.setItem('test-key', 'test-value'); |
| |
| expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); |
| expect(mockLocalStorage.setItem).toHaveBeenCalledWith( |
| 'test-key', |
| 'test-value', |
| ); |
| }); |
| |
| it('should handle localStorage errors while setting an item', () => { |
| mockLocalStorage.setItem.mockImplementation(() => { |
| throw new Error('Storage error'); |
| }); |
| |
| adapter.setItem('test-key', 'test-value'); |
| |
| expect(consoleSpy).toHaveBeenCalledTimes(1); |
| expect(consoleSpy).toHaveBeenCalledWith( |
| 'Failed to write to localStorage:', |
| expect.any(Error), |
| ); |
| }); |
| |
| it('should remove item from localStorage', () => { |
| adapter.removeItem('test-key'); |
| |
| expect(mockLocalStorage.removeItem).toHaveBeenCalledTimes(1); |
| expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('test-key'); |
| }); |
| |
| it('should handle localStorage errors while removing an item', () => { |
| mockLocalStorage.removeItem.mockImplementation(() => { |
| throw new Error('Storage error'); |
| }); |
| |
| adapter.removeItem('test-key'); |
| |
| expect(consoleSpy).toHaveBeenCalledTimes(1); |
| expect(consoleSpy).toHaveBeenCalledWith( |
| 'Failed to remove from localStorage:', |
| expect.any(Error), |
| ); |
| }); |
| }); |
| |
| describe('ThemeController', () => { |
| let controller: ThemeController; |
| const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); |
| const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); |
| |
| beforeEach(() => { |
| jest.clearAllMocks(); |
| |
| // Setup DOM environment |
| Object.defineProperty(window, 'localStorage', { |
| value: mockLocalStorage, |
| writable: true, |
| }); |
| |
| Object.defineProperty(window, 'matchMedia', { |
| value: mockMatchMedia, |
| writable: true, |
| }); |
| |
| mockMatchMedia.mockReturnValue({ |
| matches: false, |
| addEventListener: jest.fn(), |
| removeEventListener: jest.fn(), |
| }); |
| |
| mockSetConfig.mockImplementation(() => {}); |
| mockThemeFromConfig.mockReturnValue(mockThemeObject); |
| |
| // Mock Theme constructor |
| (Theme as any).fromConfig = mockThemeFromConfig; |
| |
| // Reset localStorage mocks |
| mockLocalStorage.getItem.mockReturnValue(null); |
| mockLocalStorage.setItem.mockImplementation(() => {}); |
| mockLocalStorage.removeItem.mockImplementation(() => {}); |
| |
| // Default BootstrapData |
| mockGetBootstrapData.mockReturnValue(createMockBootstrapData()); |
| }); |
| |
| afterAll(() => { |
| consoleSpy.mockRestore(); |
| consoleErrorSpy.mockRestore(); |
| }); |
| |
| it('should initialize with default options', () => { |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| expect(controller.getTheme()).toBe(mockThemeObject); |
| }); |
| |
| it('should use BootstrapData themes when available', () => { |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining({ |
| colorBgBase: '#ededed', |
| colorPrimary: '#c96f0f', |
| }), |
| }), |
| ); |
| }); |
| |
| it('should fallback to Superset default theme when BootstrapData themes are empty', () => { |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: {}, |
| dark: {}, |
| }), |
| ); |
| |
| const fallbackTheme = { |
| token: { |
| colorBgBase: '#ffffff', |
| colorPrimary: '#1890ff', |
| }, |
| }; |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| defaultTheme: fallbackTheme, |
| }); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| ...fallbackTheme, |
| algorithm: antdThemeImport.defaultAlgorithm, |
| }), |
| ); |
| }); |
| |
| it('should handle system theme preference', () => { |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: DEFAULT_THEME, |
| dark: DARK_THEME, |
| }), |
| ); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM); |
| }); |
| |
| it('should handle only default theme', () => { |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: DEFAULT_THEME, |
| dark: {}, |
| }), |
| ); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| // Clear the call from initialization |
| jest.clearAllMocks(); |
| |
| // Should throw when trying to change mode with only one theme |
| expect(() => controller.setThemeMode(ThemeMode.DARK)).toThrow( |
| 'Theme mode changes are not allowed when only one theme is available', |
| ); |
| |
| // Config should not have been called since the error was thrown |
| expect(mockSetConfig).not.toHaveBeenCalled(); |
| }); |
| |
| it('should handle only dark theme', () => { |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: {}, |
| dark: DARK_THEME, |
| }), |
| ); |
| |
| // Provide an explicit light theme fallback for this test |
| const lightThemeFallback = { |
| token: { |
| colorBgBase: '#fff', |
| colorTextBase: '#000', |
| colorPrimary: '#1890ff', |
| }, |
| }; |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| defaultTheme: lightThemeFallback, |
| }); |
| |
| // When only dark theme is available, controller uses the default fallback theme initially |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| |
| const calledWith = mockSetConfig.mock.calls[0][0]; |
| |
| // Should use the default theme fallback (not dark) for initial load |
| expect(calledWith.token.colorBgBase).toBe('#fff'); |
| expect(calledWith.token.colorTextBase).toBe('#000'); |
| |
| // Should allow mode changes since dark theme exists |
| expect(controller.canSetMode()).toBe(true); |
| |
| // Should be able to switch to dark mode |
| jest.clearAllMocks(); |
| controller.setThemeMode(ThemeMode.DARK); |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| }); |
| |
| it('should handle completely empty BootstrapData', () => { |
| const fallbackTheme = { |
| token: { |
| colorBgBase: '#ffffff', |
| colorPrimary: '#1890ff', |
| }, |
| }; |
| |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: {}, |
| dark: {}, |
| }), |
| ); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| defaultTheme: fallbackTheme, |
| }); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining({ |
| colorBgBase: '#ffffff', |
| colorPrimary: '#1890ff', |
| }), |
| algorithm: antdThemeImport.defaultAlgorithm, |
| }), |
| ); |
| }); |
| |
| it('should handle missing theme object', () => { |
| const fallbackTheme = { token: { colorPrimary: '#fallback' } }; |
| |
| mockGetBootstrapData.mockReturnValue({ |
| common: { |
| application_root: '/', |
| static_assets_prefix: '/static/assets/', |
| flash_messages: [], |
| conf: {}, |
| locale: 'en', |
| feature_flags: {}, |
| language_pack: {}, |
| extra_categorical_color_schemes: [], |
| extra_sequential_color_schemes: [], |
| menu_data: {}, |
| d3_format: {}, |
| d3_time_format: {}, |
| } as any, |
| }); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| defaultTheme: fallbackTheme, |
| }); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining({ |
| colorPrimary: '#fallback', |
| }), |
| algorithm: antdThemeImport.defaultAlgorithm, |
| }), |
| ); |
| }); |
| |
| describe('Theme Management', () => { |
| beforeEach(() => { |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| }); |
| |
| it('should update theme when allowed', () => { |
| const newTheme = { |
| token: { |
| colorBgBase: '#000000', |
| colorPrimary: '#ff0000', |
| }, |
| }; |
| |
| controller.setTheme(newTheme); |
| |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining(newTheme), |
| ); |
| }); |
| |
| it('should change theme mode when allowed', () => { |
| // Clear initialization calls |
| jest.clearAllMocks(); |
| |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(controller.getCurrentMode()).toBe(ThemeMode.DARK); |
| expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); |
| expect(mockLocalStorage.setItem).toHaveBeenCalledWith( |
| 'superset-theme-mode', |
| ThemeMode.DARK, |
| ); |
| }); |
| |
| it('should handle missing theme gracefully', () => { |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: DEFAULT_THEME, |
| dark: {}, |
| }), |
| ); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| // Should throw when trying to set mode with only one theme |
| expect(() => controller.setThemeMode(ThemeMode.DARK)).toThrow( |
| 'Theme mode changes are not allowed when only one theme is available', |
| ); |
| |
| // Mode should remain unchanged |
| expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT); |
| expect(consoleSpy).not.toHaveBeenCalled(); |
| }); |
| |
| it('should not change mode if already set', () => { |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| jest.clearAllMocks(); |
| |
| // Try to change to the same mode (DEFAULT) |
| controller.setThemeMode(ThemeMode.SYSTEM); |
| |
| // Should not call setItem since mode didn't change |
| expect(mockLocalStorage.setItem).not.toHaveBeenCalled(); |
| }); |
| |
| it('should reset to default theme', () => { |
| controller.setThemeMode(ThemeMode.DARK); |
| controller.resetTheme(); |
| |
| expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining({ |
| colorBgBase: '#141118', |
| colorTextBase: '#fdc7c7', |
| }), |
| }), |
| ); |
| }); |
| }); |
| |
| describe('System Theme Changes', () => { |
| let mockMediaQuery: any; |
| |
| beforeEach(() => { |
| mockMediaQuery = { |
| matches: false, |
| addEventListener: jest.fn(), |
| removeEventListener: jest.fn(), |
| }; |
| |
| mockMatchMedia.mockReturnValue(mockMediaQuery); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| }); |
| |
| it('should listen to system theme changes', () => { |
| expect(mockMediaQuery.addEventListener).toHaveBeenCalledTimes(1); |
| expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith( |
| 'change', |
| expect.any(Function), |
| ); |
| }); |
| |
| it('should update theme when system preference changes and mode is SYSTEM', () => { |
| controller.setThemeMode(ThemeMode.SYSTEM); |
| |
| // Simulate system theme change |
| mockMediaQuery.matches = true; |
| const changeHandler = mockMediaQuery.addEventListener.mock.calls[0][1]; |
| |
| changeHandler(); |
| |
| expect(mockSetConfig).toHaveBeenCalled(); |
| }); |
| |
| it('should not update theme when mode is not SYSTEM', () => { |
| controller.setThemeMode(ThemeMode.DEFAULT); |
| |
| const initialCallCount = mockSetConfig.mock.calls.length; |
| |
| // Simulate system theme change |
| mockMediaQuery.matches = true; |
| const changeHandler = mockMediaQuery.addEventListener.mock.calls[0][1]; |
| |
| changeHandler(); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(initialCallCount); |
| }); |
| |
| it('should switch to dark theme when system is dark and mode is SYSTEM', () => { |
| // Setup with both light and dark themes available |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: DEFAULT_THEME, |
| dark: DARK_THEME, |
| }), |
| ); |
| |
| // Setup mock media query to return dark mode preference |
| const mockMediaQueryDark = { |
| matches: true, // System is in dark mode |
| addEventListener: jest.fn(), |
| removeEventListener: jest.fn(), |
| }; |
| mockMatchMedia.mockReturnValue(mockMediaQueryDark); |
| |
| // Create fresh controller instance with dark system preference |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| // Verify system mode is set by default |
| expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM); |
| |
| // Verify that dark theme was applied during initialization |
| expect(mockSetConfig).toHaveBeenCalled(); |
| const lastCall = |
| mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0]; |
| expect(lastCall.token.colorBgBase).toBe(DARK_THEME.token!.colorBgBase); |
| expect(lastCall.token.colorTextBase).toBe( |
| DARK_THEME.token!.colorTextBase, |
| ); |
| }); |
| }); |
| |
| describe('Persistence', () => { |
| beforeEach(() => { |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| }); |
| |
| it('should save theme mode to localStorage', () => { |
| // Clear the call from controller initialization |
| jest.clearAllMocks(); |
| |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); |
| expect(mockLocalStorage.setItem).toHaveBeenCalledWith( |
| 'superset-theme-mode', |
| ThemeMode.DARK, |
| ); |
| }); |
| |
| it('should load saved theme mode from localStorage', () => { |
| mockLocalStorage.getItem.mockReturnValue(ThemeMode.DARK); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| expect(controller.getCurrentMode()).toBe(ThemeMode.DARK); |
| }); |
| |
| it('should handle invalid saved theme mode', () => { |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: {}, |
| dark: {}, |
| }), |
| ); |
| |
| mockLocalStorage.getItem.mockReturnValue('invalid-mode' as any); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT); |
| }); |
| }); |
| |
| describe('Theme Structure', () => { |
| beforeEach(() => { |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| }); |
| |
| it('should handle theme with token structure', () => { |
| const customTheme = { |
| token: { |
| colorBgBase: '#ff0000', |
| colorTextBase: '#ffffff', |
| colorPrimary: '#00ff00', |
| }, |
| }; |
| |
| // Clear the call from controller initialization |
| jest.clearAllMocks(); |
| |
| controller.setTheme(customTheme); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining({ |
| colorBgBase: '#ff0000', |
| colorTextBase: '#ffffff', |
| colorPrimary: '#00ff00', |
| }), |
| }), |
| ); |
| }); |
| |
| it('should preserve algorithm property from dark theme', () => { |
| // Clear the call from controller initialization |
| jest.clearAllMocks(); |
| |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| algorithm: antdThemeImport.darkAlgorithm, |
| token: expect.objectContaining({ |
| colorBgBase: '#141118', |
| colorTextBase: '#fdc7c7', |
| }), |
| }), |
| ); |
| }); |
| |
| it('should handle theme without algorithm property', () => { |
| // Clear the call from controller initialization |
| jest.clearAllMocks(); |
| |
| controller.setThemeMode(ThemeMode.DEFAULT); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining({ |
| colorBgBase: '#ededed', |
| colorTextBase: '#120f0f', |
| }), |
| }), |
| ); |
| |
| // Should not have algorithm property or should be overridden |
| const lastCall = |
| mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0]; |
| |
| expect(lastCall.algorithm).toBe(antdThemeImport.defaultAlgorithm); |
| }); |
| |
| it('should handle color tokens correctly in theme switching', () => { |
| // Start with default theme |
| controller.setThemeMode(ThemeMode.DEFAULT); |
| |
| let lastCall = |
| mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0]; |
| |
| expect(lastCall.token.colorBgBase).toBe('#ededed'); |
| expect(lastCall.token.colorTextBase).toBe('#120f0f'); |
| |
| // Switch to dark theme |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| lastCall = |
| mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0]; |
| |
| expect(lastCall.token.colorBgBase).toBe('#141118'); |
| expect(lastCall.token.colorTextBase).toBe('#fdc7c7'); |
| }); |
| }); |
| |
| describe('Algorithm Combinations', () => { |
| beforeEach(() => { |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: { |
| ...DEFAULT_THEME, |
| algorithm: [ThemeAlgorithm.DARK, ThemeAlgorithm.COMPACT], |
| }, |
| dark: DARK_THEME, |
| }), |
| ); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| }); |
| |
| it('should handle valid algorithm combinations', () => { |
| const themeWithAlgorithm = { |
| ...DEFAULT_THEME, |
| algorithm: [ |
| ThemeAlgorithm.DARK, |
| ThemeAlgorithm.COMPACT, |
| ] as ThemeAlgorithm[], |
| }; |
| |
| // Clear the call from controller initialization |
| jest.clearAllMocks(); |
| |
| controller.setTheme(themeWithAlgorithm); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining({ |
| colorBgBase: '#ededed', |
| colorPrimary: '#c96f0f', |
| }), |
| algorithm: [ |
| antdThemeImport.darkAlgorithm, |
| antdThemeImport.compactAlgorithm, |
| ], |
| }), |
| ); |
| }); |
| |
| it('should handle invalid algorithm combinations', () => { |
| const themeWithInvalidAlgorithm = { |
| ...DEFAULT_THEME, |
| algorithm: ['invalid', 'combination'] as any as ThemeAlgorithm[], |
| }; |
| |
| // Clear the call from controller initialization |
| jest.clearAllMocks(); |
| |
| controller.setTheme(themeWithInvalidAlgorithm); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| algorithm: antdThemeImport.defaultAlgorithm, |
| }), |
| ); |
| }); |
| }); |
| |
| describe('Change Callbacks', () => { |
| let callback: jest.Mock; |
| let unsubscribe: () => void; |
| |
| beforeEach(() => { |
| callback = jest.fn(); |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| onChange: callback, |
| }); |
| }); |
| |
| it('should call callback on theme change', () => { |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(callback).toHaveBeenCalledTimes(1); |
| expect(callback).toHaveBeenCalledWith(mockThemeObject); |
| }); |
| |
| it('should register additional callbacks', () => { |
| const additionalCallback = jest.fn(); |
| unsubscribe = controller.onChange(additionalCallback); |
| |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(callback).toHaveBeenCalled(); |
| expect(additionalCallback).toHaveBeenCalled(); |
| }); |
| |
| it('should unsubscribe callbacks', () => { |
| const additionalCallback = jest.fn(); |
| unsubscribe = controller.onChange(additionalCallback); |
| |
| unsubscribe(); |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(additionalCallback).not.toHaveBeenCalled(); |
| }); |
| |
| it('should handle callback errors', () => { |
| const errorCallback = jest.fn().mockImplementation(() => { |
| throw new Error('Callback error'); |
| }); |
| |
| controller.onChange(errorCallback); |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(consoleErrorSpy).toHaveBeenCalledTimes(1); |
| expect(consoleErrorSpy).toHaveBeenCalledWith( |
| 'Error in theme change callback:', |
| expect.any(Error), |
| ); |
| }); |
| }); |
| |
| describe('Error Handling', () => { |
| beforeEach(() => { |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| }); |
| |
| it('should handle theme application errors', () => { |
| // Mock setConfig to throw an error |
| mockSetConfig.mockImplementationOnce(() => { |
| throw new Error('Theme application error'); |
| }); |
| |
| // Mock fallbackToDefaultMode to avoid infinite recursion |
| const fallbackSpy = jest.spyOn( |
| controller as any, |
| 'fallbackToDefaultMode', |
| ); |
| fallbackSpy.mockImplementation(() => { |
| // Just set basic properties without calling updateTheme |
| (controller as any).customizations = DEFAULT_THEME; |
| (controller as any).currentMode = ThemeMode.DEFAULT; |
| }); |
| |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(consoleErrorSpy).toHaveBeenCalledTimes(1); |
| expect(consoleErrorSpy).toHaveBeenCalledWith( |
| 'Failed to apply theme:', |
| expect.any(Error), |
| ); |
| expect(fallbackSpy).toHaveBeenCalled(); |
| |
| fallbackSpy.mockRestore(); |
| }); |
| }); |
| |
| describe('Cleanup', () => { |
| let mockMediaQueryInstance: any; |
| |
| beforeEach(() => { |
| mockMediaQueryInstance = { |
| matches: false, |
| addEventListener: jest.fn(), |
| removeEventListener: jest.fn(), |
| }; |
| |
| mockMatchMedia.mockReturnValue(mockMediaQueryInstance); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| }); |
| |
| it('should clean up listeners on destroy', () => { |
| controller.destroy(); |
| |
| expect(mockMediaQueryInstance.removeEventListener).toHaveBeenCalledTimes( |
| 1, |
| ); |
| expect(mockMediaQueryInstance.removeEventListener).toHaveBeenCalledWith( |
| 'change', |
| expect.any(Function), |
| ); |
| }); |
| }); |
| |
| describe('setThemeConfig', () => { |
| beforeEach(() => { |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: {}, |
| dark: {}, |
| }), |
| ); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| defaultTheme: { token: {} }, |
| }); |
| |
| jest.clearAllMocks(); |
| }); |
| |
| it('should set complete theme configuration', () => { |
| const themeConfig = { |
| theme_default: DEFAULT_THEME, |
| theme_dark: DARK_THEME, |
| }; |
| |
| controller.setThemeConfig(themeConfig); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining(DEFAULT_THEME.token), |
| algorithm: antdThemeImport.defaultAlgorithm, |
| }), |
| ); |
| |
| expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT); |
| expect(controller.canSetTheme()).toBe(true); |
| expect(controller.canSetMode()).toBe(true); |
| }); |
| |
| it('should handle theme_default only', () => { |
| const themeConfig = { |
| theme_default: DEFAULT_THEME, |
| }; |
| |
| controller.setThemeConfig(themeConfig); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining(DEFAULT_THEME.token), |
| algorithm: antdThemeImport.defaultAlgorithm, |
| }), |
| ); |
| |
| expect(controller.canSetTheme()).toBe(true); |
| expect(controller.canSetMode()).toBe(false); |
| }); |
| |
| it('should handle theme_default and theme_dark without settings', () => { |
| const themeConfig = { |
| theme_default: DEFAULT_THEME, |
| theme_dark: DARK_THEME, |
| }; |
| |
| controller.setThemeConfig(themeConfig); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining(DEFAULT_THEME.token), |
| }), |
| ); |
| |
| jest.clearAllMocks(); |
| controller.setThemeMode(ThemeMode.DARK); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining(DARK_THEME.token), |
| algorithm: antdThemeImport.darkAlgorithm, |
| }), |
| ); |
| }); |
| |
| it('should apply appropriate theme after configuration', () => { |
| jest.clearAllMocks(); |
| |
| const themeConfig = { |
| theme_default: { |
| token: { |
| colorPrimary: '#00ff00', |
| }, |
| }, |
| theme_dark: { |
| token: { |
| colorPrimary: '#ff0000', |
| colorBgBase: '#000000', |
| }, |
| algorithm: 'dark', |
| }, |
| }; |
| |
| controller.setThemeConfig(themeConfig as SupersetThemeConfig); |
| |
| expect(mockSetConfig).toHaveBeenCalledTimes(1); |
| expect(mockSetConfig).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| token: expect.objectContaining({ |
| colorPrimary: '#00ff00', |
| }), |
| algorithm: antdThemeImport.defaultAlgorithm, |
| }), |
| ); |
| }); |
| |
| it('should handle missing theme_dark gracefully', () => { |
| const themeConfig = { |
| theme_default: DEFAULT_THEME, |
| }; |
| |
| controller.setThemeConfig(themeConfig); |
| |
| // Can't set dark mode when there's no dark theme |
| expect(controller.canSetMode()).toBe(false); |
| }); |
| |
| it('should preserve existing theme mode when possible', () => { |
| // First create controller with dark theme available |
| mockGetBootstrapData.mockReturnValue( |
| createMockBootstrapData({ |
| default: DEFAULT_THEME, |
| dark: DARK_THEME, |
| }), |
| ); |
| |
| controller = new ThemeController({ |
| themeObject: mockThemeObject, |
| }); |
| |
| controller.setThemeMode(ThemeMode.DARK); |
| const initialMode = controller.getCurrentMode(); |
| |
| jest.clearAllMocks(); |
| |
| const themeConfig = { |
| theme_default: DEFAULT_THEME, |
| theme_dark: DARK_THEME, |
| }; |
| |
| controller.setThemeConfig(themeConfig); |
| |
| expect(controller.getCurrentMode()).toBe(initialMode); |
| }); |
| |
| it('should trigger onChange callbacks', () => { |
| const changeCallback = jest.fn(); |
| controller.onChange(changeCallback); |
| |
| const themeConfig = { |
| theme_default: DEFAULT_THEME, |
| theme_dark: DARK_THEME, |
| }; |
| |
| controller.setThemeConfig(themeConfig); |
| |
| expect(changeCallback).toHaveBeenCalledTimes(1); |
| expect(changeCallback).toHaveBeenCalledWith(mockThemeObject); |
| }); |
| |
| it('should handle error in theme application', () => { |
| mockSetConfig.mockImplementationOnce(() => { |
| throw new Error('Theme application error'); |
| }); |
| |
| const themeConfig = { |
| theme_default: DEFAULT_THEME, |
| }; |
| |
| expect(() => { |
| controller.setThemeConfig(themeConfig); |
| }).not.toThrow(); |
| |
| expect(consoleErrorSpy).toHaveBeenCalledWith( |
| 'Failed to apply theme:', |
| expect.any(Error), |
| ); |
| }); |
| |
| it('should update stored theme mode', () => { |
| const themeConfig = { |
| theme_default: DEFAULT_THEME, |
| theme_dark: DARK_THEME, |
| }; |
| |
| controller.setThemeConfig(themeConfig); |
| |
| expect(mockLocalStorage.setItem).toHaveBeenCalledWith( |
| 'superset-theme-mode', |
| expect.any(String), |
| ); |
| }); |
| }); |
| }); |