refactor(orphan-cleanup): store as custom_properties mode

Drop 3UAccount.auto_cleanup_unused_channels field and its migration (apps/m3u/0020). Replace with a 3-state string under M3UAccount.custom_properties.orphan_channel_cleanup: "always" (default), "preserve_customized", "never".

Move the UI from a checkbox on the M3U account form to a SegmentedControl at the top of LiveGroupFilter. Local state mirrors the prop for immediate click feedback; reverts on API error.

M3UAccountSerializer.update() now merges incoming custom_properties before layering the typed preference fields on top.

Add LiveGroupFilter.test.jsx (5 cases). Replace AutoCleanupToggleTests with OrphanCleanupModeTests (5 cases) for the new 3-state contract.
This commit is contained in:
None 2026-05-01 23:24:40 -05:00
parent b2b108bfe5
commit 0c0e802cf2
9 changed files with 472 additions and 108 deletions

View file

@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Auto-sync overhaul (FR #1196)**: comprehensive rebuild of the M3U auto-channel-sync flow. Introduces a per-field override system, hide-from-output flag, range-bounded auto-numbering with a re-pack helper, multi-stream channel safety, multi-provider shared-range merging, and an across-the-board move from per-row writes to bulk operations. Migrations included: `apps/channels/0037_auto_sync_overhaul` (bundled CreateModel + AddField + AlterField + RunPython operations) and `apps/m3u/0020_m3uaccount_auto_cleanup_unused_channels`. (Closes #1196)
- **Auto-sync overhaul (FR #1196)**: comprehensive rebuild of the M3U auto-channel-sync flow. Introduces a per-field override system, hide-from-output flag, range-bounded auto-numbering with a re-pack helper, multi-stream channel safety, multi-provider shared-range merging, and an across-the-board move from per-row writes to bulk operations. Migration: `apps/channels/0037_auto_sync_overhaul` (bundled CreateModel + AddField + AlterField + RunPython operations). (Closes #1196)
- **Per-field channel overrides for auto-synced channels.** A new `ChannelOverride` table (one-to-one with `Channel`) holds user-specified values for `name`, `channel_number`, `channel_group`, `logo`, `tvg_id`, `tvc_guide_stationid`, `epg_data`, and `stream_profile`. Sync writes only to `Channel.*` columns; the override row takes precedence at read time via a new `with_effective_values()` queryset helper that coalesces both sources at the SQL layer. Every output surface that consumes channel data reads through this helper so overrides surface consistently: HDHR (`/hdhr/lineup.json` and per-profile variants, `/hdhr/discover.json`), M3U (`/output/m3u`, per-profile variants), EPG (`/output/epg`, per-profile variants), XC API (`get_live_streams`, `xmltv.php`), the channels list/detail endpoints, and the TV Guide summary endpoint (`GET /api/channels/channels/summary/`). Channel API responses now include both the raw `name`/`channel_number`/etc. fields AND new `effective_*` annotations plus the `override` object, so frontend consumers can show both "what the user picked" and "what the provider sent" simultaneously.
- **Per-field reset-to-provider icon.** FK pickers (channel group, logo, EPG, stream profile) and scalar inputs (name, channel_number, tvg_id, tvc_guide_stationid) display a small reset icon ("Provider: <value>" subtext + Undo2 button) when the field has an active override on an auto-synced channel. Clicking it sets the form value back to the provider value; the override for that field is cleared on save while other overrides remain intact.
- **Auto-created channel source attribution.** The channel edit form shows an "Auto-created from: <provider name> / <stream name>" label at the top of auto-synced channels so the user can see which M3U account and which stream produced the row.
@ -41,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Live overlap warning across rows.** As the user edits Start/End values across multiple groups in the same provider, an `AbortController`-debounced scan calls a new `GET /api/channels/channels/numbers-in-range/` endpoint to detect (a) cross-row range overlaps (in-memory, all group pairs) and (b) channels already occupying the configured range that are not the expected occupants for that group. An informational warning surfaces inline; configurations are not blocked.
- **Compact numbering with re-pack helper.** When `compact_numbering=True` on a group's account relation, sync packs visible auto-sync channels sequentially into the configured range. A "Re-pack now" button in the group config modal runs the pack manually via a new `POST /api/m3u/accounts/{id}/repack-group/` endpoint. Hidden channels do not occupy slots; un-hiding shifts visible numbers down to fill the gap. Override-pinned channel numbers are respected as fixed anchors. Single-channel hide / unhide via the post-save signal triggers an incremental `assign_compact_numbers_for_channels` pass without requiring a full sync.
- **Multi-stream channel safety in sync.** A user-attached second stream on an auto-created channel no longer causes the channel to be deleted when one of those streams disappears from the provider. The deletion path filters out channels where at least one current stream remains alive, and per-stream iteration mutates the same `Channel` instance so `bulk_update` writes the merged final state.
- **Auto-cleanup unused channels toggle on M3U accounts.** A new boolean controls whether sync deletes auto-created channels that no longer match any stream. Off by default; users with manual curation workflows can opt in. Hidden channels are always preserved regardless of the toggle so users can keep event/PPV channels that disappear and reappear across refreshes.
- **Orphan channel cleanup mode (3-state, account-scoped).** Stored under `M3UAccount.custom_properties.orphan_channel_cleanup` and surfaced as a SegmentedControl at the top of the M3U account's group-settings page. Three positions: `always` (default; matches the pre-FR-1196 behavior of removing every auto-channel whose source stream has disappeared), `preserve_customized` (removes orphans without a `ChannelOverride` row, preserves those with one), and `never` (preserves all orphans, intended for users with manual redundancy/failover stream setups). Hidden channels are universally preserved regardless of mode.
- **Failure modal grouped by reason.** The auto-sync completion notification's failure detail modal groups entries by typed reason (`RANGE_EXHAUSTED`, `INTEGRITY_ERROR`, `OTHER`) with collapsible sections and a per-group cap. The in-memory cap was raised from 50 to 1000 so realistic multi-provider failure sets are not truncated. The notification's auto-close timeout extends from 4s to 12s when failures are present so users have time to see and click into the modal.
- **Multi-provider shared-range merging.** Two M3U accounts can target the same group with overlapping channel-number ranges. Sync seeds `used_numbers` globally so the second provider's channels pick up where the first left off without colliding. The overlap warning in the group-settings UI surfaces this configuration as informational; the merge is intentional, not a hard error.
- **Selection summary in the bulk edit modal.** The bulk edit form shows `Selection: X auto-synced, Y manual` at the top so users can see how a single set of changes will route between override rows and direct writes.

View file

@ -1,18 +0,0 @@
# Generated by Django 6.0.4 on 2026-04-24 02:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0019_m3uaccountprofile_exp_date'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='auto_cleanup_unused_channels',
field=models.BooleanField(default=False, help_text='Automatically delete auto-created channels whose provider stream has disappeared after a refresh. Hidden channels are always preserved.'),
),
]

