mirror of
https://github.com/Screenly/Anthias.git
synced 2025-12-23 22:38:05 -05:00
test: write a new unit test suite for the Settings component (#2408)
This commit is contained in:
@@ -1 +1 @@
|
||||
// Jest setup file - currently empty since we're using basic Jest matchers
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
@@ -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({
|
||||
|
||||
245
static/src/tests/settings.test.tsx
Normal file
245
static/src/tests/settings.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
174
static/src/tests/utils.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user