Syntax formatting

This commit is contained in:
Nick Sandstrom 2026-04-08 23:28:37 -07:00
parent 067a0ce0a9
commit 48734727ad
27 changed files with 1546 additions and 596 deletions

View file

@ -16,7 +16,15 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, CheckCircle, Clock, Info, RefreshCw, Users, XCircle, } from 'lucide-react';
import {
AlertTriangle,
CheckCircle,
Clock,
Info,
RefreshCw,
Users,
XCircle,
} from 'lucide-react';
import usePlaylistsStore from '../../store/playlists';
import { showNotification } from '../../utils/notificationUtils.js';
import {

View file

@ -1,27 +1,17 @@
import React from 'react';
import API from '../../api';
import {
Button,
Modal,
Text,
Group,
Flex,
NumberInput,
} from '@mantine/core';
import { Button, Modal, Text, Group, Flex, NumberInput } from '@mantine/core';
import { ListOrdered } from 'lucide-react';
import { useForm } from '@mantine/form';
import { showNotification } from '../../utils/notificationUtils.js';
const assignChannelNumbers = (channelIds, starting_number) => {
return API.assignChannelNumbers(
channelIds,
starting_number
);
}
return API.assignChannelNumbers(channelIds, starting_number);
};
const requeryChannels = () => {
API.requeryChannels();
}
};
const AssignChannelNumbers = ({ channelIds, isOpen, onClose }) => {
const form = useForm({

View file

@ -37,7 +37,10 @@ import { ListOrdered, SquarePlus, X, Zap } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
import { FixedSizeList as List } from 'react-window';
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
import { showNotification, updateNotification, } from '../../utils/notificationUtils.js';
import {
showNotification,
updateNotification,
} from '../../utils/notificationUtils.js';
import {
addChannel,
createLogo,

View file

@ -11,10 +11,10 @@ const updateChannelGroup = (channelGroup, values) => {
id: channelGroup.id,
...values,
});
}
};
const addChannelGroup = (values) => {
return API.addChannelGroup(values);
}
};
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);

View file

@ -182,7 +182,7 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
const next = [...headers];
next[idx] = { ...next[idx], key: newValue };
setHeaders(next);
}
};
const onHeaderValueChange = (idx, newValue) => {
const next = [...headers];
@ -191,14 +191,12 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
value: newValue,
};
setHeaders(next);
}
};
const onHeaderRemove = (idx) => {
const next = headers.filter((_, i) => i !== idx);
setHeaders(
next.length ? next : [{ key: '', value: '' }]
);
}
setHeaders(next.length ? next : [{ key: '', value: '' }]);
};
return (
<Modal opened={isOpen} size="lg" onClose={handleClose} title="Connection">
@ -257,8 +255,12 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
<HeaderRow
key={idx}
h={h}
onKeyChange={(e) => onHeaderKeyChange(idx, e.target.value)}
onValueChange={(e) => onHeaderValueChange(idx, e.target.value)}
onKeyChange={(e) =>
onHeaderKeyChange(idx, e.target.value)
}
onValueChange={(e) =>
onHeaderValueChange(idx, e.target.value)
}
onRemove={() => onHeaderRemove(idx)}
/>
))}

View file

@ -42,7 +42,9 @@ const CronPartInput = ({ field, cron, onChange }) => (
label={field.label}
placeholder={field.placeholder}
value={cron.split(' ')[field.index] || '*'}
onChange={(e) => onChange(updateCronPart(cron, field.index, e.currentTarget.value))}
onChange={(e) =>
onChange(updateCronPart(cron, field.index, e.currentTarget.value))
}
/>
);

View file

@ -114,11 +114,17 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
});
const patternValidation = useMemo(() => {
const title = matchPattern(titlePattern, sampleTitle, 'Title pattern error');
const time = matchPattern(timePattern, sampleTitle, 'Time pattern error');
const date = matchPattern(datePattern, sampleTitle, 'Date pattern error');
const title = matchPattern(
titlePattern,
sampleTitle,
'Title pattern error'
);
const time = matchPattern(timePattern, sampleTitle, 'Time pattern error');
const date = matchPattern(datePattern, sampleTitle, 'Date pattern error');
const errors = [title.error, time.error, date.error].filter(Boolean).join('; ');
const errors = [title.error, time.error, date.error]
.filter(Boolean)
.join('; ');
const timePlaceholders = buildTimePlaceholders(
time.groups,
@ -138,40 +144,48 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
const hasMatch = title.matched || time.matched;
return {
titleMatch: title.matched,
timeMatch: time.matched,
dateMatch: date.matched,
titleMatch: title.matched,
timeMatch: time.matched,
dateMatch: date.matched,
titleGroups: title.groups,
timeGroups: time.groups,
dateGroups: date.groups,
timeGroups: time.groups,
dateGroups: date.groups,
calculatedPlaceholders: time.matched ? timePlaceholders : {},
error: errors || null,
...applyTemplates(
{ titleTemplate, subtitleTemplate, descriptionTemplate, upcomingTitleTemplate,
upcomingDescriptionTemplate, endedTitleTemplate, endedDescriptionTemplate,
channelLogoUrl, programPosterUrl },
{
titleTemplate,
subtitleTemplate,
descriptionTemplate,
upcomingTitleTemplate,
upcomingDescriptionTemplate,
endedTitleTemplate,
endedDescriptionTemplate,
channelLogoUrl,
programPosterUrl,
},
allGroups,
hasMatch
),
};
}, [
titlePattern,
timePattern,
datePattern,
sampleTitle,
titleTemplate,
subtitleTemplate,
descriptionTemplate,
upcomingTitleTemplate,
upcomingDescriptionTemplate,
endedTitleTemplate,
endedDescriptionTemplate,
channelLogoUrl,
programPosterUrl,
form.values.custom_properties?.timezone,
form.values.custom_properties?.output_timezone,
form.values.custom_properties?.program_duration,
]);
titlePattern,
timePattern,
datePattern,
sampleTitle,
titleTemplate,
subtitleTemplate,
descriptionTemplate,
upcomingTitleTemplate,
upcomingDescriptionTemplate,
endedTitleTemplate,
endedDescriptionTemplate,
channelLogoUrl,
programPosterUrl,
form.values.custom_properties?.timezone,
form.values.custom_properties?.output_timezone,
form.values.custom_properties?.program_duration,
]);
useEffect(() => {
if (epg) {

View file

@ -59,9 +59,7 @@ vi.mock('@mantine/core', () => ({
TableTd: ({ children }) => <td>{children}</td>,
TableTr: ({ children }) => <tr>{children}</tr>,
Text: ({ children }) => <span>{children}</span>,
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
}));
// lucide-react
@ -109,13 +107,11 @@ const makeProfile = (overrides = {}) => ({
});
const setupMocks = ({
profiles = {},
timeRemaining = '10 days 5 hours',
formattedTimestamp = 'Nov 14, 2023',
} = {}) => {
vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
sel({ profiles })
);
profiles = {},
timeRemaining = '10 days 5 hours',
formattedTimestamp = 'Nov 14, 2023',
} = {}) => {
vi.mocked(usePlaylistsStore).mockImplementation((sel) => sel({ profiles }));
vi.mocked(getTimeRemaining).mockReturnValue(timeRemaining);
vi.mocked(formatTimestamp).mockReturnValue(formattedTimestamp);
vi.mocked(refreshAccountInfo).mockResolvedValue({ success: true });
@ -397,7 +393,10 @@ describe('AccountInfoModal', () => {
it('disables the refresh button while refreshing', async () => {
setupMocks();
vi.mocked(refreshAccountInfo).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 200))
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ success: true }), 200)
)
);
render(<AccountInfoModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('action-icon'));
@ -409,7 +408,9 @@ describe('AccountInfoModal', () => {
it('re-enables the refresh button after refresh fails', async () => {
setupMocks();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
vi.mocked(refreshAccountInfo).mockRejectedValue(new Error('fail'));
render(<AccountInfoModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('action-icon'));
@ -443,4 +444,4 @@ describe('AccountInfoModal', () => {
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
});
});
});

View file