View file

@ -100,16 +100,6 @@ class M3UAccount(models.Model):
default=0,
help_text="Priority for VOD provider selection (higher numbers = higher priority). Used when multiple providers offer the same content.",
)
# When True, sync_auto_channels deletes auto-created channels whose
# provider stream no longer exists for this account. Mirrors the manual
# "Clean up" action but runs automatically after each refresh. Hidden
# channels are still preserved regardless of this toggle - they represent
# an explicit user decision to keep a channel around.
auto_cleanup_unused_channels = models.BooleanField(
default=False,
help_text="Automatically delete auto-created channels whose provider stream has disappeared after a refresh. Hidden channels are always preserved.",
)
def __str__(self):
return self.name

View file

@ -175,7 +175,6 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"password",
"stale_stream_days",
"priority",
"auto_cleanup_unused_channels",
"status",
"last_message",
"enable_vod",
@ -262,10 +261,17 @@ class M3UAccountSerializer(serializers.ModelSerializer):
auto_enable_new_groups_vod = validated_data.pop("auto_enable_new_groups_vod", None)
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", None)
# Get existing custom_properties
custom_props = instance.custom_properties or {}
# Merge client-supplied custom_properties over the existing blob
# so unrelated keys persist. The dedicated preference fields below
# overwrite their corresponding keys; clients should set those via
# the typed top-level fields rather than the custom_properties
# payload.
incoming_custom = validated_data.get("custom_properties") or {}
custom_props = {
**(instance.custom_properties or {}),
**incoming_custom,
}
# Update preferences
if enable_vod is not None:
custom_props["enable_vod"] = enable_vod
if auto_enable_new_groups_live is not None:

