diff --git a/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx b/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx new file mode 100644 index 00000000..e47b15fd --- /dev/null +++ b/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx @@ -0,0 +1,285 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Dependency mocks ─────────────────────────────────────────────────────────── +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + deleteRecordingById: vi.fn(), + deleteSeriesAndRule: vi.fn(), +})); + +vi.mock('../../../utils/guideUtils.js', () => ({ + deleteSeriesRuleByTvgId: vi.fn(), +})); + +vi.mock('../SeriesRuleEditorModal.jsx', () => ({ + default: ({ opened, onClose }) => + opened ? ( +
+ +
+ ) : null, +})); + +vi.mock('@mantine/core', async () => ({ + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Flex: ({ children }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, color, variant }) => ( + + ), + Anchor: ({ children, onClick }) => ( + {children} + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import ProgramRecordingModal from '../ProgramRecordingModal.jsx'; +import { deleteRecordingById, deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js'; +import { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeProgram = (overrides = {}) => ({ + tvg_id: 'tvg-1', + title: 'Test Show', + ...overrides, +}); + +const makeRecording = (overrides = {}) => ({ + id: 'rec-1', + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + opened: true, + onClose: vi.fn(), + program: makeProgram(), + recording: null, + existingRuleMode: null, + existingRule: null, + onRecordOne: vi.fn(), + onRecordSeriesAll: vi.fn(), + onRecordSeriesNew: vi.fn(), + onExistingRuleModeChange: vi.fn(), + ...overrides, +}); + +// ─── ProgramRecordingModal ───────────────────────────────────────────────────── + +describe('ProgramRecordingModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(deleteRecordingById).mockResolvedValue(undefined); + vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined); + vi.mocked(deleteSeriesRuleByTvgId).mockResolvedValue(undefined); + }); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders the modal when opened is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render the modal when opened is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('calls onClose when the modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── handleRemoveRecording ────────────────────────────────────────────────── + + describe('handleRemoveRecording', () => { + it('calls deleteRecordingById with the recording id', async () => { + const recording = makeRecording(); + render( + + ); + fireEvent.click(screen.getByText('Remove this recording')); + await waitFor(() => { + expect(deleteRecordingById).toHaveBeenCalledWith('rec-1'); + }); + }); + + it('calls onClose after deleting recording', async () => { + const onClose = vi.fn(); + const recording = makeRecording(); + render( + + ); + fireEvent.click(screen.getByText('Remove this recording')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('still calls onClose when deleteRecordingById throws', async () => { + vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail')); + const onClose = vi.fn(); + const recording = makeRecording(); + render( + + ); + fireEvent.click(screen.getByText('Remove this recording')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── handleRemoveSeries ───────────────────────────────────────────────────── + + describe('handleRemoveSeries', () => { + it('calls deleteSeriesAndRule with tvg_id and title', async () => { + const program = makeProgram({ tvg_id: 'tvg-2', title: 'My Series' }); + const recording = makeRecording(); + render( + + ); + fireEvent.click(screen.getByText(/Remove this series/i)); + await waitFor(() => { + expect(deleteSeriesAndRule).toHaveBeenCalledWith({ + tvg_id: 'tvg-2', + title: 'My Series', + }); + }); + }); + + it('calls onClose after removing series', async () => { + const onClose = vi.fn(); + const recording = makeRecording(); + render( + + ); + fireEvent.click(screen.getByText(/Remove this series/i)); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── handleRemoveSeriesRule ───────────────────────────────────────────────── + + describe('handleRemoveSeriesRule', () => { + it('calls deleteSeriesRuleByTvgId with tvg_id and title', async () => { + const program = makeProgram({ tvg_id: 'tvg-3', title: 'Rule Show' }); + render( + + ); + fireEvent.click(screen.getByText(/Remove series rule/i)); + await waitFor(() => { + expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith('tvg-3', 'Rule Show'); + }); + }); + + it('calls onExistingRuleModeChange(null) after removing rule', async () => { + const onExistingRuleModeChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText(/Remove series rule/i)); + await waitFor(() => { + expect(onExistingRuleModeChange).toHaveBeenCalledWith(null); + }); + }); + + it('calls onClose after removing rule', async () => { + const onClose = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText(/Remove series rule/i)); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Record actions ───────────────────────────────────────────────────────── + + describe('record actions', () => { + it('calls onRecordOne when "Record Once" is clicked', () => { + const onRecordOne = vi.fn(); + render(); + fireEvent.click(screen.getByText('Just this one')); + expect(onRecordOne).toHaveBeenCalled(); + }); + + it('calls onRecordSeriesAll when "Record All" is clicked', () => { + const onRecordSeriesAll = vi.fn(); + render(); + fireEvent.click(screen.getByText('Every episode')); + expect(onRecordSeriesAll).toHaveBeenCalled(); + }); + + it('calls onRecordSeriesNew when "Record New" is clicked', () => { + const onRecordSeriesNew = vi.fn(); + render(); + fireEvent.click(screen.getByText('New episodes only')); + expect(onRecordSeriesNew).toHaveBeenCalled(); + }); + }); + + // ── SeriesRuleEditorModal ────────────────────────────────────────────────── + + describe('SeriesRuleEditorModal', () => { + it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => { + render( + + ); + fireEvent.click(screen.getByText(/Customize rule/i)); + expect(screen.getByTestId('series-rule-editor-modal')).toBeInTheDocument(); + }); + + it('closes SeriesRuleEditorModal when its onClose is called', () => { + render( + + ); + fireEvent.click(screen.getByText(/Customize rule/i)); + fireEvent.click(screen.getByTestId('series-rule-editor-close')); + expect(screen.queryByTestId('series-rule-editor-modal')).not.toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/Recording.test.jsx b/frontend/src/components/forms/__tests__/Recording.test.jsx new file mode 100644 index 00000000..9c3771bc --- /dev/null +++ b/frontend/src/components/forms/__tests__/Recording.test.jsx @@ -0,0 +1,507 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RecordingModal from '../Recording'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + RECURRING_DAY_OPTIONS: [ + { value: 'mon', label: 'Monday' }, + { value: 'tue', label: 'Tuesday' }, + ], + toTimeString: vi.fn((val) => val), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/RecordingUtils.js', () => ({ + buildRecurringPayload: vi.fn((v) => v), + buildSinglePayload: vi.fn((v) => v), + createRecording: vi.fn(), + createRecurringRule: vi.fn(), + sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: '501 - HBO' }]), + numberedChannelLabel: vi.fn((item) => + item.channel_number ? `${item.channel_number} - ${item.name}` : item.name + ), + getChannelsSummary: vi.fn(), + getRecurringFormDefaults: vi.fn(() => ({ + channel_id: '', + rule_name: '', + days_of_week: [], + start_date: new Date('2024-01-01'), + end_date: null, + start_time: '08:00', + end_time: '09:00', + })), + getSingleFormDefaults: vi.fn(() => ({ + channel_id: 'ch-1', + start_time: new Date('2024-06-01T10:00:00'), + end_time: new Date('2024-06-01T11:00:00'), + })), + recurringFormValidators: {}, + singleFormValidators: {}, + timeChange: vi.fn((fn) => (e) => fn(e.target.value)), + updateRecording: vi.fn(), +})); + +// ── @mantine/form ────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => ({ + useForm: vi.fn(({ initialValues }) => { + const values = { ...initialValues }; + return { + values, + key: vi.fn((k) => k), + getInputProps: vi.fn((k) => ({ name: k, value: values[k] ?? '', onChange: vi.fn() })), + onSubmit: vi.fn((handler) => (e) => { e?.preventDefault?.(); return handler(values); }), + reset: vi.fn(), + setValues: vi.fn((newVals) => Object.assign(values, newVals)), + setFieldValue: vi.fn((k, v) => { values[k] = v; }), + validateField: vi.fn(), + }; + }), +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ children, title }) => ( +
+ {title} + {children} +
+ ), + Button: ({ children, onClick, loading, type }) => ( + + ), + Group: ({ children }) =>
{children}
, + Loader: ({ size, color }) => , + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + MultiSelect: ({ label, placeholder, ...props }) => ( +
+ + +
+ ), + SegmentedControl: ({ value, onChange, data, disabled }) => ( +
+ {data.map((item) => ( + + ))} +
+ ), + Select: ({ label, disabled, rightSection, data, ...props }) => ( +
+ + {rightSection} + +
+ ), + Stack: ({ children }) =>
{children}
, + TextInput: ({ label, placeholder, ...props }) => ( +
+ + +
+ ), +})); + +// ── @mantine/dates ───────────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DatePickerInput: ({ label, value, onChange }) => ( +
+ + onChange(e.target.value ? new Date(e.target.value) : null)} + /> +
+ ), + DateTimePicker: ({ label, ...props }) => ( +
+ + +
+ ), + TimeInput: ({ label, value, onChange, onBlur }) => ( +
+ + +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + CircleAlert: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js'; + +const setupStoreMock = () => { + const mockFetchRecordings = vi.fn().mockResolvedValue(undefined); + const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules }) + ); + + return { mockFetchRecordings, mockFetchRecurringRules }; +}; + +const makeRecording = (overrides = {}) => ({ + id: 'rec-1', + start_time: '2024-06-01T10:00:00Z', + end_time: '2024-06-01T11:00:00Z', + custom_properties: { program: { title: 'Test Show' } }, + ...overrides, +}); + +const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 }); + +describe('RecordingModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([ + { id: 'ch-1', name: 'HBO', channel_number: 501 }, + ]); + vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined); + setupStoreMock(); + }); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Alert ────────────────────────────────────────────────────────────────── + + describe('scheduling conflict alert', () => { + it('renders the scheduling conflicts alert', () => { + render(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent('Scheduling Conflicts'); + }); + }); + + // ── Mode switching ───────────────────────────────────────────────────────── + + describe('mode switching', () => { + it('defaults to "single" mode', () => { + render(); + expect(screen.getByTestId('mode-single')).toHaveAttribute('data-active', 'true'); + }); + + it('switches to recurring mode when Recurring button clicked', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('mode-recurring')).toHaveAttribute('data-active', 'true'); + }); + + it('shows DateTimePicker fields in single mode', () => { + render(); + expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument(); + expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument(); + }); + + it('shows recurring fields when in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument(); + }); + + it('disables mode toggle when editing an existing recording', () => { + render(); + expect(screen.getByTestId('mode-single')).toBeDisabled(); + expect(screen.getByTestId('mode-recurring')).toBeDisabled(); + }); + + it('shows "Schedule Recording" submit button in single mode', () => { + render(); + expect(screen.getByText('Schedule Recording')).toBeInTheDocument(); + }); + + it('shows "Save Rule" submit button in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByText('Save Rule')).toBeInTheDocument(); + }); + }); + + // ── Channel loading ──────────────────────────────────────────────────────── + + describe('channel loading', () => { + it('calls getChannelsSummary when modal opens', async () => { + render(); + await waitFor(() => { + expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled(); + }); + }); + + it('calls sortedChannelOptions with loaded channels', async () => { + const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }]; + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + channels, + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => { + vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail')); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + [], + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('does not load channels when modal is closed', () => { + render(); + expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled(); + }); + }); + + // ── Single form submit (create) ──────────────────────────────────────────── + + describe('single mode – create recording', () => { + it('calls buildSinglePayload with form values on submit', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecording when no existing recording', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).toHaveBeenCalled(); + }); + }); + + it('shows "Recording scheduled" notification after successful create', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording scheduled', color: 'green' }) + ); + }); + }); + + it('calls fetchRecordings after successful create', async () => { + const { mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('calls onClose after successful create', async () => { + const onClose = vi.fn(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not call showNotification when createRecording throws', async () => { + vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail')); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Single form submit (update) ──────────────────────────────────────────── + + describe('single mode – update recording', () => { + it('calls updateRecording when editing an existing recording', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.updateRecording).toHaveBeenCalledWith( + 'rec-1', + expect.anything() + ); + }); + }); + + it('does not call createRecording when updating', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).not.toHaveBeenCalled(); + }); + }); + + it('shows "Recording updated" notification after successful update', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording updated', color: 'green' }) + ); + }); + }); + }); + + // ── Recurring form submit ────────────────────────────────────────────────── + + describe('recurring mode – create rule', () => { + it('calls buildRecurringPayload with form values on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecurringRule on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecurringRule).toHaveBeenCalled(); + }); + }); + + it('calls fetchRecurringRules and fetchRecordings on success', async () => { + const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(mockFetchRecurringRules).toHaveBeenCalled(); + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('shows "Recurring rule saved" notification on success', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recurring rule saved', color: 'green' }) + ); + }); + }); + + it('calls onClose after successful recurring submit', async () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not show notification when createRecurringRule throws', async () => { + vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail')); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Form initialization ──────────────────────────────────────────────────── + + describe('form initialization', () => { + it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => { + const recording = makeRecording(); + const channel = makeChannel(); + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(recording, channel); + }); + + it('calls getSingleFormDefaults with null when opening for new recording', () => { + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(null, null); + }); + + it('calls getRecurringFormDefaults with channel on open', () => { + const channel = makeChannel(); + render(); + expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel); + }); + }); + + // ── Close / reset ────────────────────────────────────────────────────────── + + describe('close and reset', () => { + it('calls onClose when modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx new file mode 100644 index 00000000..91216ac1 --- /dev/null +++ b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx @@ -0,0 +1,770 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore.jsx', () => ({ + default: Object.assign(vi.fn(), { + getState: vi.fn(() => ({ showVideo: vi.fn() })), + }), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn(), + isAfter: vi.fn(), + isBefore: vi.fn(), + useDateTimeFormat: vi.fn(), + useTimeHelpers: vi.fn(), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + deleteRecordingById: vi.fn(), + getChannelLogoUrl: vi.fn(), + getPosterUrl: vi.fn(), + getRecordingUrl: vi.fn(), + getSeasonLabel: vi.fn(), + getShowVideoUrl: vi.fn(), + runComSkip: vi.fn(), +})); + +vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({ + getChannel: vi.fn(), + getRating: vi.fn(), + getStatRows: vi.fn(), + getUpcomingEpisodes: vi.fn(), + refreshArtwork: vi.fn(), + updateRecordingMetadata: vi.fn(), +})); + +vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Check: () => , + Pencil: () => , + RefreshCcw: () => , + X: () => , +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Badge: ({ children, color }) => ( + {children} + ), + Button: ({ children, onClick, disabled, loading, size, variant, color }) => ( + + ), + Card: ({ children, onClick, style }) => ( +
{children}
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c, fw, style }) => ( + {children} + ), + Textarea: ({ label, value, onChange, placeholder, ...props }) => ( +
+ +