@ -26,7 +26,15 @@ vi.mock('@mantine/form', () => ({
// Mantine core
vi.mock('@mantine/core', () => ({
Button: ({ children, onClick, disabled, loading, variant, color, leftSection }) => (
Button: ({
children,
onClick,
disabled,
loading,
variant,
color,
leftSection,
}) => (
<button
data-testid="button"
onClick={onClick}
@ -252,7 +260,9 @@ describe('AssignChannelNumbers', () => {
it('shows error notification when assignChannelNumbers throws', async () => {
setupMocks();
vi.mocked(API.assignChannelNumbers).mockRejectedValue(new Error('Server error'));
vi.mocked(API.assignChannelNumbers).mockRejectedValue(
new Error('Server error')
);
render(<AssignChannelNumbers {...defaultProps()} />);
fireEvent.click(screen.getByTestId('button'));
await waitFor(() => {
@ -314,7 +324,9 @@ describe('AssignChannelNumbers', () => {
describe('channelIds prop', () => {
it('passes a single channelId correctly', async () => {
setupMocks();
render(<AssignChannelNumbers {...defaultProps({ channelIds: ['ch-99'] })} />);
render(
<AssignChannelNumbers {...defaultProps({ channelIds: ['ch-99'] })} />
);
fireEvent.click(screen.getByTestId('button'));
await waitFor(() => {
expect(API.assignChannelNumbers).toHaveBeenCalledWith(['ch-99'], 1);
@ -331,4 +343,4 @@ describe('AssignChannelNumbers', () => {
});
});
});
});
});

View file

@ -65,7 +65,12 @@ vi.mock('../Logo', () => ({
vi.mock('../../LazyLogo', () => ({
default: ({ logoId, alt, style }) => (
<img data-testid="lazy-logo" data-logo-id={logoId} alt={alt} style={style} />
<img
data-testid="lazy-logo"
data-logo-id={logoId}
alt={alt}
style={style}
/>
),
}));
@ -119,7 +124,17 @@ vi.mock('@mantine/core', async () => ({
</button>
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, disabled, loading, type, variant, color, leftSection, title }) => (
Button: ({
children,
onClick,
disabled,
loading,
type,
variant,
color,
leftSection,
title,
}) => (
<button
onClick={onClick}
disabled={disabled || loading}
@ -138,7 +153,14 @@ vi.mock('@mantine/core', async () => ({
<hr data-orientation={orientation} data-size={size} />
),
Flex: ({ children, gap, justify, align, mih }) => (
<div style={{ gap, justifyContent: justify, alignItems: align, minHeight: mih }}>
<div
style={{
gap,
justifyContent: justify,
alignItems: align,
minHeight: mih,
}}
>
{children}
</div>
),
@ -165,7 +187,9 @@ vi.mock('@mantine/core', async () => ({
name={name}
type="number"
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
onChange={(e) =>
onChange(e.target.value === '' ? undefined : Number(e.target.value))
}
data-testid={`number-input-${name}`}
/>
{error && <span data-testid={`error-${name}`}>{error}</span>}
@ -181,7 +205,9 @@ vi.mock('@mantine/core', async () => ({
{children}
</div>
),
PopoverTarget: ({ children }) => <div data-testid="popover-target">{children}</div>,
PopoverTarget: ({ children }) => (
<div data-testid="popover-target">{children}</div>
),
ScrollArea: ({ children, style }) => <div style={style}>{children}</div>,
Select: ({ label, value, onChange, data, id, name, error }) => (
<div>
@ -222,7 +248,20 @@ vi.mock('@mantine/core', async () => ({
{children}
</span>
),
TextInput: ({ id, name, label, readOnly, value, onClick, onChange, error, autoFocus, placeholder, rightSection, ...rest }) => (
TextInput: ({
id,
name,
label,
readOnly,
value,
onClick,
onChange,
error,
autoFocus,
placeholder,
rightSection,
...rest
}) => (
<div>
<label htmlFor={id}>{label}</label>
<input
@ -242,9 +281,7 @@ vi.mock('@mantine/core', async () => ({
{error && <span data-testid={`error-${name}`}>{error}</span>}
</div>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
UnstyledButton: ({ children, onClick }) => (
<button data-testid="unstyled-button" onClick={onClick}>
{children}
@ -264,7 +301,10 @@ import useLogosStore from '../../../store/logos';
import { useChannelLogoSelection } from '../../../hooks/useSmartLogos';
import { useForm } from 'react-hook-form';
import * as ChannelUtils from '../../../utils/forms/ChannelUtils.js';
import { showNotification, updateNotification } from '../../../utils/notificationUtils.js';
import {
showNotification,
updateNotification,
} from '../../../utils/notificationUtils.js';
// Helpers
@ -284,7 +324,10 @@ const makeFormMethods = (overrides = {}) => {
};
const register = vi.fn((name) => ({ name, ref: vi.fn() }));
const handleSubmit = vi.fn((fn) => (e) => { e?.preventDefault?.(); return fn(watchValues); });
const handleSubmit = vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn(watchValues);
});
const setValue = vi.fn();
const watch = vi.fn((key) => (key ? watchValues[key] : watchValues));
const reset = vi.fn();
@ -326,18 +369,52 @@ const makeEpgs = () => ({
});
const makeTvgs = () => [
{ id: 'tvg-1', name: 'ESPN', tvg_id: 'espn.us', epg_source: 10, icon_url: 'http://example.com/espn.png' },
{ id: 'tvg-2', name: 'CNN', tvg_id: 'cnn.us', epg_source: 11, icon_url: 'http://example.com/cnn.png' },
{
id: 'tvg-1',
name: 'ESPN',
tvg_id: 'espn.us',
epg_source: 10,
icon_url: 'http://example.com/espn.png',
},
{
id: 'tvg-2',
name: 'CNN',
tvg_id: 'cnn.us',
epg_source: 11,
icon_url: 'http://example.com/cnn.png',
},
];
const makeTvgsById = () => ({
'tvg-1': { id: 'tvg-1', name: 'ESPN', tvg_id: 'espn.us', epg_source: 10, icon_url: 'http://example.com/espn.png' },
'tvg-2': { id: 'tvg-2', name: 'CNN', tvg_id: 'cnn.us', epg_source: 11, icon_url: 'http://example.com/cnn.png' },
'tvg-1': {
id: 'tvg-1',
name: 'ESPN',
tvg_id: 'espn.us',
epg_source: 10,
icon_url: 'http://example.com/espn.png',
},
'tvg-2': {
id: 'tvg-2',
name: 'CNN',
tvg_id: 'cnn.us',
epg_source: 11,
icon_url: 'http://example.com/cnn.png',
},
});
const makeLogos = () => ({
42: { id: 42, name: 'ESPN Logo', url: 'http://example.com/espn.png', cache_url: '/cache/espn.png' },
43: { id: 43, name: 'CNN Logo', url: 'http://example.com/cnn.png', cache_url: '/cache/cnn.png' },
42: {
id: 42,
name: 'ESPN Logo',
url: 'http://example.com/espn.png',
cache_url: '/cache/espn.png',
},
43: {
id: 43,
name: 'CNN Logo',
url: 'http://example.com/cnn.png',
cache_url: '/cache/cnn.png',
},
});
const makeChannelLogos = () => ({
@ -384,9 +461,7 @@ const setupMocks = ({ formOverrides = {}, channel = null } = {}) => {
sel({ epgs, tvgs, tvgsById })
);
vi.mocked(useLogosStore).mockImplementation((sel) =>
sel({ logos })
);
vi.mocked(useLogosStore).mockImplementation((sel) => sel({ logos }));
const mockEnsureLogosLoaded = vi.fn();
vi.mocked(useChannelLogoSelection).mockReturnValue({
@ -396,7 +471,9 @@ const setupMocks = ({ formOverrides = {}, channel = null } = {}) => {
});
// Same object reference every call prevents useMemo from recalculating
vi.mocked(ChannelUtils.getChannelFormDefaultValues).mockReturnValue(stableDefaults);
vi.mocked(ChannelUtils.getChannelFormDefaultValues).mockReturnValue(
stableDefaults
);
vi.mocked(ChannelUtils.getFormattedValues).mockImplementation((v) => v);
return { formMethods, mockEnsureLogosLoaded };
@ -460,7 +537,9 @@ describe('ChannelForm', () => {
it('renders Channel Group text input', () => {
setupMocks();
render(<ChannelForm {...defaultProps()} />);
expect(screen.getByTestId('text-input-channel_group_id')).toBeInTheDocument();
expect(
screen.getByTestId('text-input-channel_group_id')
).toBeInTheDocument();
});
it('renders stream profile select with options', () => {
@ -493,7 +572,9 @@ describe('ChannelForm', () => {
it('renders the channel number input', () => {
setupMocks();
render(<ChannelForm {...defaultProps()} />);
expect(screen.getByTestId('number-input-channel_number')).toBeInTheDocument();
expect(
screen.getByTestId('number-input-channel_number')
).toBeInTheDocument();
});
it('renders TVG-ID text input', () => {
@ -505,7 +586,9 @@ describe('ChannelForm', () => {
it('renders Gracenote StationId text input', () => {
setupMocks();
render(<ChannelForm {...defaultProps()} />);
expect(screen.getByTestId('text-input-tvc_guide_stationid')).toBeInTheDocument();
expect(
screen.getByTestId('text-input-tvc_guide_stationid')
).toBeInTheDocument();
});
it('renders EPG text input', () => {
@ -622,7 +705,9 @@ describe('ChannelForm', () => {
it('shows error notification when matchChannelEpg throws', async () => {
const channel = makeChannel();
vi.mocked(ChannelUtils.matchChannelEpg).mockRejectedValue(new Error('Network'));
vi.mocked(ChannelUtils.matchChannelEpg).mockRejectedValue(
new Error('Network')
);
setupMocks({ channel });
render(<ChannelForm {...defaultProps({ channel })} />);
fireEvent.click(screen.getByText('Auto Match'));
@ -644,7 +729,10 @@ describe('ChannelForm', () => {
render(<ChannelForm {...defaultProps({ channel })} />);
fireEvent.click(screen.getByText('Auto Match'));
await waitFor(() => {
expect(formMethods.setValue).toHaveBeenCalledWith('epg_data_id', 'tvg-1');
expect(formMethods.setValue).toHaveBeenCalledWith(
'epg_data_id',
'tvg-1'
);
});
});
});
@ -731,7 +819,6 @@ describe('ChannelForm', () => {
expect.objectContaining({ color: 'green' })
);
});
});
it('creates a new logo when no matching logo exists', async () => {
@ -745,8 +832,8 @@ describe('ChannelForm', () => {
},
});
// tvg-2 has icon_url 'http://example.com/cnn.png' but make allLogos not contain it
vi.mocked(useLogosStore).mockImplementation((sel) =>
sel({ logos: {} }) // empty logos so no match
vi.mocked(useLogosStore).mockImplementation(
(sel) => sel({ logos: {} }) // empty logos so no match
);
render(<ChannelForm {...defaultProps()} />);
fireEvent.click(screen.getByText('Use EPG Logo'));
@ -761,9 +848,7 @@ describe('ChannelForm', () => {
setupMocks({
formOverrides: { watchValues: { epg_data_id: 'tvg-2' } },
});
vi.mocked(useLogosStore).mockImplementation((sel) =>
sel({ logos: {} })
);
vi.mocked(useLogosStore).mockImplementation((sel) => sel({ logos: {} }));
render(<ChannelForm {...defaultProps()} />);
fireEvent.click(screen.getByText('Use EPG Logo'));
await waitFor(() => {
@ -844,7 +929,9 @@ describe('ChannelForm', () => {
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
fireEvent.click(screen.getByTestId('channel-group-close'));
await waitFor(() => {
expect(screen.queryByTestId('channel-group-form')).not.toBeInTheDocument();
expect(
screen.queryByTestId('channel-group-form')
).not.toBeInTheDocument();
});
});
@ -854,8 +941,13 @@ describe('ChannelForm', () => {
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
fireEvent.click(screen.getByTestId('channel-group-save'));
await waitFor(() => {
expect(formMethods.setValue).toHaveBeenCalledWith('channel_group_id', '99');
expect(screen.queryByTestId('channel-group-form')).not.toBeInTheDocument();
expect(formMethods.setValue).toHaveBeenCalledWith(
'channel_group_id',
'99'
);
expect(
screen.queryByTestId('channel-group-form')
).not.toBeInTheDocument();
});
});
});
@ -923,7 +1015,9 @@ describe('ChannelForm', () => {
});
it('still calls onClose when submission throws', async () => {
vi.mocked(ChannelUtils.addChannel).mockRejectedValue(new Error('Server error'));
vi.mocked(ChannelUtils.addChannel).mockRejectedValue(
new Error('Server error')
);
setupMocks();
const onClose = vi.fn();
render(<ChannelForm {...defaultProps({ onClose })} />);
@ -986,4 +1080,4 @@ describe('ChannelForm', () => {
expect(epgInput).toHaveValue('EPG Source 1 - ESPN');
});
});
});
});

View file

@ -57,12 +57,25 @@ vi.mock('../ChannelGroup', () => ({
}));
vi.mock('../../ConfirmationDialog', () => ({
default: ({ opened, onConfirm, onClose, title, confirmLabel, cancelLabel, loading, message }) =>
default: ({
opened,
onConfirm,
onClose,
title,
confirmLabel,
cancelLabel,
loading,
message,
}) =>
opened ? (
<div data-testid="confirmation-dialog">
<div data-testid="dialog-title">{title}</div>
<div data-testid="dialog-message">{message}</div>
<button data-testid="dialog-confirm" onClick={onConfirm} disabled={loading}>
<button
data-testid="dialog-confirm"
onClick={onConfirm}
disabled={loading}
>
{confirmLabel}
</button>
<button data-testid="dialog-cancel" onClick={onClose}>
@ -73,7 +86,9 @@ vi.mock('../../ConfirmationDialog', () => ({
}));
vi.mock('../../LazyLogo', () => ({
default: ({ src, alt }) => <img src={src} alt={alt} data-testid="lazy-logo" />,
default: ({ src, alt }) => (
<img src={src} alt={alt} data-testid="lazy-logo" />
),
}));
vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
@ -104,14 +119,20 @@ vi.mock('@mantine/core', async () => ({
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Popover: ({ children }) => <div data-testid="popover">{children}</div>,
PopoverDropdown: ({ children }) => <div data-testid="popover-dropdown">{children}</div>,
PopoverTarget: ({ children }) => <div data-testid="popover-target">{children}</div>,
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
PopoverTarget: ({ children }) => (
<div data-testid="popover-target">{children}</div>
),
ScrollArea: ({ children, h }) => <div style={{ height: h }}>{children}</div>,
Select: ({ label, data, value, onChange, disabled }) => (
<select
@ -124,13 +145,19 @@ vi.mock('@mantine/core', async () => ({
{(data ?? []).map((opt) => {
const val = typeof opt === 'string' ? opt : opt.value;
const lbl = typeof opt === 'string' ? opt : opt.label;
return <option key={val} value={val}>{lbl}</option>;
return (
<option key={val} value={val}>
{lbl}
</option>
);
})}
</select>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c }) => (
<span data-size={size} data-color={c}>{children}</span>
<span data-size={size} data-color={c}>
{children}
</span>
),
TextInput: ({ label, placeholder, value, onChange, disabled }) => (
<input
@ -144,7 +171,9 @@ vi.mock('@mantine/core', async () => ({
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
UnstyledButton: ({ children, onClick, style }) => (
<button onClick={onClick} style={style}>{children}</button>
<button onClick={onClick} style={style}>
{children}
</button>
),
useMantineTheme: () => ({ tailwind: { green: { 5: '#38a169' } } }),
}));
@ -246,7 +275,8 @@ const setupMocks = (overrides = {}) => {
vi.mocked(useWarningsStore).mockImplementation((sel) =>
sel({
isWarningSuppressed: overrides.isWarningSuppressed ?? vi.fn().mockReturnValue(false),
isWarningSuppressed:
overrides.isWarningSuppressed ?? vi.fn().mockReturnValue(false),
suppressWarning: vi.fn(),
})
);
@ -269,14 +299,24 @@ const setupMocks = (overrides = {}) => {
});
vi.mocked(ChannelBatchUtils.computeRegexPreview).mockReturnValue([]);
vi.mocked(ChannelBatchUtils.buildSubmitValues).mockReturnValue({ stream_profile_id: '1' });
vi.mocked(ChannelBatchUtils.buildSubmitValues).mockReturnValue({
stream_profile_id: '1',
});
vi.mocked(ChannelBatchUtils.buildEpgAssociations).mockResolvedValue(null);
vi.mocked(ChannelBatchUtils.updateChannels).mockResolvedValue(undefined);
vi.mocked(ChannelBatchUtils.bulkRegexRenameChannels).mockResolvedValue(undefined);
vi.mocked(ChannelBatchUtils.bulkRegexRenameChannels).mockResolvedValue(
undefined
);
vi.mocked(ChannelBatchUtils.batchSetEPG).mockResolvedValue(undefined);
vi.mocked(ChannelBatchUtils.setChannelNamesFromEpg).mockResolvedValue(undefined);
vi.mocked(ChannelBatchUtils.setChannelLogosFromEpg).mockResolvedValue(undefined);
vi.mocked(ChannelBatchUtils.setChannelTvgIdsFromEpg).mockResolvedValue(undefined);
vi.mocked(ChannelBatchUtils.setChannelNamesFromEpg).mockResolvedValue(
undefined
);
vi.mocked(ChannelBatchUtils.setChannelLogosFromEpg).mockResolvedValue(
undefined
);
vi.mocked(ChannelBatchUtils.setChannelTvgIdsFromEpg).mockResolvedValue(
undefined
);
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(null);
vi.mocked(ChannelBatchUtils.getLogoChange).mockReturnValue(null);
vi.mocked(ChannelBatchUtils.getStreamProfileChange).mockReturnValue(null);
@ -353,12 +393,16 @@ describe('ChannelBatchForm', () => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'orange' })
);
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('opens confirmation dialog when at least one change is present', () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
@ -371,11 +415,13 @@ describe('ChannelBatchForm', () => {
describe('warning suppression', () => {
it('skips confirmation dialog and calls onSubmit directly when batch-update warning is suppressed', async () => {
const isWarningSuppressed = vi.fn().mockImplementation((key) =>
key === 'batch-update-channels'
);
const isWarningSuppressed = vi
.fn()
.mockImplementation((key) => key === 'batch-update-channels');
setupMocks({ isWarningSuppressed });
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
const onClose = vi.fn();
renderForm({ onClose });
@ -384,7 +430,9 @@ describe('ChannelBatchForm', () => {
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
});
@ -393,27 +441,33 @@ describe('ChannelBatchForm', () => {
describe('confirmation dialog', () => {
it('closes confirmation dialog on cancel', () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('dialog-cancel'));
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('calls updateChannels on confirm', async () => {
setupMocks({
formValues: {
stream_profile_id: '1', // survives: not '-1' or '0', so kept as-is after parseInt...
user_level: '-1', // deleted
is_adult: '-1', // deleted
channel_group: '', // deleted (channel_group key is removed)
logo: '', // deleted
stream_profile_id: '1', // survives: not '-1' or '0', so kept as-is after parseInt...
user_level: '-1', // deleted
is_adult: '-1', // deleted
channel_group: '', // deleted (channel_group key is removed)
logo: '', // deleted
},
});
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
@ -429,7 +483,9 @@ describe('ChannelBatchForm', () => {
it('calls requeryChannels after successful submit', async () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
@ -442,7 +498,9 @@ describe('ChannelBatchForm', () => {
it('calls onClose after successful submit', async () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
const onClose = vi.fn();
renderForm({ onClose });
@ -477,7 +535,7 @@ describe('ChannelBatchForm', () => {
expect(ChannelBatchUtils.bulkRegexRenameChannels).toHaveBeenCalledWith(
CHANNEL_IDS,
'foo',
'', // regexReplace default is ''
'', // regexReplace default is ''
'g'
);
});
@ -504,20 +562,24 @@ describe('ChannelBatchForm', () => {
fireEvent.click(screen.getByText('Set Names from EPG'));
expect(screen.getByTestId('dialog-title')).toHaveTextContent('Confirm Set Names from EPG');
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
'Confirm Set Names from EPG'
);
});
it('skips dialog and executes directly when warning is suppressed', async () => {
const isWarningSuppressed = vi.fn().mockImplementation((key) =>
key === 'batch-set-names-from-epg'
);
const isWarningSuppressed = vi
.fn()
.mockImplementation((key) => key === 'batch-set-names-from-epg');
setupMocks({ isWarningSuppressed });
renderForm();
fireEvent.click(screen.getByText('Set Names from EPG'));
await waitFor(() => {
expect(ChannelBatchUtils.setChannelNamesFromEpg).toHaveBeenCalledWith(CHANNEL_IDS);
expect(ChannelBatchUtils.setChannelNamesFromEpg).toHaveBeenCalledWith(
CHANNEL_IDS
);
});
});
@ -529,7 +591,9 @@ describe('ChannelBatchForm', () => {
fireEvent.click(screen.getByTestId('dialog-confirm'));
await waitFor(() => {
expect(ChannelBatchUtils.setChannelNamesFromEpg).toHaveBeenCalledWith(CHANNEL_IDS);
expect(ChannelBatchUtils.setChannelNamesFromEpg).toHaveBeenCalledWith(
CHANNEL_IDS
);
});
});
@ -549,7 +613,9 @@ describe('ChannelBatchForm', () => {
it('shows error notification when setChannelNamesFromEpg rejects', async () => {
setupMocks();
vi.mocked(ChannelBatchUtils.setChannelNamesFromEpg).mockRejectedValue(new Error('fail'));
vi.mocked(ChannelBatchUtils.setChannelNamesFromEpg).mockRejectedValue(
new Error('fail')
);
renderForm();
fireEvent.click(screen.getByText('Set Names from EPG'));
@ -583,7 +649,9 @@ describe('ChannelBatchForm', () => {
fireEvent.click(screen.getByText('Set Logos from EPG'));
expect(screen.getByTestId('dialog-title')).toHaveTextContent('Confirm Set Logos from EPG');
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
'Confirm Set Logos from EPG'
);
});
it('calls setChannelLogosFromEpg on confirm', async () => {
@ -594,13 +662,17 @@ describe('ChannelBatchForm', () => {
fireEvent.click(screen.getByTestId('dialog-confirm'));
await waitFor(() => {
expect(ChannelBatchUtils.setChannelLogosFromEpg).toHaveBeenCalledWith(CHANNEL_IDS);
expect(ChannelBatchUtils.setChannelLogosFromEpg).toHaveBeenCalledWith(
CHANNEL_IDS
);
});
});
it('shows error notification when setChannelLogosFromEpg rejects', async () => {
setupMocks();
vi.mocked(ChannelBatchUtils.setChannelLogosFromEpg).mockRejectedValue(new Error('fail'));
vi.mocked(ChannelBatchUtils.setChannelLogosFromEpg).mockRejectedValue(
new Error('fail')
);
renderForm();
fireEvent.click(screen.getByText('Set Logos from EPG'));
@ -634,7 +706,9 @@ describe('ChannelBatchForm', () => {
fireEvent.click(screen.getByText('Set TVG-IDs from EPG'));
expect(screen.getByTestId('dialog-title')).toHaveTextContent('Confirm Set TVG-IDs from EPG');
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
'Confirm Set TVG-IDs from EPG'
);
});
it('calls setChannelTvgIdsFromEpg on confirm', async () => {
@ -645,13 +719,17 @@ describe('ChannelBatchForm', () => {
fireEvent.click(screen.getByTestId('dialog-confirm'));
await waitFor(() => {
expect(ChannelBatchUtils.setChannelTvgIdsFromEpg).toHaveBeenCalledWith(CHANNEL_IDS);
expect(ChannelBatchUtils.setChannelTvgIdsFromEpg).toHaveBeenCalledWith(
CHANNEL_IDS
);
});
});
it('shows error notification when setChannelTvgIdsFromEpg rejects', async () => {
setupMocks();
vi.mocked(ChannelBatchUtils.setChannelTvgIdsFromEpg).mockRejectedValue(new Error('fail'));
vi.mocked(ChannelBatchUtils.setChannelTvgIdsFromEpg).mockRejectedValue(
new Error('fail')
);
renderForm();
fireEvent.click(screen.getByText('Set TVG-IDs from EPG'));
@ -672,7 +750,9 @@ describe('ChannelBatchForm', () => {
setupMocks();
renderForm();
fireEvent.click(screen.getAllByTestId('icon-square-plus')[0].closest('button'));
fireEvent.click(
screen.getAllByTestId('icon-square-plus')[0].closest('button')
);
expect(screen.getByTestId('channel-group-form')).toBeInTheDocument();
});
@ -681,20 +761,28 @@ describe('ChannelBatchForm', () => {
setupMocks();
renderForm();
fireEvent.click(screen.getAllByTestId('icon-square-plus')[0].closest('button'));
fireEvent.click(
screen.getAllByTestId('icon-square-plus')[0].closest('button')
);
fireEvent.click(screen.getByText('Save Group'));
expect(screen.queryByTestId('channel-group-form')).not.toBeInTheDocument();
expect(
screen.queryByTestId('channel-group-form')
).not.toBeInTheDocument();
});
it('closes channel group modal without updating selection when cancelled', () => {
setupMocks();
renderForm();
fireEvent.click(screen.getAllByTestId('icon-square-plus')[0].closest('button'));
fireEvent.click(
screen.getAllByTestId('icon-square-plus')[0].closest('button')
);
fireEvent.click(screen.getByText('Cancel Group'));
expect(screen.queryByTestId('channel-group-form')).not.toBeInTheDocument();
expect(
screen.queryByTestId('channel-group-form')
).not.toBeInTheDocument();
});
});
@ -745,7 +833,9 @@ describe('ChannelBatchForm', () => {
describe('error resilience', () => {
it('does not throw when requeryChannels rejects after submit', async () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
vi.mocked(requeryChannels).mockRejectedValue(new Error('network'));
renderForm();
@ -758,7 +848,9 @@ describe('ChannelBatchForm', () => {
it('does not throw when setChannelNamesFromEpg rejects and no channels', async () => {
setupMocks();
vi.mocked(ChannelBatchUtils.setChannelNamesFromEpg).mockRejectedValue(new Error('fail'));
vi.mocked(ChannelBatchUtils.setChannelNamesFromEpg).mockRejectedValue(
new Error('fail')
);
renderForm();
fireEvent.click(screen.getByText('Set Names from EPG'));
@ -769,12 +861,14 @@ describe('ChannelBatchForm', () => {
});
});
// Batch update confirmation message
// Batch update confirmation message
describe('batch update confirmation message', () => {
it('displays the channel count in the confirmation message', () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
@ -784,19 +878,29 @@ describe('ChannelBatchForm', () => {
it('displays a single change line in the confirmation message', () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getStreamProfileChange).mockReturnValue('• Stream Profile: HD Profile');
vi.mocked(ChannelBatchUtils.getStreamProfileChange).mockReturnValue(
'• Stream Profile: HD Profile'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
expect(screen.getByTestId('dialog-message')).toHaveTextContent('• Stream Profile: HD Profile');
expect(screen.getByTestId('dialog-message')).toHaveTextContent(
'• Stream Profile: HD Profile'
);
});
it('displays multiple change lines when several fields are changed', () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getStreamProfileChange).mockReturnValue('• Stream Profile: HD Profile');
vi.mocked(ChannelBatchUtils.getUserLevelChange).mockReturnValue('• User Level: Admin');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
vi.mocked(ChannelBatchUtils.getStreamProfileChange).mockReturnValue(
'• Stream Profile: HD Profile'
);
vi.mocked(ChannelBatchUtils.getUserLevelChange).mockReturnValue(
'• User Level: Admin'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
@ -825,22 +929,30 @@ describe('ChannelBatchForm', () => {
it('uses "Apply Changes" as the confirm button label', () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
expect(screen.getByTestId('dialog-confirm')).toHaveTextContent('Apply Changes');
expect(screen.getByTestId('dialog-confirm')).toHaveTextContent(
'Apply Changes'
);
});
it('shows "Confirm Batch Update" as the dialog title', () => {
setupMocks();
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue('• Channel Group: Sports');
vi.mocked(ChannelBatchUtils.getChannelGroupChange).mockReturnValue(
'• Channel Group: Sports'
);
renderForm();
fireEvent.click(screen.getByText('Submit'));
expect(screen.getByTestId('dialog-title')).toHaveTextContent('Confirm Batch Update');
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
'Confirm Batch Update'
);
});
});
});
});

View file

@ -155,7 +155,9 @@ describe('ChannelGroup', () => {
renderForm();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Channel Group');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Channel Group'
);
});
});
@ -227,7 +229,10 @@ describe('ChannelGroup', () => {
it('resets the form after successful add', async () => {
setupMocks();
vi.mocked(API.addChannelGroup).mockResolvedValue({ id: 99, name: 'NewGroup' });
vi.mocked(API.addChannelGroup).mockResolvedValue({
id: 99,
name: 'NewGroup',
});
const form = makeFormMock('NewGroup');
vi.mocked(useForm).mockReturnValue(form);
@ -317,7 +322,10 @@ describe('ChannelGroup', () => {
renderForm({ channelGroup: makeGroup() });
expect(screen.getByTestId('alert')).toHaveAttribute('data-color', 'yellow');
expect(screen.getByTestId('alert')).toHaveAttribute(
'data-color',
'yellow'
);
});
it('disables the name input for non-editable group', () => {

View file

@ -75,7 +75,9 @@ vi.mock('@mantine/core', () => ({
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
@ -106,11 +108,7 @@ vi.mock('@mantine/core', () => ({
<div data-testid={`tabs-panel-${value}`}>{children}</div>
),
TabsTab: ({ children, value, onClick }) => (
<button
data-testid={`tab-${value}`}
onClick={onClick}
role="tab"
>
<button data-testid={`tab-${value}`} onClick={onClick} role="tab">
{children}
</button>
),
@ -134,7 +132,13 @@ vi.mock('@mantine/core', () => ({
placeholder={placeholder}
data-testid={`input-${label?.toLowerCase().replace(/\s+/g, '-')}`}
/>
{error && <span data-testid={`error-${label?.toLowerCase().replace(/\s+/g, '-')}`}>{error}</span>}
{error && (
<span
data-testid={`error-${label?.toLowerCase().replace(/\s+/g, '-')}`}
>
{error}
</span>
)}
</div>
),
}));
@ -152,7 +156,11 @@ const makeConnection = (overrides = {}) => ({
},
subscriptions: [
{ event: 'channel_added', enabled: true, payload_template: null },
{ event: 'recording_started', enabled: true, payload_template: '{"id": "{{id}}"}' },
{
event: 'recording_started',
enabled: true,
payload_template: '{"id": "{{id}}"}',
},
],
...overrides,
});
@ -212,12 +220,23 @@ const renderForm = (props = {}) => {
describe('ConnectionForm', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ConnectionUtils.createConnectIntegration).mockResolvedValue({ id: 99 });
vi.mocked(ConnectionUtils.updateConnectIntegration).mockResolvedValue(undefined);
vi.mocked(ConnectionUtils.setConnectSubscriptions).mockResolvedValue(undefined);
vi.mocked(ConnectionUtils.buildConfig).mockReturnValue({ url: 'https://x.com' });
vi.mocked(ConnectionUtils.createConnectIntegration).mockResolvedValue({
id: 99,
});
vi.mocked(ConnectionUtils.updateConnectIntegration).mockResolvedValue(
undefined
);
vi.mocked(ConnectionUtils.setConnectSubscriptions).mockResolvedValue(
undefined
);
vi.mocked(ConnectionUtils.buildConfig).mockReturnValue({
url: 'https://x.com',
});
vi.mocked(ConnectionUtils.buildSubscriptions).mockReturnValue([]);
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({ fieldErrors: {}, apiError: '' });
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({
fieldErrors: {},
apiError: '',
});
});
// Visibility
@ -253,7 +272,11 @@ describe('ConnectionForm', () => {
});
it('calls createConnectIntegration on submit', async () => {
const form = makeFormMock({ name: 'New Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'New Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
renderForm({ connection: null });
@ -269,7 +292,11 @@ describe('ConnectionForm', () => {
});
it('does not call updateConnectIntegration when creating', async () => {
const form = makeFormMock({ name: 'New Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'New Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
renderForm({ connection: null });
@ -284,9 +311,15 @@ describe('ConnectionForm', () => {
it('calls setConnectSubscriptions with newly created connection', async () => {
const created = { id: 99, name: 'New Hook' };
vi.mocked(ConnectionUtils.createConnectIntegration).mockResolvedValue(created);
vi.mocked(ConnectionUtils.createConnectIntegration).mockResolvedValue(
created
);
const form = makeFormMock({ name: 'New Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'New Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
renderForm({ connection: null });
@ -301,7 +334,11 @@ describe('ConnectionForm', () => {
});
it('calls onClose after successful create', async () => {
const form = makeFormMock({ name: 'New Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'New Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
const onClose = vi.fn();
@ -357,7 +394,11 @@ describe('ConnectionForm', () => {
it('does not call createConnectIntegration when editing', async () => {
const connection = makeConnection();
const form = makeFormMock({ name: 'My Webhook', type: 'webhook', url: 'https://example.com/hook' });
const form = makeFormMock({
name: 'My Webhook',
type: 'webhook',
url: 'https://example.com/hook',
});
vi.mocked(useForm).mockReturnValue(form);
renderForm({ connection });
@ -371,7 +412,11 @@ describe('ConnectionForm', () => {
it('calls setConnectSubscriptions with existing connection on update', async () => {
const connection = makeConnection();
const form = makeFormMock({ name: 'My Webhook', type: 'webhook', url: 'https://example.com/hook' });
const form = makeFormMock({
name: 'My Webhook',
type: 'webhook',
url: 'https://example.com/hook',
});
vi.mocked(useForm).mockReturnValue(form);
renderForm({ connection });
@ -387,7 +432,11 @@ describe('ConnectionForm', () => {
it('calls onClose after successful update', async () => {
const connection = makeConnection();
const form = makeFormMock({ name: 'My Webhook', type: 'webhook', url: 'https://example.com/hook' });
const form = makeFormMock({
name: 'My Webhook',
type: 'webhook',
url: 'https://example.com/hook',
});
vi.mocked(useForm).mockReturnValue(form);
const onClose = vi.fn();
@ -446,7 +495,9 @@ describe('ConnectionForm', () => {
script_path: '/usr/local/bin/notify.sh',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.buildConfig).mockReturnValue({ path: '/usr/local/bin/notify.sh' });
vi.mocked(ConnectionUtils.buildConfig).mockReturnValue({
path: '/usr/local/bin/notify.sh',
});
renderForm({ connection: null });
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
@ -485,7 +536,9 @@ describe('ConnectionForm', () => {
url: 'https://example.com/webhook',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.buildConfig).mockReturnValue({ url: 'https://example.com/webhook' });
vi.mocked(ConnectionUtils.buildConfig).mockReturnValue({
url: 'https://example.com/webhook',
});
renderForm({ connection: null });
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
@ -507,7 +560,9 @@ describe('ConnectionForm', () => {
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.buildConfig).mockReturnValue({
url: 'https://example.com/hook', headers: { 'X-Token': 'abc123' } });
url: 'https://example.com/hook',
headers: { 'X-Token': 'abc123' },
});
renderForm({ connection });
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
@ -522,7 +577,9 @@ describe('ConnectionForm', () => {
});
it('omits headers from webhook config when all header rows are empty', async () => {
const connection = makeConnection({ config: { url: 'https://example.com/hook', headers: {} } });
const connection = makeConnection({
config: { url: 'https://example.com/hook', headers: {} },
});
const form = makeFormMock({
name: 'My Webhook',
type: 'webhook',
@ -547,15 +604,22 @@ describe('ConnectionForm', () => {
describe('subscriptions', () => {
it('passes subscription list with enabled flags to setConnectSubscriptions', async () => {
const form = makeFormMock({ name: 'Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.buildSubscriptions).mockReturnValue([
{ event: 'channel_added', enabled: true, payload_template: null },
{ event: 'channel_removed', enabled: false, payload_template: null },
{ event: 'recording_started', enabled: true, payload_template: '{"id": "{{id}}"}' },
{
event: 'recording_started',
enabled: true,
payload_template: '{"id": "{{id}}"}',
},
]);
renderForm({ connection: null });
// Toggle channel_added on
@ -567,8 +631,14 @@ describe('ConnectionForm', () => {
expect.any(Object),
expect.arrayContaining([
expect.objectContaining({ event: 'channel_added', enabled: true }),
expect.objectContaining({ event: 'channel_removed', enabled: false }),
expect.objectContaining({ event: 'recording_started', enabled: true }),
expect.objectContaining({
event: 'channel_removed',
enabled: false,
}),
expect.objectContaining({
event: 'recording_started',
enabled: true,
}),
])
);
});
@ -576,12 +646,20 @@ describe('ConnectionForm', () => {
it('toggles event off when checkbox is clicked twice', async () => {
const connection = makeConnection();
const form = makeFormMock({ name: 'Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.buildSubscriptions).mockReturnValue([
{ event: 'channel_added', enabled: false, payload_template: null },
{ event: 'channel_removed', enabled: false, payload_template: null },
{ event: 'recording_started', enabled: true, payload_template: '{"id": "{{id}}"}' },
{
event: 'recording_started',
enabled: true,
payload_template: '{"id": "{{id}}"}',
},
]);
renderForm({ connection });
@ -595,8 +673,14 @@ describe('ConnectionForm', () => {
expect.any(Object),
expect.arrayContaining([
expect.objectContaining({ event: 'channel_added', enabled: false }),
expect.objectContaining({ event: 'channel_removed', enabled: false }),
expect.objectContaining({ event: 'recording_started', enabled: true }),
expect.objectContaining({
event: 'channel_removed',
enabled: false,
}),
expect.objectContaining({
event: 'recording_started',
enabled: true,
}),
])
);
});
@ -604,17 +688,26 @@ describe('ConnectionForm', () => {
it('includes payload_template in subscription when set', async () => {
const connection = makeConnection();
const form = makeFormMock({ name: 'Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.buildSubscriptions).mockReturnValue([
{ event: 'recording_started', enabled: true, payload_template: '{"id": "{{id}}"}' },
{
event: 'recording_started',
enabled: true,
payload_template: '{"id": "{{id}}"}',
},
]);
renderForm({ connection });
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
await waitFor(() => {
const subs = vi.mocked(ConnectionUtils.setConnectSubscriptions).mock.calls[0][1];
const subs = vi.mocked(ConnectionUtils.setConnectSubscriptions).mock
.calls[0][1];
const recordingSub = subs.find((s) => s.event === 'recording_started');
expect(recordingSub).toBeDefined();
expect(recordingSub.payload_template).toBe('{"id": "{{id}}"}');
@ -622,14 +715,19 @@ describe('ConnectionForm', () => {
});
it('sends null payload_template when not set for an event', async () => {
const form = makeFormMock({ name: 'Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
renderForm({ connection: null });
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
await waitFor(() => {
const subs = vi.mocked(ConnectionUtils.setConnectSubscriptions).mock.calls[0][1];
const subs = vi.mocked(ConnectionUtils.setConnectSubscriptions).mock
.calls[0][1];
subs.forEach((s) => expect(s.payload_template).toBeNull());
});
});
@ -642,9 +740,16 @@ describe('ConnectionForm', () => {
vi.mocked(ConnectionUtils.createConnectIntegration).mockRejectedValue(
new Error('Server error')
);
const form = makeFormMock({ name: 'Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({ fieldErrors: {}, apiError: 'Server error' });
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({
fieldErrors: {},
apiError: 'Server error',
});
renderForm({ connection: null });
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
@ -659,9 +764,16 @@ describe('ConnectionForm', () => {
new Error('Update failed')
);
const connection = makeConnection();
const form = makeFormMock({ name: 'My Webhook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'My Webhook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({ fieldErrors: {}, apiError: 'Update failed' });
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({
fieldErrors: {},
apiError: 'Update failed',
});
renderForm({ connection });
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
@ -675,9 +787,16 @@ describe('ConnectionForm', () => {
vi.mocked(ConnectionUtils.createConnectIntegration).mockRejectedValue(
new Error('fail')
);
const form = makeFormMock({ name: 'Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({ fieldErrors: {}, apiError: 'fail' });
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({
fieldErrors: {},
apiError: 'fail',
});
const onClose = vi.fn();
renderForm({ connection: null, onClose });
@ -694,19 +813,30 @@ describe('ConnectionForm', () => {
.mockRejectedValueOnce(new Error('First error'))
.mockResolvedValueOnce({ id: 99 });
const form = makeFormMock({ name: 'Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({ fieldErrors: {}, apiError: 'First error' });
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({
fieldErrors: {},
apiError: 'First error',
});
renderForm({ connection: null });
// First submit fails
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
await waitFor(() => expect(screen.queryByText('First error')).toBeInTheDocument());
await waitFor(() =>
expect(screen.queryByText('First error')).toBeInTheDocument()
);
// Second submit succeeds
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
await waitFor(() => expect(screen.queryByText('First error')).not.toBeInTheDocument());
await waitFor(() =>
expect(screen.queryByText('First error')).not.toBeInTheDocument()
);
});
});
@ -727,18 +857,29 @@ describe('ConnectionForm', () => {
vi.mocked(ConnectionUtils.createConnectIntegration).mockRejectedValue(
new Error('fail')
);
const form = makeFormMock({ name: 'Hook', type: 'webhook', url: 'https://x.com' });
const form = makeFormMock({
name: 'Hook',
type: 'webhook',
url: 'https://x.com',
});
vi.mocked(useForm).mockReturnValue(form);
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({ fieldErrors: {}, apiError: 'fail' });
vi.mocked(ConnectionUtils.parseApiError).mockReturnValue({
fieldErrors: {},
apiError: 'fail',
});
renderForm({ connection: null });
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
await waitFor(() => expect(screen.queryByText('fail')).toBeInTheDocument());
await waitFor(() =>
expect(screen.queryByText('fail')).toBeInTheDocument()
);
fireEvent.click(screen.getByTestId('modal-close'));
await waitFor(() => expect(screen.queryByText('fail')).not.toBeInTheDocument());
await waitFor(() =>
expect(screen.queryByText('fail')).not.toBeInTheDocument()
);
});
});

View file

@ -7,27 +7,35 @@ vi.mock('../../../utils/forms/CronBuilderUtils.js', () => ({
buildCron: vi.fn(),
parseCronPreset: vi.fn(),
CRON_FIELDS: [
{ index: 0, label: 'Minute (0-59)', placeholder: '*, 0, */15' },
{ index: 1, label: 'Hour (0-23)', placeholder: '*, 0, 9-17' },
{ index: 2, label: 'Day of Month (1-31)', placeholder: '*, 1, 1-15' },
{ index: 3, label: 'Month (1-12)', placeholder: '*, 1, 1-6' },
{ index: 4, label: 'Day of Week (0-6, Sun-Sat)', placeholder: '*, 0, 1-5' },
{ index: 0, label: 'Minute (0-59)', placeholder: '*, 0, */15' },
{ index: 1, label: 'Hour (0-23)', placeholder: '*, 0, 9-17' },
{ index: 2, label: 'Day of Month (1-31)', placeholder: '*, 1, 1-15' },
{ index: 3, label: 'Month (1-12)', placeholder: '*, 1, 1-6' },
{ index: 4, label: 'Day of Week (0-6, Sun-Sat)', placeholder: '*, 0, 1-5' },
],
DAYS_OF_WEEK: [
{ value: '*', label: 'Every day' },
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
{ value: '*', label: 'Every day' },
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
],
FREQUENCY_OPTIONS: [
{ value: 'hourly', label: 'Hourly' },
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'hourly', label: 'Hourly' },
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
],
PRESETS: [
{ label: 'Every Hour', description: 'Runs every hour', value: '0 * * * *' },
{ label: 'Every Day 3am', description: 'Runs every day at 3 AM', value: '0 3 * * *' },
{ label: 'Every Sunday', description: 'Runs every Sunday at 3AM', value: '0 3 * * 0' },
{ label: 'Every Hour', description: 'Runs every hour', value: '0 * * * *' },
{
label: 'Every Day 3am',
description: 'Runs every day at 3 AM',
value: '0 3 * * *',
},
{
label: 'Every Sunday',
description: 'Runs every Sunday at 3AM',
value: '0 3 * * 0',
},
],
updateCronPart: vi.fn(),
}));
@ -35,7 +43,12 @@ vi.mock('../../../utils/forms/CronBuilderUtils.js', () => ({
// Mantine core
vi.mock('@mantine/core', async () => ({
Badge: ({ children, size, variant, color }) => (
<span data-testid="badge" data-size={size} data-variant={variant} data-color={color}>
<span
data-testid="badge"
data-size={size}
data-variant={variant}
data-color={color}
>
{children}
</span>
),
@ -51,7 +64,9 @@ vi.mock('@mantine/core', async () => ({
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
@ -122,7 +137,7 @@ vi.mock('@mantine/core', async () => ({
// lucide-react
vi.mock('lucide-react', () => ({
Calendar: () => <svg data-testid="icon-calendar" />,
Clock: () => <svg data-testid="icon-clock" />,
Clock: () => <svg data-testid="icon-clock" />,
}));
// Imports after mocks
@ -241,7 +256,9 @@ describe('CronBuilder', () => {
it('calls parseCronPreset with the preset value on click', () => {
renderBuilder();
fireEvent.click(screen.getByText('Every Hour'));
expect(CronBuilderUtils.parseCronPreset).toHaveBeenCalledWith('0 * * * *');
expect(CronBuilderUtils.parseCronPreset).toHaveBeenCalledWith(
'0 * * * *'
);
});
it('updates state from parseCronPreset result on preset click', () => {
@ -256,7 +273,11 @@ describe('CronBuilder', () => {
fireEvent.click(screen.getByText('Every Hour'));
// buildCron should be called with the updated frequency
expect(CronBuilderUtils.buildCron).toHaveBeenCalledWith(
'hourly', 0, 0, '*', 1
'hourly',
0,
0,
'*',
1
);
});
});
@ -268,56 +289,81 @@ describe('CronBuilder', () => {
it('renders the Frequency select', () => {
renderBuilder();
expect(within(getSimplePanel()).getByLabelText('Frequency')).toBeInTheDocument();
expect(
within(getSimplePanel()).getByLabelText('Frequency')
).toBeInTheDocument();
});
it('renders the Minute input', () => {
renderBuilder();
expect(within(getSimplePanel()).getByLabelText('Minute (0-59)')).toBeInTheDocument();
expect(
within(getSimplePanel()).getByLabelText('Minute (0-59)')
).toBeInTheDocument();
});
it('renders the Hour input when frequency is not hourly', () => {
renderBuilder();
expect(within(getSimplePanel()).getByLabelText('Hour (0-23)')).toBeInTheDocument();
expect(
within(getSimplePanel()).getByLabelText('Hour (0-23)')
).toBeInTheDocument();
});
it('does not render Hour input when frequency is hourly', () => {
renderBuilder();
const freqSelect = within(getSimplePanel()).getByLabelText('Frequency');
fireEvent.change(freqSelect, { target: { value: 'hourly' } });
expect(within(getSimplePanel()).queryByLabelText('Hour (0-23)')).not.toBeInTheDocument();
expect(
within(getSimplePanel()).queryByLabelText('Hour (0-23)')
).not.toBeInTheDocument();
});
it('renders Day of Week select when frequency is weekly', () => {
renderBuilder();
const freqSelect = within(getSimplePanel()).getByLabelText('Frequency');
fireEvent.change(freqSelect, { target: { value: 'weekly' } });
expect(within(getSimplePanel()).getByLabelText('Day of Week')).toBeInTheDocument();
expect(
within(getSimplePanel()).getByLabelText('Day of Week')
).toBeInTheDocument();
});
it('does not render Day of Week for daily frequency', () => {
renderBuilder();
expect(within(getSimplePanel()).queryByLabelText('Day of Week')).not.toBeInTheDocument();
expect(
within(getSimplePanel()).queryByLabelText('Day of Week')
).not.toBeInTheDocument();
});
it('renders Day of Month input when frequency is monthly', () => {
renderBuilder();
const freqSelect = within(getSimplePanel()).getByLabelText('Frequency');
fireEvent.change(freqSelect, { target: { value: 'monthly' } });
expect(within(getSimplePanel()).getByLabelText('Day of Month (1-31)')).toBeInTheDocument();
expect(
within(getSimplePanel()).getByLabelText('Day of Month (1-31)')
).toBeInTheDocument();
});
it('does not render Day of Month for daily frequency', () => {
renderBuilder();
expect(within(getSimplePanel()).queryByLabelText('Day of Month (1-31)')).not.toBeInTheDocument();
expect(
within(getSimplePanel()).queryByLabelText('Day of Month (1-31)')
).not.toBeInTheDocument();
});
it('calls buildCron when minute changes', () => {
renderBuilder();
fireEvent.change(within(getSimplePanel()).getByLabelText('Minute (0-59)'), {
target: { value: 30 },
});
expect(CronBuilderUtils.buildCron).toHaveBeenCalledWith('daily', 30, 3, '*', 1);
fireEvent.change(
within(getSimplePanel()).getByLabelText('Minute (0-59)'),
{
target: { value: 30 },
}
);
expect(CronBuilderUtils.buildCron).toHaveBeenCalledWith(
'daily',
30,
3,
'*',
1
);
});
it('calls buildCron when hour changes', () => {
@ -325,7 +371,13 @@ describe('CronBuilder', () => {
fireEvent.change(within(getSimplePanel()).getByLabelText('Hour (0-23)'), {
target: { value: 8 },
});
expect(CronBuilderUtils.buildCron).toHaveBeenCalledWith('daily', 0, 8, '*', 1);
expect(CronBuilderUtils.buildCron).toHaveBeenCalledWith(
'daily',
0,
8,
'*',
1
);
});
});
@ -336,31 +388,47 @@ describe('CronBuilder', () => {
it('renders all 5 cron field inputs', () => {
renderBuilder();
expect(within(getAdvancedPanel()).getByLabelText('Minute (0-59)')).toBeInTheDocument();
expect(within(getAdvancedPanel()).getByLabelText('Hour (0-23)')).toBeInTheDocument();
expect(within(getAdvancedPanel()).getByLabelText('Day of Month (1-31)')).toBeInTheDocument();
expect(within(getAdvancedPanel()).getByLabelText('Month (1-12)')).toBeInTheDocument();
expect(within(getAdvancedPanel()).getByLabelText('Day of Week (0-6, Sun-Sat)')).toBeInTheDocument();
expect(
within(getAdvancedPanel()).getByLabelText('Minute (0-59)')
).toBeInTheDocument();
expect(
within(getAdvancedPanel()).getByLabelText('Hour (0-23)')
).toBeInTheDocument();
expect(
within(getAdvancedPanel()).getByLabelText('Day of Month (1-31)')
).toBeInTheDocument();
expect(
within(getAdvancedPanel()).getByLabelText('Month (1-12)')
).toBeInTheDocument();
expect(
within(getAdvancedPanel()).getByLabelText('Day of Week (0-6, Sun-Sat)')
).toBeInTheDocument();
});
it('renders descriptive helper text', () => {
renderBuilder();
expect(screen.getByText(/Build advanced cron expressions/)).toBeInTheDocument();
expect(
screen.getByText(/Build advanced cron expressions/)
).toBeInTheDocument();
});
it('calls updateCronPart when an advanced field changes', () => {
renderBuilder();
const minuteInput = within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
const minuteInput =
within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
fireEvent.change(minuteInput, { target: { value: '30' } });
expect(CronBuilderUtils.updateCronPart).toHaveBeenCalledWith(
'* * * * *', 0, '30'
'* * * * *',
0,
'30'
);
});
it('initializes advanced fields from currentValue when opened', () => {
renderBuilder({ currentValue: '5 4 * * 1' });
// The minute field (index 0) should display '5'
const minuteInput = within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
const minuteInput =
within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
expect(minuteInput).toHaveValue('5');
});
});
@ -416,7 +484,8 @@ describe('CronBuilder', () => {
// Switch to advanced mode by simulating tab onChange
// The Tabs mock renders but doesn't fire onChange on tab clicks,
// so we directly test that advanced panel input changes manualCron
const minuteInput = within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
const minuteInput =
within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
// The advanced panel's minute input value should be '5'
expect(minuteInput).toHaveValue('5');
});
@ -429,13 +498,15 @@ describe('CronBuilder', () => {
it('sets manualCron to currentValue when opened', () => {
renderBuilder({ currentValue: '*/15 * * * *' });
const minuteInput = within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
const minuteInput =
within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
expect(minuteInput).toHaveValue('*/15');
});
it('keeps default manualCron when currentValue is empty', () => {
renderBuilder({ currentValue: '' });
const minuteInput = within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
const minuteInput =
within(getAdvancedPanel()).getByLabelText('Minute (0-59)');
expect(minuteInput).toHaveValue('*');
});
@ -444,4 +515,4 @@ describe('CronBuilder', () => {
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
});
});

View file

@ -27,58 +27,58 @@ vi.mock('../../../utils/forms/DummyEpgUtils.js', () => ({
const fill = (tpl) =>
tpl
? Object.entries(allGroups).reduce(
(s, [k, v]) => s.replaceAll(`{${k}}`, v),
tpl
)
(s, [k, v]) => s.replaceAll(`{${k}}`, v),
tpl
)
: '';
return {
formattedTitle: fill(templates.titleTemplate),
formattedSubtitle: fill(templates.subtitleTemplate),
formattedDescription: fill(templates.descriptionTemplate),
formattedUpcomingTitle: fill(templates.upcomingTitleTemplate),
formattedUpcomingDescription: fill(templates.upcomingDescriptionTemplate),
formattedEndedTitle: fill(templates.endedTitleTemplate),
formattedEndedDescription: fill(templates.endedDescriptionTemplate),
formattedChannelLogoUrl: fill(templates.channelLogoUrl),
formattedProgramPosterUrl: fill(templates.programPosterUrl),
formattedTitle: fill(templates.titleTemplate),
formattedSubtitle: fill(templates.subtitleTemplate),
formattedDescription: fill(templates.descriptionTemplate),
formattedUpcomingTitle: fill(templates.upcomingTitleTemplate),
formattedUpcomingDescription: fill(templates.upcomingDescriptionTemplate),
formattedEndedTitle: fill(templates.endedTitleTemplate),
formattedEndedDescription: fill(templates.endedDescriptionTemplate),
formattedChannelLogoUrl: fill(templates.channelLogoUrl),
formattedProgramPosterUrl: fill(templates.programPosterUrl),
};
}),
buildCustomProperties: vi.fn((custom = {}) => ({
title_pattern: custom.title_pattern || '',
time_pattern: custom.time_pattern || '',
date_pattern: custom.date_pattern || '',
timezone: custom.timezone || 'US/Eastern',
output_timezone: custom.output_timezone || '',
program_duration: custom.program_duration || 180,
sample_title: custom.sample_title || '',
title_template: custom.title_template || '',
subtitle_template: custom.subtitle_template || '',
description_template: custom.description_template || '',
upcoming_title_template: custom.upcoming_title_template || '',
upcoming_description_template:custom.upcoming_description_template|| '',
ended_title_template: custom.ended_title_template || '',
ended_description_template: custom.ended_description_template || '',
fallback_title_template: custom.fallback_title_template || '',
fallback_description_template:custom.fallback_description_template|| '',
channel_logo_url: custom.channel_logo_url || '',
program_poster_url: custom.program_poster_url || '',
name_source: custom.name_source || 'channel',
stream_index: custom.stream_index || 1,
category: custom.category || '',
include_date: custom.include_date ?? true,
include_live: custom.include_live ?? false,
include_new: custom.include_new ?? false,
title_pattern: custom.title_pattern || '',
time_pattern: custom.time_pattern || '',
date_pattern: custom.date_pattern || '',
timezone: custom.timezone || 'US/Eastern',
output_timezone: custom.output_timezone || '',
program_duration: custom.program_duration || 180,
sample_title: custom.sample_title || '',
title_template: custom.title_template || '',
subtitle_template: custom.subtitle_template || '',
description_template: custom.description_template || '',
upcoming_title_template: custom.upcoming_title_template || '',
upcoming_description_template: custom.upcoming_description_template || '',
ended_title_template: custom.ended_title_template || '',
ended_description_template: custom.ended_description_template || '',
fallback_title_template: custom.fallback_title_template || '',
fallback_description_template: custom.fallback_description_template || '',
channel_logo_url: custom.channel_logo_url || '',
program_poster_url: custom.program_poster_url || '',
name_source: custom.name_source || 'channel',
stream_index: custom.stream_index || 1,
category: custom.category || '',
include_date: custom.include_date ?? true,
include_live: custom.include_live ?? false,
include_new: custom.include_new ?? false,
})),
buildTimePlaceholders: vi.fn((timeGroups) => {
if (!timeGroups || !timeGroups.hour) return {};
const hour = parseInt(timeGroups.hour, 10);
const hour = parseInt(timeGroups.hour, 10);
const minute = String(timeGroups.minute ?? '00').padStart(2, '0');
const ampm = timeGroups.ampm ? ` ${timeGroups.ampm}` : '';
const ampm = timeGroups.ampm ? ` ${timeGroups.ampm}` : '';
return {
starttime: `${hour}:${minute}${ampm}`,
starttime: `${hour}:${minute}${ampm}`,
starttime24: `${String(hour).padStart(2, '0')}:${minute}`,
endtime: `${(hour + 3) % 24}:${minute}${ampm}`,
endtime24: `${String((hour + 3) % 24).padStart(2, '0')}:${minute}`,
endtime: `${(hour + 3) % 24}:${minute}${ampm}`,
endtime24: `${String((hour + 3) % 24).padStart(2, '0')}:${minute}`,
};
}),
getDummyEpgFormInitialValues: vi.fn(() => ({
@ -86,30 +86,30 @@ vi.mock('../../../utils/forms/DummyEpgUtils.js', () => ({
is_active: true,
source_type: 'dummy',
custom_properties: {
title_pattern: '',
time_pattern: '',
date_pattern: '',
timezone: 'US/Eastern',
output_timezone: '',
program_duration: 180,
sample_title: '',
title_template: '',
subtitle_template: '',
description_template: '',
upcoming_title_template: '',
upcoming_description_template:'',
ended_title_template: '',
ended_description_template: '',
fallback_title_template: '',
fallback_description_template:'',
channel_logo_url: '',
program_poster_url: '',
name_source: 'channel',
stream_index: 1,
category: '',
include_date: true,
include_live: false,
include_new: false,
title_pattern: '',
time_pattern: '',
date_pattern: '',
timezone: 'US/Eastern',
output_timezone: '',
program_duration: 180,
sample_title: '',
title_template: '',
subtitle_template: '',
description_template: '',
upcoming_title_template: '',
upcoming_description_template: '',
ended_title_template: '',
ended_description_template: '',
fallback_title_template: '',
fallback_description_template: '',
channel_logo_url: '',
program_poster_url: '',
name_source: 'channel',
stream_index: 1,
category: '',
include_date: true,
include_live: false,
include_new: false,
},
})),
getTimezones: vi.fn().mockResolvedValue({
@ -118,19 +118,23 @@ vi.mock('../../../utils/forms/DummyEpgUtils.js', () => ({
matchPattern: vi.fn((pattern, sample, errorLabel = 'Pattern error') => {
if (!pattern) return { matched: false, groups: {}, error: null };
try {
const regex = new RegExp(pattern, 'u');
const regex = new RegExp(pattern, 'u');
const result = regex.exec(sample ?? '');
if (!result) return { matched: false, groups: {}, error: null };
const groups = result.groups ?? {};
return { matched: true, groups, error: null };
} catch {
return { matched: false, groups: {}, error: `${errorLabel}: invalid regex` };
return {
matched: false,
groups: {},
error: `${errorLabel}: invalid regex`,
};
}
}),
updateEPG: vi.fn(),
validateCustomNameSource: vi.fn(() => null),
validateCustomStreamIndex: vi.fn(() => null),
validateCustomTitlePattern: vi.fn(() => null),
validateCustomNameSource: vi.fn(() => null),
validateCustomStreamIndex: vi.fn(() => null),
validateCustomTitlePattern: vi.fn(() => null),
}));
// Mantine notifications
@ -141,13 +145,25 @@ vi.mock('@mantine/notifications', () => ({
// Mantine core
vi.mock('@mantine/core', async () => ({
Accordion: ({ children }) => <div data-testid="accordion">{children}</div>,
AccordionControl: ({ children }) => <button data-testid="accordion-control">{children}</button>,
AccordionItem: ({ children, value }) => <div data-testid={`accordion-item-${value}`}>{children}</div>,
AccordionPanel: ({ children }) => <div data-testid="accordion-panel">{children}</div>,
ActionIcon: ({ children, onClick }) => (
<button data-testid="action-icon" onClick={onClick}>{children}</button>
AccordionControl: ({ children }) => (
<button data-testid="accordion-control">{children}</button>
),
AccordionItem: ({ children, value }) => (
<div data-testid={`accordion-item-${value}`}>{children}</div>
),
AccordionPanel: ({ children }) => (
<div data-testid="accordion-panel">{children}</div>
),
ActionIcon: ({ children, onClick }) => (
<button data-testid="action-icon" onClick={onClick}>
{children}
</button>
),
Box: ({ children, mt, style }) => (
<div style={style} data-mt={mt}>
{children}
</div>
),
Box: ({ children, mt, style }) => <div style={style} data-mt={mt}>{children}</div>,
Button: ({ children, onClick, disabled, loading, type, color, variant }) => (
<button
onClick={onClick}
@ -177,7 +193,9 @@ vi.mock('@mantine/core', async () => ({
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
@ -196,7 +214,9 @@ vi.mock('@mantine/core', async () => ({
),
Paper: ({ children }) => <div data-testid="paper">{children}</div>,
Popover: ({ children }) => <div>{children}</div>,
PopoverDropdown: ({ children }) => <div data-testid="popover-dropdown">{children}</div>,
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
PopoverTarget: ({ children }) => <div>{children}</div>,
Select: ({ label, value, onChange, data, placeholder }) => (
<label>
@ -210,14 +230,20 @@ vi.mock('@mantine/core', async () => ({
{(data || []).map((opt) => {
const val = typeof opt === 'string' ? opt : opt.value;
const lab = typeof opt === 'string' ? opt : opt.label;
return <option key={val} value={val}>{lab}</option>;
return (
<option key={val} value={val}>
{lab}
</option>
);
})}
</select>
</label>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw, style }) => (
<span data-size={size} data-color={c} data-fw={fw} style={style}>{children}</span>
<span data-size={size} data-color={c} data-fw={fw} style={style}>
{children}
</span>
),
Textarea: ({ label, value, onChange, placeholder }) => (
<label>
@ -324,7 +350,10 @@ const defaultProps = (overrides = {}) => ({
describe('DummyEPGForm', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({ id: 2, name: 'New EPG' });
vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({
id: 2,
name: 'New EPG',
});
vi.mocked(DummyEpgUtils.updateEPG).mockResolvedValue({});
vi.mocked(DummyEpgUtils.getTimezones).mockResolvedValue({
timezones: ['US/Eastern', 'US/Pacific', 'UTC'],
@ -347,12 +376,16 @@ describe('DummyEPGForm', () => {
it('shows "Add Dummy EPG" title when no epg prop', () => {
render(<DummyEPGForm {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Create Dummy EPG');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Create Dummy EPG'
);
});
it('shows "Edit Dummy EPG" title when epg prop is provided', () => {
render(<DummyEPGForm {...defaultProps({ epg: makeEPG() })} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Dummy EPG');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Edit Dummy EPG'
);
});
it('pre-fills form name when editing an existing EPG', () => {
@ -395,12 +428,16 @@ describe('DummyEPGForm', () => {
it('renders timezone options after loading', async () => {
render(<DummyEPGForm {...defaultProps()} />);
await waitFor(() => {
expect(screen.getAllByRole('option', { name: 'US/Eastern' }).length).toBeGreaterThan(0);
expect(
screen.getAllByRole('option', { name: 'US/Eastern' }).length
).toBeGreaterThan(0);
});
});
it('shows fallback timezones and warning notification when getTimezones rejects', async () => {
vi.mocked(DummyEpgUtils.getTimezones).mockRejectedValueOnce(new Error('Network error'));
vi.mocked(DummyEpgUtils.getTimezones).mockRejectedValueOnce(
new Error('Network error')
);
render(<DummyEPGForm {...defaultProps()} />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
@ -409,7 +446,9 @@ describe('DummyEPGForm', () => {
});
// Fallback options should still be rendered
await waitFor(() => {
expect(screen.getAllByRole('option', { name: 'UTC' }).length).toBeGreaterThan(0);
expect(
screen.getAllByRole('option', { name: 'UTC' }).length
).toBeGreaterThan(0);
});
});
});
@ -420,13 +459,17 @@ describe('DummyEPGForm', () => {
it('shows the import select when dummyEpgs are available', () => {
setupMocks({ dummyEpgs: [makeTemplate()] });
render(<DummyEPGForm {...defaultProps()} />);
expect(screen.getByPlaceholderText(/Select a template/i)).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Select a template/i)
).toBeInTheDocument();
});
it('does not show import select when dummyEpgs is empty', () => {
setupMocks({ dummyEpgs: [] });
render(<DummyEPGForm {...defaultProps()} />);
expect(screen.queryByPlaceholderText(/Select a template/i)).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText(/Select a template/i)
).not.toBeInTheDocument();
});
it('calls buildCustomProperties and applyCustomState on template import', async () => {
@ -465,7 +508,9 @@ describe('DummyEPGForm', () => {
fireEvent.change(select, { target: { value: '99' } });
await waitFor(() => {
expect(screen.getByDisplayValue('My Template (Copy)')).toBeInTheDocument();
expect(
screen.getByDisplayValue('My Template (Copy)')
).toBeInTheDocument();
});
});
@ -551,7 +596,9 @@ describe('DummyEPGForm', () => {
});
render(<DummyEPGForm {...defaultProps({ epg })} />);
await waitFor(() => {
expect(screen.queryByText('(no template provided)')).not.toBeInTheDocument();
expect(
screen.queryByText('(no template provided)')
).not.toBeInTheDocument();
});
});
@ -559,8 +606,8 @@ describe('DummyEPGForm', () => {
const epg = makeEPG({
custom_properties: {
title_pattern: '',
date_pattern: '(?<month>\\w+)\\s+(?<day>\\d+)',
sample_title: 'Show Oct 17',
date_pattern: '(?<month>\\w+)\\s+(?<day>\\d+)',
sample_title: 'Show Oct 17',
},
});
render(<DummyEPGForm {...defaultProps({ epg })} />);
@ -573,14 +620,16 @@ describe('DummyEPGForm', () => {
const epg = makeEPG({
custom_properties: {
title_pattern: '(Show.+)',
time_pattern: '(?<hour>\\d+):(?<minute>\\d+)\\s*(?<ampm>AM|PM)',
sample_title: 'Show @ 9:00 PM',
timezone: 'US/Eastern',
time_pattern: '(?<hour>\\d+):(?<minute>\\d+)\\s*(?<ampm>AM|PM)',
sample_title: 'Show @ 9:00 PM',
timezone: 'US/Eastern',
},
});
render(<DummyEPGForm {...defaultProps({ epg })} />);
await waitFor(() => {
expect(screen.getByText(/available time placeholders/i)).toBeInTheDocument();
expect(
screen.getByText(/available time placeholders/i)
).toBeInTheDocument();
});
});
});
@ -638,7 +687,9 @@ describe('DummyEPGForm', () => {
});
it('shows error notification when addEPG rejects', async () => {
vi.mocked(DummyEpgUtils.addEPG).mockRejectedValueOnce(new Error('Server error'));
vi.mocked(DummyEpgUtils.addEPG).mockRejectedValueOnce(
new Error('Server error')
);
render(<DummyEPGForm {...defaultProps()} />);
const nameInput = screen.getByPlaceholderText('My Sports EPG');
@ -653,7 +704,9 @@ describe('DummyEPGForm', () => {
});
it('shows error notification when updateEPG rejects', async () => {
vi.mocked(DummyEpgUtils.updateEPG).mockRejectedValueOnce(new Error('Update failed'));
vi.mocked(DummyEpgUtils.updateEPG).mockRejectedValueOnce(
new Error('Update failed')
);
render(<DummyEPGForm {...defaultProps({ epg: makeEPG() })} />);
fireEvent.submit(document.querySelector('form'));
@ -687,7 +740,9 @@ describe('DummyEPGForm', () => {
fireEvent.submit(document.querySelector('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ color: 'red' }));
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red' })
);
});
expect(onClose).not.toHaveBeenCalled();
});
@ -743,4 +798,4 @@ describe('DummyEPGForm', () => {
expect(screen.getByDisplayValue('180')).toBeInTheDocument();
});
});
});
});

View file

@ -30,7 +30,9 @@ vi.mock('@mantine/form', () => ({
// Return the live object no spread so setFieldValue mutations are visible
getValues: vi.fn(() => values),
setValues: vi.fn((v) => Object.assign(values, v)),
setFieldValue: vi.fn((field, value) => { values[field] = value; }),
setFieldValue: vi.fn((field, value) => {
values[field] = value;
}),
reset: vi.fn(),
submitting: false,
onSubmit: vi.fn((handler) => (e) => {
@ -48,12 +50,23 @@ vi.mock('@mantine/form', () => ({
// ScheduleInput mock
vi.mock('../ScheduleInput', () => ({
default: ({ scheduleType, onScheduleTypeChange, onIntervalChange, onCronChange }) => (
default: ({
scheduleType,
onScheduleTypeChange,
onIntervalChange,
onCronChange,
}) => (
<div data-testid="schedule-input">
<button data-testid="set-interval" onClick={() => onScheduleTypeChange('interval')}>
<button
data-testid="set-interval"
onClick={() => onScheduleTypeChange('interval')}
>
Set Interval
</button>
<button data-testid="set-cron" onClick={() => onScheduleTypeChange('cron')}>
<button
data-testid="set-cron"
onClick={() => onScheduleTypeChange('cron')}
>
Set Cron
</button>
<input
@ -117,7 +130,11 @@ vi.mock('@mantine/core', async () => ({
{(data ?? []).map((opt) => {
const val = typeof opt === 'string' ? opt : opt.value;
const lbl = typeof opt === 'string' ? opt : opt.label;
return <option key={val} value={val}>{lbl}</option>;
return (
<option key={val} value={val}>
{lbl}
</option>
);
})}
</select>
),
@ -139,7 +156,16 @@ vi.mock('@mantine/core', async () => ({
{children}
</span>
),
TextInput: ({ label, value, onChange, placeholder, error, id, name, required }) => (
TextInput: ({
label,
value,
onChange,
placeholder,
error,
id,
name,
required,
}) => (
<div>
<label htmlFor={id || name}>{label}</label>
<input
@ -149,7 +175,10 @@ vi.mock('@mantine/core', async () => ({
placeholder={placeholder}
required={required}
onChange={(e) =>
onChange?.({ target: { value: e.target.value }, currentTarget: { value: e.target.value } })
onChange?.({
target: { value: e.target.value },
currentTarget: { value: e.target.value },
})
}
/>
{error && <span data-testid="input-error">{error}</span>}
@ -243,7 +272,9 @@ describe('EPG', () => {
it('renders a cancel/close button', () => {
render(<EPG {...defaultProps()} />);
expect(screen.getByRole('button', { name: /cancel|close/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /cancel|close/i })
).toBeInTheDocument();
});
});
@ -257,11 +288,20 @@ describe('EPG', () => {
key: vi.fn(),
getValues: vi.fn(() => ({ ...epg, cron_expression: '' })),
setValues: mockSetValues,
setFieldValue: vi.fn((field, value) => { epg[field] = value; }),
setFieldValue: vi.fn((field, value) => {
epg[field] = value;
}),
reset: vi.fn(),
submitting: false,
onSubmit: vi.fn((h) => (e) => { e?.preventDefault?.(); h({ ...epg }); }),
getInputProps: vi.fn((field) => ({ value: epg[field] ?? '', onChange: vi.fn(), error: null })),
onSubmit: vi.fn((h) => (e) => {
e?.preventDefault?.();
h({ ...epg });
}),
getInputProps: vi.fn((field) => ({
value: epg[field] ?? '',
onChange: vi.fn(),
error: null,
})),
});
render(<EPG {...defaultProps({ epg })} />);
@ -279,8 +319,15 @@ describe('EPG', () => {
setFieldValue: vi.fn(() => {}),
reset: vi.fn(),
submitting: false,
onSubmit: vi.fn((h) => (e) => { e?.preventDefault?.(); h({}); }),
getInputProps: vi.fn(() => ({ value: '', onChange: vi.fn(), error: null })),
onSubmit: vi.fn((h) => (e) => {
e?.preventDefault?.();
h({});
}),
getInputProps: vi.fn(() => ({
value: '',
onChange: vi.fn(),
error: null,
})),
});
render(<EPG {...defaultProps({ epg: null })} />);
@ -288,15 +335,22 @@ describe('EPG', () => {
});
it('sets scheduleType to "cron" when epg has a cron_expression', () => {
const epg = makeEPG({ cron_expression: '0 */6 * * *', refresh_interval: 0 });
const epg = makeEPG({
cron_expression: '0 */6 * * *',
refresh_interval: 0,
});
render(<EPG {...defaultProps({ epg })} />);
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent('cron');
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent(
'cron'
);
});
it('sets scheduleType to "interval" when epg has no cron_expression', () => {
const epg = makeEPG({ cron_expression: '', refresh_interval: 12 });
render(<EPG {...defaultProps({ epg })} />);
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent('interval');
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent(
'interval'
);
});
});
@ -331,20 +385,26 @@ describe('EPG', () => {
describe('schedule type toggling', () => {
it('scheduleType starts as "interval" for new EPG', () => {
render(<EPG {...defaultProps()} />);
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent('interval');
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent(
'interval'
);
});
it('updates scheduleType to "cron" when ScheduleInput fires onScheduleTypeChange', () => {
render(<EPG {...defaultProps()} />);
fireEvent.click(screen.getByTestId('set-cron'));
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent('cron');
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent(
'cron'
);
});
it('updates scheduleType back to "interval" from cron', () => {
render(<EPG {...defaultProps()} />);
fireEvent.click(screen.getByTestId('set-cron'));
fireEvent.click(screen.getByTestId('set-interval'));
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent('interval');
expect(screen.getByTestId('schedule-type-value')).toHaveTextContent(
'interval'
);
});
});
@ -415,16 +475,28 @@ describe('EPG', () => {
});
it('clears refresh_interval when schedule type is cron on submit', async () => {
const epg = makeEPG({ cron_expression: '0 * * * *', refresh_interval: 24 });
const epg = makeEPG({
cron_expression: '0 * * * *',
refresh_interval: 24,
});
vi.mocked(useForm).mockReturnValue({
key: vi.fn(),
getValues: vi.fn(() => ({ ...epg })),
setValues: vi.fn((v) => Object.assign(epg, v)),
setFieldValue: vi.fn((field, value) => { epg[field] = value; }),
setFieldValue: vi.fn((field, value) => {
epg[field] = value;
}),
reset: vi.fn(),
submitting: false,
onSubmit: vi.fn((h) => (e) => { e?.preventDefault?.(); h({ ...epg }); }),
getInputProps: vi.fn((field) => ({ value: epg[field] ?? '', onChange: vi.fn(), error: null })),
onSubmit: vi.fn((h) => (e) => {
e?.preventDefault?.();
h({ ...epg });
}),
getInputProps: vi.fn((field) => ({
value: epg[field] ?? '',
onChange: vi.fn(),
error: null,
})),
});
render(<EPG {...defaultProps({ epg })} />);
fireEvent.click(screen.getByText('Update EPG Source'));
@ -442,11 +514,20 @@ describe('EPG', () => {
key: vi.fn(),
getValues: vi.fn(() => ({ ...epg })),
setValues: vi.fn((v) => Object.assign(epg, v)),
setFieldValue: vi.fn((field, value) => { epg[field] = value; }),
setFieldValue: vi.fn((field, value) => {
epg[field] = value;
}),
reset: vi.fn(),
submitting: false,
onSubmit: vi.fn((h) => (e) => { e?.preventDefault?.(); h({ ...epg }); }),
getInputProps: vi.fn((field) => ({ value: epg[field] ?? '', onChange: vi.fn(), error: null })),
onSubmit: vi.fn((h) => (e) => {
e?.preventDefault?.();
h({ ...epg });
}),
getInputProps: vi.fn((field) => ({
value: epg[field] ?? '',
onChange: vi.fn(),
error: null,
})),
});
render(<EPG {...defaultProps({ epg })} />);
fireEvent.click(screen.getByText('Update EPG Source'));
@ -497,8 +578,15 @@ describe('EPG', () => {
setFieldValue: vi.fn(() => {}),
reset: mockReset,
submitting: false,
onSubmit: vi.fn((h) => (e) => { e?.preventDefault?.(); h({}); }),
getInputProps: vi.fn(() => ({ value: '', onChange: vi.fn(), error: null })),
onSubmit: vi.fn((h) => (e) => {
e?.preventDefault?.();
h({});
}),
getInputProps: vi.fn(() => ({
value: '',
onChange: vi.fn(),
error: null,
})),
});
render(<EPG {...defaultProps()} />);
fireEvent.click(screen.getByText('Create EPG Source'));
@ -513,7 +601,9 @@ describe('EPG', () => {
describe('active checkbox', () => {
it('renders the Active checkbox', () => {
render(<EPG {...defaultProps()} />);
expect(screen.getByTestId('checkbox-enable-this-epg-source')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-enable-this-epg-source')
).toBeInTheDocument();
});
});
});
});

View file

@ -614,8 +614,18 @@ describe('dateTimeUtils', () => {
it('should contain all lowercase month names', () => {
expect(dateTimeUtils.MONTH_NAMES).toEqual([
'january', 'february', 'march', 'april', 'may', 'june',
'july', 'august', 'september', 'october', 'november', 'december',
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
]);
});
});
@ -635,8 +645,18 @@ describe('dateTimeUtils', () => {
it('should contain all lowercase abbreviated month names', () => {
expect(dateTimeUtils.MONTH_ABBR).toEqual([
'jan', 'feb', 'mar', 'apr', 'may', 'jun',
'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
]);
});

View file

@ -183,4 +183,4 @@ export const buildEpgAssociations = async (
if (!epgData) return null;
return channelIds.map((id) => ({ channel_id: id, epg_data_id: epgData.id }));
};
};

View file

@ -94,4 +94,4 @@ export const handleEpgUpdate = async (
streams: channelStreams.map((stream) => stream.id),
});
}
};
};

View file

@ -72,4 +72,4 @@ export const parseApiError = (error) => {
(Object.keys(fieldErrors).length === 0 ? JSON.stringify(body) : '');
return { fieldErrors, apiError };
};
};

View file

@ -263,4 +263,4 @@ export const applyTemplates = (templateValues, groups, hasMatch) => {
});
return result;
};
};

View file

@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── API mock ───────────────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({ default: { refreshAccountInfo: vi.fn() } }));
vi.mock('../../../api.js', () => ({
default: { refreshAccountInfo: vi.fn() },
}));
// ──────────────────────────────────────────────────────────────────────────────
// Imports after mocks
@ -118,7 +120,9 @@ describe('AccountInfoModalUtils', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-10T00:00:00Z'));
// Jan 1, 2024 Unix timestamp
const past = String(Math.floor(new Date('2024-01-01T00:00:00Z').getTime() / 1000));
const past = String(
Math.floor(new Date('2024-01-01T00:00:00Z').getTime() / 1000)
);
expect(getTimeRemaining(past)).toBe('Expired');
});
@ -211,7 +215,9 @@ describe('AccountInfoModalUtils', () => {
it('propagates rejection from API.refreshAccountInfo', async () => {
const error = new Error('network error');
vi.mocked(API.refreshAccountInfo).mockRejectedValue(error);
await expect(refreshAccountInfo({ id: 'profile-123' })).rejects.toThrow('network error');
await expect(refreshAccountInfo({ id: 'profile-123' })).rejects.toThrow(
'network error'
);
});
it('calls API.refreshAccountInfo exactly once', async () => {
@ -227,4 +233,4 @@ describe('AccountInfoModalUtils', () => {
expect(API.refreshAccountInfo).toHaveBeenCalledWith(undefined);
});
});
});
});

View file

@ -39,7 +39,7 @@ describe('ChannelBatchUtils', () => {
vi.clearAllMocks();
});
// ── getChannelGroupChange ──────────────────────────────────────────────────────
// ── getChannelGroupChange ──────────────────────────────────────────────────────
describe('getChannelGroupChange', () => {
const channelGroups = {
@ -58,15 +58,19 @@ describe('ChannelBatchUtils', () => {
});
it('returns the group name when a valid group is selected', () => {
expect(getChannelGroupChange('1', channelGroups)).toBe('• Channel Group: Sports');
expect(getChannelGroupChange('1', channelGroups)).toBe(
'• Channel Group: Sports'
);
});
it('returns "Unknown" when group id is not found in channelGroups', () => {
expect(getChannelGroupChange('99', channelGroups)).toBe('• Channel Group: Unknown');
expect(getChannelGroupChange('99', channelGroups)).toBe(
'• Channel Group: Unknown'
);
});
});
// ── getLogoChange ─────────────────────────────────────────────────────────────
// ── getLogoChange ─────────────────────────────────────────────────────────────
describe('getLogoChange', () => {
const channelLogos = {
@ -97,7 +101,7 @@ describe('ChannelBatchUtils', () => {
});
});
// ── getStreamProfileChange ────────────────────────────────────────────────────
// ── getStreamProfileChange ────────────────────────────────────────────────────
describe('getStreamProfileChange', () => {
const streamProfiles = [
@ -116,23 +120,31 @@ describe('ChannelBatchUtils', () => {
});
it('returns "Use Default" message when streamProfileId is "0"', () => {
expect(getStreamProfileChange('0', streamProfiles)).toBe('• Stream Profile: Use Default');
expect(getStreamProfileChange('0', streamProfiles)).toBe(
'• Stream Profile: Use Default'
);
});
it('returns the profile name when a valid profile is selected', () => {
expect(getStreamProfileChange('1', streamProfiles)).toBe('• Stream Profile: HD Profile');
expect(getStreamProfileChange('1', streamProfiles)).toBe(
'• Stream Profile: HD Profile'
);
});
it('matches profile id using string coercion', () => {
expect(getStreamProfileChange(2, streamProfiles)).toBe('• Stream Profile: SD Profile');
expect(getStreamProfileChange(2, streamProfiles)).toBe(
'• Stream Profile: SD Profile'
);
});
it('returns "Selected Profile" fallback when profile is not found', () => {
expect(getStreamProfileChange('99', streamProfiles)).toBe('• Stream Profile: Selected Profile');
expect(getStreamProfileChange('99', streamProfiles)).toBe(
'• Stream Profile: Selected Profile'
);
});
});
// ── getUserLevelChange ────────────────────────────────────────────────────────
// ── getUserLevelChange ────────────────────────────────────────────────────────
describe('getUserLevelChange', () => {
const userLevelLabels = {
@ -151,15 +163,19 @@ describe('ChannelBatchUtils', () => {
});
it('returns the label when a valid user level is selected', () => {
expect(getUserLevelChange('1', userLevelLabels)).toBe('• User Level: Admin');
expect(getUserLevelChange('1', userLevelLabels)).toBe(
'• User Level: Admin'
);
});
it('returns the raw userLevel value when label is not found', () => {
expect(getUserLevelChange('99', userLevelLabels)).toBe('• User Level: 99');
expect(getUserLevelChange('99', userLevelLabels)).toBe(
'• User Level: 99'
);
});
});
// ── getMatureContentChange ────────────────────────────────────────────────────
// ── getMatureContentChange ────────────────────────────────────────────────────
describe('getMatureContentChange', () => {
it('returns null when isAdult is falsy', () => {
@ -181,7 +197,7 @@ describe('ChannelBatchUtils', () => {
});
});
// ── getRegexNameChange ────────────────────────────────────────────────────────
// ── getRegexNameChange ────────────────────────────────────────────────────────
describe('getRegexNameChange', () => {
it('returns null when regexFind is falsy', () => {
@ -210,7 +226,7 @@ describe('ChannelBatchUtils', () => {
});
});
// ── getEpgChange ──────────────────────────────────────────────────────────────
// ── getEpgChange ──────────────────────────────────────────────────────────────
describe('getEpgChange', () => {
const epgs = {
@ -225,7 +241,9 @@ describe('ChannelBatchUtils', () => {
});
it('returns clear assignment message when selectedDummyEpgId is "clear"', () => {
expect(getEpgChange('clear', epgs)).toBe('• EPG: Clear Assignment (use default dummy)');
expect(getEpgChange('clear', epgs)).toBe(
'• EPG: Clear Assignment (use default dummy)'
);
});
it('returns the EPG name when a valid EPG is selected', () => {
@ -237,7 +255,7 @@ describe('ChannelBatchUtils', () => {
});
});
// ── API wrappers ──────────────────────────────────────────────────────────────
// ── API wrappers ──────────────────────────────────────────────────────────────
describe('updateChannels', () => {
it('calls API.updateChannels with channelIds and values', () => {
@ -257,17 +275,32 @@ describe('ChannelBatchUtils', () => {
describe('bulkRegexRenameChannels', () => {
it('calls API.bulkRegexRenameChannels with all arguments', () => {
bulkRegexRenameChannels([1, 2], 'find', 'replace', 'g');
expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith([1, 2], 'find', 'replace', 'g');
expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith(
[1, 2],
'find',
'replace',
'g'
);
});
it('passes empty string when regexReplace is null', () => {
bulkRegexRenameChannels([1], 'find', null, 'g');
expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith([1], 'find', '', 'g');
expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith(
[1],
'find',
'',
'g'
);
});
it('passes empty string when regexReplace is undefined', () => {
bulkRegexRenameChannels([1], 'find', undefined, 'g');
expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith([1], 'find', '', 'g');
expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith(
[1],
'find',
'',
'g'
);
});
});
@ -307,7 +340,7 @@ describe('ChannelBatchUtils', () => {
});
});
// ── computeRegexPreview ───────────────────────────────────────────────────────
// ── computeRegexPreview ───────────────────────────────────────────────────────
describe('computeRegexPreview', () => {
const nameById = {
@ -343,7 +376,12 @@ describe('ChannelBatchUtils', () => {
});
it('only includes ids that exist in nameById', () => {
const result = computeRegexPreview([1, 99, 2], nameById, 'HBO', 'Cinemax');
const result = computeRegexPreview(
[1, 99, 2],
nameById,
'HBO',
'Cinemax'
);
expect(result).toHaveLength(2);
});
@ -352,7 +390,13 @@ describe('ChannelBatchUtils', () => {
Array.from({ length: 30 }, (_, i) => [i + 1, `HBO ${i + 1}`])
);
const ids = Array.from({ length: 30 }, (_, i) => i + 1);
const result = computeRegexPreview(ids, largeNameById, 'HBO', 'Cinemax', 5);
const result = computeRegexPreview(
ids,
largeNameById,
'HBO',
'Cinemax',
5
);
expect(result).toHaveLength(5);
});
@ -377,7 +421,7 @@ describe('ChannelBatchUtils', () => {
});
});
// ── buildSubmitValues ─────────────────────────────────────────────────────────
// ── buildSubmitValues ─────────────────────────────────────────────────────────
describe('buildSubmitValues', () => {
const baseFormValues = {
@ -395,47 +439,83 @@ describe('ChannelBatchUtils', () => {
});
it('removes stream_profile_id when it is "-1"', () => {
const result = buildSubmitValues({ ...baseFormValues, stream_profile_id: '-1' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, stream_profile_id: '-1' },
null,
null
);
expect(result).not.toHaveProperty('stream_profile_id');
});
it('removes stream_profile_id when it is falsy', () => {
const result = buildSubmitValues({ ...baseFormValues, stream_profile_id: '' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, stream_profile_id: '' },
null,
null
);
expect(result).not.toHaveProperty('stream_profile_id');
});
it('converts stream_profile_id "0" to null (use default)', () => {
const result = buildSubmitValues({ ...baseFormValues, stream_profile_id: '0' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, stream_profile_id: '0' },
null,
null
);
expect(result.stream_profile_id).toBeNull();
});
it('preserves a valid stream_profile_id string', () => {
const result = buildSubmitValues({ ...baseFormValues, stream_profile_id: '3' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, stream_profile_id: '3' },
null,
null
);
expect(result.stream_profile_id).toBe('3');
});
it('removes user_level when it is "-1"', () => {
const result = buildSubmitValues({ ...baseFormValues, user_level: '-1' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, user_level: '-1' },
null,
null
);
expect(result).not.toHaveProperty('user_level');
});
it('preserves a valid user_level', () => {
const result = buildSubmitValues({ ...baseFormValues, user_level: '2' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, user_level: '2' },
null,
null
);
expect(result.user_level).toBe('2');
});
it('removes is_adult when it is "-1"', () => {
const result = buildSubmitValues({ ...baseFormValues, is_adult: '-1' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, is_adult: '-1' },
null,
null
);
expect(result).not.toHaveProperty('is_adult');
});
it('converts is_adult "true" to boolean true', () => {
const result = buildSubmitValues({ ...baseFormValues, is_adult: 'true' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, is_adult: 'true' },
null,
null
);
expect(result.is_adult).toBe(true);
});
it('converts is_adult "false" to boolean false', () => {
const result = buildSubmitValues({ ...baseFormValues, is_adult: 'false' }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, is_adult: 'false' },
null,
null
);
expect(result.is_adult).toBe(false);
});
@ -445,12 +525,20 @@ describe('ChannelBatchUtils', () => {
});
it('removes channel_group_id when selectedChannelGroup is null', () => {
const result = buildSubmitValues({ ...baseFormValues, channel_group_id: 99 }, null, null);
const result = buildSubmitValues(
{ ...baseFormValues, channel_group_id: 99 },
null,
null
);
expect(result).not.toHaveProperty('channel_group_id');
});
it('removes channel_group_id when selectedChannelGroup is "-1"', () => {
const result = buildSubmitValues({ ...baseFormValues, channel_group_id: 99 }, '-1', null);
const result = buildSubmitValues(
{ ...baseFormValues, channel_group_id: 99 },
'-1',
null
);
expect(result).not.toHaveProperty('channel_group_id');
});
@ -481,7 +569,7 @@ describe('ChannelBatchUtils', () => {
});
});
// ── buildEpgAssociations ──────────────────────────────────────────────────────
// ── buildEpgAssociations ──────────────────────────────────────────────────────
describe('buildEpgAssociations', () => {
const channelIds = [1, 2, 3];
@ -491,18 +579,27 @@ describe('ChannelBatchUtils', () => {
11: { name: 'Empty EPG', epg_data_count: 0 },
};
const tvgs = [
{ id: 42, epg_source: 10 },
];
const tvgs = [{ id: 42, epg_source: 10 }];
it('returns null when selectedDummyEpgId is falsy', async () => {
await expect(buildEpgAssociations(null, channelIds, epgs, tvgs)).resolves.toBeNull();
await expect(buildEpgAssociations('', channelIds, epgs, tvgs)).resolves.toBeNull();
await expect(buildEpgAssociations(undefined, channelIds, epgs, tvgs)).resolves.toBeNull();
await expect(
buildEpgAssociations(null, channelIds, epgs, tvgs)
).resolves.toBeNull();
await expect(
buildEpgAssociations('', channelIds, epgs, tvgs)
).resolves.toBeNull();
await expect(
buildEpgAssociations(undefined, channelIds, epgs, tvgs)
).resolves.toBeNull();
});
it('returns clear associations when selectedDummyEpgId is "clear"', async () => {
const result = await buildEpgAssociations('clear', channelIds, epgs, tvgs);
const result = await buildEpgAssociations(
'clear',
channelIds,
epgs,
tvgs
);
expect(result).toEqual([
{ channel_id: 1, epg_data_id: null },
{ channel_id: 2, epg_data_id: null },
@ -531,9 +628,7 @@ describe('ChannelBatchUtils', () => {
});
it('calls getEpgData when tvgs does not contain a matching epg_source', async () => {
vi.mocked(API.getEPGData).mockResolvedValue([
{ id: 55, epg_source: 10 },
]);
vi.mocked(API.getEPGData).mockResolvedValue([{ id: 55, epg_source: 10 }]);
const result = await buildEpgAssociations('10', channelIds, epgs, []);
expect(API.getEPGData).toHaveBeenCalled();
expect(result).toEqual([

View file

@ -238,48 +238,85 @@ describe('ChannelUtils', () => {
describe('getFormattedValues', () => {
it('converts "0" stream_profile_id to null', () => {
const result = getFormattedValues({ stream_profile_id: '0', tvg_id: 'x', tvc_guide_stationid: 'y' });
const result = getFormattedValues({
stream_profile_id: '0',
tvg_id: 'x',
tvc_guide_stationid: 'y',
});
expect(result.stream_profile_id).toBeNull();
});
it('converts empty string stream_profile_id to null', () => {
const result = getFormattedValues({ stream_profile_id: '', tvg_id: 'x', tvc_guide_stationid: 'y' });
const result = getFormattedValues({
stream_profile_id: '',
tvg_id: 'x',
tvc_guide_stationid: 'y',
});
expect(result.stream_profile_id).toBeNull();
});
it('preserves non-zero stream_profile_id', () => {
const result = getFormattedValues({ stream_profile_id: '5', tvg_id: 'x', tvc_guide_stationid: 'y' });
const result = getFormattedValues({
stream_profile_id: '5',
tvg_id: 'x',
tvc_guide_stationid: 'y',
});
expect(result.stream_profile_id).toBe('5');
});
it('converts empty tvg_id to null', () => {
const result = getFormattedValues({ stream_profile_id: '1', tvg_id: '', tvc_guide_stationid: 'y' });
const result = getFormattedValues({
stream_profile_id: '1',
tvg_id: '',
tvc_guide_stationid: 'y',
});
expect(result.tvg_id).toBeNull();
});
it('preserves non-empty tvg_id', () => {
const result = getFormattedValues({ stream_profile_id: '1', tvg_id: 'hbo.us', tvc_guide_stationid: 'y' });
const result = getFormattedValues({
stream_profile_id: '1',
tvg_id: 'hbo.us',
tvc_guide_stationid: 'y',
});
expect(result.tvg_id).toBe('hbo.us');
});
it('converts empty tvc_guide_stationid to null', () => {
const result = getFormattedValues({ stream_profile_id: '1', tvg_id: 'x', tvc_guide_stationid: '' });
const result = getFormattedValues({
stream_profile_id: '1',
tvg_id: 'x',
tvc_guide_stationid: '',
});
expect(result.tvc_guide_stationid).toBeNull();
});
it('preserves non-empty tvc_guide_stationid', () => {
const result = getFormattedValues({ stream_profile_id: '1', tvg_id: 'x', tvc_guide_stationid: 'hbo-station' });
const result = getFormattedValues({
stream_profile_id: '1',
tvg_id: 'x',
tvc_guide_stationid: 'hbo-station',
});
expect(result.tvc_guide_stationid).toBe('hbo-station');
});
it('does not mutate the original values object', () => {
const values = { stream_profile_id: '0', tvg_id: '', tvc_guide_stationid: '' };
const values = {
stream_profile_id: '0',
tvg_id: '',
tvc_guide_stationid: '',
};
getFormattedValues(values);
expect(values.stream_profile_id).toBe('0');
});
it('passes through unrelated fields unchanged', () => {
const result = getFormattedValues({ stream_profile_id: '1', tvg_id: 'x', tvc_guide_stationid: 'y', name: 'HBO' });
const result = getFormattedValues({
stream_profile_id: '1',
tvg_id: 'x',
tvc_guide_stationid: 'y',
name: 'HBO',
});
expect(result.name).toBe('HBO');
});
});
@ -314,7 +351,12 @@ describe('ChannelUtils', () => {
it('calls API.setChannelEPG with channel id and new epg_data_id', async () => {
const channel = makeChannel({ epg_data_id: 'epg-old' });
const values = makeValues({ epg_data_id: 'epg-new' });
await handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-new' }), makeChannelStreams());
await handleEpgUpdate(
channel,
values,
makeFormattedValues({ epg_data_id: 'epg-new' }),
makeChannelStreams()
);
expect(API.setChannelEPG).toHaveBeenCalledWith('ch-1', 'epg-new');
});
@ -338,7 +380,12 @@ describe('ChannelUtils', () => {
const channel = makeChannel({ epg_data_id: 'epg-old' });
const values = makeValues({ epg_data_id: 'epg-new' });
// Only epg_data_id in formatted — after stripping it, nothing remains
await handleEpgUpdate(channel, values, { epg_data_id: 'epg-new' }, makeChannelStreams());
await handleEpgUpdate(
channel,
values,
{ epg_data_id: 'epg-new' },
makeChannelStreams()
);
expect(API.updateChannel).not.toHaveBeenCalled();
});
});
@ -347,7 +394,12 @@ describe('ChannelUtils', () => {
it('does not call API.setChannelEPG', async () => {
const channel = makeChannel({ epg_data_id: 'epg-1' });
const values = makeValues({ epg_data_id: 'epg-1' });
await handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-1' }), makeChannelStreams());
await handleEpgUpdate(
channel,
values,
makeFormattedValues({ epg_data_id: 'epg-1' }),
makeChannelStreams()
);
expect(API.setChannelEPG).not.toHaveBeenCalled();
});
@ -366,7 +418,12 @@ describe('ChannelUtils', () => {
it('handles empty channel streams array', async () => {
const channel = makeChannel({ epg_data_id: 'epg-1' });
const values = makeValues({ epg_data_id: 'epg-1' });
await handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-1' }), []);
await handleEpgUpdate(
channel,
values,
makeFormattedValues({ epg_data_id: 'epg-1' }),
[]
);
expect(API.updateChannel).toHaveBeenCalledWith(
expect.objectContaining({ streams: [] })
);
@ -378,7 +435,12 @@ describe('ChannelUtils', () => {
const channel = makeChannel({ epg_data_id: 'epg-old' });
const values = makeValues({ epg_data_id: 'epg-new' });
await expect(
handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-new' }), makeChannelStreams())
handleEpgUpdate(
channel,
values,
makeFormattedValues({ epg_data_id: 'epg-new' }),
makeChannelStreams()
)
).rejects.toThrow('EPG error');
});
@ -387,8 +449,13 @@ describe('ChannelUtils', () => {
const channel = makeChannel({ epg_data_id: 'epg-1' });
const values = makeValues({ epg_data_id: 'epg-1' });
await expect(
handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-1' }), makeChannelStreams())
handleEpgUpdate(
channel,
values,
makeFormattedValues({ epg_data_id: 'epg-1' }),
makeChannelStreams()
)
).rejects.toThrow('Update error');
});
});
});
});

View file

@ -73,7 +73,9 @@ describe('ConnectionUtils', () => {
it('calls API.createConnectIntegration with the correct payload', async () => {
const values = makeValues();
const config = { url: 'https://example.com/hook' };
vi.mocked(API.createConnectIntegration).mockResolvedValue({ id: 'new-1' });
vi.mocked(API.createConnectIntegration).mockResolvedValue({
id: 'new-1',
});
await createConnectIntegration(values, config);
@ -86,14 +88,20 @@ describe('ConnectionUtils', () => {
});
it('returns the API response', async () => {
vi.mocked(API.createConnectIntegration).mockResolvedValue({ id: 'new-1' });
vi.mocked(API.createConnectIntegration).mockResolvedValue({
id: 'new-1',
});
const result = await createConnectIntegration(makeValues(), {});
expect(result).toEqual({ id: 'new-1' });
});
it('propagates API errors', async () => {
vi.mocked(API.createConnectIntegration).mockRejectedValue(new Error('Network error'));
await expect(createConnectIntegration(makeValues(), {})).rejects.toThrow('Network error');
vi.mocked(API.createConnectIntegration).mockRejectedValue(
new Error('Network error')
);
await expect(createConnectIntegration(makeValues(), {})).rejects.toThrow(
'Network error'
);
});
});
@ -104,7 +112,9 @@ describe('ConnectionUtils', () => {
const connection = makeConnection();
const values = makeValues();
const config = { url: 'https://example.com/hook' };
vi.mocked(API.updateConnectIntegration).mockResolvedValue({ id: 'conn-1' });
vi.mocked(API.updateConnectIntegration).mockResolvedValue({
id: 'conn-1',
});
await updateConnectIntegration(connection, values, config);
@ -117,13 +127,22 @@ describe('ConnectionUtils', () => {
});
it('returns the API response', async () => {
vi.mocked(API.updateConnectIntegration).mockResolvedValue({ id: 'conn-1', name: 'Updated' });
const result = await updateConnectIntegration(makeConnection(), makeValues(), {});
vi.mocked(API.updateConnectIntegration).mockResolvedValue({
id: 'conn-1',
name: 'Updated',
});
const result = await updateConnectIntegration(
makeConnection(),
makeValues(),
{}
);
expect(result).toEqual({ id: 'conn-1', name: 'Updated' });
});
it('propagates API errors', async () => {
vi.mocked(API.updateConnectIntegration).mockRejectedValue(new Error('Server error'));
vi.mocked(API.updateConnectIntegration).mockRejectedValue(
new Error('Server error')
);
await expect(
updateConnectIntegration(makeConnection(), makeValues(), {})
).rejects.toThrow('Server error');
@ -135,7 +154,9 @@ describe('ConnectionUtils', () => {
describe('setConnectSubscriptions', () => {
it('calls API.setConnectSubscriptions with connection.id and subs', async () => {
const connection = makeConnection();
const subs = [{ event: 'channel_added', enabled: true, payload_template: null }];
const subs = [
{ event: 'channel_added', enabled: true, payload_template: null },
];
vi.mocked(API.setConnectSubscriptions).mockResolvedValue(undefined);
await setConnectSubscriptions(connection, subs);
@ -144,8 +165,12 @@ describe('ConnectionUtils', () => {
});
it('propagates API errors', async () => {
vi.mocked(API.setConnectSubscriptions).mockRejectedValue(new Error('Failed'));
await expect(setConnectSubscriptions(makeConnection(), [])).rejects.toThrow('Failed');
vi.mocked(API.setConnectSubscriptions).mockRejectedValue(
new Error('Failed')
);
await expect(
setConnectSubscriptions(makeConnection(), [])
).rejects.toThrow('Failed');
});
});
@ -154,12 +179,20 @@ describe('ConnectionUtils', () => {
describe('buildConfig', () => {
describe('webhook type', () => {
it('returns config with url only when no headers are provided', () => {
const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' });
expect(buildConfig(values, [])).toEqual({ url: 'https://example.com/hook' });
const values = makeValues({
type: 'webhook',
url: 'https://example.com/hook',
});
expect(buildConfig(values, [])).toEqual({
url: 'https://example.com/hook',
});
});
it('includes headers when non-empty key/value pairs are present', () => {
const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' });
const values = makeValues({
type: 'webhook',
url: 'https://example.com/hook',
});
const headers = [{ key: 'Authorization', value: 'Bearer token' }];
expect(buildConfig(values, headers)).toEqual({
url: 'https://example.com/hook',
@ -168,7 +201,10 @@ describe('ConnectionUtils', () => {
});
it('omits headers with blank keys', () => {
const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' });
const values = makeValues({
type: 'webhook',
url: 'https://example.com/hook',
});
const headers = [
{ key: '', value: 'should-be-ignored' },
{ key: ' ', value: 'also-ignored' },
@ -181,14 +217,20 @@ describe('ConnectionUtils', () => {
});
it('omits headers property entirely when all keys are blank', () => {
const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' });
const values = makeValues({
type: 'webhook',
url: 'https://example.com/hook',
});
const headers = [{ key: '', value: 'ignored' }];
const config = buildConfig(values, headers);
expect(config).not.toHaveProperty('headers');
});
it('supports multiple headers', () => {
const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' });
const values = makeValues({
type: 'webhook',
url: 'https://example.com/hook',
});
const headers = [
{ key: 'X-One', value: '1' },
{ key: 'X-Two', value: '2' },
@ -202,12 +244,20 @@ describe('ConnectionUtils', () => {
describe('script type', () => {
it('returns config with path from script_path', () => {
const values = makeValues({ type: 'script', script_path: '/usr/local/bin/notify.sh' });
expect(buildConfig(values, [])).toEqual({ path: '/usr/local/bin/notify.sh' });
const values = makeValues({
type: 'script',
script_path: '/usr/local/bin/notify.sh',
});
expect(buildConfig(values, [])).toEqual({
path: '/usr/local/bin/notify.sh',
});
});
it('ignores headers for script type', () => {
const values = makeValues({ type: 'script', script_path: '/usr/bin/run.sh' });
const values = makeValues({
type: 'script',
script_path: '/usr/bin/run.sh',
});
const headers = [{ key: 'Authorization', value: 'Bearer token' }];
const config = buildConfig(values, headers);
expect(config).not.toHaveProperty('headers');
@ -262,20 +312,31 @@ describe('ConnectionUtils', () => {
describe('parseApiError', () => {
it('returns message when error has no body', () => {
const error = { message: 'Network failure' };
expect(parseApiError(error)).toEqual({ fieldErrors: {}, apiError: 'Network failure' });
expect(parseApiError(error)).toEqual({
fieldErrors: {},
apiError: 'Network failure',
});
});
it('returns "Unknown error" when error has no body and no message', () => {
expect(parseApiError({})).toEqual({ fieldErrors: {}, apiError: 'Unknown error' });
expect(parseApiError({})).toEqual({
fieldErrors: {},
apiError: 'Unknown error',
});
});
it('returns message when body is a string (not an object)', () => {
const error = { body: 'Bad Request', message: 'HTTP 400' };
expect(parseApiError(error)).toEqual({ fieldErrors: {}, apiError: 'HTTP 400' });
expect(parseApiError(error)).toEqual({
fieldErrors: {},
apiError: 'HTTP 400',
});
});
it('extracts known field errors from body', () => {
const error = { body: { name: 'This field is required.', type: 'Invalid choice.' } };
const error = {
body: { name: 'This field is required.', type: 'Invalid choice.' },
};
const { fieldErrors } = parseApiError(error);
expect(fieldErrors).toEqual({
name: 'This field is required.',
@ -302,7 +363,9 @@ describe('ConnectionUtils', () => {
});
it('prefers non_field_errors over detail', () => {
const error = { body: { non_field_errors: 'NF error', detail: 'Detail error' } };
const error = {
body: { non_field_errors: 'NF error', detail: 'Detail error' },
};
const { apiError } = parseApiError(error);
expect(apiError).toBe('NF error');
});
@ -320,11 +383,17 @@ describe('ConnectionUtils', () => {
});
it('handles null error gracefully', () => {
expect(parseApiError(null)).toEqual({ fieldErrors: {}, apiError: 'Unknown error' });
expect(parseApiError(null)).toEqual({
fieldErrors: {},
apiError: 'Unknown error',
});
});
it('handles undefined error gracefully', () => {
expect(parseApiError(undefined)).toEqual({ fieldErrors: {}, apiError: 'Unknown error' });
expect(parseApiError(undefined)).toEqual({
fieldErrors: {},
apiError: 'Unknown error',
});
});
});
});
});

View file

@ -33,7 +33,7 @@ describe('CronBuilderUtils', () => {
});
});
// ── DAYS_OF_WEEK ───────────────────────────────────────────────────────────────
// ── DAYS_OF_WEEK ───────────────────────────────────────────────────────────────
describe('DAYS_OF_WEEK', () => {
it('contains 8 entries (wildcard + 7 days)', () => {
@ -53,11 +53,19 @@ describe('CronBuilderUtils', () => {
it('numeric entries run 0-6', () => {
const numeric = DAYS_OF_WEEK.filter((d) => d.value !== '*');
expect(numeric.map((d) => d.value)).toEqual(['0', '1', '2', '3', '4', '5', '6']);
expect(numeric.map((d) => d.value)).toEqual([
'0',
'1',
'2',
'3',
'4',
'5',
'6',
]);
});
});
// ── FREQUENCY_OPTIONS ──────────────────────────────────────────────────────────
// ── FREQUENCY_OPTIONS ──────────────────────────────────────────────────────────
describe('FREQUENCY_OPTIONS', () => {
it('contains exactly 4 options', () => {
@ -77,7 +85,7 @@ describe('CronBuilderUtils', () => {
});
});
// ── CRON_FIELDS ────────────────────────────────────────────────────────────────
// ── CRON_FIELDS ────────────────────────────────────────────────────────────────
describe('CRON_FIELDS', () => {
it('contains exactly 5 fields', () => {
@ -97,7 +105,7 @@ describe('CronBuilderUtils', () => {
});
});
// ── buildCron ──────────────────────────────────────────────────────────────────
// ── buildCron ──────────────────────────────────────────────────────────────────
describe('buildCron', () => {
describe('hourly', () => {
@ -171,7 +179,7 @@ describe('CronBuilderUtils', () => {
});
});
// ── parseCronPreset ────────────────────────────────────────────────────────────
// ── parseCronPreset ────────────────────────────────────────────────────────────
describe('parseCronPreset', () => {
describe('hourly detection', () => {
@ -279,7 +287,7 @@ describe('CronBuilderUtils', () => {
});
});
// ── updateCronPart ─────────────────────────────────────────────────────────────
// ── updateCronPart ─────────────────────────────────────────────────────────────
describe('updateCronPart', () => {
describe('valid 5-part cron', () => {
@ -342,7 +350,9 @@ describe('CronBuilderUtils', () => {
});
it('accepts comma-separated values like 0,15,30,45', () => {
expect(updateCronPart('* * * * *', 0, '0,15,30,45')).toBe('0,15,30,45 * * * *');
expect(updateCronPart('* * * * *', 0, '0,15,30,45')).toBe(
'0,15,30,45 * * * *'
);
});
});
});

View file

@ -3,27 +3,52 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Module mocks (must be before imports) ─────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
getTimezones: vi.fn(),
addEPG: vi.fn(),
updateEPG: vi.fn(),
getTimezones: vi.fn(),
addEPG: vi.fn(),
updateEPG: vi.fn(),
},
}));
vi.mock('../../dateTimeUtils.js', () => ({
format: vi.fn(),
getDay: vi.fn(),
getHour: vi.fn(),
getMinute: vi.fn(),
getMonth: vi.fn(),
getNow: vi.fn(),
getYear: vi.fn(),
MONTH_ABBR: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
MONTH_NAMES: ['January','February','March','April','May','June',
'July','August','September','October','November','December'],
setHour: vi.fn((dt) => dt),
setMinute: vi.fn((dt) => dt),
setSecond: vi.fn((dt) => dt),
setTz: vi.fn(),
format: vi.fn(),
getDay: vi.fn(),
getHour: vi.fn(),
getMinute: vi.fn(),
getMonth: vi.fn(),
getNow: vi.fn(),
getYear: vi.fn(),
MONTH_ABBR: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
MONTH_NAMES: [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
],
setHour: vi.fn((dt) => dt),
setMinute: vi.fn((dt) => dt),
setSecond: vi.fn((dt) => dt),
setTz: vi.fn(),
}));
// ── Imports after mocks ────────────────────────────────────────────────────────
@ -55,30 +80,30 @@ const makeEPG = (overrides = {}) => ({
});
const makeCustom = (overrides = {}) => ({
title_pattern: '(?<title>.+)',
time_pattern: '(?<hour>\\d+):(?<minute>\\d+)',
date_pattern: '',
timezone: 'US/Eastern',
output_timezone: '',
program_duration: 180,
sample_title: 'Test Show 9:00 PM',
title_template: '{title}',
subtitle_template: '',
description_template: '',
upcoming_title_template: '',
title_pattern: '(?<title>.+)',
time_pattern: '(?<hour>\\d+):(?<minute>\\d+)',
date_pattern: '',
timezone: 'US/Eastern',
output_timezone: '',
program_duration: 180,
sample_title: 'Test Show 9:00 PM',
title_template: '{title}',
subtitle_template: '',
description_template: '',
upcoming_title_template: '',
upcoming_description_template: '',
ended_title_template: '',
ended_description_template: '',
fallback_title_template: '',
ended_title_template: '',
ended_description_template: '',
fallback_title_template: '',
fallback_description_template: '',
channel_logo_url: '',
program_poster_url: '',
name_source: 'channel',
stream_index: 1,
category: '',
include_date: true,
include_live: false,
include_new: false,
channel_logo_url: '',
program_poster_url: '',
name_source: 'channel',
stream_index: 1,
category: '',
include_date: true,
include_live: false,
include_new: false,
...overrides,
});
@ -86,7 +111,6 @@ const makeCustom = (overrides = {}) => ({
// Tests
// ──────────────────────────────────────────────────────────────────────────────
describe('DummyEpgUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
@ -94,7 +118,9 @@ describe('DummyEpgUtils', () => {
// ── getTimezones ───────────────────────────────────────────────────────────
describe('getTimezones', () => {
it('calls API.getTimezones and returns its result', async () => {
vi.mocked(API.getTimezones).mockResolvedValue({ timezones: ['UTC', 'US/Eastern'] });
vi.mocked(API.getTimezones).mockResolvedValue({
timezones: ['UTC', 'US/Eastern'],
});
const result = await getTimezones();
expect(API.getTimezones).toHaveBeenCalledTimes(1);
expect(result).toEqual({ timezones: ['UTC', 'US/Eastern'] });
@ -125,13 +151,19 @@ describe('DummyEpgUtils', () => {
// ── updateEPG ──────────────────────────────────────────────────────────────
describe('updateEPG', () => {
it('calls API.updateEPG with values merged with epg.id', async () => {
const epg = makeEPG({ id: 42 });
const epg = makeEPG({ id: 42 });
const values = { name: 'Updated EPG' };
vi.mocked(API.updateEPG).mockResolvedValue({ id: 42, name: 'Updated EPG' });
vi.mocked(API.updateEPG).mockResolvedValue({
id: 42,
name: 'Updated EPG',
});
const result = await updateEPG(values, epg);
expect(API.updateEPG).toHaveBeenCalledWith({ name: 'Updated EPG', id: 42 });
expect(API.updateEPG).toHaveBeenCalledWith({
name: 'Updated EPG',
id: 42,
});
expect(result).toMatchObject({ id: 42 });
});
@ -139,7 +171,9 @@ describe('DummyEpgUtils', () => {
const epg = makeEPG({ id: 99 });
vi.mocked(API.updateEPG).mockResolvedValue({});
await updateEPG({ name: 'X' }, epg);
expect(API.updateEPG).toHaveBeenCalledWith(expect.objectContaining({ id: 99 }));
expect(API.updateEPG).toHaveBeenCalledWith(
expect.objectContaining({ id: 99 })
);
});
it('propagates rejection from API.updateEPG', async () => {
@ -153,8 +187,8 @@ describe('DummyEpgUtils', () => {
it('returns an object with name, is_active, source_type, and custom_properties', () => {
const result = getDummyEpgFormInitialValues();
expect(result).toMatchObject({
name: expect.any(String),
is_active: expect.any(Boolean),
name: expect.any(String),
is_active: expect.any(Boolean),
source_type: 'dummy',
});
expect(result.custom_properties).toBeDefined();
@ -165,7 +199,9 @@ describe('DummyEpgUtils', () => {
});
it('sets include_date to true by default', () => {
expect(getDummyEpgFormInitialValues().custom_properties.include_date).toBe(true);
expect(
getDummyEpgFormInitialValues().custom_properties.include_date
).toBe(true);
});
it('sets include_live and include_new to false by default', () => {
@ -175,15 +211,21 @@ describe('DummyEpgUtils', () => {
});
it('defaults name_source to "channel"', () => {
expect(getDummyEpgFormInitialValues().custom_properties.name_source).toBe('channel');
expect(getDummyEpgFormInitialValues().custom_properties.name_source).toBe(
'channel'
);
});
it('defaults program_duration to 180', () => {
expect(getDummyEpgFormInitialValues().custom_properties.program_duration).toBe(180);
expect(
getDummyEpgFormInitialValues().custom_properties.program_duration
).toBe(180);
});
it('defaults stream_index to 1', () => {
expect(getDummyEpgFormInitialValues().custom_properties.stream_index).toBe(1);
expect(
getDummyEpgFormInitialValues().custom_properties.stream_index
).toBe(1);
});
});
@ -201,7 +243,10 @@ describe('DummyEpgUtils', () => {
});
it('preserves provided values over defaults', () => {
const custom = makeCustom({ timezone: 'US/Pacific', program_duration: 60 });
const custom = makeCustom({
timezone: 'US/Pacific',
program_duration: 60,
});
const result = buildCustomProperties(custom);
expect(result.timezone).toBe('US/Pacific');
expect(result.program_duration).toBe(60);
@ -224,17 +269,17 @@ describe('DummyEpgUtils', () => {
it('maps all template fields', () => {
const custom = makeCustom({
title_template: 'T:{title}',
subtitle_template: 'S:{title}',
description_template: 'D:{title}',
upcoming_title_template: 'U:{title}',
title_template: 'T:{title}',
subtitle_template: 'S:{title}',
description_template: 'D:{title}',
upcoming_title_template: 'U:{title}',
upcoming_description_template: 'UD:{title}',
ended_title_template: 'E:{title}',
ended_description_template: 'ED:{title}',
fallback_title_template: 'FT:{title}',
ended_title_template: 'E:{title}',
ended_description_template: 'ED:{title}',
fallback_title_template: 'FT:{title}',
fallback_description_template: 'FD:{title}',
channel_logo_url: 'http://logo',
program_poster_url: 'http://poster',
channel_logo_url: 'http://logo',
program_poster_url: 'http://poster',
});
const result = buildCustomProperties(custom);
expect(result.title_template).toBe('T:{title}');
@ -288,7 +333,9 @@ describe('DummyEpgUtils', () => {
});
it('returns null for a complex valid regex with named groups', () => {
expect(validateCustomTitlePattern('(?<title>[A-Z].+)\\s(?<time>\\d+:\\d+)')).toBeNull();
expect(
validateCustomTitlePattern('(?<title>[A-Z].+)\\s(?<time>\\d+:\\d+)')
).toBeNull();
});
});
@ -398,7 +445,11 @@ describe('DummyEpgUtils', () => {
'Test Show 9:00'
);
expect(result.matched).toBe(true);
expect(result.groups).toMatchObject({ title: 'Test Show', hour: '9', minute: '00' });
expect(result.groups).toMatchObject({
title: 'Test Show',
hour: '9',
minute: '00',
});
});
});
@ -425,7 +476,10 @@ describe('DummyEpgUtils', () => {
});
it('adds normalized keys for multiple groups', () => {
const result = addNormalizedGroups({ title: 'Show', subtitle: 'Épilogue' });
const result = addNormalizedGroups({
title: 'Show',
subtitle: 'Épilogue',
});
expect(result.title_normalize).toBeDefined();
expect(result.subtitle_normalize).toBeDefined();
});
@ -434,15 +488,15 @@ describe('DummyEpgUtils', () => {
// ── applyTemplates ─────────────────────────────────────────────────────────
describe('applyTemplates', () => {
const templates = {
titleTemplate: '{title}',
subtitleTemplate: '{subtitle}',
descriptionTemplate: '{title} - {subtitle}',
upcomingTitleTemplate: 'Upcoming: {title}',
titleTemplate: '{title}',
subtitleTemplate: '{subtitle}',
descriptionTemplate: '{title} - {subtitle}',
upcomingTitleTemplate: 'Upcoming: {title}',
upcomingDescriptionTemplate: 'Details for {title}',
endedTitleTemplate: 'Ended: {title}',
endedDescriptionTemplate: 'Summary of {title}',
channelLogoUrl: 'http://logo/{title}',
programPosterUrl: 'http://poster/{title}',
endedTitleTemplate: 'Ended: {title}',
endedDescriptionTemplate: 'Summary of {title}',
channelLogoUrl: 'http://logo/{title}',
programPosterUrl: 'http://poster/{title}',
};
const groups = { title: 'Test Show', subtitle: 'Pilot' };
@ -479,7 +533,9 @@ describe('DummyEpgUtils', () => {
it('fills programPosterUrl', () => {
const result = applyTemplates(templates, groups, true);
expect(result.formattedProgramPosterUrl).toBe('http://poster/Test%20Show');
expect(result.formattedProgramPosterUrl).toBe(
'http://poster/Test%20Show'
);
});
it('returns empty string for template that has no matching placeholder', () => {
@ -510,10 +566,10 @@ describe('DummyEpgUtils', () => {
describe('buildTimePlaceholders', () => {
beforeEach(() => {
// Provide realistic return values from dateTimeUtils
vi.mocked(dateTimeUtils.getHour).mockReturnValue(21); // 9 PM
vi.mocked(dateTimeUtils.getHour).mockReturnValue(21); // 9 PM
vi.mocked(dateTimeUtils.getMinute).mockReturnValue(0);
vi.mocked(dateTimeUtils.getDay).mockReturnValue(15);
vi.mocked(dateTimeUtils.getMonth).mockReturnValue(5); // June (0-indexed)
vi.mocked(dateTimeUtils.getMonth).mockReturnValue(5); // June (0-indexed)
vi.mocked(dateTimeUtils.getYear).mockReturnValue(2024);
vi.mocked(dateTimeUtils.format).mockImplementation((dt, fmt) => fmt);
vi.mocked(dateTimeUtils.setHour).mockImplementation((dt) => dt);
@ -525,20 +581,38 @@ describe('DummyEpgUtils', () => {
it('returns an object when given valid time groups', () => {
const timeGroups = { hour: '9', minute: '00', ampm: 'PM' };
const result = buildTimePlaceholders(timeGroups, {}, 'US/Eastern', '', 180);
const result = buildTimePlaceholders(
timeGroups,
{},
'US/Eastern',
'',
180
);
expect(result).toBeDefined();
expect(typeof result).toBe('object');
});
it('returns starttime key when time groups have hour and minute', () => {
const timeGroups = { hour: '9', minute: '00', ampm: 'PM' };
const result = buildTimePlaceholders(timeGroups, {}, 'US/Eastern', '', 180);
const result = buildTimePlaceholders(
timeGroups,
{},
'US/Eastern',
'',
180
);
expect(result).toHaveProperty('starttime');
});
it('returns endtime key calculated from duration', () => {
const timeGroups = { hour: '9', minute: '00', ampm: 'PM' };
const result = buildTimePlaceholders(timeGroups, {}, 'US/Eastern', '', 180);
const result = buildTimePlaceholders(
timeGroups,
{},
'US/Eastern',
'',
180
);
expect(result).toHaveProperty('endtime');
});
@ -555,8 +629,14 @@ describe('DummyEpgUtils', () => {
it('incorporates date groups when provided', () => {
const timeGroups = { hour: '9', minute: '00' };
const dateGroups = { year: '2024', month: '06', day: '15' };
const result = buildTimePlaceholders(timeGroups, dateGroups, 'US/Eastern', '', 180);
const result = buildTimePlaceholders(
timeGroups,
dateGroups,
'US/Eastern',
'',
180
);
expect(result).toBeDefined();
});
});
});
});