- {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('../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 }) => (
+
+ ),
+ Checkbox: ({ label, checked, onChange, description, disabled }) => (
- ),
- Tooltip: ({ children }) => <>{children}>,
- Popover: ({ children }) =>
- {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);
});
});
});