This commit is contained in:
SergeantPanda 2026-03-15 17:00:00 -05:00
commit 28b9de94dc
7 changed files with 2907 additions and 1 deletions

View file

@ -0,0 +1,531 @@
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 }) => <div data-testid="alert">{title}</div>,
Button: ({ children, onClick, disabled, type, variant }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={disabled}
data-variant={variant}
>
{children}
</button>
),
FileInput: ({ placeholder, onChange, disabled }) => (
<input
data-testid="file-input"
type="file"
placeholder={placeholder}
disabled={disabled}
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
onChange?.(file);
}}
/>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
NumberInput: ({ label, id, name, ...rest }) => (
<input
data-testid={id}
id={id}
name={name}
aria-label={label}
type="number"
onChange={(e) => rest.onChange?.(Number(e.target.value))}
/>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ label, id, checked, onChange }) => (
<input
data-testid={id}
id={id}
type="checkbox"
aria-label={label}
checked={checked ?? false}
onChange={onChange}
/>
),
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, id, name, placeholder, ...rest }) => (
<input
data-testid={id}
id={id}
name={name}
aria-label={label}
placeholder={placeholder}
onChange={(e) => 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(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByTestId('comskip_enabled')).toBeInTheDocument();
});
});
it('renders the comskip enabled switch', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByTestId('comskip_enabled')).toBeInTheDocument();
});
});
it('renders the comskip custom path text input', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByTestId('comskip_custom_path')).toBeInTheDocument();
});
});
it('renders the file input for comskip.ini upload', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByTestId('file-input')).toBeInTheDocument();
});
});
it('renders the Upload comskip.ini button', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByText('Upload comskip.ini')).toBeInTheDocument();
});
});
it('renders the pre_offset_minutes number input', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByTestId('pre_offset_minutes')).toBeInTheDocument();
});
});
it('renders the post_offset_minutes number input', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByTestId('post_offset_minutes')).toBeInTheDocument();
});
});
it('renders all template text inputs', async () => {
render(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.getByText('Save')).toBeInTheDocument();
});
});
it('does not show success alert on initial render', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
it('shows "No custom comskip.ini uploaded." when no config exists', async () => {
render(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(
screen.getByText('Using /app/docker/comskip.ini')
).toBeInTheDocument();
});
});
});
// Initialization
describe('initialization', () => {
it('calls getDvrSettingsFormInitialValues on mount', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(getDvrSettingsFormInitialValues).toHaveBeenCalled();
});
});
it('calls parseSettings with settings on mount', async () => {
render(<DvrSettingsForm active={true} />);
await waitFor(() => {
expect(parseSettings).toHaveBeenCalledWith(makeSettings());
expect(formMock.setValues).toHaveBeenCalledWith(mockFormValues);
});
});
it('calls getComskipConfig on mount', async () => {
render(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
rerender(<DvrSettingsForm active={false} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
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(<DvrSettingsForm active={true} />);
await waitFor(() => screen.getByTestId('file-input'));
expect(uploadComskipIni).not.toHaveBeenCalled();
});
});
});

View file

