mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-19 01:25:06 +00:00
Formatting changes
This commit is contained in:
parent
24a25821fc
commit
7eeecd8701
29 changed files with 1418 additions and 505 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,4 +14,4 @@ export const deleteChannelGroup = (group) => {
|
|||
};
|
||||
export const cleanupUnusedChannelGroups = () => {
|
||||
return API.cleanupUnusedChannelGroups();
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ export const updateM3UFilter = (m3u, filter, values) => {
|
|||
};
|
||||
export const deleteM3UFilter = async (playlist, id) => {
|
||||
await API.deleteM3UFilter(playlist.id, id);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -66,4 +66,4 @@ const prepareCategorySettings = (movieCategoryStates, seriesCategoryStates) => {
|
|||
custom_properties: state.custom_properties || undefined,
|
||||
}))
|
||||
.filter((state) => state.enabled !== state.original_enabled);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -120,4 +120,4 @@ export const buildSubmitValues = (
|
|||
...(isXC ? { xcMode } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)' });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue