From e95285ec8c92bba427687df9c2f6974b3bb61eac Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 14 Mar 2026 07:54:19 -0700 Subject: [PATCH 1/2] Added forms/settings tests --- .../__tests__/DvrSettingsForm.test.jsx | 483 +++++++++++ .../__tests__/NetworkAccessForm.test.jsx | 378 +++++++++ .../__tests__/ProxySettingsForm.test.jsx | 347 ++++++++ .../__tests__/StreamSettingsForm.test.jsx | 760 ++++++++++++++++++ .../__tests__/SystemSettingsForm.test.jsx | 311 +++++++ .../__tests__/UiSettingsForm.test.jsx | 424 ++++++++++ 6 files changed, 2703 insertions(+) create mode 100644 frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx diff --git a/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx new file mode 100644 index 00000000..ad86f1e0 --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx @@ -0,0 +1,483 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import DvrSettingsForm from '../DvrSettingsForm'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => { + const mock = vi.fn(); + mock.getState = vi.fn(); + return { default: mock }; +}); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({ + getChangedSettings: vi.fn(), + parseSettings: vi.fn(), + saveChangedSettings: vi.fn(), +})); + +vi.mock('../../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../../utils/forms/settings/DvrSettingsFormUtils.js', () => ({ + getComskipConfig: vi.fn(), + getDvrSettingsFormInitialValues: vi.fn(), + uploadComskipIni: vi.fn(), +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => ({ + useForm: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ title }) =>
{title}
, + Button: ({ children, onClick, disabled, type, variant }) => ( + + ), + FileInput: ({ placeholder, onChange, disabled }) => ( + { + const file = e.target.files?.[0] ?? null; + onChange?.(file); + }} + /> + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + NumberInput: ({ label, id, name, ...rest }) => ( + rest.onChange?.(Number(e.target.value))} + /> + ), + Stack: ({ children }) =>
{children}
, + Switch: ({ label, id, checked, onChange }) => ( + + ), + Text: ({ children }) => {children}, + TextInput: ({ label, id, name, placeholder, ...rest }) => ( + rest.onChange?.(e)} + /> + ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import { useForm } from '@mantine/form'; +import { + getChangedSettings, + parseSettings, + saveChangedSettings, +} from '../../../../utils/pages/SettingsUtils.js'; +import { showNotification } from '../../../../utils/notificationUtils.js'; +import { + getComskipConfig, + getDvrSettingsFormInitialValues, + uploadComskipIni, +} from '../../../../utils/forms/settings/DvrSettingsFormUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── +const mockFormValues = { + comskip_enabled: false, + comskip_custom_path: '', + pre_offset_minutes: 0, + post_offset_minutes: 0, + tv_template: '', + tv_fallback_template: '', + movie_template: '', + movie_fallback_template: '', +}; + +const makeFormMock = (overrides = {}) => ({ + getInputProps: vi.fn((field, opts) => { + if (opts?.type === 'checkbox') return { checked: mockFormValues[field] ?? false, onChange: vi.fn() }; + return { value: mockFormValues[field] ?? '', onChange: vi.fn() }; + }), + setValues: vi.fn(), + setFieldValue: vi.fn(), + getValues: vi.fn(() => mockFormValues), + onSubmit: vi.fn((handler) => (e) => { e?.preventDefault?.(); return handler(); }), + ...overrides, +}); + +const makeSettings = (overrides = {}) => ({ + comskip_enabled: { key: 'comskip_enabled', value: 'false' }, + comskip_custom_path: { key: 'comskip_custom_path', value: '' }, + pre_offset_minutes: { key: 'pre_offset_minutes', value: '0' }, + post_offset_minutes: { key: 'post_offset_minutes', value: '0' }, + ...overrides, +}); + +const setupMocks = ({ settings = makeSettings(), formOverrides = {} } = {}) => { + const formMock = makeFormMock(formOverrides); + + vi.mocked(useForm).mockReturnValue(formMock); + vi.mocked(getDvrSettingsFormInitialValues).mockReturnValue(mockFormValues); + vi.mocked(parseSettings).mockReturnValue(mockFormValues); + vi.mocked(getComskipConfig).mockResolvedValue({ path: '', exists: false }); + vi.mocked(getChangedSettings).mockReturnValue({}); + vi.mocked(saveChangedSettings).mockResolvedValue(undefined); + + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); + vi.mocked(useSettingsStore).getState = vi.fn(() => ({ + updateSetting: vi.fn(), + })); + + return { formMock }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── +describe('DvrSettingsForm', () => { + let formMock; + + beforeEach(() => { + vi.clearAllMocks(); + ({ formMock } = setupMocks()); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + describe('rendering', () => { + it('renders the form without crashing', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('comskip_enabled')).toBeInTheDocument(); + }); + }); + + it('renders the comskip enabled switch', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('comskip_enabled')).toBeInTheDocument(); + }); + }); + + it('renders the comskip custom path text input', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('comskip_custom_path')).toBeInTheDocument(); + }); + }); + + it('renders the file input for comskip.ini upload', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('file-input')).toBeInTheDocument(); + }); + }); + + it('renders the Upload comskip.ini button', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Upload comskip.ini')).toBeInTheDocument(); + }); + }); + + it('renders the pre_offset_minutes number input', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('pre_offset_minutes')).toBeInTheDocument(); + }); + }); + + it('renders the post_offset_minutes number input', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('post_offset_minutes')).toBeInTheDocument(); + }); + }); + + it('renders all template text inputs', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('tv_template')).toBeInTheDocument(); + expect(screen.getByTestId('tv_fallback_template')).toBeInTheDocument(); + expect(screen.getByTestId('movie_template')).toBeInTheDocument(); + expect(screen.getByTestId('movie_fallback_template')).toBeInTheDocument(); + }); + }); + + it('renders the Save button', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + }); + + it('does not show success alert on initial render', async () => { + render(); + await waitFor(() => { + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + it('shows "No custom comskip.ini uploaded." when no config exists', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('No custom comskip.ini uploaded.')).toBeInTheDocument(); + }); + }); + + it('shows config path when comskipConfig has path and exists', async () => { + vi.mocked(getComskipConfig).mockResolvedValue({ + path: '/app/docker/comskip.ini', + exists: true, + }); + render(); + await waitFor(() => { + expect(screen.getByText('Using /app/docker/comskip.ini')).toBeInTheDocument(); + }); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + describe('initialization', () => { + it('calls getDvrSettingsFormInitialValues on mount', async () => { + render(); + await waitFor(() => { + expect(getDvrSettingsFormInitialValues).toHaveBeenCalled(); + }); + }); + + it('calls parseSettings with settings on mount', async () => { + render(); + await waitFor(() => { + expect(parseSettings).toHaveBeenCalledWith(makeSettings()); + expect(formMock.setValues).toHaveBeenCalledWith(mockFormValues); + }); + }); + + it('calls getComskipConfig on mount', async () => { + render(); + await waitFor(() => { + expect(getComskipConfig).toHaveBeenCalled(); + }); + }); + + it('sets comskip path from getComskipConfig response', async () => { + vi.mocked(getComskipConfig).mockResolvedValue({ + path: '/custom/path/comskip.ini', + exists: true, + }); + render(); + await waitFor(() => { + expect(formMock.setFieldValue).toHaveBeenCalledWith( + 'comskip_custom_path', + '/custom/path/comskip.ini' + ); + }); + }); + + it('handles getComskipConfig error gracefully', async () => { + vi.mocked(getComskipConfig).mockRejectedValue(new Error('network error')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load comskip config', + expect.any(Error) + ); + }); + consoleSpy.mockRestore(); + }); + + it('does not call setFieldValue when getComskipConfig returns no path', async () => { + render(); + await waitFor(() => { + expect(getComskipConfig).toHaveBeenCalled(); + }); + expect(formMock.setFieldValue).not.toHaveBeenCalledWith( + 'comskip_custom_path', + expect.anything() + ); + }); + }); + + // ── active prop ──────────────────────────────────────────────────────────── + describe('active prop', () => { + it('resets saved state when active becomes false', async () => { + vi.mocked(getChangedSettings).mockReturnValue({ comskip_enabled: true }); + const { rerender } = render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Form submission ──────────────────────────────────────────────────────── + describe('form submission', () => { + it('calls getChangedSettings and saveChangedSettings on submit', async () => { + vi.mocked(getChangedSettings).mockReturnValue({ pre_offset_minutes: 5 }); + render(); + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(getChangedSettings).toHaveBeenCalledWith(mockFormValues, makeSettings()); + expect(saveChangedSettings).toHaveBeenCalledWith(makeSettings(), { pre_offset_minutes: 5 }); + }); + }); + + it('shows success alert after successful save', async () => { + render(); + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByText('Saved Successfully')).toBeInTheDocument(); + }); + }); + + it('does not show success alert when saveChangedSettings throws', async () => { + vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error saving settings:', + expect.any(Error) + ); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + consoleSpy.mockRestore(); + }); + }); + + // ── comskip upload ───────────────────────────────────────────────────────── + describe('comskip upload', () => { + it('Upload button is disabled when no file is selected', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Upload comskip.ini')).toBeDisabled(); + }); + }); + + it('calls uploadComskipIni with the selected file', async () => { + const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); + vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' }); + + render(); + fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } }); + + await waitFor(() => { + fireEvent.click(screen.getByText('Upload comskip.ini')); + }); + + await waitFor(() => { + expect(uploadComskipIni).toHaveBeenCalledWith(mockFile); + }); + }); + + it('shows success notification after successful upload', async () => { + const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); + vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' }); + + render(); + fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } }); + + await waitFor(() => { + fireEvent.click(screen.getByText('Upload comskip.ini')); + }); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'comskip.ini uploaded', color: 'green' }) + ); + }); + }); + + it('sets comskip_custom_path form field after successful upload', async () => { + const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); + vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' }); + + render(); + fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } }); + + await waitFor(() => { + fireEvent.click(screen.getByText('Upload comskip.ini')); + }); + + await waitFor(() => { + expect(formMock.setFieldValue).toHaveBeenCalledWith( + 'comskip_custom_path', + '/uploaded/comskip.ini' + ); + }); + }); + + it('handles upload error gracefully', async () => { + const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); + vi.mocked(uploadComskipIni).mockRejectedValue(new Error('upload failed')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } }); + + await waitFor(() => { + fireEvent.click(screen.getByText('Upload comskip.ini')); + }); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to upload comskip.ini', + expect.any(Error) + ); + expect(showNotification).not.toHaveBeenCalled(); + }); + consoleSpy.mockRestore(); + }); + + it('does not call uploadComskipIni when no file is selected', async () => { + render(); + await waitFor(() => screen.getByTestId('file-input')); + expect(uploadComskipIni).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx new file mode 100644 index 00000000..32271d0b --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx @@ -0,0 +1,378 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import NetworkAccessForm from '../NetworkAccessForm'; + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../../constants.js', () => ({ + NETWORK_ACCESS_OPTIONS: [ + { value: 'all', label: 'All' }, + { value: 'local', label: 'Local Only' }, + { value: 'custom', label: 'Custom' }, + ], +})); + +// ── Store mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({ + checkSetting: vi.fn(), + updateSetting: vi.fn(), +})); + +vi.mock('../../../../utils/forms/settings/NetworkAccessFormUtils.js', () => ({ + getNetworkAccessFormInitialValues: vi.fn(), + getNetworkAccessFormValidation: vi.fn(), + getNetworkAccessDefaults: vi.fn(), +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => ({ useForm: vi.fn() })); + +// ── ConfirmationDialog mock ──────────────────────────────────────────────────── +vi.mock('../../../ConfirmationDialog.jsx', () => ({ + default: ({ opened, onConfirm, onClose, title, message }) => + opened ? ( +
+
{title}
+
{message}
+ + +
+ ) : null, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ title, children }) => ( +
+ {title} + {children} +
+ ), + Button: ({ children, onClick, disabled, loading, type, variant }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, + TextInput: ({ label, id, placeholder, error, ...rest }) => ( +
+ rest.onChange?.(e)} + value={rest.value ?? ''} + /> + {error && {error}} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import { useForm } from '@mantine/form'; +import { checkSetting, updateSetting } from '../../../../utils/pages/SettingsUtils.js'; +import { + getNetworkAccessFormInitialValues, + getNetworkAccessFormValidation, + getNetworkAccessDefaults, +} from '../../../../utils/forms/settings/NetworkAccessFormUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── +const mockInitialValues = { + m3u_access: 'local', + epg_access: 'local', + recordings_access: 'all', + m3u_custom_cidrs: '', + epg_custom_cidrs: '', + recordings_custom_cidrs: '', +}; + +const makeFormMock = (overrides = {}) => ({ + key: vi.fn(), + getInputProps: vi.fn((field) => ({ + value: mockInitialValues[field] ?? '', + onChange: vi.fn(), + })), + setValues: vi.fn(), + setErrors: vi.fn(), + setFieldValue: vi.fn(), + getValues: vi.fn(() => mockInitialValues), + validate: vi.fn(() => ({ hasErrors: false })), + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + return handler(mockInitialValues); + }), + errors: {}, + ...overrides, +}); + +const makeSettings = (overrides = {}) => ({ + network_access: { + key: 'network_access', + value: { + m3u_access: 'local', + epg_access: 'local', + recordings_access: 'all', + }, + }, + ...overrides, +}); + +const setupMocks = ({ settings = makeSettings(), formOverrides = {} } = {}) => { + const formMock = makeFormMock(formOverrides); + + vi.mocked(useForm).mockReturnValue(formMock); + vi.mocked(getNetworkAccessFormInitialValues).mockReturnValue(mockInitialValues); + vi.mocked(getNetworkAccessFormValidation).mockReturnValue({}); + vi.mocked(getNetworkAccessDefaults).mockReturnValue(mockInitialValues); + vi.mocked(checkSetting).mockResolvedValue({ client_ip: '192.168.1.1', UI: ['192.168.0.0/16'] }); + vi.mocked(updateSetting).mockResolvedValue(undefined); + + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); + + return { formMock }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── +describe('NetworkAccessForm', () => { + let formMock; + + beforeEach(() => { + vi.clearAllMocks(); + ({ formMock } = setupMocks()); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + describe('rendering', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('renders the Save button', () => { + render(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('does not show success alert on initial render', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not show confirmation dialog on initial render', () => { + render(); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('does not show error alert on initial render', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + describe('initialization', () => { + it('calls getNetworkAccessFormInitialValues on mount', () => { + render(); + expect(getNetworkAccessFormInitialValues).toHaveBeenCalled(); + }); + + it('calls getNetworkAccessFormValidation on mount', () => { + render(); + expect(getNetworkAccessFormValidation).toHaveBeenCalled(); + }); + + it('does not call checkSetting on mount', () => { + render(); + expect(checkSetting).not.toHaveBeenCalled(); + }); + + it('calls checkSetting when form is submitted', async () => { + render(); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(checkSetting).toHaveBeenCalled(); + }); + }); + + it('sets form values from network_access settings on mount', async () => { + render(); + await waitFor(() => { + expect(formMock.setValues).toHaveBeenCalled(); + }); + }); + + it('handles missing network_access setting gracefully', async () => { + ({ formMock } = setupMocks({ settings: {} })); + expect(() => render()).not.toThrow(); + }); + + it('handles checkSetting error gracefully', async () => { + vi.mocked(checkSetting).mockRejectedValue(new Error('network error')); + render(); + }); + }); + + // ── active prop ──────────────────────────────────────────────────────────── + describe('active prop', () => { + it('resets saved state when active becomes false', async () => { + vi.mocked(updateSetting).mockResolvedValue(undefined); + const { rerender } = render(); + + fireEvent.submit(screen.getByText('Save').closest('form') ?? screen.getByText('Save').closest('div')); + + await waitFor(() => { + expect(screen.queryByTestId('confirmation-dialog')).not.toBeNull(); + }).catch(() => {}); + + rerender(); + + await waitFor(() => { + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Form submission ──────────────────────────────────────────────────────── + describe('form submission', () => { + it('opens confirmation dialog on Save click', async () => { + render(); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + }); + + it('closes confirmation dialog on Cancel click', async () => { + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + + it('calls updateSetting on confirm', async () => { + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalled(); + }); + }); + + it('shows success alert after confirmed save', async () => { + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + }); + }); + + it('does not call updateSetting when confirmation is cancelled', async () => { + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + await waitFor(() => { + expect(updateSetting).not.toHaveBeenCalled(); + }); + }); + + it('does not show success alert when updateSetting throws', async () => { + vi.mocked(updateSetting).mockRejectedValue( + { body: { value: { m3u_custom_cidrs: 'Invalid CIDR' } } }); + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + const alertTitle = screen.queryByTestId('alert-title'); + expect(alertTitle?.textContent).not.toBe('Saved Successfully'); + }); + }); + }); + + // ── Client IP display ────────────────────────────────────────────────────── + describe('client IP display', () => { + it('displays client IP address fetched on submit', async () => { + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => { + expect(screen.getByText(/192\.168\.1\.1/)).toBeInTheDocument(); + }); + }); + + it('does not display IP when checkSetting returns null', async () => { + vi.mocked(checkSetting).mockResolvedValue(null); + render(); + await waitFor(() => { + expect(screen.queryByText(/\d+\.\d+\.\d+\.\d+/)).not.toBeInTheDocument(); + }); + }); + }); + + // ── Network access error ─────────────────────────────────────────────────── + describe('network access error state', () => { + it('clears error state after successful save', async () => { + vi.mocked(checkSetting).mockResolvedValue( + { error: true, message: 'Invalid CIDR', data: 'Error details' } + ); + + render(); + + // First save — fails + fireEvent.click(screen.getByText('Save')); + await waitFor(() => screen.getByTestId('alert')); + + vi.mocked(checkSetting).mockResolvedValue( + { client_ip: '192.168.1.1', UI: ['192.168.0.0/16'] }); + + // Second save — succeeds + fireEvent.click(screen.getByText('Save')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + }); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx new file mode 100644 index 00000000..193f528c --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -0,0 +1,347 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ProxySettingsForm from '../ProxySettingsForm'; + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../../constants.js', () => ({ + PROXY_SETTINGS_OPTIONS: { + buffering_timeout: { label: 'Buffering Timeout', description: 'Timeout in seconds' }, + buffering_speed: { label: 'Buffering Speed', description: 'Speed multiplier' }, + redis_url: { label: 'Redis URL', description: 'Redis connection URL' }, + }, +})); + +// ── Store mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({ + updateSetting: vi.fn(), +})); + +vi.mock('../../../../utils/forms/settings/ProxySettingsFormUtils.js', () => ({ + getProxySettingsFormInitialValues: vi.fn(), + getProxySettingDefaults: vi.fn(), +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => ({ useForm: vi.fn() })); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ title, children }) => ( +
+ {title} + {children} +
+ ), + Button: ({ children, onClick, disabled, loading, type, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + NumberInput: ({ label, description, min, max, step, ...rest }) => ( +
+ + {description && {description}} + rest.onChange?.(Number(e.target.value))} + value={rest.value ?? 0} + /> +
+ ), + Stack: ({ children }) =>
{children}
, + TextInput: ({ label, description, ...rest }) => ( +
+ + {description && {description}} + rest.onChange?.(e)} + value={rest.value ?? ''} + /> +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import { useForm } from '@mantine/form'; +import { updateSetting } from '../../../../utils/pages/SettingsUtils.js'; +import { + getProxySettingsFormInitialValues, + getProxySettingDefaults, +} from '../../../../utils/forms/settings/ProxySettingsFormUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── +const mockInitialValues = { + buffering_timeout: 30, + buffering_speed: 1.0, + redis_url: 'redis://localhost:6379', +}; + +const mockDefaults = { + buffering_timeout: 30, + buffering_speed: 1.0, + redis_url: '', +}; + +const makeFormMock = (overrides = {}) => ({ + getInputProps: vi.fn((field) => ({ + value: mockInitialValues[field] ?? '', + onChange: vi.fn(), + })), + setValues: vi.fn(), + getValues: vi.fn(() => mockInitialValues), + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + return handler(); + }), + submitting: false, + errors: {}, + ...overrides, +}); + +const makeSettings = (overrides = {}) => ({ + proxy_settings: { + key: 'proxy_settings', + value: { ...mockInitialValues }, + }, + ...overrides, +}); + +const setupMocks = ({ settings = makeSettings(), formOverrides = {} } = {}) => { + const formMock = makeFormMock(formOverrides); + + vi.mocked(useForm).mockReturnValue(formMock); + vi.mocked(getProxySettingsFormInitialValues).mockReturnValue(mockInitialValues); + vi.mocked(getProxySettingDefaults).mockReturnValue(mockDefaults); + vi.mocked(updateSetting).mockResolvedValue({ success: true }); + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); + + return { formMock }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── +describe('ProxySettingsForm', () => { + let formMock; + + beforeEach(() => { + vi.clearAllMocks(); + ({ formMock } = setupMocks()); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + describe('rendering', () => { + it('does not show success alert on initial render', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('renders NumberInput for buffering_timeout', () => { + render(); + expect(screen.getByTestId('number-input-Buffering Timeout')).toBeInTheDocument(); + }); + + it('renders NumberInput for buffering_speed', () => { + render(); + expect(screen.getByTestId('number-input-Buffering Speed')).toBeInTheDocument(); + }); + + it('renders TextInput for redis_url', () => { + render(); + expect(screen.getByTestId('text-input-Redis URL')).toBeInTheDocument(); + }); + + it('renders description text for fields that have one', () => { + render(); + expect(screen.getByTestId('desc-Buffering Timeout')).toBeInTheDocument(); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + describe('initialization', () => { + it('calls getProxySettingsFormInitialValues on mount', () => { + render(); + expect(getProxySettingsFormInitialValues).toHaveBeenCalled(); + }); + + it('calls setValues with merged defaults and stored settings on mount', async () => { + render(); + await waitFor(() => { + expect(formMock.setValues).toHaveBeenCalledWith({ + ...mockDefaults, + ...makeSettings().proxy_settings.value, + }); + }); + }); + + it('does not call setValues when proxy_settings is missing', async () => { + ({ formMock } = setupMocks({ settings: {} })); + render(); + await waitFor(() => { + expect(formMock.setValues).not.toHaveBeenCalled(); + }); + }); + + it('does not call setValues when proxy_settings.value is missing', async () => { + ({ formMock } = setupMocks({ + settings: { proxy_settings: { key: 'proxy_settings' } }, + })); + render(); + await waitFor(() => { + expect(formMock.setValues).not.toHaveBeenCalled(); + }); + }); + + it('handles null settings gracefully', () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings: null })); + expect(() => render()).not.toThrow(); + }); + }); + + // ── active prop ──────────────────────────────────────────────────────────── + describe('active prop', () => { + it('clears saved state when active becomes false', async () => { + vi.mocked(updateSetting).mockResolvedValue({ success: true }); + const { rerender } = render(); + + // Trigger a successful save + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Form submission ──────────────────────────────────────────────────────── + describe('form submission', () => { + it('calls updateSetting with proxy_settings on Save', async () => { + render(); + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalledWith({ + ...makeSettings().proxy_settings, + value: mockInitialValues, + }); + }); + }); + + it('shows success alert when updateSetting returns a truthy result', async () => { + render(); + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + }); + }); + + it('does not show success alert when updateSetting returns undefined', async () => { + vi.mocked(updateSetting).mockResolvedValue(undefined); + render(); + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not show success alert when updateSetting returns null', async () => { + vi.mocked(updateSetting).mockResolvedValue(null); + render(); + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not show success alert when updateSetting throws', async () => { + vi.mocked(updateSetting).mockRejectedValue(new Error('save failed')); + render(); + fireEvent.submit(screen.getByText('Save').closest('form')); + + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('resets saved to false at the start of a new submission', async () => { + render(); + + // First save — succeeds + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => screen.getByTestId('alert')); + + // Second save — returns undefined (no result) + vi.mocked(updateSetting).mockResolvedValue(undefined); + fireEvent.submit(screen.getByText('Save').closest('form')); + + await waitFor(() => { + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Reset to Defaults ────────────────────────────────────────────────────── + describe('Reset to Defaults', () => { + it('calls getProxySettingDefaults when Reset to Defaults is clicked', () => { + render(); + fireEvent.click(screen.getByText('Reset to Defaults')); + expect(getProxySettingDefaults).toHaveBeenCalled(); + }); + + it('calls form.setValues with defaults when Reset to Defaults is clicked', () => { + render(); + fireEvent.click(screen.getByText('Reset to Defaults')); + expect(formMock.setValues).toHaveBeenCalledWith(mockDefaults); + }); + + it('does not submit the form when Reset to Defaults is clicked', async () => { + render(); + fireEvent.click(screen.getByText('Reset to Defaults')); + await waitFor(() => { + expect(updateSetting).not.toHaveBeenCalled(); + }); + }); + }); + + // ── ProxySettingsOptions field routing ───────────────────────────────────── + describe('ProxySettingsOptions field routing', () => { + it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => { + render(); + expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout'); + expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed'); + expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx new file mode 100644 index 00000000..c0742c8e --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx @@ -0,0 +1,760 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import StreamSettingsForm from '../StreamSettingsForm'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../../store/warnings.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../../store/userAgents.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../../store/streamProfiles.jsx', () => ({ default: vi.fn() })); + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../../constants.js', () => ({ + REGION_CHOICES: [ + { label: 'US', value: 'us' }, + { label: 'EU', value: 'eu' }, + ], +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({ + getChangedSettings: vi.fn(), + parseSettings: vi.fn(), + rehashStreams: vi.fn(), + saveChangedSettings: vi.fn(), +})); + +vi.mock('../../../../utils/forms/settings/StreamSettingsFormUtils.js', () => ({ + getStreamSettingsFormInitialValues: vi.fn(), + getStreamSettingsFormValidation: vi.fn(), +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => ({ useForm: vi.fn() })); + +// ── ConfirmationDialog mock ──────────────────────────────────────────────────── +vi.mock('../../../ConfirmationDialog.jsx', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + actionKey, + onSuppressChange, + }) => + opened ? ( +
+
{title}
+
{message}
+ + + {actionKey && ( + + )} +
+ ) : null, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ title, children }) => ( +
+ {title} + {children} +
+ ), + Button: ({ children, onClick, disabled, loading, type, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + MultiSelect: ({ label, id, data, ...rest }) => ( +
+ + +
+ ), + Select: ({ label, id, data, ...rest }) => ( +
+ + +
+ ), + Switch: ({ id, ...rest }) => ( + rest.onChange?.(e)} + /> + ), + Text: ({ children, size, fw }) => ( + + {children} + + ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import useWarningsStore from '../../../../store/warnings.jsx'; +import useUserAgentsStore from '../../../../store/userAgents.jsx'; +import useStreamProfilesStore from '../../../../store/streamProfiles.jsx'; +import { useForm } from '@mantine/form'; +import { + getChangedSettings, + parseSettings, + rehashStreams, + saveChangedSettings, +} from '../../../../utils/pages/SettingsUtils.js'; +import { + getStreamSettingsFormInitialValues, + getStreamSettingsFormValidation, +} from '../../../../utils/forms/settings/StreamSettingsFormUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── +const mockFormValues = { + default_user_agent: '1', + default_stream_profile: '2', + preferred_region: 'us', + auto_import_mapped_files: false, + m3u_hash_key: ['name'], +}; + +const makeFormMock = (overrides = {}) => ({ + getInputProps: vi.fn((field, opts) => { + if (opts?.type === 'checkbox') { + return { checked: mockFormValues[field] ?? false, onChange: vi.fn() }; + } + return { value: mockFormValues[field] ?? '', onChange: vi.fn() }; + }), + setValues: vi.fn(), + getValues: vi.fn(() => mockFormValues), + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + return handler(); + }), + submitting: false, + errors: {}, + ...overrides, +}); + +const makeSettings = (overrides = {}) => ({ + stream_settings: { + key: 'stream_settings', + value: { m3u_hash_key: 'name' }, + }, + ...overrides, +}); + +const makeUserAgents = () => [ + { id: 1, name: 'Chrome' }, + { id: 2, name: 'Firefox' }, +]; + +const makeStreamProfiles = () => [ + { id: 1, name: 'Default' }, + { id: 2, name: 'HLS' }, +]; + +const setupMocks = ({ + settings = makeSettings(), + formOverrides = {}, + warningSuppressed = false, + userAgents = makeUserAgents(), + streamProfiles = makeStreamProfiles(), + } = {}) => { + const formMock = makeFormMock(formOverrides); + + vi.mocked(useForm).mockReturnValue(formMock); + vi.mocked(getStreamSettingsFormInitialValues).mockReturnValue(mockFormValues); + vi.mocked(getStreamSettingsFormValidation).mockReturnValue({}); + vi.mocked(parseSettings).mockReturnValue(mockFormValues); + vi.mocked(getChangedSettings).mockReturnValue({ preferred_region: 'eu' }); + vi.mocked(saveChangedSettings).mockResolvedValue(undefined); + vi.mocked(rehashStreams).mockResolvedValue(undefined); + + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ + suppressWarning: vi.fn(), + isWarningSuppressed: vi.fn(() => warningSuppressed), + }) + ); + vi.mocked(useUserAgentsStore).mockImplementation((sel) => + sel({ userAgents }) + ); + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ profiles: streamProfiles }) + ); + + return { formMock }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── +describe('StreamSettingsForm', () => { + let formMock; + + beforeEach(() => { + vi.clearAllMocks(); + ({ formMock } = setupMocks()); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + describe('rendering', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('renders the Save button', () => { + render(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('renders the Rehash Streams button', () => { + render(); + expect(screen.getByText('Rehash Streams')).toBeInTheDocument(); + }); + + it('renders the Default User Agent select', () => { + render(); + expect(screen.getByTestId('default_user_agent')).toBeInTheDocument(); + }); + + it('renders the Default Stream Profile select', () => { + render(); + expect(screen.getByTestId('default_stream_profile')).toBeInTheDocument(); + }); + + it('renders the Preferred Region select', () => { + render(); + expect(screen.getByTestId('preferred_region')).toBeInTheDocument(); + }); + + it('renders the Auto-Import Mapped Files switch', () => { + render(); + expect(screen.getByTestId('auto_import_mapped_files')).toBeInTheDocument(); + }); + + it('renders the M3U Hash Key multiselect', () => { + render(); + expect(screen.getByTestId('m3u_hash_key')).toBeInTheDocument(); + }); + + it('populates user agent options from store', () => { + render(); + expect(screen.getByText('Chrome')).toBeInTheDocument(); + expect(screen.getByText('Firefox')).toBeInTheDocument(); + }); + + it('populates stream profile options from store', () => { + render(); + expect(screen.getByText('Default')).toBeInTheDocument(); + expect(screen.getByText('HLS')).toBeInTheDocument(); + }); + + it('populates region options from REGION_CHOICES', () => { + render(); + expect(screen.getByText('US')).toBeInTheDocument(); + expect(screen.getByText('EU')).toBeInTheDocument(); + }); + + it('does not show success alert on initial render', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not show confirmation dialog on initial render', () => { + render(); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + describe('initialization', () => { + it('calls getStreamSettingsFormInitialValues on mount', () => { + render(); + expect(getStreamSettingsFormInitialValues).toHaveBeenCalled(); + }); + + it('calls getStreamSettingsFormValidation on mount', () => { + render(); + expect(getStreamSettingsFormValidation).toHaveBeenCalled(); + }); + + it('calls parseSettings with settings from store on mount', async () => { + const settings = makeSettings(); + setupMocks({ settings }); + render(); + await waitFor(() => { + expect(parseSettings).toHaveBeenCalledWith(settings); + }); + }); + + it('calls form.setValues with parsed settings on mount', async () => { + render(); + await waitFor(() => { + expect(formMock.setValues).toHaveBeenCalledWith(mockFormValues); + }); + }); + + it('does not call parseSettings when settings is null', async () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); + render(); + await waitFor(() => { + expect(parseSettings).not.toHaveBeenCalled(); + }); + }); + }); + + // ── active prop ──────────────────────────────────────────────────────────── + describe('active prop', () => { + it('clears saved state when active becomes false', async () => { + vi.mocked(saveChangedSettings).mockResolvedValue(undefined); + vi.mocked(getChangedSettings).mockReturnValue({}); + const { rerender } = render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + rerender(); + await waitFor(() => { + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + it('clears rehashSuccess when active becomes false', async () => { + ({ formMock } = setupMocks({ warningSuppressed: true })); + const { rerender } = render(); + + fireEvent.click(screen.getByText('Rehash Streams')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + rerender(); + await waitFor(() => { + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Form submission — no hash key change ─────────────────────────────────── + describe('form submission (no M3U hash key change)', () => { + beforeEach(() => { + // Same hash key before and after → no dialog + vi.mocked(getChangedSettings).mockReturnValue({ preferred_region: 'eu' }); + setupMocks(); + formMock = makeFormMock(); + vi.mocked(useForm).mockReturnValue(formMock); + }); + + it('calls saveChangedSettings with settings and changed values on submit', async () => { + const settings = makeSettings(); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(saveChangedSettings).toHaveBeenCalledWith( + settings, + expect.any(Object) + ); + }); + }); + + it('shows success alert after successful save', async () => { + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + }); + }); + + it('does not show success alert when saveChangedSettings throws', async () => { + vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + consoleSpy.mockRestore(); + }); + + it('logs error when saveChangedSettings throws', async () => { + const error = new Error('save failed'); + vi.mocked(saveChangedSettings).mockRejectedValue(error); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Error saving settings:', error); + }); + consoleSpy.mockRestore(); + }); + + it('does not open confirmation dialog when hash key is unchanged', async () => { + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(saveChangedSettings).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Form submission — hash key changed ──────────────────────────────────── + describe('form submission (M3U hash key changed)', () => { + const makeHashChangedFormMock = () => + makeFormMock({ + getValues: vi.fn(() => ({ + ...mockFormValues, + m3u_hash_key: ['url'], // different from stored 'name' + })), + }); + + it('opens confirmation dialog with save title when hash key changes', async () => { + const fm = makeHashChangedFormMock(); + vi.mocked(useForm).mockReturnValue(fm); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Save Settings and Rehash Streams' + ); + }); + }); + + it('shows "Save and Rehash" confirm label for hash key change dialog', async () => { + const fm = makeHashChangedFormMock(); + vi.mocked(useForm).mockReturnValue(fm); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Save and Rehash'); + }); + }); + + it('does not call saveChangedSettings before dialog is confirmed', async () => { + const fm = makeHashChangedFormMock(); + vi.mocked(useForm).mockReturnValue(fm); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + expect(saveChangedSettings).not.toHaveBeenCalled(); + }); + + it('calls saveChangedSettings after confirming save-and-rehash dialog', async () => { + const fm = makeHashChangedFormMock(); + vi.mocked(useForm).mockReturnValue(fm); + const settings = makeSettings(); + setupMocks({ settings }); + vi.mocked(useForm).mockReturnValue(fm); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => + screen.getByTestId('confirmation-dialog') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(saveChangedSettings).toHaveBeenCalled(); + }); + }); + + it('shows success alert after confirming save-and-rehash dialog', async () => { + const fm = makeHashChangedFormMock(); + vi.mocked(useForm).mockReturnValue(fm); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + }); + }); + + it('closes dialog after confirming save-and-rehash', async () => { + const fm = makeHashChangedFormMock(); + vi.mocked(useForm).mockReturnValue(fm); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + + it('closes dialog and does not save when Cancel is clicked', async () => { + const fm = makeHashChangedFormMock(); + vi.mocked(useForm).mockReturnValue(fm); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + expect(saveChangedSettings).not.toHaveBeenCalled(); + }); + + it('skips dialog entirely when rehash-streams warning is suppressed', async () => { + const fm = makeHashChangedFormMock(); + vi.mocked(useForm).mockReturnValue(fm); + setupMocks({ warningSuppressed: true }); + vi.mocked(useForm).mockReturnValue(fm); + render(); + + fireEvent.submit(screen.getByText('Save').closest('form')); + await waitFor(() => { + expect(saveChangedSettings).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Rehash Streams ───────────────────────────────────────────────────────── + describe('Rehash Streams button', () => { + it('opens confirmation dialog with rehash title when warning is not suppressed', async () => { + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + + await waitFor(() => { + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm Stream Rehash' + ); + }); + }); + + it('shows "Start Rehash" confirm label for rehash-only dialog', async () => { + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + + await waitFor(() => { + expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Start Rehash'); + }); + }); + + it('calls rehashStreams after confirming rehash dialog', async () => { + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(rehashStreams).toHaveBeenCalled(); + }); + }); + + it('skips confirmation dialog and calls rehashStreams directly when suppressed', async () => { + setupMocks({ warningSuppressed: true }); + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + + await waitFor(() => { + expect(rehashStreams).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('shows rehash success alert after rehash completes', async () => { + setupMocks({ warningSuppressed: true }); + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + + await waitFor(() => { + expect(screen.getByTestId('alert-title')).toHaveTextContent( + 'Rehash task queued successfully' + ); + }); + }); + + it('does not show rehash success alert when rehashStreams throws', async () => { + setupMocks({ warningSuppressed: true }); + vi.mocked(rehashStreams).mockRejectedValue(new Error('fail')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + consoleSpy.mockRestore(); + }); + + it('logs error when rehashStreams throws', async () => { + setupMocks({ warningSuppressed: true }); + const error = new Error('network error'); + vi.mocked(rehashStreams).mockRejectedValue(error); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Error rehashing streams:', error); + }); + consoleSpy.mockRestore(); + }); + + it('closes confirmation dialog when Cancel is clicked on rehash dialog', async () => { + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + expect(rehashStreams).not.toHaveBeenCalled(); + }); + + it('calls suppressWarning when "Don\'t show again" is clicked', async () => { + const suppressWarning = vi.fn(); + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ + suppressWarning, + isWarningSuppressed: vi.fn(() => false), + }) + ); + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + fireEvent.click(screen.getByTestId('confirm-suppress')); + + expect(suppressWarning).toHaveBeenCalledWith('rehash-streams'); + }); + + it('Rehash Streams button is disabled while rehashing', async () => { + let resolveRehash; + vi.mocked(rehashStreams).mockReturnValue( + new Promise((res) => { resolveRehash = res; }) + ); + setupMocks({ warningSuppressed: true }); + render(); + fireEvent.click(screen.getByText('Rehash Streams')); + + await waitFor(() => { + expect(screen.getByText('Rehash Streams')).toBeDisabled(); + }); + + resolveRehash(); + await waitFor(() => { + expect(screen.getByText('Rehash Streams')).not.toBeDisabled(); + }); + }); + }); + + // ── getInputProps wiring ─────────────────────────────────────────────────── + describe('getInputProps wiring', () => { + it('calls getInputProps for default_user_agent', () => { + render(); + expect(formMock.getInputProps).toHaveBeenCalledWith('default_user_agent'); + }); + + it('calls getInputProps for default_stream_profile', () => { + render(); + expect(formMock.getInputProps).toHaveBeenCalledWith('default_stream_profile'); + }); + + it('calls getInputProps for preferred_region', () => { + render(); + expect(formMock.getInputProps).toHaveBeenCalledWith('preferred_region'); + }); + + it('calls getInputProps for auto_import_mapped_files with checkbox type', () => { + render(); + expect(formMock.getInputProps).toHaveBeenCalledWith( + 'auto_import_mapped_files', + { type: 'checkbox' } + ); + }); + + it('calls getInputProps for m3u_hash_key', () => { + render(); + expect(formMock.getInputProps).toHaveBeenCalledWith('m3u_hash_key'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx new file mode 100644 index 00000000..b9baa711 --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx @@ -0,0 +1,311 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import SystemSettingsForm from '../SystemSettingsForm'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({ + getChangedSettings: vi.fn(), + parseSettings: vi.fn(), + saveChangedSettings: vi.fn(), +})); + +vi.mock('../../../../utils/forms/settings/SystemSettingsFormUtils.js', () => ({ + getSystemSettingsFormInitialValues: vi.fn(), +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => ({ + useForm: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ title }) =>
{title}
, + Button: ({ children, onClick, disabled }) => ( + + ), + Flex: ({ children }) =>
{children}
, + NumberInput: ({ label, value, onChange, min, max, step, description }) => ( +
+ +

{description}

+ onChange(Number(e.target.value))} + /> +
+ ), + Stack: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import { getChangedSettings, parseSettings, saveChangedSettings } from '../../../../utils/pages/SettingsUtils.js'; +import { getSystemSettingsFormInitialValues } from '../../../../utils/forms/settings/SystemSettingsFormUtils.js'; +import { useForm } from '@mantine/form'; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── +const makeSettings = (overrides = {}) => ({ + max_system_events: 100, + ...overrides, +}); + +const setupMocks = ({ settings = makeSettings() } = {}) => { + const formValues = { max_system_events: settings?.max_system_events ?? 100 }; + + const formMock = { + values: formValues, + getValues: vi.fn().mockReturnValue(formValues), + setValues: vi.fn(), + setFieldValue: vi.fn((key, value) => { + formMock.values[key] = value; + }), + onSubmit: vi.fn((handler) => handler), + submitting: false, + }; + + vi.mocked(useForm).mockReturnValue(formMock); + vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue(formValues); + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); + vi.mocked(parseSettings).mockReturnValue(formValues); + vi.mocked(getChangedSettings).mockReturnValue({ max_system_events: settings?.max_system_events ?? 100 }); + vi.mocked(saveChangedSettings).mockResolvedValue(undefined); + + return { formMock }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── +describe('SystemSettingsForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the Save button', () => { + setupMocks(); + render(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('renders the NumberInput for max_system_events', () => { + setupMocks(); + render(); + expect(screen.getByTestId('number-input')).toBeInTheDocument(); + }); + + it('renders the NumberInput label', () => { + setupMocks(); + render(); + expect(screen.getByText('Maximum System Events')).toBeInTheDocument(); + }); + + it('renders the NumberInput description', () => { + setupMocks(); + render(); + expect( + screen.getByText('Number of events to retain (minimum: 10, maximum: 1000)') + ).toBeInTheDocument(); + }); + + it('renders descriptive text about system events', () => { + setupMocks(); + render(); + expect( + screen.getByText(/Configure how many system events/) + ).toBeInTheDocument(); + }); + + it('does not show success alert on initial render', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('renders NumberInput with value from form values', () => { + setupMocks({ settings: makeSettings({ max_system_events: 250 }) }); + render(); + expect(screen.getByTestId('number-input')).toHaveValue(250); + }); + + it('falls back to 100 when max_system_events is 0/falsy', () => { + const formValues = { max_system_events: 0 }; + const formMock = { + values: formValues, + getValues: vi.fn().mockReturnValue(formValues), + setValues: vi.fn(), + setFieldValue: vi.fn(), + onSubmit: vi.fn((handler) => handler), + submitting: false, + }; + vi.mocked(useForm).mockReturnValue(formMock); + vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue(formValues); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({ max_system_events: 0 }) }) + ); + vi.mocked(parseSettings).mockReturnValue(formValues); + vi.mocked(getChangedSettings).mockReturnValue({}); + vi.mocked(saveChangedSettings).mockResolvedValue(undefined); + + render(); + expect(screen.getByTestId('number-input')).toHaveValue(100); + }); + }); + + // ── Settings initialization ──────────────────────────────────────────────── + + describe('settings initialization', () => { + it('calls parseSettings with settings on mount', () => { + const settings = makeSettings(); + setupMocks({ settings }); + render(); + expect(parseSettings).toHaveBeenCalledWith(settings); + }); + + it('calls form.setValues with parsed settings on mount', () => { + const settings = makeSettings(); + const { formMock } = setupMocks({ settings }); + render(); + expect(formMock.setValues).toHaveBeenCalledWith({ max_system_events: 100 }); + }); + + it('does not call parseSettings when settings is null', () => { + const formMock = { + values: { max_system_events: 100 }, + getValues: vi.fn().mockReturnValue({ max_system_events: 100 }), + setValues: vi.fn(), + setFieldValue: vi.fn(), + onSubmit: vi.fn((handler) => handler), + submitting: false, + }; + vi.mocked(useForm).mockReturnValue(formMock); + vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue({ max_system_events: 100 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings: null })); + vi.mocked(parseSettings).mockReturnValue({}); + vi.mocked(saveChangedSettings).mockResolvedValue(undefined); + + render(); + expect(parseSettings).not.toHaveBeenCalled(); + }); + }); + + // ── NumberInput interaction ──────────────────────────────────────────────── + + describe('NumberInput interaction', () => { + it('calls form.setFieldValue when NumberInput changes', () => { + const { formMock } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('number-input'), { target: { value: '200' } }); + expect(formMock.setFieldValue).toHaveBeenCalledWith('max_system_events', 200); + }); + }); + + // ── Save / submit ────────────────────────────────────────────────────────── + + describe('save button', () => { + it('calls getChangedSettings and saveChangedSettings on submit', async () => { + const settings = makeSettings(); + const { formMock } = setupMocks({ settings }); + render(); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(getChangedSettings).toHaveBeenCalledWith(formMock.getValues(), settings); + expect(saveChangedSettings).toHaveBeenCalled(); + }); + }); + + it('shows success alert after successful save', async () => { + setupMocks(); + render(); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + expect(screen.getByText('Saved Successfully')).toBeInTheDocument(); + }); + + it('does not show success alert when saveChangedSettings throws', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setupMocks(); + vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed')); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + consoleSpy.mockRestore(); + }); + + it('logs error when saveChangedSettings throws', async () => { + const error = new Error('save failed'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setupMocks(); + vi.mocked(saveChangedSettings).mockRejectedValue(error); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Error saving settings:', error); + }); + consoleSpy.mockRestore(); + }); + }); + + // ── active prop / saved state reset ─────────────────────────────────────── + + describe('active prop behavior', () => { + it('clears saved alert when active becomes false', async () => { + setupMocks(); + const { rerender } = render(); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + rerender(); + await waitFor(() => { + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + it('does not clear saved alert while active remains true', async () => { + setupMocks(); + const { rerender } = render(); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + rerender(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx new file mode 100644 index 00000000..b8c0b5ea --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx @@ -0,0 +1,424 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import UiSettingsForm from '../UiSettingsForm'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../../hooks/useLocalStorage.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../../hooks/useTablePreferences.jsx', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../../utils/dateTimeUtils.js', () => ({ + buildTimeZoneOptions: vi.fn(), + getDefaultTimeZone: vi.fn(), +})); + +vi.mock('../../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../../utils/forms/settings/UiSettingsFormUtils.js', () => ({ + saveTimeZoneSetting: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Select: ({ label, value, onChange, data }) => ( +
+ + +
+ ), + Switch: ({ label, description, checked, onChange }) => ( +
+ + {description &&

{description}

} + +
+ ), + Stack: ({ children }) =>
{children}
, +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import useLocalStorage from '../../../../hooks/useLocalStorage.jsx'; +import useTablePreferences from '../../../../hooks/useTablePreferences.jsx'; +import { buildTimeZoneOptions, getDefaultTimeZone } from '../../../../utils/dateTimeUtils.js'; +import { showNotification } from '../../../../utils/notificationUtils.js'; +import { saveTimeZoneSetting } from '../../../../utils/forms/settings/UiSettingsFormUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── +const makeSettings = ({ timeZone = null } = {}) => ({ + system_settings: timeZone + ? { value: { time_zone: timeZone } } + : { value: {} }, +}); + +const DEFAULT_TZ = 'America/New_York'; +const TZ_OPTIONS = [ + { value: 'America/New_York', label: 'America/New_York' }, + { value: 'America/Chicago', label: 'America/Chicago' }, + { value: 'UTC', label: 'UTC' }, +]; + +const setupMocks = ({ + settings = makeSettings(), + timeFormat = '12h', + dateFormat = 'mdy', + timeZone = DEFAULT_TZ, + headerPinned = false, + tableSize = 'default', + } = {}) => { + const setTimeFormat = vi.fn(); + const setDateFormat = vi.fn(); + const setTimeZone = vi.fn(); + const setHeaderPinned = vi.fn(); + const setTableSize = vi.fn(); + + vi.mocked(getDefaultTimeZone).mockReturnValue(DEFAULT_TZ); + vi.mocked(buildTimeZoneOptions).mockReturnValue(TZ_OPTIONS); + vi.mocked(saveTimeZoneSetting).mockResolvedValue(undefined); + + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); + + vi.mocked(useLocalStorage) + .mockImplementation((key, defaultVal) => { + if (key === 'time-format') return [timeFormat, setTimeFormat]; + if (key === 'date-format') return [dateFormat, setDateFormat]; + if (key === 'time-zone') return [timeZone, setTimeZone]; + return [defaultVal, vi.fn()]; + }); + + vi.mocked(useTablePreferences).mockReturnValue({ + headerPinned, + setHeaderPinned, + tableSize, + setTableSize, + }); + + return { setTimeFormat, setDateFormat, setTimeZone, setHeaderPinned, setTableSize }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── +describe('UiSettingsForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the Table Size select', () => { + setupMocks(); + render(); + expect(screen.getByTestId('select-table-size')).toBeInTheDocument(); + }); + + it('renders the Time format select', () => { + setupMocks(); + render(); + expect(screen.getByTestId('select-time-format')).toBeInTheDocument(); + }); + + it('renders the Date format select', () => { + setupMocks(); + render(); + expect(screen.getByTestId('select-date-format')).toBeInTheDocument(); + }); + + it('renders the Time zone select', () => { + setupMocks(); + render(); + expect(screen.getByTestId('select-time-zone')).toBeInTheDocument(); + }); + + it('renders the Pin Table Headers switch', () => { + setupMocks(); + render(); + expect(screen.getByTestId('switch-header-pinned')).toBeInTheDocument(); + }); + + it('renders the switch description', () => { + setupMocks(); + render(); + expect(screen.getByText('Keep table headers visible when scrolling')).toBeInTheDocument(); + }); + + it('renders Table Size select with initial value', () => { + setupMocks({ tableSize: 'compact' }); + render(); + expect(screen.getByTestId('select-table-size')).toHaveValue('compact'); + }); + + it('renders Time format select with initial value', () => { + setupMocks({ timeFormat: '24h' }); + render(); + expect(screen.getByTestId('select-time-format')).toHaveValue('24h'); + }); + + it('renders Date format select with initial value', () => { + setupMocks({ dateFormat: 'dmy' }); + render(); + expect(screen.getByTestId('select-date-format')).toHaveValue('dmy'); + }); + + it('renders Time zone select with initial value', () => { + setupMocks({ timeZone: 'UTC' }); + render(); + expect(screen.getByTestId('select-time-zone')).toHaveValue('UTC'); + }); + + it('renders switch unchecked when headerPinned is false', () => { + setupMocks({ headerPinned: false }); + render(); + expect(screen.getByTestId('switch-header-pinned')).not.toBeChecked(); + }); + + it('renders switch checked when headerPinned is true', () => { + setupMocks({ headerPinned: true }); + render(); + expect(screen.getByTestId('switch-header-pinned')).toBeChecked(); + }); + }); + + // ── onChange handlers ────────────────────────────────────────────────────── + + describe('onChange handlers', () => { + it('calls setTableSize when Table Size select changes', () => { + const { setTableSize } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('select-table-size'), { + target: { value: 'large' }, + }); + expect(setTableSize).toHaveBeenCalledWith('large'); + }); + + it('does not call setTableSize when value is empty', () => { + const { setTableSize } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('select-table-size'), { + target: { value: '' }, + }); + expect(setTableSize).not.toHaveBeenCalled(); + }); + + it('calls setTimeFormat when Time format select changes', () => { + const { setTimeFormat } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('select-time-format'), { + target: { value: '24h' }, + }); + expect(setTimeFormat).toHaveBeenCalledWith('24h'); + }); + + it('does not call setTimeFormat when value is empty', () => { + const { setTimeFormat } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('select-time-format'), { + target: { value: '' }, + }); + expect(setTimeFormat).not.toHaveBeenCalled(); + }); + + it('calls setDateFormat when Date format select changes', () => { + const { setDateFormat } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('select-date-format'), { + target: { value: 'dmy' }, + }); + expect(setDateFormat).toHaveBeenCalledWith('dmy'); + }); + + it('does not call setDateFormat when value is empty', () => { + const { setDateFormat } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('select-date-format'), { + target: { value: '' }, + }); + expect(setDateFormat).not.toHaveBeenCalled(); + }); + + it('calls setTimeZone and saveTimeZoneSetting when Time zone select changes', async () => { + const { setTimeZone } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('select-time-zone'), { + target: { value: 'UTC' }, + }); + expect(setTimeZone).toHaveBeenCalledWith('UTC'); + await waitFor(() => { + expect(saveTimeZoneSetting).toHaveBeenCalledWith('UTC', makeSettings()); + }); + }); + + it('does not call setTimeZone when value is empty', () => { + const { setTimeZone } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('select-time-zone'), { + target: { value: '' }, + }); + expect(setTimeZone).not.toHaveBeenCalled(); + // Only called during initial sync, not on empty change + expect(saveTimeZoneSetting).toHaveBeenCalledTimes(1); + }); + + it('calls setHeaderPinned when switch is toggled on', () => { + const { setHeaderPinned } = setupMocks({ headerPinned: false }); + render(); + fireEvent.click(screen.getByTestId('switch-header-pinned')); + expect(setHeaderPinned).toHaveBeenCalledWith(true); + }); + + it('calls setHeaderPinned when switch is toggled off', () => { + const { setHeaderPinned } = setupMocks({ headerPinned: true }); + render(); + fireEvent.click(screen.getByTestId('switch-header-pinned')); + expect(setHeaderPinned).toHaveBeenCalledWith(false); + }); + }); + + // ── Time zone sync from settings ─────────────────────────────────────────── + + describe('time zone sync from settings', () => { + it('calls setTimeZone with system time_zone on mount when settings has tz', () => { + const { setTimeZone } = setupMocks({ + settings: makeSettings({ timeZone: 'America/Chicago' }), + timeZone: DEFAULT_TZ, + }); + render(); + expect(setTimeZone).toHaveBeenCalled(); + }); + + it('does not change timeZone when settings tz matches current tz', () => { + const { setTimeZone } = setupMocks({ + settings: makeSettings({ timeZone: DEFAULT_TZ }), + timeZone: DEFAULT_TZ, + }); + render(); + // setTimeZone is called with a function that returns prev when equal + const callArg = setTimeZone.mock.calls[0]?.[0]; + if (typeof callArg === 'function') { + expect(callArg(DEFAULT_TZ)).toBe(DEFAULT_TZ); + } + }); + + it('calls persistTimeZoneSetting (saveTimeZoneSetting) when no tz in settings and timeZone is set', async () => { + setupMocks({ + settings: makeSettings({ timeZone: null }), + timeZone: DEFAULT_TZ, + }); + render(); + await waitFor(() => { + expect(saveTimeZoneSetting).toHaveBeenCalledWith(DEFAULT_TZ, makeSettings({ timeZone: null })); + }); + }); + + it('does not call saveTimeZoneSetting when settings is null', async () => { + setupMocks({ settings: null, timeZone: DEFAULT_TZ }); + render(); + await waitFor(() => { + expect(saveTimeZoneSetting).not.toHaveBeenCalled(); + }); + }); + + it('does not call saveTimeZoneSetting when timeZone is falsy and no tz in settings', async () => { + setupMocks({ + settings: makeSettings({ timeZone: null }), + timeZone: '', + }); + render(); + await waitFor(() => { + expect(saveTimeZoneSetting).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Error handling ───────────────────────────────────────────────────────── + + describe('error handling', () => { + it('shows error notification when saveTimeZoneSetting throws on tz change', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: '' }); + vi.mocked(saveTimeZoneSetting).mockRejectedValue(new Error('network error')); + + render(); + fireEvent.change(screen.getByTestId('select-time-zone'), { + target: { value: 'UTC' }, + }); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Failed to update time zone' }) + ); + }); + consoleSpy.mockRestore(); + }); + + it('logs error when saveTimeZoneSetting throws on tz change', async () => { + const error = new Error('network error'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: '' }); + vi.mocked(saveTimeZoneSetting).mockRejectedValue(error); + + render(); + fireEvent.change(screen.getByTestId('select-time-zone'), { + target: { value: 'UTC' }, + }); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to persist time zone setting', + error + ); + }); + consoleSpy.mockRestore(); + }); + + it('shows error notification when saveTimeZoneSetting throws during initial sync', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: DEFAULT_TZ }); + vi.mocked(saveTimeZoneSetting).mockRejectedValue(new Error('sync failed')); + + render(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Failed to update time zone' }) + ); + }); + consoleSpy.mockRestore(); + }); + }); + + // ── buildTimeZoneOptions ─────────────────────────────────────────────────── + + describe('buildTimeZoneOptions', () => { + it('calls buildTimeZoneOptions with current timeZone value', () => { + setupMocks({ timeZone: 'America/Chicago' }); + render(); + expect(buildTimeZoneOptions).toHaveBeenCalledWith('America/Chicago'); + }); + }); +}); \ No newline at end of file From e421c007ccbc76603391be2fa6fbc5e8aaf20a1f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 14 Mar 2026 07:56:05 -0700 Subject: [PATCH 2/2] Conforming to style guide --- .../__tests__/DvrSettingsForm.test.jsx | 96 +++++++++---- .../settings/__tests__/NavOrderForm.test.jsx | 2 +- .../__tests__/NetworkAccessForm.test.jsx | 68 +++++++--- .../__tests__/ProxySettingsForm.test.jsx | 32 +++-- .../__tests__/StreamSettingsForm.test.jsx | 128 ++++++++++++------ .../__tests__/SystemSettingsForm.test.jsx | 59 ++++++-- .../__tests__/UiSettingsForm.test.jsx | 90 ++++++++---- 7 files changed, 339 insertions(+), 136 deletions(-) diff --git a/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx index ad86f1e0..f3293051 100644 --- a/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx @@ -125,13 +125,17 @@ const mockFormValues = { const makeFormMock = (overrides = {}) => ({ getInputProps: vi.fn((field, opts) => { - if (opts?.type === 'checkbox') return { checked: mockFormValues[field] ?? false, onChange: vi.fn() }; + if (opts?.type === 'checkbox') + return { checked: mockFormValues[field] ?? false, onChange: vi.fn() }; return { value: mockFormValues[field] ?? '', onChange: vi.fn() }; }), setValues: vi.fn(), setFieldValue: vi.fn(), getValues: vi.fn(() => mockFormValues), - onSubmit: vi.fn((handler) => (e) => { e?.preventDefault?.(); return handler(); }), + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + return handler(); + }), ...overrides, }); @@ -229,7 +233,9 @@ describe('DvrSettingsForm', () => { expect(screen.getByTestId('tv_template')).toBeInTheDocument(); expect(screen.getByTestId('tv_fallback_template')).toBeInTheDocument(); expect(screen.getByTestId('movie_template')).toBeInTheDocument(); - expect(screen.getByTestId('movie_fallback_template')).toBeInTheDocument(); + expect( + screen.getByTestId('movie_fallback_template') + ).toBeInTheDocument(); }); }); @@ -250,7 +256,9 @@ describe('DvrSettingsForm', () => { it('shows "No custom comskip.ini uploaded." when no config exists', async () => { render(); await waitFor(() => { - expect(screen.getByText('No custom comskip.ini uploaded.')).toBeInTheDocument(); + expect( + screen.getByText('No custom comskip.ini uploaded.') + ).toBeInTheDocument(); }); }); @@ -261,7 +269,9 @@ describe('DvrSettingsForm', () => { }); render(); await waitFor(() => { - expect(screen.getByText('Using /app/docker/comskip.ini')).toBeInTheDocument(); + expect( + screen.getByText('Using /app/docker/comskip.ini') + ).toBeInTheDocument(); }); }); }); @@ -306,7 +316,9 @@ describe('DvrSettingsForm', () => { it('handles getComskipConfig error gracefully', async () => { vi.mocked(getComskipConfig).mockRejectedValue(new Error('network error')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); render(); await waitFor(() => { expect(consoleSpy).toHaveBeenCalledWith( @@ -357,8 +369,13 @@ describe('DvrSettingsForm', () => { fireEvent.submit(screen.getByText('Save').closest('form')); await waitFor(() => { - expect(getChangedSettings).toHaveBeenCalledWith(mockFormValues, makeSettings()); - expect(saveChangedSettings).toHaveBeenCalledWith(makeSettings(), { pre_offset_minutes: 5 }); + expect(getChangedSettings).toHaveBeenCalledWith( + mockFormValues, + makeSettings() + ); + expect(saveChangedSettings).toHaveBeenCalledWith(makeSettings(), { + pre_offset_minutes: 5, + }); }); }); @@ -373,8 +390,12 @@ describe('DvrSettingsForm', () => { }); it('does not show success alert when saveChangedSettings throws', async () => { - vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(saveChangedSettings).mockRejectedValue( + new Error('save failed') + ); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); render(); fireEvent.submit(screen.getByText('Save').closest('form')); @@ -400,11 +421,17 @@ describe('DvrSettingsForm', () => { }); it('calls uploadComskipIni with the selected file', async () => { - const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); - vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' }); + const mockFile = new File(['content'], 'comskip.ini', { + type: 'text/plain', + }); + vi.mocked(uploadComskipIni).mockResolvedValue({ + path: '/uploaded/comskip.ini', + }); render(); - fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } }); + fireEvent.change(screen.getByTestId('file-input'), { + target: { files: [mockFile] }, + }); await waitFor(() => { fireEvent.click(screen.getByText('Upload comskip.ini')); @@ -416,11 +443,17 @@ describe('DvrSettingsForm', () => { }); it('shows success notification after successful upload', async () => { - const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); - vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' }); + const mockFile = new File(['content'], 'comskip.ini', { + type: 'text/plain', + }); + vi.mocked(uploadComskipIni).mockResolvedValue({ + path: '/uploaded/comskip.ini', + }); render(); - fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } }); + fireEvent.change(screen.getByTestId('file-input'), { + target: { files: [mockFile] }, + }); await waitFor(() => { fireEvent.click(screen.getByText('Upload comskip.ini')); @@ -428,17 +461,26 @@ describe('DvrSettingsForm', () => { await waitFor(() => { expect(showNotification).toHaveBeenCalledWith( - expect.objectContaining({ title: 'comskip.ini uploaded', color: 'green' }) + expect.objectContaining({ + title: 'comskip.ini uploaded', + color: 'green', + }) ); }); }); it('sets comskip_custom_path form field after successful upload', async () => { - const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); - vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' }); + const mockFile = new File(['content'], 'comskip.ini', { + type: 'text/plain', + }); + vi.mocked(uploadComskipIni).mockResolvedValue({ + path: '/uploaded/comskip.ini', + }); render(); - fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } }); + fireEvent.change(screen.getByTestId('file-input'), { + target: { files: [mockFile] }, + }); await waitFor(() => { fireEvent.click(screen.getByText('Upload comskip.ini')); @@ -453,12 +495,18 @@ describe('DvrSettingsForm', () => { }); it('handles upload error gracefully', async () => { - const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); + const mockFile = new File(['content'], 'comskip.ini', { + type: 'text/plain', + }); vi.mocked(uploadComskipIni).mockRejectedValue(new Error('upload failed')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); render(); - fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } }); + fireEvent.change(screen.getByTestId('file-input'), { + target: { files: [mockFile] }, + }); await waitFor(() => { fireEvent.click(screen.getByText('Upload comskip.ini')); @@ -480,4 +528,4 @@ describe('DvrSettingsForm', () => { expect(uploadComskipIni).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx index 5cbf603e..3fb91a43 100644 --- a/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx @@ -64,7 +64,7 @@ vi.mock('@dnd-kit/modifiers', () => ({ // Mock Mantine components vi.mock('@mantine/core', () => ({ Box: ({ children, ...props }) =>
{children}
, - Button: ({ children, onClick, disabled, ...props }) => ( + Button: ({ children, onClick, disabled }) => ( diff --git a/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx index 32271d0b..15e5e430 100644 --- a/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx @@ -36,8 +36,12 @@ vi.mock('../../../ConfirmationDialog.jsx', () => ({
{title}
{message}
- - + +
) : null, })); @@ -85,7 +89,10 @@ vi.mock('@mantine/core', () => ({ // ────────────────────────────────────────────────────────────────────────────── import useSettingsStore from '../../../../store/settings.jsx'; import { useForm } from '@mantine/form'; -import { checkSetting, updateSetting } from '../../../../utils/pages/SettingsUtils.js'; +import { + checkSetting, + updateSetting, +} from '../../../../utils/pages/SettingsUtils.js'; import { getNetworkAccessFormInitialValues, getNetworkAccessFormValidation, @@ -139,10 +146,15 @@ const setupMocks = ({ settings = makeSettings(), formOverrides = {} } = {}) => { const formMock = makeFormMock(formOverrides); vi.mocked(useForm).mockReturnValue(formMock); - vi.mocked(getNetworkAccessFormInitialValues).mockReturnValue(mockInitialValues); + vi.mocked(getNetworkAccessFormInitialValues).mockReturnValue( + mockInitialValues + ); vi.mocked(getNetworkAccessFormValidation).mockReturnValue({}); vi.mocked(getNetworkAccessDefaults).mockReturnValue(mockInitialValues); - vi.mocked(checkSetting).mockResolvedValue({ client_ip: '192.168.1.1', UI: ['192.168.0.0/16'] }); + vi.mocked(checkSetting).mockResolvedValue({ + client_ip: '192.168.1.1', + UI: ['192.168.0.0/16'], + }); vi.mocked(updateSetting).mockResolvedValue(undefined); vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); @@ -180,7 +192,9 @@ describe('NetworkAccessForm', () => { it('does not show confirmation dialog on initial render', () => { render(); - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); it('does not show error alert on initial render', () => { @@ -238,7 +252,10 @@ describe('NetworkAccessForm', () => { vi.mocked(updateSetting).mockResolvedValue(undefined); const { rerender } = render(); - fireEvent.submit(screen.getByText('Save').closest('form') ?? screen.getByText('Save').closest('div')); + fireEvent.submit( + screen.getByText('Save').closest('form') ?? + screen.getByText('Save').closest('div') + ); await waitFor(() => { expect(screen.queryByTestId('confirmation-dialog')).not.toBeNull(); @@ -270,7 +287,9 @@ describe('NetworkAccessForm', () => { fireEvent.click(screen.getByTestId('confirm-cancel')); await waitFor(() => { - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); }); @@ -295,7 +314,9 @@ describe('NetworkAccessForm', () => { await waitFor(() => { expect(screen.getByTestId('alert')).toBeInTheDocument(); - expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + expect(screen.getByTestId('alert-title')).toHaveTextContent( + 'Saved Successfully' + ); }); }); @@ -312,8 +333,9 @@ describe('NetworkAccessForm', () => { }); it('does not show success alert when updateSetting throws', async () => { - vi.mocked(updateSetting).mockRejectedValue( - { body: { value: { m3u_custom_cidrs: 'Invalid CIDR' } } }); + vi.mocked(updateSetting).mockRejectedValue({ + body: { value: { m3u_custom_cidrs: 'Invalid CIDR' } }, + }); render(); fireEvent.click(screen.getByText('Save')); @@ -344,7 +366,9 @@ describe('NetworkAccessForm', () => { vi.mocked(checkSetting).mockResolvedValue(null); render(); await waitFor(() => { - expect(screen.queryByText(/\d+\.\d+\.\d+\.\d+/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/\d+\.\d+\.\d+\.\d+/) + ).not.toBeInTheDocument(); }); }); }); @@ -352,9 +376,11 @@ describe('NetworkAccessForm', () => { // ── Network access error ─────────────────────────────────────────────────── describe('network access error state', () => { it('clears error state after successful save', async () => { - vi.mocked(checkSetting).mockResolvedValue( - { error: true, message: 'Invalid CIDR', data: 'Error details' } - ); + vi.mocked(checkSetting).mockResolvedValue({ + error: true, + message: 'Invalid CIDR', + data: 'Error details', + }); render(); @@ -362,8 +388,10 @@ describe('NetworkAccessForm', () => { fireEvent.click(screen.getByText('Save')); await waitFor(() => screen.getByTestId('alert')); - vi.mocked(checkSetting).mockResolvedValue( - { client_ip: '192.168.1.1', UI: ['192.168.0.0/16'] }); + vi.mocked(checkSetting).mockResolvedValue({ + client_ip: '192.168.1.1', + UI: ['192.168.0.0/16'], + }); // Second save — succeeds fireEvent.click(screen.getByText('Save')); @@ -371,8 +399,10 @@ describe('NetworkAccessForm', () => { fireEvent.click(screen.getByTestId('confirm-ok')); await waitFor(() => { - expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + expect(screen.getByTestId('alert-title')).toHaveTextContent( + 'Saved Successfully' + ); }); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index 193f528c..c874b3e6 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -5,8 +5,14 @@ import ProxySettingsForm from '../ProxySettingsForm'; // ── Constants mock ───────────────────────────────────────────────────────────── vi.mock('../../../../constants.js', () => ({ PROXY_SETTINGS_OPTIONS: { - buffering_timeout: { label: 'Buffering Timeout', description: 'Timeout in seconds' }, - buffering_speed: { label: 'Buffering Speed', description: 'Speed multiplier' }, + buffering_timeout: { + label: 'Buffering Timeout', + description: 'Timeout in seconds', + }, + buffering_speed: { + label: 'Buffering Speed', + description: 'Speed multiplier', + }, redis_url: { label: 'Redis URL', description: 'Redis connection URL' }, }, })); @@ -133,7 +139,9 @@ const setupMocks = ({ settings = makeSettings(), formOverrides = {} } = {}) => { const formMock = makeFormMock(formOverrides); vi.mocked(useForm).mockReturnValue(formMock); - vi.mocked(getProxySettingsFormInitialValues).mockReturnValue(mockInitialValues); + vi.mocked(getProxySettingsFormInitialValues).mockReturnValue( + mockInitialValues + ); vi.mocked(getProxySettingDefaults).mockReturnValue(mockDefaults); vi.mocked(updateSetting).mockResolvedValue({ success: true }); vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); @@ -161,12 +169,16 @@ describe('ProxySettingsForm', () => { it('renders NumberInput for buffering_timeout', () => { render(); - expect(screen.getByTestId('number-input-Buffering Timeout')).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Buffering Timeout') + ).toBeInTheDocument(); }); it('renders NumberInput for buffering_speed', () => { render(); - expect(screen.getByTestId('number-input-Buffering Speed')).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Buffering Speed') + ).toBeInTheDocument(); }); it('renders TextInput for redis_url', () => { @@ -216,7 +228,9 @@ describe('ProxySettingsForm', () => { }); it('handles null settings gracefully', () => { - vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings: null })); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); expect(() => render()).not.toThrow(); }); }); @@ -261,7 +275,9 @@ describe('ProxySettingsForm', () => { await waitFor(() => { expect(screen.getByTestId('alert')).toBeInTheDocument(); - expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + expect(screen.getByTestId('alert-title')).toHaveTextContent( + 'Saved Successfully' + ); }); }); @@ -344,4 +360,4 @@ describe('ProxySettingsForm', () => { expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx index c0742c8e..25ee46aa 100644 --- a/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx @@ -35,16 +35,16 @@ vi.mock('@mantine/form', () => ({ useForm: vi.fn() })); // ── ConfirmationDialog mock ──────────────────────────────────────────────────── vi.mock('../../../ConfirmationDialog.jsx', () => ({ default: ({ - opened, - onClose, - onConfirm, - title, - message, - confirmLabel, - cancelLabel, - actionKey, - onSuppressChange, - }) => + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + actionKey, + onSuppressChange, + }) => opened ? (
{title}
@@ -98,7 +98,9 @@ vi.mock('@mantine/core', () => ({ aria-label={label} multiple onChange={(e) => { - const selected = Array.from(e.target.selectedOptions).map((o) => o.value); + const selected = Array.from(e.target.selectedOptions).map( + (o) => o.value + ); rest.onChange?.(selected); }} value={rest.value ?? []} @@ -212,12 +214,12 @@ const makeStreamProfiles = () => [ ]; const setupMocks = ({ - settings = makeSettings(), - formOverrides = {}, - warningSuppressed = false, - userAgents = makeUserAgents(), - streamProfiles = makeStreamProfiles(), - } = {}) => { + settings = makeSettings(), + formOverrides = {}, + warningSuppressed = false, + userAgents = makeUserAgents(), + streamProfiles = makeStreamProfiles(), +} = {}) => { const formMock = makeFormMock(formOverrides); vi.mocked(useForm).mockReturnValue(formMock); @@ -290,7 +292,9 @@ describe('StreamSettingsForm', () => { it('renders the Auto-Import Mapped Files switch', () => { render(); - expect(screen.getByTestId('auto_import_mapped_files')).toBeInTheDocument(); + expect( + screen.getByTestId('auto_import_mapped_files') + ).toBeInTheDocument(); }); it('renders the M3U Hash Key multiselect', () => { @@ -323,7 +327,9 @@ describe('StreamSettingsForm', () => { it('does not show confirmation dialog on initial render', () => { render(); - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); }); @@ -429,13 +435,19 @@ describe('StreamSettingsForm', () => { fireEvent.submit(screen.getByText('Save').closest('form')); await waitFor(() => { expect(screen.getByTestId('alert')).toBeInTheDocument(); - expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + expect(screen.getByTestId('alert-title')).toHaveTextContent( + 'Saved Successfully' + ); }); }); it('does not show success alert when saveChangedSettings throws', async () => { - vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(saveChangedSettings).mockRejectedValue( + new Error('save failed') + ); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); render(); fireEvent.submit(screen.getByText('Save').closest('form')); @@ -449,12 +461,17 @@ describe('StreamSettingsForm', () => { it('logs error when saveChangedSettings throws', async () => { const error = new Error('save failed'); vi.mocked(saveChangedSettings).mockRejectedValue(error); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); render(); fireEvent.submit(screen.getByText('Save').closest('form')); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('Error saving settings:', error); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error saving settings:', + error + ); }); consoleSpy.mockRestore(); }); @@ -466,7 +483,9 @@ describe('StreamSettingsForm', () => { await waitFor(() => { expect(saveChangedSettings).toHaveBeenCalled(); }); - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); }); @@ -501,7 +520,9 @@ describe('StreamSettingsForm', () => { fireEvent.submit(screen.getByText('Save').closest('form')); await waitFor(() => { - expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Save and Rehash'); + expect(screen.getByTestId('confirm-ok')).toHaveTextContent( + 'Save and Rehash' + ); }); }); @@ -526,9 +547,7 @@ describe('StreamSettingsForm', () => { render(); fireEvent.submit(screen.getByText('Save').closest('form')); - await waitFor(() => - screen.getByTestId('confirmation-dialog') - ); + await waitFor(() => screen.getByTestId('confirmation-dialog')); fireEvent.click(screen.getByTestId('confirm-ok')); await waitFor(() => { @@ -546,7 +565,9 @@ describe('StreamSettingsForm', () => { fireEvent.click(screen.getByTestId('confirm-ok')); await waitFor(() => { - expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully'); + expect(screen.getByTestId('alert-title')).toHaveTextContent( + 'Saved Successfully' + ); }); }); @@ -560,7 +581,9 @@ describe('StreamSettingsForm', () => { fireEvent.click(screen.getByTestId('confirm-ok')); await waitFor(() => { - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); }); @@ -574,7 +597,9 @@ describe('StreamSettingsForm', () => { fireEvent.click(screen.getByTestId('confirm-cancel')); await waitFor(() => { - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); expect(saveChangedSettings).not.toHaveBeenCalled(); }); @@ -590,7 +615,9 @@ describe('StreamSettingsForm', () => { await waitFor(() => { expect(saveChangedSettings).toHaveBeenCalled(); }); - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); }); @@ -613,7 +640,9 @@ describe('StreamSettingsForm', () => { fireEvent.click(screen.getByText('Rehash Streams')); await waitFor(() => { - expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Start Rehash'); + expect(screen.getByTestId('confirm-ok')).toHaveTextContent( + 'Start Rehash' + ); }); }); @@ -636,7 +665,9 @@ describe('StreamSettingsForm', () => { await waitFor(() => { expect(rehashStreams).toHaveBeenCalled(); }); - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); it('shows rehash success alert after rehash completes', async () => { @@ -654,7 +685,9 @@ describe('StreamSettingsForm', () => { it('does not show rehash success alert when rehashStreams throws', async () => { setupMocks({ warningSuppressed: true }); vi.mocked(rehashStreams).mockRejectedValue(new Error('fail')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); render(); fireEvent.click(screen.getByText('Rehash Streams')); @@ -669,12 +702,17 @@ describe('StreamSettingsForm', () => { setupMocks({ warningSuppressed: true }); const error = new Error('network error'); vi.mocked(rehashStreams).mockRejectedValue(error); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); render(); fireEvent.click(screen.getByText('Rehash Streams')); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('Error rehashing streams:', error); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error rehashing streams:', + error + ); }); consoleSpy.mockRestore(); }); @@ -686,7 +724,9 @@ describe('StreamSettingsForm', () => { fireEvent.click(screen.getByTestId('confirm-cancel')); await waitFor(() => { - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); }); expect(rehashStreams).not.toHaveBeenCalled(); }); @@ -710,7 +750,9 @@ describe('StreamSettingsForm', () => { it('Rehash Streams button is disabled while rehashing', async () => { let resolveRehash; vi.mocked(rehashStreams).mockReturnValue( - new Promise((res) => { resolveRehash = res; }) + new Promise((res) => { + resolveRehash = res; + }) ); setupMocks({ warningSuppressed: true }); render(); @@ -736,7 +778,9 @@ describe('StreamSettingsForm', () => { it('calls getInputProps for default_stream_profile', () => { render(); - expect(formMock.getInputProps).toHaveBeenCalledWith('default_stream_profile'); + expect(formMock.getInputProps).toHaveBeenCalledWith( + 'default_stream_profile' + ); }); it('calls getInputProps for preferred_region', () => { @@ -757,4 +801,4 @@ describe('StreamSettingsForm', () => { expect(formMock.getInputProps).toHaveBeenCalledWith('m3u_hash_key'); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx index b9baa711..a3b156d5 100644 --- a/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx @@ -53,7 +53,11 @@ vi.mock('@mantine/core', () => ({ // Imports after mocks // ────────────────────────────────────────────────────────────────────────────── import useSettingsStore from '../../../../store/settings.jsx'; -import { getChangedSettings, parseSettings, saveChangedSettings } from '../../../../utils/pages/SettingsUtils.js'; +import { + getChangedSettings, + parseSettings, + saveChangedSettings, +} from '../../../../utils/pages/SettingsUtils.js'; import { getSystemSettingsFormInitialValues } from '../../../../utils/forms/settings/SystemSettingsFormUtils.js'; import { useForm } from '@mantine/form'; @@ -83,7 +87,9 @@ const setupMocks = ({ settings = makeSettings() } = {}) => { vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue(formValues); vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); vi.mocked(parseSettings).mockReturnValue(formValues); - vi.mocked(getChangedSettings).mockReturnValue({ max_system_events: settings?.max_system_events ?? 100 }); + vi.mocked(getChangedSettings).mockReturnValue({ + max_system_events: settings?.max_system_events ?? 100, + }); vi.mocked(saveChangedSettings).mockResolvedValue(undefined); return { formMock }; @@ -122,7 +128,9 @@ describe('SystemSettingsForm', () => { setupMocks(); render(); expect( - screen.getByText('Number of events to retain (minimum: 10, maximum: 1000)') + screen.getByText( + 'Number of events to retain (minimum: 10, maximum: 1000)' + ) ).toBeInTheDocument(); }); @@ -184,7 +192,9 @@ describe('SystemSettingsForm', () => { const settings = makeSettings(); const { formMock } = setupMocks({ settings }); render(); - expect(formMock.setValues).toHaveBeenCalledWith({ max_system_events: 100 }); + expect(formMock.setValues).toHaveBeenCalledWith({ + max_system_events: 100, + }); }); it('does not call parseSettings when settings is null', () => { @@ -197,8 +207,12 @@ describe('SystemSettingsForm', () => { submitting: false, }; vi.mocked(useForm).mockReturnValue(formMock); - vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue({ max_system_events: 100 }); - vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings: null })); + vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue({ + max_system_events: 100, + }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); vi.mocked(parseSettings).mockReturnValue({}); vi.mocked(saveChangedSettings).mockResolvedValue(undefined); @@ -213,8 +227,13 @@ describe('SystemSettingsForm', () => { it('calls form.setFieldValue when NumberInput changes', () => { const { formMock } = setupMocks(); render(); - fireEvent.change(screen.getByTestId('number-input'), { target: { value: '200' } }); - expect(formMock.setFieldValue).toHaveBeenCalledWith('max_system_events', 200); + fireEvent.change(screen.getByTestId('number-input'), { + target: { value: '200' }, + }); + expect(formMock.setFieldValue).toHaveBeenCalledWith( + 'max_system_events', + 200 + ); }); }); @@ -229,7 +248,10 @@ describe('SystemSettingsForm', () => { fireEvent.click(screen.getByText('Save')); await waitFor(() => { - expect(getChangedSettings).toHaveBeenCalledWith(formMock.getValues(), settings); + expect(getChangedSettings).toHaveBeenCalledWith( + formMock.getValues(), + settings + ); expect(saveChangedSettings).toHaveBeenCalled(); }); }); @@ -247,9 +269,13 @@ describe('SystemSettingsForm', () => { }); it('does not show success alert when saveChangedSettings throws', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); setupMocks(); - vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed')); + vi.mocked(saveChangedSettings).mockRejectedValue( + new Error('save failed') + ); render(); fireEvent.click(screen.getByText('Save')); @@ -263,7 +289,9 @@ describe('SystemSettingsForm', () => { it('logs error when saveChangedSettings throws', async () => { const error = new Error('save failed'); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); setupMocks(); vi.mocked(saveChangedSettings).mockRejectedValue(error); @@ -271,7 +299,10 @@ describe('SystemSettingsForm', () => { fireEvent.click(screen.getByText('Save')); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('Error saving settings:', error); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error saving settings:', + error + ); }); consoleSpy.mockRestore(); }); @@ -308,4 +339,4 @@ describe('SystemSettingsForm', () => { expect(screen.getByTestId('alert')).toBeInTheDocument(); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx index b8c0b5ea..101f32a4 100644 --- a/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx @@ -7,7 +7,9 @@ vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); // ── Hook mocks ───────────────────────────────────────────────────────────────── vi.mock('../../../../hooks/useLocalStorage.jsx', () => ({ default: vi.fn() })); -vi.mock('../../../../hooks/useTablePreferences.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../../hooks/useTablePreferences.jsx', () => ({ + default: vi.fn(), +})); // ── Utility mocks ────────────────────────────────────────────────────────────── vi.mock('../../../../utils/dateTimeUtils.js', () => ({ @@ -62,7 +64,10 @@ vi.mock('@mantine/core', () => ({ import useSettingsStore from '../../../../store/settings.jsx'; import useLocalStorage from '../../../../hooks/useLocalStorage.jsx'; import useTablePreferences from '../../../../hooks/useTablePreferences.jsx'; -import { buildTimeZoneOptions, getDefaultTimeZone } from '../../../../utils/dateTimeUtils.js'; +import { + buildTimeZoneOptions, + getDefaultTimeZone, +} from '../../../../utils/dateTimeUtils.js'; import { showNotification } from '../../../../utils/notificationUtils.js'; import { saveTimeZoneSetting } from '../../../../utils/forms/settings/UiSettingsFormUtils.js'; @@ -83,13 +88,13 @@ const TZ_OPTIONS = [ ]; const setupMocks = ({ - settings = makeSettings(), - timeFormat = '12h', - dateFormat = 'mdy', - timeZone = DEFAULT_TZ, - headerPinned = false, - tableSize = 'default', - } = {}) => { + settings = makeSettings(), + timeFormat = '12h', + dateFormat = 'mdy', + timeZone = DEFAULT_TZ, + headerPinned = false, + tableSize = 'default', +} = {}) => { const setTimeFormat = vi.fn(); const setDateFormat = vi.fn(); const setTimeZone = vi.fn(); @@ -102,13 +107,12 @@ const setupMocks = ({ vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings })); - vi.mocked(useLocalStorage) - .mockImplementation((key, defaultVal) => { - if (key === 'time-format') return [timeFormat, setTimeFormat]; - if (key === 'date-format') return [dateFormat, setDateFormat]; - if (key === 'time-zone') return [timeZone, setTimeZone]; - return [defaultVal, vi.fn()]; - }); + vi.mocked(useLocalStorage).mockImplementation((key, defaultVal) => { + if (key === 'time-format') return [timeFormat, setTimeFormat]; + if (key === 'date-format') return [dateFormat, setDateFormat]; + if (key === 'time-zone') return [timeZone, setTimeZone]; + return [defaultVal, vi.fn()]; + }); vi.mocked(useTablePreferences).mockReturnValue({ headerPinned, @@ -117,7 +121,13 @@ const setupMocks = ({ setTableSize, }); - return { setTimeFormat, setDateFormat, setTimeZone, setHeaderPinned, setTableSize }; + return { + setTimeFormat, + setDateFormat, + setTimeZone, + setHeaderPinned, + setTableSize, + }; }; // ────────────────────────────────────────────────────────────────────────────── @@ -164,7 +174,9 @@ describe('UiSettingsForm', () => { it('renders the switch description', () => { setupMocks(); render(); - expect(screen.getByText('Keep table headers visible when scrolling')).toBeInTheDocument(); + expect( + screen.getByText('Keep table headers visible when scrolling') + ).toBeInTheDocument(); }); it('renders Table Size select with initial value', () => { @@ -331,7 +343,10 @@ describe('UiSettingsForm', () => { }); render(); await waitFor(() => { - expect(saveTimeZoneSetting).toHaveBeenCalledWith(DEFAULT_TZ, makeSettings({ timeZone: null })); + expect(saveTimeZoneSetting).toHaveBeenCalledWith( + DEFAULT_TZ, + makeSettings({ timeZone: null }) + ); }); }); @@ -359,9 +374,13 @@ describe('UiSettingsForm', () => { describe('error handling', () => { it('shows error notification when saveTimeZoneSetting throws on tz change', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: '' }); - vi.mocked(saveTimeZoneSetting).mockRejectedValue(new Error('network error')); + vi.mocked(saveTimeZoneSetting).mockRejectedValue( + new Error('network error') + ); render(); fireEvent.change(screen.getByTestId('select-time-zone'), { @@ -370,7 +389,10 @@ describe('UiSettingsForm', () => { await waitFor(() => { expect(showNotification).toHaveBeenCalledWith( - expect.objectContaining({ color: 'red', title: 'Failed to update time zone' }) + expect.objectContaining({ + color: 'red', + title: 'Failed to update time zone', + }) ); }); consoleSpy.mockRestore(); @@ -378,7 +400,9 @@ describe('UiSettingsForm', () => { it('logs error when saveTimeZoneSetting throws on tz change', async () => { const error = new Error('network error'); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: '' }); vi.mocked(saveTimeZoneSetting).mockRejectedValue(error); @@ -397,15 +421,25 @@ describe('UiSettingsForm', () => { }); it('shows error notification when saveTimeZoneSetting throws during initial sync', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: DEFAULT_TZ }); - vi.mocked(saveTimeZoneSetting).mockRejectedValue(new Error('sync failed')); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + setupMocks({ + settings: makeSettings({ timeZone: null }), + timeZone: DEFAULT_TZ, + }); + vi.mocked(saveTimeZoneSetting).mockRejectedValue( + new Error('sync failed') + ); render(); await waitFor(() => { expect(showNotification).toHaveBeenCalledWith( - expect.objectContaining({ color: 'red', title: 'Failed to update time zone' }) + expect.objectContaining({ + color: 'red', + title: 'Failed to update time zone', + }) ); }); consoleSpy.mockRestore(); @@ -421,4 +455,4 @@ describe('UiSettingsForm', () => { expect(buildTimeZoneOptions).toHaveBeenCalledWith('America/Chicago'); }); }); -}); \ No newline at end of file +});