@ -64,7 +64,7 @@ vi.mock('@dnd-kit/modifiers', () => ({
// Mock Mantine components
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Button: ({ children, onClick, disabled, ...props }) => (
Button: ({ children, onClick, disabled }) => (
<button onClick={onClick} disabled={disabled} data-testid="reset-button">
{children}
</button>

View file

@ -0,0 +1,408 @@
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 ? (
<div data-testid="confirmation-dialog">
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-message">{message}</div>
<button data-testid="confirm-ok" onClick={onConfirm}>
Confirm
</button>
<button data-testid="confirm-cancel" onClick={onClose}>
Cancel
</button>
</div>
) : null,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Alert: ({ title, children }) => (
<div data-testid="alert">
<span data-testid="alert-title">{title}</span>
{children}
</div>
),
Button: ({ children, onClick, disabled, loading, type, variant }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={disabled || loading}
data-variant={variant}
data-loading={String(loading)}
>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, id, placeholder, error, ...rest }) => (
<div>
<input
data-testid={id}
id={id}
aria-label={label}
placeholder={placeholder}
data-error={error}
onChange={(e) => rest.onChange?.(e)}
value={rest.value ?? ''}
/>
{error && <span data-testid={`${id}-error`}>{error}</span>}
</div>
),
}));
//
// 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(<NetworkAccessForm active={true} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('renders the Save button', () => {
render(<NetworkAccessForm active={true} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('does not show success alert on initial render', () => {
render(<NetworkAccessForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not show confirmation dialog on initial render', () => {
render(<NetworkAccessForm active={true} />);
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('does not show error alert on initial render', () => {
render(<NetworkAccessForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Initialization
describe('initialization', () => {
it('calls getNetworkAccessFormInitialValues on mount', () => {
render(<NetworkAccessForm active={true} />);
expect(getNetworkAccessFormInitialValues).toHaveBeenCalled();
});
it('calls getNetworkAccessFormValidation on mount', () => {
render(<NetworkAccessForm active={true} />);
expect(getNetworkAccessFormValidation).toHaveBeenCalled();
});
it('does not call checkSetting on mount', () => {
render(<NetworkAccessForm active={true} />);
expect(checkSetting).not.toHaveBeenCalled();
});
it('calls checkSetting when form is submitted', async () => {
render(<NetworkAccessForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(checkSetting).toHaveBeenCalled();
});
});
it('sets form values from network_access settings on mount', async () => {
render(<NetworkAccessForm active={true} />);
await waitFor(() => {
expect(formMock.setValues).toHaveBeenCalled();
});
});
it('handles missing network_access setting gracefully', async () => {
({ formMock } = setupMocks({ settings: {} }));
expect(() => render(<NetworkAccessForm active={true} />)).not.toThrow();
});
it('handles checkSetting error gracefully', async () => {
vi.mocked(checkSetting).mockRejectedValue(new Error('network error'));
render(<NetworkAccessForm active={true} />);
});
});
// active prop
describe('active prop', () => {
it('resets saved state when active becomes false', async () => {
vi.mocked(updateSetting).mockResolvedValue(undefined);
const { rerender } = render(<NetworkAccessForm active={true} />);
fireEvent.submit(
screen.getByText('Save').closest('form') ??
screen.getByText('Save').closest('div')
);
await waitFor(() => {
expect(screen.queryByTestId('confirmation-dialog')).not.toBeNull();
}).catch(() => {});
rerender(<NetworkAccessForm active={false} />);
await waitFor(() => {
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
});
// Form submission
describe('form submission', () => {
it('opens confirmation dialog on Save click', async () => {
render(<NetworkAccessForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
});
});
it('closes confirmation dialog on Cancel click', async () => {
render(<NetworkAccessForm active={true} />);
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(<NetworkAccessForm active={true} />);
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(<NetworkAccessForm active={true} />);
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(<NetworkAccessForm active={true} />);
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(<NetworkAccessForm active={true} />);
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(<NetworkAccessForm active={true} />);
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(<NetworkAccessForm active={true} />);
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(<NetworkAccessForm active={true} />);
// 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'
);
});
});
});
});

View file

@ -0,0 +1,363 @@
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 }) => (
<div data-testid="alert">
<span data-testid="alert-title">{title}</span>
{children}
</div>
),
Button: ({ children, onClick, disabled, loading, type, variant, color }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={disabled || loading}
data-variant={variant}
data-color={color}
data-loading={String(loading)}
>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
NumberInput: ({ label, description, min, max, step, ...rest }) => (
<div>
<label>{label}</label>
{description && <span data-testid={`desc-${label}`}>{description}</span>}
<input
data-testid={`number-input-${label}`}
type="number"
aria-label={label}
min={min}
max={max}
step={step}
onChange={(e) => rest.onChange?.(Number(e.target.value))}
value={rest.value ?? 0}
/>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
TextInput: ({ label, description, ...rest }) => (
<div>
<label>{label}</label>
{description && <span data-testid={`desc-${label}`}>{description}</span>}
<input
data-testid={`text-input-${label}`}
aria-label={label}
onChange={(e) => rest.onChange?.(e)}
value={rest.value ?? ''}
/>
</div>
),
}));
//
// 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(<ProxySettingsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('renders NumberInput for buffering_timeout', () => {
render(<ProxySettingsForm active={true} />);
expect(
screen.getByTestId('number-input-Buffering Timeout')
).toBeInTheDocument();
});
it('renders NumberInput for buffering_speed', () => {
render(<ProxySettingsForm active={true} />);
expect(
screen.getByTestId('number-input-Buffering Speed')
).toBeInTheDocument();
});
it('renders TextInput for redis_url', () => {
render(<ProxySettingsForm active={true} />);
expect(screen.getByTestId('text-input-Redis URL')).toBeInTheDocument();
});
it('renders description text for fields that have one', () => {
render(<ProxySettingsForm active={true} />);
expect(screen.getByTestId('desc-Buffering Timeout')).toBeInTheDocument();
});
});
// Initialization
describe('initialization', () => {
it('calls getProxySettingsFormInitialValues on mount', () => {
render(<ProxySettingsForm active={true} />);
expect(getProxySettingsFormInitialValues).toHaveBeenCalled();
});
it('calls setValues with merged defaults and stored settings on mount', async () => {
render(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
await waitFor(() => {
expect(formMock.setValues).not.toHaveBeenCalled();
});
});
it('handles null settings gracefully', () => {
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
expect(() => render(<ProxySettingsForm active={true} />)).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(<ProxySettingsForm active={true} />);
// Trigger a successful save
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
rerender(<ProxySettingsForm active={false} />);
await waitFor(() => {
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
});
// Form submission
describe('form submission', () => {
it('calls updateSetting with proxy_settings on Save', async () => {
render(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
// 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(<ProxySettingsForm active={true} />);
fireEvent.click(screen.getByText('Reset to Defaults'));
expect(getProxySettingDefaults).toHaveBeenCalled();
});
it('calls form.setValues with defaults when Reset to Defaults is clicked', () => {
render(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout');
expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed');
expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url');
});
});
});

View file

@ -0,0 +1,804 @@
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 ? (
<div data-testid="confirmation-dialog">
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-message">{message}</div>
<button data-testid="confirm-ok" onClick={onConfirm}>
{confirmLabel}
</button>
<button data-testid="confirm-cancel" onClick={onClose}>
{cancelLabel}
</button>
{actionKey && (
<button
data-testid="confirm-suppress"
onClick={() => onSuppressChange?.(actionKey)}
>
Don't show again
</button>
)}
</div>
) : null,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Alert: ({ title, children }) => (
<div data-testid="alert">
<span data-testid="alert-title">{title}</span>
{children}
</div>
),
Button: ({ children, onClick, disabled, loading, type, variant, color }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={disabled || loading}
data-variant={variant}
data-color={color}
data-loading={String(loading)}
>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
MultiSelect: ({ label, id, data, ...rest }) => (
<div>
<label htmlFor={id}>{label}</label>
<select
data-testid={id}
id={id}
aria-label={label}
multiple
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions).map(
(o) => o.value
);
rest.onChange?.(selected);
}}
value={rest.value ?? []}
>
{data?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Select: ({ label, id, data, ...rest }) => (
<div>
<label htmlFor={id}>{label}</label>
<select
data-testid={id}
id={id}
aria-label={label}
onChange={(e) => rest.onChange?.(e.target.value)}
value={rest.value ?? ''}
>
{data?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Switch: ({ id, ...rest }) => (
<input
data-testid={id}
id={id}
type="checkbox"
checked={rest.checked ?? false}
onChange={(e) => rest.onChange?.(e)}
/>
),
Text: ({ children, size, fw }) => (
<span data-size={size} data-fw={fw}>
{children}
</span>
),
}));
//
// 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(<StreamSettingsForm active={true} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('renders the Save button', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('renders the Rehash Streams button', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByText('Rehash Streams')).toBeInTheDocument();
});
it('renders the Default User Agent select', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByTestId('default_user_agent')).toBeInTheDocument();
});
it('renders the Default Stream Profile select', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByTestId('default_stream_profile')).toBeInTheDocument();
});
it('renders the Preferred Region select', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByTestId('preferred_region')).toBeInTheDocument();
});
it('renders the Auto-Import Mapped Files switch', () => {
render(<StreamSettingsForm active={true} />);
expect(
screen.getByTestId('auto_import_mapped_files')
).toBeInTheDocument();
});
it('renders the M3U Hash Key multiselect', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByTestId('m3u_hash_key')).toBeInTheDocument();
});
it('populates user agent options from store', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByText('Chrome')).toBeInTheDocument();
expect(screen.getByText('Firefox')).toBeInTheDocument();
});
it('populates stream profile options from store', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByText('Default')).toBeInTheDocument();
expect(screen.getByText('HLS')).toBeInTheDocument();
});
it('populates region options from REGION_CHOICES', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.getByText('US')).toBeInTheDocument();
expect(screen.getByText('EU')).toBeInTheDocument();
});
it('does not show success alert on initial render', () => {
render(<StreamSettingsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not show confirmation dialog on initial render', () => {
render(<StreamSettingsForm active={true} />);
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
});
// Initialization
describe('initialization', () => {
it('calls getStreamSettingsFormInitialValues on mount', () => {
render(<StreamSettingsForm active={true} />);
expect(getStreamSettingsFormInitialValues).toHaveBeenCalled();
});
it('calls getStreamSettingsFormValidation on mount', () => {
render(<StreamSettingsForm active={true} />);
expect(getStreamSettingsFormValidation).toHaveBeenCalled();
});
it('calls parseSettings with settings from store on mount', async () => {
const settings = makeSettings();
setupMocks({ settings });
render(<StreamSettingsForm active={true} />);
await waitFor(() => {
expect(parseSettings).toHaveBeenCalledWith(settings);
});
});
it('calls form.setValues with parsed settings on mount', async () => {
render(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
rerender(<StreamSettingsForm active={false} />);
await waitFor(() => {
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
it('clears rehashSuccess when active becomes false', async () => {
({ formMock } = setupMocks({ warningSuppressed: true }));
const { rerender } = render(<StreamSettingsForm active={true} />);
fireEvent.click(screen.getByText('Rehash Streams'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
rerender(<StreamSettingsForm active={false} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
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(<StreamSettingsForm active={true} />);
expect(formMock.getInputProps).toHaveBeenCalledWith('default_user_agent');
});
it('calls getInputProps for default_stream_profile', () => {
render(<StreamSettingsForm active={true} />);
expect(formMock.getInputProps).toHaveBeenCalledWith(
'default_stream_profile'
);
});
it('calls getInputProps for preferred_region', () => {
render(<StreamSettingsForm active={true} />);
expect(formMock.getInputProps).toHaveBeenCalledWith('preferred_region');
});
it('calls getInputProps for auto_import_mapped_files with checkbox type', () => {
render(<StreamSettingsForm active={true} />);
expect(formMock.getInputProps).toHaveBeenCalledWith(
'auto_import_mapped_files',
{ type: 'checkbox' }
);
});
it('calls getInputProps for m3u_hash_key', () => {
render(<StreamSettingsForm active={true} />);
expect(formMock.getInputProps).toHaveBeenCalledWith('m3u_hash_key');
});
});
});

View file

@ -0,0 +1,342 @@
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 }) => <div data-testid="alert">{title}</div>,
Button: ({ children, onClick, disabled }) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
NumberInput: ({ label, value, onChange, min, max, step, description }) => (
<div>
<label>{label}</label>
<p>{description}</p>
<input
data-testid="number-input"
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
}));
//
// 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(<SystemSettingsForm active={true} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('renders the NumberInput for max_system_events', () => {
setupMocks();
render(<SystemSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('renders the NumberInput label', () => {
setupMocks();
render(<SystemSettingsForm active={true} />);
expect(screen.getByText('Maximum System Events')).toBeInTheDocument();
});
it('renders the NumberInput description', () => {
setupMocks();
render(<SystemSettingsForm active={true} />);
expect(
screen.getByText(
'Number of events to retain (minimum: 10, maximum: 1000)'
)
).toBeInTheDocument();
});
it('renders descriptive text about system events', () => {
setupMocks();
render(<SystemSettingsForm active={true} />);
expect(
screen.getByText(/Configure how many system events/)
).toBeInTheDocument();
});
it('does not show success alert on initial render', () => {
setupMocks();
render(<SystemSettingsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('renders NumberInput with value from form values', () => {
setupMocks({ settings: makeSettings({ max_system_events: 250 }) });
render(<SystemSettingsForm active={true} />);
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(<SystemSettingsForm active={true} />);
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(<SystemSettingsForm active={true} />);
expect(parseSettings).toHaveBeenCalledWith(settings);
});
it('calls form.setValues with parsed settings on mount', () => {
const settings = makeSettings();
const { formMock } = setupMocks({ settings });
render(<SystemSettingsForm active={true} />);
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(<SystemSettingsForm active={true} />);
expect(parseSettings).not.toHaveBeenCalled();
});
});
// NumberInput interaction
describe('NumberInput interaction', () => {
it('calls form.setFieldValue when NumberInput changes', () => {
const { formMock } = setupMocks();
render(<SystemSettingsForm active={true} />);
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(<SystemSettingsForm active={true} />);
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(<SystemSettingsForm active={true} />);
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(<SystemSettingsForm active={true} />);
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(<SystemSettingsForm active={true} />);
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(<SystemSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
rerender(<SystemSettingsForm active={false} />);
await waitFor(() => {
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
it('does not clear saved alert while active remains true', async () => {
setupMocks();
const { rerender } = render(<SystemSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
rerender(<SystemSettingsForm active={true} />);
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,458 @@
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 }) => (
<div>
<label>{label}</label>
<select
data-testid={`select-${label?.toLowerCase().replace(/\s+/g, '-')}`}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
>
{data?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Switch: ({ label, description, checked, onChange }) => (
<div>
<label>{label}</label>
{description && <p>{description}</p>}
<input
data-testid="switch-header-pinned"
type="checkbox"
checked={checked}
onChange={onChange}
/>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
}));
//
// 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(<UiSettingsForm />);
expect(screen.getByTestId('select-table-size')).toBeInTheDocument();
});
it('renders the Time format select', () => {
setupMocks();
render(<UiSettingsForm />);
expect(screen.getByTestId('select-time-format')).toBeInTheDocument();
});
it('renders the Date format select', () => {
setupMocks();
render(<UiSettingsForm />);
expect(screen.getByTestId('select-date-format')).toBeInTheDocument();
});
it('renders the Time zone select', () => {
setupMocks();
render(<UiSettingsForm />);
expect(screen.getByTestId('select-time-zone')).toBeInTheDocument();
});
it('renders the Pin Table Headers switch', () => {
setupMocks();
render(<UiSettingsForm />);
expect(screen.getByTestId('switch-header-pinned')).toBeInTheDocument();
});
it('renders the switch description', () => {
setupMocks();
render(<UiSettingsForm />);
expect(
screen.getByText('Keep table headers visible when scrolling')
).toBeInTheDocument();
});
it('renders Table Size select with initial value', () => {
setupMocks({ tableSize: 'compact' });
render(<UiSettingsForm />);
expect(screen.getByTestId('select-table-size')).toHaveValue('compact');
});
it('renders Time format select with initial value', () => {
setupMocks({ timeFormat: '24h' });
render(<UiSettingsForm />);
expect(screen.getByTestId('select-time-format')).toHaveValue('24h');
});
it('renders Date format select with initial value', () => {
setupMocks({ dateFormat: 'dmy' });
render(<UiSettingsForm />);
expect(screen.getByTestId('select-date-format')).toHaveValue('dmy');
});
it('renders Time zone select with initial value', () => {
setupMocks({ timeZone: 'UTC' });
render(<UiSettingsForm />);
expect(screen.getByTestId('select-time-zone')).toHaveValue('UTC');
});
it('renders switch unchecked when headerPinned is false', () => {
setupMocks({ headerPinned: false });
render(<UiSettingsForm />);
expect(screen.getByTestId('switch-header-pinned')).not.toBeChecked();
});
it('renders switch checked when headerPinned is true', () => {
setupMocks({ headerPinned: true });
render(<UiSettingsForm />);
expect(screen.getByTestId('switch-header-pinned')).toBeChecked();
});
});
// onChange handlers
describe('onChange handlers', () => {
it('calls setTableSize when Table Size select changes', () => {
const { setTableSize } = setupMocks();
render(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
// 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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
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(<UiSettingsForm />);
expect(buildTimeZoneOptions).toHaveBeenCalledWith('America/Chicago');
});
});
});