diff --git a/static/src/setupTests.ts b/static/src/setupTests.ts index 26976820..c44951a6 100644 --- a/static/src/setupTests.ts +++ b/static/src/setupTests.ts @@ -1 +1 @@ -// Jest setup file - currently empty since we're using basic Jest matchers +import '@testing-library/jest-dom' diff --git a/static/src/tests/home.test.tsx b/static/src/tests/home.test.tsx index 97a06998..ca9fa285 100644 --- a/static/src/tests/home.test.tsx +++ b/static/src/tests/home.test.tsx @@ -7,105 +7,9 @@ import { RootState } from '@/types' import { assetsReducer, assetModalReducer } from '@/store/assets' import settingsReducer from '@/store/settings' import websocketReducer from '@/store/websocket' +import { getInitialState } from '@/tests/utils' -const initialState: RootState = { - assets: { - items: [ - { - asset_id: 'ff18e72b5a2447fab372f5effa0797b1', - name: 'https://react.dev/', - uri: 'https://react.dev/', - start_date: '2025-07-07T22:51:55.640000Z', - end_date: '2025-08-06T22:51:55.640000Z', - duration: 10, - mimetype: 'webpage', - is_enabled: 1, - nocache: false, - play_order: 0, - skip_asset_check: false, - is_active: true, - is_processing: false, - }, - { - asset_id: '5bbf68491a0d4461bfe860911265b8be', - name: 'https://angular.dev/', - uri: 'https://angular.dev/', - start_date: '2025-07-07T22:52:47.421000Z', - end_date: '2025-08-06T22:52:47.421000Z', - duration: 10, - mimetype: 'webpage', - is_enabled: 1, - nocache: false, - play_order: 1, - skip_asset_check: false, - is_active: true, - is_processing: false, - }, - { - asset_id: '6eb86ce9d5c14597ae68017d4dd93900', - name: 'https://vuejs.org/', - uri: 'https://vuejs.org/', - start_date: '2025-07-07T22:52:58.934000Z', - end_date: '2025-08-06T22:52:58.934000Z', - duration: 10, - mimetype: 'webpage', - is_enabled: 1, - nocache: false, - play_order: 2, - skip_asset_check: false, - is_active: true, - is_processing: false, - }, - ], - status: 'succeeded', - error: null, - }, - assetModal: { - activeTab: 'uri', - formData: { - uri: '', - skipAssetCheck: false, - }, - isValid: true, - errorMessage: '', - statusMessage: '', - isSubmitting: false, - uploadProgress: 0, - }, - settings: { - settings: { - playerName: '', - defaultDuration: 10, - defaultStreamingDuration: 300, - audioOutput: 'hdmi', - dateFormat: 'mm/dd/yyyy', - authBackend: '', - currentPassword: '', - user: '', - password: '', - confirmPassword: '', - showSplash: true, - defaultAssets: false, - shufflePlaylist: false, - use24HourClock: false, - debugLogging: false, - }, - deviceModel: '', - prevAuthBackend: '', - hasSavedBasicAuth: false, - isLoading: false, - isUploading: false, - uploadProgress: 0, - error: null, - }, - websocket: { - isConnected: false, - isConnecting: false, - error: null, - lastMessage: null, - reconnectAttempts: 0, - }, -} +const initialState: RootState = getInitialState() const createTestStore = (preloadedState = {}) => { return configureStore({ diff --git a/static/src/tests/settings.test.tsx b/static/src/tests/settings.test.tsx new file mode 100644 index 00000000..87bb8f96 --- /dev/null +++ b/static/src/tests/settings.test.tsx @@ -0,0 +1,245 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import Swal from 'sweetalert2' +import { Settings } from '@/components/settings/index' +import settingsReducer from '@/store/settings' +import { RootState } from '@/types' +import { createMockServer } from '@/tests/utils' + +// Mock SweetAlert2 +jest.mock('sweetalert2') + +// Mock document.title +Object.defineProperty(document, 'title', { + writable: true, + value: '', +}) + +const server = createMockServer() + +beforeAll(() => server.listen()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +const createMockStore = (preloadedState: Partial = {}) => { + return configureStore({ + reducer: { + settings: settingsReducer, + }, + preloadedState: { + settings: { + settings: { + playerName: 'Test Player', + defaultDuration: 10, + defaultStreamingDuration: 300, + audioOutput: 'hdmi', + dateFormat: 'mm/dd/yyyy', + authBackend: '', + currentPassword: '', + user: '', + password: '', + confirmPassword: '', + showSplash: true, + defaultAssets: false, + shufflePlaylist: true, + use24HourClock: false, + debugLogging: true, + }, + deviceModel: 'Raspberry Pi 4', + isLoading: false, + prevAuthBackend: '', + hasSavedBasicAuth: false, + isUploading: false, + uploadProgress: 0, + error: null, + ...(preloadedState.settings || {}), + }, + }, + }) +} + +const renderWithProvider = ( + component: React.ReactElement, + initialState: RootState = {} as RootState, +) => { + const store = createMockStore(initialState) + return render({component}) +} + +const testFormSubmission = async ( + shouldSucceed: boolean, + expectedMessage: string, +) => { + const mockDispatch = jest.fn() + const mockUnwrap = shouldSucceed + ? jest.fn().mockResolvedValue({}) + : jest.fn().mockRejectedValue(new Error('Save failed')) + + const store = createMockStore() + store.dispatch = mockDispatch + mockDispatch.mockReturnValue({ unwrap: mockUnwrap }) + + render( + + + , + ) + + const submitButton = screen.getByText('Save Settings') + fireEvent.click(submitButton) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(Swal.fire).toHaveBeenCalledWith( + expect.objectContaining({ + title: shouldSucceed ? 'Success!' : 'Error!', + text: expectedMessage, + icon: shouldSucceed ? 'success' : 'error', + }), + ) + }) +} + +describe('Settings Component', () => { + it('renders settings form with all components', async () => { + renderWithProvider() + + expect(screen.getByText('Settings')).toBeInTheDocument() + + // Wait for the component to load and render the save button + await waitFor(() => { + expect(screen.getByText('Save Settings')).toBeInTheDocument() + }) + }) + + it('displays player name from settings', async () => { + renderWithProvider() + + await waitFor(() => { + expect(screen.getByDisplayValue('Test Player')).toBeInTheDocument() + }) + }) + + it('updates document title with player name', async () => { + renderWithProvider() + + await waitFor(() => { + expect(document.title).toBe('Test Player ยท Settings') + }) + }) + + it('handles input changes for text fields', async () => { + renderWithProvider() + + const playerNameInput = screen.getByDisplayValue( + 'Test Player', + ) as HTMLInputElement + fireEvent.change(playerNameInput, { target: { value: 'New Player Name' } }) + + await waitFor(() => { + expect(playerNameInput.value).toBe('New Player Name') + }) + }) + + it('handles checkbox changes', async () => { + renderWithProvider() + + // Wait for the component to load, then find the specific checkbox by its name attribute + await waitFor(() => { + const showSplashCheckbox = screen + .getAllByRole('checkbox') + .find( + (el) => (el as HTMLInputElement).name === 'showSplash', + ) as HTMLInputElement + fireEvent.click(showSplashCheckbox) + + expect(showSplashCheckbox.checked).toBe(false) + }) + }) + + it('shows loading state when submitting form', async () => { + const store = createMockStore({ + settings: { + settings: { + playerName: 'Test Player', + defaultDuration: 10, + defaultStreamingDuration: 300, + audioOutput: 'hdmi', + dateFormat: 'mm/dd/yyyy', + authBackend: '', + currentPassword: '', + user: '', + password: '', + confirmPassword: '', + showSplash: true, + defaultAssets: false, + shufflePlaylist: true, + use24HourClock: false, + debugLogging: true, + }, + deviceModel: 'Raspberry Pi 4', + isLoading: true, + prevAuthBackend: '', + hasSavedBasicAuth: false, + isUploading: false, + uploadProgress: 0, + error: null, + }, + }) + + render( + + + , + ) + + // The button should be disabled when loading + const submitButton = screen + .getAllByRole('button') + .find( + (el) => (el as HTMLButtonElement).type === 'submit', + ) as HTMLButtonElement + expect(submitButton).toBeDisabled() + + // Check that the spinner is present (it's inside the button) + const spinner = submitButton.querySelector('[role="status"]') + expect(spinner).toBeInTheDocument() + }) + + it('handles successful form submission', async () => { + await testFormSubmission(true, 'Settings were successfully saved.') + }) + + it('shows success message on successful save', async () => { + await testFormSubmission(true, 'Settings were successfully saved.') + }) + + it('shows error message on failed save', async () => { + await testFormSubmission(false, 'Save failed') + }) + + it('fetches settings and device model on mount', async () => { + const mockDispatch = jest.fn() + const store = createMockStore() + store.dispatch = mockDispatch + + render( + + + , + ) + + // Wait for the component to mount and dispatch actions + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalled() + }) + + // Check that both actions were dispatched + const calls = mockDispatch.mock.calls + expect(calls.length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/static/src/tests/system-info.test.tsx b/static/src/tests/system-info.test.tsx index c6176001..98e5f612 100644 --- a/static/src/tests/system-info.test.tsx +++ b/static/src/tests/system-info.test.tsx @@ -1,39 +1,9 @@ import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { SystemInfo } from '@/components/system-info' +import { createMockServer } from '@/tests/utils' -import { http, HttpResponse } from 'msw' -import { setupServer } from 'msw/node' - -const server = setupServer( - http.get('/api/v2/info', () => { - return HttpResponse.json({ - loadavg: 1.58, - free_space: '31G', - display_power: 'CEC error', - uptime: { - days: 8, - hours: 18.56, - }, - memory: { - total: 15659, - used: 9768, - free: 1522, - shared: 1439, - buff: 60, - available: 3927, - }, - device_model: 'Generic x86_64 Device', - anthias_version: 'master@3a4747f', - mac_address: 'Unable to retrieve MAC address.', - }) - }), - http.get('/api/v2/device_settings', () => { - return HttpResponse.json({ - player_name: 'Test Player', - }) - }), -) +const server = createMockServer() beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) diff --git a/static/src/tests/utils.ts b/static/src/tests/utils.ts new file mode 100644 index 00000000..621785da --- /dev/null +++ b/static/src/tests/utils.ts @@ -0,0 +1,174 @@ +import { + RootState, + SystemInfoResponse, + DeviceSettingsResponse, + IntegrationsResponse, +} from '@/types' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +interface MockResponses { + info?: Partial + deviceSettings?: Partial + integrations?: Partial +} + +export function createMockServer(overrides: Partial = {}) { + const defaultInfo: SystemInfoResponse = { + loadavg: 1.58, + free_space: '31G', + display_power: 'CEC error', + uptime: { + days: 8, + hours: 18.56, + }, + memory: { + total: 15659, + used: 9768, + free: 1522, + shared: 1439, + buff: 60, + available: 3927, + }, + device_model: 'Generic x86_64 Device', + anthias_version: 'master@3a4747f', + mac_address: 'Unable to retrieve MAC address.', + ip_addresses: ['192.168.1.100', '10.0.0.50'], + host_user: 'pi', + } + + const defaultDeviceSettings: DeviceSettingsResponse = { + player_name: 'Test Player', + } + + const defaultIntegrations: IntegrationsResponse = { + is_balena: false, + } + + return setupServer( + http.get('/api/v2/info', () => { + return HttpResponse.json({ + ...defaultInfo, + ...overrides.info, + }) + }), + http.get('/api/v2/device_settings', () => { + return HttpResponse.json({ + ...defaultDeviceSettings, + ...overrides.deviceSettings, + }) + }), + http.patch('/api/v2/device_settings', () => { + return HttpResponse.json({ + message: 'Settings were successfully saved.', + }) + }), + http.get('/api/v2/integrations', () => { + return HttpResponse.json({ + ...defaultIntegrations, + ...overrides.integrations, + }) + }), + ) +} + +export function getInitialState(): RootState { + return { + assets: { + items: [ + { + asset_id: 'ff18e72b5a2447fab372f5effa0797b1', + name: 'https://react.dev/', + uri: 'https://react.dev/', + start_date: '2025-07-07T22:51:55.640000Z', + end_date: '2025-08-06T22:51:55.640000Z', + duration: 10, + mimetype: 'webpage', + is_enabled: 1, + nocache: false, + play_order: 0, + skip_asset_check: false, + is_active: true, + is_processing: false, + }, + { + asset_id: '5bbf68491a0d4461bfe860911265b8be', + name: 'https://angular.dev/', + uri: 'https://angular.dev/', + start_date: '2025-07-07T22:52:47.421000Z', + end_date: '2025-08-06T22:52:47.421000Z', + duration: 10, + mimetype: 'webpage', + is_enabled: 1, + nocache: false, + play_order: 1, + skip_asset_check: false, + is_active: true, + is_processing: false, + }, + { + asset_id: '6eb86ce9d5c14597ae68017d4dd93900', + name: 'https://vuejs.org/', + uri: 'https://vuejs.org/', + start_date: '2025-07-07T22:52:58.934000Z', + end_date: '2025-08-06T22:52:58.934000Z', + duration: 10, + mimetype: 'webpage', + is_enabled: 1, + nocache: false, + play_order: 2, + skip_asset_check: false, + is_active: true, + is_processing: false, + }, + ], + status: 'succeeded', + error: null, + }, + assetModal: { + activeTab: 'uri', + formData: { + uri: '', + skipAssetCheck: false, + }, + isValid: true, + errorMessage: '', + statusMessage: '', + isSubmitting: false, + uploadProgress: 0, + }, + settings: { + settings: { + playerName: '', + defaultDuration: 10, + defaultStreamingDuration: 300, + audioOutput: 'hdmi', + dateFormat: 'mm/dd/yyyy', + authBackend: '', + currentPassword: '', + user: '', + password: '', + confirmPassword: '', + showSplash: true, + defaultAssets: false, + shufflePlaylist: false, + use24HourClock: false, + debugLogging: false, + }, + deviceModel: '', + prevAuthBackend: '', + hasSavedBasicAuth: false, + isLoading: false, + isUploading: false, + uploadProgress: 0, + error: null, + }, + websocket: { + isConnected: false, + isConnecting: false, + error: null, + lastMessage: null, + reconnectAttempts: 0, + }, + } +} diff --git a/static/src/types.ts b/static/src/types.ts index e0538f0d..c4e74c01 100644 --- a/static/src/types.ts +++ b/static/src/types.ts @@ -269,3 +269,24 @@ export interface UptimeInfo { minutes?: number seconds?: number } + +export interface SystemInfoResponse { + loadavg: number + free_space: string + display_power: string + uptime: UptimeInfo + memory: MemoryInfo + device_model: string + anthias_version: string + mac_address: string + ip_addresses?: string[] + host_user?: string +} + +export interface DeviceSettingsResponse { + player_name: string +} + +export interface IntegrationsResponse { + is_balena: boolean +} diff --git a/tsconfig.json b/tsconfig.json index 51d9d7b3..78849a1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,8 @@ "@/hooks/*": ["static/src/hooks/*"], "@/store/*": ["static/src/store/*"], "@/sass/*": ["static/sass/*"], - "@/types": ["static/src/types.ts"] + "@/types": ["static/src/types.ts"], + "@/tests/*": ["static/src/tests/*"] } }, "include": [ diff --git a/tsconfig.test.json b/tsconfig.test.json index 86145a65..90e79a46 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,7 +1,16 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["jest", "@testing-library/jest-dom", "node"] + "types": ["jest", "@testing-library/jest-dom", "node"], + "paths": { + "@/components/*": ["static/src/components/*"], + "@/constants": ["static/src/constants.ts"], + "@/hooks/*": ["static/src/hooks/*"], + "@/store/*": ["static/src/store/*"], + "@/sass/*": ["static/sass/*"], + "@/types": ["static/src/types.ts"], + "@/tests/*": ["static/src/tests/*"] + } }, "include": [ "static/src/**/*.test.ts",