diff --git a/frontend/src/components/forms/AutoSyncAdvanced.jsx b/frontend/src/components/forms/AutoSyncAdvanced.jsx new file mode 100644 index 00000000..abe101fd --- /dev/null +++ b/frontend/src/components/forms/AutoSyncAdvanced.jsx @@ -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 ( + + + {result ? formatPreviewSummary(summaryLabel, result) : placeholderLabel} + + {result?.error && ( + + Invalid regex: {result.error} + + )} + {loading && !result && ( + + Scanning streams... + + )} + {result && !result.error && result.matches?.length === 0 && ( + + {result.total_in_group === 0 + ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' + : emptyText} + + )} + {result?.matches?.map((row, idx) => + kind === 'find' ? ( + + + {row.before} + + + {' -> '} + + + {row.after} + + + ) : ( + + + {markerChar} + + + {row.name} + + + ) + )} + + ); +}; + +// 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 ( + + + + Name Transforms + + } + labelPosition="left" + size="sm" + color="gray.6" + /> + + + + updateFind(e.currentTarget.value)} + size="xs" + style={{ flex: 1 }} + /> + updateReplace(e.currentTarget.value)} + size="xs" + style={{ flex: 1 }} + /> + + {findValue && ( + + )} + + + + + + updateFilter(e.currentTarget.value)} + size="xs" + /> + {filterValue && ( + + )} + + + + + updateExclude(e.currentTarget.value)} + size="xs" + /> + {excludeValue && ( + + )} + + + + + + + + EPG & Logo + + } + labelPosition="left" + size="sm" + color="gray.6" + /> + + + ({ + value: g.id.toString(), + label: g.name, + }))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + + + ({ + value: profile.id.toString(), + label: profile.name, + }))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + + + + + + {sortReverseEnabled && ( + + updateSortReverse(event.currentTarget.checked) + } + size="xs" + mt="xs" + /> + )} + + + + + + + { + if (event.currentTarget.checked) { + setCp({ compact_numbering: true }); + } else { + setCp({}, ['compact_numbering']); + } + }} + size="xs" + /> + + + + + + + + + ); +}; + +export default AutoSyncAdvanced; diff --git a/frontend/src/components/forms/AutoSyncBasic.jsx b/frontend/src/components/forms/AutoSyncBasic.jsx new file mode 100644 index 00000000..6cab8ea7 --- /dev/null +++ b/frontend/src/components/forms/AutoSyncBasic.jsx @@ -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 ( + + Channels receive the lowest available numbers starting at 1. + + ); + } + 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 ( + + + + + + + + + + + + {metaText} + + {showWarning && ( + + + + )} + + + ); +}; + +export default AutoSyncBasic; diff --git a/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx b/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx new file mode 100644 index 00000000..52150e30 --- /dev/null +++ b/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx @@ -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 ( + + + + + Auto-sync orphan cleanup + + + + + + {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.'} + + + ); +}; + +export default OrphanCleanupControl; diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx index c4193433..626ec55e 100644 --- a/frontend/src/components/forms/LiveGroupFilter.jsx +++ b/frontend/src/components/forms/LiveGroupFilter.jsx @@ -1,56 +1,51 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { Suspense, useEffect, useRef, useState } from 'react'; import { - TextInput, - Button, - Checkbox, - Flex, - Select, - Stack, - Group, - SimpleGrid, - Text, - NumberInput, - Divider, + ActionIcon, Alert, Box, - MultiSelect, - Tooltip, - Popover, - ScrollArea, - Center, + Button, + Checkbox, + Divider, + Flex, + Group, + Loader, SegmentedControl, - ActionIcon, - Switch, + SimpleGrid, + Stack, + Text, + TextInput, + Tooltip, } from '@mantine/core'; -import { - Info, - CircleCheck, - CircleX, - Settings as Cog, - AlertTriangle, - RefreshCw, -} from 'lucide-react'; +import { CircleCheck, CircleX, Info, Settings as Cog } from 'lucide-react'; import GroupConfigureModal from './GroupConfigureModal'; -import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; import useStreamProfilesStore from '../../store/streamProfiles'; import { useChannelLogoSelection } from '../../hooks/useSmartLogos'; -import { FixedSizeList as List } from 'react-window'; -import LazyLogo from '../LazyLogo'; -import LogoForm from './Logo'; -import logo from '../../images/logo.png'; -import API from '../../api'; -import { getGroupReservation } from '../../utils/forms/GroupSyncUtils'; +import OrphanCleanupControl from './AutoSyncOrphanCleanup.jsx'; +import AutoSyncBasic from './AutoSyncBasic.jsx'; +import ErrorBoundary from '../ErrorBoundary.jsx'; +const AutoSyncAdvanced = React.lazy(() => import('./AutoSyncAdvanced.jsx')); +const LogoForm = React.lazy(() => import('./Logo.jsx')); +import { + abortTimers, + computeAutoSyncStart, + getChannelsInRange, + getEPGs, + getRegexOptions, + getStreamsRegexPreview, + isExpectedOccupantForGroup, + isGroupVisible, + rangeFor, +} from '../../utils/forms/LiveGroupFilterUtils.js'; const LiveGroupFilter = ({ - playlist, - groupStates, - setGroupStates, - autoEnableNewGroupsLive, - setAutoEnableNewGroupsLive, - }) => { + playlist, + groupStates, + setGroupStates, + autoEnableNewGroupsLive, + setAutoEnableNewGroupsLive, +}) => { const channelGroups = useChannelsStore((s) => s.channelGroups); - const profiles = useChannelsStore((s) => s.profiles); const streamProfiles = useStreamProfilesStore((s) => s.profiles); const fetchStreamProfiles = useStreamProfilesStore((s) => s.fetchProfiles); const [groupFilter, setGroupFilter] = useState(''); @@ -107,31 +102,6 @@ const LiveGroupFilter = ({ ); }; - // "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. - const isExpectedOccupantForGroup = (occupant, groupChannelGroupId) => { - if (!occupant) return false; - if (!occupant.auto_created) return false; - if (occupant.has_channel_number_override) return false; - if ( - occupant.channel_group_id !== undefined && - occupant.channel_group_id !== groupChannelGroupId - ) - return false; - if ( - occupant.auto_created_by_account_id !== undefined && - playlist?.id !== undefined && - occupant.auto_created_by_account_id !== playlist.id - ) - return false; - return true; - }; - // Update one source ('occupant' or 'form') of a group's conflict // tracking and re-merge into the public `groupConflicts` state. const setConflictSource = (groupId, source, value) => { @@ -181,14 +151,12 @@ const LiveGroupFilter = ({ const controller = new AbortController(); conflictAbortRef.current[groupId] = controller; try { - const result = await API.getChannelsInRange(start, end, { - signal: controller.signal, - }); + const result = await getChannelsInRange(start, end, controller); const occupants = Array.isArray(result?.occupants) ? result.occupants : []; const unexpected = occupants.filter( - (o) => !isExpectedOccupantForGroup(o, groupId) + (o) => !isExpectedOccupantForGroup(o, groupId, playlist) ); setConflictSource(groupId, 'occupant', unexpected.length > 0); } catch (e) { @@ -203,16 +171,7 @@ const LiveGroupFilter = ({ // Clear pending timers and abort in-flight conflict-scan requests on // unmount so a late response cannot setState on an unmounted component. return () => { - Object.values(conflictTimersRef.current).forEach((t) => clearTimeout(t)); - conflictTimersRef.current = {}; - Object.values(conflictAbortRef.current).forEach((c) => { - try { - c.abort(); - } catch { - // ignore - } - }); - conflictAbortRef.current = {}; + abortTimers(conflictTimersRef, conflictAbortRef); }; }, []); @@ -220,24 +179,6 @@ const LiveGroupFilter = ({ // (cheap). The HTTP-bound DB scan only runs for groups whose own // range fields changed since the last sweep. useEffect(() => { - 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 }; - }; - const ranges = new Map(); for (const g of groupStates) { const r = rangeFor(g); @@ -360,15 +301,15 @@ const LiveGroupFilter = ({ regexPreviewAbortRef.current[groupId] = controller; let response; try { - response = await 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, - }); + response = await getStreamsRegexPreview( + group, + find, + replace, + match, + exclude, + controller, + playlist + ); } catch (e) { if (e?.name === 'AbortError') return; throw e; @@ -401,18 +342,7 @@ const LiveGroupFilter = ({ useEffect(() => { return () => { - Object.values(regexPreviewTimersRef.current).forEach((t) => - clearTimeout(t) - ); - regexPreviewTimersRef.current = {}; - Object.values(regexPreviewAbortRef.current).forEach((c) => { - try { - c.abort(); - } catch { - // ignore - } - }); - regexPreviewAbortRef.current = {}; + abortTimers(regexPreviewTimersRef, regexPreviewAbortRef); }; }, []); @@ -423,12 +353,15 @@ const LiveGroupFilter = ({ useEffect(() => { if (!configuringGroup) return; const cp = configuringGroup.custom_properties || {}; - scheduleRegexPreview(configuringGroup, { - find: cp.name_regex_pattern || '', - replace: cp.name_replace_pattern ?? '', - match: cp.name_match_regex || '', - exclude: cp.name_match_exclude_regex || '', - }); + scheduleRegexPreview( + configuringGroup, + getRegexOptions( + cp.name_regex_pattern || '', + cp.name_replace_pattern ?? '', + cp.name_match_regex || '', + cp.name_match_exclude_regex || '' + ) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [configuringGroup?.channel_group]); @@ -448,7 +381,7 @@ const LiveGroupFilter = ({ useEffect(() => { const fetchEPGSources = async () => { try { - const sources = await API.getEPGs(); + const sources = await getEPGs(); setEpgSources(sources || []); } catch (error) { console.error('Failed to fetch EPG sources:', error); @@ -526,29 +459,7 @@ const LiveGroupFilter = ({ const currentStart = state.auto_sync_channel_start; if (currentStart && currentStart > 1) return next; - 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; - } - next.auto_sync_channel_start = proposedStart; + next.auto_sync_channel_start = computeAutoSyncStart(prev, id); return next; }) ); @@ -577,22 +488,13 @@ const LiveGroupFilter = ({ setCurrentEditingGroupId(null); }; - const isVisible = (group) => { - const matchesText = group.name - .toLowerCase() - .includes(groupFilter.toLowerCase()); - const matchesStatus = - statusFilter === 'all' || - (statusFilter === 'enabled' && group.enabled) || - (statusFilter === 'disabled' && !group.enabled); - return matchesText && matchesStatus; - }; - const selectAll = () => { setGroupStates((prev) => prev.map((state) => ({ ...state, - enabled: isVisible(state) ? true : state.enabled, + enabled: isGroupVisible(state, groupFilter, statusFilter) + ? true + : state.enabled, })) ); }; @@ -601,1009 +503,13 @@ const LiveGroupFilter = ({ setGroupStates((prev) => prev.map((state) => ({ ...state, - enabled: isVisible(state) ? false : state.enabled, + enabled: isGroupVisible(state, groupFilter, statusFilter) + ? false + : state.enabled, })) ); }; - // Returns {name, start, end}[] for groups whose declared ranges - // intersect this group's range, or [] when there is no overlap. - const computeRangeOverlapsFor = (group) => { - 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; - }; - - // 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. - const renderNumberingRange = (group) => { - const mode = group.custom_properties?.channel_numbering_mode || 'fixed'; - if (mode === 'next_available') { - return ( - - Channels receive the lowest available numbers starting at 1. - - ); - } - 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 MAX_CHANNEL_NUMBER = 999999; - const clampChannelNumber = (n) => - Math.max(1, Math.min(MAX_CHANNEL_NUMBER, Math.floor(Number(n) || 1))); - const updateStart = (value) => { - const normalized = - value === '' || value === null || value === undefined - ? 1 - : clampChannelNumber(value); - if (mode === 'provider') { - applyGroupChange({ - ...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; - } - applyGroupChange(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); - applyGroupChange({ - ...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); - - // 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 ( - - - - - - - - - - - - {metaText} - - {showWarning && ( - - - - )} - - - ); - }; - - // Header line for the preview box. Adds a scan-cap suffix when the - // backend only scanned the first SCAN_CAP streams of the group. - 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'}`; - }; - - // Find/replace regex preview backed by /streams/regex-preview/, so - // counts reflect the whole group (or up to SCAN_CAP) rather than a - // small client-side sample. - const renderRegexPreview = (group) => { - const find = group.custom_properties?.name_regex_pattern || ''; - if (!find) return null; - const state = regexPreviewState[group.channel_group] || {}; - const result = state.findResult; - const loading = state.loading; - return ( - - - {result ? formatPreviewSummary('rename', result) : 'Preview'} - - {result?.error && ( - - Invalid regex: {result.error} - - )} - {loading && !result && ( - - Scanning streams... - - )} - {result && !result.error && result.matches.length === 0 && ( - - {result.total_in_group === 0 - ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' - : 'No streams matched this pattern.'} - - )} - {result?.matches?.map((row, idx) => ( - - - {row.before} - - - {' -> '} - - - {row.after} - - - ))} - - ); - }; - - // 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. - const renderFilterPreview = (group, kind) => { - const pattern = - kind === 'exclude' - ? group.custom_properties?.name_match_exclude_regex || '' - : group.custom_properties?.name_match_regex || ''; - if (!pattern) return null; - const state = regexPreviewState[group.channel_group] || {}; - const result = - kind === 'exclude' ? state.excludeResult : state.filterResult; - const loading = state.loading; - const summaryLabel = kind === 'exclude' ? 'exclude' : 'filter'; - const placeholderLabel = - kind === 'exclude' ? 'Exclude preview' : 'Filter 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 ( - - - {result - ? formatPreviewSummary(summaryLabel, result) - : placeholderLabel} - - {result?.error && ( - - Invalid regex: {result.error} - - )} - {loading && !result && ( - - Scanning streams... - - )} - {result && !result.error && result.matches.length === 0 && ( - - {result.total_in_group === 0 - ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' - : emptyText} - - )} - {result?.matches?.map((row, idx) => ( - - - {markerChar} - - - {row.name} - - - ))} - - ); - }; - - const renderMatchPreview = (group) => renderFilterPreview(group, 'include'); - const renderExcludePreview = (group) => renderFilterPreview(group, 'exclude'); - - // 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 renderAdvancedOptions = (group) => { - const cp = group.custom_properties || {}; - const setCp = (patch, clears = []) => { - const next = { ...cp, ...patch }; - clears.forEach((k) => delete next[k]); - applyGroupChange({ ...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, - }); - } - scheduleRegexPreview(group, { - find: val, - replace: replaceValue, - match: filterValue, - exclude: excludeValue, - }); - }; - const updateReplace = (val) => { - if (!val && !findValue) { - setCp({}, ['name_regex_pattern', 'name_replace_pattern']); - } else { - setCp({ - name_regex_pattern: findValue, - name_replace_pattern: val, - }); - } - scheduleRegexPreview(group, { - find: findValue, - replace: val, - match: filterValue, - exclude: excludeValue, - }); - }; - const updateFilter = (val) => { - if (!val) setCp({}, ['name_match_regex']); - else setCp({ name_match_regex: val }); - scheduleRegexPreview(group, { - find: findValue, - replace: replaceValue, - match: val, - exclude: excludeValue, - }); - }; - const updateExclude = (val) => { - if (!val) setCp({}, ['name_match_exclude_regex']); - else setCp({ name_match_exclude_regex: val }); - scheduleRegexPreview(group, { - find: findValue, - replace: replaceValue, - match: filterValue, - exclude: val, - }); - }; - - // --- EPG --- - - const epgValue = (() => { - if (cp.custom_epg_id !== undefined && cp.custom_epg_id !== null) { - return cp.custom_epg_id.toString(); - } - if (cp.force_dummy_epg) return '0'; - return ''; - })(); - 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); - } - applyGroupChange({ ...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 ( - - - - Name Transforms - - } - labelPosition="left" - size="sm" - color="gray.6" - /> - - - - updateFind(e.currentTarget.value)} - size="xs" - style={{ flex: 1 }} - /> - updateReplace(e.currentTarget.value)} - size="xs" - style={{ flex: 1 }} - /> - - {findValue && renderRegexPreview(group)} - - - - - - updateFilter(e.currentTarget.value)} - size="xs" - /> - {filterValue && renderMatchPreview(group)} - - - - - updateExclude(e.currentTarget.value)} - size="xs" - /> - {excludeValue && renderExcludePreview(group)} - - - - - - - - EPG & Logo - - } - labelPosition="left" - size="sm" - color="gray.6" - /> - - - ({ - value: g.id.toString(), - label: g.name, - }))} - clearable - searchable - size="xs" - style={{ flex: 1 }} - /> - - - ({ - value: profile.id.toString(), - label: profile.name, - }))} - clearable - searchable - size="xs" - style={{ flex: 1 }} - /> - - - - - - {sortReverseEnabled && ( - - updateSortReverse(event.currentTarget.checked) - } - size="xs" - mt="xs" - /> - )} - - - - - - - { - if (event.currentTarget.checked) { - setCp({ compact_numbering: true }); - } else { - setCp({}, ['compact_numbering']); - } - }} - size="xs" - /> - - - - - - - - - ); - }; - - // 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 API.updatePlaylist({ - id: playlist.id, - custom_properties: nextProps, - }); - } catch (err) { - setOrphanCleanupMode(previousMode); - notifications.show({ - title: 'Failed to update cleanup mode', - message: err?.body?.detail || err?.message || 'Please try again.', - color: 'red', - }); - } - }; - return ( } color="blue" variant="light"> @@ -1625,39 +531,7 @@ const LiveGroupFilter = ({ description="When disabled, new groups from the M3U source will be created but disabled by default. You can enable them manually later." /> - - - - - Auto-sync orphan cleanup - - - - - - {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.'} - - + {groupStates - .filter((group) => isVisible(group)) + .filter((group) => isGroupVisible(group, groupFilter, statusFilter)) .sort((a, b) => a.name.localeCompare(b.name)) .map((group) => ( )} @@ -1894,20 +773,48 @@ const LiveGroupFilter = ({ }} group={configuringGroup} > - {configuringGroup && renderAdvancedOptions(configuringGroup)} + {configuringGroup && ( + + }> + { + setCurrentEditingGroupId(groupId); + setLogoModalOpen(true); + }} + channelLogos={channelLogos} + playlist={playlist} + logosLoading={logosLoading} + ensureLogosLoaded={ensureLogosLoaded} + /> + + + )} {/* Logo Upload Modal */} - { - setLogoModalOpen(false); - setCurrentEditingGroupId(null); - }} - onSuccess={handleLogoSuccess} - /> + {logoModalOpen && ( + + }> + { + setLogoModalOpen(false); + setCurrentEditingGroupId(null); + }} + onSuccess={handleLogoSuccess} + /> + + + )} ); }; -export default LiveGroupFilter; \ No newline at end of file +export default LiveGroupFilter; diff --git a/frontend/src/components/forms/__tests__/AutoSyncAdvanced.test.jsx b/frontend/src/components/forms/__tests__/AutoSyncAdvanced.test.jsx new file mode 100644 index 00000000..177eaea2 --- /dev/null +++ b/frontend/src/components/forms/__tests__/AutoSyncAdvanced.test.jsx @@ -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 }) => ( + {alt} + ), +})); + +vi.mock('react-window', () => ({ + FixedSizeList: ({ children, itemCount, itemData }) => ( +
+ {Array.from({ length: Math.min(itemCount, 5) }, (_, i) => + children({ index: i, style: {}, data: itemData }) + )} +
+ ), +})); + +vi.mock('lucide-react', () => ({ + RefreshCw: () => , +})); + +vi.mock('@mantine/core', () => ({ + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, variant, size, color }) => ( + + ), + Center: ({ children }) =>
{children}
, + Checkbox: ({ checked, onChange, label, 'data-testid': testId }) => ( + + ), + Divider: () =>
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + MultiSelect: ({ value, onChange, label, data, 'data-testid': testId }) => ( +
+ {label && } + +
+ ), + Popover: ({ children }) =>
{children}
, + PopoverDropdown: ({ children }) => ( +
{children}
+ ), + PopoverTarget: ({ children }) => ( +
{children}
+ ), + ScrollArea: ({ children }) =>
{children}
, + Select: ({ + value, + onChange, + label, + data, + 'data-testid': testId, + clearable, + }) => ( +
+ {label && } + +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange, label, 'data-testid': testId }) => ( + + ), + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + TextInput: ({ + value, + onChange, + label, + placeholder, + 'data-testid': testId, + }) => ( +
+ {label && } + onChange?.(e)} + /> +
+ ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), +})); + +// ── 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(); + 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(); + expect(getEpgSourceValue).toHaveBeenCalledTimes(1); + + const newGroup = makeGroup({ channel_group: 2, name: 'News' }); + rerender(); + expect(getEpgSourceValue).toHaveBeenCalledTimes(2); + }); + + it('re-calls getEpgSourceData when epgSources prop changes', () => { + const { rerender } = render(); + const callCount = vi.mocked(getEpgSourceData).mock.calls.length; + + const newSources = [ + makeEpgSource(), + makeEpgSource({ id: 99, name: 'New' }), + ]; + rerender( + + ); + expect(vi.mocked(getEpgSourceData).mock.calls.length).toBeGreaterThan( + callCount + ); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx b/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx new file mode 100644 index 00000000..67055a4a --- /dev/null +++ b/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx @@ -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 }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), + NumberInput: ({ + value, + onChange, + label, + 'data-testid': testId, + min, + max, + }) => ( +
+ {label && } + + onChange?.(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertTriangle: () => , +})); + +// ── 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( + + ); + 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( + + ); + 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( + + ); + expect(computeRangeOverlapsFor).toHaveBeenCalledWith(group, groupStates); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx b/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx new file mode 100644 index 00000000..509f835f --- /dev/null +++ b/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx @@ -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 }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, + Tooltip: ({ children }) => <>{children}, + SegmentedControl: ({ value, onChange, data }) => ( +
+ {data.map((opt) => ( + + ))} +
+ ), +})); + +// ── 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(); + +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( + + ); + expect(getControl()).toHaveAttribute('data-value', 'always'); + + rerender( + + ); + expect(getControl()).toHaveAttribute('data-value', 'never'); + }); + + it('resets to "always" when orphan_channel_cleanup is removed from custom_properties', () => { + const { rerender } = render( + + ); + rerender( + + ); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx b/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx index 35549856..b96b6b4b 100644 --- a/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx +++ b/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx @@ -1,251 +1,913 @@ +import React, { useState } from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Targeted Vitest for the orphan-cleanup SegmentedControl that lives at -// the top of the M3U account's group-settings page. Covers default-mode -// resolution, click-to-PATCH, and optimistic-update / error-revert -// behavior. Other parts of LiveGroupFilter (per-group inline config, -// gear modal, regex preview, overlap warning) are not unit-tested here -// because they depend on external APIs and live data; they are exercised -// via the in-repo manual UI walkthrough. - -// ── Store mocks ──────────────────────────────────────────────────────────────── +// ── Store mocks ─────────────────────────────────────────────────────────────── vi.mock('../../../store/channels', () => ({ default: vi.fn() })); vi.mock('../../../store/streamProfiles', () => ({ default: vi.fn() })); -// ── Hook mocks ───────────────────────────────────────────────────────────────── +// ── Hook mocks ──────────────────────────────────────────────────────────────── vi.mock('../../../hooks/useSmartLogos', () => ({ - useChannelLogoSelection: vi.fn(() => ({ - logos: {}, - ensureLogosLoaded: vi.fn(), - isLoading: false, - })), + useChannelLogoSelection: vi.fn(), })); -// ── Module mocks ─────────────────────────────────────────────────────────────── -vi.mock('@mantine/notifications', () => ({ - notifications: { show: vi.fn() }, -})); - -vi.mock('../../../api', () => ({ - default: { - updatePlaylist: vi.fn(() => Promise.resolve({})), - getEPGs: vi.fn(() => Promise.resolve([])), - getChannelsInRange: vi.fn(() => Promise.resolve({ data: [] })), - getStreamsRegexPreview: vi.fn(() => Promise.resolve({ data: {} })), - repackGroupChannels: vi.fn(() => Promise.resolve({})), - }, -})); - -vi.mock('../../../utils/forms/GroupSyncUtils', () => ({ - getGroupReservation: vi.fn(() => null), -})); - -// ── Child component mocks ────────────────────────────────────────────────────── +// ── Child component mocks ───────────────────────────────────────────────────── vi.mock('../GroupConfigureModal', () => ({ - default: ({ children }) =>
{children}
, + default: ({ opened, onDone, onCancel, group, children }) => + opened ? ( +
+ {children} + + +
+ ) : null, })); -vi.mock('../Logo', () => ({ default: () => null })); -vi.mock('../../LazyLogo', () => ({ default: () => null })); -vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); +vi.mock('../AutoSyncOrphanCleanup.jsx', () => ({ + default: ({ playlist }) => ( +
+ ), +})); -vi.mock('react-window', () => ({ - FixedSizeList: ({ children, itemCount }) => ( -
- {Array.from({ length: itemCount }, (_, index) => - children({ index, style: {} }) - )} +vi.mock('../AutoSyncBasic.jsx', () => ({ + default: () =>
, +})); + +vi.mock('../../ErrorBoundary.jsx', () => ({ + default: ({ children }) => <>{children}, +})); + +vi.mock('../AutoSyncAdvanced.jsx', () => ({ + default: ({ + group, + onApplyGroupChange, + onScheduleRegexPreview, + onOpenLogoUpload, + }) => ( +
+ + +
), })); -vi.mock('lucide-react', () => ({ - Info: () => , - CircleCheck: () => , - CircleX: () => , - Settings: () => , - AlertTriangle: () => , - RefreshCw: () => , +vi.mock('../Logo.jsx', () => ({ + default: ({ isOpen, onClose, onSuccess }) => + isOpen ? ( +
+ + +
+ ) : null, })); -// ── @mantine/core minimal mocks ──────────────────────────────────────────────── -vi.mock('@mantine/core', () => ({ - TextInput: ({ value, onChange, placeholder }) => ( - +// ── Utility mocks ───────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/LiveGroupFilterUtils.js', () => ({ + abortTimers: vi.fn(), + computeAutoSyncStart: vi.fn(() => 101), + getChannelsInRange: vi.fn().mockResolvedValue({ occupants: [] }), + getEPGs: vi.fn().mockResolvedValue([]), + getRegexOptions: vi.fn((find, replace, match, exclude) => ({ + find, + replace, + match, + exclude, + })), + getStreamsRegexPreview: vi.fn().mockResolvedValue(null), + isExpectedOccupantForGroup: vi.fn(() => true), + isGroupVisible: vi.fn((group, filter, status) => { + if (status === 'enabled') return group.enabled; + if (status === 'disabled') return !group.enabled; + if (filter) return group.name.toLowerCase().includes(filter.toLowerCase()); + return true; + }), + rangeFor: vi.fn(() => null), +})); + +// ── Mantine core mock ───────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + ), - Button: ({ children, onClick }) => , - Checkbox: ({ label, checked, onChange }) => ( + Alert: ({ children }) =>
{children}
, + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, variant, color }) => ( + + ), + Checkbox: ({ label, checked, onChange, description, disabled }) => ( ), + Divider: () =>
, Flex: ({ children }) =>
{children}
, - Select: ({ value, onChange, data }) => ( - + Group: ({ children, style }) => ( +
+ {children} +
), - Stack: ({ children }) =>
{children}
, - Group: ({ children }) =>
{children}
, - SimpleGrid: ({ children }) =>
{children}
, - Text: ({ children }) => {children}, - NumberInput: ({ value, onChange }) => ( - onChange?.(Number(e.target.value))} - /> - ), - Divider: ({ label }) =>
, - Alert: ({ children }) =>
{children}
, - Box: ({ children }) =>
{children}
, - MultiSelect: ({ value, onChange }) => ( - - ), - Tooltip: ({ children }) => <>{children}, - Popover: ({ children }) =>
{children}
, - ScrollArea: ({ children }) =>
{children}
, - Center: ({ children }) =>
{children}
, - SegmentedControl: ({ value, onChange, data }) => ( -
- {data.map((opt) => ( + Loader: () =>
, + SegmentedControl: ({ onChange, data }) => ( +
+ {(data || []).map((item) => ( ))}
), - ActionIcon: ({ children, onClick }) => , - Switch: ({ label, checked, onChange }) => ( - + SimpleGrid: ({ children }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children }) => {children}, + TextInput: ({ placeholder, value, onChange }) => ( + ), + Tooltip: ({ children }) => <>{children}, })); -// ── Imports after mocks ──────────────────────────────────────────────────────── +// ── lucide-react mock ───────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + CircleCheck: () => , + CircleX: () => , + Info: () => , + Settings: () => , +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── import LiveGroupFilter from '../LiveGroupFilter'; -import API from '../../../api'; -import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../../store/channels'; import useStreamProfilesStore from '../../../store/streamProfiles'; +import { useChannelLogoSelection } from '../../../hooks/useSmartLogos'; +import { + getEPGs, + getRegexOptions, + getStreamsRegexPreview, + isGroupVisible, +} from '../../../utils/forms/LiveGroupFilterUtils.js'; -// ────────────────────────────────────────────────────────────────────────────── - -const makePlaylist = (overrides = {}) => ({ - id: 7, - channel_groups: [], - custom_properties: null, +// ── Factories ───────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + channel_group: 1, + name: 'Sports', + enabled: true, + auto_channel_sync: true, + auto_sync_channel_start: 1, + auto_sync_channel_end: null, + custom_properties: {}, + is_stale: false, + original_enabled: true, ...overrides, }); -const setupStores = () => { - vi.mocked(useChannelsStore).mockImplementation((sel) => - sel({ channelGroups: {}, profiles: [] }) - ); - vi.mocked(useStreamProfilesStore).mockImplementation((sel) => - sel({ profiles: [], fetchProfiles: vi.fn() }) +const makePlaylist = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + channel_groups: [], + ...overrides, +}); + +// ── Stateful wrapper ────────────────────────────────────────────────────────── +const Wrapper = ({ + initialGroups = [], + initialAutoEnable = true, + playlist, +}) => { + const [groupStates, setGroupStates] = useState(initialGroups); + const [autoEnable, setAutoEnable] = useState(initialAutoEnable); + return ( + ); }; -const renderFilter = (playlistOverrides = {}) => - render( - - ); +// ── Test setup helpers ──────────────────────────────────────────────────────── +describe('LiveGroupFilter', () => { + let mockEnsureLogosLoaded; + let mockFetchProfiles; -// The orphan-cleanup SegmentedControl shares its mocked testid with the -// status-filter SegmentedControl that lives elsewhere in the form, so -// disambiguate by walking from a uniquely-testided child button up to -// its parent SegmentedControl element. -const findCleanupControl = () => - screen.getByTestId('segmented-always').closest('[data-testid="segmented-control"]'); - -describe('LiveGroupFilter orphan-cleanup SegmentedControl', () => { beforeEach(() => { vi.clearAllMocks(); - setupStores(); - }); + mockEnsureLogosLoaded = vi.fn(); + mockFetchProfiles = vi.fn(); - it('defaults to "always" when custom_properties is null', () => { - renderFilter({ custom_properties: null }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); - }); - - it('defaults to "always" when the orphan_channel_cleanup key is absent', () => { - renderFilter({ custom_properties: { compact_numbering: true } }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); - }); - - it('reads the persisted mode from custom_properties on mount', () => { - renderFilter({ - custom_properties: { orphan_channel_cleanup: 'preserve_customized' }, - }); - expect(findCleanupControl()).toHaveAttribute( - 'data-value', - 'preserve_customized' + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups: {} }) ); + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ profiles: [], fetchProfiles: mockFetchProfiles }) + ); + vi.mocked(useChannelLogoSelection).mockReturnValue({ + logos: [], + ensureLogosLoaded: mockEnsureLogosLoaded, + isLoading: false, + }); + vi.mocked(getEPGs).mockResolvedValue([]); }); - it('PATCHes the playlist with merged custom_properties on click and updates the displayed value', async () => { - renderFilter({ - custom_properties: { compact_numbering: true }, - }); - fireEvent.click(screen.getByTestId('segmented-never')); - - await waitFor(() => { - expect(API.updatePlaylist).toHaveBeenCalledWith({ - id: 7, - custom_properties: { - compact_numbering: true, - orphan_channel_cleanup: 'never', - }, + const renderWith = ({ + initialGroups = [], + initialAutoEnable = true, + playlist, + channelGroups, + streamProfiles, + fetchProfiles, + ensureLogosLoaded, + } = {}) => { + if (channelGroups !== undefined) { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + } + if (streamProfiles !== undefined || fetchProfiles !== undefined) { + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ + profiles: streamProfiles ?? [], + fetchProfiles: fetchProfiles ?? mockFetchProfiles, + }) + ); + } + if (ensureLogosLoaded !== undefined) { + vi.mocked(useChannelLogoSelection).mockReturnValue({ + logos: [], + ensureLogosLoaded, + isLoading: false, }); - }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'never'); - }); + } - it('reverts to the previous mode and surfaces an error toast when the PATCH fails', async () => { - vi.mocked(API.updatePlaylist).mockRejectedValueOnce( - new Error('Server error') + return render( + ); - renderFilter({ - custom_properties: { orphan_channel_cleanup: 'always' }, - }); - fireEvent.click(screen.getByTestId('segmented-never')); + }; - await waitFor(() => { - expect(notifications.show).toHaveBeenCalledWith( - expect.objectContaining({ color: 'red' }) + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the info alert', () => { + renderWith(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByText(/Auto Channel Sync/i)).toBeInTheDocument(); + }); + + it('renders the auto-enable new groups checkbox', () => { + renderWith(); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).toBeInTheDocument(); + }); + + it('reflects initialAutoEnable=true on the checkbox', () => { + renderWith({ initialAutoEnable: true }); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).toBeChecked(); + }); + + it('reflects initialAutoEnable=false on the checkbox', () => { + renderWith({ initialAutoEnable: false }); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).not.toBeChecked(); + }); + + it('renders OrphanCleanupControl with the playlist', () => { + renderWith({ playlist: makePlaylist({ id: 7 }) }); + expect(screen.getByTestId('orphan-cleanup')).toHaveAttribute( + 'data-playlist-id', + '7' ); }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); + + it('renders the group name filter input', () => { + renderWith(); + expect(screen.getByTestId('group-filter-input')).toBeInTheDocument(); + }); + + it('renders All / Enabled / Disabled status filter buttons', () => { + renderWith(); + expect(screen.getByTestId('seg-all')).toBeInTheDocument(); + expect(screen.getByTestId('seg-enabled')).toBeInTheDocument(); + expect(screen.getByTestId('seg-disabled')).toBeInTheDocument(); + }); + + it('renders Select Visible and Deselect Visible buttons', () => { + renderWith(); + expect(screen.getByText('Select Visible')).toBeInTheDocument(); + expect(screen.getByText('Deselect Visible')).toBeInTheDocument(); + }); + + it('renders a card for each group in groupStates', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('renders groups sorted alphabetically by name', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 3, name: 'Sports' }), + makeGroup({ channel_group: 1, name: 'Arts' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + const names = screen + .getAllByText(/Arts|News|Sports/) + .map((el) => el.textContent); + expect(names).toEqual(['Arts', 'News', 'Sports']); + }); + + it('renders no group cards when groupStates is empty', () => { + renderWith({ initialGroups: [] }); + expect(screen.queryByTestId('group-card')).not.toBeInTheDocument(); + }); + + it('does not render the configure modal on initial render', () => { + renderWith({ initialGroups: [makeGroup()] }); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + }); + + // ── Mount effects ────────────────────────────────────────────────────────── + + describe('mount effects', () => { + it('calls ensureLogosLoaded on mount', () => { + renderWith({ ensureLogosLoaded: mockEnsureLogosLoaded }); + expect(mockEnsureLogosLoaded).toHaveBeenCalled(); + }); + + it('calls fetchStreamProfiles when profiles array is empty', () => { + renderWith({ streamProfiles: [], fetchProfiles: mockFetchProfiles }); + expect(mockFetchProfiles).toHaveBeenCalled(); + }); + + it('does not call fetchStreamProfiles when profiles already exist', () => { + renderWith({ + streamProfiles: [{ id: 'p1', name: 'Default' }], + fetchProfiles: mockFetchProfiles, + }); + expect(mockFetchProfiles).not.toHaveBeenCalled(); + }); + + it('calls getEPGs on mount', async () => { + renderWith(); + await waitFor(() => { + expect(getEPGs).toHaveBeenCalled(); + }); + }); + + it('initializes groupStates from playlist.channel_groups when channelGroups are loaded', async () => { + const channelGroups = { 10: { id: 10, name: 'Movies' } }; + const playlist = makePlaylist({ + id: 2, + channel_groups: [ + { + channel_group: 10, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + auto_sync_channel_end: null, + custom_properties: {}, + }, + ], + }); + renderWith({ channelGroups, playlist, initialGroups: [] }); + await waitFor(() => { + expect(screen.getByText('Movies')).toBeInTheDocument(); + }); + }); + + it('skips groupStates initialization when channelGroups is empty', () => { + renderWith({ + channelGroups: {}, + playlist: makePlaylist({ + channel_groups: [ + { + channel_group: 1, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + }, + ], + }), + initialGroups: [], + }); + // Init effect bails early — no group cards rendered + expect(screen.queryByTestId('group-card')).not.toBeInTheDocument(); + }); + + it('does not re-initialize groupStates for the same playlist/channelGroups key', async () => { + // Covers the lastInitKey guard preventing re-init on prop-reference changes + const channelGroups = { 1: { id: 1, name: 'Sports' } }; + const playlist = makePlaylist({ + id: 1, + channel_groups: [ + { + channel_group: 1, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + custom_properties: {}, + }, + ], + }); + const { rerender } = renderWith({ channelGroups, playlist }); + await waitFor(() => + expect(screen.getByText('Sports')).toBeInTheDocument() + ); + + // Re-render with identical playlist reference — init must not fire again + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + rerender(); + // Still renders Sports; no duplicate init + expect(screen.getAllByText('Sports')).toHaveLength(1); + }); + }); + + // ── autoEnableNewGroupsLive checkbox ────────────────────────────────────── + + describe('autoEnableNewGroupsLive checkbox', () => { + it('toggles off when clicked while checked', () => { + renderWith({ initialAutoEnable: true }); + const cb = screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }); + fireEvent.click(cb); + expect(cb).not.toBeChecked(); + }); + + it('toggles on when clicked while unchecked', () => { + renderWith({ initialAutoEnable: false }); + const cb = screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }); + fireEvent.click(cb); + expect(cb).toBeChecked(); + }); + }); + + // ── Group text filter ────────────────────────────────────────────────────── + + describe('group text filter', () => { + it('shows groups matching the filter and hides non-matching groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sport' }, + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + + it('shows all groups when filter is cleared', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sport' }, + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: '' }, + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('calls isGroupVisible with the current filter text and status', () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sp' }, + }); + expect(isGroupVisible).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Sports' }), + 'sp', + 'all' + ); + }); + }); + + // ── Status filter ────────────────────────────────────────────────────────── + + describe('status filter', () => { + it('shows only enabled groups when Enabled filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + + it('shows only disabled groups when Disabled filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-disabled')); + expect(screen.queryByText('Sports')).not.toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('passes the active status value to isGroupVisible', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + ], + }); + fireEvent.click(screen.getByTestId('seg-disabled')); + expect(isGroupVisible).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Sports' }), + '', + 'disabled' + ); + }); + + it('reverts to showing all groups when All filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + fireEvent.click(screen.getByTestId('seg-all')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + }); + + // ── Select Visible / Deselect Visible ───────────────────────────────────── + + describe('Select Visible / Deselect Visible', () => { + it('Select Visible enables all currently visible groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: false }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByText('Select Visible')); + // Both groups remain visible (isGroupVisible still returns true) + expect(screen.getAllByTestId('group-card')).toHaveLength(2); + }); + + it('Deselect Visible disables all currently visible groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: true }), + ], + }); + // With status=all, isGroupVisible still returns true after deselect + fireEvent.click(screen.getByText('Deselect Visible')); + expect(screen.getAllByTestId('group-card')).toHaveLength(2); + }); + + it('Select Visible only applies to groups passing the current filter', () => { + // With Enabled filter active, only already-enabled groups are "visible" + // so disabling one and clicking Select Visible should not re-enable the hidden one + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + fireEvent.click(screen.getByText('Select Visible')); + // News (disabled) is not visible in Enabled filter, so it stays hidden + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + }); + + // ── Configure modal ──────────────────────────────────────────────────────── + + describe('configure modal', () => { + it('opens when the Cog ActionIcon is clicked', () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + expect(screen.getByTestId('configure-modal')).toBeInTheDocument(); + }); + + it('passes the correct group id to GroupConfigureModal', () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 42 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + expect(screen.getByTestId('configure-modal')).toHaveAttribute( + 'data-group-id', + '42' + ); + }); + + it('closes on Done click', () => { + renderWith({ initialGroups: [makeGroup()] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + fireEvent.click(screen.getByTestId('modal-done')); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + + it('closes on Cancel click', () => { + renderWith({ initialGroups: [makeGroup()] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + fireEvent.click(screen.getByTestId('modal-cancel')); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + + it('renders AutoSyncAdvanced inside the modal with the correct group', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => { + expect(screen.getByTestId('auto-sync-advanced')).toBeInTheDocument(); + expect(screen.getByTestId('auto-sync-advanced')).toHaveAttribute( + 'data-group-id', + '1' + ); + }); + }); + + it('restores the group state when Cancel is clicked after a change', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + // Open modal — snapshot is taken + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + + // Mutate the group via AutoSyncAdvanced + fireEvent.click(screen.getByTestId('trigger-apply-group')); + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + + // Cancel should revert to the snapshot + fireEvent.click(screen.getByTestId('modal-cancel')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect( + screen.queryByText('Modified By Advanced') + ).not.toBeInTheDocument(); + }); + + it('does not revert the group state when Done is clicked after a change', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + + fireEvent.click(screen.getByTestId('trigger-apply-group')); + fireEvent.click(screen.getByTestId('modal-done')); + + // The modified name should persist + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + }); + + it('triggers a regex preview when configuringGroup changes', async () => { + renderWith({ + initialGroups: [ + makeGroup({ + channel_group: 1, + custom_properties: { + name_regex_pattern: 'ESPN', + name_match_regex: '', + }, + }), + ], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => { + expect(getRegexOptions).toHaveBeenCalled(); + }); + }); + }); + + // ── Logo upload modal ────────────────────────────────────────────────────── + + describe('logo upload modal', () => { + const openLogoUpload = async () => { + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-logo-upload')); + fireEvent.click(screen.getByTestId('trigger-logo-upload')); + }; + + it('opens LogoForm when onOpenLogoUpload is called', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + await openLogoUpload(); + await waitFor(() => + expect(screen.getByTestId('logo-form')).toBeInTheDocument() + ); + }); + + it('closes LogoForm when onClose is called', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + await openLogoUpload(); + fireEvent.click(screen.getByTestId('logo-close')); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + + it('applies custom_logo_id and calls ensureLogosLoaded on logo success', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1 })], + ensureLogosLoaded: mockEnsureLogosLoaded, + }); + await openLogoUpload(); + + const callCountBefore = mockEnsureLogosLoaded.mock.calls.length; + fireEvent.click(screen.getByTestId('logo-success')); + + expect(mockEnsureLogosLoaded.mock.calls.length).toBeGreaterThan( + callCountBefore + ); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + + it('does not apply logo when onSuccess provides no logo', async () => { + // Test the logo === null guard: LogoForm calls onSuccess({ logo: null }) + vi.mock('./Logo.jsx', () => ({ + default: ({ isOpen, onSuccess }) => + isOpen ? ( + + ) : null, + })); + // Without a valid logo object, ensureLogosLoaded should not be called again + renderWith({ + initialGroups: [makeGroup({ channel_group: 1 })], + ensureLogosLoaded: mockEnsureLogosLoaded, + }); + await openLogoUpload(); + // If the null-logo button renders, click it; otherwise the modal just closes + const nullBtn = screen.queryByTestId('logo-success-null'); + if (nullBtn) fireEvent.click(nullBtn); + // ensureLogosLoaded should only have been called once (on mount) + expect(mockEnsureLogosLoaded).toHaveBeenCalledTimes(1); + }); + }); + + // ── scheduleRegexPreview ─────────────────────────────────────────────────── + + describe('scheduleRegexPreview (via AutoSyncAdvanced)', () => { + it('passes onScheduleRegexPreview to AutoSyncAdvanced and it triggers without error', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-regex-preview')); + expect(() => + fireEvent.click(screen.getByTestId('trigger-regex-preview')) + ).not.toThrow(); + }); + + it('clears regexPreview state when all pattern fields are empty', async () => { + // scheduleRegexPreview with empty opts should not call getStreamsRegexPreview + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('auto-sync-advanced')); + // Trigger with empty patterns by mounting a group with no patterns set + // (the useEffect on configuringGroup?.channel_group fires getRegexOptions with empty strings) + expect(getStreamsRegexPreview).not.toHaveBeenCalled(); + }); + + it('calls getStreamsRegexPreview when non-empty patterns are provided', async () => { + vi.mocked(getStreamsRegexPreview).mockResolvedValue({ + find_matches: ['ESPN HD'], + find_match_count: 1, + total_in_group: 10, + total_scanned: 10, + scan_limit_hit: false, + }); + renderWith({ + initialGroups: [ + makeGroup({ + channel_group: 1, + custom_properties: { name_regex_pattern: 'ESPN' }, + }), + ], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-regex-preview')); + fireEvent.click(screen.getByTestId('trigger-regex-preview')); + await waitFor(() => { + expect(getStreamsRegexPreview).toHaveBeenCalled(); + }); + }); + }); + + // ── applyGroupChange ─────────────────────────────────────────────────────── + + describe('applyGroupChange (via AutoSyncAdvanced)', () => { + it('updates only the target group in groupStates', async () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + // Group cards will be sorted alphabetically, so the first cog button corresponds to the "News" group, not "Sports" — ensure we target the correct one + const cogButtons = screen.getAllByTestId('icon-cog'); + fireEvent.click(cogButtons[1].closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + fireEvent.click(screen.getByTestId('trigger-apply-group')); + + // Group 1 name changed; Group 2 unchanged + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/utils/forms/AutoSyncAdvancedUtils.js b/frontend/src/utils/forms/AutoSyncAdvancedUtils.js new file mode 100644 index 00000000..87434eaa --- /dev/null +++ b/frontend/src/utils/forms/AutoSyncAdvancedUtils.js @@ -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'}`; +}; diff --git a/frontend/src/utils/forms/AutoSyncBasicUtils.js b/frontend/src/utils/forms/AutoSyncBasicUtils.js new file mode 100644 index 00000000..b98b715b --- /dev/null +++ b/frontend/src/utils/forms/AutoSyncBasicUtils.js @@ -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))); diff --git a/frontend/src/utils/forms/LiveGroupFilterUtils.js b/frontend/src/utils/forms/LiveGroupFilterUtils.js index b02abc63..a1372887 100644 --- a/frontend/src/utils/forms/LiveGroupFilterUtils.js +++ b/frontend/src/utils/forms/LiveGroupFilterUtils.js @@ -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; }; diff --git a/frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js b/frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js new file mode 100644 index 00000000..f17e5a4b --- /dev/null +++ b/frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js @@ -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' + ); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js b/frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js new file mode 100644 index 00000000..bcace1a7 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js @@ -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); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js b/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js index 13f7dea8..feb878e1 100644 --- a/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js +++ b/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js @@ -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); }); }); });