test: write a new unit test suite for the Settings component (#2408)

This commit is contained in:
Nico Miguelino
2025-07-28 08:52:37 -07:00
committed by GitHub
parent 2973d59f66
commit 1daaa8218f
8 changed files with 457 additions and 133 deletions

View File

@@ -1 +1 @@
// Jest setup file - currently empty since we're using basic Jest matchers
import '@testing-library/jest-dom'

View File

@@ -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({

View File

@@ -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<RootState> = {}) => {
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(<Provider store={store}>{component}</Provider>)
}
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(
<Provider store={store}>
<Settings />
</Provider>,
)
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(<Settings />)
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(<Settings />)
await waitFor(() => {
expect(screen.getByDisplayValue('Test Player')).toBeInTheDocument()
})
})
it('updates document title with player name', async () => {
renderWithProvider(<Settings />)
await waitFor(() => {
expect(document.title).toBe('Test Player · Settings')
})
})
it('handles input changes for text fields', async () => {
renderWithProvider(<Settings />)
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(<Settings />)
// 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(
<Provider store={store}>
<Settings />
</Provider>,
)
// 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(
<Provider store={store}>
<Settings />
</Provider>,
)
// 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)
})
})

View File

@@ -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())

174
static/src/tests/utils.ts Normal file
View File

@@ -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<SystemInfoResponse>
deviceSettings?: Partial<DeviceSettingsResponse>
integrations?: Partial<IntegrationsResponse>
}
export function createMockServer(overrides: Partial<MockResponses> = {}) {
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,
},
}
}

View File

@@ -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
}

View File

@@ -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": [

View File

@@ -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",