View file

@ -2707,9 +2707,15 @@ def sync_auto_channels(account_id, scan_start_time=None):
f"{pack_result['failed']} failed"
)
# Hidden channels are always preserved regardless of the toggle so users
# can keep event/PPV channels that disappear and reappear.
if account.auto_cleanup_unused_channels:
# Cleanup mode read from account.custom_properties.orphan_channel_cleanup:
# "always" (default; key absent) removes every orphan auto channel;
# "preserve_customized" keeps those with a ChannelOverride row;
# "never" disables cleanup. Hidden channels are preserved across all
# modes so event/PPV channels that come and go are not silently lost.
cleanup_mode = (account.custom_properties or {}).get(
"orphan_channel_cleanup", "always"
)
if cleanup_mode != "never":
orphaned_channels = Channel.objects.filter(
auto_created=True,
auto_created_by=account,
@ -2720,13 +2726,15 @@ def sync_auto_channels(account_id, scan_start_time=None):
stream__isnull=False,
).values_list("channel_id", flat=True)
)
if cleanup_mode == "preserve_customized":
orphaned_channels = orphaned_channels.filter(override__isnull=True)
_, per_model = orphaned_channels.delete()
deleted_channels = per_model.get("dispatcharr_channels.Channel", 0)
if deleted_channels:
channels_deleted += deleted_channels
logger.info(
f"Deleted {deleted_channels} orphaned auto channels with no valid streams"
f"Deleted {deleted_channels} orphaned auto channels with no valid streams (mode={cleanup_mode})"
)
logger.info(

View file

@ -1454,48 +1454,33 @@ class SyncPerformanceRegressionTests(TestCase):
)
class AutoCleanupToggleTests(TestCase):
class OrphanCleanupModeTests(TestCase):
"""
Orphan-channel cleanup (a channel auto-created from a stream that has
since disappeared) used to run unconditionally at the end of every sync.
Users who run event-style sources (sports/PPV) want the channel to
persist across the stream disappearing and reappearing. The new
`auto_cleanup_unused_channels` toggle on M3UAccount gates the cleanup.
Hidden channels are always preserved regardless of the toggle.
Account-level `custom_properties.orphan_channel_cleanup` is a 3-state
selector that governs how sync handles auto-created channels whose
source streams have disappeared.
- "always" (default; absent key behaves the same): delete every orphan
auto-created channel.
- "preserve_customized": delete orphans without a ChannelOverride row;
preserve those with one.
- "never": preserve every orphan auto-created channel.
Hidden channels (`hidden_from_output=True`) are universally preserved
regardless of mode, because the user has explicitly signaled "keep this
around but do not show clients".
"""
def test_cleanup_toggle_off_leaves_orphaned_channels(self):
account = _make_account()
account.auto_cleanup_unused_channels = False
def _set_mode(self, account, mode):
account.custom_properties = {"orphan_channel_cleanup": mode}
account.save()
def test_default_mode_when_key_absent_is_always(self):
# Account with no custom_properties.orphan_channel_cleanup value
# behaves like "always": orphans get cleaned up.
account = _make_account()
group = _make_group(name="Sports")
_attach_group_to_account(account, group)
# Orphan: auto-created by this account, but no associated stream.
Channel.objects.create(
name="OldESPN",
channel_number=500,
channel_group=group,
auto_created=True,
auto_created_by=account,
)
result = _sync(account)
self.assertEqual(result["status"], "ok")
self.assertEqual(
Channel.objects.filter(auto_created=True, auto_created_by=account).count(),
1,
"Toggle off: orphan should survive the sync",
)
def test_cleanup_toggle_on_removes_orphaned_channels(self):
account = _make_account()
account.auto_cleanup_unused_channels = True
account.save()
group = _make_group(name="Sports")
_attach_group_to_account(account, group)
Channel.objects.create(
name="OldESPN",
channel_number=500,
@ -1509,39 +1494,132 @@ class AutoCleanupToggleTests(TestCase):
self.assertEqual(result["status"], "ok")
self.assertEqual(result["channels_deleted"], 1)
self.assertEqual(
Channel.objects.filter(auto_created=True, auto_created_by=account).count(),
Channel.objects.filter(
auto_created=True, auto_created_by=account
).count(),
0,
"Default (absent key): orphans must be cleaned up",
)
def test_cleanup_preserves_hidden_channels_even_when_toggle_on(self):
def test_always_mode_removes_orphan_with_override(self):
from apps.channels.models import ChannelOverride
account = _make_account()
account.auto_cleanup_unused_channels = True
account.save()
self._set_mode(account, "always")
group = _make_group(name="Sports")
_attach_group_to_account(account, group)
ch = Channel.objects.create(
name="OldESPN",
channel_number=500,
channel_group=group,
auto_created=True,
auto_created_by=account,
)
ChannelOverride.objects.create(channel=ch, name="My Custom ESPN")
Channel.objects.create(
name="HiddenESPN",
result = _sync(account)
self.assertEqual(result["channels_deleted"], 1)
self.assertFalse(
Channel.objects.filter(id=ch.id).exists(),
"Always mode: even customized orphans get deleted",
)
def test_preserve_customized_mode_spares_overrides(self):
from apps.channels.models import ChannelOverride
account = _make_account()
self._set_mode(account, "preserve_customized")
group = _make_group(name="Sports")
_attach_group_to_account(account, group)
plain = Channel.objects.create(
name="OldPlain",
channel_number=500,
channel_group=group,
auto_created=True,
auto_created_by=account,
)
customized = Channel.objects.create(
name="OldCustomized",
channel_number=501,
channel_group=group,
auto_created=True,
auto_created_by=account,
)
ChannelOverride.objects.create(channel=customized, name="My Renamed")
_sync(account)
self.assertFalse(
Channel.objects.filter(id=plain.id).exists(),
"Preserve-customized mode: orphan without override is removed",
)
self.assertTrue(
Channel.objects.filter(id=customized.id).exists(),
"Preserve-customized mode: orphan WITH override is preserved",
)
def test_never_mode_preserves_all_orphans(self):
account = _make_account()
self._set_mode(account, "never")
group = _make_group(name="Sports")
_attach_group_to_account(account, group)
Channel.objects.create(
name="OldA",
channel_number=500,
channel_group=group,
auto_created=True,
auto_created_by=account,
)
Channel.objects.create(
name="OldB",
channel_number=501,
channel_group=group,
auto_created=True,
auto_created_by=account,
hidden_from_output=True,
)
result = _sync(account)
self.assertEqual(result["status"], "ok")
self.assertEqual(result["channels_deleted"], 0)
self.assertEqual(
Channel.objects.filter(
auto_created=True,
auto_created_by=account,
hidden_from_output=True,
auto_created=True, auto_created_by=account
).count(),
1,
"Hidden channels must survive cleanup regardless of the toggle",
2,
"Never mode: every orphan survives",
)
def test_hidden_channels_universally_preserved(self):
# Hidden orphans must survive in every mode, including the
# default "always" mode.
for mode in ("always", "preserve_customized", "never"):
with self.subTest(mode=mode):
account = _make_account(name=f"Provider-{mode}")
self._set_mode(account, mode)
group = _make_group(name=f"Sports-{mode}")
_attach_group_to_account(account, group)
Channel.objects.create(
name=f"Hidden-{mode}",
channel_number=600,
channel_group=group,
auto_created=True,
auto_created_by=account,
hidden_from_output=True,
)
_sync(account)
self.assertEqual(
Channel.objects.filter(
auto_created=True,
auto_created_by=account,
hidden_from_output=True,
).count(),
1,
f"Hidden channel must survive cleanup in mode={mode}",
)
class MultiStreamChannelTests(TestCase):
"""

View file

@ -1561,6 +1561,42 @@ const LiveGroupFilter = ({
);
};
// 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 (
<Stack style={{ paddingTop: 10 }}>
<Alert icon={<Info size={16} />} color="blue" variant="light">
@ -1582,6 +1618,40 @@ const LiveGroupFilter = ({
description="When disabled, new groups from the M3U source will be created but disabled by default. You can enable them manually later."
/>
<Box>
<Group gap="sm" align="center" wrap="nowrap">
<Tooltip
label="Controls what sync does with auto-synced channels whose source streams have been removed from this provider. Manual channels and hidden channels are never affected by this setting."
withArrow
multiline
w={320}
openDelay={400}
>
<Text size="sm" fw={500}>
Auto-sync orphan cleanup
</Text>
</Tooltip>
<SegmentedControl
size="xs"
value={orphanCleanupMode}
onChange={handleOrphanCleanupChange}
data={[
{ label: 'Always remove', value: 'always' },
{ label: 'Preserve customized', value: 'preserve_customized' },
{ label: 'Never remove', value: 'never' },
]}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{orphanCleanupMode === 'always' &&
'Removes any auto-synced channel whose source stream is gone from this provider.'}
{orphanCleanupMode === 'preserve_customized' &&
'Removes orphaned auto-synced channels except those with active overrides.'}
{orphanCleanupMode === 'never' &&
'Keeps all orphaned auto-synced channels. You can clean up manually from the channels page.'}
</Text>
</Box>
<Flex gap="sm" align="center">
<TextInput
placeholder="Filter groups..."

View file

@ -69,7 +69,6 @@ const M3U = ({
stale_stream_days: 7,
priority: 0,
enable_vod: false,
auto_cleanup_unused_channels: false,
},
validate: {
@ -102,8 +101,6 @@ const M3U = ({
? m3uAccount.priority
: 0,
enable_vod: m3uAccount.enable_vod || false,
auto_cleanup_unused_channels:
m3uAccount.auto_cleanup_unused_channels || false,
});
setExpDate(m3uAccount.exp_date ? new Date(m3uAccount.exp_date) : null);
@ -475,24 +472,6 @@ const M3U = ({
{...form.getInputProps('is_active', { type: 'checkbox' })}
key={form.key('is_active')}
/>
<Tooltip
label="On every refresh, removes auto-synced channels whose source streams are no longer present in this provider. Hidden channels are preserved."
withArrow
multiline
w={300}
openDelay={500}
>
<Box>
<Checkbox
label="Auto-cleanup unused channels"
description="Remove orphaned auto-synced channels on refresh"
{...form.getInputProps('auto_cleanup_unused_channels', {
type: 'checkbox',
})}
key={form.key('auto_cleanup_unused_channels')}
/>
</Box>
</Tooltip>
</Flex>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">

View file

@ -0,0 +1,251 @@
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
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
vi.mock('../../../store/streamProfiles', () => ({ default: vi.fn() }));
// Hook mocks
vi.mock('../../../hooks/useSmartLogos', () => ({
useChannelLogoSelection: vi.fn(() => ({
logos: {},
ensureLogosLoaded: vi.fn(),
isLoading: false,
})),
}));
// 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
vi.mock('../GroupConfigureModal', () => ({
default: ({ children }) => <div data-testid="group-config-modal">{children}</div>,
}));
vi.mock('../Logo', () => ({ default: () => null }));
vi.mock('../../LazyLogo', () => ({ default: () => null }));
vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
vi.mock('react-window', () => ({
FixedSizeList: ({ children, itemCount }) => (
<div data-testid="fixed-size-list">
{Array.from({ length: itemCount }, (_, index) =>
children({ index, style: {} })
)}
</div>
),
}));
vi.mock('lucide-react', () => ({
Info: () => <svg data-testid="icon-info" />,
CircleCheck: () => <svg data-testid="icon-check" />,
CircleX: () => <svg data-testid="icon-x" />,
Settings: () => <svg data-testid="icon-cog" />,
AlertTriangle: () => <svg data-testid="icon-warn" />,
RefreshCw: () => <svg data-testid="icon-refresh" />,
}));
// @mantine/core minimal mocks
vi.mock('@mantine/core', () => ({
TextInput: ({ value, onChange, placeholder }) => (
<input value={value ?? ''} onChange={onChange} placeholder={placeholder} />
),
Button: ({ children, onClick }) => <button onClick={onClick}>{children}</button>,
Checkbox: ({ label, checked, onChange }) => (
<label>
<input type="checkbox" checked={!!checked} onChange={onChange} />
{label}
</label>
),
Flex: ({ children }) => <div>{children}</div>,
Select: ({ value, onChange, data }) => (
<select value={value ?? ''} onChange={(e) => onChange?.(e.target.value)}>
{(data ?? []).map((opt) => {
const v = typeof opt === 'string' ? opt : opt.value;
const l = typeof opt === 'string' ? opt : opt.label;
return (
<option key={v} value={v}>
{l}
</option>
);
})}
</select>
),
Stack: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
SimpleGrid: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
NumberInput: ({ value, onChange }) => (
<input
type="number"
value={value ?? ''}
onChange={(e) => onChange?.(Number(e.target.value))}
/>
),
Divider: ({ label }) => <hr aria-label={label} />,
Alert: ({ children }) => <div role="alert">{children}</div>,
Box: ({ children }) => <div>{children}</div>,
MultiSelect: ({ value, onChange }) => (
<select multiple value={value ?? []} onChange={onChange}>
{(value ?? []).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
),
Tooltip: ({ children }) => <>{children}</>,
Popover: ({ children }) => <div>{children}</div>,
ScrollArea: ({ children }) => <div>{children}</div>,
Center: ({ children }) => <div>{children}</div>,
SegmentedControl: ({ value, onChange, data }) => (
<div data-testid="segmented-control" data-value={value}>
{data.map((opt) => (
<button
key={opt.value}
data-testid={`segmented-${opt.value}`}
data-active={value === opt.value}
onClick={() => onChange?.(opt.value)}
>
{opt.label}
</button>
))}
</div>
),
ActionIcon: ({ children, onClick }) => <button onClick={onClick}>{children}</button>,
Switch: ({ label, checked, onChange }) => (
<label>
<input type="checkbox" checked={!!checked} onChange={onChange} />
{label}
</label>
),
}));
// 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';
//
const makePlaylist = (overrides = {}) => ({
id: 7,
channel_groups: [],
custom_properties: null,
...overrides,
});
const setupStores = () => {
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ channelGroups: {}, profiles: [] })
);
vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
sel({ profiles: [], fetchProfiles: vi.fn() })
);
};
const renderFilter = (playlistOverrides = {}) =>
render(
<LiveGroupFilter
playlist={makePlaylist(playlistOverrides)}
groupStates={[]}
setGroupStates={vi.fn()}
autoEnableNewGroupsLive={false}
setAutoEnableNewGroupsLive={vi.fn()}
/>
);
// 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();
});
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'
);
});
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',
},
});
});
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')
);
renderFilter({
custom_properties: { orphan_channel_cleanup: 'always' },
});
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(notifications.show).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red' })
);
});
expect(findCleanupControl()).toHaveAttribute('data-value', 'always');
});
});