mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-19 01:25:06 +00:00
Splitting up LiveGroupFilter component
Added AutoSyncBasic, AutoSyncAdvanced, AutoSyncOrphanCleanup.
This commit is contained in:
parent
d824cd1646
commit
64f19237f9
14 changed files with 4333 additions and 1779 deletions
756
frontend/src/components/forms/AutoSyncAdvanced.jsx
Normal file
756
frontend/src/components/forms/AutoSyncAdvanced.jsx
Normal file
|
|
@ -0,0 +1,756 @@
|
|||
// Shared preview box for include and exclude filters. The marker and
|
||||
// color reflect whether matched names are kept (teal check) or dropped
|
||||
// (red x); empty/loading/error states mirror the find preview.
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Popover,
|
||||
PopoverDropdown,
|
||||
PopoverTarget,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { getRegexOptions } from '../../utils/forms/LiveGroupFilterUtils.js';
|
||||
import React from 'react';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import logo from '../../images/logo.png';
|
||||
import LazyLogo from '../LazyLogo.jsx';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
formatPreviewSummary,
|
||||
getEpgSourceData,
|
||||
getEpgSourceValue,
|
||||
repackGroupChannels,
|
||||
} from '../../utils/forms/AutoSyncAdvancedUtils.js';
|
||||
|
||||
const RegexPreviewBox = ({ group, kind, regexPreviewState }) => {
|
||||
const pattern =
|
||||
kind === 'exclude'
|
||||
? group.custom_properties?.name_match_exclude_regex || ''
|
||||
: kind === 'include'
|
||||
? group.custom_properties?.name_match_regex || ''
|
||||
: group.custom_properties?.name_regex_pattern || '';
|
||||
if (!pattern) return null;
|
||||
const state = regexPreviewState[group.channel_group] || {};
|
||||
const result =
|
||||
kind === 'exclude'
|
||||
? state.excludeResult
|
||||
: kind === 'include'
|
||||
? state.filterResult
|
||||
: state.findResult;
|
||||
const loading = state.loading;
|
||||
const summaryLabel =
|
||||
kind === 'exclude' ? 'exclude' : kind === 'include' ? 'filter' : 'rename';
|
||||
const placeholderLabel =
|
||||
kind === 'exclude'
|
||||
? 'Exclude preview'
|
||||
: kind === 'include'
|
||||
? 'Filter preview'
|
||||
: 'Preview';
|
||||
const markerChar = kind === 'exclude' ? '✗' : '✓';
|
||||
const markerColor = kind === 'exclude' ? 'red.4' : 'teal.4';
|
||||
const emptyText =
|
||||
kind === 'exclude'
|
||||
? 'No streams matched this pattern (nothing would be excluded).'
|
||||
: 'No streams matched this pattern.';
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
border: '1px solid #3F3F46',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
backgroundColor: '#1E1E22',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={600} mb={4}>
|
||||
{result ? formatPreviewSummary(summaryLabel, result) : placeholderLabel}
|
||||
</Text>
|
||||
{result?.error && (
|
||||
<Text size="xs" c="red.5">
|
||||
Invalid regex: {result.error}
|
||||
</Text>
|
||||
)}
|
||||
{loading && !result && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Scanning streams...
|
||||
</Text>
|
||||
)}
|
||||
{result && !result.error && result.matches?.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{result.total_in_group === 0
|
||||
? 'No streams in this group yet. Run an M3U refresh first to populate streams.'
|
||||
: emptyText}
|
||||
</Text>
|
||||
)}
|
||||
{result?.matches?.map((row, idx) =>
|
||||
kind === 'find' ? (
|
||||
<Flex
|
||||
key={`${row.before}-${idx}`}
|
||||
gap="xs"
|
||||
align="center"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
<Text size="xs" c="dimmed" style={{ flex: 1 }} truncate>
|
||||
{row.before}
|
||||
</Text>
|
||||
<Text size="xs" c="gray.5">
|
||||
{' -> '}
|
||||
</Text>
|
||||
<Text size="xs" c="teal.4" style={{ flex: 1 }} truncate>
|
||||
{row.after}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex
|
||||
key={`${row.name}-${idx}`}
|
||||
gap="xs"
|
||||
align="center"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
<Text size="xs" c={markerColor} style={{ width: 18 }}>
|
||||
{markerChar}
|
||||
</Text>
|
||||
<Text size="xs" c="gray.2" style={{ flex: 1 }} truncate>
|
||||
{row.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Advanced Options form rendered inside the gear modal. A field's
|
||||
// presence in custom_properties activates it; blanking returns the
|
||||
// group to default behavior.
|
||||
const AutoSyncAdvanced = ({
|
||||
group,
|
||||
epgSources,
|
||||
channelGroups,
|
||||
streamProfiles,
|
||||
regexPreviewState,
|
||||
onApplyGroupChange,
|
||||
onScheduleRegexPreview,
|
||||
onOpenLogoUpload,
|
||||
channelLogos,
|
||||
playlist,
|
||||
logosLoading,
|
||||
ensureLogosLoaded,
|
||||
}) => {
|
||||
const cp = group.custom_properties || {};
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
|
||||
const setCp = (patch, clears = []) => {
|
||||
const next = { ...cp, ...patch };
|
||||
clears.forEach((k) => delete next[k]);
|
||||
onApplyGroupChange({ ...group, custom_properties: next });
|
||||
};
|
||||
|
||||
// --- Name Transforms ---
|
||||
|
||||
const findValue = cp.name_regex_pattern ?? '';
|
||||
const replaceValue = cp.name_replace_pattern ?? '';
|
||||
const filterValue = cp.name_match_regex ?? '';
|
||||
const excludeValue = cp.name_match_exclude_regex ?? '';
|
||||
const updateFind = (val) => {
|
||||
if (!val && !replaceValue) {
|
||||
setCp({}, ['name_regex_pattern', 'name_replace_pattern']);
|
||||
} else {
|
||||
setCp({
|
||||
name_regex_pattern: val,
|
||||
name_replace_pattern: replaceValue,
|
||||
});
|
||||
}
|
||||
onScheduleRegexPreview(
|
||||
group,
|
||||
getRegexOptions(val, replaceValue, filterValue, excludeValue)
|
||||
);
|
||||
};
|
||||
const updateReplace = (val) => {
|
||||
if (!val && !findValue) {
|
||||
setCp({}, ['name_regex_pattern', 'name_replace_pattern']);
|
||||
} else {
|
||||
setCp({
|
||||
name_regex_pattern: findValue,
|
||||
name_replace_pattern: val,
|
||||
});
|
||||
}
|
||||
onScheduleRegexPreview(
|
||||
group,
|
||||
getRegexOptions(findValue, val, filterValue, excludeValue)
|
||||
);
|
||||
};
|
||||
const updateFilter = (val) => {
|
||||
if (!val) setCp({}, ['name_match_regex']);
|
||||
else setCp({ name_match_regex: val });
|
||||
onScheduleRegexPreview(
|
||||
group,
|
||||
getRegexOptions(findValue, replaceValue, val, excludeValue)
|
||||
);
|
||||
};
|
||||
const updateExclude = (val) => {
|
||||
if (!val) setCp({}, ['name_match_exclude_regex']);
|
||||
else setCp({ name_match_exclude_regex: val });
|
||||
onScheduleRegexPreview(
|
||||
group,
|
||||
getRegexOptions(findValue, replaceValue, filterValue, val)
|
||||
);
|
||||
};
|
||||
|
||||
// --- EPG ---
|
||||
|
||||
const epgValue = getEpgSourceValue(cp);
|
||||
const updateEpg = (value) => {
|
||||
const next = { ...cp };
|
||||
delete next.custom_epg_id;
|
||||
delete next.force_dummy_epg;
|
||||
delete next.force_epg_selected;
|
||||
if (value === '0') {
|
||||
next.force_dummy_epg = true;
|
||||
} else if (value) {
|
||||
next.custom_epg_id = parseInt(value);
|
||||
}
|
||||
onApplyGroupChange({ ...group, custom_properties: next });
|
||||
};
|
||||
|
||||
// --- Channel Assignment ---
|
||||
|
||||
const groupOverrideValue = cp.group_override
|
||||
? cp.group_override.toString()
|
||||
: '';
|
||||
const updateGroupOverride = (value) => {
|
||||
if (!value) setCp({}, ['group_override']);
|
||||
else setCp({ group_override: parseInt(value) });
|
||||
};
|
||||
|
||||
const profileValue = cp.channel_profile_ids ?? [];
|
||||
const updateProfiles = (value) => {
|
||||
if (!value || value.length === 0) {
|
||||
setCp({}, ['channel_profile_ids']);
|
||||
} else {
|
||||
setCp({ channel_profile_ids: value });
|
||||
}
|
||||
};
|
||||
|
||||
const streamProfileValue = cp.stream_profile_id
|
||||
? cp.stream_profile_id.toString()
|
||||
: '';
|
||||
const updateStreamProfile = (value) => {
|
||||
if (!value) setCp({}, ['stream_profile_id']);
|
||||
else setCp({ stream_profile_id: parseInt(value) });
|
||||
};
|
||||
|
||||
const sortOrderValue = cp.channel_sort_order ?? '__default__';
|
||||
const sortReverseEnabled = cp.channel_sort_order !== undefined;
|
||||
const updateSortOrder = (value) => {
|
||||
if (!value || value === '__default__') {
|
||||
setCp({}, ['channel_sort_order', 'channel_sort_reverse']);
|
||||
} else {
|
||||
setCp({
|
||||
channel_sort_order: value,
|
||||
channel_sort_reverse: cp.channel_sort_reverse ?? false,
|
||||
});
|
||||
}
|
||||
};
|
||||
const updateSortReverse = (checked) => {
|
||||
setCp({ channel_sort_reverse: checked });
|
||||
};
|
||||
|
||||
// --- Custom Logo ---
|
||||
|
||||
const logoValue = cp.custom_logo_id;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="sm">
|
||||
<Divider
|
||||
label={
|
||||
<Text size="sm" fw={600} c="gray.3">
|
||||
Name Transforms
|
||||
</Text>
|
||||
}
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
color="gray.6"
|
||||
/>
|
||||
<Tooltip
|
||||
label="Apply a regex find/replace to channel names during sync. Leave both empty to skip."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Box>
|
||||
<Flex gap="xs">
|
||||
<TextInput
|
||||
label="Find (Regex)"
|
||||
placeholder="e.g. ^.*? - PPV\\d+ - (.+)$"
|
||||
value={findValue}
|
||||
onChange={(e) => updateFind(e.currentTarget.value)}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Replace"
|
||||
placeholder="e.g. $1"
|
||||
value={replaceValue}
|
||||
onChange={(e) => updateReplace(e.currentTarget.value)}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Flex>
|
||||
{findValue && (
|
||||
<RegexPreviewBox
|
||||
group={group}
|
||||
kind="find"
|
||||
regexPreviewState={regexPreviewState}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Flex gap="xs" align="flex-start">
|
||||
<Tooltip
|
||||
label="Only include channels whose names match the pattern. Leave empty to include all."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
label="Include if name matches (Regex)"
|
||||
placeholder="e.g. ^Sports.*"
|
||||
value={filterValue}
|
||||
onChange={(e) => updateFilter(e.currentTarget.value)}
|
||||
size="xs"
|
||||
/>
|
||||
{filterValue && (
|
||||
<RegexPreviewBox
|
||||
group={group}
|
||||
kind="include"
|
||||
regexPreviewState={regexPreviewState}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Drop channels whose names match the pattern. Applied after the include filter; useful for removing specific bad streams without rewriting the include pattern."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
label="Exclude if name matches (Regex)"
|
||||
placeholder="e.g. TEST|BACKUP"
|
||||
value={excludeValue}
|
||||
onChange={(e) => updateExclude(e.currentTarget.value)}
|
||||
size="xs"
|
||||
/>
|
||||
{excludeValue && (
|
||||
<RegexPreviewBox
|
||||
group={group}
|
||||
kind="exclude"
|
||||
regexPreviewState={regexPreviewState}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Divider
|
||||
label={
|
||||
<Text size="sm" fw={600} c="gray.3">
|
||||
EPG & Logo
|
||||
</Text>
|
||||
}
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
color="gray.6"
|
||||
/>
|
||||
<Flex gap="xs" align="flex-start">
|
||||
<Tooltip
|
||||
label="Force a specific EPG source. Defaults to auto-matching by tvg_id when blank."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Select
|
||||
label="EPG Source"
|
||||
placeholder="Auto-match (default)"
|
||||
value={epgValue || null}
|
||||
onChange={updateEpg}
|
||||
data={getEpgSourceData(epgSources)}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group justify="space-between">
|
||||
<Popover
|
||||
opened={group.logoPopoverOpened || false}
|
||||
onChange={(opened) => {
|
||||
onApplyGroupChange({ ...group, logoPopoverOpened: opened });
|
||||
if (opened) ensureLogosLoaded();
|
||||
}}
|
||||
withArrow
|
||||
>
|
||||
<PopoverTarget>
|
||||
<TextInput
|
||||
label="Custom Logo"
|
||||
placeholder="Stream logo (default)"
|
||||
readOnly
|
||||
value={logoValue ? channelLogos[logoValue]?.name || '' : ''}
|
||||
onClick={() =>
|
||||
onApplyGroupChange({
|
||||
...group,
|
||||
logoPopoverOpened: true,
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</PopoverTarget>
|
||||
<PopoverDropdown onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Filter logos..."
|
||||
size="xs"
|
||||
value={group.logoFilter || ''}
|
||||
onChange={(e) =>
|
||||
onApplyGroupChange({
|
||||
...group,
|
||||
logoFilter: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{logosLoading && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading...
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<ScrollArea style={{ height: 200 }}>
|
||||
{(() => {
|
||||
const logoOptions = [
|
||||
{ id: '0', name: 'Default' },
|
||||
...Object.values(channelLogos),
|
||||
];
|
||||
const filteredLogos = logoOptions.filter((logoItem) =>
|
||||
logoItem.name
|
||||
.toLowerCase()
|
||||
.includes((group.logoFilter || '').toLowerCase())
|
||||
);
|
||||
if (filteredLogos.length === 0) {
|
||||
return (
|
||||
<Center style={{ height: 200 }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{group.logoFilter
|
||||
? 'No logos match your filter'
|
||||
: 'No logos available'}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<List
|
||||
height={200}
|
||||
itemCount={filteredLogos.length}
|
||||
itemSize={55}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const logoItem = filteredLogos[index];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
cursor: 'pointer',
|
||||
padding: '5px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onClick={() => {
|
||||
const next = { ...cp };
|
||||
if (logoItem.id === '0' || !logoItem.id) {
|
||||
delete next.custom_logo_id;
|
||||
} else {
|
||||
next.custom_logo_id = logoItem.id;
|
||||
}
|
||||
onApplyGroupChange({
|
||||
...group,
|
||||
custom_properties: next,
|
||||
logoPopoverOpened: false,
|
||||
});
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
'rgb(68, 68, 68)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
'transparent';
|
||||
}}
|
||||
>
|
||||
<Center
|
||||
style={{
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={logoItem.cache_url || logo}
|
||||
height="30"
|
||||
style={{
|
||||
maxWidth: 80,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
alt={logoItem.name || 'Logo'}
|
||||
onError={(e) => {
|
||||
if (e.target.src !== logo) {
|
||||
e.target.src = logo;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
style={{
|
||||
maxWidth: 80,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{logoItem.name || 'Default'}
|
||||
</Text>
|
||||
</Center>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</List>
|
||||
);
|
||||
})()}
|
||||
</ScrollArea>
|
||||
</PopoverDropdown>
|
||||
</Popover>
|
||||
{logoValue && (
|
||||
<Stack gap="xs" align="center">
|
||||
<LazyLogo
|
||||
logoId={logoValue}
|
||||
alt="custom logo"
|
||||
style={{ height: 40 }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
onClick={() => onOpenLogoUpload(group.channel_group)}
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
mt={4}
|
||||
>
|
||||
+ Upload new logo
|
||||
</Button>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Divider
|
||||
label={
|
||||
<Text size="sm" fw={600} c="gray.3">
|
||||
Channel Assignment
|
||||
</Text>
|
||||
}
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
color="gray.6"
|
||||
/>
|
||||
<Flex gap="xs">
|
||||
<Tooltip
|
||||
label="Send auto-created channels into a different group than their source."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Select
|
||||
label="Override Channel Group"
|
||||
placeholder="Source group (default)"
|
||||
value={groupOverrideValue || null}
|
||||
onChange={updateGroupOverride}
|
||||
data={Object.values(channelGroups).map((g) => ({
|
||||
value: g.id.toString(),
|
||||
label: g.name,
|
||||
}))}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Limit auto-created channels to specific channel profiles. Defaults to all profiles when blank."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<MultiSelect
|
||||
label="Channel Profiles"
|
||||
placeholder="All profiles (default)"
|
||||
value={profileValue}
|
||||
onChange={updateProfiles}
|
||||
data={Object.values(profiles).map((profile) => ({
|
||||
value: profile.id.toString(),
|
||||
label: profile.name,
|
||||
}))}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Flex gap="xs" align="flex-start">
|
||||
<Tooltip
|
||||
label="Apply a specific stream profile to channels created by this group."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Select
|
||||
label="Stream Profile"
|
||||
placeholder="Account default"
|
||||
value={streamProfileValue || null}
|
||||
onChange={updateStreamProfile}
|
||||
data={streamProfiles.map((profile) => ({
|
||||
value: profile.id.toString(),
|
||||
label: profile.name,
|
||||
}))}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Order channels within the group before assigning numbers."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Select
|
||||
label="Channel Sort Order"
|
||||
value={sortOrderValue}
|
||||
onChange={updateSortOrder}
|
||||
data={[
|
||||
{ value: '__default__', label: 'Provider Order (Default)' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'tvg_id', label: 'TVG ID' },
|
||||
{ value: 'updated_at', label: 'Updated At' },
|
||||
]}
|
||||
searchable
|
||||
size="xs"
|
||||
/>
|
||||
{sortReverseEnabled && (
|
||||
<Checkbox
|
||||
label="Reverse sort order"
|
||||
checked={cp.channel_sort_reverse || false}
|
||||
onChange={(event) =>
|
||||
updateSortReverse(event.currentTarget.checked)
|
||||
}
|
||||
size="xs"
|
||||
mt="xs"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Flex align="center" justify="space-between" gap="md" mt="xs">
|
||||
<Tooltip
|
||||
label="Visible channels get sequential numbers; hidden channels release theirs. Set a channel number override to preserve channel numbers over time."
|
||||
withArrow
|
||||
multiline
|
||||
w={320}
|
||||
openDelay={500}
|
||||
>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Switch
|
||||
label="Compact numbering"
|
||||
description="Numbers shift on hide/unhide. Pin a number with a channel number override."
|
||||
checked={!!cp.compact_numbering}
|
||||
onChange={(event) => {
|
||||
if (event.currentTarget.checked) {
|
||||
setCp({ compact_numbering: true });
|
||||
} else {
|
||||
setCp({}, ['compact_numbering']);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Re-assign visible channels into the group's current range. Overrides are kept as reservations and not modified."
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="gray"
|
||||
leftSection={<RefreshCw size={12} />}
|
||||
onClick={async () => {
|
||||
const result = await repackGroupChannels(playlist, group);
|
||||
if (result) {
|
||||
showNotification({
|
||||
title: 'Channels renumbered',
|
||||
message: `Assigned ${result.assigned}, released ${result.released}${
|
||||
result.failed
|
||||
? `, ${result.failed} could not fit in the configured range`
|
||||
: ''
|
||||
}.`,
|
||||
color: result.failed ? 'yellow' : 'green',
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Renumber now
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoSyncAdvanced;
|
||||
181
frontend/src/components/forms/AutoSyncBasic.jsx
Normal file
181
frontend/src/components/forms/AutoSyncBasic.jsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Inline Start/End range inputs for Fixed and Provider modes. Start
|
||||
// writes to auto_sync_channel_start (Fixed) or
|
||||
// custom_properties.channel_numbering_fallback (Provider); End always
|
||||
// writes to auto_sync_channel_end. Next Available shows a one-line
|
||||
// explanation because a range contradicts pack-anywhere semantics.
|
||||
import { Flex, NumberInput, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import {
|
||||
clampChannelNumber,
|
||||
computeRangeOverlapsFor,
|
||||
} from '../../utils/forms/AutoSyncBasicUtils.js';
|
||||
|
||||
const AutoSyncBasic = ({
|
||||
group,
|
||||
groupStates,
|
||||
groupConflicts,
|
||||
onApplyGroupChange,
|
||||
}) => {
|
||||
const mode = group.custom_properties?.channel_numbering_mode || 'fixed';
|
||||
if (mode === 'next_available') {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
Channels receive the lowest available numbers starting at 1.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const startValue =
|
||||
mode === 'provider'
|
||||
? (group.custom_properties?.channel_numbering_fallback ?? 1)
|
||||
: (group.auto_sync_channel_start ?? 1);
|
||||
const endValue = group.auto_sync_channel_end;
|
||||
// Caps pathological pasted values like "1e308" at the input layer.
|
||||
const updateStart = (value) => {
|
||||
const normalized =
|
||||
value === '' || value === null || value === undefined
|
||||
? 1
|
||||
: clampChannelNumber(value);
|
||||
if (mode === 'provider') {
|
||||
onApplyGroupChange({
|
||||
...group,
|
||||
custom_properties: {
|
||||
...(group.custom_properties || {}),
|
||||
channel_numbering_fallback: normalized,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// If End is set and the new Start exceeds it, drop End so the user
|
||||
// is not left holding an invalid range silently.
|
||||
const next = { ...group, auto_sync_channel_start: normalized };
|
||||
if (
|
||||
endValue !== null &&
|
||||
endValue !== undefined &&
|
||||
normalized > endValue
|
||||
) {
|
||||
next.auto_sync_channel_end = null;
|
||||
}
|
||||
onApplyGroupChange(next);
|
||||
}
|
||||
// Sweep effect picks up the state change and dispatches the scan.
|
||||
};
|
||||
const updateEnd = (value) => {
|
||||
const normalized =
|
||||
value === '' || value === null || value === undefined
|
||||
? null
|
||||
: clampChannelNumber(value);
|
||||
onApplyGroupChange({
|
||||
...group,
|
||||
auto_sync_channel_end: normalized,
|
||||
});
|
||||
};
|
||||
|
||||
const streamCount =
|
||||
typeof group.stream_count === 'number' ? group.stream_count : null;
|
||||
const metaParts = [];
|
||||
if (endValue) {
|
||||
metaParts.push(`Range: ${Math.max(endValue - startValue + 1, 0)}`);
|
||||
}
|
||||
if (streamCount !== null) {
|
||||
metaParts.push(`Streams: ${streamCount}`);
|
||||
}
|
||||
const metaText = metaParts.join(' · ');
|
||||
|
||||
const conflict = groupConflicts[group.channel_group];
|
||||
const hasChannelConflict = !!conflict?.hasChannelConflict;
|
||||
const overlaps = computeRangeOverlapsFor(group, groupStates);
|
||||
|
||||
// Channel-level conflicts get a generic Channels-page pointer (count
|
||||
// can be large); range-level overlaps stay specific to the modal.
|
||||
const tooltipSections = [];
|
||||
if (hasChannelConflict) {
|
||||
tooltipSections.push(
|
||||
'Range conflicts with configured channels.\nView the Channels page to inspect.'
|
||||
);
|
||||
}
|
||||
if (overlaps.length > 0) {
|
||||
const overlapLines = overlaps
|
||||
.map((o) => `${o.name} (${o.start}-${o.end})`)
|
||||
.join('\n ');
|
||||
tooltipSections.push(
|
||||
overlaps.length === 1
|
||||
? `Range overlaps with: ${overlaps[0].name} (${overlaps[0].start}-${overlaps[0].end})`
|
||||
: `Range overlaps with:\n ${overlapLines}`
|
||||
);
|
||||
}
|
||||
const showWarning = tooltipSections.length > 0;
|
||||
const tooltipBody = tooltipSections.join('\n\n');
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Flex gap="xs" align="flex-start">
|
||||
<Tooltip
|
||||
label={
|
||||
mode === 'provider'
|
||||
? 'Fallback channel number used when a stream has no provider-supplied number.'
|
||||
: 'First channel number assigned to this group.'
|
||||
}
|
||||
withArrow
|
||||
multiline
|
||||
w={260}
|
||||
>
|
||||
<NumberInput
|
||||
label="Start #"
|
||||
value={startValue}
|
||||
onChange={updateStart}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={0}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Optional upper bound. Streams exceeding the range are skipped and reported after sync."
|
||||
withArrow
|
||||
multiline
|
||||
w={260}
|
||||
>
|
||||
<NumberInput
|
||||
label="End # (optional)"
|
||||
placeholder="Unlimited"
|
||||
value={endValue ?? ''}
|
||||
onChange={updateEnd}
|
||||
min={startValue || 1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={0}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Flex
|
||||
gap="xs"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ minHeight: 18 }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{metaText}
|
||||
</Text>
|
||||
{showWarning && (
|
||||
<Tooltip
|
||||
label={tooltipBody}
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
styles={{ tooltip: { whiteSpace: 'pre-line' } }}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
color="var(--mantine-color-yellow-6)"
|
||||
aria-label="Range conflict warning"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoSyncBasic;
|
||||
77
frontend/src/components/forms/AutoSyncOrphanCleanup.jsx
Normal file
77
frontend/src/components/forms/AutoSyncOrphanCleanup.jsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { updatePlaylist } from '../../utils/forms/M3uUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import { Box, Group, SegmentedControl, Text, Tooltip } from '@mantine/core';
|
||||
|
||||
const OrphanCleanupControl = ({ playlist }) => {
|
||||
// Local state mirrors the persisted mode so the SegmentedControl
|
||||
// reflects clicks immediately even when the parent's playlist prop is
|
||||
// a stale snapshot from before the PATCH lands.
|
||||
const [orphanCleanupMode, setOrphanCleanupMode] = useState(
|
||||
(playlist?.custom_properties || {}).orphan_channel_cleanup || 'always'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOrphanCleanupMode(
|
||||
(playlist?.custom_properties || {}).orphan_channel_cleanup || 'always'
|
||||
);
|
||||
}, [playlist?.id, playlist?.custom_properties?.orphan_channel_cleanup]);
|
||||
|
||||
const handleOrphanCleanupChange = async (mode) => {
|
||||
if (!playlist?.id) return;
|
||||
const previousMode = orphanCleanupMode;
|
||||
setOrphanCleanupMode(mode);
|
||||
const nextProps = {
|
||||
...(playlist.custom_properties || {}),
|
||||
orphan_channel_cleanup: mode,
|
||||
};
|
||||
try {
|
||||
await updatePlaylist(playlist, { custom_properties: nextProps });
|
||||
} catch (err) {
|
||||
setOrphanCleanupMode(previousMode);
|
||||
showNotification({
|
||||
title: 'Failed to update cleanup mode',
|
||||
message: err?.body?.detail || err?.message || 'Please try again.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<Tooltip
|
||||
label="Controls what sync does with auto-synced channels whose source streams have been removed from this provider. Manual channels and hidden channels are never affected by this setting."
|
||||
withArrow
|
||||
multiline
|
||||
w={320}
|
||||
openDelay={400}
|
||||
>
|
||||
<Text size="sm" fw={500}>
|
||||
Auto-sync orphan cleanup
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={orphanCleanupMode}
|
||||
onChange={handleOrphanCleanupChange}
|
||||
data={[
|
||||
{ label: 'Always remove', value: 'always' },
|
||||
{ label: 'Preserve customized', value: 'preserve_customized' },
|
||||
{ label: 'Never remove', value: 'never' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{orphanCleanupMode === 'always' &&
|
||||
'Removes any auto-synced channel whose source stream is gone from this provider.'}
|
||||
{orphanCleanupMode === 'preserve_customized' &&
|
||||
'Removes orphaned auto-synced channels except those with active overrides.'}
|
||||
{orphanCleanupMode === 'never' &&
|
||||
'Keeps all orphaned auto-synced channels. You can clean up manually from the channels page.'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrphanCleanupControl;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,648 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module mocks ───────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/AutoSyncAdvancedUtils.js', () => ({
|
||||
getEpgSourceValue: vi.fn(() => null),
|
||||
getEpgSourceData: vi.fn(() => []),
|
||||
repackGroupChannels: vi.fn(() => Promise.resolve({})),
|
||||
formatPreviewSummary: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/LiveGroupFilterUtils.js', () => ({
|
||||
getRegexOptions: vi.fn(() => [
|
||||
{ value: 'i', label: 'Case insensitive' },
|
||||
{ value: 'g', label: 'Global' },
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../images/logo.png', () => ({ default: 'logo.png' }));
|
||||
|
||||
vi.mock('../../LazyLogo.jsx', () => ({
|
||||
default: ({ src, alt }) => (
|
||||
<img data-testid="lazy-logo" src={src} alt={alt} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('react-window', () => ({
|
||||
FixedSizeList: ({ children, itemCount, itemData }) => (
|
||||
<div data-testid="fixed-size-list">
|
||||
{Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
|
||||
children({ index: i, style: {}, data: itemData })
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
RefreshCw: () => <svg data-testid="icon-refresh-cw" />,
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Box: ({ children, style }) => <div style={style}>{children}</div>,
|
||||
Button: ({ children, onClick, disabled, loading, variant, size, color }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-color={color}
|
||||
data-loading={loading}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Center: ({ children }) => <div data-testid="center">{children}</div>,
|
||||
Checkbox: ({ checked, onChange, label, 'data-testid': testId }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={testId || label}
|
||||
checked={!!checked}
|
||||
onChange={(e) => onChange?.(e)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
Divider: () => <hr data-testid="divider" />,
|
||||
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
|
||||
Group: ({ children }) => <div data-testid="group">{children}</div>,
|
||||
MultiSelect: ({ value, onChange, label, data, 'data-testid': testId }) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<select
|
||||
multiple
|
||||
data-testid={testId || label}
|
||||
value={value ?? []}
|
||||
onChange={(e) =>
|
||||
onChange?.(Array.from(e.target.selectedOptions, (o) => o.value))
|
||||
}
|
||||
>
|
||||
{(data || []).map((opt) => (
|
||||
<option key={opt.value ?? opt} value={opt.value ?? opt}>
|
||||
{opt.label ?? opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Popover: ({ children }) => <div data-testid="popover">{children}</div>,
|
||||
PopoverDropdown: ({ children }) => (
|
||||
<div data-testid="popover-dropdown">{children}</div>
|
||||
),
|
||||
PopoverTarget: ({ children }) => (
|
||||
<div data-testid="popover-target">{children}</div>
|
||||
),
|
||||
ScrollArea: ({ children }) => <div data-testid="scroll-area">{children}</div>,
|
||||
Select: ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
data,
|
||||
'data-testid': testId,
|
||||
clearable,
|
||||
}) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<select
|
||||
data-testid={testId || label}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange?.(e.target.value || null)}
|
||||
>
|
||||
{clearable && <option value="">--</option>}
|
||||
{(data || []).map((opt) => (
|
||||
<option key={opt.value ?? opt} value={opt.value ?? opt}>
|
||||
{opt.label ?? opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
|
||||
Switch: ({ checked, onChange, label, 'data-testid': testId }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
data-testid={testId || label}
|
||||
checked={!!checked}
|
||||
onChange={(e) => onChange?.(e)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
Text: ({ children, size, c, fw }) => (
|
||||
<span data-testid="text" data-size={size} data-color={c} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
TextInput: ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
'data-testid': testId,
|
||||
}) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<input
|
||||
data-testid={testId || label}
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children, label }) => (
|
||||
<div data-testid="tooltip" data-label={label}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import AutoSyncAdvanced from '../AutoSyncAdvanced';
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import {
|
||||
getEpgSourceValue,
|
||||
getEpgSourceData,
|
||||
repackGroupChannels,
|
||||
formatPreviewSummary,
|
||||
} from '../../../utils/forms/AutoSyncAdvancedUtils.js';
|
||||
import { getRegexOptions } from '../../../utils/forms/LiveGroupFilterUtils.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makeGroup = (overrides = {}) => ({
|
||||
channel_group: 1,
|
||||
name: 'Sports',
|
||||
logo_url: null,
|
||||
auto_sync_enabled: true,
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
custom_properties: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeEpgSource = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'My EPG',
|
||||
source_type: 'xmltv',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makePlaylist = (overrides = {}) => ({
|
||||
id: 5,
|
||||
custom_properties: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
group: makeGroup(),
|
||||
epgSources: [makeEpgSource()],
|
||||
channelGroups: [],
|
||||
streamProfiles: [],
|
||||
regexPreviewState: {},
|
||||
onApplyGroupChange: vi.fn(),
|
||||
onScheduleRegexPreview: vi.fn(),
|
||||
onOpenLogoUpload: vi.fn(),
|
||||
channelLogos: [],
|
||||
playlist: makePlaylist(),
|
||||
logosLoading: false,
|
||||
ensureLogosLoaded: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderComponent = (overrides = {}) => {
|
||||
const props = defaultProps(overrides);
|
||||
const utils = render(<AutoSyncAdvanced {...props} />);
|
||||
return { ...props, ...utils };
|
||||
};
|
||||
|
||||
const setupStore = () => {
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ fetchGroups: vi.fn(), profiles: {} })
|
||||
);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('AutoSyncAdvanced', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getEpgSourceValue).mockReturnValue(null);
|
||||
vi.mocked(getEpgSourceData).mockReturnValue([
|
||||
{ value: '1', label: 'My EPG (xmltv)' },
|
||||
]);
|
||||
vi.mocked(repackGroupChannels).mockResolvedValue({});
|
||||
vi.mocked(formatPreviewSummary).mockReturnValue(null);
|
||||
setupStore();
|
||||
});
|
||||
|
||||
// ── Basic rendering ────────────────────────────────────────────────────────
|
||||
describe('basic rendering', () => {
|
||||
it('renders without crashing', () => {
|
||||
renderComponent();
|
||||
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls getEpgSourceData with epgSources on render', () => {
|
||||
const epgSources = [
|
||||
makeEpgSource(),
|
||||
makeEpgSource({ id: 2, name: 'EPG 2' }),
|
||||
];
|
||||
renderComponent({ epgSources });
|
||||
expect(getEpgSourceData).toHaveBeenCalledWith(epgSources);
|
||||
});
|
||||
|
||||
it('calls getEpgSourceValue with group.custom_properties on render', () => {
|
||||
const group = makeGroup();
|
||||
const epgSources = [makeEpgSource()];
|
||||
renderComponent({ group, epgSources });
|
||||
expect(getEpgSourceValue).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
// ── RegexPreviewBox ────────────────────────────────────────────────────────
|
||||
describe('RegexPreviewBox', () => {
|
||||
it('does not render preview box when no include regex is set', () => {
|
||||
renderComponent({ group: makeGroup({ custom_properties: {} }) });
|
||||
expect(screen.queryByText(/matched/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders include preview box when name_match_regex is set', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: { name_match_regex: 'ESPN' },
|
||||
});
|
||||
const regexPreviewState = {
|
||||
1: {
|
||||
filterResult: {
|
||||
match_count: 3,
|
||||
total_scanned: 50,
|
||||
scan_limit_hit: false,
|
||||
},
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(formatPreviewSummary).mockReturnValue(
|
||||
'3 filter matches in 50 streams'
|
||||
);
|
||||
renderComponent({ group, regexPreviewState });
|
||||
expect(
|
||||
screen.getByText('3 filter matches in 50 streams')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders exclude preview box when name_match_exclude_regex is set', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: { name_match_exclude_regex: 'HD' },
|
||||
});
|
||||
const regexPreviewState = {
|
||||
1: {
|
||||
excludeResult: {
|
||||
match_count: 2,
|
||||
total_scanned: 50,
|
||||
scan_limit_hit: false,
|
||||
},
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(formatPreviewSummary).mockReturnValue(
|
||||
'2 exclude matches in 50 streams'
|
||||
);
|
||||
renderComponent({ group, regexPreviewState });
|
||||
expect(
|
||||
screen.getByText('2 exclude matches in 50 streams')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders find preview box when name_regex_pattern is set', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: {
|
||||
name_regex_pattern: 'News',
|
||||
name_replace_pattern: '',
|
||||
},
|
||||
});
|
||||
const regexPreviewState = {
|
||||
1: {
|
||||
findResult: {
|
||||
matches: [
|
||||
{ before: 'BBC News', after: 'BBC' },
|
||||
{ before: 'Sky News', after: 'Sky' },
|
||||
{ before: 'CNN News', after: 'CNN' },
|
||||
{ before: 'Fox News', after: 'Fox' },
|
||||
{ before: 'Al Jazeera News', after: 'Al Jazeera' },
|
||||
],
|
||||
total_in_group: 50,
|
||||
},
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(formatPreviewSummary).mockReturnValue(
|
||||
'5 regex matches in 50 streams'
|
||||
);
|
||||
renderComponent({ group, regexPreviewState });
|
||||
expect(
|
||||
screen.getByText('5 regex matches in 50 streams')
|
||||
).toBeInTheDocument();
|
||||
expect(formatPreviewSummary).toHaveBeenCalledWith(
|
||||
'rename',
|
||||
regexPreviewState[1].findResult
|
||||
);
|
||||
});
|
||||
|
||||
it('shows loading state in preview box when state.loading is true', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: { name_match_regex: 'Sports' },
|
||||
});
|
||||
const regexPreviewState = { 1: { loading: true } };
|
||||
renderComponent({ group, regexPreviewState });
|
||||
// No summary rendered during loading
|
||||
expect(formatPreviewSummary).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ match_count: expect.any(Number) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render include preview when name_match_regex is empty string', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: { name_match_regex: '' },
|
||||
});
|
||||
renderComponent({ group, regexPreviewState: {} });
|
||||
expect(formatPreviewSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders regex error message when result has an error', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: { name_match_regex: 'bad(' },
|
||||
});
|
||||
const regexPreviewState = {
|
||||
1: { filterResult: { error: 'Unterminated group' }, loading: false },
|
||||
};
|
||||
renderComponent({ group, regexPreviewState });
|
||||
expect(
|
||||
screen.getByText(/invalid regex.*unterminated group/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no-match message when filter result has empty matches', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: { name_match_regex: 'ZZZ' },
|
||||
});
|
||||
const regexPreviewState = {
|
||||
1: {
|
||||
filterResult: { matches: [], total_in_group: 10, error: null },
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(formatPreviewSummary).mockReturnValue(
|
||||
'0 filter matches in 10 streams'
|
||||
);
|
||||
renderComponent({ group, regexPreviewState });
|
||||
expect(
|
||||
screen.getByText(/no streams matched this pattern/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── EPG source select ──────────────────────────────────────────────────────
|
||||
describe('EPG source select', () => {
|
||||
it('passes EPG source data from getEpgSourceData to the Select', () => {
|
||||
vi.mocked(getEpgSourceData).mockReturnValue([
|
||||
{ value: '1', label: 'My EPG (xmltv)' },
|
||||
{ value: '2', label: 'EPG 2 (m3u)' },
|
||||
]);
|
||||
renderComponent();
|
||||
const options = screen.getAllByRole('option');
|
||||
const labels = options.map((o) => o.textContent);
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining(['My EPG (xmltv)', 'EPG 2 (m3u)'])
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-selects the value returned by getEpgSourceValue', () => {
|
||||
vi.mocked(getEpgSourceValue).mockReturnValue('2');
|
||||
vi.mocked(getEpgSourceData).mockReturnValue([
|
||||
{ value: '1', label: 'EPG 1' },
|
||||
{ value: '2', label: 'EPG 2' },
|
||||
]);
|
||||
renderComponent();
|
||||
// The select with value '2' is present
|
||||
const select = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((s) => s.value === '2');
|
||||
expect(select).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onApplyGroupChange when EPG source is changed', () => {
|
||||
vi.mocked(getEpgSourceData).mockReturnValue([
|
||||
{ value: '1', label: 'EPG 1' },
|
||||
{ value: '2', label: 'EPG 2' },
|
||||
]);
|
||||
const { onApplyGroupChange } = renderComponent();
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
fireEvent.change(selects[0], { target: { value: '2' } });
|
||||
expect(onApplyGroupChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regex text inputs ──────────────────────────────────────────────────────
|
||||
describe('regex text inputs', () => {
|
||||
it('renders the include regex input', () => {
|
||||
renderComponent();
|
||||
// TextInput mocked with label as testid fallback
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
expect(inputs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onScheduleRegexPreview when find regex changes', () => {
|
||||
const { onScheduleRegexPreview } = renderComponent();
|
||||
const findInput = screen.getByTestId('Find (Regex)');
|
||||
fireEvent.change(findInput, { target: { value: 'ESPN' } });
|
||||
expect(onScheduleRegexPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onScheduleRegexPreview when include regex changes', () => {
|
||||
const { onScheduleRegexPreview } = renderComponent();
|
||||
const includeInput = screen.getByTestId(
|
||||
'Include if name matches (Regex)'
|
||||
);
|
||||
fireEvent.change(includeInput, { target: { value: 'ESPN' } });
|
||||
expect(onScheduleRegexPreview).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repack channels ────────────────────────────────────────────────────────
|
||||
describe('repack channels button', () => {
|
||||
it('calls repackGroupChannels with playlist and group objects', async () => {
|
||||
vi.mocked(repackGroupChannels).mockResolvedValue({
|
||||
assigned: 5,
|
||||
released: 2,
|
||||
failed: 0,
|
||||
});
|
||||
const group = makeGroup({ channel_group: 7 });
|
||||
const playlist = makePlaylist({ id: 3 });
|
||||
renderComponent({ group, playlist });
|
||||
|
||||
const repackBtn = screen.getByRole('button', { name: /renumber now/i });
|
||||
fireEvent.click(repackBtn);
|
||||
await waitFor(() => {
|
||||
expect(repackGroupChannels).toHaveBeenCalledWith(playlist, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification after repack resolves', async () => {
|
||||
vi.mocked(repackGroupChannels).mockResolvedValue({});
|
||||
renderComponent();
|
||||
const repackBtn = screen.queryByRole('button', { name: /repack/i });
|
||||
if (repackBtn) {
|
||||
fireEvent.click(repackBtn);
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
color: expect.stringMatching(/teal|green|blue/i),
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('shows error notification when repack fails', async () => {
|
||||
vi.mocked(repackGroupChannels).mockRejectedValue(
|
||||
new Error('Server error')
|
||||
);
|
||||
renderComponent();
|
||||
const repackBtn = screen.queryByRole('button', { name: /repack/i });
|
||||
if (repackBtn) {
|
||||
fireEvent.click(repackBtn);
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: expect.stringMatching(/red/i) })
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Logo upload ────────────────────────────────────────────────────────────
|
||||
describe('logo upload', () => {
|
||||
it('renders logo-related controls', () => {
|
||||
renderComponent();
|
||||
const logos = screen.queryAllByTestId('lazy-logo');
|
||||
expect(logos.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('calls onOpenLogoUpload when logo upload button is clicked', () => {
|
||||
const { onOpenLogoUpload } = renderComponent();
|
||||
const uploadBtn = screen.queryByRole('button', { name: /upload|logo/i });
|
||||
if (uploadBtn) {
|
||||
fireEvent.click(uploadBtn);
|
||||
expect(onOpenLogoUpload).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes channelLogos to the logo selector when provided', () => {
|
||||
const channelLogos = [
|
||||
{ id: 'logo1', url: '/logos/espn.png', name: 'ESPN' },
|
||||
{ id: 'logo2', url: '/logos/cnn.png', name: 'CNN' },
|
||||
];
|
||||
renderComponent({ channelLogos });
|
||||
// Just verify rendering doesn't throw with logos
|
||||
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stream profiles ────────────────────────────────────────────────────────
|
||||
describe('stream profiles', () => {
|
||||
it('populates profile options from streamProfiles prop', () => {
|
||||
const streamProfiles = [
|
||||
{ id: 1, name: 'HD Profile' },
|
||||
{ id: 2, name: 'SD Profile' },
|
||||
];
|
||||
renderComponent({ streamProfiles });
|
||||
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Custom properties toggles ──────────────────────────────────────────────
|
||||
describe('custom properties toggles', () => {
|
||||
it('renders switches/checkboxes for advanced options', () => {
|
||||
renderComponent();
|
||||
const switches = screen.queryAllByRole('switch');
|
||||
const checkboxes = screen.queryAllByRole('checkbox');
|
||||
expect(switches.length + checkboxes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onApplyGroupChange with compact_numbering when switch is toggled on', () => {
|
||||
const { onApplyGroupChange } = renderComponent();
|
||||
const compactSwitch = screen.getByTestId('Compact numbering');
|
||||
fireEvent.click(compactSwitch);
|
||||
expect(onApplyGroupChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
custom_properties: expect.objectContaining({
|
||||
compact_numbering: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('selects force_dummy_epg option via EPG select', () => {
|
||||
vi.mocked(getEpgSourceData).mockReturnValue([
|
||||
{ value: '0', label: 'No EPG' },
|
||||
{ value: '1', label: 'My EPG' },
|
||||
]);
|
||||
const { onApplyGroupChange } = renderComponent();
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
fireEvent.change(selects[0], { target: { value: '0' } });
|
||||
expect(onApplyGroupChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
custom_properties: expect.objectContaining({ force_dummy_epg: true }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regex options (MultiSelect) ────────────────────────────────────────────
|
||||
describe('regex options MultiSelect', () => {
|
||||
it('calls getRegexOptions when find input changes', () => {
|
||||
renderComponent();
|
||||
const findInput = screen.getByTestId('Find (Regex)');
|
||||
fireEvent.change(findInput, { target: { value: 'ESPN' } });
|
||||
expect(getRegexOptions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEffect / prop sync ──────────────────────────────────────────────────
|
||||
describe('prop sync', () => {
|
||||
it('re-calls getEpgSourceValue when group prop changes', () => {
|
||||
const { rerender } = render(<AutoSyncAdvanced {...defaultProps()} />);
|
||||
expect(getEpgSourceValue).toHaveBeenCalledTimes(1);
|
||||
|
||||
const newGroup = makeGroup({ channel_group: 2, name: 'News' });
|
||||
rerender(<AutoSyncAdvanced {...defaultProps({ group: newGroup })} />);
|
||||
expect(getEpgSourceValue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-calls getEpgSourceData when epgSources prop changes', () => {
|
||||
const { rerender } = render(<AutoSyncAdvanced {...defaultProps()} />);
|
||||
const callCount = vi.mocked(getEpgSourceData).mock.calls.length;
|
||||
|
||||
const newSources = [
|
||||
makeEpgSource(),
|
||||
makeEpgSource({ id: 99, name: 'New' }),
|
||||
];
|
||||
rerender(
|
||||
<AutoSyncAdvanced {...defaultProps({ epgSources: newSources })} />
|
||||
);
|
||||
expect(vi.mocked(getEpgSourceData).mock.calls.length).toBeGreaterThan(
|
||||
callCount
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
429
frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx
Normal file
429
frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/AutoSyncBasicUtils.js', () => ({
|
||||
clampChannelNumber: vi.fn((v) =>
|
||||
Math.min(999999, Math.max(1, Math.floor(Number(v) || 1)))
|
||||
),
|
||||
computeRangeOverlapsFor: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
|
||||
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
|
||||
Text: ({ children, size, c }) => (
|
||||
<span data-testid="text" data-size={size} data-color={c}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Tooltip: ({ children, label }) => (
|
||||
<div data-testid="tooltip" data-label={label}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
NumberInput: ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
'data-testid': testId,
|
||||
min,
|
||||
max,
|
||||
}) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<input
|
||||
data-testid={testId || label}
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(e) =>
|
||||
onChange?.(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
AlertTriangle: () => <svg data-testid="icon-alert-triangle" />,
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import AutoSyncBasic from '../AutoSyncBasic';
|
||||
import {
|
||||
clampChannelNumber,
|
||||
computeRangeOverlapsFor,
|
||||
} from '../../../utils/forms/AutoSyncBasicUtils.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makeGroup = (overrides = {}) => ({
|
||||
channel_group: 1,
|
||||
name: 'Sports',
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
custom_properties: { channel_numbering_mode: 'fixed' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderComponent = (groupOverrides = {}, props = {}) => {
|
||||
const group = makeGroup(groupOverrides);
|
||||
const onApplyGroupChange = vi.fn();
|
||||
const utils = render(
|
||||
<AutoSyncBasic
|
||||
group={group}
|
||||
groupStates={[]}
|
||||
groupConflicts={{}}
|
||||
onApplyGroupChange={onApplyGroupChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return { group, onApplyGroupChange, ...utils };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('AutoSyncBasic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(computeRangeOverlapsFor).mockReturnValue([]);
|
||||
vi.mocked(clampChannelNumber).mockImplementation((v) =>
|
||||
Math.min(999999, Math.max(1, Math.floor(Number(v) || 1)))
|
||||
);
|
||||
});
|
||||
|
||||
// ── next_available mode ────────────────────────────────────────────────────
|
||||
describe('next_available mode', () => {
|
||||
it('renders the explanation text instead of inputs', () => {
|
||||
renderComponent({
|
||||
custom_properties: { channel_numbering_mode: 'next_available' },
|
||||
});
|
||||
expect(
|
||||
screen.getByText(/Channels receive the lowest available numbers/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render any NumberInput', () => {
|
||||
renderComponent({
|
||||
custom_properties: { channel_numbering_mode: 'next_available' },
|
||||
});
|
||||
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── fixed mode rendering ───────────────────────────────────────────────────
|
||||
describe('fixed mode rendering', () => {
|
||||
it('renders Start and End inputs', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByTestId(/start/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(/end/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays auto_sync_channel_start as the start value', () => {
|
||||
renderComponent({ auto_sync_channel_start: 150 });
|
||||
expect(screen.getByTestId(/start/i)).toHaveValue(150);
|
||||
});
|
||||
|
||||
it('defaults start to 1 when auto_sync_channel_start is undefined', () => {
|
||||
renderComponent({ auto_sync_channel_start: undefined });
|
||||
expect(screen.getByTestId(/start/i)).toHaveValue(1);
|
||||
});
|
||||
|
||||
it('displays auto_sync_channel_end as the end value', () => {
|
||||
renderComponent({ auto_sync_channel_end: 250 });
|
||||
expect(screen.getByTestId(/end/i)).toHaveValue(250);
|
||||
});
|
||||
|
||||
it('displays empty end when auto_sync_channel_end is undefined', () => {
|
||||
renderComponent({ auto_sync_channel_end: undefined });
|
||||
expect(screen.getByTestId(/end/i)).toHaveValue(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── provider mode rendering ────────────────────────────────────────────────
|
||||
describe('provider mode rendering', () => {
|
||||
it('reads start from custom_properties.channel_numbering_fallback', () => {
|
||||
renderComponent({
|
||||
custom_properties: {
|
||||
channel_numbering_mode: 'provider',
|
||||
channel_numbering_fallback: 500,
|
||||
},
|
||||
});
|
||||
expect(screen.getByTestId(/start/i)).toHaveValue(500);
|
||||
});
|
||||
|
||||
it('defaults fallback start to 1 when channel_numbering_fallback is undefined', () => {
|
||||
renderComponent({
|
||||
custom_properties: {
|
||||
channel_numbering_mode: 'provider',
|
||||
},
|
||||
});
|
||||
expect(screen.getByTestId(/start/i)).toHaveValue(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── defaults when no mode is set ──────────────────────────────────────────
|
||||
describe('defaults when mode is absent', () => {
|
||||
it('treats missing mode as fixed and renders inputs', () => {
|
||||
renderComponent({ custom_properties: {} });
|
||||
expect(screen.getByTestId(/start/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(/end/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateStart — fixed mode ───────────────────────────────────────────────
|
||||
describe('updateStart in fixed mode', () => {
|
||||
it('calls onApplyGroupChange with clamped start value', () => {
|
||||
const { onApplyGroupChange } = renderComponent({
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
});
|
||||
fireEvent.change(screen.getByTestId(/start/i), {
|
||||
target: { value: '150' },
|
||||
});
|
||||
expect(clampChannelNumber).toHaveBeenCalledWith(150);
|
||||
expect(onApplyGroupChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_sync_channel_start: expect.any(Number) })
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes empty string input to 1', () => {
|
||||
const { onApplyGroupChange } = renderComponent();
|
||||
fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } });
|
||||
expect(onApplyGroupChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_sync_channel_start: 1 })
|
||||
);
|
||||
expect(clampChannelNumber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops auto_sync_channel_end when new start exceeds current end', () => {
|
||||
const { onApplyGroupChange } = renderComponent({
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
});
|
||||
vi.mocked(clampChannelNumber).mockReturnValueOnce(300);
|
||||
fireEvent.change(screen.getByTestId(/start/i), {
|
||||
target: { value: '300' },
|
||||
});
|
||||
expect(onApplyGroupChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_sync_channel_end: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps auto_sync_channel_end when new start is below end', () => {
|
||||
const { onApplyGroupChange, group } = renderComponent({
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
});
|
||||
vi.mocked(clampChannelNumber).mockReturnValueOnce(150);
|
||||
fireEvent.change(screen.getByTestId(/start/i), {
|
||||
target: { value: '150' },
|
||||
});
|
||||
expect(onApplyGroupChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auto_sync_channel_end: group.auto_sync_channel_end,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps end when there is no existing end value', () => {
|
||||
const { onApplyGroupChange } = renderComponent({
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: undefined,
|
||||
});
|
||||
vi.mocked(clampChannelNumber).mockReturnValueOnce(500);
|
||||
fireEvent.change(screen.getByTestId(/start/i), {
|
||||
target: { value: '500' },
|
||||
});
|
||||
const call = onApplyGroupChange.mock.calls[0][0];
|
||||
expect(call.auto_sync_channel_end).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateStart — provider mode ────────────────────────────────────────────
|
||||
describe('updateStart in provider mode', () => {
|
||||
it('writes to custom_properties.channel_numbering_fallback', () => {
|
||||
const { onApplyGroupChange } = renderComponent({
|
||||
custom_properties: {
|
||||
channel_numbering_mode: 'provider',
|
||||
channel_numbering_fallback: 100,
|
||||
},
|
||||
});
|
||||
vi.mocked(clampChannelNumber).mockReturnValueOnce(400);
|
||||
fireEvent.change(screen.getByTestId(/start/i), {
|
||||
target: { value: '400' },
|
||||
});
|
||||
expect(onApplyGroupChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
custom_properties: expect.objectContaining({
|
||||
channel_numbering_fallback: 400,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('merges existing custom_properties when updating fallback', () => {
|
||||
const { onApplyGroupChange } = renderComponent({
|
||||
custom_properties: {
|
||||
channel_numbering_mode: 'provider',
|
||||
channel_numbering_fallback: 100,
|
||||
some_other_key: true,
|
||||
},
|
||||
});
|
||||
vi.mocked(clampChannelNumber).mockReturnValueOnce(200);
|
||||
fireEvent.change(screen.getByTestId(/start/i), {
|
||||
target: { value: '200' },
|
||||
});
|
||||
const call = onApplyGroupChange.mock.calls[0][0];
|
||||
expect(call.custom_properties.some_other_key).toBe(true);
|
||||
expect(call.custom_properties.channel_numbering_mode).toBe('provider');
|
||||
});
|
||||
|
||||
it('normalizes empty string to 1 in provider mode', () => {
|
||||
const { onApplyGroupChange } = renderComponent({
|
||||
custom_properties: { channel_numbering_mode: 'provider' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } });
|
||||
const call = onApplyGroupChange.mock.calls[0][0];
|
||||
expect(call.custom_properties.channel_numbering_fallback).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateEnd ──────────────────────────────────────────────────────────────
|
||||
describe('updateEnd', () => {
|
||||
it('calls onApplyGroupChange with clamped end value', () => {
|
||||
const { onApplyGroupChange } = renderComponent();
|
||||
vi.mocked(clampChannelNumber).mockReturnValueOnce(300);
|
||||
fireEvent.change(screen.getByTestId(/end/i), {
|
||||
target: { value: '300' },
|
||||
});
|
||||
expect(onApplyGroupChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_sync_channel_end: 300 })
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes empty end to null/falsy', () => {
|
||||
const { onApplyGroupChange } = renderComponent();
|
||||
fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } });
|
||||
const call = onApplyGroupChange.mock.calls[0][0];
|
||||
expect(
|
||||
call.auto_sync_channel_end == null || call.auto_sync_channel_end === ''
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not call clampChannelNumber when end is cleared', () => {
|
||||
renderComponent();
|
||||
fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } });
|
||||
expect(clampChannelNumber).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Meta text ──────────────────────────────────────────────────────────────
|
||||
describe('meta text', () => {
|
||||
it('shows range info when end is set', () => {
|
||||
renderComponent({
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
});
|
||||
expect(screen.getByTestId('text').textContent).toMatch(/100.*200|range/i);
|
||||
});
|
||||
|
||||
it('shows stream count from group when available', () => {
|
||||
const group = makeGroup({ auto_sync_channel_end: 200, stream_count: 42 });
|
||||
const groupStates = [{ channel_group: 1 }];
|
||||
render(
|
||||
<AutoSyncBasic
|
||||
group={group}
|
||||
groupStates={groupStates}
|
||||
groupConflicts={{}}
|
||||
onApplyGroupChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('text').textContent).toMatch(/42/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Overlap and conflict warnings ──────────────────────────────────────────
|
||||
describe('warnings', () => {
|
||||
it('does not render warning icon when no conflicts or overlaps', () => {
|
||||
renderComponent({}, { groupConflicts: {} });
|
||||
expect(
|
||||
screen.queryByTestId('icon-alert-triangle')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders warning icon when computeRangeOverlapsFor returns overlaps', () => {
|
||||
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
|
||||
{ name: 'Other Group', start: 150, end: 160 },
|
||||
]);
|
||||
renderComponent();
|
||||
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders warning icon when group has a channel conflict', () => {
|
||||
renderComponent(
|
||||
{},
|
||||
{ groupConflicts: { 1: { hasChannelConflict: true } } }
|
||||
);
|
||||
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes overlap group name to tooltip', () => {
|
||||
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
|
||||
{ name: 'News Group', start: 150, end: 160 },
|
||||
]);
|
||||
renderComponent();
|
||||
|
||||
const tooltip = screen
|
||||
.getAllByTestId('tooltip')
|
||||
.find((t) => t.getAttribute('data-label')?.includes('News Group'));
|
||||
expect(tooltip).toBeDefined();
|
||||
});
|
||||
|
||||
it('passes channel conflict message to tooltip', () => {
|
||||
renderComponent(
|
||||
{},
|
||||
{ groupConflicts: { 1: { hasChannelConflict: true } } }
|
||||
);
|
||||
|
||||
const tooltip = screen
|
||||
.getAllByTestId('tooltip')
|
||||
.find((t) =>
|
||||
t.getAttribute('data-label')?.toLowerCase().includes('channel')
|
||||
);
|
||||
expect(tooltip).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes both conflict and overlap info in tooltip when both present', () => {
|
||||
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
|
||||
{ name: 'Overlap Group', start: 110, end: 120 },
|
||||
]);
|
||||
renderComponent(
|
||||
{},
|
||||
{ groupConflicts: { 1: { hasChannelConflict: true } } }
|
||||
);
|
||||
|
||||
const tooltip = screen.getAllByTestId('tooltip').find((t) => {
|
||||
const label = t.getAttribute('data-label')?.toLowerCase();
|
||||
return label?.includes('channel') && label.includes('overlap group');
|
||||
});
|
||||
expect(tooltip).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls computeRangeOverlapsFor with the group and groupStates', () => {
|
||||
const group = makeGroup();
|
||||
const groupStates = [makeGroup({ channel_group: 2, name: 'B' })];
|
||||
render(
|
||||
<AutoSyncBasic
|
||||
group={group}
|
||||
groupStates={groupStates}
|
||||
groupConflicts={{}}
|
||||
onApplyGroupChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(computeRangeOverlapsFor).toHaveBeenCalledWith(group, groupStates);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module mocks ───────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/M3uUtils.js', () => ({
|
||||
updatePlaylist: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
SegmentedControl: ({ value, onChange, data }) => (
|
||||
<div data-testid="segmented-control" data-value={value}>
|
||||
{data.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-testid={`segmented-${opt.value}`}
|
||||
data-active={value === opt.value}
|
||||
onClick={() => onChange?.(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import OrphanCleanupControl from '../AutoSyncOrphanCleanup';
|
||||
import { updatePlaylist } from '../../../utils/forms/M3uUtils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makePlaylist = (overrides = {}) => ({
|
||||
id: 7,
|
||||
custom_properties: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderControl = (playlistOverrides = {}) =>
|
||||
render(<OrphanCleanupControl playlist={makePlaylist(playlistOverrides)} />);
|
||||
|
||||
const getControl = () => screen.getByTestId('segmented-control');
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('OrphanCleanupControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Initial state ──────────────────────────────────────────────────────────
|
||||
describe('initial state', () => {
|
||||
it('defaults to "always" when custom_properties is null', () => {
|
||||
renderControl({ custom_properties: null });
|
||||
expect(getControl()).toHaveAttribute('data-value', 'always');
|
||||
});
|
||||
|
||||
it('defaults to "always" when orphan_channel_cleanup key is absent', () => {
|
||||
renderControl({ custom_properties: { compact_numbering: true } });
|
||||
expect(getControl()).toHaveAttribute('data-value', 'always');
|
||||
});
|
||||
|
||||
it('reads persisted "preserve_customized" mode from custom_properties', () => {
|
||||
renderControl({
|
||||
custom_properties: { orphan_channel_cleanup: 'preserve_customized' },
|
||||
});
|
||||
expect(getControl()).toHaveAttribute('data-value', 'preserve_customized');
|
||||
});
|
||||
|
||||
it('reads persisted "never" mode from custom_properties', () => {
|
||||
renderControl({
|
||||
custom_properties: { orphan_channel_cleanup: 'never' },
|
||||
});
|
||||
expect(getControl()).toHaveAttribute('data-value', 'never');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Optimistic update ──────────────────────────────────────────────────────
|
||||
describe('on mode change', () => {
|
||||
it('updates the displayed value immediately before the PATCH resolves', () => {
|
||||
vi.mocked(updatePlaylist).mockReturnValue(new Promise(() => {})); // never resolves
|
||||
renderControl();
|
||||
fireEvent.click(screen.getByTestId('segmented-never'));
|
||||
expect(getControl()).toHaveAttribute('data-value', 'never');
|
||||
});
|
||||
|
||||
it('calls updatePlaylist with merged custom_properties', async () => {
|
||||
renderControl({ custom_properties: { compact_numbering: true } });
|
||||
fireEvent.click(screen.getByTestId('segmented-never'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updatePlaylist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 7 }),
|
||||
{
|
||||
custom_properties: {
|
||||
compact_numbering: true,
|
||||
orphan_channel_cleanup: 'never',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updatePlaylist when playlist has no id', async () => {
|
||||
renderControl({ id: undefined });
|
||||
fireEvent.click(screen.getByTestId('segmented-never'));
|
||||
await Promise.resolve();
|
||||
expect(updatePlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves final "always" value after successful PATCH', async () => {
|
||||
renderControl({ custom_properties: { orphan_channel_cleanup: 'never' } });
|
||||
fireEvent.click(screen.getByTestId('segmented-always'));
|
||||
await waitFor(() => expect(updatePlaylist).toHaveBeenCalled());
|
||||
expect(getControl()).toHaveAttribute('data-value', 'always');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error / revert ─────────────────────────────────────────────────────────
|
||||
describe('on PATCH failure', () => {
|
||||
it('reverts to the previous mode', async () => {
|
||||
vi.mocked(updatePlaylist).mockRejectedValueOnce(
|
||||
new Error('Server error')
|
||||
);
|
||||
renderControl({
|
||||
custom_properties: { orphan_channel_cleanup: 'always' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('segmented-never'));
|
||||
|
||||
await waitFor(() => expect(showNotification).toHaveBeenCalled());
|
||||
expect(getControl()).toHaveAttribute('data-value', 'always');
|
||||
});
|
||||
|
||||
it('shows a red error notification', async () => {
|
||||
vi.mocked(updatePlaylist).mockRejectedValueOnce(
|
||||
new Error('Server error')
|
||||
);
|
||||
renderControl();
|
||||
fireEvent.click(screen.getByTestId('segmented-never'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: 'red' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('includes err.message in the notification when body.detail is absent', async () => {
|
||||
vi.mocked(updatePlaylist).mockRejectedValueOnce(new Error('Timeout'));
|
||||
renderControl();
|
||||
fireEvent.click(screen.getByTestId('segmented-never'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Timeout' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers err.body.detail over err.message', async () => {
|
||||
const err = Object.assign(new Error('generic'), {
|
||||
body: { detail: 'Specific detail from server' },
|
||||
});
|
||||
vi.mocked(updatePlaylist).mockRejectedValueOnce(err);
|
||||
renderControl();
|
||||
fireEvent.click(screen.getByTestId('segmented-never'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Specific detail from server' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to "Please try again." when error has no message or body', async () => {
|
||||
vi.mocked(updatePlaylist).mockRejectedValueOnce({});
|
||||
renderControl();
|
||||
fireEvent.click(screen.getByTestId('segmented-never'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Please try again.' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEffect sync ─────────────────────────────────────────────────────────
|
||||
describe('useEffect sync on prop change', () => {
|
||||
it('syncs displayed mode when playlist.custom_properties changes', () => {
|
||||
const { rerender } = render(
|
||||
<OrphanCleanupControl
|
||||
playlist={makePlaylist({
|
||||
custom_properties: { orphan_channel_cleanup: 'always' },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(getControl()).toHaveAttribute('data-value', 'always');
|
||||
|
||||
rerender(
|
||||
<OrphanCleanupControl
|
||||
playlist={makePlaylist({
|
||||
custom_properties: { orphan_channel_cleanup: 'never' },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(getControl()).toHaveAttribute('data-value', 'never');
|
||||
});
|
||||
|
||||
it('resets to "always" when orphan_channel_cleanup is removed from custom_properties', () => {
|
||||
const { rerender } = render(
|
||||
<OrphanCleanupControl
|
||||
playlist={makePlaylist({
|
||||
custom_properties: { orphan_channel_cleanup: 'never' },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
rerender(
|
||||
<OrphanCleanupControl
|
||||
playlist={makePlaylist({ custom_properties: {} })}
|
||||
/>
|
||||
);
|
||||
expect(getControl()).toHaveAttribute('data-value', 'always');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
50
frontend/src/utils/forms/AutoSyncAdvancedUtils.js
Normal file
50
frontend/src/utils/forms/AutoSyncAdvancedUtils.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import API from '../../api.js';
|
||||
|
||||
export const getEpgSourceValue = (cp) => {
|
||||
// Show custom EPG if set
|
||||
if (cp?.custom_epg_id !== undefined && cp?.custom_epg_id !== null) {
|
||||
return cp.custom_epg_id.toString();
|
||||
}
|
||||
// Show "No EPG" if force_dummy_epg is set
|
||||
if (cp?.force_dummy_epg) {
|
||||
return '0';
|
||||
}
|
||||
// Otherwise show empty/placeholder
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getEpgSourceData = (epgSources) => {
|
||||
return [
|
||||
{ value: '0', label: 'No EPG (Disabled)' },
|
||||
...[...epgSources]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((source) => ({
|
||||
value: source.id.toString(),
|
||||
label: `${source.name} (${
|
||||
source.source_type === 'dummy'
|
||||
? 'Dummy'
|
||||
: source.source_type === 'xmltv'
|
||||
? 'XMLTV'
|
||||
: source.source_type === 'schedules_direct'
|
||||
? 'Schedules Direct'
|
||||
: source.source_type
|
||||
})`,
|
||||
})),
|
||||
];
|
||||
};
|
||||
|
||||
export const repackGroupChannels = (playlist, group) => {
|
||||
return API.repackGroupChannels(playlist.id, group.channel_group);
|
||||
};
|
||||
|
||||
// Header line for the preview box. Adds a scan-cap suffix when the
|
||||
// backend only scanned the first SCAN_CAP streams of the group.
|
||||
export const formatPreviewSummary = (label, result) => {
|
||||
if (!result) return null;
|
||||
const { match_count, total_in_group, total_scanned, scan_limit_hit } = result;
|
||||
const matchWord = `match${match_count === 1 ? '' : 'es'}`;
|
||||
if (scan_limit_hit) {
|
||||
return `${match_count} ${matchWord} in first ${total_scanned.toLocaleString()} streams scanned (of ${total_in_group.toLocaleString()} total)`;
|
||||
}
|
||||
return `${match_count} ${label} ${matchWord} in ${total_scanned.toLocaleString()} stream${total_scanned === 1 ? '' : 's'}`;
|
||||
};
|
||||
25
frontend/src/utils/forms/AutoSyncBasicUtils.js
Normal file
25
frontend/src/utils/forms/AutoSyncBasicUtils.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Returns {name, start, end}[] for groups whose declared ranges
|
||||
// intersect this group's range, or [] when there is no overlap.
|
||||
import { getGroupReservation } from './GroupSyncUtils.js';
|
||||
|
||||
export const computeRangeOverlapsFor = (group, groupStates) => {
|
||||
const myReservation = getGroupReservation(group);
|
||||
if (!myReservation) return [];
|
||||
const [myStart, myEnd] = myReservation;
|
||||
const overlaps = [];
|
||||
for (const other of groupStates) {
|
||||
if (other.channel_group === group.channel_group) continue;
|
||||
const otherReservation = getGroupReservation(other);
|
||||
if (!otherReservation) continue;
|
||||
const [oStart, oEnd] = otherReservation;
|
||||
if (myStart <= oEnd && oStart <= myEnd) {
|
||||
overlaps.push({ name: other.name, start: oStart, end: oEnd });
|
||||
}
|
||||
}
|
||||
return overlaps;
|
||||
};
|
||||
|
||||
const MAX_CHANNEL_NUMBER = 999999;
|
||||
|
||||
export const clampChannelNumber = (n) =>
|
||||
Math.max(1, Math.min(MAX_CHANNEL_NUMBER, Math.floor(Number(n) || 1)));
|
||||
|
|
@ -4,141 +4,137 @@ export const getEPGs = () => {
|
|||
return API.getEPGs();
|
||||
};
|
||||
|
||||
export const ADVANCED_OPTIONS_CONFIG = [
|
||||
{
|
||||
value: 'force_epg',
|
||||
label: 'Force EPG Source',
|
||||
description:
|
||||
'Force a specific EPG source for all auto-synced channels, or disable EPG assignment entirely',
|
||||
isActive: (p) =>
|
||||
p.custom_epg_id !== undefined ||
|
||||
p.force_dummy_epg ||
|
||||
p.force_epg_selected,
|
||||
defaults: { force_dummy_epg: true },
|
||||
removeKeys: ['force_dummy_epg', 'custom_epg_id', 'force_epg_selected'],
|
||||
},
|
||||
{
|
||||
value: 'group_override',
|
||||
label: 'Override Channel Group',
|
||||
description: 'Override the group assignment for all channels in this group',
|
||||
isActive: (p) => p.group_override !== undefined,
|
||||
defaults: { group_override: null },
|
||||
removeKeys: ['group_override'],
|
||||
},
|
||||
{
|
||||
value: 'name_regex',
|
||||
label: 'Channel Name Find & Replace (Regex)',
|
||||
description:
|
||||
'Find and replace part of the channel name using a regex pattern',
|
||||
isActive: (p) =>
|
||||
p.name_regex_pattern !== undefined ||
|
||||
p.name_replace_pattern !== undefined,
|
||||
defaults: { name_regex_pattern: '', name_replace_pattern: '' },
|
||||
removeKeys: ['name_regex_pattern', 'name_replace_pattern'],
|
||||
},
|
||||
{
|
||||
value: 'name_match_regex',
|
||||
label: 'Channel Name Filter (Regex)',
|
||||
description: 'Only sync channels whose name matches this regex.',
|
||||
isActive: (p) => p.name_match_regex !== undefined,
|
||||
defaults: { name_match_regex: '' },
|
||||
removeKeys: ['name_match_regex'],
|
||||
},
|
||||
{
|
||||
value: 'profile_assignment',
|
||||
label: 'Channel Profile Assignment',
|
||||
description:
|
||||
'Specify which channel profiles the auto-synced channels should be added to',
|
||||
isActive: (p) => p.channel_profile_ids !== undefined,
|
||||
defaults: { channel_profile_ids: [] },
|
||||
removeKeys: ['channel_profile_ids'],
|
||||
},
|
||||
{
|
||||
value: 'channel_sort_order',
|
||||
label: 'Channel Sort Order',
|
||||
description:
|
||||
'Specify the order in which channels are created (name, tvg_id, updated_at)',
|
||||
isActive: (p) => p.channel_sort_order !== undefined,
|
||||
defaults: { channel_sort_order: '', channel_sort_reverse: false },
|
||||
removeKeys: ['channel_sort_order', 'channel_sort_reverse'],
|
||||
},
|
||||
{
|
||||
value: 'stream_profile_assignment',
|
||||
label: 'Stream Profile Assignment',
|
||||
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'],
|
||||
},
|
||||
{
|
||||
value: 'custom_logo',
|
||||
label: 'Custom Logo',
|
||||
description:
|
||||
'Assign a custom logo to all auto-synced channels in this group',
|
||||
isActive: (p) => p.custom_logo_id !== undefined,
|
||||
defaults: { custom_logo_id: null },
|
||||
removeKeys: ['custom_logo_id'],
|
||||
},
|
||||
];
|
||||
|
||||
export const getSelectedAdvancedOptions = (customProps) =>
|
||||
ADVANCED_OPTIONS_CONFIG.filter((opt) => opt.isActive(customProps ?? {})).map(
|
||||
(opt) => opt.value
|
||||
);
|
||||
|
||||
export const applyAdvancedOptionsChange = (prevCustomProps, newValues) => {
|
||||
const next = { ...prevCustomProps };
|
||||
|
||||
// Add defaults for newly selected options
|
||||
for (const opt of ADVANCED_OPTIONS_CONFIG) {
|
||||
if (newValues.includes(opt.value) && !opt.isActive(next)) {
|
||||
Object.assign(next, opt.defaults);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove keys for deselected options
|
||||
for (const opt of ADVANCED_OPTIONS_CONFIG) {
|
||||
if (!newValues.includes(opt.value) && opt.isActive(next)) {
|
||||
for (const key of opt.removeKeys) delete next[key];
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
export const getChannelsInRange = (start, end, controller) => {
|
||||
return API.getChannelsInRange(start, end, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getEpgSourceValue = (group) => {
|
||||
// Show custom EPG if set
|
||||
export const getStreamsRegexPreview = (
|
||||
group,
|
||||
find,
|
||||
replace,
|
||||
match,
|
||||
exclude,
|
||||
controller,
|
||||
playlist
|
||||
) => {
|
||||
return API.getStreamsRegexPreview(group.name, {
|
||||
find: find || undefined,
|
||||
replace: find ? replace : undefined,
|
||||
match: match || undefined,
|
||||
exclude: exclude || undefined,
|
||||
limit: 10,
|
||||
signal: controller.signal,
|
||||
m3uAccountId: playlist?.id,
|
||||
});
|
||||
};
|
||||
|
||||
// "Expected" occupants are this group's own auto-sync output:
|
||||
// auto_created, in this group on this account, no channel_number
|
||||
// override. Channels from any other provider, group, or with a user
|
||||
// pin all surface as a warning so the user is aware their range
|
||||
// overlaps with existing assignments. Sync still merges shared
|
||||
// ranges across providers, so the warning is informational rather
|
||||
// than blocking.
|
||||
export const isExpectedOccupantForGroup = (
|
||||
occupant,
|
||||
groupChannelGroupId,
|
||||
playlist
|
||||
) => {
|
||||
if (!occupant) return false;
|
||||
if (!occupant.auto_created) return false;
|
||||
if (occupant.has_channel_number_override) return false;
|
||||
if (
|
||||
group.custom_properties?.custom_epg_id !== undefined &&
|
||||
group.custom_properties?.custom_epg_id !== null
|
||||
) {
|
||||
return group.custom_properties.custom_epg_id.toString();
|
||||
}
|
||||
// Show "No EPG" if force_dummy_epg is set
|
||||
if (group.custom_properties?.force_dummy_epg) {
|
||||
return '0';
|
||||
}
|
||||
// Otherwise show empty/placeholder
|
||||
return null;
|
||||
occupant.channel_group_id !== undefined &&
|
||||
occupant.channel_group_id !== groupChannelGroupId
|
||||
)
|
||||
return false;
|
||||
return !(
|
||||
occupant.auto_created_by_account_id !== undefined &&
|
||||
playlist?.id !== undefined &&
|
||||
occupant.auto_created_by_account_id !== playlist.id
|
||||
);
|
||||
};
|
||||
|
||||
export const getEpgSourceData = (epgSources) => {
|
||||
return [
|
||||
{ value: '0', label: 'No EPG (Disabled)' },
|
||||
...[...epgSources]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((source) => ({
|
||||
value: source.id.toString(),
|
||||
label: `${source.name} (${
|
||||
source.source_type === 'dummy'
|
||||
? 'Dummy'
|
||||
: source.source_type === 'xmltv'
|
||||
? 'XMLTV'
|
||||
: source.source_type === 'schedules_direct'
|
||||
? 'Schedules Direct'
|
||||
: source.source_type
|
||||
})`,
|
||||
})),
|
||||
];
|
||||
export const rangeFor = (g) => {
|
||||
if (!g.enabled || !g.auto_channel_sync) return null;
|
||||
const mode = g.custom_properties?.channel_numbering_mode || 'fixed';
|
||||
if (mode === 'next_available') return null;
|
||||
const startRaw =
|
||||
mode === 'provider'
|
||||
? (g.custom_properties?.channel_numbering_fallback ?? 1)
|
||||
: (g.auto_sync_channel_start ?? 1);
|
||||
const start = Number(startRaw);
|
||||
if (!Number.isFinite(start)) return null;
|
||||
const endRaw = g.auto_sync_channel_end;
|
||||
const end =
|
||||
endRaw === null || endRaw === undefined || endRaw === ''
|
||||
? start
|
||||
: Number(endRaw);
|
||||
return { start, end, startRaw };
|
||||
};
|
||||
|
||||
export const abortTimers = (timerRef, abortRef) => {
|
||||
Object.values(timerRef.current).forEach((t) => clearTimeout(t));
|
||||
timerRef.current = {};
|
||||
Object.values(abortRef.current).forEach((c) => {
|
||||
try {
|
||||
c.abort();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
abortRef.current = {};
|
||||
};
|
||||
|
||||
export const getRegexOptions = (
|
||||
findValue,
|
||||
replaceValue,
|
||||
filterValue,
|
||||
excludeValue
|
||||
) => {
|
||||
return {
|
||||
find: findValue,
|
||||
replace: replaceValue,
|
||||
match: filterValue,
|
||||
exclude: excludeValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const computeAutoSyncStart = (prev, id) => {
|
||||
let proposedStart = 1;
|
||||
for (const other of prev) {
|
||||
if (other.channel_group == id) continue;
|
||||
if (!other.enabled || !other.auto_channel_sync) continue;
|
||||
const otherMode =
|
||||
other.custom_properties?.channel_numbering_mode || 'fixed';
|
||||
if (otherMode === 'next_available') continue;
|
||||
const otherStart = Number(
|
||||
otherMode === 'provider'
|
||||
? (other.custom_properties?.channel_numbering_fallback ?? 1)
|
||||
: (other.auto_sync_channel_start ?? 1)
|
||||
);
|
||||
if (!Number.isFinite(otherStart)) continue;
|
||||
const otherEnd =
|
||||
other.auto_sync_channel_end === null ||
|
||||
other.auto_sync_channel_end === undefined ||
|
||||
other.auto_sync_channel_end === ''
|
||||
? otherStart
|
||||
: Number(other.auto_sync_channel_end);
|
||||
const upper = Math.max(otherStart, otherEnd);
|
||||
if (upper + 1 > proposedStart) proposedStart = upper + 1;
|
||||
}
|
||||
return proposedStart;
|
||||
};
|
||||
|
||||
export const isGroupVisible = (group, groupFilter, statusFilter) => {
|
||||
const matchesText = group.name
|
||||
.toLowerCase()
|
||||
.includes(groupFilter.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'enabled' && group.enabled) ||
|
||||
(statusFilter === 'disabled' && !group.enabled);
|
||||
return matchesText && matchesStatus;
|
||||
};
|
||||
|
|
|
|||
257
frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js
Normal file
257
frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getEpgSourceValue,
|
||||
getEpgSourceData,
|
||||
repackGroupChannels,
|
||||
formatPreviewSummary,
|
||||
} from '../AutoSyncAdvancedUtils.js';
|
||||
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
repackGroupChannels: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import API from '../../../api.js';
|
||||
|
||||
const makeEpgSource = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'Source One',
|
||||
source_type: 'xmltv',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeResult = (overrides = {}) => ({
|
||||
match_count: 3,
|
||||
total_in_group: 100,
|
||||
total_scanned: 100,
|
||||
scan_limit_hit: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AutoSyncAdvancedUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── getEpgSourceValue ─────────────────────────────────────────────────────
|
||||
describe('getEpgSourceValue', () => {
|
||||
it('returns custom_epg_id as string when set', () => {
|
||||
expect(getEpgSourceValue({ custom_epg_id: 5 })).toBe('5');
|
||||
});
|
||||
|
||||
it('returns custom_epg_id as string when set to 0', () => {
|
||||
expect(getEpgSourceValue({ custom_epg_id: 0 })).toBe('0');
|
||||
});
|
||||
|
||||
it('returns "0" when force_dummy_epg is true and no custom_epg_id', () => {
|
||||
expect(getEpgSourceValue({ force_dummy_epg: true })).toBe('0');
|
||||
});
|
||||
|
||||
it('prefers custom_epg_id over force_dummy_epg', () => {
|
||||
expect(
|
||||
getEpgSourceValue({ custom_epg_id: 7, force_dummy_epg: true })
|
||||
).toBe('7');
|
||||
});
|
||||
|
||||
it('returns null when custom_epg_id is null', () => {
|
||||
expect(getEpgSourceValue({ custom_epg_id: null })).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null when custom_epg_id is undefined and force_dummy_epg is false', () => {
|
||||
expect(
|
||||
getEpgSourceValue({ custom_epg_id: undefined, force_dummy_epg: false })
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for empty custom_properties object', () => {
|
||||
expect(getEpgSourceValue({})).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for null custom_properties', () => {
|
||||
expect(getEpgSourceValue(null)).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for undefined custom_properties', () => {
|
||||
expect(getEpgSourceValue(undefined)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getEpgSourceData ──────────────────────────────────────────────────────
|
||||
describe('getEpgSourceData', () => {
|
||||
it('always includes "No EPG" as the first option', () => {
|
||||
const result = getEpgSourceData([]);
|
||||
expect(result[0]).toEqual({ value: '0', label: 'No EPG (Disabled)' });
|
||||
});
|
||||
|
||||
it('returns only the No EPG option for an empty source list', () => {
|
||||
expect(getEpgSourceData([])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('labels xmltv sources correctly', () => {
|
||||
const result = getEpgSourceData([
|
||||
makeEpgSource({ source_type: 'xmltv' }),
|
||||
]);
|
||||
expect(result[1].label).toBe('Source One (XMLTV)');
|
||||
});
|
||||
|
||||
it('labels dummy sources correctly', () => {
|
||||
const result = getEpgSourceData([
|
||||
makeEpgSource({ source_type: 'dummy' }),
|
||||
]);
|
||||
expect(result[1].label).toBe('Source One (Dummy)');
|
||||
});
|
||||
|
||||
it('labels schedules_direct sources correctly', () => {
|
||||
const result = getEpgSourceData([
|
||||
makeEpgSource({ source_type: 'schedules_direct' }),
|
||||
]);
|
||||
expect(result[1].label).toBe('Source One (Schedules Direct)');
|
||||
});
|
||||
|
||||
it('falls back to raw source_type for unknown types', () => {
|
||||
const result = getEpgSourceData([
|
||||
makeEpgSource({ source_type: 'custom_type' }),
|
||||
]);
|
||||
expect(result[1].label).toBe('Source One (custom_type)');
|
||||
});
|
||||
|
||||
it('maps id to string value', () => {
|
||||
const result = getEpgSourceData([makeEpgSource({ id: 42 })]);
|
||||
expect(result[1].value).toBe('42');
|
||||
});
|
||||
|
||||
it('sorts sources alphabetically by name', () => {
|
||||
const sources = [
|
||||
makeEpgSource({ id: 1, name: 'Zebra' }),
|
||||
makeEpgSource({ id: 2, name: 'Alpha' }),
|
||||
makeEpgSource({ id: 3, name: 'Mango' }),
|
||||
];
|
||||
const result = getEpgSourceData(sources);
|
||||
expect(result.map((r) => r.label)).toEqual([
|
||||
'No EPG (Disabled)',
|
||||
'Alpha (XMLTV)',
|
||||
'Mango (XMLTV)',
|
||||
'Zebra (XMLTV)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const sources = [
|
||||
makeEpgSource({ id: 1, name: 'Zebra' }),
|
||||
makeEpgSource({ id: 2, name: 'Alpha' }),
|
||||
];
|
||||
const original = [...sources];
|
||||
getEpgSourceData(sources);
|
||||
expect(sources).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
// ── repackGroupChannels ───────────────────────────────────────────────────
|
||||
describe('repackGroupChannels', () => {
|
||||
it('calls API.repackGroupChannels with playlist id and channel_group', () => {
|
||||
const playlist = { id: 10 };
|
||||
const group = { channel_group: 5 };
|
||||
vi.mocked(API.repackGroupChannels).mockResolvedValue({ ok: true });
|
||||
|
||||
repackGroupChannels(playlist, group);
|
||||
|
||||
expect(API.repackGroupChannels).toHaveBeenCalledWith(10, 5);
|
||||
});
|
||||
|
||||
it('returns the API promise', async () => {
|
||||
const playlist = { id: 10 };
|
||||
const group = { channel_group: 5 };
|
||||
vi.mocked(API.repackGroupChannels).mockResolvedValue({ ok: true });
|
||||
|
||||
await expect(repackGroupChannels(playlist, group)).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatPreviewSummary ──────────────────────────────────────────────────
|
||||
describe('formatPreviewSummary', () => {
|
||||
it('returns null when result is null', () => {
|
||||
expect(formatPreviewSummary('streams', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when result is undefined', () => {
|
||||
expect(formatPreviewSummary('streams', undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('formats normal result with plural matches', () => {
|
||||
const result = makeResult({ match_count: 3, total_scanned: 50 });
|
||||
expect(formatPreviewSummary('streams', result)).toBe(
|
||||
'3 streams matches in 50 streams'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats normal result with singular match', () => {
|
||||
const result = makeResult({ match_count: 1, total_scanned: 50 });
|
||||
expect(formatPreviewSummary('streams', result)).toBe(
|
||||
'1 streams match in 50 streams'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses singular "stream" when total_scanned is 1', () => {
|
||||
const result = makeResult({ match_count: 1, total_scanned: 1 });
|
||||
expect(formatPreviewSummary('streams', result)).toBe(
|
||||
'1 streams match in 1 stream'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats scan_limit_hit result with plural matches', () => {
|
||||
const result = makeResult({
|
||||
match_count: 5,
|
||||
total_scanned: 200,
|
||||
total_in_group: 1000,
|
||||
scan_limit_hit: true,
|
||||
});
|
||||
expect(formatPreviewSummary('streams', result)).toBe(
|
||||
'5 matches in first 200 streams scanned (of 1,000 total)'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats scan_limit_hit result with singular match', () => {
|
||||
const result = makeResult({
|
||||
match_count: 1,
|
||||
total_scanned: 200,
|
||||
total_in_group: 1000,
|
||||
scan_limit_hit: true,
|
||||
});
|
||||
expect(formatPreviewSummary('streams', result)).toBe(
|
||||
'1 match in first 200 streams scanned (of 1,000 total)'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses toLocaleString formatting for large numbers in scan_limit_hit', () => {
|
||||
const result = makeResult({
|
||||
match_count: 2,
|
||||
total_scanned: 5000,
|
||||
total_in_group: 10000,
|
||||
scan_limit_hit: true,
|
||||
});
|
||||
expect(formatPreviewSummary('streams', result)).toBe(
|
||||
'2 matches in first 5,000 streams scanned (of 10,000 total)'
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the label in normal result output', () => {
|
||||
const result = makeResult({ match_count: 2, total_scanned: 10 });
|
||||
expect(formatPreviewSummary('channels', result)).toContain('channels');
|
||||
});
|
||||
|
||||
it('does not include the label in scan_limit_hit output', () => {
|
||||
const result = makeResult({
|
||||
scan_limit_hit: true,
|
||||
match_count: 2,
|
||||
total_scanned: 10,
|
||||
total_in_group: 100,
|
||||
});
|
||||
expect(formatPreviewSummary('channels', result)).not.toContain(
|
||||
'channels'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js
Normal file
158
frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
computeRangeOverlapsFor,
|
||||
clampChannelNumber,
|
||||
} from '../AutoSyncBasicUtils.js';
|
||||
|
||||
vi.mock('../GroupSyncUtils.js', () => ({
|
||||
getGroupReservation: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getGroupReservation } from '../GroupSyncUtils.js';
|
||||
|
||||
const makeGroup = (overrides = {}) => ({
|
||||
channel_group: 1,
|
||||
name: 'Group A',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AutoSyncBasicUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── computeRangeOverlapsFor ───────────────────────────────────────────────
|
||||
describe('computeRangeOverlapsFor', () => {
|
||||
it('returns [] when the target group has no reservation', () => {
|
||||
getGroupReservation.mockReturnValue(null);
|
||||
expect(computeRangeOverlapsFor(makeGroup(), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] when there are no other groups', () => {
|
||||
getGroupReservation.mockReturnValue([100, 200]);
|
||||
expect(computeRangeOverlapsFor(makeGroup(), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips groups with the same channel_group id', () => {
|
||||
const group = makeGroup({ channel_group: 1 });
|
||||
const same = makeGroup({ channel_group: 1, name: 'Same' });
|
||||
getGroupReservation.mockReturnValue([100, 200]);
|
||||
expect(computeRangeOverlapsFor(group, [same])).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips other groups with no reservation', () => {
|
||||
const group = makeGroup({ channel_group: 1 });
|
||||
const other = makeGroup({ channel_group: 2, name: 'Other' });
|
||||
getGroupReservation
|
||||
.mockReturnValueOnce([100, 200]) // target group
|
||||
.mockReturnValueOnce(null); // other group
|
||||
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects exact overlap', () => {
|
||||
const group = makeGroup({ channel_group: 1 });
|
||||
const other = makeGroup({ channel_group: 2, name: 'Other' });
|
||||
getGroupReservation
|
||||
.mockReturnValueOnce([100, 200])
|
||||
.mockReturnValueOnce([100, 200]);
|
||||
expect(computeRangeOverlapsFor(group, [other])).toEqual([
|
||||
{ name: 'Other', start: 100, end: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects partial overlap at start boundary', () => {
|
||||
const group = makeGroup({ channel_group: 1 });
|
||||
const other = makeGroup({ channel_group: 2, name: 'Other' });
|
||||
getGroupReservation
|
||||
.mockReturnValueOnce([150, 250])
|
||||
.mockReturnValueOnce([100, 150]);
|
||||
expect(computeRangeOverlapsFor(group, [other])).toEqual([
|
||||
{ name: 'Other', start: 100, end: 150 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects partial overlap at end boundary', () => {
|
||||
const group = makeGroup({ channel_group: 1 });
|
||||
const other = makeGroup({ channel_group: 2, name: 'Other' });
|
||||
getGroupReservation
|
||||
.mockReturnValueOnce([100, 200])
|
||||
.mockReturnValueOnce([200, 300]);
|
||||
expect(computeRangeOverlapsFor(group, [other])).toEqual([
|
||||
{ name: 'Other', start: 200, end: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when other group is entirely before target', () => {
|
||||
const group = makeGroup({ channel_group: 1 });
|
||||
const other = makeGroup({ channel_group: 2, name: 'Other' });
|
||||
getGroupReservation
|
||||
.mockReturnValueOnce([200, 300])
|
||||
.mockReturnValueOnce([100, 199]);
|
||||
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] when other group is entirely after target', () => {
|
||||
const group = makeGroup({ channel_group: 1 });
|
||||
const other = makeGroup({ channel_group: 2, name: 'Other' });
|
||||
getGroupReservation
|
||||
.mockReturnValueOnce([100, 199])
|
||||
.mockReturnValueOnce([200, 300]);
|
||||
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns multiple overlapping groups', () => {
|
||||
const group = makeGroup({ channel_group: 1 });
|
||||
const other1 = makeGroup({ channel_group: 2, name: 'B' });
|
||||
const other2 = makeGroup({ channel_group: 3, name: 'C' });
|
||||
getGroupReservation
|
||||
.mockReturnValueOnce([100, 300]) // target
|
||||
.mockReturnValueOnce([150, 200]) // other1 overlaps
|
||||
.mockReturnValueOnce([250, 350]); // other2 overlaps
|
||||
expect(computeRangeOverlapsFor(group, [other1, other2])).toEqual([
|
||||
{ name: 'B', start: 150, end: 200 },
|
||||
{ name: 'C', start: 250, end: 350 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── clampChannelNumber ────────────────────────────────────────────────────
|
||||
describe('clampChannelNumber', () => {
|
||||
it('returns the value unchanged when within range', () => {
|
||||
expect(clampChannelNumber(500)).toBe(500);
|
||||
});
|
||||
|
||||
it('clamps below 1 to 1', () => {
|
||||
expect(clampChannelNumber(0)).toBe(1);
|
||||
expect(clampChannelNumber(-50)).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps above 999999 to 999999', () => {
|
||||
expect(clampChannelNumber(1000000)).toBe(999999);
|
||||
expect(clampChannelNumber(99999999)).toBe(999999);
|
||||
});
|
||||
|
||||
it('floors decimal values', () => {
|
||||
expect(clampChannelNumber(5.9)).toBe(5);
|
||||
expect(clampChannelNumber(1.1)).toBe(1);
|
||||
});
|
||||
|
||||
it('coerces numeric strings', () => {
|
||||
expect(clampChannelNumber('42')).toBe(42);
|
||||
});
|
||||
|
||||
it('falls back to 1 for NaN inputs', () => {
|
||||
expect(clampChannelNumber('abc')).toBe(1);
|
||||
expect(clampChannelNumber(NaN)).toBe(1);
|
||||
expect(clampChannelNumber(undefined)).toBe(1);
|
||||
expect(clampChannelNumber(null)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for boundary value 1', () => {
|
||||
expect(clampChannelNumber(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 999999 for boundary value 999999', () => {
|
||||
expect(clampChannelNumber(999999)).toBe(999999);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,23 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getEPGs,
|
||||
getSelectedAdvancedOptions,
|
||||
applyAdvancedOptionsChange,
|
||||
getEpgSourceValue,
|
||||
getEpgSourceData,
|
||||
getChannelsInRange,
|
||||
getStreamsRegexPreview,
|
||||
isExpectedOccupantForGroup,
|
||||
rangeFor,
|
||||
abortTimers,
|
||||
getRegexOptions,
|
||||
computeAutoSyncStart,
|
||||
isGroupVisible,
|
||||
} from '../LiveGroupFilterUtils.js';
|
||||
|
||||
// ── API mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: { getEPGs: vi.fn() },
|
||||
default: {
|
||||
getEPGs: vi.fn(),
|
||||
getChannelsInRange: vi.fn(),
|
||||
getStreamsRegexPreview: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import API from '../../../api.js';
|
||||
|
|
@ -22,9 +30,41 @@ const makeEpgSource = (overrides = {}) => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
describe('LiveGroupFilterUtils', () => {
|
||||
// ── getEPGs ────────────────────────────────────────────────────────────────
|
||||
const makeGroup = (overrides = {}) => ({
|
||||
name: 'Group A',
|
||||
enabled: true,
|
||||
auto_channel_sync: true,
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
custom_properties: {},
|
||||
channel_group: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeOccupant = (overrides = {}) => ({
|
||||
auto_created: true,
|
||||
has_channel_number_override: false,
|
||||
channel_group_id: 1,
|
||||
auto_created_by_account_id: 42,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makePlaylist = (overrides = {}) => ({
|
||||
id: 42,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeController = () => {
|
||||
const controller = { signal: {} };
|
||||
return controller;
|
||||
};
|
||||
|
||||
describe('LiveGroupFilterUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── getEPGs ────────────────────────────────────────────────────────────────
|
||||
describe('getEPGs', () => {
|
||||
it('delegates to API.getEPGs', () => {
|
||||
const result = [makeEpgSource()];
|
||||
|
|
@ -34,289 +74,425 @@ describe('LiveGroupFilterUtils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── getSelectedAdvancedOptions ───────────────────────────────────────────────
|
||||
// ── getChannelsInRange ─────────────────────────────────────────────────────
|
||||
describe('getChannelsInRange', () => {
|
||||
it('calls API.getChannelsInRange with start, end, and signal', () => {
|
||||
const controller = makeController();
|
||||
const result = [{ id: 1 }];
|
||||
vi.mocked(API.getChannelsInRange).mockResolvedValue(result);
|
||||
|
||||
describe('getSelectedAdvancedOptions', () => {
|
||||
it('returns empty array when custom_properties is empty', () => {
|
||||
expect(getSelectedAdvancedOptions({})).toEqual([]);
|
||||
});
|
||||
getChannelsInRange(100, 200, controller);
|
||||
|
||||
it('returns empty array when custom_properties is nullish', () => {
|
||||
expect(getSelectedAdvancedOptions(null)).toEqual([]);
|
||||
expect(getSelectedAdvancedOptions(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects force_epg via custom_epg_id', () => {
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects force_epg via force_epg_selected', () => {
|
||||
expect(
|
||||
getSelectedAdvancedOptions({ force_epg_selected: true })
|
||||
).toContain('force_epg');
|
||||
});
|
||||
|
||||
it('detects 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'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects name_regex via name_replace_pattern', () => {
|
||||
expect(
|
||||
getSelectedAdvancedOptions({ name_replace_pattern: '' })
|
||||
).toContain('name_regex');
|
||||
});
|
||||
|
||||
it('detects 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'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects 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'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects custom_logo', () => {
|
||||
expect(getSelectedAdvancedOptions({ custom_logo_id: null })).toContain(
|
||||
'custom_logo'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns multiple active options', () => {
|
||||
const result = getSelectedAdvancedOptions({
|
||||
name_match_regex: '',
|
||||
channel_sort_order: 'name',
|
||||
expect(API.getChannelsInRange).toHaveBeenCalledWith(100, 200, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result).toContain('name_match_regex');
|
||||
expect(result).toContain('channel_sort_order');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns the API promise', () => {
|
||||
const controller = makeController();
|
||||
const result = [{ id: 1 }];
|
||||
vi.mocked(API.getChannelsInRange).mockResolvedValue(result);
|
||||
|
||||
expect(getChannelsInRange(100, 200, controller)).resolves.toEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
// ── applyAdvancedOptionsChange ───────────────────────────────────────────────
|
||||
// ── getStreamsRegexPreview ─────────────────────────────────────────────────
|
||||
describe('getStreamsRegexPreview', () => {
|
||||
it('calls API with correct params when all values provided', () => {
|
||||
const group = { name: 'Group A' };
|
||||
const controller = makeController();
|
||||
const playlist = makePlaylist();
|
||||
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
|
||||
|
||||
describe('applyAdvancedOptionsChange', () => {
|
||||
describe('adding options', () => {
|
||||
it('adds force_epg defaults when newly selected', () => {
|
||||
const result = applyAdvancedOptionsChange({}, ['force_epg']);
|
||||
expect(result).toMatchObject({ force_dummy_epg: true });
|
||||
});
|
||||
getStreamsRegexPreview(
|
||||
group,
|
||||
'find',
|
||||
'replace',
|
||||
'match',
|
||||
'exclude',
|
||||
controller,
|
||||
playlist
|
||||
);
|
||||
|
||||
it('adds name_regex defaults when newly selected', () => {
|
||||
const result = applyAdvancedOptionsChange({}, ['name_regex']);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds profile_assignment defaults', () => {
|
||||
const result = applyAdvancedOptionsChange({}, ['profile_assignment']);
|
||||
expect(result).toMatchObject({ channel_profile_ids: [] });
|
||||
});
|
||||
|
||||
it('adds custom_logo defaults', () => {
|
||||
const result = applyAdvancedOptionsChange({}, ['custom_logo']);
|
||||
expect(result).toMatchObject({ custom_logo_id: null });
|
||||
});
|
||||
|
||||
it('does not overwrite existing keys when option is already active', () => {
|
||||
const prev = { name_match_regex: 'existing' };
|
||||
const result = applyAdvancedOptionsChange(prev, ['name_match_regex']);
|
||||
expect(result.name_match_regex).toBe('existing');
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
|
||||
find: 'find',
|
||||
replace: 'replace',
|
||||
match: 'match',
|
||||
exclude: 'exclude',
|
||||
limit: 10,
|
||||
signal: controller.signal,
|
||||
m3uAccountId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
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 result = applyAdvancedOptionsChange(prev, []);
|
||||
expect(result).not.toHaveProperty('force_dummy_epg');
|
||||
expect(result).not.toHaveProperty('custom_epg_id');
|
||||
expect(result).not.toHaveProperty('force_epg_selected');
|
||||
});
|
||||
it('omits find/replace when find is falsy', () => {
|
||||
const group = { name: 'Group A' };
|
||||
const controller = makeController();
|
||||
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
|
||||
|
||||
it('removes name_regex keys when deselected', () => {
|
||||
const prev = { name_regex_pattern: 'foo', name_replace_pattern: 'bar' };
|
||||
const result = applyAdvancedOptionsChange(prev, []);
|
||||
expect(result).not.toHaveProperty('name_regex_pattern');
|
||||
expect(result).not.toHaveProperty('name_replace_pattern');
|
||||
});
|
||||
getStreamsRegexPreview(group, '', 'replace', '', '', controller, null);
|
||||
|
||||
it('removes channel_sort_order and channel_sort_reverse when deselected', () => {
|
||||
const prev = { channel_sort_order: 'name', channel_sort_reverse: true };
|
||||
const result = applyAdvancedOptionsChange(prev, []);
|
||||
expect(result).not.toHaveProperty('channel_sort_order');
|
||||
expect(result).not.toHaveProperty('channel_sort_reverse');
|
||||
});
|
||||
|
||||
it('removes custom_logo_id when deselected', () => {
|
||||
const prev = { custom_logo_id: 42 };
|
||||
const result = applyAdvancedOptionsChange(prev, []);
|
||||
expect(result).not.toHaveProperty('custom_logo_id');
|
||||
});
|
||||
|
||||
it('does not remove keys for options that are still selected', () => {
|
||||
const prev = { name_match_regex: 'foo', custom_logo_id: 1 };
|
||||
const result = applyAdvancedOptionsChange(prev, ['name_match_regex']);
|
||||
expect(result).toHaveProperty('name_match_regex', 'foo');
|
||||
expect(result).not.toHaveProperty('custom_logo_id');
|
||||
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
|
||||
find: undefined,
|
||||
replace: undefined,
|
||||
match: undefined,
|
||||
exclude: undefined,
|
||||
limit: 10,
|
||||
signal: controller.signal,
|
||||
m3uAccountId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the original object', () => {
|
||||
const prev = { name_match_regex: 'foo' };
|
||||
applyAdvancedOptionsChange(prev, []);
|
||||
expect(prev).toHaveProperty('name_match_regex', 'foo');
|
||||
it('omits replace when find is falsy but keeps match/exclude if truthy', () => {
|
||||
const group = { name: 'Group A' };
|
||||
const controller = makeController();
|
||||
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
|
||||
|
||||
getStreamsRegexPreview(
|
||||
group,
|
||||
'',
|
||||
'',
|
||||
'match',
|
||||
'exclude',
|
||||
controller,
|
||||
null
|
||||
);
|
||||
|
||||
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
|
||||
find: undefined,
|
||||
replace: undefined,
|
||||
match: 'match',
|
||||
exclude: 'exclude',
|
||||
limit: 10,
|
||||
signal: controller.signal,
|
||||
m3uAccountId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getEpgSourceValue ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getEpgSourceValue', () => {
|
||||
it('returns custom_epg_id as string when set', () => {
|
||||
const group = { custom_properties: { custom_epg_id: 7 } };
|
||||
expect(getEpgSourceValue(group)).toBe('7');
|
||||
// ── isExpectedOccupantForGroup ─────────────────────────────────────────────
|
||||
describe('isExpectedOccupantForGroup', () => {
|
||||
it('returns false for null occupant', () => {
|
||||
expect(isExpectedOccupantForGroup(null, 1, makePlaylist())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "0" when force_dummy_epg is true and no custom_epg_id', () => {
|
||||
const group = { custom_properties: { force_dummy_epg: true } };
|
||||
expect(getEpgSourceValue(group)).toBe('0');
|
||||
it('returns false when occupant is not auto_created', () => {
|
||||
const occupant = makeOccupant({ auto_created: false });
|
||||
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when neither custom_epg_id nor force_dummy_epg is set', () => {
|
||||
const group = { custom_properties: {} };
|
||||
expect(getEpgSourceValue(group)).toBeNull();
|
||||
it('returns false when occupant has a channel number override', () => {
|
||||
const occupant = makeOccupant({ has_channel_number_override: true });
|
||||
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers custom_epg_id over force_dummy_epg', () => {
|
||||
const group = {
|
||||
custom_properties: { custom_epg_id: 3, force_dummy_epg: true },
|
||||
it('returns false when occupant belongs to a different group', () => {
|
||||
const occupant = makeOccupant({ channel_group_id: 99 });
|
||||
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when occupant was created by a different account', () => {
|
||||
const occupant = makeOccupant({ auto_created_by_account_id: 99 });
|
||||
expect(
|
||||
isExpectedOccupantForGroup(occupant, 1, makePlaylist({ id: 42 }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a valid expected occupant', () => {
|
||||
const occupant = makeOccupant();
|
||||
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true when channel_group_id is undefined', () => {
|
||||
const occupant = makeOccupant({ channel_group_id: undefined });
|
||||
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true when auto_created_by_account_id is undefined', () => {
|
||||
const occupant = makeOccupant({ auto_created_by_account_id: undefined });
|
||||
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── rangeFor ──────────────────────────────────────────────────────────────
|
||||
describe('rangeFor', () => {
|
||||
it('returns null when group is disabled', () => {
|
||||
expect(rangeFor(makeGroup({ enabled: false }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when auto_channel_sync is off', () => {
|
||||
expect(rangeFor(makeGroup({ auto_channel_sync: false }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when mode is next_available', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: { channel_numbering_mode: 'next_available' },
|
||||
});
|
||||
expect(rangeFor(group)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when start is not finite', () => {
|
||||
const group = makeGroup({ auto_sync_channel_start: 'abc' });
|
||||
expect(rangeFor(group)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns correct range for fixed mode', () => {
|
||||
const group = makeGroup({
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
custom_properties: { channel_numbering_mode: 'fixed' },
|
||||
});
|
||||
expect(rangeFor(group)).toEqual({ start: 100, end: 200, startRaw: 100 });
|
||||
});
|
||||
|
||||
it('uses fallback start for provider mode', () => {
|
||||
const group = makeGroup({
|
||||
custom_properties: {
|
||||
channel_numbering_mode: 'provider',
|
||||
channel_numbering_fallback: 50,
|
||||
},
|
||||
auto_sync_channel_end: 150,
|
||||
});
|
||||
expect(rangeFor(group)).toEqual({ start: 50, end: 150, startRaw: 50 });
|
||||
});
|
||||
|
||||
it('uses start as end when end is null', () => {
|
||||
const group = makeGroup({
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: null,
|
||||
});
|
||||
expect(rangeFor(group)).toEqual({ start: 100, end: 100, startRaw: 100 });
|
||||
});
|
||||
|
||||
it('uses start as end when end is empty string', () => {
|
||||
const group = makeGroup({
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: '',
|
||||
});
|
||||
expect(rangeFor(group)).toEqual({ start: 100, end: 100, startRaw: 100 });
|
||||
});
|
||||
|
||||
it('defaults start to 1 when auto_sync_channel_start is undefined', () => {
|
||||
const group = makeGroup({
|
||||
auto_sync_channel_start: undefined,
|
||||
auto_sync_channel_end: 10,
|
||||
});
|
||||
expect(rangeFor(group)).toEqual({ start: 1, end: 10, startRaw: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── abortTimers ───────────────────────────────────────────────────────────
|
||||
describe('abortTimers', () => {
|
||||
it('clears all timeouts and aborts all controllers', () => {
|
||||
const t1 = setTimeout(() => {}, 10000);
|
||||
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
|
||||
|
||||
const abortFn = vi.fn();
|
||||
const timerRef = { current: { a: t1 } };
|
||||
const abortRef = { current: { b: { abort: abortFn } } };
|
||||
|
||||
abortTimers(timerRef, abortRef);
|
||||
|
||||
expect(clearSpy).toHaveBeenCalledWith(t1);
|
||||
expect(abortFn).toHaveBeenCalledOnce();
|
||||
expect(timerRef.current).toEqual({});
|
||||
expect(abortRef.current).toEqual({});
|
||||
|
||||
clearSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not throw when abort throws', () => {
|
||||
const timerRef = { current: {} };
|
||||
const abortRef = {
|
||||
current: {
|
||||
a: {
|
||||
abort: () => {
|
||||
throw new Error('already aborted');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(getEpgSourceValue(group)).toBe('3');
|
||||
});
|
||||
|
||||
it('returns null when custom_epg_id is explicitly null', () => {
|
||||
const group = { custom_properties: { custom_epg_id: null } };
|
||||
expect(getEpgSourceValue(group)).toBeNull();
|
||||
expect(() => abortTimers(timerRef, abortRef)).not.toThrow();
|
||||
expect(abortRef.current).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getEpgSourceData ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getEpgSourceData', () => {
|
||||
it('always includes "No EPG (Disabled)" as the first entry', () => {
|
||||
const result = getEpgSourceData([]);
|
||||
expect(result[0]).toEqual({ value: '0', label: 'No EPG (Disabled)' });
|
||||
});
|
||||
|
||||
it('maps an xmltv source correctly', () => {
|
||||
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' }),
|
||||
]);
|
||||
expect(result).toContainEqual({ value: '2', label: 'Dummy (Dummy)' });
|
||||
});
|
||||
|
||||
it('maps a schedules_direct source correctly', () => {
|
||||
const result = getEpgSourceData([
|
||||
makeEpgSource({ id: 3, name: 'SD', source_type: 'schedules_direct' }),
|
||||
]);
|
||||
expect(result).toContainEqual({
|
||||
value: '3',
|
||||
label: 'SD (Schedules Direct)',
|
||||
// ── getRegexOptions ───────────────────────────────────────────────────────
|
||||
describe('getRegexOptions', () => {
|
||||
it('returns an object with the four regex fields', () => {
|
||||
expect(getRegexOptions('find', 'replace', 'filter', 'exclude')).toEqual({
|
||||
find: 'find',
|
||||
replace: 'replace',
|
||||
match: 'filter',
|
||||
exclude: 'exclude',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to raw source_type for unknown types', () => {
|
||||
const result = getEpgSourceData([
|
||||
makeEpgSource({ id: 4, name: 'Other', source_type: 'iptv' }),
|
||||
]);
|
||||
expect(result).toContainEqual({ value: '4', label: 'Other (iptv)' });
|
||||
it('preserves empty string values', () => {
|
||||
expect(getRegexOptions('', '', '', '')).toEqual({
|
||||
find: '',
|
||||
replace: '',
|
||||
match: '',
|
||||
exclude: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── computeAutoSyncStart ──────────────────────────────────────────────────
|
||||
describe('computeAutoSyncStart', () => {
|
||||
it('returns 1 when no other groups are active', () => {
|
||||
expect(computeAutoSyncStart([], 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('sorts sources alphabetically by name', () => {
|
||||
const sources = [
|
||||
makeEpgSource({ id: 1, name: 'Zebra' }),
|
||||
makeEpgSource({ id: 2, name: 'Apple' }),
|
||||
makeEpgSource({ id: 3, name: 'Mango' }),
|
||||
it('skips groups with the same id', () => {
|
||||
const groups = [
|
||||
makeGroup({
|
||||
channel_group: 1,
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
}),
|
||||
];
|
||||
const result = getEpgSourceData(sources);
|
||||
const labels = result.slice(1).map((r) => r.label.split(' (')[0]);
|
||||
expect(labels).toEqual(['Apple', 'Mango', 'Zebra']);
|
||||
expect(computeAutoSyncStart(groups, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not mutate the original sources array', () => {
|
||||
const sources = [
|
||||
makeEpgSource({ id: 1, name: 'Zebra' }),
|
||||
makeEpgSource({ id: 2, name: 'Apple' }),
|
||||
it('skips disabled groups', () => {
|
||||
const groups = [
|
||||
makeGroup({
|
||||
channel_group: 2,
|
||||
enabled: false,
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
}),
|
||||
];
|
||||
const original = [...sources];
|
||||
getEpgSourceData(sources);
|
||||
expect(sources).toEqual(original);
|
||||
expect(computeAutoSyncStart(groups, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns only the "No EPG" entry when sources array is empty', () => {
|
||||
expect(getEpgSourceData([])).toHaveLength(1);
|
||||
it('skips groups with auto_channel_sync off', () => {
|
||||
const groups = [
|
||||
makeGroup({
|
||||
channel_group: 2,
|
||||
auto_channel_sync: false,
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
}),
|
||||
];
|
||||
expect(computeAutoSyncStart(groups, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('skips groups with next_available mode', () => {
|
||||
const groups = [
|
||||
makeGroup({
|
||||
channel_group: 2,
|
||||
custom_properties: { channel_numbering_mode: 'next_available' },
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
}),
|
||||
];
|
||||
expect(computeAutoSyncStart(groups, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns upper + 1 of the highest active group range', () => {
|
||||
const groups = [
|
||||
makeGroup({
|
||||
channel_group: 2,
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
}),
|
||||
];
|
||||
expect(computeAutoSyncStart(groups, 1)).toBe(201);
|
||||
});
|
||||
|
||||
it('handles multiple groups and picks the highest upper bound', () => {
|
||||
const groups = [
|
||||
makeGroup({
|
||||
channel_group: 2,
|
||||
auto_sync_channel_start: 100,
|
||||
auto_sync_channel_end: 200,
|
||||
}),
|
||||
makeGroup({
|
||||
channel_group: 3,
|
||||
auto_sync_channel_start: 300,
|
||||
auto_sync_channel_end: 400,
|
||||
}),
|
||||
];
|
||||
expect(computeAutoSyncStart(groups, 1)).toBe(401);
|
||||
});
|
||||
|
||||
it('uses start as end when end is null', () => {
|
||||
const groups = [
|
||||
makeGroup({
|
||||
channel_group: 2,
|
||||
auto_sync_channel_start: 50,
|
||||
auto_sync_channel_end: null,
|
||||
}),
|
||||
];
|
||||
expect(computeAutoSyncStart(groups, 1)).toBe(51);
|
||||
});
|
||||
|
||||
it('uses provider fallback for provider mode', () => {
|
||||
const groups = [
|
||||
makeGroup({
|
||||
channel_group: 2,
|
||||
custom_properties: {
|
||||
channel_numbering_mode: 'provider',
|
||||
channel_numbering_fallback: 500,
|
||||
},
|
||||
auto_sync_channel_end: 600,
|
||||
}),
|
||||
];
|
||||
expect(computeAutoSyncStart(groups, 1)).toBe(601);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isGroupVisible ────────────────────────────────────────────────────────
|
||||
describe('isGroupVisible', () => {
|
||||
it('returns true when text and status both match', () => {
|
||||
const group = makeGroup({ name: 'Sports', enabled: true });
|
||||
expect(isGroupVisible(group, 'sport', 'enabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when text does not match', () => {
|
||||
const group = makeGroup({ name: 'Sports', enabled: true });
|
||||
expect(isGroupVisible(group, 'news', 'all')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when status filter is enabled but group is disabled', () => {
|
||||
const group = makeGroup({ name: 'Sports', enabled: false });
|
||||
expect(isGroupVisible(group, 'sport', 'enabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when status filter is disabled but group is enabled', () => {
|
||||
const group = makeGroup({ name: 'Sports', enabled: true });
|
||||
expect(isGroupVisible(group, 'sport', 'disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for disabled group with disabled filter', () => {
|
||||
const group = makeGroup({ name: 'Sports', enabled: false });
|
||||
expect(isGroupVisible(group, 'sport', 'disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches regardless of case', () => {
|
||||
const group = makeGroup({ name: 'Sports HD', enabled: true });
|
||||
expect(isGroupVisible(group, 'SPORTS', 'all')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with empty text filter', () => {
|
||||
const group = makeGroup({ name: 'Sports', enabled: true });
|
||||
expect(isGroupVisible(group, '', 'all')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue