Formatting changes

This commit is contained in:
Nick Sandstrom 2026-05-02 02:05:31 -07:00
parent 24a25821fc
commit 7eeecd8701
29 changed files with 1418 additions and 505 deletions

View file

@ -16,7 +16,18 @@ import {
TextInput,
useMantineTheme,
} from '@mantine/core';
import { AlertCircle, Check, Database, Filter, SquareMinus, SquarePen, SquarePlus, Trash, Tv, X, } from 'lucide-react';
import {
AlertCircle,
Check,
Database,
Filter,
SquareMinus,
SquarePen,
SquarePlus,
Trash,
Tv,
X,
} from 'lucide-react';
import useChannelsStore from '../../store/channels';
import useWarningsStore from '../../store/warnings';
import ConfirmationDialog from '../ConfirmationDialog';
@ -220,7 +231,14 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
}
return { filteredGroups: filtered, filterCounts: counts };
}, [sortedGroups, groupUsage, searchTerm, showChannelGroups, showM3UGroups, showUnusedGroups]);
}, [
sortedGroups,
groupUsage,
searchTerm,
showChannelGroups,
showM3UGroups,
showUnusedGroups,
]);
const fetchGroupUsage = useCallback(async () => {
setLoading(true);
@ -268,10 +286,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
}
try {
await updateChannelGroup(
channelGroups[editingGroupId],
{ name: editName.trim() }
);
await updateChannelGroup(channelGroups[editingGroupId], {
name: editName.trim(),
});
showNotification({
title: 'Success',

View file

@ -51,16 +51,20 @@ const OptionWithTooltip = forwardRef(
)
);
const LogoPopover = ({ group,
channelLogos,
logosLoading,
onPopoverChange,
onLogoInputClick,
onFilterChange,
onLogoSelect,
const LogoPopover = ({
group,
channelLogos,
logosLoading,
onPopoverChange,
onLogoInputClick,
onFilterChange,
onLogoSelect,
}) => {
const renderLogoList = () => {
const logoOptions = [{ id: '0', name: 'Default' }, ...Object.values(channelLogos)];
const logoOptions = [
{ id: '0', name: 'Default' },
...Object.values(channelLogos),
];
const filteredLogos = logoOptions.filter((logo) =>
logo.name.toLowerCase().includes((group.logoFilter || '').toLowerCase())
);
@ -69,7 +73,9 @@ const LogoPopover = ({ group,
return (
<Center style={{ height: 200 }}>
<Text size="sm" c="dimmed">
{group.logoFilter ? 'No logos match your filter' : 'No logos available'}
{group.logoFilter
? 'No logos match your filter'
: 'No logos available'}
</Text>
</Center>
);
@ -92,14 +98,14 @@ const LogoPopover = ({ group,
padding: '5px',
borderRadius: '4px',
}}
onClick={() => onLogoSelect(logoItem.id === '0' ? null : logoItem.id)}
onClick={() =>
onLogoSelect(logoItem.id === '0' ? null : logoItem.id)
}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
'rgb(68, 68, 68)';
e.currentTarget.style.backgroundColor = 'rgb(68, 68, 68)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
'transparent';
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<Center
@ -154,7 +160,8 @@ const LogoPopover = ({ group,
label="Custom Logo"
readOnly
value={
channelLogos[group.custom_properties?.custom_logo_id]?.name || 'Default'
channelLogos[group.custom_properties?.custom_logo_id]?.name ||
'Default'
}
onClick={onLogoInputClick}
size="xs"
@ -180,7 +187,7 @@ const LogoPopover = ({ group,
</PopoverDropdown>
</Popover>
);
}
};
const LiveGroupFilter = ({
playlist,
@ -836,22 +843,30 @@ const LiveGroupFilter = ({
channelLogos={channelLogos}
logosLoading={logosLoading}
onPopoverChange={(opened) => {
updateGroupState(group.channel_group, () => ({ logoPopoverOpened: opened }));
updateGroupState(group.channel_group, () => ({
logoPopoverOpened: opened,
}));
if (opened) ensureLogosLoaded();
}}
onLogoInputClick={() => {
setGroupStates((prev) =>
prev.map((state) => ({
...state,
logoPopoverOpened: state.channel_group === group.channel_group,
logoPopoverOpened:
state.channel_group ===
group.channel_group,
}))
);
}}
onFilterChange={(val) => {
updateGroupState(group.channel_group, () => ({ logoFilter: val }));
updateGroupState(group.channel_group, () => ({
logoFilter: val,
}));
}}
onLogoSelect={(logoId) => {
updateCustomProps(group.channel_group, () => ({ custom_logo_id: logoId }));
updateCustomProps(group.channel_group, () => ({
custom_logo_id: logoId,
}));
}}
/>

View file

@ -1,9 +1,25 @@
import React, {useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
import {Box, Button, Center, Divider, Group, Image, Modal, Stack, Text, TextInput,} from '@mantine/core';
import {Dropzone, DropzoneAccept, DropzoneIdle, DropzoneReject,} from '@mantine/dropzone';
import {FileImage, Upload, X} from 'lucide-react';
import {showNotification} from '../../utils/notificationUtils.js';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import {
Box,
Button,
Center,
Divider,
Group,
Image,
Modal,
Stack,
Text,
TextInput,
} from '@mantine/core';
import {
Dropzone,
DropzoneAccept,
DropzoneIdle,
DropzoneReject,
} from '@mantine/dropzone';
import { FileImage, Upload, X } from 'lucide-react';
import { showNotification } from '../../utils/notificationUtils.js';
import {
createLogo,
getFilenameWithoutExtension,

View file

@ -81,21 +81,36 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
if (isXC && xcMode === 'simple' && !isDefaultProfile) {
const errs = validateXcSimple(newUsername, newPassword);
if (Object.keys(errs).length > 0) { setSimpleErrors(errs); return; }
if (Object.keys(errs).length > 0) {
setSimpleErrors(errs);
return;
}
setSimpleErrors({});
values = applyXcSimplePatterns(values, m3u, newUsername, newPassword);
}
if (isXC && xcMode === 'advanced' && !isDefaultProfile) {
if (!searchPattern.trim()) { setError('search_pattern', { message: 'Search pattern is required' }); return; }
if (!replacePattern.trim()) { setError('replace_pattern', { message: 'Replace pattern is required' }); return; }
if (!searchPattern.trim()) {
setError('search_pattern', { message: 'Search pattern is required' });
return;
}
if (!replacePattern.trim()) {
setError('replace_pattern', { message: 'Replace pattern is required' });
return;
}
}
const submitValues = buildSubmitValues(values, profile, isDefaultProfile, isXC, xcMode);
const submitValues = buildSubmitValues(
values,
profile,
isDefaultProfile,
isXC,
xcMode
);
if (expDate !== undefined) submitValues.exp_date = expDate;
profile?.id
? await updateM3UProfile(m3u, {...submitValues, id: profile.id})
? await updateM3UProfile(m3u, { ...submitValues, id: profile.id })
: await addM3UProfile(m3u, submitValues);
reset();

View file

@ -19,7 +19,10 @@ import {
useMantineTheme,
} from '@mantine/core';
import { Info, SquareMinus, SquarePen } from 'lucide-react';
import { deleteM3UProfile, updateM3UProfile, } from '../../utils/forms/M3uProfileUtils.js';
import {
deleteM3UProfile,
updateM3UProfile,
} from '../../utils/forms/M3uProfileUtils.js';
import {
getExpirationInfo,
isAccountExpired,
@ -291,22 +294,20 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
withinPortal={true}
yOffset="2vh"
>
{profilesArray
.sort(profileSortComparator)
.map((item) => {
return (
<M3uProfileCard
key={item.id}
item={item}
accountType={playlist?.account_type}
onClickInfo={() => showAccountInfo(item)}
onChangeMaxStreams={(value) => modifyMaxStreams(value, item)}
onChangeActive={() => toggleActive(item)}
onClickEdit={() => editProfile(item)}
onClickDelete={() => deleteProfile(item.id)}
/>
);
})}
{profilesArray.sort(profileSortComparator).map((item) => {
return (
<M3uProfileCard
key={item.id}
item={item}
accountType={playlist?.account_type}
onClickInfo={() => showAccountInfo(item)}
onChangeMaxStreams={(value) => modifyMaxStreams(value, item)}
onChangeActive={() => toggleActive(item)}
onClickEdit={() => editProfile(item)}
onClickDelete={() => deleteProfile(item.id)}
/>
);
})}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button

View file

@ -263,7 +263,9 @@ describe('ChannelGroup', () => {
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
await waitFor(() => {
expect(API.updateChannelGroup).toHaveBeenCalledWith(group, { name: 'Sports Updated' });
expect(API.updateChannelGroup).toHaveBeenCalledWith(group, {
name: 'Sports Updated',
});
});
});

View file

@ -20,11 +20,23 @@ vi.mock('../../../utils/forms/ChannelGroupUtils.js', () => ({
// ConfirmationDialog mock
vi.mock('../../ConfirmationDialog', () => ({
default: ({ opened, onClose, onConfirm, title, confirmLabel, cancelLabel, loading }) =>
default: ({
opened,
onClose,
onConfirm,
title,
confirmLabel,
cancelLabel,
loading,
}) =>
opened ? (
<div data-testid="confirmation-dialog">
<div data-testid="confirm-title">{title}</div>
<button data-testid="confirm-btn" onClick={onConfirm} disabled={loading}>
<button
data-testid="confirm-btn"
onClick={onConfirm}
disabled={loading}
>
{confirmLabel}
</button>
<button data-testid="cancel-btn" onClick={onClose}>
@ -68,14 +80,24 @@ vi.mock('@mantine/core', async () => ({
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, disabled, loading, leftSection }) => (
<button onClick={onClick} disabled={disabled || loading} data-loading={loading}>
{leftSection && <span data-testid="button-left-section">{leftSection}</span>}
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
>
{leftSection && (
<span data-testid="button-left-section">{leftSection}</span>
)}
{children}
</button>
),
Chip: ({ children, checked, onChange }) => (
<label>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
{children}
</label>
),
@ -104,7 +126,9 @@ vi.mock('@mantine/core', async () => ({
onKeyDown={onKeyDown}
/>
),
useMantineTheme: () => ({ tailwind: { yellow: ['#fefcbf'], red: ['#f56565'] } }),
useMantineTheme: () => ({
tailwind: { yellow: ['#fefcbf'], red: ['#f56565'] },
}),
}));
// Imports after mocks
@ -193,7 +217,9 @@ describe('GroupManager', () => {
},
});
renderGroupManager();
const names = screen.getAllByText(/Alpha|Zebra/).map((el) => el.textContent);
const names = screen
.getAllByText(/Alpha|Zebra/)
.map((el) => el.textContent);
expect(names.indexOf('Alpha')).toBeLessThan(names.indexOf('Zebra'));
});
@ -278,7 +304,9 @@ describe('GroupManager', () => {
});
it('calls updateChannelGroup with trimmed name on save', async () => {
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue(undefined);
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue(
undefined
);
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
@ -296,7 +324,9 @@ describe('GroupManager', () => {
});
it('shows success notification after save', async () => {
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue(undefined);
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue(
undefined
);
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
@ -328,7 +358,9 @@ describe('GroupManager', () => {
});
it('shows error notification when updateChannelGroup throws', async () => {
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockRejectedValue(new Error('fail'));
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockRejectedValue(
new Error('fail')
);
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
@ -371,7 +403,9 @@ describe('GroupManager', () => {
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(ChannelGroupUtils.addChannelGroup).toHaveBeenCalledWith({ name: 'New Group' });
expect(ChannelGroupUtils.addChannelGroup).toHaveBeenCalledWith({
name: 'New Group',
});
});
});
@ -408,7 +442,9 @@ describe('GroupManager', () => {
});
it('shows error notification when addChannelGroup throws', async () => {
vi.mocked(ChannelGroupUtils.addChannelGroup).mockRejectedValue(new Error('fail'));
vi.mocked(ChannelGroupUtils.addChannelGroup).mockRejectedValue(
new Error('fail')
);
setupMocks();
renderGroupManager();
@ -435,7 +471,9 @@ describe('GroupManager', () => {
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(screen.queryByPlaceholderText(/Enter group name/i)).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText(/Enter group name/i)
).not.toBeInTheDocument();
});
});
@ -444,10 +482,14 @@ describe('GroupManager', () => {
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
expect(screen.getByPlaceholderText(/Enter group name/i)).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Enter group name/i)
).toBeInTheDocument();
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
expect(screen.queryByPlaceholderText(/Enter group name/i)).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText(/Enter group name/i)
).not.toBeInTheDocument();
});
});
@ -455,36 +497,62 @@ describe('GroupManager', () => {
describe('delete group', () => {
it('opens confirmation dialog when delete is clicked', async () => {
setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } });
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-minus').closest('button'));
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
await waitFor(() => {
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
expect(screen.getByTestId('confirm-title')).toHaveTextContent('Confirm Group Deletion');
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
expect(screen.getByTestId('confirm-title')).toHaveTextContent(
'Confirm Group Deletion'
);
});
});
it('calls deleteChannelGroup when confirmed', async () => {
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(undefined);
const groupToDelete = { ...makeGroup(), hasChannels: false, hasM3UAccounts: false };
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(
undefined
);
const groupToDelete = {
...makeGroup(),
hasChannels: false,
hasM3UAccounts: false,
};
setupMocks({ groups: { 1: groupToDelete } });
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-minus').closest('button'));
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
expect(ChannelGroupUtils.deleteChannelGroup).toHaveBeenCalledWith(groupToDelete);
expect(ChannelGroupUtils.deleteChannelGroup).toHaveBeenCalledWith(
groupToDelete
);
});
});
it('shows success notification after delete', async () => {
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(undefined);
setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } });
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(
undefined
);
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-minus').closest('button'));
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
@ -495,11 +563,19 @@ describe('GroupManager', () => {
});
it('shows error notification when deleteChannelGroup throws', async () => {
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockRejectedValue(new Error('fail'));
setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } });
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockRejectedValue(
new Error('fail')
);
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-minus').closest('button'));
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
@ -510,25 +586,43 @@ describe('GroupManager', () => {
});
it('closes confirmation dialog on cancel', () => {
setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } });
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-minus').closest('button'));
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('cancel-btn'));
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('closes confirmation dialog after successful delete', async () => {
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(undefined);
setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } });
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(
undefined
);
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-minus').closest('button'));
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
});
});
@ -548,7 +642,9 @@ describe('GroupManager', () => {
});
it('calls cleanupUnusedChannelGroups directly when warning is suppressed', async () => {
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(successResponse);
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(
successResponse
);
setupMocks({ isWarningSuppressed: true });
renderGroupManager();
@ -557,11 +653,15 @@ describe('GroupManager', () => {
await waitFor(() => {
expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled();
});
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('calls cleanupUnusedChannelGroups when cleanup confirmed', async () => {
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(successResponse);
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(
successResponse
);
setupMocks({ isWarningSuppressed: false });
renderGroupManager();
@ -574,7 +674,9 @@ describe('GroupManager', () => {
});
it('shows success notification after cleanup', async () => {
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(successResponse);
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(
successResponse
);
setupMocks({ isWarningSuppressed: true });
renderGroupManager();
@ -610,7 +712,9 @@ describe('GroupManager', () => {
fireEvent.click(screen.getByText(/cleanup/i));
fireEvent.click(screen.getByTestId('cancel-btn'));
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
});
@ -618,8 +722,18 @@ describe('GroupManager', () => {
describe('filter chips', () => {
const twoGroups = {
1: makeGroup({ id: 1, name: 'Channel Group', hasChannels: true, hasM3UAccounts: false }),
2: makeGroup({ id: 2, name: 'M3U Group', hasChannels: false, hasM3UAccounts: true }),
1: makeGroup({
id: 1,
name: 'Channel Group',
hasChannels: true,
hasM3UAccounts: false,
}),
2: makeGroup({
id: 2,
name: 'M3U Group',
hasChannels: false,
hasM3UAccounts: true,
}),
};
it('hides channel groups when Channels chip is unchecked', () => {
@ -647,8 +761,18 @@ describe('GroupManager', () => {
it('hides unused groups when Unused chip is unchecked', () => {
setupMocks({
groups: {
1: makeGroup({ id: 1, name: 'Unused Group', hasChannels: false, hasM3UAccounts: false }),
2: makeGroup({ id: 2, name: 'Used Group', hasChannels: true, hasM3UAccounts: false }),
1: makeGroup({
id: 1,
name: 'Unused Group',
hasChannels: false,
hasM3UAccounts: false,
}),
2: makeGroup({
id: 2,
name: 'Used Group',
hasChannels: true,
hasM3UAccounts: false,
}),
},
});
renderGroupManager();
@ -684,4 +808,4 @@ describe('GroupManager', () => {
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
});
});

View file

@ -17,7 +17,8 @@ vi.mock('../../../utils/forms/LiveGroupFilterUtils', () => ({
{
value: 'stream_profile',
label: 'Stream Profile',
description: 'Assign a specific stream profile to all channels in this group during auto sync',
description:
'Assign a specific stream profile to all channels in this group during auto sync',
isActive: (p) => p.stream_profile_id !== undefined,
defaults: { stream_profile_id: null },
removeKeys: ['stream_profile_id'],
@ -49,7 +50,9 @@ vi.mock('../Logo', () => ({
}));
vi.mock('../../LazyLogo', () => ({
default: ({ logoId, alt }) => <img data-testid="lazy-logo" alt={alt} data-logo-id={logoId} />,
default: ({ logoId, alt }) => (
<img data-testid="lazy-logo" alt={alt} data-logo-id={logoId} />
),
}));
vi.mock('react-window', () => ({
@ -66,7 +69,15 @@ vi.mock('../../../images/logo.png', () => ({ default: 'logo.png' }));
// Mantine core
vi.mock('@mantine/core', async () => ({
TextInput: ({ label, placeholder, value, onChange, onClick, readOnly, size }) => (
TextInput: ({
label,
placeholder,
value,
onChange,
onClick,
readOnly,
size,
}) => (
<input
aria-label={label || placeholder}
placeholder={placeholder}
@ -78,7 +89,12 @@ vi.mock('@mantine/core', async () => ({
/>
),
Button: ({ children, onClick, disabled, variant, size, leftSection }) => (
<button onClick={onClick} disabled={disabled} data-variant={variant} data-size={size}>
<button
onClick={onClick}
disabled={disabled}
data-variant={variant}
data-size={size}
>
{leftSection}
{children}
</button>
@ -94,7 +110,9 @@ vi.mock('@mantine/core', async () => ({
data-size={size}
/>
{label}
{description && <span data-testid="checkbox-description">{description}</span>}
{description && (
<span data-testid="checkbox-description">{description}</span>
)}
</label>
),
Flex: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
@ -114,9 +132,15 @@ vi.mock('@mantine/core', async () => ({
</select>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
Group: ({ children, justify }) => <div data-justify={justify}>{children}</div>,
Group: ({ children, justify }) => (
<div data-justify={justify}>{children}</div>
),
SimpleGrid: ({ children }) => <div data-testid="simple-grid">{children}</div>,
Text: ({ children, size, c }) => <span data-size={size} data-color={c}>{children}</span>,
Text: ({ children, size, c }) => (
<span data-size={size} data-color={c}>
{children}
</span>
),
NumberInput: ({ label, value, onChange, min, step, size }) => (
<input
type="number"
@ -142,7 +166,9 @@ vi.mock('@mantine/core', async () => ({
multiple
value={value || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
const selected = Array.from(e.target.selectedOptions).map(
(o) => o.value
);
onChange(selected);
}}
data-size={size}
@ -155,10 +181,14 @@ vi.mock('@mantine/core', async () => ({
</select>
),
Tooltip: ({ children, label, disabled }) => (
<div data-tooltip={label} data-tooltip-disabled={disabled}>{children}</div>
<div data-tooltip={label} data-tooltip-disabled={disabled}>
{children}
</div>
),
Popover: ({ children, opened }) => (
<div data-testid="popover" data-opened={opened}>{children}</div>
<div data-testid="popover" data-opened={opened}>
{children}
</div>
),
ScrollArea: ({ children, style }) => <div style={style}>{children}</div>,
Center: ({ children }) => <div data-testid="center">{children}</div>,
@ -177,7 +207,9 @@ vi.mock('@mantine/core', async () => ({
),
PopoverTarget: ({ children }) => <div>{children}</div>,
PopoverDropdown: ({ children, onMouseDown }) => (
<div data-testid="popover-dropdown" onMouseDown={onMouseDown}>{children}</div>
<div data-testid="popover-dropdown" onMouseDown={onMouseDown}>
{children}
</div>
),
}));
@ -195,12 +227,8 @@ import useChannelsStore from '../../../store/channels';
import useStreamProfilesStore from '../../../store/streamProfiles';
import { useChannelLogoSelection } from '../../../hooks/useSmartLogos';
import {
ADVANCED_OPTIONS_CONFIG,
applyAdvancedOptionsChange,
getEPGs,
getEpgSourceData,
getEpgSourceValue,
getSelectedAdvancedOptions,
} from '../../../utils/forms/LiveGroupFilterUtils.js';
// Fixtures
@ -238,11 +266,11 @@ const defaultStreamProfiles = [
];
const setupStoreMocks = ({
channelGroups = defaultChannelGroups,
profiles = defaultProfiles,
streamProfiles = defaultStreamProfiles,
fetchStreamProfiles = vi.fn(),
} = {}) => {
channelGroups = defaultChannelGroups,
profiles = defaultProfiles,
streamProfiles = defaultStreamProfiles,
fetchStreamProfiles = vi.fn(),
} = {}) => {
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ channelGroups, profiles })
);
@ -252,10 +280,10 @@ const setupStoreMocks = ({
};
const setupLogoMock = ({
logos = { 5: { id: 5, name: 'My Logo', url: '/logos/5.png' } },
ensureLogosLoaded = vi.fn(),
isLoading = false,
} = {}) => {
logos = { 5: { id: 5, name: 'My Logo', url: '/logos/5.png' } },
ensureLogosLoaded = vi.fn(),
isLoading = false,
} = {}) => {
vi.mocked(useChannelLogoSelection).mockReturnValue({
logos,
ensureLogosLoaded,
@ -265,12 +293,12 @@ const setupLogoMock = ({
};
const defaultProps = ({
groupStates = [],
setGroupStates = vi.fn(),
autoEnableNewGroupsLive = true,
setAutoEnableNewGroupsLive = vi.fn(),
playlist = makePlaylist([]),
} = {}) => ({
groupStates = [],
setGroupStates = vi.fn(),
autoEnableNewGroupsLive = true,
setAutoEnableNewGroupsLive = vi.fn(),
playlist = makePlaylist([]),
} = {}) => ({
playlist,
groupStates,
setGroupStates,
@ -300,7 +328,9 @@ describe('LiveGroupFilter', () => {
});
it('renders auto-enable checkbox with correct checked state', async () => {
render(<LiveGroupFilter {...defaultProps({ autoEnableNewGroupsLive: true })} />);
render(
<LiveGroupFilter {...defaultProps({ autoEnableNewGroupsLive: true })} />
);
const checkbox = screen.getByRole('checkbox', {
name: /Automatically enable new groups/i,
});
@ -310,7 +340,11 @@ describe('LiveGroupFilter', () => {
});
it('renders auto-enable checkbox unchecked when false', () => {
render(<LiveGroupFilter {...defaultProps({ autoEnableNewGroupsLive: false })} />);
render(
<LiveGroupFilter
{...defaultProps({ autoEnableNewGroupsLive: false })}
/>
);
const checkbox = screen.getByRole('checkbox', {
name: /Automatically enable new groups/i,
});
@ -320,7 +354,9 @@ describe('LiveGroupFilter', () => {
it('renders filter input', async () => {
render(<LiveGroupFilter {...defaultProps()} />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter groups...')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Filter groups...')
).toBeInTheDocument();
});
});
@ -401,7 +437,9 @@ describe('LiveGroupFilter', () => {
it('handles getEPGs failure gracefully', async () => {
vi.mocked(getEPGs).mockRejectedValue(new Error('Network error'));
expect(() => render(<LiveGroupFilter {...defaultProps()} />)).not.toThrow();
expect(() =>
render(<LiveGroupFilter {...defaultProps()} />)
).not.toThrow();
});
});
@ -411,22 +449,25 @@ describe('LiveGroupFilter', () => {
it('calls setGroupStates when channelGroups and playlist are set', () => {
const setGroupStates = vi.fn();
const playlist = makePlaylist([
{ channel_group: 1, enabled: true, auto_channel_sync: false, auto_sync_channel_start: 1 },
{
channel_group: 1,
enabled: true,
auto_channel_sync: false,
auto_sync_channel_start: 1,
},
]);
render(
<LiveGroupFilter
{...defaultProps({ playlist, setGroupStates })}
/>
<LiveGroupFilter {...defaultProps({ playlist, setGroupStates })} />
);
expect(setGroupStates).toHaveBeenCalled();
});
it('skips groups that are not in channelGroups', () => {
const setGroupStates = vi.fn();
const playlist = makePlaylist([
{ channel_group: 999, enabled: true },
]);
render(<LiveGroupFilter {...defaultProps({ playlist, setGroupStates })} />);
const playlist = makePlaylist([{ channel_group: 999, enabled: true }]);
render(
<LiveGroupFilter {...defaultProps({ playlist, setGroupStates })} />
);
expect(setGroupStates).toHaveBeenCalledWith([]);
});
@ -441,8 +482,11 @@ describe('LiveGroupFilter', () => {
custom_properties: JSON.stringify({ foo: 'bar' }),
},
]);
render(<LiveGroupFilter {...defaultProps({ playlist, setGroupStates })} />);
const [mappedGroups] = setGroupStates.mock.calls[setGroupStates.mock.calls.length - 1];
render(
<LiveGroupFilter {...defaultProps({ playlist, setGroupStates })} />
);
const [mappedGroups] =
setGroupStates.mock.calls[setGroupStates.mock.calls.length - 1];
expect(mappedGroups[0].custom_properties).toEqual({ foo: 'bar' });
});
@ -452,7 +496,9 @@ describe('LiveGroupFilter', () => {
{ channel_group: 1, enabled: true, custom_properties: '{invalid}' },
]);
expect(() =>
render(<LiveGroupFilter {...defaultProps({ playlist, setGroupStates })} />)
render(
<LiveGroupFilter {...defaultProps({ playlist, setGroupStates })} />
)
).not.toThrow();
});
@ -471,11 +517,16 @@ describe('LiveGroupFilter', () => {
const setAutoEnableNewGroupsLive = vi.fn();
render(
<LiveGroupFilter
{...defaultProps({ autoEnableNewGroupsLive: true, setAutoEnableNewGroupsLive })}
{...defaultProps({
autoEnableNewGroupsLive: true,
setAutoEnableNewGroupsLive,
})}
/>
);
fireEvent.click(
screen.getByRole('checkbox', { name: /Automatically enable new groups/i })
screen.getByRole('checkbox', {
name: /Automatically enable new groups/i,
})
);
expect(setAutoEnableNewGroupsLive).toHaveBeenCalled();
});
@ -490,15 +541,19 @@ describe('LiveGroupFilter', () => {
const setGroupStates = vi.fn((arg) => {
if (typeof arg === 'function') lastUpdater = arg;
});
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
fireEvent.click(screen.getByText('Sports'));
await waitFor(() => {
expect(setGroupStates).toHaveBeenCalled();
const result = lastUpdater(groupStates);
expect(result).toEqual(expect.arrayContaining([
expect.objectContaining({ channel_group: 1, enabled: false }),
]));
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ channel_group: 1, enabled: false }),
])
);
});
});
@ -511,7 +566,9 @@ describe('LiveGroupFilter', () => {
const setGroupStates = vi.fn((arg) => {
if (typeof arg === 'function') lastUpdater = arg;
});
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
const buttons = screen.getAllByRole('button', { name: /Sports/ });
fireEvent.click(buttons[0]); // click Sports
await waitFor(() => {
@ -603,7 +660,9 @@ describe('LiveGroupFilter', () => {
makeGroupState({ channel_group: 1, name: 'Sports', enabled: false }),
makeGroupState({ channel_group: 2, name: 'News', enabled: false }),
];
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
fireEvent.click(screen.getByText('Select Visible'));
await waitFor(() => {
expect(setGroupStates).toHaveBeenCalled();
@ -622,7 +681,9 @@ describe('LiveGroupFilter', () => {
makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }),
makeGroupState({ channel_group: 2, name: 'News', enabled: true }),
];
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
fireEvent.click(screen.getByText('Deselect Visible'));
await waitFor(() => {
expect(setGroupStates).toHaveBeenCalled();
@ -641,7 +702,9 @@ describe('LiveGroupFilter', () => {
makeGroupState({ channel_group: 1, name: 'Sports', enabled: false }),
makeGroupState({ channel_group: 2, name: 'News', enabled: false }),
];
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
fireEvent.change(screen.getByPlaceholderText('Filter groups...'), {
target: { value: 'Sports' },
});
@ -661,13 +724,17 @@ describe('LiveGroupFilter', () => {
it('renders Auto Channel Sync checkbox when group is enabled', () => {
const groupStates = [makeGroupState({ enabled: true })];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('checkbox', { name: /Auto Channel Sync/i })).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: /Auto Channel Sync/i })
).toBeInTheDocument();
});
it('Auto Channel Sync checkbox is disabled when group is disabled', () => {
const groupStates = [makeGroupState({ enabled: false })];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('checkbox', { name: /Auto Channel Sync/i })).toBeDisabled();
expect(
screen.getByRole('checkbox', { name: /Auto Channel Sync/i })
).toBeDisabled();
});
it('toggleAutoSync calls setGroupStates toggling auto_channel_sync', async () => {
@ -675,9 +742,15 @@ describe('LiveGroupFilter', () => {
const setGroupStates = vi.fn((arg) => {
if (typeof arg === 'function') lastUpdater = arg;
});
const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: false })];
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
fireEvent.click(screen.getByRole('checkbox', { name: /Auto Channel Sync/i }));
const groupStates = [
makeGroupState({ enabled: true, auto_channel_sync: false }),
];
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
fireEvent.click(
screen.getByRole('checkbox', { name: /Auto Channel Sync/i })
);
await waitFor(() => {
expect(setGroupStates).toHaveBeenCalled();
const result = lastUpdater(groupStates);
@ -686,15 +759,23 @@ describe('LiveGroupFilter', () => {
});
it('shows Start Channel # input when auto_channel_sync is enabled', () => {
const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: true })];
const groupStates = [
makeGroupState({ enabled: true, auto_channel_sync: true }),
];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('spinbutton', { name: /Start Channel #/i })).toBeInTheDocument();
expect(
screen.getByRole('spinbutton', { name: /Start Channel #/i })
).toBeInTheDocument();
});
it('hides Start Channel # when auto_channel_sync is false', () => {
const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: false })];
const groupStates = [
makeGroupState({ enabled: true, auto_channel_sync: false }),
];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.queryByRole('spinbutton', { name: /Start Channel #/i })).not.toBeInTheDocument();
expect(
screen.queryByRole('spinbutton', { name: /Start Channel #/i })
).not.toBeInTheDocument();
});
it('updateChannelStart calls setGroupStates with new value', async () => {
@ -702,11 +783,18 @@ describe('LiveGroupFilter', () => {
const setGroupStates = vi.fn((arg) => {
if (typeof arg === 'function') lastUpdater = arg;
});
const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: true })];
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
fireEvent.change(screen.getByRole('spinbutton', { name: /Start Channel #/i }), {
target: { value: '100' },
});
const groupStates = [
makeGroupState({ enabled: true, auto_channel_sync: true }),
];
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
fireEvent.change(
screen.getByRole('spinbutton', { name: /Start Channel #/i }),
{
target: { value: '100' },
}
);
await waitFor(() => {
expect(setGroupStates).toHaveBeenCalled();
const result = lastUpdater(groupStates);
@ -719,9 +807,13 @@ describe('LiveGroupFilter', () => {
describe('Channel Numbering Mode', () => {
it('shows Channel Numbering Mode select when auto_channel_sync enabled', () => {
const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: true })];
const groupStates = [
makeGroupState({ enabled: true, auto_channel_sync: true }),
];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('combobox', { name: /Channel Numbering Mode/i })).toBeInTheDocument();
expect(
screen.getByRole('combobox', { name: /Channel Numbering Mode/i })
).toBeInTheDocument();
});
it('shows Fallback Channel # when numbering mode is provider', () => {
@ -747,16 +839,25 @@ describe('LiveGroupFilter', () => {
}),
];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.queryByRole('spinbutton', { name: /Start Channel #/i })).not.toBeInTheDocument();
expect(
screen.queryByRole('spinbutton', { name: /Start Channel #/i })
).not.toBeInTheDocument();
});
it('updating Channel Numbering Mode calls setGroupStates', () => {
const setGroupStates = vi.fn();
const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: true })];
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
fireEvent.change(screen.getByRole('combobox', { name: /Channel Numbering Mode/i }), {
target: { value: 'provider' },
});
const groupStates = [
makeGroupState({ enabled: true, auto_channel_sync: true }),
];
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
fireEvent.change(
screen.getByRole('combobox', { name: /Channel Numbering Mode/i }),
{
target: { value: 'provider' },
}
);
expect(setGroupStates).toHaveBeenCalled();
});
});
@ -777,23 +878,35 @@ describe('LiveGroupFilter', () => {
{...defaultProps({ groupStates: [makeAutoSyncGroup()] })}
/>
);
expect(screen.getByRole('listbox', { name: /Advanced Options/i })).toBeInTheDocument();
expect(
screen.getByRole('listbox', { name: /Advanced Options/i })
).toBeInTheDocument();
});
it('shows Channel Name Find input when name_regex_pattern is set', () => {
const groupStates = [
makeAutoSyncGroup({ name_regex_pattern: '.*', name_replace_pattern: '$1' }),
makeAutoSyncGroup({
name_regex_pattern: '.*',
name_replace_pattern: '$1',
}),
];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('textbox', { name: /Channel Name Find/i })).toBeInTheDocument();
expect(
screen.getByRole('textbox', { name: /Channel Name Find/i })
).toBeInTheDocument();
});
it('shows Channel Name Replace input when name_replace_pattern is set', () => {
const groupStates = [
makeAutoSyncGroup({ name_regex_pattern: '.*', name_replace_pattern: '$1' }),
makeAutoSyncGroup({
name_regex_pattern: '.*',
name_replace_pattern: '$1',
}),
];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('textbox', { name: /Channel Name Replace/i })).toBeInTheDocument();
expect(
screen.getByRole('textbox', { name: /Channel Name Replace/i })
).toBeInTheDocument();
});
it('shows Channel Name Filter input when name_match_regex is set', () => {
@ -807,7 +920,9 @@ describe('LiveGroupFilter', () => {
it('shows Channel Profiles multiselect when channel_profile_ids is set', () => {
const groupStates = [makeAutoSyncGroup({ channel_profile_ids: [] })];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('listbox', { name: /Channel Profiles/i })).toBeInTheDocument();
expect(
screen.getByRole('listbox', { name: /Channel Profiles/i })
).toBeInTheDocument();
});
it('shows Override Channel Group select when group_override is set', () => {
@ -821,7 +936,9 @@ describe('LiveGroupFilter', () => {
it('shows Stream Profile select when stream_profile_id is set', () => {
const groupStates = [makeAutoSyncGroup({ stream_profile_id: null })];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('combobox', { name: /Stream Profile/i })).toBeInTheDocument();
expect(
screen.getByRole('combobox', { name: /Stream Profile/i })
).toBeInTheDocument();
});
it('shows Channel Sort Order select when channel_sort_order is set', () => {
@ -834,22 +951,31 @@ describe('LiveGroupFilter', () => {
it('shows Reverse Sort Order checkbox when channel_sort_order is set', () => {
const groupStates = [
makeAutoSyncGroup({ channel_sort_order: '', channel_sort_reverse: false }),
makeAutoSyncGroup({
channel_sort_order: '',
channel_sort_reverse: false,
}),
];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('checkbox', { name: /Reverse Sort Order/i })).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: /Reverse Sort Order/i })
).toBeInTheDocument();
});
it('shows EPG Source select when force_dummy_epg is set', () => {
const groupStates = [makeAutoSyncGroup({ force_dummy_epg: true })];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('combobox', { name: /EPG Source/i })).toBeInTheDocument();
expect(
screen.getByRole('combobox', { name: /EPG Source/i })
).toBeInTheDocument();
});
it('shows EPG Source select when custom_epg_id is set', () => {
const groupStates = [makeAutoSyncGroup({ custom_epg_id: 42 })];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
expect(screen.getByRole('combobox', { name: /EPG Source/i })).toBeInTheDocument();
expect(
screen.getByRole('combobox', { name: /EPG Source/i })
).toBeInTheDocument();
});
});
@ -890,7 +1016,9 @@ describe('LiveGroupFilter', () => {
if (typeof arg === 'function') lastUpdater = arg;
});
const groupStates = [makeLogoGroup()];
render(<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />);
render(
<LiveGroupFilter {...defaultProps({ groupStates, setGroupStates })} />
);
fireEvent.click(screen.getByText('Upload or Create Logo'));
fireEvent.click(screen.getByTestId('logo-form-success'));
expect(setGroupStates).toHaveBeenCalled();
@ -927,8 +1055,12 @@ describe('LiveGroupFilter', () => {
];
render(<LiveGroupFilter {...defaultProps({ groupStates })} />);
await waitFor(() => {
expect(screen.getByRole('option', { name: /EPG One/i })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /EPG Two/i })).toBeInTheDocument();
expect(
screen.getByRole('option', { name: /EPG One/i })
).toBeInTheDocument();
expect(
screen.getByRole('option', { name: /EPG Two/i })
).toBeInTheDocument();
});
});
});
@ -947,4 +1079,4 @@ describe('LiveGroupFilter', () => {
expect(buttons[1]).toHaveTextContent('Zebra');
});
});
});
});

View file

@ -44,7 +44,15 @@ vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
vi.mock('@mantine/core', async () => ({
Paper: ({ children }) => <div data-testid="paper">{children}</div>,
Title: ({ children }) => <h1>{children}</h1>,
TextInput: ({ label, name, value, onChange, placeholder, type, disabled }) => (
TextInput: ({
label,
name,
value,
onChange,
placeholder,
type,
disabled,
}) => (
<input
aria-label={label}
name={name}
@ -112,10 +120,10 @@ import useSettingsStore from '../../../store/settings';
// Helpers
const setupMocks = ({
isAuthenticated = false,
loginResult = Promise.resolve(true),
version = '1.0.0',
} = {}) => {
isAuthenticated = false,
loginResult = Promise.resolve(true),
version = '1.0.0',
} = {}) => {
const mockLogin = vi.fn().mockReturnValue(loginResult);
const mockLogout = vi.fn();
const mockInitData = vi.fn().mockResolvedValue(undefined);
@ -200,7 +208,7 @@ describe('LoginForm', () => {
it('renders version info when version is available', () => {
setupMocks({ version: '2.3.4' });
renderLoginForm();
expect(screen.getByText(/2.3.4/i)).toBeInTheDocument();
expect(screen.getByText(/2.3.4/i)).toBeInTheDocument();
});
it('renders forgot password anchor', () => {
@ -256,12 +264,17 @@ describe('LoginForm', () => {
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith({ username: 'admin', password: 'pass123' });
expect(mockLogin).toHaveBeenCalledWith({
username: 'admin',
password: 'pass123',
});
});
});
it('calls initData after successful login', async () => {
const { mockInitData } = setupMocks({ loginResult: Promise.resolve(true) });
const { mockInitData } = setupMocks({
loginResult: Promise.resolve(true),
});
renderLoginForm();
fireEvent.change(getUsername(), { target: { value: 'admin' } });
@ -320,7 +333,9 @@ describe('LoginForm', () => {
describe('loading state', () => {
it('disables the login button while loading', async () => {
let resolveLogin;
const loginResult = new Promise((res) => { resolveLogin = res; });
const loginResult = new Promise((res) => {
resolveLogin = res;
});
setupMocks({ loginResult });
renderLoginForm();
@ -357,7 +372,9 @@ describe('LoginForm', () => {
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(localStorage.getItem('dispatcharr_remembered_username')).toBe('savedUser');
expect(localStorage.getItem('dispatcharr_remembered_username')).toBe(
'savedUser'
);
});
});
@ -370,7 +387,9 @@ describe('LoginForm', () => {
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(localStorage.getItem('dispatcharr_remembered_username')).toBeNull();
expect(
localStorage.getItem('dispatcharr_remembered_username')
).toBeNull();
});
});
@ -397,7 +416,9 @@ describe('LoginForm', () => {
});
await waitFor(() => {
expect(localStorage.getItem('dispatcharr_saved_password')).not.toBeNull();
expect(
localStorage.getItem('dispatcharr_saved_password')
).not.toBeNull();
});
});
@ -451,4 +472,4 @@ describe('LoginForm', () => {
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
});
});

View file

@ -26,8 +26,12 @@ vi.mock('@mantine/dropzone', () => ({
{children}
</div>
)),
DropzoneAccept: ({ children }) => <div data-testid="dz-accept">{children}</div>,
DropzoneReject: ({ children }) => <div data-testid="dz-reject">{children}</div>,
DropzoneAccept: ({ children }) => (
<div data-testid="dz-accept">{children}</div>
),
DropzoneReject: ({ children }) => (
<div data-testid="dz-reject">{children}</div>
),
DropzoneIdle: ({ children }) => <div data-testid="dz-idle">{children}</div>,
}));
@ -35,7 +39,13 @@ vi.mock('@mantine/dropzone', () => ({
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, type, loading, variant }) => (
<button type={type || 'button'} onClick={onClick} disabled={loading} data-variant={variant} data-loading={loading}>
<button
type={type || 'button'}
onClick={onClick}
disabled={loading}
data-variant={variant}
data-loading={loading}
>
{children}
</button>
),
@ -49,17 +59,30 @@ 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,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, color }) => (
<span data-size={size} data-color={color}>{children}</span>
<span data-size={size} data-color={color}>
{children}
</span>
),
TextInput: ({ label, placeholder, onChange, onBlur, error, disabled, ...rest }) => (
TextInput: ({
label,
placeholder,
onChange,
onBlur,
error,
disabled,
...rest
}) => (
<div>
<label>{label}
<label>
{label}
<input
aria-label={label}
placeholder={placeholder}
@ -113,9 +136,21 @@ describe('LogoForm', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(LogoUtils.validateFileSize).mockReturnValue(true);
vi.mocked(LogoUtils.createLogo).mockResolvedValue({ id: 2, name: 'New Logo', url: 'https://example.com/new.png' });
vi.mocked(LogoUtils.updateLogo).mockResolvedValue({ id: 1, name: 'Updated Logo', url: 'https://example.com/updated.png' });
vi.mocked(LogoUtils.uploadLogo).mockResolvedValue({ id: 3, name: 'uploaded-logo', url: 'https://cdn.example.com/uploaded.png' });
vi.mocked(LogoUtils.createLogo).mockResolvedValue({
id: 2,
name: 'New Logo',
url: 'https://example.com/new.png',
});
vi.mocked(LogoUtils.updateLogo).mockResolvedValue({
id: 1,
name: 'Updated Logo',
url: 'https://example.com/updated.png',
});
vi.mocked(LogoUtils.uploadLogo).mockResolvedValue({
id: 3,
name: 'uploaded-logo',
url: 'https://cdn.example.com/uploaded.png',
});
vi.mocked(LogoUtils.getResolver).mockReturnValue(undefined);
URL.createObjectURL = vi.fn();
@ -165,7 +200,9 @@ describe('LogoForm', () => {
it('pre-fills URL input when editing existing logo', () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
expect(screen.getByDisplayValue('https://example.com/logo.png')).toBeInTheDocument();
expect(
screen.getByDisplayValue('https://example.com/logo.png')
).toBeInTheDocument();
});
it('shows logo preview when logo has cache_url', () => {
@ -205,9 +242,12 @@ describe('LogoForm', () => {
it('calls createLogo with entered values on submit', async () => {
render(<LogoForm {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'https://example.com/new.png' },
});
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.change(screen.getByPlaceholderText('Enter logo name'), {
target: { value: 'New Logo' },
});
@ -221,9 +261,12 @@ describe('LogoForm', () => {
it('shows success notification after creating logo', async () => {
render(<LogoForm {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'https://example.com/new.png' },
});
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
@ -237,9 +280,12 @@ describe('LogoForm', () => {
const onSuccess = vi.fn();
render(<LogoForm {...defaultProps({ onSuccess })} />);
fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'https://example.com/new.png' },
});
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
@ -253,9 +299,12 @@ describe('LogoForm', () => {
const onClose = vi.fn();
render(<LogoForm {...defaultProps({ onClose })} />);
fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'https://example.com/new.png' },
});
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
@ -267,9 +316,12 @@ describe('LogoForm', () => {
vi.mocked(LogoUtils.createLogo).mockRejectedValue(new Error('API error'));
render(<LogoForm {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'https://example.com/new.png' },
});
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
@ -319,7 +371,9 @@ describe('LogoForm', () => {
});
it('shows error notification when updateLogo throws', async () => {
vi.mocked(LogoUtils.updateLogo).mockRejectedValue(new Error('Server error'));
vi.mocked(LogoUtils.updateLogo).mockRejectedValue(
new Error('Server error')
);
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
fireEvent.click(screen.getByText('Update'));
@ -358,7 +412,6 @@ describe('LogoForm', () => {
});
});
it('shows error notification when file is too large', async () => {
vi.mocked(LogoUtils.validateFileSize).mockReturnValue(false);
@ -382,7 +435,9 @@ describe('LogoForm', () => {
});
it('shows upload error notification when uploadLogo throws', async () => {
vi.mocked(LogoUtils.uploadLogo).mockRejectedValue(new Error('Upload failed'));
vi.mocked(LogoUtils.uploadLogo).mockRejectedValue(
new Error('Upload failed')
);
const file = makeFile('logo.png');
const { Dropzone } = await import('@mantine/dropzone');
vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => (
@ -408,38 +463,56 @@ describe('LogoForm', () => {
describe('URL input behaviour', () => {
it('updates preview when a valid http URL is entered', () => {
render(<LogoForm {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'https://example.com/img.png' },
});
expect(screen.getByAltText('Logo preview')).toHaveAttribute('src', 'https://example.com/img.png');
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/img.png' },
}
);
expect(screen.getByAltText('Logo preview')).toHaveAttribute(
'src',
'https://example.com/img.png'
);
});
it('removes preview when URL is cleared', () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: '' },
});
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: '' },
}
);
expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument();
});
it('auto-fills name from URL on blur', () => {
render(<LogoForm {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'https://example.com/my-channel-logo.png' },
});
fireEvent.blur(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'https://example.com/my-channel-logo.png' },
});
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/my-channel-logo.png' },
}
);
fireEvent.blur(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/my-channel-logo.png' },
}
);
expect(LogoUtils.getFilenameWithoutExtension).toHaveBeenCalled();
});
it('does not throw on blur with invalid URL', () => {
render(<LogoForm {...defaultProps()} />);
expect(() => {
fireEvent.blur(screen.getByPlaceholderText('https://example.com/logo.png'), {
target: { value: 'not-a-url' },
});
fireEvent.blur(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'not-a-url' },
}
);
}).not.toThrow();
});
});
});
});

View file

@ -65,12 +65,15 @@ vi.mock('../ScheduleInput', () => ({
vi.mock('@mantine/dates', () => ({
DateTimePicker: ({ label, value, onChange, placeholder }) => (
<div>
<label>{label}
<label>
{label}
<input
data-testid="date-time-picker"
placeholder={placeholder}
value={value ? value.toISOString() : ''}
onChange={(e) => onChange?.(e.target.value ? new Date(e.target.value) : null)}
onChange={(e) =>
onChange?.(e.target.value ? new Date(e.target.value) : null)
}
/>
</label>
</div>
@ -83,7 +86,9 @@ vi.mock('@mantine/form', () => {
return {
isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')),
__resetFormState: () => { _values = null; },
__resetFormState: () => {
_values = null;
},
useForm: vi.fn(({ initialValues = {} } = {}) => {
if (_values === null) {
_values = { ...initialValues };
@ -92,9 +97,15 @@ vi.mock('@mantine/form', () => {
return {
key: vi.fn((field) => field),
getValues: () => ({ ..._values }),
setValues: (v) => { Object.assign(_values, v); },
setFieldValue: (field, val) => { _values[field] = val; },
reset: () => { _values = { ...initialValues }; },
setValues: (v) => {
Object.assign(_values, v);
},
setFieldValue: (field, val) => {
_values[field] = val;
},
reset: () => {
_values = { ...initialValues };
},
submitting: false,
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
@ -113,7 +124,6 @@ vi.mock('@mantine/form', () => {
};
});
// Mantine core
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
@ -144,7 +154,8 @@ vi.mock('@mantine/core', () => ({
Divider: ({ label }) => <hr aria-label={label} />,
FileInput: ({ label, placeholder, onChange, accept, disabled }) => (
<div>
<label>{label}
<label>
{label}
<input
type="file"
aria-label={label || placeholder}
@ -163,13 +174,25 @@ vi.mock('@mantine/core', () => ({
opened ? (
<div data-testid="modal" data-size={size}>
<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,
NumberInput: ({ label, placeholder, value, onChange, min, max, disabled, error }) => (
NumberInput: ({
label,
placeholder,
value,
onChange,
min,
max,
disabled,
error,
}) => (
<div>
<label>{label}
<label>
{label}
<input
type="number"
aria-label={label || placeholder}
@ -184,9 +207,18 @@ vi.mock('@mantine/core', () => ({
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
PasswordInput: ({ label, placeholder, value, onChange, onBlur, error, disabled }) => (
PasswordInput: ({
label,
placeholder,
value,
onChange,
onBlur,
error,
disabled,
}) => (
<div>
<label>{label}
<label>
{label}
<input
type="password"
aria-label={label || placeholder}
@ -202,7 +234,8 @@ vi.mock('@mantine/core', () => ({
),
Select: ({ label, placeholder, value, onChange, data, disabled, error }) => (
<div>
<label>{label}
<label>
{label}
<select
aria-label={label || placeholder}
value={value || ''}
@ -213,7 +246,11 @@ vi.mock('@mantine/core', () => ({
{data?.map((d) => {
const val = typeof d === 'string' ? d : d.value;
const lbl = typeof d === 'string' ? d : d.label;
return <option key={val} value={val}>{lbl}</option>;
return (
<option key={val} value={val}>
{lbl}
</option>
);
})}
</select>
</label>
@ -234,9 +271,20 @@ vi.mock('@mantine/core', () => ({
{label}
</label>
),
TextInput: ({ id, label, placeholder, value, onChange, onBlur, error, disabled, ...rest }) => (
TextInput: ({
id,
label,
placeholder,
value,
onChange,
onBlur,
error,
disabled,
...rest
}) => (
<div>
<label>{label}
<label>
{label}
<input
data-testid={id ? `text-input-${id}` : undefined}
aria-label={label || placeholder}
@ -337,18 +385,22 @@ const setupStores = (overrides = {}) => {
};
};
//
describe('M3U', () => {
beforeEach(() => {
vi.clearAllMocks();
mantineForm.__resetFormState();
vi.mocked(M3uUtils.addPlaylist).mockResolvedValue(makeM3uAccount({ id: 2 }));
vi.mocked(M3uUtils.addPlaylist).mockResolvedValue(
makeM3uAccount({ id: 2 })
);
vi.mocked(M3uUtils.updatePlaylist).mockResolvedValue(makeM3uAccount());
vi.mocked(M3uUtils.getPlaylist).mockResolvedValue(makeM3uAccount());
vi.mocked(M3uUtils.prepareSubmitValues).mockImplementation((v) => v);
vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({ id: 10, name: 'Dummy EPG' });
vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({
id: 10,
name: 'Dummy EPG',
});
});
// Rendering
@ -536,7 +588,9 @@ describe('M3U', () => {
it('calls onClose after successful update', async () => {
const onClose = vi.fn();
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount(), onClose })} />);
render(
<M3U {...defaultProps({ m3uAccount: makeM3uAccount(), onClose })} />
);
fireEvent.click(screen.getByRole('button', { name: /update|save/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
@ -583,7 +637,9 @@ describe('M3U', () => {
it('pre-fills max_streams when editing', () => {
setupStores();
render(
<M3U {...defaultProps({ m3uAccount: makeM3uAccount({ max_streams: 5 }) })} />
<M3U
{...defaultProps({ m3uAccount: makeM3uAccount({ max_streams: 5 }) })}
/>
);
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
});
@ -637,7 +693,9 @@ describe('M3U', () => {
const urlInput = screen.getByTestId('text-input-server_url');
if (urlInput) {
fireEvent.change(urlInput, { target: { value: 'http://example.com/p.m3u' } });
fireEvent.change(urlInput, {
target: { value: 'http://example.com/p.m3u' },
});
}
fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
@ -647,4 +705,4 @@ describe('M3U', () => {
// addEPG may be called conditionally assert it was called or not based on a checkbox
});
});
});
});

View file

@ -29,7 +29,9 @@ vi.mock('@mantine/form', () => {
return {
isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')),
__resetFormState: () => { _values = null; },
__resetFormState: () => {
_values = null;
},
useForm: vi.fn(({ initialValues = {} } = {}) => {
if (_values === null) {
_values = { ...initialValues };
@ -38,9 +40,15 @@ vi.mock('@mantine/form', () => {
return {
key: vi.fn((field) => field),
getValues: () => ({ ..._values }),
setValues: (v) => { Object.assign(_values, v); },
setFieldValue: (field, val) => { _values[field] = val; },
reset: () => { _values = { ...initialValues }; },
setValues: (v) => {
Object.assign(_values, v);
},
setFieldValue: (field, val) => {
_values[field] = val;
},
reset: () => {
_values = { ...initialValues };
},
submitting: false,
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
@ -50,10 +58,12 @@ vi.mock('@mantine/form', () => {
const isCheckbox = options?.type === 'checkbox';
return {
...(isCheckbox
? { checked: !!(_values?.[field]) }
? { checked: !!_values?.[field] }
: { value: _values?.[field] ?? '' }),
onChange: vi.fn((e) => {
const val = isCheckbox ? (e?.target?.checked ?? e) : (e?.target?.value ?? e);
const val = isCheckbox
? (e?.target?.checked ?? e)
: (e?.target?.value ?? e);
if (_values) _values[field] = val;
}),
error: null,
@ -85,7 +95,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,
@ -124,7 +136,15 @@ vi.mock('@mantine/core', () => ({
{label}
</label>
),
TextInput: ({ label, placeholder, value, onChange, onBlur, error, disabled }) => (
TextInput: ({
label,
placeholder,
value,
onChange,
onBlur,
error,
disabled,
}) => (
<div>
<label>
{label}
@ -175,7 +195,9 @@ const defaultProps = (overrides = {}) => ({
...overrides,
});
const setupStores = ({ fetchPlaylist = vi.fn().mockResolvedValue(undefined) } = {}) => {
const setupStores = ({
fetchPlaylist = vi.fn().mockResolvedValue(undefined),
} = {}) => {
vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
sel({ fetchPlaylist })
);
@ -222,8 +244,12 @@ describe('M3UFilter', () => {
it('renders filter type options from M3U_FILTER_TYPES', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(screen.getByRole('option', { name: 'Include' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Exclude' })).toBeInTheDocument();
expect(
screen.getByRole('option', { name: 'Include' })
).toBeInTheDocument();
expect(
screen.getByRole('option', { name: 'Exclude' })
).toBeInTheDocument();
});
it('renders a text input for the pattern', () => {
@ -336,7 +362,9 @@ describe('M3UFilter', () => {
it('calls updateM3UFilter on submit for an existing filter', async () => {
setupStores();
render(<M3UFilter {...defaultProps({ filter: makeFilter() })} />);
fireEvent.click(screen.getByRole('button', { name: /add|save|update|submit/i }));
fireEvent.click(
screen.getByRole('button', { name: /add|save|update|submit/i })
);
await waitFor(() => {
expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled();
});
@ -345,7 +373,9 @@ describe('M3UFilter', () => {
it('does not call addM3UFilter when updating', async () => {
setupStores();
render(<M3UFilter {...defaultProps({ filter: makeFilter() })} />);
fireEvent.click(screen.getByRole('button', { name: /add|save|update|submit/i }));
fireEvent.click(
screen.getByRole('button', { name: /add|save|update|submit/i })
);
await waitFor(() => {
expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled();
});
@ -355,7 +385,9 @@ describe('M3UFilter', () => {
it('calls fetchPlaylist after successful update', async () => {
const { fetchPlaylist } = setupStores();
render(<M3UFilter {...defaultProps({ filter: makeFilter() })} />);
fireEvent.click(screen.getByRole('button', { name: /add|save|update|submit/i }));
fireEvent.click(
screen.getByRole('button', { name: /add|save|update|submit/i })
);
await waitFor(() => {
expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id);
});
@ -364,11 +396,15 @@ describe('M3UFilter', () => {
it('calls onClose after successful update', async () => {
const onClose = vi.fn();
setupStores();
render(<M3UFilter {...defaultProps({ filter: makeFilter(), onClose })} />);
fireEvent.click(screen.getByRole('button', { name: /add|save|update|submit/i }));
render(
<M3UFilter {...defaultProps({ filter: makeFilter(), onClose })} />
);
fireEvent.click(
screen.getByRole('button', { name: /add|save|update|submit/i })
);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
});
});

View file

@ -160,8 +160,8 @@ vi.mock('@mantine/core', () => ({
useMantineTheme: () => ({
tailwind: {
red: { 6: '#f56565' },
green: { 5: '#48bb78'},
yellow: { 3: '#ecc94b'},
green: { 5: '#48bb78' },
yellow: { 3: '#ecc94b' },
},
}),
}));
@ -189,7 +189,12 @@ const makePlaylist = (overrides = {}) => ({
name: 'Test Playlist',
filters: [
makeFilter({ id: 1, regex_pattern: 'HBO.*', order: 0 }),
makeFilter({ id: 2, filter_type: 'exclude', regex_pattern: 'ESPN.*', order: 1 }),
makeFilter({
id: 2,
filter_type: 'exclude',
regex_pattern: 'ESPN.*',
order: 1,
}),
],
...overrides,
});
@ -202,10 +207,10 @@ const defaultProps = (overrides = {}) => ({
});
const setupStores = ({
fetchPlaylist = vi.fn().mockResolvedValue(undefined),
isWarningSuppressed = vi.fn().mockReturnValue(false),
suppressWarning = vi.fn(),
} = {}) => {
fetchPlaylist = vi.fn().mockResolvedValue(undefined),
isWarningSuppressed = vi.fn().mockReturnValue(false),
suppressWarning = vi.fn(),
} = {}) => {
vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
sel({ fetchPlaylist })
);
@ -268,9 +273,7 @@ describe('M3UFilters', () => {
it('renders an "Add Filter" button', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(
screen.getByRole('button', { name: /new/i })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
});
it('renders filter patterns from playlist', () => {
@ -507,11 +510,7 @@ describe('M3UFilters', () => {
const updatedPlaylist = makePlaylist({
filters: [makeFilter({ id: 3, regex_pattern: 'CNN.*', order: 0 })],
});
rerender(
<M3UFilters
{...defaultProps({ playlist: updatedPlaylist })}
/>
);
rerender(<M3UFilters {...defaultProps({ playlist: updatedPlaylist })} />);
expect(screen.getByText('CNN.*')).toBeInTheDocument();
expect(screen.queryByText('HBO.*')).not.toBeInTheDocument();
});
@ -592,4 +591,4 @@ describe('M3UFilters', () => {
expect(suppressWarning).toBeDefined();
});
});
});
});

View file

@ -1,9 +1,4 @@
import {
render,
screen,
fireEvent,
waitFor,
} from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import M3UGroupFilter from '../M3UGroupFilter';
@ -23,7 +18,12 @@ vi.mock('../../../utils/forms/M3uGroupFilterUtils.js', () => ({
// Sub-component mocks
vi.mock('../LiveGroupFilter', () => ({
default: ({ groupStates, setGroupStates, autoEnableNewGroupsLive, setAutoEnableNewGroupsLive }) => (
default: ({
groupStates,
setGroupStates,
autoEnableNewGroupsLive,
setAutoEnableNewGroupsLive,
}) => (
<div data-testid="live-group-filter">
<span data-testid="live-group-count">{groupStates?.length ?? 0}</span>
<button
@ -44,14 +44,16 @@ vi.mock('../LiveGroupFilter', () => ({
vi.mock('../VODCategoryFilter', () => ({
default: ({
categoryStates,
setCategoryStates,
autoEnableNewGroups,
setAutoEnableNewGroups,
type,
}) => (
categoryStates,
setCategoryStates,
autoEnableNewGroups,
setAutoEnableNewGroups,
type,
}) => (
<div data-testid={`vod-category-filter-${type}`}>
<span data-testid={`${type}-category-count`}>{categoryStates?.length ?? 0}</span>
<span data-testid={`${type}-category-count`}>
{categoryStates?.length ?? 0}
</span>
<button
data-testid={`vod-toggle-auto-${type}`}
onClick={() => setAutoEnableNewGroups?.(!autoEnableNewGroups)}
@ -143,15 +145,13 @@ const defaultProps = (overrides = {}) => ({
});
const setupStores = ({
channelGroups = [makeGroup(), makeGroup({ id: 2, name: 'Group B' })],
fetchCategories = vi.fn().mockResolvedValue(undefined),
} = {}) => {
channelGroups = [makeGroup(), makeGroup({ id: 2, name: 'Group B' })],
fetchCategories = vi.fn().mockResolvedValue(undefined),
} = {}) => {
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ channelGroups })
);
vi.mocked(useVODStore).mockImplementation((sel) =>
sel({ fetchCategories })
);
vi.mocked(useVODStore).mockImplementation((sel) => sel({ fetchCategories }));
return { channelGroups, fetchCategories };
};
@ -160,7 +160,9 @@ const setupStores = ({
describe('M3UGroupFilter', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockResolvedValue(undefined);
vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockResolvedValue(
undefined
);
vi.mocked(M3uGroupFilterUtils.buildGroupStates).mockReturnValue([]);
});
@ -207,8 +209,12 @@ describe('M3UGroupFilter', () => {
it('renders VODCategoryFilter panels', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(screen.getByTestId('vod-category-filter-movie')).toBeInTheDocument();
expect(screen.getByTestId('vod-category-filter-series')).toBeInTheDocument();
expect(
screen.getByTestId('vod-category-filter-movie')
).toBeInTheDocument();
expect(
screen.getByTestId('vod-category-filter-series')
).toBeInTheDocument();
});
it('renders a Save button', () => {
@ -220,7 +226,9 @@ describe('M3UGroupFilter', () => {
it('renders a Cancel button', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /cancel/i })
).toBeInTheDocument();
});
});
@ -249,8 +257,14 @@ describe('M3UGroupFilter', () => {
it('re-initializes when playlist prop changes', async () => {
setupStores();
const { rerender } = render(<M3UGroupFilter {...defaultProps()} />);
const updatedPlaylist = makePlaylist({ id: 2, name: 'Updated Playlist', channel_groups: [{ id: 3, name: 'Group C', playlist_id: 2 }] });
rerender(<M3UGroupFilter {...defaultProps({ playlist: updatedPlaylist })} />);
const updatedPlaylist = makePlaylist({
id: 2,
name: 'Updated Playlist',
channel_groups: [{ id: 3, name: 'Group C', playlist_id: 2 }],
});
rerender(
<M3UGroupFilter {...defaultProps({ playlist: updatedPlaylist })} />
);
await waitFor(() => {
expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith(
expect.anything(),
@ -316,7 +330,9 @@ describe('M3UGroupFilter', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
fireEvent.click(screen.getByTestId('vod-change-series'));
expect(screen.getByTestId('series-category-count')).toHaveTextContent('1');
expect(screen.getByTestId('series-category-count')).toHaveTextContent(
'1'
);
});
it('toggles autoEnableNewGroupsVod when movie VODCategoryFilter fires setAutoEnableNewGroups', () => {
@ -375,7 +391,9 @@ describe('M3UGroupFilter', () => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: expect.stringMatching(/green|teal/) })
expect.objectContaining({
color: expect.stringMatching(/green|teal/),
})
);
});
});
@ -399,7 +417,10 @@ describe('M3UGroupFilter', () => {
it('disables Save button while submitting', async () => {
let resolveSave;
vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockImplementation(
() => new Promise((res) => { resolveSave = res; })
() =>
new Promise((res) => {
resolveSave = res;
})
);
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
@ -409,12 +430,12 @@ describe('M3UGroupFilter', () => {
fireEvent.click(saveBtn);
await waitFor(() => {
expect(
saveBtn.disabled || saveBtn.dataset.loading === 'true'
).toBe(true);
expect(saveBtn.disabled || saveBtn.dataset.loading === 'true').toBe(
true
);
});
resolveSave();
});
});
});
});

View file

@ -87,7 +87,15 @@ vi.mock('@mantine/core', () => ({
{children}
</div>
) : null,
NumberInput: ({ label, value, onChange, disabled, min, max, placeholder }) => (
NumberInput: ({
label,
value,
onChange,
disabled,
min,
max,
placeholder,
}) => (
<div>
<label>{label}</label>
<input
@ -185,10 +193,23 @@ const makeProfile = (overrides = {}) => ({
});
const makeFormMethods = (overrides = {}) => ({
register: vi.fn(() => ({ onChange: vi.fn(), onBlur: vi.fn(), ref: vi.fn(), name: '' })),
handleSubmit: vi.fn((fn) => (e) => { e?.preventDefault?.(); return fn({}); }),
register: vi.fn(() => ({
onChange: vi.fn(),
onBlur: vi.fn(),
ref: vi.fn(),
name: '',
})),
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({});
}),
watch: vi.fn((field) => {
const defaults = { type: 'regex', search_pattern: '', name: '', max_streams: 1 };
const defaults = {
type: 'regex',
search_pattern: '',
name: '',
max_streams: 1,
};
return field ? defaults[field] : defaults;
}),
setValue: vi.fn(),
@ -230,7 +251,9 @@ describe('M3UProfile', () => {
vi.mocked(M3uProfileUtils.buildSubmitValues).mockReturnValue({});
vi.mocked(M3uProfileUtils.getDetectedMode).mockReturnValue('regex');
vi.mocked(M3uProfileUtils.prepareExpDate).mockReturnValue(null);
vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue('http://example.com/stream1');
vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
'http://example.com/stream1'
);
vi.mocked(M3uProfileUtils.applyRegex).mockReturnValue('');
vi.mocked(M3uProfileUtils.applyXcSimplePatterns).mockResolvedValue([]);
vi.mocked(M3uProfileUtils.validateXcSimple).mockReturnValue(true);
@ -258,22 +281,32 @@ describe('M3UProfile', () => {
it('renders "Edit Default Profile" title when editing default profile', () => {
const profile = makeProfile({ is_default: true });
render(<M3UProfile {...defaultProps({ profile })} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(/edit default profile/i);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
/edit default profile/i
);
});
it('renders "M3U Profile" title when not default', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(/M3U profile/i);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
/M3U profile/i
);
});
it('renders a Save button', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /submit/i })
).toBeInTheDocument();
});
it('renders the segmented control for profile type', () => {
const profile = makeProfile();
render(<M3UProfile {...defaultProps({ profile, m3u: makeM3U({ account_type: 'XC' }) })} />);
render(
<M3UProfile
{...defaultProps({ profile, m3u: makeM3U({ account_type: 'XC' }) })}
/>
);
expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
});
@ -284,9 +317,7 @@ describe('M3UProfile', () => {
it('renders the Max Streams field', () => {
render(<M3UProfile {...defaultProps()} />);
expect(
screen.getByTestId(/number-input/i)
).toBeInTheDocument();
expect(screen.getByTestId(/number-input/i)).toBeInTheDocument();
});
it('renders the DateTimePicker for expiration date', () => {
@ -322,7 +353,9 @@ describe('M3UProfile', () => {
it('re-initializes when profile prop changes from null to a value', () => {
const formMethods = setupForm();
const { rerender } = render(<M3UProfile {...defaultProps({ profile: null })} />);
const { rerender } = render(
<M3UProfile {...defaultProps({ profile: null })} />
);
const profile = makeProfile();
rerender(<M3UProfile {...defaultProps({ profile })} />);
expect(formMethods.reset).toHaveBeenCalledTimes(2);
@ -330,7 +363,11 @@ describe('M3UProfile', () => {
it('calls getDetectedMode when m3u is XC type', () => {
const profile = makeProfile();
render(<M3UProfile {...defaultProps({ profile, m3u: makeM3U({ account_type: 'XC' }) })} />);
render(
<M3UProfile
{...defaultProps({ profile, m3u: makeM3U({ account_type: 'XC' }) })}
/>
);
expect(M3uProfileUtils.getDetectedMode).toHaveBeenCalled();
});
});
@ -429,12 +466,18 @@ describe('M3UProfile', () => {
it('calls prepareExpDate when form is submitted with a profile', async () => {
const profile = makeProfile({ exp_date: '2025-12-31T00:00:00Z' });
setupForm({
handleSubmit: vi.fn((fn) => (e) => { e?.preventDefault?.(); return fn({ exp_date: profile.exp_date }); }),
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ exp_date: profile.exp_date });
}),
});
render(<M3UProfile {...defaultProps({ profile })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(M3uProfileUtils.prepareExpDate).toHaveBeenCalledWith(profile.exp_date, false);
expect(M3uProfileUtils.prepareExpDate).toHaveBeenCalledWith(
profile.exp_date,
false
);
});
});
@ -571,4 +614,4 @@ describe('M3UProfile', () => {
});
});
});
});
});

View file

@ -184,11 +184,11 @@ const defaultProps = (overrides = {}) => ({
});
const setupStores = ({
fetchPlaylist = vi.fn().mockResolvedValue(undefined),
suppressWarning = vi.fn(),
isWarningSuppressed = vi.fn().mockReturnValue(false),
profiles = { 1: [makeProfile()] },
} = {}) => {
fetchPlaylist = vi.fn().mockResolvedValue(undefined),
suppressWarning = vi.fn(),
isWarningSuppressed = vi.fn().mockReturnValue(false),
profiles = { 1: [makeProfile()] },
} = {}) => {
vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
sel({ fetchPlaylist, profiles })
);
@ -243,9 +243,7 @@ describe('M3UProfiles', () => {
it('renders an "Add Profile" button', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
expect(
screen.getByRole('button', { name: /new/i })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
});
it('renders a profile card for each profile', () => {
@ -300,7 +298,12 @@ describe('M3UProfiles', () => {
vi.mocked(M3uProfilesUtils.profileSortComparator).mockImplementation(
(a, b) => b.id - a.id
);
const profiles = { 1: [makeProfile({ id: 1, name: 'Alpha' }), makeProfile({ id: 2, name: 'Beta' })] };
const profiles = {
1: [
makeProfile({ id: 1, name: 'Alpha' }),
makeProfile({ id: 2, name: 'Beta' }),
],
};
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
const cards = screen.getAllByTestId('profile-card');
@ -553,4 +556,4 @@ describe('M3UProfiles', () => {
expect(suppressWarning).toBeDefined();
});
});
});
});

View file

@ -14,4 +14,4 @@ export const deleteChannelGroup = (group) => {
};
export const cleanupUnusedChannelGroups = () => {
return API.cleanupUnusedChannelGroups();
};
};

View file

@ -8,4 +8,4 @@ export const updateM3UFilter = (m3u, filter, values) => {
};
export const deleteM3UFilter = async (playlist, id) => {
await API.deleteM3UFilter(playlist.id, id);
};
};

View file

@ -66,4 +66,4 @@ const prepareCategorySettings = (movieCategoryStates, seriesCategoryStates) => {
custom_properties: state.custom_properties || undefined,
}))
.filter((state) => state.enabled !== state.original_enabled);
};
};

View file

@ -120,4 +120,4 @@ export const buildSubmitValues = (
...(isXC ? { xcMode } : {}),
},
};
};
};

View file

@ -34,7 +34,8 @@ export const prepareSubmitValues = (values, expDate) => {
prepared.exp_date = null;
}
const hasCron = prepared.cron_expression && prepared.cron_expression.trim() !== '';
const hasCron =
prepared.cron_expression && prepared.cron_expression.trim() !== '';
if (hasCron) {
prepared.refresh_interval = 0;
} else {
@ -50,4 +51,4 @@ export const prepareSubmitValues = (values, expDate) => {
}
return prepared;
};
};

View file

@ -146,7 +146,9 @@ describe('ChannelGroupUtils', () => {
vi.mocked(API.cleanupUnusedChannelGroups).mockRejectedValue(
new Error('Server error')
);
await expect(cleanupUnusedChannelGroups()).rejects.toThrow('Server error');
await expect(cleanupUnusedChannelGroups()).rejects.toThrow(
'Server error'
);
});
});
});
});

View file

@ -34,7 +34,7 @@ describe('LiveGroupFilterUtils', () => {
});
});
// ── getSelectedAdvancedOptions ───────────────────────────────────────────────
// ── getSelectedAdvancedOptions ───────────────────────────────────────────────
describe('getSelectedAdvancedOptions', () => {
it('returns empty array when custom_properties is empty', () => {
@ -47,47 +47,69 @@ describe('LiveGroupFilterUtils', () => {
});
it('detects force_epg via custom_epg_id', () => {
expect(getSelectedAdvancedOptions({ custom_epg_id: 5 })).toContain('force_epg');
expect(getSelectedAdvancedOptions({ custom_epg_id: 5 })).toContain(
'force_epg'
);
});
it('detects force_epg via force_dummy_epg', () => {
expect(getSelectedAdvancedOptions({ force_dummy_epg: true })).toContain('force_epg');
expect(getSelectedAdvancedOptions({ force_dummy_epg: true })).toContain(
'force_epg'
);
});
it('detects force_epg via force_epg_selected', () => {
expect(getSelectedAdvancedOptions({ force_epg_selected: true })).toContain('force_epg');
expect(
getSelectedAdvancedOptions({ force_epg_selected: true })
).toContain('force_epg');
});
it('detects group_override', () => {
expect(getSelectedAdvancedOptions({ group_override: null })).toContain('group_override');
expect(getSelectedAdvancedOptions({ group_override: null })).toContain(
'group_override'
);
});
it('detects name_regex via name_regex_pattern', () => {
expect(getSelectedAdvancedOptions({ name_regex_pattern: '' })).toContain('name_regex');
expect(getSelectedAdvancedOptions({ name_regex_pattern: '' })).toContain(
'name_regex'
);
});
it('detects name_regex via name_replace_pattern', () => {
expect(getSelectedAdvancedOptions({ name_replace_pattern: '' })).toContain('name_regex');
expect(
getSelectedAdvancedOptions({ name_replace_pattern: '' })
).toContain('name_regex');
});
it('detects name_match_regex', () => {
expect(getSelectedAdvancedOptions({ name_match_regex: '' })).toContain('name_match_regex');
expect(getSelectedAdvancedOptions({ name_match_regex: '' })).toContain(
'name_match_regex'
);
});
it('detects profile_assignment via channel_profile_ids', () => {
expect(getSelectedAdvancedOptions({ channel_profile_ids: [] })).toContain('profile_assignment');
expect(getSelectedAdvancedOptions({ channel_profile_ids: [] })).toContain(
'profile_assignment'
);
});
it('detects channel_sort_order', () => {
expect(getSelectedAdvancedOptions({ channel_sort_order: 'name' })).toContain('channel_sort_order');
expect(
getSelectedAdvancedOptions({ channel_sort_order: 'name' })
).toContain('channel_sort_order');
});
it('detects stream_profile_assignment', () => {
expect(getSelectedAdvancedOptions({ stream_profile_id: null })).toContain('stream_profile_assignment');
expect(getSelectedAdvancedOptions({ stream_profile_id: null })).toContain(
'stream_profile_assignment'
);
});
it('detects custom_logo', () => {
expect(getSelectedAdvancedOptions({ custom_logo_id: null })).toContain('custom_logo');
expect(getSelectedAdvancedOptions({ custom_logo_id: null })).toContain(
'custom_logo'
);
});
it('returns multiple active options', () => {
@ -101,7 +123,7 @@ describe('LiveGroupFilterUtils', () => {
});
});
// ── applyAdvancedOptionsChange ───────────────────────────────────────────────
// ── applyAdvancedOptionsChange ───────────────────────────────────────────────
describe('applyAdvancedOptionsChange', () => {
describe('adding options', () => {
@ -112,12 +134,18 @@ describe('LiveGroupFilterUtils', () => {
it('adds name_regex defaults when newly selected', () => {
const result = applyAdvancedOptionsChange({}, ['name_regex']);
expect(result).toMatchObject({ name_regex_pattern: '', name_replace_pattern: '' });
expect(result).toMatchObject({
name_regex_pattern: '',
name_replace_pattern: '',
});
});
it('adds channel_sort_order defaults including channel_sort_reverse', () => {
const result = applyAdvancedOptionsChange({}, ['channel_sort_order']);
expect(result).toMatchObject({ channel_sort_order: '', channel_sort_reverse: false });
expect(result).toMatchObject({
channel_sort_order: '',
channel_sort_reverse: false,
});
});
it('adds profile_assignment defaults', () => {
@ -137,14 +165,24 @@ describe('LiveGroupFilterUtils', () => {
});
it('adds defaults for multiple options at once', () => {
const result = applyAdvancedOptionsChange({}, ['name_match_regex', 'custom_logo']);
expect(result).toMatchObject({ name_match_regex: '', custom_logo_id: null });
const result = applyAdvancedOptionsChange({}, [
'name_match_regex',
'custom_logo',
]);
expect(result).toMatchObject({
name_match_regex: '',
custom_logo_id: null,
});
});
});
describe('removing options', () => {
it('removes force_epg keys when deselected', () => {
const prev = { force_dummy_epg: true, custom_epg_id: 3, force_epg_selected: true };
const prev = {
force_dummy_epg: true,
custom_epg_id: 3,
force_epg_selected: true,
};
const result = applyAdvancedOptionsChange(prev, []);
expect(result).not.toHaveProperty('force_dummy_epg');
expect(result).not.toHaveProperty('custom_epg_id');
@ -186,7 +224,7 @@ describe('LiveGroupFilterUtils', () => {
});
});
// ── getEpgSourceValue ────────────────────────────────────────────────────────
// ── getEpgSourceValue ────────────────────────────────────────────────────────
describe('getEpgSourceValue', () => {
it('returns custom_epg_id as string when set', () => {
@ -205,7 +243,9 @@ describe('LiveGroupFilterUtils', () => {
});
it('prefers custom_epg_id over force_dummy_epg', () => {
const group = { custom_properties: { custom_epg_id: 3, force_dummy_epg: true } };
const group = {
custom_properties: { custom_epg_id: 3, force_dummy_epg: true },
};
expect(getEpgSourceValue(group)).toBe('3');
});
@ -215,7 +255,7 @@ describe('LiveGroupFilterUtils', () => {
});
});
// ── getEpgSourceData ─────────────────────────────────────────────────────────
// ── getEpgSourceData ─────────────────────────────────────────────────────────
describe('getEpgSourceData', () => {
it('always includes "No EPG (Disabled)" as the first entry', () => {
@ -224,12 +264,16 @@ describe('LiveGroupFilterUtils', () => {
});
it('maps an xmltv source correctly', () => {
const result = getEpgSourceData([makeEpgSource({ id: 1, name: 'My XMLTV', source_type: 'xmltv' })]);
const result = getEpgSourceData([
makeEpgSource({ id: 1, name: 'My XMLTV', source_type: 'xmltv' }),
]);
expect(result).toContainEqual({ value: '1', label: 'My XMLTV (XMLTV)' });
});
it('maps a dummy source correctly', () => {
const result = getEpgSourceData([makeEpgSource({ id: 2, name: 'Dummy', source_type: 'dummy' })]);
const result = getEpgSourceData([
makeEpgSource({ id: 2, name: 'Dummy', source_type: 'dummy' }),
]);
expect(result).toContainEqual({ value: '2', label: 'Dummy (Dummy)' });
});
@ -237,11 +281,16 @@ describe('LiveGroupFilterUtils', () => {
const result = getEpgSourceData([
makeEpgSource({ id: 3, name: 'SD', source_type: 'schedules_direct' }),
]);
expect(result).toContainEqual({ value: '3', label: 'SD (Schedules Direct)' });
expect(result).toContainEqual({
value: '3',
label: 'SD (Schedules Direct)',
});
});
it('falls back to raw source_type for unknown types', () => {
const result = getEpgSourceData([makeEpgSource({ id: 4, name: 'Other', source_type: 'iptv' })]);
const result = getEpgSourceData([
makeEpgSource({ id: 4, name: 'Other', source_type: 'iptv' }),
]);
expect(result).toContainEqual({ value: '4', label: 'Other (iptv)' });
});

View file

@ -36,7 +36,8 @@ describe('LogoUtils', () => {
// ── uploadLogo ─────────────────────────────────────────────────────────────
describe('uploadLogo', () => {
const makeFile = () => new File(['content'], 'logo.png', { type: 'image/png' });
const makeFile = () =>
new File(['content'], 'logo.png', { type: 'image/png' });
const makeValues = (overrides = {}) => ({ name: 'My Logo', ...overrides });
it('calls API.uploadLogo with the selected file and values.name', async () => {
@ -69,14 +70,19 @@ describe('LogoUtils', () => {
it('propagates rejection from API.uploadLogo', async () => {
vi.mocked(API.uploadLogo).mockRejectedValue(new Error('Upload failed'));
await expect(uploadLogo(makeFile(), makeValues())).rejects.toThrow('Upload failed');
await expect(uploadLogo(makeFile(), makeValues())).rejects.toThrow(
'Upload failed'
);
});
});
// ── createLogo ─────────────────────────────────────────────────────────────
describe('createLogo', () => {
const makeValues = () => ({ name: 'New Logo', url: 'https://example.com/logo.png' });
const makeValues = () => ({
name: 'New Logo',
url: 'https://example.com/logo.png',
});
it('calls API.createLogo with the provided values', async () => {
const values = makeValues();
@ -114,8 +120,15 @@ describe('LogoUtils', () => {
// ── updateLogo ─────────────────────────────────────────────────────────────
describe('updateLogo', () => {
const makeLogo = (overrides = {}) => ({ id: 'logo-1', name: 'Old Logo', ...overrides });
const makeValues = () => ({ name: 'Updated Logo', url: 'https://example.com/new.png' });
const makeLogo = (overrides = {}) => ({
id: 'logo-1',
name: 'Old Logo',
...overrides,
});
const makeValues = () => ({
name: 'Updated Logo',
url: 'https://example.com/new.png',
});
it('calls API.updateLogo with the logo id and values', async () => {
const logo = makeLogo();
@ -156,7 +169,9 @@ describe('LogoUtils', () => {
it('propagates rejection from API.updateLogo', async () => {
vi.mocked(API.updateLogo).mockRejectedValue(new Error('Update failed'));
await expect(updateLogo(makeLogo(), makeValues())).rejects.toThrow('Update failed');
await expect(updateLogo(makeLogo(), makeValues())).rejects.toThrow(
'Update failed'
);
});
});
@ -183,7 +198,10 @@ describe('LogoUtils', () => {
const [schema] = vi.mocked(yupResolver).mock.calls[0];
await expect(
schema.validate({ name: 'My Logo', url: 'https://example.com/logo.png' })
schema.validate({
name: 'My Logo',
url: 'https://example.com/logo.png',
})
).resolves.not.toThrow();
});
@ -233,7 +251,9 @@ describe('LogoUtils', () => {
});
it('returns timeout message when message includes "timeout"', () => {
const result = getUploadErrorMessage({ message: 'request timeout occurred' });
const result = getUploadErrorMessage({
message: 'request timeout occurred',
});
expect(result).toBe('Upload timed out. Please try again.');
});
@ -243,7 +263,9 @@ describe('LogoUtils', () => {
});
it('returns body error message when body.error is present', () => {
const result = getUploadErrorMessage({ body: { error: 'Unsupported format' } });
const result = getUploadErrorMessage({
body: { error: 'Unsupported format' },
});
expect(result).toBe('Unsupported format');
});
@ -253,12 +275,18 @@ describe('LogoUtils', () => {
});
it('prioritizes NETWORK_ERROR over status 413', () => {
const result = getUploadErrorMessage({ code: 'NETWORK_ERROR', status: 413 });
const result = getUploadErrorMessage({
code: 'NETWORK_ERROR',
status: 413,
});
expect(result).toBe('Upload timed out. Please try again.');
});
it('prioritizes status 413 over body.error', () => {
const result = getUploadErrorMessage({ status: 413, body: { error: 'Custom error' } });
const result = getUploadErrorMessage({
status: 413,
body: { error: 'Custom error' },
});
expect(result).toBe('File too large. Please choose a smaller file.');
});
});
@ -269,12 +297,16 @@ describe('LogoUtils', () => {
const makeLogo = () => ({ id: 'logo-1', name: 'My Logo' });
it('returns timeout message for NETWORK_ERROR code', () => {
const result = getUpdateLogoErrorMessage(makeLogo(), { code: 'NETWORK_ERROR' });
const result = getUpdateLogoErrorMessage(makeLogo(), {
code: 'NETWORK_ERROR',
});
expect(result).toBe('Request timed out. Please try again.');
});
it('returns timeout message when message includes "timeout"', () => {
const result = getUpdateLogoErrorMessage(makeLogo(), { message: 'connection timeout' });
const result = getUpdateLogoErrorMessage(makeLogo(), {
message: 'connection timeout',
});
expect(result).toBe('Request timed out. Please try again.');
});
@ -312,8 +344,7 @@ describe('LogoUtils', () => {
// ── validateFileSize ───────────────────────────────────────────────────────
describe('validateFileSize', () => {
const makeFile = (sizeBytes) =>
({ size: sizeBytes });
const makeFile = (sizeBytes) => ({ size: sizeBytes });
it('returns true for a file exactly at the 5MB limit', () => {
const file = makeFile(5 * 1024 * 1024);
@ -349,7 +380,9 @@ describe('LogoUtils', () => {
it('calls URL.revokeObjectURL for a blob URL', () => {
releaseUrl('blob:https://example.com/1234');
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:https://example.com/1234');
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'blob:https://example.com/1234'
);
});
it('does not call URL.revokeObjectURL for a non-blob URL', () => {
@ -386,7 +419,9 @@ describe('LogoUtils', () => {
});
it('handles filenames with a .jpg extension', () => {
expect(getFilenameWithoutExtension('channel-logo.jpg')).toBe('channel-logo');
expect(getFilenameWithoutExtension('channel-logo.jpg')).toBe(
'channel-logo'
);
});
it('handles filenames with a .svg extension', () => {
@ -401,4 +436,4 @@ describe('LogoUtils', () => {
expect(getFilenameWithoutExtension('.png')).toBe('');
});
});
});
});

View file

@ -17,9 +17,21 @@ vi.mock('../../../api.js', () => ({
import API from '../../../api.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const makeM3U = (overrides = {}) => ({ id: 'm3u-1', name: 'My Playlist', ...overrides });
const makeFilter = (overrides = {}) => ({ id: 'filter-1', name: 'Filter A', ...overrides });
const makeValues = (overrides = {}) => ({ keyword: 'sports', type: 'include', ...overrides });
const makeM3U = (overrides = {}) => ({
id: 'm3u-1',
name: 'My Playlist',
...overrides,
});
const makeFilter = (overrides = {}) => ({
id: 'filter-1',
name: 'Filter A',
...overrides,
});
const makeValues = (overrides = {}) => ({
keyword: 'sports',
type: 'include',
...overrides,
});
describe('M3uFilterUtils', () => {
beforeEach(() => {
@ -51,7 +63,10 @@ describe('M3uFilterUtils', () => {
await addM3UFilter(m3u, makeValues());
expect(API.addM3UFilter).toHaveBeenCalledWith('m3u-99', expect.anything());
expect(API.addM3UFilter).toHaveBeenCalledWith(
'm3u-99',
expect.anything()
);
});
it('passes values through unmodified', async () => {
@ -66,7 +81,9 @@ describe('M3uFilterUtils', () => {
it('propagates rejection from API.addM3UFilter', async () => {
vi.mocked(API.addM3UFilter).mockRejectedValue(new Error('Add failed'));
await expect(addM3UFilter(makeM3U(), makeValues())).rejects.toThrow('Add failed');
await expect(addM3UFilter(makeM3U(), makeValues())).rejects.toThrow(
'Add failed'
);
});
it('resolves without returning a value', async () => {
@ -86,7 +103,11 @@ describe('M3uFilterUtils', () => {
updateM3UFilter(makeM3U(), makeFilter(), makeValues());
expect(API.updateM3UFilter).toHaveBeenCalledWith('m3u-1', 'filter-1', makeValues());
expect(API.updateM3UFilter).toHaveBeenCalledWith(
'm3u-1',
'filter-1',
makeValues()
);
});
it('returns the result of API.updateM3UFilter', () => {
@ -112,7 +133,11 @@ describe('M3uFilterUtils', () => {
updateM3UFilter(m3u, makeFilter(), makeValues());
expect(API.updateM3UFilter).toHaveBeenCalledWith('m3u-42', expect.anything(), expect.anything());
expect(API.updateM3UFilter).toHaveBeenCalledWith(
'm3u-42',
expect.anything(),
expect.anything()
);
});
it('uses only the id from the filter object', () => {
@ -121,7 +146,11 @@ describe('M3uFilterUtils', () => {
updateM3UFilter(makeM3U(), filter, makeValues());
expect(API.updateM3UFilter).toHaveBeenCalledWith(expect.anything(), 'filter-99', expect.anything());
expect(API.updateM3UFilter).toHaveBeenCalledWith(
expect.anything(),
'filter-99',
expect.anything()
);
});
it('passes values through unmodified', () => {
@ -130,7 +159,11 @@ describe('M3uFilterUtils', () => {
updateM3UFilter(makeM3U(), makeFilter(), values);
expect(API.updateM3UFilter).toHaveBeenCalledWith(expect.anything(), expect.anything(), values);
expect(API.updateM3UFilter).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
values
);
});
});
@ -159,7 +192,10 @@ describe('M3uFilterUtils', () => {
await deleteM3UFilter(playlist, 'filter-1');
expect(API.deleteM3UFilter).toHaveBeenCalledWith('playlist-99', expect.anything());
expect(API.deleteM3UFilter).toHaveBeenCalledWith(
'playlist-99',
expect.anything()
);
});
it('passes the filter id through unmodified', async () => {
@ -167,13 +203,20 @@ describe('M3uFilterUtils', () => {
await deleteM3UFilter(makeM3U(), 'filter-42');
expect(API.deleteM3UFilter).toHaveBeenCalledWith(expect.anything(), 'filter-42');
expect(API.deleteM3UFilter).toHaveBeenCalledWith(
expect.anything(),
'filter-42'
);
});
it('propagates rejection from API.deleteM3UFilter', async () => {
vi.mocked(API.deleteM3UFilter).mockRejectedValue(new Error('Delete failed'));
vi.mocked(API.deleteM3UFilter).mockRejectedValue(
new Error('Delete failed')
);
await expect(deleteM3UFilter(makeM3U(), 'filter-1')).rejects.toThrow('Delete failed');
await expect(deleteM3UFilter(makeM3U(), 'filter-1')).rejects.toThrow(
'Delete failed'
);
});
it('resolves without returning a value', async () => {
@ -184,4 +227,4 @@ describe('M3uFilterUtils', () => {
expect(result).toBeUndefined();
});
});
});
});

View file

@ -21,7 +21,11 @@ import API from '../../../api.js';
import { refreshPlaylist, updatePlaylist } from '../M3uUtils.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const makePlaylist = (overrides = {}) => ({ id: 'playlist-1', name: 'My Playlist', ...overrides });
const makePlaylist = (overrides = {}) => ({
id: 'playlist-1',
name: 'My Playlist',
...overrides,
});
const makeChannelGroups = () => ({
'group-1': { name: 'Sports' },
@ -124,7 +128,10 @@ describe('M3uGroupFilterUtils', () => {
});
it('spreads all original group properties into the result', () => {
const group = makePlaylistChannelGroup({ enabled: true, extra_prop: 'value' });
const group = makePlaylistChannelGroup({
enabled: true,
extra_prop: 'value',
});
const result = buildGroupStates(makeChannelGroups(), [group]);
expect(result[0].enabled).toBe(true);
expect(result[0].extra_prop).toBe('value');
@ -199,7 +206,13 @@ describe('M3uGroupFilterUtils', () => {
it('calls API.updateM3UGroupSettings with playlist id, groupSettings, and categorySettings', async () => {
const playlist = makePlaylist();
await saveAndRefreshPlaylist(playlist, [], [], [], makeAutoEnableSettings());
await saveAndRefreshPlaylist(
playlist,
[],
[],
[],
makeAutoEnableSettings()
);
expect(API.updateM3UGroupSettings).toHaveBeenCalledWith(
'playlist-1',
@ -211,13 +224,25 @@ describe('M3uGroupFilterUtils', () => {
it('calls refreshPlaylist with playlist', async () => {
const playlist = makePlaylist();
await saveAndRefreshPlaylist(playlist, [], [], [], makeAutoEnableSettings());
await saveAndRefreshPlaylist(
playlist,
[],
[],
[],
makeAutoEnableSettings()
);
expect(refreshPlaylist).toHaveBeenCalledWith(playlist);
});
it('calls updatePlaylist, updateM3UGroupSettings, and refreshPlaylist exactly once each', async () => {
await saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings());
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
);
expect(updatePlaylist).toHaveBeenCalledTimes(1);
expect(API.updateM3UGroupSettings).toHaveBeenCalledTimes(1);
@ -226,11 +251,23 @@ describe('M3uGroupFilterUtils', () => {
it('calls updatePlaylist before updateM3UGroupSettings', async () => {
const callOrder = [];
vi.mocked(updatePlaylist).mockImplementation(async () => { callOrder.push('updatePlaylist'); });
vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => { callOrder.push('updateM3UGroupSettings'); });
vi.mocked(refreshPlaylist).mockImplementation(async () => { callOrder.push('refreshPlaylist'); });
vi.mocked(updatePlaylist).mockImplementation(async () => {
callOrder.push('updatePlaylist');
});
vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => {
callOrder.push('updateM3UGroupSettings');
});
vi.mocked(refreshPlaylist).mockImplementation(async () => {
callOrder.push('refreshPlaylist');
});
await saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings());
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
);
expect(callOrder.indexOf('updatePlaylist')).toBeLessThan(
callOrder.indexOf('updateM3UGroupSettings')
@ -239,11 +276,23 @@ describe('M3uGroupFilterUtils', () => {
it('calls updateM3UGroupSettings before refreshPlaylist', async () => {
const callOrder = [];
vi.mocked(updatePlaylist).mockImplementation(async () => { callOrder.push('updatePlaylist'); });
vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => { callOrder.push('updateM3UGroupSettings'); });
vi.mocked(refreshPlaylist).mockImplementation(async () => { callOrder.push('refreshPlaylist'); });
vi.mocked(updatePlaylist).mockImplementation(async () => {
callOrder.push('updatePlaylist');
});
vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => {
callOrder.push('updateM3UGroupSettings');
});
vi.mocked(refreshPlaylist).mockImplementation(async () => {
callOrder.push('refreshPlaylist');
});
await saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings());
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
);
expect(callOrder.indexOf('updateM3UGroupSettings')).toBeLessThan(
callOrder.indexOf('refreshPlaylist')
@ -254,15 +303,29 @@ describe('M3uGroupFilterUtils', () => {
vi.mocked(updatePlaylist).mockRejectedValue(new Error('Update failed'));
await expect(
saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings())
saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
)
).rejects.toThrow('Update failed');
});
it('propagates rejection from API.updateM3UGroupSettings', async () => {
vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(new Error('Settings failed'));
vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(
new Error('Settings failed')
);
await expect(
saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings())
saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
)
).rejects.toThrow('Settings failed');
});
@ -270,14 +333,28 @@ describe('M3uGroupFilterUtils', () => {
vi.mocked(refreshPlaylist).mockRejectedValue(new Error('Refresh failed'));
await expect(
saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings())
saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
)
).rejects.toThrow('Refresh failed');
});
it('does not call refreshPlaylist when updateM3UGroupSettings rejects', async () => {
vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(new Error('fail'));
vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(
new Error('fail')
);
await saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings()).catch(() => {});
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
).catch(() => {});
expect(refreshPlaylist).not.toHaveBeenCalled();
});
@ -286,50 +363,95 @@ describe('M3uGroupFilterUtils', () => {
describe('prepareCategorySettings (via API.updateM3UGroupSettings call)', () => {
it('includes category states where enabled differs from original_enabled', async () => {
const changedMovie = makeCategoryState({ enabled: true, original_enabled: false });
const unchangedMovie = makeCategoryState({ id: 'cat-2', enabled: false, original_enabled: false });
const changedMovie = makeCategoryState({
enabled: true,
original_enabled: false,
});
const unchangedMovie = makeCategoryState({
id: 'cat-2',
enabled: false,
original_enabled: false,
});
await saveAndRefreshPlaylist(
makePlaylist(), [], [changedMovie], [unchangedMovie], makeAutoEnableSettings()
makePlaylist(),
[],
[changedMovie],
[unchangedMovie],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0];
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings).toHaveLength(1);
expect(categorySettings[0].id).toBe('cat-1');
});
it('excludes category states where enabled equals original_enabled', async () => {
const unchanged = makeCategoryState({ enabled: true, original_enabled: true });
const unchanged = makeCategoryState({
enabled: true,
original_enabled: true,
});
await saveAndRefreshPlaylist(
makePlaylist(), [], [unchanged], [], makeAutoEnableSettings()
makePlaylist(),
[],
[unchanged],
[],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0];
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings).toHaveLength(0);
});
it('merges movie and series category states together', async () => {
const movieCat = makeCategoryState({ id: 'movie-1', enabled: true, original_enabled: false });
const seriesCat = makeCategoryState({ id: 'series-1', enabled: false, original_enabled: true });
const movieCat = makeCategoryState({
id: 'movie-1',
enabled: true,
original_enabled: false,
});
const seriesCat = makeCategoryState({
id: 'series-1',
enabled: false,
original_enabled: true,
});
await saveAndRefreshPlaylist(
makePlaylist(), [], [movieCat], [seriesCat], makeAutoEnableSettings()
makePlaylist(),
[],
[movieCat],
[seriesCat],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0];
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings).toHaveLength(2);
expect(categorySettings.map((c) => c.id)).toEqual(['movie-1', 'series-1']);
expect(categorySettings.map((c) => c.id)).toEqual([
'movie-1',
'series-1',
]);
});
it('sets custom_properties to undefined when null', async () => {
const cat = makeCategoryState({ custom_properties: null, enabled: true, original_enabled: false });
const cat = makeCategoryState({
custom_properties: null,
enabled: true,
original_enabled: false,
});
await saveAndRefreshPlaylist(
makePlaylist(), [], [cat], [], makeAutoEnableSettings()
makePlaylist(),
[],
[cat],
[],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0];
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings[0].custom_properties).toBeUndefined();
});
@ -341,12 +463,17 @@ describe('M3uGroupFilterUtils', () => {
});
await saveAndRefreshPlaylist(
makePlaylist(), [], [cat], [], makeAutoEnableSettings()
makePlaylist(),
[],
[cat],
[],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0];
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings[0].custom_properties).toEqual({ key: 'value' });
});
});
});
});
});

View file

@ -26,8 +26,16 @@ vi.mock('../../../api.js', () => ({
import API from '../../../api.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const makeM3U = (overrides = {}) => ({ id: 'm3u-1', name: 'My Playlist', ...overrides });
const makeSubmitValues = (overrides = {}) => ({ name: 'Profile A', xcMode: false, ...overrides });
const makeM3U = (overrides = {}) => ({
id: 'm3u-1',
name: 'My Playlist',
...overrides,
});
const makeSubmitValues = (overrides = {}) => ({
name: 'Profile A',
xcMode: false,
...overrides,
});
describe('M3uProfileUtils', () => {
beforeEach(() => {
@ -61,7 +69,10 @@ describe('M3uProfileUtils', () => {
await updateM3UProfile(m3u, makeSubmitValues());
expect(API.updateM3UProfile).toHaveBeenCalledWith('playlist-99', expect.anything());
expect(API.updateM3UProfile).toHaveBeenCalledWith(
'playlist-99',
expect.anything()
);
});
it('passes submitValues through unmodified', async () => {
@ -70,13 +81,20 @@ describe('M3uProfileUtils', () => {
await updateM3UProfile(makeM3U(), values);
expect(API.updateM3UProfile).toHaveBeenCalledWith(expect.anything(), values);
expect(API.updateM3UProfile).toHaveBeenCalledWith(
expect.anything(),
values
);
});
it('propagates rejection from API.updateM3UProfile', async () => {
vi.mocked(API.updateM3UProfile).mockRejectedValue(new Error('Update failed'));
vi.mocked(API.updateM3UProfile).mockRejectedValue(
new Error('Update failed')
);
await expect(updateM3UProfile(makeM3U(), makeSubmitValues())).rejects.toThrow('Update failed');
await expect(
updateM3UProfile(makeM3U(), makeSubmitValues())
).rejects.toThrow('Update failed');
});
it('resolves without returning a value', async () => {
@ -88,7 +106,7 @@ describe('M3uProfileUtils', () => {
});
});
// ── addM3UProfile ──────────────────────────────────────────────────────────
// ── addM3UProfile ──────────────────────────────────────────────────────────
describe('addM3UProfile', () => {
it('calls API.addM3UProfile with the m3u id and submitValues', async () => {
@ -115,7 +133,10 @@ describe('M3uProfileUtils', () => {
await addM3UProfile(m3u, makeSubmitValues());
expect(API.addM3UProfile).toHaveBeenCalledWith('playlist-42', expect.anything());
expect(API.addM3UProfile).toHaveBeenCalledWith(
'playlist-42',
expect.anything()
);
});
it('passes submitValues through unmodified', async () => {
@ -130,7 +151,9 @@ describe('M3uProfileUtils', () => {
it('propagates rejection from API.addM3UProfile', async () => {
vi.mocked(API.addM3UProfile).mockRejectedValue(new Error('Add failed'));
await expect(addM3UProfile(makeM3U(), makeSubmitValues())).rejects.toThrow('Add failed');
await expect(
addM3UProfile(makeM3U(), makeSubmitValues())
).rejects.toThrow('Add failed');
});
it('resolves without returning a value', async () => {
@ -142,7 +165,7 @@ describe('M3uProfileUtils', () => {
});
});
// ── deleteM3UProfile ───────────────────────────────────────────────────────
// ── deleteM3UProfile ───────────────────────────────────────────────────────
describe('deleteM3UProfile', () => {
it('calls API.deleteM3UProfile with the playlist id and profile id', async () => {
@ -167,7 +190,10 @@ describe('M3uProfileUtils', () => {
await deleteM3UProfile(playlist, 'profile-1');
expect(API.deleteM3UProfile).toHaveBeenCalledWith('playlist-99', expect.anything());
expect(API.deleteM3UProfile).toHaveBeenCalledWith(
'playlist-99',
expect.anything()
);
});
it('passes the profile id through unmodified', async () => {
@ -175,13 +201,20 @@ describe('M3uProfileUtils', () => {
await deleteM3UProfile(makeM3U(), 'profile-42');
expect(API.deleteM3UProfile).toHaveBeenCalledWith(expect.anything(), 'profile-42');
expect(API.deleteM3UProfile).toHaveBeenCalledWith(
expect.anything(),
'profile-42'
);
});
it('propagates rejection from API.deleteM3UProfile', async () => {
vi.mocked(API.deleteM3UProfile).mockRejectedValue(new Error('Delete failed'));
vi.mocked(API.deleteM3UProfile).mockRejectedValue(
new Error('Delete failed')
);
await expect(deleteM3UProfile(makeM3U(), 'profile-1')).rejects.toThrow('Delete failed');
await expect(deleteM3UProfile(makeM3U(), 'profile-1')).rejects.toThrow(
'Delete failed'
);
});
it('resolves without returning a value', async () => {
@ -218,7 +251,9 @@ describe('M3uProfileUtils', () => {
it('returns "simple" when profile has no search_pattern', () => {
const profile = {};
expect(getDetectedMode(null, profile, { username: 'u', password: 'p' })).toBe('simple');
expect(
getDetectedMode(null, profile, { username: 'u', password: 'p' })
).toBe('simple');
});
});
@ -252,8 +287,12 @@ describe('M3uProfileUtils', () => {
describe('buildProfileSchema', () => {
it('requires search_pattern and replace_pattern when not default and not XC', async () => {
const schema = buildProfileSchema(false, false);
await expect(schema.validate({ name: 'Test' }, { abortEarly: false })).rejects.toThrow();
const error = await schema.validate({ name: 'Test' }, { abortEarly: false }).catch((e) => e);
await expect(
schema.validate({ name: 'Test' }, { abortEarly: false })
).rejects.toThrow();
const error = await schema
.validate({ name: 'Test' }, { abortEarly: false })
.catch((e) => e);
expect(error.errors).toContain('Search pattern is required');
expect(error.errors).toContain('Replace pattern is required');
});
@ -270,14 +309,20 @@ describe('M3uProfileUtils', () => {
it('requires name in all cases', async () => {
const schema = buildProfileSchema(true, true);
const error = await schema.validate({}, { abortEarly: false }).catch((e) => e);
const error = await schema
.validate({}, { abortEarly: false })
.catch((e) => e);
expect(error.errors).toContain('Name is required');
});
it('validates successfully with all required fields', async () => {
const schema = buildProfileSchema(false, false);
await expect(
schema.validate({ name: 'Test', search_pattern: 'foo', replace_pattern: 'bar' })
schema.validate({
name: 'Test',
search_pattern: 'foo',
replace_pattern: 'bar',
})
).resolves.toBeTruthy();
});
});
@ -288,7 +333,9 @@ describe('M3uProfileUtils', () => {
beforeEach(() => vi.clearAllMocks());
it('returns url from first result', async () => {
API.queryStreams.mockResolvedValue({ results: [{ url: 'http://stream.url' }] });
API.queryStreams.mockResolvedValue({
results: [{ url: 'http://stream.url' }],
});
const url = await fetchFirstStreamUrl(5);
expect(url).toBe('http://stream.url');
const params = API.queryStreams.mock.calls[0][0];
@ -386,7 +433,12 @@ describe('M3uProfileUtils', () => {
it('trims whitespace from newUsername and newPassword', () => {
const m3u = { username: 'u', password: 'p' };
const result = applyXcSimplePatterns({}, m3u, ' newUser ', ' newPass ');
const result = applyXcSimplePatterns(
{},
m3u,
' newUser ',
' newPass '
);
expect(result.replace_pattern).toBe('newUser/newPass');
});
});
@ -427,13 +479,25 @@ describe('M3uProfileUtils', () => {
it('includes xcMode in custom_properties when isXC is true', () => {
const profile = { custom_properties: {} };
const result = buildSubmitValues(baseValues, profile, false, true, 'simple');
const result = buildSubmitValues(
baseValues,
profile,
false,
true,
'simple'
);
expect(result.custom_properties.xcMode).toBe('simple');
});
it('does not include xcMode when isXC is false', () => {
const profile = { custom_properties: {} };
const result = buildSubmitValues(baseValues, profile, false, false, 'simple');
const result = buildSubmitValues(
baseValues,
profile,
false,
false,
'simple'
);
expect(result.custom_properties.xcMode).toBeUndefined();
});

View file

@ -65,7 +65,9 @@ describe('M3uProfilesUtils', () => {
});
it('returns null when exp_date is missing', () => {
expect(parseExpirationDate({ custom_properties: { user_info: {} } })).toBeNull();
expect(
parseExpirationDate({ custom_properties: { user_info: {} } })
).toBeNull();
});
it('returns null when exp_date is null', () => {
@ -211,7 +213,10 @@ describe('M3uProfilesUtils', () => {
// ── profileSortComparator ──────────────────────────────────────────────────
describe('profileSortComparator', () => {
const makeProfile = (name, isDefault = false) => ({ name, is_default: isDefault });
const makeProfile = (name, isDefault = false) => ({
name,
is_default: isDefault,
});
it('sorts default profile before non-default', () => {
const defaultProfile = makeProfile('Zebra', true);
@ -279,4 +284,4 @@ describe('M3uProfilesUtils', () => {
expect(sorted.map((p) => p.name)).toEqual(['Alpha', 'Mu', 'Zeta']);
});
});
});
});

View file

@ -21,7 +21,7 @@ import API from '../../../api.js';
describe('M3uUtils', () => {
beforeEach(() => vi.clearAllMocks());
// ── updatePlaylist ─────────────────────────────────────────────────────────────
// ── updatePlaylist ─────────────────────────────────────────────────────────────
describe('updatePlaylist', () => {
it('calls API.updatePlaylist with merged id, values, and file', () => {
@ -29,7 +29,11 @@ describe('M3uUtils', () => {
const values = { name: 'Test' };
const file = new File([''], 'test.m3u');
updatePlaylist(playlist, values, file);
expect(API.updatePlaylist).toHaveBeenCalledWith({ id: 1, name: 'Test', file });
expect(API.updatePlaylist).toHaveBeenCalledWith({
id: 1,
name: 'Test',
file,
});
});
it('returns the result of API.updatePlaylist', async () => {
@ -40,11 +44,15 @@ describe('M3uUtils', () => {
it('passes null file when no file provided', () => {
updatePlaylist({ id: 2 }, { name: 'No File' }, null);
expect(API.updatePlaylist).toHaveBeenCalledWith({ id: 2, name: 'No File', file: null });
expect(API.updatePlaylist).toHaveBeenCalledWith({
id: 2,
name: 'No File',
file: null,
});
});
});
// ── addPlaylist ────────────────────────────────────────────────────────────────
// ── addPlaylist ────────────────────────────────────────────────────────────────
describe('addPlaylist', () => {
it('calls API.addPlaylist with merged values and file', async () => {
@ -61,11 +69,14 @@ describe('M3uUtils', () => {
it('passes null file when no file provided', async () => {
await addPlaylist({ name: 'No File' }, null);
expect(API.addPlaylist).toHaveBeenCalledWith({ name: 'No File', file: null });
expect(API.addPlaylist).toHaveBeenCalledWith({
name: 'No File',
file: null,
});
});
});
// ── getPlaylist ────────────────────────────────────────────────────────────────
// ── getPlaylist ────────────────────────────────────────────────────────────────
describe('getPlaylist', () => {
it('calls API.getPlaylist with the playlist id', async () => {
@ -81,7 +92,7 @@ describe('M3uUtils', () => {
});
});
// ── refreshPlaylist ────────────────────────────────────────────────────────────
// ── refreshPlaylist ────────────────────────────────────────────────────────────
describe('refreshPlaylist', () => {
it('calls API.refreshPlaylist with the playlist id', async () => {
@ -97,7 +108,7 @@ describe('M3uUtils', () => {
});
});
// ── prepareSubmitValues ────────────────────────────────────────────────────────
// ── prepareSubmitValues ────────────────────────────────────────────────────────
describe('prepareSubmitValues', () => {
describe('exp_date handling', () => {