" for schedules_direct with a username', () => {
+ setupMocks();
+ render( );
+ const row = {
+ original: {
+ ...makeEpg(),
+ source_type: 'schedules_direct',
+ username: 'myuser',
+ },
+ };
+ const cell = { getValue: vi.fn(() => null) };
+ const { getByText } = render(getUrlCol().cell({ cell, row }));
+ expect(getByText('User: myuser')).toBeInTheDocument();
+ });
+
+ it('shows "(credentials set)" for schedules_direct with no username', () => {
+ setupMocks();
+ render( );
+ const row = {
+ original: {
+ ...makeEpg(),
+ source_type: 'schedules_direct',
+ username: '',
+ },
+ };
+ const cell = { getValue: vi.fn(() => null) };
+ const { getByText } = render(getUrlCol().cell({ cell, row }));
+ expect(getByText('(credentials set)')).toBeInTheDocument();
+ });
+
+ it('falls back to password when url is empty and password is set', () => {
+ setupMocks();
+ render( );
+ const row = {
+ original: { ...makeEpg(), url: null, password: 'secret123' },
+ };
+ const cell = { getValue: vi.fn(() => null) };
+ const { getByText } = render(getUrlCol().cell({ cell, row }));
+ expect(getByText('secret123')).toBeInTheDocument();
+ });
+ });
+
+ // ── Schedules Direct early refresh dialog ──────────────────────────────────
+
+ describe('Schedules Direct early refresh', () => {
+ const makeRecentSdEpg = (overrides = {}) =>
+ makeEpg({
+ id: 'sd-1',
+ source_type: 'schedules_direct',
+ // 30 minutes ago — well within the 2-hour window
+ updated_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
+ ...overrides,
+ });
+
+ const makeOldSdEpg = (overrides = {}) =>
+ makeEpg({
+ id: 'sd-1',
+ source_type: 'schedules_direct',
+ // 3 hours ago — outside the 2-hour window
+ updated_at: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
+ ...overrides,
+ });
+
+ it('opens the SD early-refresh confirmation dialog when the EPG was refreshed recently', () => {
+ const epg = makeRecentSdEpg();
+ setupMocks({ epgs: { 'sd-1': epg } });
+ render( );
+
+ const { row, cell } = makeRowContext(epg);
+ const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row });
+ const { getByTestId } = render(rendered);
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent(
+ 'Refresh Schedules Direct Early?'
+ );
+ });
+
+ it('shows "Refresh Anyway" as the confirm label', () => {
+ const epg = makeRecentSdEpg();
+ setupMocks({ epgs: { 'sd-1': epg } });
+ render( );
+
+ const { row, cell } = makeRowContext(epg);
+ const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row });
+ const { getByTestId } = render(rendered);
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+
+ expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Refresh Anyway');
+ });
+
+ it('skips the SD dialog and calls refreshEpg directly when EPG was refreshed more than 2 hours ago', async () => {
+ const epg = makeOldSdEpg();
+ setupMocks({ epgs: { 'sd-1': epg } });
+ render( );
+
+ const { row, cell } = makeRowContext(epg);
+ const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row });
+ const { getByTestId } = render(rendered);
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+
+ await waitFor(() => {
+ expect(EPGsTableUtils.refreshEpg).toHaveBeenCalled();
+ });
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ });
+
+ it('skips the SD dialog when the SD EPG has no updated_at', async () => {
+ const epg = makeEpg({
+ id: 'sd-1',
+ source_type: 'schedules_direct',
+ updated_at: null,
+ });
+ setupMocks({ epgs: { 'sd-1': epg } });
+ render( );
+
+ const { row, cell } = makeRowContext(epg);
+ const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row });
+ const { getByTestId } = render(rendered);
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+
+ await waitFor(() => {
+ expect(EPGsTableUtils.refreshEpg).toHaveBeenCalled();
+ });
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ });
+
+ it('calls refreshEpg with force=true when "Refresh Anyway" is confirmed', async () => {
+ const epg = makeRecentSdEpg();
+ setupMocks({ epgs: { 'sd-1': epg } });
+ render( );
+
+ const { row, cell } = makeRowContext(epg);
+ const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row });
+ const { getByTestId } = render(rendered);
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() => {
+ expect(EPGsTableUtils.refreshEpg).toHaveBeenCalledWith('sd-1', true);
+ });
+ });
+
+ it('shows a notification after force-refreshing', async () => {
+ const epg = makeRecentSdEpg();
+ setupMocks({ epgs: { 'sd-1': epg } });
+ render( );
+
+ const { row, cell } = makeRowContext(epg);
+ const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row });
+ const { getByTestId } = render(rendered);
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'EPG refresh initiated' })
+ );
+ });
+ });
+
+ it('closes the SD dialog without calling refreshEpg when cancelled', () => {
+ const epg = makeRecentSdEpg();
+ setupMocks({ epgs: { 'sd-1': epg } });
+ render( );
+
+ const { row, cell } = makeRowContext(epg);
+ const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row });
+ const { getByTestId } = render(rendered);
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ expect(EPGsTableUtils.refreshEpg).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Status message cell - progress edge cases ──────────────────────────────
+
+ describe('status message cell - progress edge cases', () => {
+ const getMsgCol = () =>
+ capturedTableOptions.columns.find((c) => c.accessorKey === 'last_message');
+
+ it('renders nothing when getProgressLabel returns null for active progress', () => {
+ const epg = makeEpg();
+ vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue(null);
+ setupMocks({
+ epgs: { 'epg-1': epg },
+ refreshProgress: { 'epg-1': { action: 'unknown_action', progress: 50 } },
+ });
+ render( );
+
+ const row = { original: epg };
+ const { container } = render({getMsgCol().cell({ row })}
);
+ expect(container.firstChild).toBeEmptyDOMElement();
+ });
+
+ it('shows progress when progress.status is "in_progress" even at 100%', () => {
+ const epg = makeEpg();
+ vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Processing');
+ setupMocks({
+ epgs: { 'epg-1': epg },
+ refreshProgress: {
+ 'epg-1': { action: 'processing', progress: 100, status: 'in_progress' },
+ },
+ });
+ render( );
+
+ const row = { original: epg };
+ const { getByText } = render(getMsgCol().cell({ row }));
+ expect(getByText(/Processing: 100%/)).toBeInTheDocument();
+ });
+
+ it('shows progress for parsing_channels action when epg.status is "parsing"', () => {
+ const epg = makeEpg({ status: 'parsing' });
+ vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Parsing Channels');
+ setupMocks({
+ epgs: { 'epg-1': epg },
+ refreshProgress: {
+ 'epg-1': { action: 'parsing_channels', progress: 100 },
+ },
+ });
+ render( );
+
+ const row = { original: epg };
+ const { getByText } = render(getMsgCol().cell({ row }));
+ expect(getByText(/Parsing Channels: 100%/)).toBeInTheDocument();
+ });
+
+ it('does not show progress for parsing_channels action when epg.status is not "parsing"', () => {
+ const epg = makeEpg({ status: 'idle' });
+ vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Parsing Channels');
+ setupMocks({
+ epgs: { 'epg-1': epg },
+ refreshProgress: {
+ 'epg-1': { action: 'parsing_channels', progress: 100 },
+ },
+ });
+ render( );
+
+ const row = { original: epg };
+ const { container } = render({getMsgCol().cell({ row })}
);
+ // condition fails (100 < 100=false, no in_progress, parsing_channels + idle=false)
+ // falls through to the idle-message branch; no last_message → returns null
+ expect(container.firstChild).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing when there is no active progress and no status message', () => {
+ const epg = makeEpg({ status: 'idle', last_message: null });
+ setupMocks({ epgs: { 'epg-1': epg } });
+ render( );
+
+ const row = { original: epg };
+ const { container } = render({getMsgCol().cell({ row })}
);
+ expect(container.firstChild).toBeEmptyDOMElement();
+ });
+ });
+
+ // ── Table structure ────────────────────────────────────────────────────────
+
+ describe('table structure', () => {
+ it('renders the CustomTable component', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('passes enablePagination: false to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enablePagination).toBe(false);
+ });
+
+ it('passes enableRowSelection: false to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enableRowSelection).toBe(false);
+ });
+
+ it('passes manualSorting: true to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.manualSorting).toBe(true);
+ });
+
+ it('passes renderTopToolbar: false to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.renderTopToolbar).toBe(false);
+ });
+
+ it('actions column size is 75 in compact mode', () => {
+ setupMocks({ tableSize: 'compact' });
+ render( );
+ const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions');
+ expect(actionsCol.size).toBe(75);
+ });
+
+ it('actions column size is 100 in default mode', () => {
+ setupMocks({ tableSize: 'default' });
+ render( );
+ const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions');
+ expect(actionsCol.size).toBe(100);
+ });
+ });
+});
diff --git a/frontend/src/components/tables/__tests__/LogosTable.test.jsx b/frontend/src/components/tables/__tests__/LogosTable.test.jsx
new file mode 100644
index 00000000..995e9d84
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/LogosTable.test.jsx
@@ -0,0 +1,1054 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/logos', () => ({ default: vi.fn() }));
+vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
+
+// ── Hook mocks ─────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/tables/LogosTableUtils.js', () => ({
+ cleanupUnusedLogos: vi.fn(),
+ deleteLogo: vi.fn(),
+ deleteLogos: vi.fn(),
+ generateUsageLabel: vi.fn((names, count) => `${count} channel${count !== 1 ? 's' : ''}`),
+ getFilteredLogos: vi.fn(),
+}));
+
+// ── Child component mocks ──────────────────────────────────────────────────────
+vi.mock('../../forms/Logo', () => ({
+ default: ({ isOpen, onClose, logo, onSuccess }) =>
+ isOpen ? (
+
+ {logo?.name ?? 'new'}
+
+ Close
+
+
+ onSuccess({ type: 'update', logo: { id: 1, name: 'Updated', url: '/x.png', channel_count: 0 } })
+ }
+ >
+ Success Update
+
+
+ onSuccess({ type: 'create', logo: { id: 99, name: 'New', url: '/n.png', channel_count: 0 } })
+ }
+ >
+ Success Create
+
+ onSuccess(null)}>
+ Success Null
+
+ onSuccess({ type: 'update', logo: null })}
+ >
+ Success No Logo
+
+
+ ) : null,
+}));
+
+vi.mock('../../ConfirmationDialog', () => ({
+ default: ({
+ opened,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ loading,
+ showDeleteFileOption,
+ }) =>
+ opened ? (
+
+
{title}
+
+ {typeof message === 'string' ? message : 'rich-message'}
+
+
+ {cancelLabel}
+
+
onConfirm(false)}
+ disabled={loading}
+ >
+ {confirmLabel}
+
+ {showDeleteFileOption && (
+
onConfirm(true)}
+ disabled={loading}
+ >
+ Also Delete Files
+
+ )}
+
+ ) : null,
+}));
+
+vi.mock('../CustomTable', () => ({
+ CustomTable: () =>
,
+ useTable: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, style }) => {children}
,
+ Button: ({ children, onClick, leftSection, disabled, loading, color, variant }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Center: ({ children, style }) => {children}
,
+ Checkbox: ({ checked, onChange, label, disabled }) => (
+
+
+ {label}
+
+ ),
+ Group: ({ children, style }) => {children}
,
+ Image: ({ src, alt, fallbackSrc, style, onMouseEnter, onMouseLeave }) => (
+
+ ),
+ LoadingOverlay: ({ visible }) => (visible ?
: null),
+ NativeSelect: ({ value, data, onChange, style }) => (
+
+ {data.map((d) => (
+
+ {d}
+
+ ))}
+
+ ),
+ Pagination: ({ total, value, onChange, style }) => (
+
+ onChange(value - 1)}
+ disabled={value <= 1}
+ >
+ Prev
+
+ {value}
+ onChange(value + 1)}
+ disabled={value >= total}
+ >
+ Next
+
+
+ ),
+ Paper: ({ children, style }) => {children}
,
+ Select: ({ value, onChange, data, style }) => (
+ onChange(e.target.value)}
+ style={style}
+ >
+ {data.map((d) => (
+
+ {d.label}
+
+ ))}
+
+ ),
+ Stack: ({ children, style }) => {children}
,
+ Text: ({ children, size, c, style }) => (
+
+ {children}
+
+ ),
+ TextInput: ({ value, onChange, placeholder, style }) => (
+
+ ),
+ Tooltip: ({ children }) => {children}
,
+ useMantineTheme: vi.fn(() => ({
+ tailwind: {
+ yellow: { 3: '#fbbf24' },
+ red: { 6: '#dc2626' },
+ green: { 5: '#22c55e' },
+ },
+ })),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ ExternalLink: () => ,
+ SquareMinus: () => ,
+ SquarePen: () => ,
+ SquarePlus: () => ,
+ Trash: () => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useLogosStore from '../../../store/logos';
+import useWarningsStore from '../../../store/warnings';
+import useLocalStorage from '../../../hooks/useLocalStorage';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import * as LogosTableUtils from '../../../utils/tables/LogosTableUtils.js';
+import { useTable } from '../CustomTable';
+import LogosTable from '../LogosTable';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeLogo = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Logo',
+ url: 'http://example.com/logo.png',
+ cache_url: '/cached/logo.png',
+ channel_count: 0,
+ channel_names: [],
+ is_used: false,
+ ...overrides,
+});
+
+let capturedTableOptions = null;
+
+const setupMocks = ({
+ logos = { 1: makeLogo() },
+ storeLoading = false,
+ tableSize = 'default',
+} = {}) => {
+ const mockFetchAllLogos = vi.fn().mockResolvedValue(undefined);
+ const mockUpdateLogo = vi.fn();
+ const mockAddLogo = vi.fn();
+
+ vi.mocked(useLogosStore).mockImplementation((sel) => {
+ const state = {
+ logos,
+ fetchAllLogos: mockFetchAllLogos,
+ updateLogo: mockUpdateLogo,
+ addLogo: mockAddLogo,
+ isLoading: storeLoading,
+ };
+ return typeof sel === 'function' ? sel(state) : state;
+ });
+
+ vi.mocked(useWarningsStore).mockImplementation((sel) =>
+ sel({ suppressWarning: vi.fn(), isWarningSuppressed: vi.fn(() => false) })
+ );
+
+ vi.mocked(useLocalStorage).mockImplementation((key, defaultVal) => {
+ if (key === 'table-size') return [tableSize, vi.fn()];
+ if (key === 'logos-page-size') return [25, vi.fn()];
+ return [defaultVal, vi.fn()];
+ });
+
+ vi.mocked(LogosTableUtils.getFilteredLogos).mockReturnValue(Object.values(logos));
+
+ vi.mocked(useTable).mockImplementation((opts) => {
+ capturedTableOptions = opts;
+ return {
+ getRowModel: () => ({ rows: [] }),
+ getHeaderGroups: () => [],
+ setSelectedTableIds: vi.fn(),
+ };
+ });
+
+ return { mockFetchAllLogos, mockUpdateLogo, mockAddLogo };
+};
+
+// ── Column helpers ─────────────────────────────────────────────────────────────
+const getActionsCell = (logo) => {
+ const col = capturedTableOptions.columns.find((c) => c.id === 'actions');
+ return col.cell({ row: { id: logo.id, original: logo } });
+};
+
+const getSelectCell = (logo) => {
+ const col = capturedTableOptions.columns.find((c) => c.id === 'select');
+ return col.cell({ row: { id: logo.id, original: logo } });
+};
+
+const getSelectHeader = () => {
+ const col = capturedTableOptions.columns.find((c) => c.id === 'select');
+ return col.header({ table: {} });
+};
+
+// ══════════════════════════════════════════════════════════════════════════════
+// Tests
+// ══════════════════════════════════════════════════════════════════════════════
+
+describe('LogosTable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedTableOptions = null;
+ vi.mocked(LogosTableUtils.deleteLogo).mockResolvedValue(undefined);
+ vi.mocked(LogosTableUtils.deleteLogos).mockResolvedValue(undefined);
+ vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({
+ deleted_count: 3,
+ local_files_deleted: 0,
+ });
+ window.open = vi.fn();
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the name filter input', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByPlaceholderText('Filter by name...')).toBeInTheDocument();
+ });
+
+ it('renders the usage filter select with all options', () => {
+ setupMocks();
+ render( );
+ const select = screen.getByTestId('usage-select');
+ expect(select).toBeInTheDocument();
+ expect(select).toHaveValue('all');
+ });
+
+ it('renders the "Add Logo" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Add Logo')).toBeInTheDocument();
+ });
+
+ it('"Delete" button is disabled when no rows are selected', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Delete').closest('button')).toBeDisabled();
+ });
+
+ it('"Cleanup Unused" button is disabled when there are no unused logos', () => {
+ setupMocks({ logos: { 1: makeLogo({ is_used: true }) } });
+ render( );
+ expect(screen.getByText(/Cleanup Unused/).closest('button')).toBeDisabled();
+ });
+
+ it('"Cleanup Unused" button shows count and is enabled when unused logos exist', () => {
+ setupMocks({
+ logos: {
+ 1: makeLogo({ id: 1, is_used: false }),
+ 2: makeLogo({ id: 2, name: 'Logo 2', is_used: false }),
+ },
+ });
+ render( );
+ const btn = screen.getByText(/Cleanup Unused/).closest('button');
+ expect(btn).not.toBeDisabled();
+ expect(btn).toHaveTextContent('(2)');
+ });
+
+ it('renders the custom table', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('renders loading overlay when store is loading', () => {
+ setupMocks({ storeLoading: true });
+ render( );
+ expect(screen.getByTestId('loading-overlay')).toBeInTheDocument();
+ });
+
+ it('does not render loading overlay when not loading', () => {
+ setupMocks({ storeLoading: false });
+ render( );
+ expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
+ });
+
+ it('renders pagination controls', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('pagination')).toBeInTheDocument();
+ });
+
+ it('renders the page size selector', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('native-select')).toBeInTheDocument();
+ });
+
+ it('shows "1 to 1 of 1" pagination string for a single logo', () => {
+ setupMocks({ logos: { 1: makeLogo() } });
+ render( );
+ expect(screen.getByText('1 to 1 of 1')).toBeInTheDocument();
+ });
+
+ it('"Prev" pagination button is disabled on first page', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('pagination-prev')).toBeDisabled();
+ });
+
+ it('"Next" pagination button is disabled when on the only page', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('pagination-next')).toBeDisabled();
+ });
+ });
+
+ // ── Add Logo ───────────────────────────────────────────────────────────────
+
+ describe('Add Logo', () => {
+ it('opens logo form with no selected logo when "Add Logo" is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Logo'));
+ expect(screen.getByTestId('logo-form')).toBeInTheDocument();
+ expect(screen.getByTestId('logo-form-name')).toHaveTextContent('new');
+ });
+
+ it('closes the logo form when onClose is called', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Logo'));
+ fireEvent.click(screen.getByTestId('logo-form-close'));
+ expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Edit Logo ──────────────────────────────────────────────────────────────
+
+ describe('Edit Logo', () => {
+ it('opens the logo form populated with the logo when edit action is clicked', () => {
+ const logo = makeLogo({ name: 'My Logo' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { getByTestId: getActionIcon } = render(getActionsCell(logo));
+ fireEvent.click(getActionIcon('icon-square-pen').closest('button'));
+
+ expect(screen.getByTestId('logo-form')).toBeInTheDocument();
+ expect(screen.getByTestId('logo-form-name')).toHaveTextContent('My Logo');
+ });
+
+ it('closes the logo form when onClose is called after edit', () => {
+ const logo = makeLogo();
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { getByTestId: getActionIcon } = render(getActionsCell(logo));
+ fireEvent.click(getActionIcon('icon-square-pen').closest('button'));
+ fireEvent.click(screen.getByTestId('logo-form-close'));
+
+ expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── onLogoSuccess ──────────────────────────────────────────────────────────
+
+ describe('onLogoSuccess', () => {
+ it('calls updateLogo when result type is "update"', () => {
+ const { mockUpdateLogo } = setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Logo'));
+ fireEvent.click(screen.getByTestId('logo-form-success-update'));
+ expect(mockUpdateLogo).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 1, name: 'Updated' })
+ );
+ });
+
+ it('calls addLogo when result type is "create"', () => {
+ const { mockAddLogo } = setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Logo'));
+ fireEvent.click(screen.getByTestId('logo-form-success-create'));
+ expect(mockAddLogo).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 99, name: 'New' })
+ );
+ });
+
+ it('calls fetchAllLogos when the logo is missing from the result', async () => {
+ const { mockFetchAllLogos } = setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Logo'));
+ fireEvent.click(screen.getByTestId('logo-form-success-no-logo'));
+ await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled());
+ });
+
+ it('does nothing when result is null', () => {
+ const { mockUpdateLogo, mockAddLogo, mockFetchAllLogos } = setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Logo'));
+ fireEvent.click(screen.getByTestId('logo-form-success-null'));
+ expect(mockUpdateLogo).not.toHaveBeenCalled();
+ expect(mockAddLogo).not.toHaveBeenCalled();
+ expect(mockFetchAllLogos).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Delete single logo ─────────────────────────────────────────────────────
+
+ describe('Delete single logo', () => {
+ it('opens "Delete Logo" confirmation dialog when delete action icon is clicked', () => {
+ const logo = makeLogo();
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent('Delete Logo');
+ });
+
+ it('calls deleteLogo with the logo id on confirm', async () => {
+ const logo = makeLogo();
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(LogosTableUtils.deleteLogo).toHaveBeenCalledWith(logo.id, false)
+ );
+ });
+
+ it('calls fetchAllLogos after a successful delete', async () => {
+ const logo = makeLogo();
+ const { mockFetchAllLogos } = setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled());
+ });
+
+ it('shows a success notification after delete', async () => {
+ const logo = makeLogo();
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'green', title: 'Success' })
+ )
+ );
+ });
+
+ it('shows an error notification when deleteLogo rejects', async () => {
+ vi.mocked(LogosTableUtils.deleteLogo).mockRejectedValue(new Error('fail'));
+ const logo = makeLogo();
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'red' })
+ )
+ );
+ });
+
+ it('closes the dialog after confirming delete', async () => {
+ const logo = makeLogo();
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument()
+ );
+ });
+
+ it('closes the dialog on cancel without calling deleteLogo', () => {
+ const logo = makeLogo();
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ expect(LogosTableUtils.deleteLogo).not.toHaveBeenCalled();
+ });
+
+ it('shows "Also Delete Files" option for /data/logos URLs', () => {
+ const logo = makeLogo({ url: '/data/logos/test.png' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+
+ expect(screen.getByTestId('confirm-ok-with-files')).toBeInTheDocument();
+ });
+
+ it('does not show "Also Delete Files" option for external URLs', () => {
+ const logo = makeLogo({ url: 'http://example.com/logo.png' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+
+ expect(screen.queryByTestId('confirm-ok-with-files')).not.toBeInTheDocument();
+ });
+
+ it('calls deleteLogo with deleteFile=true when confirmed with file deletion', async () => {
+ const logo = makeLogo({ url: '/data/logos/test.png' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { container: actionsContainer } = render(getActionsCell(logo));
+ fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok-with-files'));
+
+ await waitFor(() =>
+ expect(LogosTableUtils.deleteLogo).toHaveBeenCalledWith(logo.id, true)
+ );
+ });
+ });
+
+ // ── Bulk delete ────────────────────────────────────────────────────────────
+
+ describe('Bulk delete', () => {
+ it('"Delete" button is disabled when no rows are selected', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Delete').closest('button')).toBeDisabled();
+ });
+
+ it('enables "Delete" button and shows count after a row is selected', () => {
+ const logo = makeLogo({ id: 1 });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { getByTestId: getCheckbox } = render(getSelectCell(logo));
+ fireEvent.click(getCheckbox('checkbox'));
+
+ expect(screen.getByText(/Delete \(1\)/).closest('button')).not.toBeDisabled();
+ });
+
+ it('opens "Delete Multiple Logos" dialog after selecting rows and clicking Delete', () => {
+ const logo = makeLogo({ id: 1 });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { getByTestId: getCheckbox } = render(getSelectCell(logo));
+ fireEvent.click(getCheckbox('checkbox'));
+ fireEvent.click(screen.getByText(/Delete \(1\)/));
+
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent('Delete Multiple Logos');
+ });
+
+ it('calls deleteLogos with selected ids on confirm', async () => {
+ const logo = makeLogo({ id: 1 });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { getByTestId: getCheckbox } = render(getSelectCell(logo));
+ fireEvent.click(getCheckbox('checkbox'));
+ fireEvent.click(screen.getByText(/Delete \(1\)/));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(LogosTableUtils.deleteLogos).toHaveBeenCalledWith([1], false)
+ );
+ });
+
+ it('calls fetchAllLogos after bulk delete', async () => {
+ const logo = makeLogo({ id: 1 });
+ const { mockFetchAllLogos } = setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { getByTestId: getCheckbox } = render(getSelectCell(logo));
+ fireEvent.click(getCheckbox('checkbox'));
+ fireEvent.click(screen.getByText(/Delete \(1\)/));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled());
+ });
+
+ it('shows success notification after bulk delete', async () => {
+ const logo = makeLogo({ id: 1 });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { getByTestId: getCheckbox } = render(getSelectCell(logo));
+ fireEvent.click(getCheckbox('checkbox'));
+ fireEvent.click(screen.getByText(/Delete \(1\)/));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'green' })
+ )
+ );
+ });
+
+ it('shows error notification when deleteLogos rejects', async () => {
+ vi.mocked(LogosTableUtils.deleteLogos).mockRejectedValue(new Error('fail'));
+ const logo = makeLogo({ id: 1 });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const { getByTestId: getCheckbox } = render(getSelectCell(logo));
+ fireEvent.click(getCheckbox('checkbox'));
+ fireEvent.click(screen.getByText(/Delete \(1\)/));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'red' })
+ )
+ );
+ });
+
+ it('select-all header checkbox selects all logos', () => {
+ const logos = {
+ 1: makeLogo({ id: 1 }),
+ 2: makeLogo({ id: 2, name: 'Logo 2' }),
+ };
+ setupMocks({ logos });
+ render( );
+
+ const { getByTestId: getHeaderCheckbox } = render(getSelectHeader());
+ fireEvent.click(getHeaderCheckbox('checkbox'));
+
+ expect(screen.getByText(/Delete \(2\)/).closest('button')).not.toBeDisabled();
+ });
+ });
+
+ // ── Cleanup unused ─────────────────────────────────────────────────────────
+
+ describe('Cleanup unused', () => {
+ const withUnused = () => ({ logos: { 1: makeLogo({ is_used: false }) } });
+
+ it('opens the cleanup confirmation dialog when the button is clicked', () => {
+ setupMocks(withUnused());
+ render( );
+ fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button'));
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent('Cleanup Unused Logos');
+ });
+
+ it('calls cleanupUnusedLogos(false) on confirm', async () => {
+ setupMocks(withUnused());
+ render( );
+ fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(LogosTableUtils.cleanupUnusedLogos).toHaveBeenCalledWith(false)
+ );
+ });
+
+ it('calls cleanupUnusedLogos(true) when confirmed with file deletion', async () => {
+ setupMocks(withUnused());
+ render( );
+ fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok-with-files'));
+ await waitFor(() =>
+ expect(LogosTableUtils.cleanupUnusedLogos).toHaveBeenCalledWith(true)
+ );
+ });
+
+ it('calls fetchAllLogos after successful cleanup', async () => {
+ const { mockFetchAllLogos } = setupMocks(withUnused());
+ render( );
+ fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled());
+ });
+
+ it('shows "Cleanup Complete" success notification with deleted count', async () => {
+ vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({
+ deleted_count: 3,
+ local_files_deleted: 0,
+ });
+ setupMocks(withUnused());
+ render( );
+ fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ color: 'green',
+ title: 'Cleanup Complete',
+ message: expect.stringContaining('3'),
+ })
+ )
+ );
+ });
+
+ it('includes local file count in success notification when files were deleted', async () => {
+ vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({
+ deleted_count: 5,
+ local_files_deleted: 2,
+ });
+ setupMocks(withUnused());
+ render( );
+ fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringContaining('2 local files'),
+ })
+ )
+ );
+ });
+
+ it('shows "Cleanup Failed" error notification when cleanup rejects', async () => {
+ vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockRejectedValue(new Error('Server error'));
+ setupMocks(withUnused());
+ render( );
+ fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'red', title: 'Cleanup Failed' })
+ )
+ );
+ });
+
+ it('closes the cleanup dialog on cancel', () => {
+ setupMocks(withUnused());
+ render( );
+ fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Name filter (debounced) ────────────────────────────────────────────────
+
+ describe('name filter (debounced)', () => {
+ it('reflects the typed value in the filter input immediately', () => {
+ setupMocks();
+ render( );
+ const input = screen.getByPlaceholderText('Filter by name...');
+ fireEvent.change(input, { target: { value: 'sports' } });
+ expect(input).toHaveValue('sports');
+ });
+
+ it('does not pass filter value to getFilteredLogos immediately after typing', () => {
+ setupMocks();
+ render( );
+ vi.mocked(LogosTableUtils.getFilteredLogos).mockClear();
+
+ fireEvent.change(screen.getByPlaceholderText('Filter by name...'), {
+ target: { value: 'sports' },
+ });
+
+ expect(LogosTableUtils.getFilteredLogos).not.toHaveBeenCalledWith(
+ expect.anything(),
+ 'sports',
+ expect.anything()
+ );
+ });
+
+ it('passes filter value to getFilteredLogos after the 300ms debounce fires', async () => {
+ setupMocks();
+ render( );
+ vi.mocked(LogosTableUtils.getFilteredLogos).mockClear();
+
+ fireEvent.change(screen.getByPlaceholderText('Filter by name...'), {
+ target: { value: 'sports' },
+ });
+
+ await waitFor(
+ () =>
+ expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith(
+ expect.anything(),
+ 'sports',
+ expect.anything()
+ ),
+ { timeout: 600 }
+ );
+ });
+ });
+
+ // ── Usage filter ───────────────────────────────────────────────────────────
+
+ describe('usage filter', () => {
+ it('calls getFilteredLogos with "used" when filter is changed to "Used only"', () => {
+ const logos = { 1: makeLogo() };
+ setupMocks({ logos });
+ render( );
+ vi.mocked(LogosTableUtils.getFilteredLogos).mockClear();
+
+ fireEvent.change(screen.getByTestId('usage-select'), { target: { value: 'used' } });
+
+ expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith(logos, '', 'used');
+ });
+
+ it('calls getFilteredLogos with "unused" when filter is changed to "Unused only"', () => {
+ const logos = { 1: makeLogo() };
+ setupMocks({ logos });
+ render( );
+ vi.mocked(LogosTableUtils.getFilteredLogos).mockClear();
+
+ fireEvent.change(screen.getByTestId('usage-select'), { target: { value: 'unused' } });
+
+ expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith(logos, '', 'unused');
+ });
+ });
+
+ // ── Column cell renderers ──────────────────────────────────────────────────
+
+ describe('column cell renderers', () => {
+ it('renders the preview image with cache_url as src and /logo.png as fallback', () => {
+ const logo = makeLogo({ cache_url: '/cached/test.png', name: 'My Logo' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'cache_url');
+ const { getByAltText } = render(
+ col.cell({ getValue: () => logo.cache_url, row: { original: logo } })
+ );
+
+ expect(getByAltText('My Logo')).toHaveAttribute('src', '/cached/test.png');
+ expect(getByAltText('My Logo')).toHaveAttribute('data-fallback', '/logo.png');
+ });
+
+ it('renders the logo name in the name column', () => {
+ const logo = makeLogo({ name: 'Channel 4 Logo' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'name');
+ const { getByText } = render(col.cell({ getValue: () => logo.name }));
+ expect(getByText('Channel 4 Logo')).toBeInTheDocument();
+ });
+
+ it('renders an "Unused" badge when channel_count is 0', () => {
+ const logo = makeLogo({ channel_count: 0 });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'channel_count');
+ const { getByText } = render(col.cell({ getValue: () => 0, row: { original: logo } }));
+ expect(getByText('Unused')).toBeInTheDocument();
+ });
+
+ it('renders a usage label badge when channel_count is > 0', () => {
+ const logo = makeLogo({ channel_count: 2, channel_names: ['Channel: HBO', 'Channel: CNN'] });
+ vi.mocked(LogosTableUtils.generateUsageLabel).mockReturnValue('2 channels');
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'channel_count');
+ const { getByText } = render(col.cell({ getValue: () => 2, row: { original: logo } }));
+ expect(getByText('2 channels')).toBeInTheDocument();
+ });
+
+ it('renders the URL text in the URL column', () => {
+ const logo = makeLogo({ url: 'http://example.com/logo.png' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url');
+ const { getByText } = render(col.cell({ getValue: () => logo.url }));
+ expect(getByText('http://example.com/logo.png')).toBeInTheDocument();
+ });
+
+ it('renders external link icon for http URLs', () => {
+ const logo = makeLogo({ url: 'http://example.com/logo.png' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url');
+ const { getByTestId } = render(col.cell({ getValue: () => logo.url }));
+ expect(getByTestId('icon-external-link')).toBeInTheDocument();
+ });
+
+ it('does not render external link icon for non-http URLs', () => {
+ const logo = makeLogo({ url: '/data/logos/test.png' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url');
+ const { queryByTestId } = render(col.cell({ getValue: () => logo.url }));
+ expect(queryByTestId('icon-external-link')).not.toBeInTheDocument();
+ });
+
+ it('opens URL in a new tab when external link button is clicked', () => {
+ const logo = makeLogo({ url: 'http://example.com/logo.png' });
+ setupMocks({ logos: { 1: logo } });
+ render( );
+
+ const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url');
+ const { getByTestId } = render(col.cell({ getValue: () => logo.url }));
+ fireEvent.click(getByTestId('icon-external-link').closest('button'));
+
+ expect(window.open).toHaveBeenCalledWith('http://example.com/logo.png', '_blank');
+ });
+ });
+
+ // ── Pagination controls ────────────────────────────────────────────────────
+
+ describe('pagination controls', () => {
+ it('updates pagination when page size is changed', () => {
+ setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('native-select'), { target: { value: '50' } });
+ expect(screen.getByTestId('pagination')).toBeInTheDocument();
+ });
+
+ it('shows "Page Size" label', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Page Size')).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/tables/__tests__/M3UsTable.test.jsx b/frontend/src/components/tables/__tests__/M3UsTable.test.jsx
new file mode 100644
index 00000000..f0f44a66
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/M3UsTable.test.jsx
@@ -0,0 +1,1260 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/playlists', () => ({ default: vi.fn() }));
+vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
+
+// ── Hook mocks ─────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(() => ['default', vi.fn()]),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/dateTimeUtils.js', () => ({
+ useDateTimeFormat: vi.fn(() => ({
+ fullDateFormat: 'MM/DD/YYYY',
+ fullDateTimeFormat: 'MM/DD/YYYY HH:mm',
+ })),
+ format: vi.fn((val) => `formatted:${val}`),
+ diff: vi.fn(() => 30),
+ getNow: vi.fn(() => '2024-06-01T12:00:00Z'),
+}));
+
+vi.mock('../../../utils/tables/M3UsTableUtils.js', () => ({
+ deletePlaylist: vi.fn().mockResolvedValue(undefined),
+ getExpirationInfo: vi.fn(() => ({ color: 'green.5', label: '30d left' })),
+ getExpirationTooltip: vi.fn(() => 'expiration-tooltip'),
+ getPlaylistAutoCreatedChannelsCount: vi.fn().mockResolvedValue({
+ count: 0,
+ sample_names: [],
+ }),
+ getSortedPlaylists: vi.fn((playlists) =>
+ playlists.filter((p) => p.locked === false)
+ ),
+ getStatusColor: vi.fn(() => 'green.5'),
+ getStatusContent: vi.fn(() => ({ type: 'default', label: 'Idle' })),
+ formatStatusText: vi.fn((s) =>
+ s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Unknown'
+ ),
+ refreshPlaylist: vi.fn().mockResolvedValue(undefined),
+ updatePlaylist: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock('../M3uTableUtils.jsx', () => ({
+ makeHeaderCellRenderer: vi.fn(() => (header) => (
+
+ {header.column.columnDef.header}
+
+ )),
+ makeSortingChangeHandler: vi.fn(() => vi.fn()),
+}));
+
+vi.mock('../../../helpers', () => ({
+ TableHelper: {
+ defaultProperties: { mantineTableProps: { striped: true } },
+ },
+}));
+
+// ── Child component mocks ──────────────────────────────────────────────────────
+vi.mock('../../forms/M3U', () => ({
+ default: ({ isOpen, onClose, m3uAccount }) =>
+ isOpen ? (
+
+ {m3uAccount?.name ?? 'new'}
+ onClose(null)}>
+ Close
+
+ onClose({ id: 99, name: 'New Playlist' })}
+ >
+ Close with playlist
+
+
+ ) : null,
+}));
+
+vi.mock('../../ServerGroupsManagerModal', () => ({
+ default: ({ isOpen, onClose }) =>
+ isOpen ? (
+
+
+ Close
+
+
+ ) : null,
+}));
+
+vi.mock('../../../components/ConfirmationDialog', () => ({
+ default: ({
+ opened,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ loading,
+ }) =>
+ opened ? (
+
+
{title}
+
+ {typeof message === 'string' ? message : 'rich-message'}
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+
+ ) : null,
+}));
+
+vi.mock('../CustomTable', () => ({
+ CustomTable: () =>
,
+ useTable: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, style }) => {children}
,
+ Button: ({ children, onClick, leftSection, disabled, loading }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Flex: ({ children, style }) => {children}
,
+ Paper: ({ children, style }) => {children}
,
+ Switch: ({ checked, onChange }) => (
+
+ ),
+ Text: ({ children, size, c, fw, style }) => (
+
+ {children}
+
+ ),
+ Tooltip: ({ children, label }) => (
+ {children}
+ ),
+ useMantineTheme: vi.fn(() => ({
+ palette: { background: { paper: '#1a1a1a' } },
+ colors: { red: { 6: '#fa5252' }, green: { 6: '#40c057' } },
+ })),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ // Icons used by M3UsTable
+ RefreshCcw: () => ,
+ SquareMinus: () => ,
+ SquarePen: () => ,
+ SquarePlus: () => ,
+ // Icons required by src/config/navigation.js (pulled in via store/auth)
+ Blocks: () => ,
+ ChartLine: () => ,
+ Database: () => ,
+ Download: () => ,
+ FileImage: () => ,
+ LayoutGrid: () => ,
+ ListOrdered: () => ,
+ Logs: () => ,
+ MonitorCog: () => ,
+ Package: () => ,
+ Play: () => ,
+ PlugZap: () => ,
+ Settings: () => ,
+ User: () => ,
+ Video: () => ,
+ Webhook: () => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import usePlaylistsStore from '../../../store/playlists';
+import useWarningsStore from '../../../store/warnings';
+import useLocalStorage from '../../../hooks/useLocalStorage';
+import * as M3UsTableUtils from '../../../utils/tables/M3UsTableUtils.js';
+import * as DateTimeUtils from '../../../utils/dateTimeUtils.js';
+import { useTable } from '../CustomTable';
+import M3UTable from '../M3UsTable';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makePlaylist = (overrides = {}) => ({
+ id: 1,
+ name: 'Test M3U',
+ account_type: 'M3U',
+ server_url: 'http://example.com/playlist.m3u',
+ file_path: null,
+ status: 'success',
+ last_message: 'Loaded 500 streams',
+ max_streams: 5,
+ profiles: [],
+ is_active: true,
+ locked: false,
+ updated_at: '2024-01-01T12:00:00Z',
+ earliest_expiration: '2024-12-01T00:00:00Z',
+ all_expirations: [],
+ ...overrides,
+});
+
+let capturedTableOptions = null;
+
+const setupMocks = ({
+ playlists = [makePlaylist()],
+ refreshProgress = {},
+ editPlaylistId = null,
+ isWarningSuppressed = vi.fn(() => false),
+ suppressWarning = vi.fn(),
+ tableSize = 'default',
+} = {}) => {
+ const mockSetRefreshProgress = vi.fn();
+ const mockSetEditPlaylistId = vi.fn();
+
+ vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
+ sel({
+ playlists,
+ refreshProgress,
+ setRefreshProgress: mockSetRefreshProgress,
+ editPlaylistId,
+ setEditPlaylistId: mockSetEditPlaylistId,
+ })
+ );
+
+ vi.mocked(useWarningsStore).mockImplementation((sel) =>
+ sel({ isWarningSuppressed, suppressWarning })
+ );
+
+ vi.mocked(useLocalStorage).mockReturnValue([tableSize, vi.fn()]);
+
+ vi.mocked(useTable).mockImplementation((opts) => {
+ capturedTableOptions = opts;
+ return { getRowModel: () => ({ rows: [] }), getHeaderGroups: () => [] };
+ });
+
+ return { mockSetRefreshProgress, mockSetEditPlaylistId };
+};
+
+const getCol = (keyOrId) =>
+ capturedTableOptions.columns.find(
+ (c) => c.accessorKey === keyOrId || c.id === keyOrId
+ );
+
+const makeRowCtx = (playlist) => ({
+ row: { id: String(playlist.id), original: playlist },
+ cell: { column: { id: 'actions', columnDef: {} }, getValue: vi.fn() },
+});
+
+// ══════════════════════════════════════════════════════════════════════════════
+// Tests
+// ══════════════════════════════════════════════════════════════════════════════
+
+describe('M3UTable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedTableOptions = null;
+ vi.mocked(M3UsTableUtils.deletePlaylist).mockResolvedValue(undefined);
+ vi.mocked(M3UsTableUtils.refreshPlaylist).mockResolvedValue(undefined);
+ vi.mocked(M3UsTableUtils.updatePlaylist).mockResolvedValue(undefined);
+ vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockResolvedValue({
+ count: 0,
+ sample_names: [],
+ });
+ vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'default', label: 'Idle' });
+ vi.mocked(DateTimeUtils.format).mockImplementation((val) => `formatted:${val}`);
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the "M3U Accounts" heading', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('M3U Accounts')).toBeInTheDocument();
+ });
+
+ it('renders the "Add M3U" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Add M3U')).toBeInTheDocument();
+ });
+
+ it('renders the custom table', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('does not render the M3U form on initial load', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument();
+ });
+
+ it('filters out locked playlists from the table data', () => {
+ setupMocks({
+ playlists: [
+ makePlaylist({ id: 1, name: 'Unlocked', locked: false }),
+ makePlaylist({ id: 2, name: 'Locked', locked: true }),
+ ],
+ });
+ render( );
+ expect(capturedTableOptions.data.every((p) => p.locked === false)).toBe(true);
+ });
+
+ it('places active playlists before inactive ones', () => {
+ setupMocks({
+ playlists: [
+ makePlaylist({ id: 1, name: 'Inactive', is_active: false }),
+ makePlaylist({ id: 2, name: 'Active', is_active: true }),
+ ],
+ });
+ render( );
+ expect(capturedTableOptions.data[0].name).toBe('Active');
+ expect(capturedTableOptions.data[1].name).toBe('Inactive');
+ });
+
+ it('sorts playlists alphabetically within the same active group', () => {
+ setupMocks({
+ playlists: [
+ makePlaylist({ id: 1, name: 'Zebra', is_active: true }),
+ makePlaylist({ id: 2, name: 'Alpha', is_active: true }),
+ ],
+ });
+ render( );
+ expect(capturedTableOptions.data[0].name).toBe('Alpha');
+ expect(capturedTableOptions.data[1].name).toBe('Zebra');
+ });
+ });
+
+ // ── Add M3U ────────────────────────────────────────────────────────────────
+
+ describe('Add M3U', () => {
+ it('opens M3UForm with no account when "Add M3U" is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add M3U'));
+ expect(screen.getByTestId('m3u-form')).toBeInTheDocument();
+ expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('new');
+ });
+
+ it('closes the form when onClose(null) is called', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add M3U'));
+ fireEvent.click(screen.getByTestId('m3u-form-close'));
+ expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument();
+ });
+
+ it('keeps form open and updates playlist when onClose receives a new playlist', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add M3U'));
+ fireEvent.click(screen.getByTestId('m3u-form-close-with-playlist'));
+ expect(screen.getByTestId('m3u-form')).toBeInTheDocument();
+ expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('New Playlist');
+ });
+ });
+
+ // ── Edit playlist via RowActions ───────────────────────────────────────────
+
+ describe('edit playlist via RowActions', () => {
+ it('opens M3UForm populated with the playlist when edit icon is clicked', () => {
+ const playlist = makePlaylist({ name: 'My M3U' });
+ setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+
+ expect(screen.getByTestId('m3u-form')).toBeInTheDocument();
+ expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('My M3U');
+ });
+
+ it('closes the form after editing when onClose(null) is called', () => {
+ const playlist = makePlaylist({ name: 'My M3U' });
+ setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+ fireEvent.click(screen.getByTestId('m3u-form-close'));
+
+ expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── editPlaylistId (from notifications) ───────────────────────────────────
+
+ describe('editPlaylistId from store', () => {
+ it('auto-opens M3UForm for the matching playlist', () => {
+ const playlists = [makePlaylist({ id: 42, name: 'Notification Playlist' })];
+ setupMocks({ playlists, editPlaylistId: 42 });
+ render( );
+ expect(screen.getByTestId('m3u-form')).toBeInTheDocument();
+ expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('Notification Playlist');
+ });
+
+ it('calls setEditPlaylistId(null) after handling editPlaylistId', () => {
+ const playlists = [makePlaylist({ id: 42 })];
+ const { mockSetEditPlaylistId } = setupMocks({ playlists, editPlaylistId: 42 });
+ render( );
+ expect(mockSetEditPlaylistId).toHaveBeenCalledWith(null);
+ });
+
+ it('does not open form when editPlaylistId does not match any playlist', () => {
+ setupMocks({ playlists: [makePlaylist({ id: 1 })], editPlaylistId: 999 });
+ render( );
+ expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Refresh playlist ───────────────────────────────────────────────────────
+
+ describe('refresh playlist', () => {
+ it('calls setRefreshProgress with initializing state immediately', async () => {
+ const playlist = makePlaylist({ id: 1 });
+ const { mockSetRefreshProgress } = setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+
+ expect(mockSetRefreshProgress).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ action: 'initializing', progress: 0 })
+ );
+ });
+
+ it('calls refreshPlaylist with the playlist id', async () => {
+ const playlist = makePlaylist({ id: 1 });
+ setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+
+ await waitFor(() =>
+ expect(M3UsTableUtils.refreshPlaylist).toHaveBeenCalledWith(1)
+ );
+ });
+
+ it('sets error progress when refreshPlaylist rejects', async () => {
+ vi.mocked(M3UsTableUtils.refreshPlaylist).mockRejectedValue(new Error('fail'));
+ const playlist = makePlaylist({ id: 1 });
+ const { mockSetRefreshProgress } = setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-refresh').closest('button'));
+
+ await waitFor(() =>
+ expect(mockSetRefreshProgress).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ action: 'error', status: 'error' })
+ )
+ );
+ });
+
+ it('disables the refresh button when playlist is inactive', () => {
+ const playlist = makePlaylist({ id: 1, is_active: false });
+ setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-refresh').closest('button')).toBeDisabled();
+ });
+
+ it('enables the refresh button when playlist is active', () => {
+ const playlist = makePlaylist({ id: 1, is_active: true });
+ setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-refresh').closest('button')).not.toBeDisabled();
+ });
+ });
+
+ // ── Delete playlist ────────────────────────────────────────────────────────
+
+ describe('delete playlist', () => {
+ const openDeleteDialog = async (playlist) => {
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+ await waitFor(() => screen.getByTestId('confirmation-dialog'));
+ };
+
+ it('opens "Confirm M3U Account Deletion" dialog', async () => {
+ const playlist = makePlaylist();
+ setupMocks({ playlists: [playlist] });
+ render( );
+ await openDeleteDialog(playlist);
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent(
+ 'Confirm M3U Account Deletion'
+ );
+ });
+
+ it('shows rich message content in the dialog', async () => {
+ const playlist = makePlaylist({ name: 'My Channel List' });
+ setupMocks({ playlists: [playlist] });
+ render( );
+ await openDeleteDialog(playlist);
+ expect(screen.getByTestId('confirm-message')).toHaveTextContent('rich-message');
+ });
+
+ it('calls deletePlaylist with the correct id on confirm', async () => {
+ const playlist = makePlaylist({ id: 7 });
+ setupMocks({ playlists: [playlist] });
+ render( );
+ await openDeleteDialog(playlist);
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(M3UsTableUtils.deletePlaylist).toHaveBeenCalledWith(7)
+ );
+ });
+
+ it('closes the dialog after confirming delete', async () => {
+ const playlist = makePlaylist();
+ setupMocks({ playlists: [playlist] });
+ render( );
+ await openDeleteDialog(playlist);
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument()
+ );
+ });
+
+ it('closes dialog on cancel without calling deletePlaylist', async () => {
+ const playlist = makePlaylist();
+ setupMocks({ playlists: [playlist] });
+ render( );
+ await openDeleteDialog(playlist);
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ expect(M3UsTableUtils.deletePlaylist).not.toHaveBeenCalled();
+ });
+
+ it('skips dialog and deletes directly when warning suppressed and 0 auto-channels', async () => {
+ const playlist = makePlaylist({ id: 5 });
+ setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) });
+ render( );
+
+ const { row, cell } = makeRowCtx(playlist);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+
+ await waitFor(() =>
+ expect(M3UsTableUtils.deletePlaylist).toHaveBeenCalledWith(5)
+ );
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ });
+
+ it('opens dialog when warning suppressed but auto-channels count > 0', async () => {
+ vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockResolvedValue({
+ count: 3,
+ sample_names: ['Ch 1'],
+ });
+ const playlist = makePlaylist();
+ setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) });
+ render( );
+ await openDeleteDialog(playlist);
+ expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
+ });
+
+ it('opens dialog when auto-channel count fetch fails', async () => {
+ vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockRejectedValue(
+ new Error('Network error')
+ );
+ const playlist = makePlaylist();
+ setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) });
+ render( );
+ await openDeleteDialog(playlist);
+ expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
+ });
+
+ it('does not throw when deletePlaylist rejects', async () => {
+ vi.mocked(M3UsTableUtils.deletePlaylist).mockRejectedValue(new Error('server error'));
+ const playlist = makePlaylist();
+ setupMocks({ playlists: [playlist] });
+ render( );
+ await openDeleteDialog(playlist);
+ await expect(
+ act(async () => fireEvent.click(screen.getByTestId('confirm-ok')))
+ ).resolves.not.toThrow();
+ });
+ });
+
+ // ── Toggle active ──────────────────────────────────────────────────────────
+
+ describe('toggle active', () => {
+ const renderSwitch = (playlist) => {
+ const col = getCol('is_active');
+ return col.cell({
+ cell: { getValue: () => playlist.is_active },
+ row: { original: playlist },
+ });
+ };
+
+ it('calls updatePlaylist with is_active:false when toggling an active playlist', async () => {
+ const playlist = makePlaylist({ is_active: true });
+ setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(playlist));
+ fireEvent.click(getByTestId('active-switch'));
+
+ await waitFor(() =>
+ expect(M3UsTableUtils.updatePlaylist).toHaveBeenCalledWith(
+ { is_active: false },
+ playlist,
+ true
+ )
+ );
+ });
+
+ it('calls updatePlaylist with is_active:true when toggling an inactive playlist', async () => {
+ const playlist = makePlaylist({ is_active: false });
+ setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(playlist));
+ fireEvent.click(getByTestId('active-switch'));
+
+ await waitFor(() =>
+ expect(M3UsTableUtils.updatePlaylist).toHaveBeenCalledWith(
+ { is_active: true },
+ playlist,
+ true
+ )
+ );
+ });
+
+ it('does not throw when updatePlaylist rejects', async () => {
+ vi.mocked(M3UsTableUtils.updatePlaylist).mockRejectedValue(new Error('toggle error'));
+ const playlist = makePlaylist({ is_active: true });
+ setupMocks({ playlists: [playlist] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(playlist));
+ await expect(
+ act(async () => fireEvent.click(getByTestId('active-switch')))
+ ).resolves.not.toThrow();
+ });
+ });
+
+ // ── Column: Type ───────────────────────────────────────────────────────────
+
+ describe('Type column', () => {
+ it('renders "XC" for Xtream Codes type', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('account_type').cell({ cell: { getValue: () => 'XC' } })
+ );
+ expect(getByText('XC')).toBeInTheDocument();
+ });
+
+ it('renders "M3U" for standard type', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('account_type').cell({ cell: { getValue: () => 'M3U' } })
+ );
+ expect(getByText('M3U')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column: URL / File ─────────────────────────────────────────────────────
+
+ describe('URL / File column', () => {
+ it('renders server_url when present', () => {
+ setupMocks();
+ render( );
+ const playlist = makePlaylist({ server_url: 'http://example.com/list.m3u' });
+ const { getByText } = render(
+ getCol('server_url').cell({
+ cell: { getValue: () => playlist.server_url },
+ row: { original: playlist },
+ })
+ );
+ expect(getByText('http://example.com/list.m3u')).toBeInTheDocument();
+ });
+
+ it('falls back to file_path when server_url is empty', () => {
+ setupMocks();
+ render( );
+ const playlist = makePlaylist({ server_url: '', file_path: '/files/list.m3u' });
+ const { getByText } = render(
+ getCol('server_url').cell({
+ cell: { getValue: () => '' },
+ row: { original: playlist },
+ })
+ );
+ expect(getByText('/files/list.m3u')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column: Status ─────────────────────────────────────────────────────────
+
+ describe('Status column', () => {
+ it('returns null when status value is empty', () => {
+ setupMocks();
+ render( );
+ expect(getCol('status').cell({ cell: { getValue: () => null } })).toBeNull();
+ });
+
+ it('renders formatted status text', () => {
+ vi.mocked(M3UsTableUtils.formatStatusText).mockReturnValue('Success');
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('status').cell({ cell: { getValue: () => 'success' } })
+ );
+ expect(getByText('Success')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column: Status Message ─────────────────────────────────────────────────
+
+ describe('Status Message column', () => {
+ it('returns null when last_message is empty and no active progress', () => {
+ setupMocks();
+ render( );
+ const playlist = makePlaylist({ id: 1, status: 'idle' });
+ expect(
+ getCol('last_message').cell({
+ cell: { getValue: () => null },
+ row: { original: playlist },
+ })
+ ).toBeNull();
+ });
+
+ it('renders the last_message text for a generic status', () => {
+ setupMocks();
+ render( );
+ const playlist = makePlaylist({ id: 1, status: 'idle' });
+ const { getByText } = render(
+ getCol('last_message').cell({
+ cell: { getValue: () => 'Loaded 200 streams' },
+ row: { original: playlist },
+ })
+ );
+ expect(getByText('Loaded 200 streams')).toBeInTheDocument();
+ });
+
+ it('shows progress UI when active progress (< 100) exists', () => {
+ vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'initializing' });
+ const playlist = makePlaylist({ id: 1 });
+ setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 50 } } });
+ render( );
+ const { getByText } = render(
+ getCol('last_message').cell({
+ cell: { getValue: () => null },
+ row: { original: playlist },
+ })
+ );
+ expect(getByText('Initializing refresh...')).toBeInTheDocument();
+ });
+
+ it('bypasses progress UI when progress equals 100', () => {
+ const playlist = makePlaylist({ id: 1, status: 'success' });
+ setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 100 } } });
+ render( );
+ const { getByText } = render(
+ getCol('last_message').cell({
+ cell: { getValue: () => 'Done' },
+ row: { original: playlist },
+ })
+ );
+ expect(getByText('Done')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column: Max Streams ────────────────────────────────────────────────────
+
+ describe('Max Streams column', () => {
+ const renderMaxStreams = (playlist) => {
+ setupMocks({ playlists: [playlist] });
+ render( );
+ return getCol('max_streams').cell({ row: { original: playlist } });
+ };
+
+ it('renders max_streams when no active profiles', () => {
+ const { getByText } = render(
+ renderMaxStreams(makePlaylist({ max_streams: 10, profiles: [] }))
+ );
+ expect(getByText('10')).toBeInTheDocument();
+ });
+
+ it('renders "∞" when max_streams is 0 and no active profiles', () => {
+ const { getByText } = render(
+ renderMaxStreams(makePlaylist({ max_streams: 0, profiles: [] }))
+ );
+ expect(getByText('∞')).toBeInTheDocument();
+ });
+
+ it('renders the sum of active profile max_streams', () => {
+ const playlist = makePlaylist({
+ profiles: [
+ { name: 'P1', max_streams: 3, is_active: true },
+ { name: 'P2', max_streams: 5, is_active: true },
+ ],
+ });
+ const { getByText } = render(renderMaxStreams(playlist));
+ expect(getByText('8')).toBeInTheDocument();
+ });
+
+ it('renders "∞" when any active profile has max_streams 0', () => {
+ const playlist = makePlaylist({
+ profiles: [
+ { name: 'P1', max_streams: 0, is_active: true },
+ { name: 'P2', max_streams: 5, is_active: true },
+ ],
+ });
+ const { getByText } = render(renderMaxStreams(playlist));
+ expect(getByText('∞')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column: Expiration ─────────────────────────────────────────────────────
+
+ describe('Expiration column', () => {
+ it('returns null when earliest_expiration is absent', () => {
+ setupMocks();
+ render( );
+ expect(
+ getCol('earliest_expiration').cell({
+ cell: { getValue: () => null },
+ row: { original: makePlaylist({ earliest_expiration: null }) },
+ })
+ ).toBeNull();
+ });
+
+ it('renders the expiration label from getExpirationInfo', () => {
+ vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({
+ color: 'orange.5',
+ label: '7d left',
+ });
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('earliest_expiration').cell({
+ cell: { getValue: () => '2024-12-01T00:00:00Z' },
+ row: { original: makePlaylist() },
+ })
+ );
+ expect(getByText('7d left')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column: Updated ────────────────────────────────────────────────────────
+
+ describe('Updated column', () => {
+ it('renders "Never" when updated_at is absent', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('updated_at').cell({ cell: { getValue: () => null } })
+ );
+ expect(getByText('Never')).toBeInTheDocument();
+ });
+
+ it('renders the formatted date string when updated_at is present', () => {
+ vi.mocked(DateTimeUtils.format).mockReturnValue('formatted:2024-01-01T12:00:00Z');
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('updated_at').cell({ cell: { getValue: () => '2024-01-01T12:00:00Z' } })
+ );
+ expect(getByText('formatted:2024-01-01T12:00:00Z')).toBeInTheDocument();
+ });
+ });
+
+ // ── generateStatusString content types ────────────────────────────────────
+
+ describe('generateStatusString via status message column', () => {
+ const renderProgressCell = (contentOverride, progressData = { progress: 50 }) => {
+ vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue(contentOverride);
+ const playlist = makePlaylist({ id: 1 });
+ setupMocks({ playlists: [playlist], refreshProgress: { 1: progressData } });
+ render( );
+ return getCol('last_message').cell({
+ cell: { getValue: () => null },
+ row: { original: playlist },
+ });
+ };
+
+ it('renders download progress with speed and time', () => {
+ const { getByText } = render(
+ renderProgressCell({
+ type: 'downloading',
+ progress: 45,
+ speed: '1.2 MB/s',
+ timeRemaining: '2m',
+ }, { progress: 45 })
+ );
+ expect(getByText(/Downloading/)).toBeInTheDocument();
+ expect(getByText('45%')).toBeInTheDocument();
+ expect(getByText('1.2 MB/s')).toBeInTheDocument();
+ });
+
+ it('renders parsing progress', () => {
+ const { getByText } = render(
+ renderProgressCell({
+ type: 'parsing',
+ progress: 60,
+ elapsedTime: '5s',
+ timeRemaining: '3s',
+ streamsProcessed: '300/500',
+ }, { progress: 60 })
+ );
+ expect(getByText(/Parsing/)).toBeInTheDocument();
+ expect(getByText('60%')).toBeInTheDocument();
+ });
+
+ it('renders groups processing progress', () => {
+ const { getByText } = render(
+ renderProgressCell({
+ type: 'groups',
+ progress: 30,
+ elapsedTime: '2s',
+ groupsProcessed: '10/50',
+ }, { progress: 30 })
+ );
+ expect(getByText(/Processing groups/)).toBeInTheDocument();
+ });
+
+ it('renders error message from getStatusContent', () => {
+ const { getByText } = render(
+ renderProgressCell({ type: 'error', error: 'Connection refused' })
+ );
+ expect(getByText('Connection refused')).toBeInTheDocument();
+ });
+
+ it('falls back to "Unknown error occurred" when error text is absent', () => {
+ const { getByText } = render(
+ renderProgressCell({ type: 'error', error: null })
+ );
+ expect(getByText('Unknown error occurred')).toBeInTheDocument();
+ });
+
+ it('returns "Idle" string when progress is 100', () => {
+ vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'downloading', progress: 100 });
+ const playlist = makePlaylist({ id: 1 });
+ setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 100 } } });
+ render( );
+ // progress === 100 → generateStatusString returns the string 'Idle'
+ const result = getCol('last_message').cell({
+ cell: { getValue: () => null },
+ row: { original: playlist },
+ });
+ // The status message cell bypasses progress when progress === 100, so
+ // generateStatusString is not called. Verify via the progress-bypass path:
+ // (this is tested via "bypasses progress UI when progress equals 100" already)
+ expect(result).toBeNull(); // last_message is null, progress=100 so falls through to null
+ });
+
+ it('renders content.label for default type', () => {
+ vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'default', label: 'Queued' });
+ const playlist = makePlaylist({ id: 1 });
+ setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 50 } } });
+ render( );
+ const { getByText } = render(
+ getCol('last_message').cell({
+ cell: { getValue: () => null },
+ row: { original: playlist },
+ })
+ );
+ expect(getByText('Queued')).toBeInTheDocument();
+ });
+
+ it('renders downloading timeRemaining when present', () => {
+ const { getByText } = render(
+ renderProgressCell({
+ type: 'downloading',
+ progress: 45,
+ speed: '1.2 MB/s',
+ timeRemaining: '30s left',
+ }, { progress: 45 })
+ );
+ expect(getByText('30s left')).toBeInTheDocument();
+ });
+
+ it('renders groups elapsedTime and groupsProcessed when present', () => {
+ const { getByText } = render(
+ renderProgressCell({
+ type: 'groups',
+ progress: 30,
+ elapsedTime: '4s',
+ groupsProcessed: '20/80',
+ }, { progress: 30 })
+ );
+ expect(getByText('4s')).toBeInTheDocument();
+ expect(getByText('20/80')).toBeInTheDocument();
+ });
+
+ it('renders parsing elapsedTime, timeRemaining and streamsProcessed when present', () => {
+ const { getByText } = render(
+ renderProgressCell({
+ type: 'parsing',
+ progress: 60,
+ elapsedTime: '10s',
+ timeRemaining: '5s',
+ streamsProcessed: '600/1000',
+ }, { progress: 60 })
+ );
+ expect(getByText('10s')).toBeInTheDocument();
+ expect(getByText('5s')).toBeInTheDocument();
+ expect(getByText('600/1000')).toBeInTheDocument();
+ });
+ });
+
+ // ── Status Message column – error / success styling ────────────────────────
+
+ describe('Status Message column – error/success text', () => {
+ it('renders error-styled text when status is "error"', () => {
+ setupMocks();
+ render( );
+ const playlist = makePlaylist({ id: 1, status: 'error' });
+ const { getByText } = render(
+ getCol('last_message').cell({
+ cell: { getValue: () => 'Parse failure' },
+ row: { original: playlist },
+ })
+ );
+ expect(getByText('Parse failure')).toBeInTheDocument();
+ });
+
+ it('renders success-styled text when status is "success"', () => {
+ setupMocks();
+ render( );
+ const playlist = makePlaylist({ id: 1, status: 'success' });
+ const { getByText } = render(
+ getCol('last_message').cell({
+ cell: { getValue: () => 'Loaded 500 streams' },
+ row: { original: playlist },
+ })
+ );
+ expect(getByText('Loaded 500 streams')).toBeInTheDocument();
+ });
+ });
+
+ // ── Server Groups modal ────────────────────────────────────────────────────
+
+ describe('Server Groups modal', () => {
+ it('renders the "Server Groups" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Server Groups')).toBeInTheDocument();
+ });
+
+ it('opens the Server Groups modal when "Server Groups" is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Server Groups'));
+ expect(screen.getByTestId('server-groups-modal')).toBeInTheDocument();
+ });
+
+ it('closes the Server Groups modal when onClose is called', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Server Groups'));
+ fireEvent.click(screen.getByTestId('server-groups-close'));
+ expect(screen.queryByTestId('server-groups-modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Max Streams column – inactive profiles excluded ────────────────────────
+
+ describe('Max Streams column – profile filtering', () => {
+ const renderMaxStreams = (playlist) => {
+ setupMocks({ playlists: [playlist] });
+ render( );
+ return getCol('max_streams').cell({ row: { original: playlist } });
+ };
+
+ it('excludes inactive profiles from the sum', () => {
+ // Need 2+ active profiles to trigger the sum branch; inactive ones must be ignored
+ const playlist = makePlaylist({
+ profiles: [
+ { name: 'A1', max_streams: 3, is_active: true },
+ { name: 'A2', max_streams: 5, is_active: true },
+ { name: 'Inactive', max_streams: 100, is_active: false },
+ ],
+ });
+ const { getByText } = render(renderMaxStreams(playlist));
+ // 3 + 5 = 8, not 3 + 5 + 100 = 108
+ expect(getByText('8')).toBeInTheDocument();
+ });
+
+ it('uses playlist max_streams directly when there is exactly one active profile', () => {
+ const playlist = makePlaylist({
+ max_streams: 7,
+ profiles: [{ name: 'Solo', max_streams: 7, is_active: true }],
+ });
+ const { getByText } = render(renderMaxStreams(playlist));
+ expect(getByText('7')).toBeInTheDocument();
+ });
+ });
+
+ // ── Expiration column – bold label when daysLeft ≤ 7 ──────────────────────
+
+ describe('Expiration column – bold label', () => {
+ it('renders with bold weight (600) when daysLeft is 7 or less', () => {
+ vi.mocked(DateTimeUtils.diff).mockReturnValue(5);
+ vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({
+ color: 'red.6',
+ label: '5d left',
+ });
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('earliest_expiration').cell({
+ cell: { getValue: () => '2024-06-06T00:00:00Z' },
+ row: { original: makePlaylist() },
+ })
+ );
+ const el = getByText('5d left');
+ // The Text mock passes `fw` as data-fw
+ expect(el).toHaveAttribute('data-fw', '600');
+ });
+
+ it('renders with normal weight (400) when daysLeft > 7', () => {
+ vi.mocked(DateTimeUtils.diff).mockReturnValue(30);
+ vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({
+ color: 'green.5',
+ label: '30d left',
+ });
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('earliest_expiration').cell({
+ cell: { getValue: () => '2024-07-01T00:00:00Z' },
+ row: { original: makePlaylist() },
+ })
+ );
+ expect(getByText('30d left')).toHaveAttribute('data-fw', '400');
+ });
+
+ it('passes all_expirations to getExpirationTooltip', () => {
+ const expirations = ['2024-12-01', '2025-01-01'];
+ setupMocks();
+ render( );
+ const playlist = makePlaylist({ all_expirations: expirations });
+ render(
+ getCol('earliest_expiration').cell({
+ cell: { getValue: () => '2024-12-01T00:00:00Z' },
+ row: { original: playlist },
+ })
+ );
+ expect(M3UsTableUtils.getExpirationTooltip).toHaveBeenCalledWith(
+ expirations,
+ expect.any(String),
+ expect.any(String)
+ );
+ });
+ });
+
+ // ── Table structure (useTable options) ────────────────────────────────────
+
+ describe('table structure', () => {
+ it('passes enablePagination: false to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enablePagination).toBe(false);
+ });
+
+ it('passes enableRowVirtualization: true to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enableRowVirtualization).toBe(true);
+ });
+
+ it('passes enableRowSelection: false to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enableRowSelection).toBe(false);
+ });
+
+ it('passes renderTopToolbar: false to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.renderTopToolbar).toBe(false);
+ });
+
+ it('passes manualSorting: true to useTable', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.manualSorting).toBe(true);
+ });
+
+ it('passes allRowIds as the playlist ids', () => {
+ const playlists = [
+ makePlaylist({ id: 10, name: 'A' }),
+ makePlaylist({ id: 20, name: 'B' }),
+ ];
+ setupMocks({ playlists });
+ render( );
+ expect(capturedTableOptions.allRowIds).toEqual(
+ expect.arrayContaining([10, 20])
+ );
+ });
+
+ it('actions column size is 75 in compact mode', () => {
+ setupMocks({ tableSize: 'compact' });
+ render( );
+ const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions');
+ expect(actionsCol.size).toBe(75);
+ });
+
+ it('actions column size is 100 in default mode', () => {
+ setupMocks({ tableSize: 'default' });
+ render( );
+ const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions');
+ expect(actionsCol.size).toBe(100);
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx
new file mode 100644
index 00000000..7ad3a002
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx
@@ -0,0 +1,229 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ makeHeaderCellRenderer,
+ makeSortingChangeHandler,
+} from '../M3uTableUtils';
+
+vi.mock('@mantine/core', () => ({
+ Center: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Text: ({ children, size, name }) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock('lucide-react', () => ({
+ ArrowUpDown: (props) => (
+
+ ),
+ ArrowUpNarrowWide: (props) => (
+
+ ),
+ ArrowDownWideNarrow: (props) => (
+
+ ),
+}));
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+/** Build a minimal header object that matches what TanStack Table provides */
+const makeHeader = ({ id = 'name', label = 'Name', sortable = true } = {}) => ({
+ id,
+ column: {
+ columnDef: {
+ header: label,
+ sortable,
+ },
+ },
+});
+
+// ── makeHeaderCellRenderer ───────────────────────────────────────────────────
+
+describe('makeHeaderCellRenderer', () => {
+ describe('with no active sort', () => {
+ const sorting = [];
+ let onSortingChange;
+ let renderHeader;
+
+ beforeEach(() => {
+ onSortingChange = vi.fn();
+ renderHeader = makeHeaderCellRenderer(sorting, onSortingChange);
+ });
+
+ it('renders the column label text', () => {
+ render(renderHeader(makeHeader({ label: 'Channel' })));
+ expect(screen.getByTestId('text')).toHaveTextContent('Channel');
+ });
+
+ it('renders the neutral ArrowUpDown icon when no sort is active', () => {
+ render(renderHeader(makeHeader()));
+ expect(screen.getByTestId('icon-arrow-up-down')).toBeInTheDocument();
+ });
+
+ it('does not render a sort icon when column is not sortable', () => {
+ render(renderHeader(makeHeader({ sortable: false })));
+ expect(
+ screen.queryByTestId('icon-arrow-up-down')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('icon-arrow-up-narrow-wide')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('icon-arrow-down-wide-narrow')
+ ).not.toBeInTheDocument();
+ });
+
+ it('calls onSortingChange with the header id when icon is clicked', () => {
+ render(renderHeader(makeHeader({ id: 'title' })));
+ fireEvent.click(screen.getByTestId('icon-arrow-up-down'));
+ expect(onSortingChange).toHaveBeenCalledTimes(1);
+ expect(onSortingChange).toHaveBeenCalledWith('title');
+ });
+
+ it('sets the data-name attribute on the Text element to the header id', () => {
+ render(renderHeader(makeHeader({ id: 'status' })));
+ expect(screen.getByTestId('text')).toHaveAttribute('data-name', 'status');
+ });
+ });
+
+ describe('when sorting asc on the current column (desc: false)', () => {
+ it('renders ArrowUpNarrowWide icon', () => {
+ const sorting = [{ id: 'name', desc: false }];
+ const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
+ render(renderHeader(makeHeader({ id: 'name' })));
+ expect(
+ screen.getByTestId('icon-arrow-up-narrow-wide')
+ ).toBeInTheDocument();
+ });
+
+ it('does not render ArrowDownWideNarrow or ArrowUpDown', () => {
+ const sorting = [{ id: 'name', desc: false }];
+ const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
+ render(renderHeader(makeHeader({ id: 'name' })));
+ expect(
+ screen.queryByTestId('icon-arrow-down-wide-narrow')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('icon-arrow-up-down')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('when sorting desc on the current column (desc: true)', () => {
+ it('renders ArrowDownWideNarrow icon', () => {
+ const sorting = [{ id: 'name', desc: true }];
+ const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
+ render(renderHeader(makeHeader({ id: 'name' })));
+ expect(
+ screen.getByTestId('icon-arrow-down-wide-narrow')
+ ).toBeInTheDocument();
+ });
+
+ it('does not render ArrowUpNarrowWide or ArrowUpDown', () => {
+ const sorting = [{ id: 'name', desc: true }];
+ const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
+ render(renderHeader(makeHeader({ id: 'name' })));
+ expect(
+ screen.queryByTestId('icon-arrow-up-narrow-wide')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('icon-arrow-up-down')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('when a different column is sorted', () => {
+ it('renders the neutral ArrowUpDown icon for the unsorted column', () => {
+ const sorting = [{ id: 'status', desc: false }];
+ const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
+ render(renderHeader(makeHeader({ id: 'name' })));
+ expect(screen.getByTestId('icon-arrow-up-down')).toBeInTheDocument();
+ });
+ });
+});
+
+// ── makeSortingChangeHandler ─────────────────────────────────────────────────
+
+describe('makeSortingChangeHandler', () => {
+ let setSorting;
+ let onDataSort;
+
+ beforeEach(() => {
+ setSorting = vi.fn();
+ onDataSort = vi.fn();
+ });
+
+ describe('first click on a new column', () => {
+ it('sets ascending sort (desc: false)', () => {
+ const handler = makeSortingChangeHandler([], setSorting, onDataSort);
+ handler('name');
+ expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: false }]);
+ });
+
+ it('calls onDataSort with the column and desc: false', () => {
+ const handler = makeSortingChangeHandler([], setSorting, onDataSort);
+ handler('name');
+ expect(onDataSort).toHaveBeenCalledWith('name', false);
+ });
+ });
+
+ describe('second click on the same column (currently asc)', () => {
+ it('sets descending sort (desc: true)', () => {
+ const sorting = [{ id: 'name', desc: false }];
+ const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
+ handler('name');
+ expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: true }]);
+ });
+
+ it('calls onDataSort with the column and desc: true', () => {
+ const sorting = [{ id: 'name', desc: false }];
+ const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
+ handler('name');
+ expect(onDataSort).toHaveBeenCalledWith('name', true);
+ });
+ });
+
+ describe('third click on the same column (currently desc) → clears sort', () => {
+ it('sets sorting to an empty array', () => {
+ const sorting = [{ id: 'name', desc: true }];
+ const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
+ handler('name');
+ expect(setSorting).toHaveBeenCalledWith([]);
+ });
+
+ it('does NOT call onDataSort when sorting is cleared', () => {
+ const sorting = [{ id: 'name', desc: true }];
+ const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
+ handler('name');
+ expect(onDataSort).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('switching to a different column while another is sorted', () => {
+ it('resets to ascending sort on the new column', () => {
+ const sorting = [{ id: 'status', desc: true }];
+ const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
+ handler('name');
+ expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: false }]);
+ });
+
+ it('calls onDataSort with the new column and desc: false', () => {
+ const sorting = [{ id: 'status', desc: true }];
+ const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
+ handler('name');
+ expect(onDataSort).toHaveBeenCalledWith('name', false);
+ });
+ });
+
+ describe('always calls setSorting', () => {
+ it('calls setSorting exactly once per handler invocation', () => {
+ const handler = makeSortingChangeHandler([], setSorting, onDataSort);
+ handler('title');
+ expect(setSorting).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx b/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx
new file mode 100644
index 00000000..47e66324
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx
@@ -0,0 +1,524 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() }));
+
+// ── Hook mocks ─────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(() => ['default', vi.fn()]),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/tables/OutputProfilesTableUtils.js', () => ({
+ deleteOutputProfile: vi.fn().mockResolvedValue(undefined),
+ updateOutputProfile: vi.fn().mockResolvedValue(undefined),
+}));
+
+// ── Child component mocks ──────────────────────────────────────────────────────
+vi.mock('../../forms/OutputProfile', () => ({
+ default: ({ isOpen, onClose, profile }) =>
+ isOpen ? (
+
+ {profile?.name ?? 'new'}
+
+ Close
+
+
+ ) : null,
+}));
+
+vi.mock('../CustomTable', () => ({
+ CustomTable: () =>
,
+ useTable: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, style }) => {children}
,
+ Button: ({ children, onClick, leftSection, disabled, loading }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Center: ({ children }) => {children}
,
+ Flex: ({ children }) => {children}
,
+ Paper: ({ children, style }) => {children}
,
+ Stack: ({ children, style }) => {children}
,
+ Switch: ({ checked, onChange, disabled }) => (
+
+ ),
+ Text: ({ children, size, style }) => (
+
+ {children}
+
+ ),
+ Tooltip: ({ children, label }) => (
+ {children}
+ ),
+ useMantineTheme: vi.fn(() => ({
+ palette: { background: { paper: '#1a1a1a' } },
+ })),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Eye: () => ,
+ EyeOff: () => ,
+ SquareMinus: () => ,
+ SquarePen: () => ,
+ SquarePlus: () => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useOutputProfilesStore from '../../../store/outputProfiles';
+import useLocalStorage from '../../../hooks/useLocalStorage';
+import * as OutputProfilesTableUtils from '../../../utils/tables/OutputProfilesTableUtils.js';
+import { useTable } from '../CustomTable';
+import OutputProfiles from '../OutputProfilesTable';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeProfile = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Profile',
+ command: 'ffmpeg',
+ parameters: '-c:v copy',
+ is_active: true,
+ locked: false,
+ ...overrides,
+});
+
+let capturedTableOptions = null;
+
+const setupMocks = ({
+ profiles = [makeProfile()],
+ tableSize = 'default',
+} = {}) => {
+ vi.mocked(useOutputProfilesStore).mockImplementation((sel) =>
+ sel({ profiles })
+ );
+
+ vi.mocked(useLocalStorage).mockReturnValue([tableSize, vi.fn()]);
+
+ vi.mocked(useTable).mockImplementation((opts) => {
+ capturedTableOptions = opts;
+ return {
+ getRowModel: () => ({ rows: [] }),
+ getHeaderGroups: () => [],
+ };
+ });
+};
+
+const getCol = (keyOrId) =>
+ capturedTableOptions.columns.find(
+ (c) => c.accessorKey === keyOrId || c.id === keyOrId
+ );
+
+const makeRowCtx = (profile) => ({
+ row: { id: String(profile.id), original: profile },
+ cell: { column: { id: 'actions', columnDef: {} }, getValue: vi.fn(() => undefined) },
+});
+
+// ══════════════════════════════════════════════════════════════════════════════
+// Tests
+// ══════════════════════════════════════════════════════════════════════════════
+
+describe('OutputProfiles', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedTableOptions = null;
+ vi.mocked(OutputProfilesTableUtils.deleteOutputProfile).mockResolvedValue(undefined);
+ vi.mocked(OutputProfilesTableUtils.updateOutputProfile).mockResolvedValue(undefined);
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the "Add Output Profile" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Add Output Profile')).toBeInTheDocument();
+ });
+
+ it('renders the hide/show inactive toggle button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
+ });
+
+ it('renders the custom table', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('does not render the form on initial load', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument();
+ });
+
+ it('passes all unlocked+active profiles to useTable when hideInactive is false', () => {
+ setupMocks({
+ profiles: [
+ makeProfile({ id: 1, name: 'Active', is_active: true }),
+ makeProfile({ id: 2, name: 'Inactive', is_active: false }),
+ ],
+ });
+ render( );
+ expect(capturedTableOptions.data).toHaveLength(2);
+ });
+ });
+
+ // ── Add Output Profile ─────────────────────────────────────────────────────
+
+ describe('Add Output Profile', () => {
+ it('opens the form with no profile when "Add Output Profile" is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Output Profile'));
+ expect(screen.getByTestId('output-profile-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-profile-name')).toHaveTextContent('new');
+ });
+
+ it('closes the form when onClose is called', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Output Profile'));
+ fireEvent.click(screen.getByTestId('form-close'));
+ expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Edit Output Profile (RowActions) ───────────────────────────────────────
+
+ describe('edit profile via RowActions', () => {
+ it('opens the form populated with the profile when edit icon is clicked', () => {
+ const profile = makeProfile({ name: 'My Profile' });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+
+ expect(screen.getByTestId('output-profile-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-profile-name')).toHaveTextContent('My Profile');
+ });
+
+ it('closes the form after editing when onClose is called', () => {
+ const profile = makeProfile({ name: 'My Profile' });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+ fireEvent.click(screen.getByTestId('form-close'));
+
+ expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument();
+ });
+
+ it('edit button is disabled when profile is locked', () => {
+ const profile = makeProfile({ locked: true });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled();
+ });
+
+ it('edit button is enabled when profile is not locked', () => {
+ const profile = makeProfile({ locked: false });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled();
+ });
+ });
+
+ // ── Delete Output Profile (RowActions) ────────────────────────────────────
+
+ describe('delete profile via RowActions', () => {
+ it('calls deleteOutputProfile with the profile id when delete icon is clicked', async () => {
+ const profile = makeProfile({ id: 7 });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+
+ await waitFor(() =>
+ expect(OutputProfilesTableUtils.deleteOutputProfile).toHaveBeenCalledWith(7)
+ );
+ });
+
+ it('delete button is disabled when profile is locked', () => {
+ const profile = makeProfile({ locked: true });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled();
+ });
+
+ it('delete button is enabled when profile is not locked', () => {
+ const profile = makeProfile({ locked: false });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled();
+ });
+
+ it('does not throw when deleteOutputProfile rejects', async () => {
+ vi.mocked(OutputProfilesTableUtils.deleteOutputProfile).mockRejectedValue(
+ new Error('server error')
+ );
+ const profile = makeProfile({ id: 1 });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ await expect(
+ act(async () => fireEvent.click(getByTestId('icon-square-minus').closest('button')))
+ ).resolves.not.toThrow();
+ });
+ });
+
+ // ── Toggle active (is_active Switch) ──────────────────────────────────────
+
+ describe('toggle profile is_active', () => {
+ const renderSwitch = (profile) => {
+ const col = getCol('is_active');
+ return col.cell({
+ cell: { getValue: () => profile.is_active },
+ row: { original: profile },
+ });
+ };
+
+ it('calls updateOutputProfile with is_active toggled to false', async () => {
+ const profile = makeProfile({ is_active: true });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(profile));
+ fireEvent.click(getByTestId('active-switch'));
+
+ await waitFor(() =>
+ expect(OutputProfilesTableUtils.updateOutputProfile).toHaveBeenCalledWith(
+ expect.objectContaining({ id: profile.id, is_active: false })
+ )
+ );
+ });
+
+ it('calls updateOutputProfile with is_active toggled to true', async () => {
+ const profile = makeProfile({ is_active: false });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(profile));
+ fireEvent.click(getByTestId('active-switch'));
+
+ await waitFor(() =>
+ expect(OutputProfilesTableUtils.updateOutputProfile).toHaveBeenCalledWith(
+ expect.objectContaining({ id: profile.id, is_active: true })
+ )
+ );
+ });
+
+ it('switch is disabled when profile is locked', () => {
+ const profile = makeProfile({ locked: true });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(profile));
+ expect(getByTestId('active-switch')).toBeDisabled();
+ });
+
+ it('switch is enabled when profile is not locked', () => {
+ const profile = makeProfile({ locked: false });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(profile));
+ expect(getByTestId('active-switch')).not.toBeDisabled();
+ });
+ });
+
+ // ── Hide inactive toggle ───────────────────────────────────────────────────
+
+ describe('hide inactive toggle', () => {
+ it('shows Eye icon when hideInactive is false (default)', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
+ expect(screen.queryByTestId('icon-eye-off')).not.toBeInTheDocument();
+ });
+
+ it('shows EyeOff icon after the toggle is clicked', () => {
+ setupMocks();
+ render( );
+ const toggleBtn = screen.getByTestId('icon-eye').closest('button');
+ fireEvent.click(toggleBtn);
+ expect(screen.getByTestId('icon-eye-off')).toBeInTheDocument();
+ expect(screen.queryByTestId('icon-eye')).not.toBeInTheDocument();
+ });
+
+ it('shows Eye icon again after toggling twice', () => {
+ setupMocks();
+ render( );
+ const toggleBtn = screen.getByTestId('icon-eye').closest('button');
+ fireEvent.click(toggleBtn);
+ fireEvent.click(screen.getByTestId('icon-eye-off').closest('button'));
+ expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
+ });
+
+ it('excludes inactive profiles from table data when hideInactive is true', () => {
+ setupMocks({
+ profiles: [
+ makeProfile({ id: 1, name: 'Active', is_active: true }),
+ makeProfile({ id: 2, name: 'Inactive', is_active: false }),
+ ],
+ });
+ render( );
+
+ fireEvent.click(screen.getByTestId('icon-eye').closest('button'));
+
+ expect(capturedTableOptions.data).toHaveLength(1);
+ expect(capturedTableOptions.data[0].name).toBe('Active');
+ });
+
+ it('restores all profiles when hideInactive is turned back off', () => {
+ setupMocks({
+ profiles: [
+ makeProfile({ id: 1, name: 'Active', is_active: true }),
+ makeProfile({ id: 2, name: 'Inactive', is_active: false }),
+ ],
+ });
+ render( );
+
+ const toggleBtn = screen.getByTestId('icon-eye').closest('button');
+ fireEvent.click(toggleBtn); // hide inactive
+ fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); // show all
+
+ expect(capturedTableOptions.data).toHaveLength(2);
+ });
+
+ it('does not filter already-active profiles when hideInactive is true', () => {
+ setupMocks({
+ profiles: [
+ makeProfile({ id: 1, is_active: true }),
+ makeProfile({ id: 2, is_active: true }),
+ ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('icon-eye').closest('button'));
+ expect(capturedTableOptions.data).toHaveLength(2);
+ });
+ });
+
+ // ── Column: Name ───────────────────────────────────────────────────────────
+
+ describe('Name column', () => {
+ it('renders the profile name', () => {
+ setupMocks();
+ render( );
+ const col = getCol('name');
+ const { getByText } = render(
+ col.cell({ cell: { getValue: () => 'My Profile' } })
+ );
+ expect(getByText('My Profile')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column: Command ────────────────────────────────────────────────────────
+
+ describe('Command column', () => {
+ it('renders the command value', () => {
+ setupMocks();
+ render( );
+ const col = getCol('command');
+ const { getByText } = render(
+ col.cell({ cell: { getValue: () => 'ffmpeg' } })
+ );
+ expect(getByText('ffmpeg')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column: Parameters ─────────────────────────────────────────────────────
+
+ describe('Parameters column', () => {
+ it('renders the parameters value', () => {
+ setupMocks();
+ render( );
+ const col = getCol('parameters');
+ const { getByText } = render(
+ col.cell({ cell: { getValue: () => '-c:v copy -preset fast' } })
+ );
+ expect(getByText('-c:v copy -preset fast')).toBeInTheDocument();
+ });
+ });
+
+ // ── Store reactivity ───────────────────────────────────────────────────────
+
+ describe('store reactivity', () => {
+ it('passes store profiles directly to the table data', () => {
+ const profiles = [
+ makeProfile({ id: 1, name: 'Alpha' }),
+ makeProfile({ id: 2, name: 'Beta' }),
+ ];
+ setupMocks({ profiles });
+ render( );
+ expect(capturedTableOptions.data).toHaveLength(2);
+ expect(capturedTableOptions.data.map((p) => p.name)).toEqual(['Alpha', 'Beta']);
+ });
+
+ it('passes an empty array to the table when no profiles exist', () => {
+ setupMocks({ profiles: [] });
+ render( );
+ expect(capturedTableOptions.data).toHaveLength(0);
+ });
+ });
+});
diff --git a/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx b/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx
new file mode 100644
index 00000000..e13db683
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx
@@ -0,0 +1,540 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api', () => ({
+ default: {
+ deleteStreamProfile: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/streamProfiles', () => ({ default: vi.fn() }));
+vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
+
+// ── Hook mocks ─────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(() => ['default', vi.fn()]),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
+ updateStreamProfile: vi.fn().mockResolvedValue(undefined),
+}));
+
+// ── Child component mocks ──────────────────────────────────────────────────────
+vi.mock('../../forms/StreamProfile', () => ({
+ default: ({ isOpen, onClose, profile }) =>
+ isOpen ? (
+
+ {profile?.name ?? 'new'}
+
+ Close
+
+
+ ) : null,
+}));
+
+vi.mock('../CustomTable', () => ({
+ CustomTable: () =>
,
+ useTable: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, style }) => {children}
,
+ Button: ({ children, onClick, leftSection, disabled, loading }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Center: ({ children }) => {children}
,
+ Flex: ({ children }) => {children}
,
+ Paper: ({ children, style }) => {children}
,
+ Stack: ({ children, style }) => {children}
,
+ Switch: ({ checked, onChange, disabled }) => (
+
+ ),
+ Text: ({ children, name }) => (
+
+ {children}
+
+ ),
+ Tooltip: ({ children, label }) => (
+ {children}
+ ),
+ useMantineTheme: vi.fn(() => ({
+ palette: { background: { paper: '#1a1a1a' } },
+ })),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Eye: () => ,
+ EyeOff: () => ,
+ SquareMinus: () => ,
+ SquarePen: () => ,
+ SquarePlus: () => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useStreamProfilesStore from '../../../store/streamProfiles';
+import useSettingsStore from '../../../store/settings';
+import { useTable } from '../CustomTable';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import { updateStreamProfile } from '../../../utils/forms/StreamProfileUtils.js';
+import API from '../../../api';
+import StreamProfiles from '../StreamProfilesTable';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeProfile = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Profile',
+ command: 'ffmpeg',
+ parameters: '-c copy',
+ is_active: true,
+ locked: false,
+ ...overrides,
+});
+
+let capturedTableOptions = null;
+
+const setupMocks = ({
+ profiles = [makeProfile()],
+ defaultProfileId = 99,
+} = {}) => {
+ vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
+ sel({ profiles })
+ );
+
+ vi.mocked(useSettingsStore).mockImplementation((sel) =>
+ sel({ settings: { default_stream_profile: defaultProfileId } })
+ );
+
+ vi.mocked(useTable).mockImplementation((opts) => {
+ capturedTableOptions = opts;
+ return {
+ getRowModel: () => ({ rows: [] }),
+ getHeaderGroups: () => [],
+ };
+ });
+};
+
+const getCol = (keyOrId) =>
+ capturedTableOptions.columns.find(
+ (c) => c.accessorKey === keyOrId || c.id === keyOrId
+ );
+
+const makeRowCtx = (profile) => ({
+ row: { id: String(profile.id), original: profile },
+ cell: {
+ column: { id: 'actions', columnDef: {} },
+ getValue: vi.fn(() => undefined),
+ },
+});
+
+// ══════════════════════════════════════════════════════════════════════════════
+// Tests
+// ══════════════════════════════════════════════════════════════════════════════
+
+describe('StreamProfiles', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedTableOptions = null;
+ vi.mocked(API.deleteStreamProfile).mockResolvedValue(undefined);
+ vi.mocked(updateStreamProfile).mockResolvedValue(undefined);
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the "Add Stream Profile" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Add Stream Profile')).toBeInTheDocument();
+ });
+
+ it('renders the hide/show inactive toggle button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
+ });
+
+ it('renders the custom table', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('does not render the form on initial load', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument();
+ });
+
+ it('passes all profiles to useTable when hideInactive is false', () => {
+ setupMocks({
+ profiles: [
+ makeProfile({ id: 1, name: 'Active', is_active: true }),
+ makeProfile({ id: 2, name: 'Inactive', is_active: false }),
+ ],
+ });
+ render( );
+ expect(capturedTableOptions.data).toHaveLength(2);
+ });
+ });
+
+ // ── Add Stream Profile ─────────────────────────────────────────────────────
+
+ describe('Add Stream Profile', () => {
+ it('opens the form with no profile when "Add Stream Profile" is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Stream Profile'));
+ expect(screen.getByTestId('stream-profile-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-profile-name')).toHaveTextContent('new');
+ });
+
+ it('closes the form when onClose is called', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add Stream Profile'));
+ fireEvent.click(screen.getByTestId('form-close'));
+ expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Edit profile via RowActions ────────────────────────────────────────────
+
+ describe('edit profile via RowActions', () => {
+ it('opens the form populated with the profile when edit icon is clicked', () => {
+ const profile = makeProfile({ name: 'My Profile' });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+
+ expect(screen.getByTestId('stream-profile-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-profile-name')).toHaveTextContent('My Profile');
+ });
+
+ it('closes the form after editing when onClose is called', () => {
+ const profile = makeProfile({ name: 'My Profile' });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+ fireEvent.click(screen.getByTestId('form-close'));
+
+ expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument();
+ });
+
+ it('edit button is disabled when profile is locked', () => {
+ const profile = makeProfile({ locked: true });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled();
+ });
+
+ it('edit button is enabled when profile is not locked', () => {
+ const profile = makeProfile({ locked: false });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled();
+ });
+ });
+
+ // ── Delete profile via RowActions ──────────────────────────────────────────
+
+ describe('delete profile via RowActions', () => {
+ it('calls API.deleteStreamProfile with the profile id when delete icon is clicked', async () => {
+ const profile = makeProfile({ id: 7 });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+
+ await waitFor(() =>
+ expect(API.deleteStreamProfile).toHaveBeenCalledWith(7)
+ );
+ });
+
+ it('shows a notification and does NOT call API when deleting the default profile', async () => {
+ const profile = makeProfile({ id: 5 });
+ setupMocks({ profiles: [profile], defaultProfileId: 5 });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Cannot delete default stream-profile',
+ color: 'red.5',
+ })
+ )
+ );
+ expect(API.deleteStreamProfile).not.toHaveBeenCalled();
+ });
+
+ it('delete button is disabled when profile is locked', () => {
+ const profile = makeProfile({ locked: true });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled();
+ });
+
+ it('delete button is enabled when profile is not locked', () => {
+ const profile = makeProfile({ locked: false });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { row, cell } = makeRowCtx(profile);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled();
+ });
+ });
+
+ // ── Toggle profile is_active ───────────────────────────────────────────────
+
+ describe('toggle profile is_active', () => {
+ const renderSwitch = (profile) => {
+ const col = getCol('is_active');
+ return col.cell({
+ cell: { getValue: () => profile.is_active },
+ row: { original: profile },
+ });
+ };
+
+ it('calls updateStreamProfile with is_active toggled to false', async () => {
+ const profile = makeProfile({ is_active: true });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(profile));
+ fireEvent.click(getByTestId('active-switch'));
+
+ await waitFor(() =>
+ expect(updateStreamProfile).toHaveBeenCalledWith(
+ profile.id,
+ expect.objectContaining({ id: profile.id, is_active: false })
+ )
+ );
+ });
+
+ it('calls updateStreamProfile with is_active toggled to true', async () => {
+ const profile = makeProfile({ is_active: false });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(profile));
+ fireEvent.click(getByTestId('active-switch'));
+
+ await waitFor(() =>
+ expect(updateStreamProfile).toHaveBeenCalledWith(
+ profile.id,
+ expect.objectContaining({ id: profile.id, is_active: true })
+ )
+ );
+ });
+
+ it('switch is disabled when profile is locked', () => {
+ const profile = makeProfile({ locked: true });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(profile));
+ expect(getByTestId('active-switch')).toBeDisabled();
+ });
+
+ it('switch is enabled when profile is not locked', () => {
+ const profile = makeProfile({ locked: false });
+ setupMocks({ profiles: [profile] });
+ render( );
+
+ const { getByTestId } = render(renderSwitch(profile));
+ expect(getByTestId('active-switch')).not.toBeDisabled();
+ });
+ });
+
+ // ── Hide inactive toggle ───────────────────────────────────────────────────
+
+ describe('hide inactive toggle', () => {
+ it('shows Eye icon when hideInactive is false (default)', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
+ expect(screen.queryByTestId('icon-eye-off')).not.toBeInTheDocument();
+ });
+
+ it('shows EyeOff icon after the toggle is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByTestId('icon-eye').closest('button'));
+ expect(screen.getByTestId('icon-eye-off')).toBeInTheDocument();
+ expect(screen.queryByTestId('icon-eye')).not.toBeInTheDocument();
+ });
+
+ it('shows Eye icon again after toggling twice', () => {
+ setupMocks();
+ render( );
+ const btn = screen.getByTestId('icon-eye').closest('button');
+ fireEvent.click(btn);
+ fireEvent.click(screen.getByTestId('icon-eye-off').closest('button'));
+ expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
+ });
+
+ it('excludes inactive profiles from table data when hideInactive is true', () => {
+ setupMocks({
+ profiles: [
+ makeProfile({ id: 1, is_active: true }),
+ makeProfile({ id: 2, is_active: false }),
+ ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('icon-eye').closest('button'));
+ expect(capturedTableOptions.data).toHaveLength(1);
+ expect(capturedTableOptions.data[0].id).toBe(1);
+ });
+
+ it('restores all profiles when hideInactive is turned back off', () => {
+ setupMocks({
+ profiles: [
+ makeProfile({ id: 1, is_active: true }),
+ makeProfile({ id: 2, is_active: false }),
+ ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('icon-eye').closest('button'));
+ expect(capturedTableOptions.data).toHaveLength(1);
+ fireEvent.click(screen.getByTestId('icon-eye-off').closest('button'));
+ expect(capturedTableOptions.data).toHaveLength(2);
+ });
+
+ it('does not filter already-active profiles when hideInactive is true', () => {
+ setupMocks({
+ profiles: [
+ makeProfile({ id: 1, is_active: true }),
+ makeProfile({ id: 2, is_active: true }),
+ ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('icon-eye').closest('button'));
+ expect(capturedTableOptions.data).toHaveLength(2);
+ });
+ });
+
+ // ── Column cell renderers ──────────────────────────────────────────────────
+
+ describe('Name column', () => {
+ it('renders the profile name', () => {
+ setupMocks();
+ render( );
+ const col = getCol('name');
+ const { getByText } = render(
+ col.cell({ cell: { getValue: () => 'My Encoder' } })
+ );
+ expect(getByText('My Encoder')).toBeInTheDocument();
+ });
+ });
+
+ describe('Command column', () => {
+ it('renders the command value', () => {
+ setupMocks();
+ render( );
+ const col = getCol('command');
+ const { getByText } = render(
+ col.cell({ cell: { getValue: () => 'ffmpeg' } })
+ );
+ expect(getByText('ffmpeg')).toBeInTheDocument();
+ });
+ });
+
+ describe('Parameters column', () => {
+ it('renders the parameters value inside a Tooltip', () => {
+ setupMocks();
+ render( );
+ const col = getCol('parameters');
+ const { getByText } = render(
+ col.cell({ cell: { getValue: () => '-c copy -f mpegts' } })
+ );
+ expect(getByText('-c copy -f mpegts')).toBeInTheDocument();
+ });
+ });
+
+ // ── Store reactivity ───────────────────────────────────────────────────────
+
+ describe('store reactivity', () => {
+ it('passes store profiles directly to the table data', () => {
+ const profiles = [
+ makeProfile({ id: 1, name: 'P1' }),
+ makeProfile({ id: 2, name: 'P2' }),
+ makeProfile({ id: 3, name: 'P3' }),
+ ];
+ setupMocks({ profiles });
+ render( );
+ expect(capturedTableOptions.data).toHaveLength(3);
+ });
+
+ it('passes an empty array to the table when no profiles exist', () => {
+ setupMocks({ profiles: [] });
+ render( );
+ expect(capturedTableOptions.data).toHaveLength(0);
+ });
+ });
+});
diff --git a/frontend/src/components/tables/__tests__/StreamsTable.test.jsx b/frontend/src/components/tables/__tests__/StreamsTable.test.jsx
new file mode 100644
index 00000000..84554ca7
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/StreamsTable.test.jsx
@@ -0,0 +1,833 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/playlists', () => ({ default: vi.fn() }));
+vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
+vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
+vi.mock('../../../store/useVideoStore', () => ({ default: vi.fn() }));
+vi.mock('../../../store/channelsTable', () => ({ default: vi.fn() }));
+vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
+vi.mock('../../../store/streamsTable', () => ({ default: vi.fn() }));
+
+// ── Hook mocks ─────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(() => [{}, vi.fn()]),
+}));
+
+// ── Router mock ────────────────────────────────────────────────────────────────
+vi.mock('react-router-dom', () => ({
+ useNavigate: vi.fn(() => vi.fn()),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils', () => ({
+ copyToClipboard: vi.fn().mockResolvedValue(undefined),
+ useDebounce: vi.fn((value) => value),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/components/FloatingVideoUtils.js', () => ({
+ buildLiveStreamUrl: vi.fn((path) => path),
+}));
+
+vi.mock('../../../utils/forms/ChannelUtils.js', () => ({
+ requeryChannels: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock('../../../utils/tables/StreamsTableUtils.js', () => ({
+ addStreamsToChannel: vi.fn().mockResolvedValue(undefined),
+ appendFetchPageParams: vi.fn(),
+ createChannelFromStream: vi.fn().mockResolvedValue(undefined),
+ createChannelsFromStreamsAsync: vi.fn().mockResolvedValue({ task_id: 'task-1', stream_count: 1 }),
+ deleteStream: vi.fn().mockResolvedValue(undefined),
+ deleteStreams: vi.fn().mockResolvedValue(undefined),
+ getAllStreamIds: vi.fn().mockResolvedValue([]),
+ getChannelNumberValue: vi.fn((mode) => mode === 'provider' ? null : 1),
+ getChannelProfileIds: vi.fn((profileIds) => profileIds),
+ getFilterParams: vi.fn(() => new URLSearchParams()),
+ getStatsTooltip: vi.fn(() => ({ compactDisplay: '1080p', tooltipContent: '1920x1080' })),
+ getStreamFilterOptions: vi.fn().mockResolvedValue({ groups: [], m3u_accounts: [] }),
+ getStreams: vi.fn().mockResolvedValue([]),
+ queryStreamsTable: vi.fn().mockResolvedValue({ count: 5, results: [] }),
+ requeryStreams: vi.fn().mockResolvedValue(undefined),
+}));
+
+// ── Child component mocks ──────────────────────────────────────────────────────
+vi.mock('../../forms/Stream', () => ({
+ default: ({ isOpen, onClose, stream }) =>
+ isOpen ? (
+
+ {stream?.name ?? 'new'}
+
+ Close
+
+
+ ) : null,
+}));
+
+vi.mock('../../ConfirmationDialog', () => ({
+ default: ({ opened, onClose, onConfirm, title, message, confirmLabel, cancelLabel, loading }) =>
+ opened ? (
+
+ {title}
+ {typeof message === 'string' ? message : 'message'}
+
+ {confirmLabel}
+
+
+ {cancelLabel}
+
+
+ ) : null,
+}));
+
+vi.mock('../../modals/CreateChannelModal', () => ({
+ default: ({ opened, onClose, onConfirm }) =>
+ opened ? (
+
+
+ Confirm
+
+
+ Close
+
+
+ ) : null,
+}));
+
+vi.mock('../CustomTable', () => ({
+ CustomTable: () =>
,
+ useTable: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, style }) => {children}
,
+ Button: ({ children, onClick, leftSection, disabled, loading }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Card: ({ children, style }) => (
+ {children}
+ ),
+ Center: ({ children, style }) => {children}
,
+ Divider: ({ label }) => ,
+ Flex: ({ children, style }) => (
+ {children}
+ ),
+ Group: ({ children, style }) => {children}
,
+ LoadingOverlay: ({ visible }) => visible ?
: null,
+ Menu: Object.assign(
+ ({ children }) => {children}
,
+ {
+ Target: ({ children }) => {children}
,
+ Dropdown: ({ children }) => {children}
,
+ Label: ({ children }) => {children}
,
+ Item: ({ children, onClick, leftSection }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Divider: () => ,
+ }
+ ),
+ MenuDivider: () => ,
+ MenuDropdown: ({ children }) => {children}
,
+ MenuItem: ({ children, onClick, leftSection }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ MenuLabel: ({ children }) => {children}
,
+ MenuTarget: ({ children }) => {children}
,
+ MultiSelect: ({ onChange, value, data }) => (
+ onChange && onChange([e.target.value])} value={value}>
+ {(data || []).map((d) => (
+ {d.label ?? d}
+ ))}
+
+ ),
+ NativeSelect: ({ onChange, value, data }) => (
+
+ {(data || []).map((d) => (
+ {d}
+ ))}
+
+ ),
+ Pagination: ({ total, value, onChange }) => (
+
+ onChange && onChange(value - 1)} disabled={value <= 1}>
+ Prev
+
+ {value}
+ onChange && onChange(value + 1)} disabled={value >= total}>
+ Next
+
+
+ ),
+ Paper: ({ children, style }) => {children}
,
+ Stack: ({ children, style }) => {children}
,
+ Text: ({ children, style }) => (
+ {children}
+ ),
+ TextInput: ({ onChange, value, placeholder }) => (
+
+ ),
+ Title: ({ children, style }) => {children} ,
+ Tooltip: ({ children, label }) => (
+ {children}
+ ),
+ UnstyledButton: ({ children, onClick }) => (
+ {children}
+ ),
+ useMantineTheme: vi.fn(() => ({
+ tailwind: { blue: { 6: '#3b82f6' }, green: { 5: '#22c55e' }, yellow: { 3: '#fde047' } },
+ palette: { background: { paper: '#1a1a1a' } },
+ colors: {},
+ })),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ ArrowDownWideNarrow: () => ,
+ ArrowUpDown: () => ,
+ ArrowUpNarrowWide: () => ,
+ Copy: () => ,
+ EllipsisVertical: () => ,
+ Eye: () => ,
+ EyeOff: () => ,
+ Filter: () => ,
+ ListPlus: () => ,
+ RotateCcw: () => ,
+ Search: () => ,
+ Square: () => ,
+ SquareCheck: () => ,
+ SquareMinus: () => ,
+ SquarePlus: () => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import usePlaylistsStore from '../../../store/playlists';
+import useChannelsStore from '../../../store/channels';
+import useSettingsStore from '../../../store/settings';
+import useVideoStore from '../../../store/useVideoStore';
+import useChannelsTableStore from '../../../store/channelsTable';
+import useWarningsStore from '../../../store/warnings';
+import useStreamsTableStore from '../../../store/streamsTable';
+import useLocalStorage from '../../../hooks/useLocalStorage';
+import { useNavigate } from 'react-router-dom';
+import { useTable } from '../CustomTable';
+import * as StreamsTableUtils from '../../../utils/tables/StreamsTableUtils.js';
+import StreamsTable from '../StreamsTable';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeStream = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Stream',
+ url: 'http://example.com/stream',
+ stream_hash: 'abc123',
+ channel_group: 'group-1',
+ m3u_account: 10,
+ tvg_id: 'tvg-1',
+ stream_stats: null,
+ is_custom: true,
+ is_stale: false,
+ ...overrides,
+});
+
+let capturedTableOptions = null;
+
+const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 50 };
+const DEFAULT_SORTING = [{ id: 'name', desc: false }];
+
+const setupMocks = ({
+ streams = [makeStream()],
+ pageCount = 1,
+ totalCount = 1,
+ allQueryIds = [1],
+ pagination = DEFAULT_PAGINATION,
+ sorting = DEFAULT_SORTING,
+ selectedStreamIds = [],
+ playlists = [{ id: 10, name: 'My M3U' }],
+ channelGroups = { 'group-1': { name: 'Sports' } },
+ expandedChannelId = null,
+ selectedChannelIds = [],
+ channelProfiles = {},
+ isWarningSuppressed = vi.fn(() => false),
+ suppressWarning = vi.fn(),
+ envMode = 'production',
+ showVideo = vi.fn(),
+ isVisible = false,
+ tableSize = null,
+} = {}) => {
+ vi.mocked(useStreamsTableStore).mockImplementation((sel) =>
+ sel({
+ streams,
+ pageCount,
+ totalCount,
+ allQueryIds,
+ pagination,
+ sorting,
+ selectedStreamIds,
+ setAllQueryIds: vi.fn(),
+ setPagination: vi.fn(),
+ setSorting: vi.fn(),
+ setSelectedStreamIds: vi.fn(),
+ })
+ );
+
+ vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
+ sel({ playlists, fetchPlaylists: vi.fn(), isLoading: false })
+ );
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({
+ channelGroups,
+ fetchChannelGroups: vi.fn(),
+ profiles: channelProfiles,
+ selectedProfileId: '0',
+ })
+ );
+
+ vi.mocked(useSettingsStore).mockImplementation((sel) =>
+ sel({ environment: { env_mode: envMode } })
+ );
+
+ vi.mocked(useVideoStore).mockImplementation((sel) =>
+ sel({ showVideo, isVisible })
+ );
+
+ vi.mocked(useChannelsTableStore).mockImplementation((sel) =>
+ sel({
+ expandedChannelId,
+ selectedChannelIds,
+ channels: [],
+ })
+ );
+
+ vi.mocked(useWarningsStore).mockImplementation((sel) =>
+ sel({ suppressWarning, isWarningSuppressed })
+ );
+
+ // useLocalStorage: first call is column-sizing, second is column-visibility
+ vi.mocked(useLocalStorage)
+ .mockReturnValueOnce([{}, vi.fn()]) // streams-table-column-sizing
+ .mockReturnValueOnce([tableSize, vi.fn()]); // streams-table-column-visibility
+
+ vi.mocked(useTable).mockImplementation((opts) => {
+ capturedTableOptions = opts;
+ return {
+ getRowModel: () => ({ rows: [] }),
+ getHeaderGroups: () => [],
+ setSelectedTableIds: vi.fn(),
+ tableSize: tableSize ?? 'default',
+ };
+ });
+};
+
+// ══════════════════════════════════════════════════════════════════════════════
+// Tests
+// ══════════════════════════════════════════════════════════════════════════════
+
+describe('StreamsTable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedTableOptions = null;
+
+ vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 5, results: [] });
+ vi.mocked(StreamsTableUtils.getAllStreamIds).mockResolvedValue([]);
+ vi.mocked(StreamsTableUtils.getStreamFilterOptions).mockResolvedValue({
+ groups: [],
+ m3u_accounts: [],
+ });
+ vi.mocked(StreamsTableUtils.deleteStream).mockResolvedValue(undefined);
+ vi.mocked(StreamsTableUtils.deleteStreams).mockResolvedValue(undefined);
+ vi.mocked(StreamsTableUtils.addStreamsToChannel).mockResolvedValue(undefined);
+ vi.mocked(StreamsTableUtils.requeryStreams).mockResolvedValue(undefined);
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the "Streams" heading', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Streams')).toBeInTheDocument();
+ });
+
+ it('renders the "Create Stream" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Create Stream')).toBeInTheDocument();
+ });
+
+ it('renders the "Delete" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Delete')).toBeInTheDocument();
+ });
+
+ it('renders the "Add to Channel" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Add to Channel')).toBeInTheDocument();
+ });
+
+ it('renders "Create Channel (0)" button when no streams are selected', () => {
+ setupMocks({ selectedStreamIds: [] });
+ render( );
+ expect(screen.getByText('Create Channel (0)')).toBeInTheDocument();
+ });
+
+ it('renders "Create Channels (N)" button when multiple streams are selected', () => {
+ setupMocks({ selectedStreamIds: [1, 2] });
+ render( );
+ expect(screen.getByText('Create Channels (2)')).toBeInTheDocument();
+ });
+
+ it('does not render the stream form on initial load', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('stream-form')).not.toBeInTheDocument();
+ });
+
+ it('shows getting-started card when totalCount is 0', async () => {
+ vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 0, results: [] });
+ setupMocks({ totalCount: 0, streams: [] });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText('Getting started')).toBeInTheDocument();
+ });
+ });
+
+ it('renders the custom table when totalCount > 0', async () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()] });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+ });
+
+ it('loading overlay is not visible after data finishes loading', async () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()] });
+ render( );
+ // After the initial fetch resolves, the overlay is hidden
+ await waitFor(() => {
+ expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Stream form (Create/Edit) ──────────────────────────────────────────────
+
+ describe('Create Stream modal', () => {
+ it('opens the stream form with no stream when "Create Stream" is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Create Stream'));
+ expect(screen.getByTestId('stream-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-stream-name')).toHaveTextContent('new');
+ });
+
+ it('closes the stream form when onClose is called', async () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Create Stream'));
+ fireEvent.click(screen.getByTestId('form-close'));
+ await waitFor(() => {
+ expect(screen.queryByTestId('stream-form')).not.toBeInTheDocument();
+ });
+ });
+
+ it('calls requeryStreams after form is closed', async () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Create Stream'));
+ fireEvent.click(screen.getByTestId('form-close'));
+ await waitFor(() => {
+ expect(StreamsTableUtils.requeryStreams).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Delete button state ────────────────────────────────────────────────────
+
+ describe('"Delete" button', () => {
+ it('is disabled when no streams are selected', () => {
+ setupMocks({ selectedStreamIds: [] });
+ render( );
+ const deleteBtn = screen.getByText('Delete').closest('button');
+ expect(deleteBtn).toBeDisabled();
+ });
+
+ it('is enabled when streams are selected', () => {
+ setupMocks({ selectedStreamIds: [1] });
+ render( );
+ const deleteBtn = screen.getByText('Delete').closest('button');
+ expect(deleteBtn).not.toBeDisabled();
+ });
+ });
+
+ // ── "Add to Channel" button state ──────────────────────────────────────────
+
+ describe('"Add to Channel" button', () => {
+ it('is disabled when no streams are selected', () => {
+ setupMocks({ selectedStreamIds: [] });
+ render( );
+ const btn = screen.getByText('Add to Channel').closest('button');
+ expect(btn).toBeDisabled();
+ });
+
+ it('is disabled when streams selected but no target channel', () => {
+ setupMocks({ selectedStreamIds: [1], expandedChannelId: null, selectedChannelIds: [] });
+ render( );
+ const btn = screen.getByText('Add to Channel').closest('button');
+ expect(btn).toBeDisabled();
+ });
+
+ it('is enabled when streams selected and target channel exists', () => {
+ setupMocks({
+ selectedStreamIds: [1],
+ expandedChannelId: 42,
+ });
+ render( );
+ const btn = screen.getByText('Add to Channel').closest('button');
+ expect(btn).not.toBeDisabled();
+ });
+ });
+
+ // ── Single delete confirmation dialog ─────────────────────────────────────
+
+ describe('single stream delete', () => {
+ it('opens ConfirmationDialog when delete is clicked and warning is not suppressed', () => {
+ setupMocks({
+ selectedStreamIds: [1],
+ isWarningSuppressed: vi.fn(() => false),
+ });
+ render( );
+ fireEvent.click(screen.getByText('Delete'));
+ expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument();
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent('Confirm Bulk Stream Deletion');
+ });
+
+ it('calls deleteStreams when delete is confirmed', async () => {
+ setupMocks({
+ selectedStreamIds: [1],
+ isWarningSuppressed: vi.fn(() => false),
+ });
+ render( );
+ fireEvent.click(screen.getByText('Delete'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() => {
+ expect(StreamsTableUtils.deleteStreams).toHaveBeenCalled();
+ });
+ });
+
+ it('closes the dialog after confirming delete', async () => {
+ setupMocks({
+ selectedStreamIds: [1],
+ isWarningSuppressed: vi.fn(() => false),
+ });
+ render( );
+ fireEvent.click(screen.getByText('Delete'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() => {
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('closes the dialog on Cancel', () => {
+ setupMocks({
+ selectedStreamIds: [1],
+ isWarningSuppressed: vi.fn(() => false),
+ });
+ render( );
+ fireEvent.click(screen.getByText('Delete'));
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+
+ it('skips the dialog and calls deleteStreams directly when warning is suppressed', async () => {
+ setupMocks({
+ selectedStreamIds: [1, 2],
+ isWarningSuppressed: vi.fn(() => true),
+ });
+ render( );
+ fireEvent.click(screen.getByText('Delete'));
+ await waitFor(() => {
+ expect(StreamsTableUtils.deleteStreams).toHaveBeenCalled();
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── "Create Channel" button and modal ─────────────────────────────────────
+
+ describe('Create Channel modal', () => {
+ it('opens CreateChannelModal when "Create Channel" is clicked with 1+ streams selected and warning not suppressed', () => {
+ setupMocks({
+ selectedStreamIds: [1, 2],
+ isWarningSuppressed: vi.fn(() => false),
+ });
+ render( );
+ fireEvent.click(screen.getByText('Create Channels (2)'));
+ expect(screen.getByTestId('create-channel-modal')).toBeInTheDocument();
+ });
+
+ it('closes CreateChannelModal when Close is clicked', () => {
+ setupMocks({
+ selectedStreamIds: [1, 2],
+ isWarningSuppressed: vi.fn(() => false),
+ });
+ render( );
+ fireEvent.click(screen.getByText('Create Channels (2)'));
+ fireEvent.click(screen.getByTestId('create-channel-close'));
+ expect(screen.queryByTestId('create-channel-modal')).not.toBeInTheDocument();
+ });
+
+ it('calls createChannelsFromStreamsAsync when modal is confirmed', async () => {
+ setupMocks({
+ selectedStreamIds: [1, 2],
+ isWarningSuppressed: vi.fn(() => false),
+ });
+ render( );
+ fireEvent.click(screen.getByText('Create Channels (2)'));
+ fireEvent.click(screen.getByTestId('create-channel-confirm'));
+ await waitFor(() => {
+ expect(StreamsTableUtils.createChannelsFromStreamsAsync).toHaveBeenCalled();
+ });
+ });
+
+ it('"Create Channel" button is disabled when no streams selected', () => {
+ setupMocks({ selectedStreamIds: [] });
+ render( );
+ const btn = screen.getByText('Create Channel (0)').closest('button');
+ expect(btn).toBeDisabled();
+ });
+ });
+
+ // ── Getting started card navigation ───────────────────────────────────────
+
+ describe('getting started card', () => {
+ const renderEmpty = async () => {
+ vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 0, results: [] });
+ setupMocks({ totalCount: 0, streams: [] });
+ render( );
+ await waitFor(() => screen.getByText('Getting started'));
+ };
+
+ it('shows "Add M3U" button', async () => {
+ await renderEmpty();
+ expect(screen.getByText('Add M3U')).toBeInTheDocument();
+ });
+
+ it('shows "Add Individual Stream" button', async () => {
+ await renderEmpty();
+ expect(screen.getByText('Add Individual Stream')).toBeInTheDocument();
+ });
+
+ it('navigates to /sources when "Add M3U" is clicked', async () => {
+ const mockNavigate = vi.fn();
+ vi.mocked(useNavigate).mockReturnValue(mockNavigate);
+ await renderEmpty();
+ fireEvent.click(screen.getByText('Add M3U'));
+ expect(mockNavigate).toHaveBeenCalledWith('/sources');
+ });
+
+ it('opens stream form when "Add Individual Stream" is clicked', async () => {
+ await renderEmpty();
+ fireEvent.click(screen.getByText('Add Individual Stream'));
+ expect(screen.getByTestId('stream-form')).toBeInTheDocument();
+ });
+ });
+
+ // ── Pagination ─────────────────────────────────────────────────────────────
+
+ describe('pagination', () => {
+ it('renders pagination controls when totalCount > 0', async () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()] });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId('pagination')).toBeInTheDocument();
+ });
+ });
+
+ it('renders current page number', async () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()], pagination: { pageIndex: 0, pageSize: 50 } });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId('page-current')).toHaveTextContent('1');
+ });
+ });
+
+ it('renders native select for page size', async () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()] });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId('native-select')).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Column visibility (Table Settings menu) ────────────────────────────────
+
+ describe('column visibility toggle menu', () => {
+ it('renders "Toggle Columns" label in the settings menu', () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()] });
+ render( );
+ // Menu label is always rendered even in collapsed state due to mock
+ expect(screen.getByText('Toggle Columns')).toBeInTheDocument();
+ });
+
+ it('renders all column toggle menu items', () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()] });
+ render( );
+ expect(screen.getByText('Name')).toBeInTheDocument();
+ expect(screen.getByText('Group')).toBeInTheDocument();
+ expect(screen.getByText('M3U')).toBeInTheDocument();
+ expect(screen.getByText('TVG-ID')).toBeInTheDocument();
+ expect(screen.getByText('Stats')).toBeInTheDocument();
+ });
+ });
+
+ // ── useTable integration ───────────────────────────────────────────────────
+
+ describe('useTable integration', () => {
+ it('passes stream data to useTable', async () => {
+ const streams = [makeStream({ id: 1 }), makeStream({ id: 2 })];
+ setupMocks({ streams, totalCount: 2 });
+ render( );
+ await waitFor(() => {
+ expect(capturedTableOptions.data).toHaveLength(2);
+ });
+ });
+
+ it('passes manualPagination: true to useTable', async () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()] });
+ render( );
+ await waitFor(() => {
+ expect(capturedTableOptions.manualPagination).toBe(true);
+ });
+ });
+
+ it('passes manualSorting: true to useTable', async () => {
+ setupMocks({ totalCount: 5, streams: [makeStream()] });
+ render( );
+ await waitFor(() => {
+ expect(capturedTableOptions.manualSorting).toBe(true);
+ });
+ });
+
+ it('passes pagination state to useTable', async () => {
+ const pagination = { pageIndex: 2, pageSize: 25 };
+ setupMocks({ totalCount: 5, streams: [makeStream()], pagination });
+ render( );
+ await waitFor(() => {
+ expect(capturedTableOptions.state.pagination).toEqual(pagination);
+ });
+ });
+ });
+
+ // ── Initial data fetch ─────────────────────────────────────────────────────
+
+ describe('initial data fetch', () => {
+ it('calls queryStreamsTable on mount', async () => {
+ setupMocks();
+ render( );
+ await waitFor(() => {
+ expect(StreamsTableUtils.queryStreamsTable).toHaveBeenCalled();
+ });
+ });
+
+ it('calls getAllStreamIds on mount', async () => {
+ setupMocks();
+ render( );
+ await waitFor(() => {
+ expect(StreamsTableUtils.getAllStreamIds).toHaveBeenCalled();
+ });
+ });
+
+ it('calls getStreamFilterOptions on mount', async () => {
+ setupMocks();
+ render( );
+ await waitFor(() => {
+ expect(StreamsTableUtils.getStreamFilterOptions).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onReady callback once data is loaded', async () => {
+ setupMocks();
+ const onReady = vi.fn();
+ render( );
+ await waitFor(() => {
+ expect(onReady).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ // ── "Add to Channel" action ────────────────────────────────────────────────
+
+ describe('"Add to Channel" action', () => {
+ it('calls addStreamsToChannel with selected streams', async () => {
+ const stream = makeStream({ id: 5 });
+ setupMocks({
+ streams: [stream],
+ selectedStreamIds: [5],
+ expandedChannelId: 42,
+ });
+ render( );
+ fireEvent.click(screen.getByText('Add to Channel'));
+ await waitFor(() => {
+ expect(StreamsTableUtils.addStreamsToChannel).toHaveBeenCalledWith(
+ 42,
+ undefined,
+ expect.arrayContaining([expect.objectContaining({ id: 5 })])
+ );
+ });
+ });
+ });
+
+ // ── handleWatchStream ──────────────────────────────────────────────────────
+
+ describe('handleWatchStream (via row actions)', () => {
+ it('calls buildLiveStreamUrl and showVideo via the actions cell renderer', async () => {
+ const mockShowVideo = vi.fn();
+ setupMocks({ totalCount: 5, streams: [makeStream()], showVideo: mockShowVideo });
+ render( );
+ await waitFor(() => expect(capturedTableOptions).not.toBeNull());
+
+ const actionsCell = capturedTableOptions.bodyCellRenderFns?.actions;
+ expect(actionsCell).toBeDefined();
+
+ // Render the actions cell to get the StreamRowActions component
+ const row = {
+ original: makeStream({ stream_hash: 'hash-abc', name: 'My Stream' }),
+ };
+ const cell = { column: { id: 'actions' } };
+
+ // The actions renderer returns a StreamRowActions element; find Preview Stream button
+ const { getByText } = render(actionsCell({ cell, row }));
+ fireEvent.click(getByText('Preview Stream'));
+ expect(mockShowVideo).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx b/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx
new file mode 100644
index 00000000..906bb0e6
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx
@@ -0,0 +1,348 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api', () => ({
+ default: {
+ deleteUserAgent: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
+vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
+
+// ── Hook mocks ─────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(() => ['default', vi.fn()]),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+// ── Child component mocks ──────────────────────────────────────────────────────
+vi.mock('../../forms/UserAgent', () => ({
+ default: ({ isOpen, onClose, userAgent }) =>
+ isOpen ? (
+
+ {userAgent?.name ?? 'new'}
+
+ Close
+
+
+ ) : null,
+}));
+
+vi.mock('../CustomTable', () => ({
+ CustomTable: () =>
,
+ useTable: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, style }) => {children}
,
+ Button: ({ children, onClick, leftSection, disabled, loading }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Center: ({ children }) => {children}
,
+ Flex: ({ children }) => {children}
,
+ Paper: ({ children, style }) => {children}
,
+ Stack: ({ children, style }) => {children}
,
+ Text: ({ children, name }) => (
+
+ {children}
+
+ ),
+ Tooltip: ({ children, label }) => (
+ {children}
+ ),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Check: ({ color }) => ,
+ SquareMinus: () => ,
+ SquarePen: () => ,
+ SquarePlus: () => ,
+ X: ({ color }) => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useUserAgentsStore from '../../../store/userAgents';
+import useSettingsStore from '../../../store/settings';
+import { useTable } from '../CustomTable';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import API from '../../../api';
+import UserAgentsTable from '../UserAgentsTable';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeUA = (overrides = {}) => ({
+ id: 1,
+ name: 'Chrome Default',
+ user_agent: 'Mozilla/5.0 Chrome/120',
+ description: 'Standard Chrome UA',
+ is_active: true,
+ ...overrides,
+});
+
+let capturedTableOptions = null;
+
+const setupMocks = ({
+ userAgents = [makeUA()],
+ defaultUserAgentId = 99,
+} = {}) => {
+ vi.mocked(useUserAgentsStore).mockImplementation((sel) =>
+ sel({ userAgents })
+ );
+
+ vi.mocked(useSettingsStore).mockImplementation((sel) =>
+ sel({ settings: { default_user_agent: defaultUserAgentId } })
+ );
+
+ vi.mocked(useTable).mockImplementation((opts) => {
+ capturedTableOptions = opts;
+ return {
+ getRowModel: () => ({ rows: [] }),
+ getHeaderGroups: () => [],
+ };
+ });
+};
+
+const makeRowCtx = (ua) => ({
+ row: { id: String(ua.id), original: ua },
+ cell: {
+ column: { id: 'actions', columnDef: {} },
+ getValue: vi.fn(() => undefined),
+ },
+});
+
+// ══════════════════════════════════════════════════════════════════════════════
+// Tests
+// ══════════════════════════════════════════════════════════════════════════════
+
+describe('UserAgentsTable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedTableOptions = null;
+ vi.mocked(API.deleteUserAgent).mockResolvedValue(undefined);
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the "Add User-Agent" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Add User-Agent')).toBeInTheDocument();
+ });
+
+ it('renders the custom table', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('does not render the form on initial load', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument();
+ });
+
+ it('passes all userAgents to useTable as data', () => {
+ const uas = [makeUA({ id: 1 }), makeUA({ id: 2 })];
+ setupMocks({ userAgents: uas });
+ render( );
+ expect(capturedTableOptions.data).toHaveLength(2);
+ });
+
+ it('passes an empty array when no userAgents exist', () => {
+ setupMocks({ userAgents: [] });
+ render( );
+ expect(capturedTableOptions.data).toHaveLength(0);
+ });
+ });
+
+ // ── Add User-Agent ─────────────────────────────────────────────────────────
+
+ describe('Add User-Agent', () => {
+ it('opens the form with no user-agent when "Add User-Agent" is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add User-Agent'));
+ expect(screen.getByTestId('user-agent-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-ua-name')).toHaveTextContent('new');
+ });
+
+ it('closes the form when onClose is called', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add User-Agent'));
+ fireEvent.click(screen.getByTestId('form-close'));
+ expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Edit via RowActions ────────────────────────────────────────────────────
+
+ describe('edit user-agent via RowActions', () => {
+ it('opens the form populated with the user-agent when edit icon is clicked', () => {
+ const ua = makeUA({ name: 'Firefox UA' });
+ setupMocks({ userAgents: [ua] });
+ render( );
+
+ const { row, cell } = makeRowCtx(ua);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+
+ expect(screen.getByTestId('user-agent-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-ua-name')).toHaveTextContent('Firefox UA');
+ });
+
+ it('closes the form after editing when onClose is called', () => {
+ const ua = makeUA({ name: 'Firefox UA' });
+ setupMocks({ userAgents: [ua] });
+ render( );
+
+ const { row, cell } = makeRowCtx(ua);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+ fireEvent.click(screen.getByTestId('form-close'));
+
+ expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Delete via RowActions (single) ─────────────────────────────────────────
+
+ describe('delete user-agent via RowActions (single id)', () => {
+ it('calls API.deleteUserAgent with the user-agent id', async () => {
+ const ua = makeUA({ id: 7 });
+ setupMocks({ userAgents: [ua] });
+ render( );
+
+ const { row, cell } = makeRowCtx(ua);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+
+ await waitFor(() =>
+ expect(API.deleteUserAgent).toHaveBeenCalledWith(7)
+ );
+ });
+
+ it('shows a notification and does NOT call API when deleting the default user-agent', async () => {
+ const ua = makeUA({ id: 5 });
+ setupMocks({ userAgents: [ua], defaultUserAgentId: 5 });
+ render( );
+
+ const { row, cell } = makeRowCtx(ua);
+ const { getByTestId } = render(
+ capturedTableOptions.bodyCellRenderFns.actions({ cell, row })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Cannot delete default user-agent',
+ color: 'red.5',
+ })
+ )
+ );
+ expect(API.deleteUserAgent).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Active column cell renderer ────────────────────────────────────────────
+
+ describe('is_active column cell renderer', () => {
+ const renderIsActiveCell = (value) => {
+ const col = capturedTableOptions.columns.find(
+ (c) => c.accessorKey === 'is_active'
+ );
+ return col.cell({ cell: { getValue: () => value } });
+ };
+
+ it('renders Check icon when is_active is true', () => {
+ setupMocks();
+ render( );
+
+ const { getByTestId } = render(renderIsActiveCell(true));
+ expect(getByTestId('icon-check')).toBeInTheDocument();
+ });
+
+ it('renders X icon when is_active is false', () => {
+ setupMocks();
+ render( );
+
+ const { getByTestId } = render(renderIsActiveCell(false));
+ expect(getByTestId('icon-x')).toBeInTheDocument();
+ });
+ });
+
+ // ── user_agent column cell renderer ───────────────────────────────────────
+
+ describe('user_agent column cell renderer', () => {
+ it('renders the user_agent string', () => {
+ setupMocks();
+ render( );
+
+ const col = capturedTableOptions.columns.find(
+ (c) => c.accessorKey === 'user_agent'
+ );
+ const { getByText } = render(
+ col.cell({ cell: { getValue: () => 'Mozilla/5.0 Safari/537' } })
+ );
+ expect(getByText('Mozilla/5.0 Safari/537')).toBeInTheDocument();
+ });
+ });
+
+ // ── description column cell renderer ──────────────────────────────────────
+
+ describe('description column cell renderer', () => {
+ it('renders the description string', () => {
+ setupMocks();
+ render( );
+
+ const col = capturedTableOptions.columns.find(
+ (c) => c.accessorKey === 'description'
+ );
+ const { getByText } = render(
+ col.cell({ cell: { getValue: () => 'A custom user agent' } })
+ );
+ expect(getByText('A custom user agent')).toBeInTheDocument();
+ });
+ });
+
+ // ── store reactivity ───────────────────────────────────────────────────────
+
+ describe('store reactivity', () => {
+ it('passes allRowIds derived from userAgent ids to useTable', () => {
+ const uas = [makeUA({ id: 10 }), makeUA({ id: 20 }), makeUA({ id: 30 })];
+ setupMocks({ userAgents: uas });
+ render( );
+ expect(capturedTableOptions.allRowIds).toEqual([10, 20, 30]);
+ });
+ });
+});
diff --git a/frontend/src/components/tables/__tests__/UsersTable.test.jsx b/frontend/src/components/tables/__tests__/UsersTable.test.jsx
new file mode 100644
index 00000000..c972fd81
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/UsersTable.test.jsx
@@ -0,0 +1,674 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api', () => ({
+ default: {
+ deleteUser: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/users', () => ({ default: vi.fn() }));
+vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
+vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
+vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
+
+// ── Hook mocks ─────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(() => ['default', vi.fn()]),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/dateTimeUtils.js', () => ({
+ useDateTimeFormat: vi.fn(),
+ format: vi.fn((val) => `formatted:${val}`),
+}));
+
+// ── Child component mocks ──────────────────────────────────────────────────────
+vi.mock('../../forms/User', () => ({
+ default: ({ isOpen, onClose, user }) =>
+ isOpen ? (
+
+ {user?.username ?? 'new'}
+
+ Close
+
+
+ ) : null,
+}));
+
+vi.mock('../../ConfirmationDialog', () => ({
+ default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) =>
+ opened ? (
+
+ {title}
+
+ {confirmLabel}
+
+
+ {cancelLabel}
+
+
+ ) : null,
+}));
+
+vi.mock('../CustomTable', () => ({
+ CustomTable: () =>
,
+ useTable: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, style }) => {children}
,
+ Button: ({ children, onClick, leftSection, disabled, loading }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Flex: ({ children, style }) => {children}
,
+ Group: ({ children, style }) => (
+ {children}
+ ),
+ LoadingOverlay: ({ visible }) => visible ?
: null,
+ Paper: ({ children, style }) => {children}
,
+ Stack: ({ children, style }) => {children}
,
+ Text: ({ children, style, name }) => (
+
+ {children}
+
+ ),
+ Tooltip: ({ children, label }) => (
+ {children}
+ ),
+ useMantineTheme: vi.fn(() => ({
+ tailwind: {
+ yellow: { 3: '#fde047' },
+ red: { 6: '#dc2626' },
+ green: { 5: '#22c55e' },
+ },
+ })),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Eye: () => ,
+ EyeOff: () => ,
+ SquareMinus: () => ,
+ SquarePen: () => ,
+ SquarePlus: () => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useUsersStore from '../../../store/users';
+import useChannelsStore from '../../../store/channels';
+import useAuthStore from '../../../store/auth';
+import useWarningsStore from '../../../store/warnings';
+import { useDateTimeFormat, format } from '../../../utils/dateTimeUtils.js';
+import { useTable } from '../CustomTable';
+import API from '../../../api';
+import { USER_LEVELS, USER_LEVEL_LABELS } from '../../../constants';
+import UsersTable from '../UsersTable';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeUser = (overrides = {}) => ({
+ id: 1,
+ username: 'testuser',
+ first_name: 'Test',
+ last_name: 'User',
+ email: 'test@example.com',
+ user_level: USER_LEVELS.STANDARD,
+ date_joined: '2024-01-15T10:00:00Z',
+ last_login: '2024-06-01T12:00:00Z',
+ custom_properties: { xc_password: 'secret123' },
+ channel_profiles: [],
+ ...overrides,
+});
+
+const makeAdminUser = (overrides = {}) =>
+ makeUser({ id: 99, username: 'admin', user_level: USER_LEVELS.ADMIN, ...overrides });
+
+let capturedTableOptions = null;
+
+const setupMocks = ({
+ users = [makeUser()],
+ authUser = makeAdminUser(),
+ profiles = { 10: { id: 10, name: 'HD Profile' } },
+ isWarningSuppressed = vi.fn(() => false),
+ suppressWarning = vi.fn(),
+} = {}) => {
+ vi.mocked(useUsersStore).mockImplementation((sel) =>
+ sel({ users })
+ );
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ profiles })
+ );
+
+ vi.mocked(useAuthStore).mockImplementation((sel) =>
+ sel({ user: authUser })
+ );
+
+ vi.mocked(useWarningsStore).mockImplementation((sel) =>
+ sel({ isWarningSuppressed, suppressWarning })
+ );
+
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ fullDateFormat: 'MM/DD/YYYY',
+ fullDateTimeFormat: 'MM/DD/YYYY HH:mm',
+ });
+
+ vi.mocked(useTable).mockImplementation((opts) => {
+ capturedTableOptions = opts;
+ return {
+ getRowModel: () => ({ rows: [] }),
+ getHeaderGroups: () => [],
+ };
+ });
+};
+
+const getActionsCell = () =>
+ capturedTableOptions.columns.find((c) => c.id === 'actions');
+
+const getCol = (key) =>
+ capturedTableOptions.columns.find(
+ (c) => c.accessorKey === key || c.id === key
+ );
+
+// ══════════════════════════════════════════════════════════════════════════════
+// Tests
+// ══════════════════════════════════════════════════════════════════════════════
+
+describe('UsersTable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedTableOptions = null;
+ vi.mocked(API.deleteUser).mockResolvedValue(undefined);
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the "Users" heading', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Users')).toBeInTheDocument();
+ });
+
+ it('renders the "Add User" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Add User')).toBeInTheDocument();
+ });
+
+ it('renders the custom table', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('does not render the user form on initial load', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
+ });
+
+ it('does not render the confirmation dialog on initial load', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+
+ it('passes users sorted by id to useTable', () => {
+ const users = [
+ makeUser({ id: 3, username: 'c' }),
+ makeUser({ id: 1, username: 'a' }),
+ makeUser({ id: 2, username: 'b' }),
+ ];
+ setupMocks({ users });
+ render( );
+ expect(capturedTableOptions.data.map((u) => u.id)).toEqual([1, 2, 3]);
+ });
+
+ it('passes allRowIds derived from user ids', () => {
+ const users = [makeUser({ id: 1 }), makeUser({ id: 2 })];
+ setupMocks({ users });
+ render( );
+ expect(capturedTableOptions.allRowIds).toEqual([1, 2]);
+ });
+ });
+
+ // ── "Add User" button state ────────────────────────────────────────────────
+
+ describe('"Add User" button access control', () => {
+ it('is enabled for admin users', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render( );
+ expect(screen.getByText('Add User').closest('button')).not.toBeDisabled();
+ });
+
+ it('is disabled for non-admin users', () => {
+ setupMocks({ authUser: makeUser({ user_level: USER_LEVELS.STANDARD }) });
+ render( );
+ expect(screen.getByText('Add User').closest('button')).toBeDisabled();
+ });
+ });
+
+ // ── Add / Edit User form ───────────────────────────────────────────────────
+
+ describe('Add User form', () => {
+ it('opens the form with no user when "Add User" is clicked', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add User'));
+ expect(screen.getByTestId('user-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-user-name')).toHaveTextContent('new');
+ });
+
+ it('closes the form when onClose is called', () => {
+ setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Add User'));
+ fireEvent.click(screen.getByTestId('form-close'));
+ expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Edit user via actions column', () => {
+ it('opens the form populated with the user when edit icon is clicked', () => {
+ const user = makeUser({ username: 'janedoe' });
+ setupMocks({ users: [user] });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+
+ expect(screen.getByTestId('user-form')).toBeInTheDocument();
+ expect(screen.getByTestId('form-user-name')).toHaveTextContent('janedoe');
+ });
+
+ it('closes the form after editing when onClose is called', () => {
+ const user = makeUser({ username: 'janedoe' });
+ setupMocks({ users: [user] });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ fireEvent.click(getByTestId('icon-square-pen').closest('button'));
+ fireEvent.click(screen.getByTestId('form-close'));
+
+ expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
+ });
+
+ it('edit button is disabled for non-admin auth user', () => {
+ const user = makeUser({ id: 5 });
+ setupMocks({ users: [user], authUser: makeUser({ id: 99, user_level: USER_LEVELS.STANDARD }) });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled();
+ });
+
+ it('edit button is enabled for admin auth user', () => {
+ const user = makeUser({ id: 5 });
+ setupMocks({ users: [user], authUser: makeAdminUser() });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled();
+ });
+ });
+
+ // ── Delete user ────────────────────────────────────────────────────────────
+
+ describe('Delete user via actions column', () => {
+ it('opens ConfirmationDialog when delete is clicked and warning is not suppressed', () => {
+ const user = makeUser({ id: 5 });
+ setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+
+ expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument();
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent('Confirm User Deletion');
+ });
+
+ it('calls API.deleteUser when confirmed via dialog', async () => {
+ const user = makeUser({ id: 5 });
+ setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(API.deleteUser).toHaveBeenCalledWith(5)
+ );
+ });
+
+ it('closes the dialog after confirming delete', async () => {
+ const user = makeUser({ id: 5 });
+ setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
+ );
+ });
+
+ it('closes the dialog when Cancel is clicked', () => {
+ const user = makeUser({ id: 5 });
+ setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+
+ it('skips dialog and calls API.deleteUser directly when warning is suppressed', async () => {
+ const user = makeUser({ id: 7 });
+ setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => true) });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ fireEvent.click(getByTestId('icon-square-minus').closest('button'));
+
+ await waitFor(() =>
+ expect(API.deleteUser).toHaveBeenCalledWith(7)
+ );
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+
+ it('delete button is disabled for non-admin auth user', () => {
+ const user = makeUser({ id: 5 });
+ setupMocks({ users: [user], authUser: makeUser({ id: 99, user_level: USER_LEVELS.STANDARD }) });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled();
+ });
+
+ it('delete button is disabled when admin tries to delete themselves', () => {
+ const admin = makeAdminUser({ id: 99 });
+ setupMocks({ users: [admin], authUser: admin });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: admin } })
+ );
+ expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled();
+ });
+
+ it('delete button is enabled for admin deleting a different user', () => {
+ const user = makeUser({ id: 5 });
+ setupMocks({ users: [user], authUser: makeAdminUser({ id: 99 }) });
+ render( );
+
+ const actionsCol = getActionsCell();
+ const { getByTestId } = render(
+ actionsCol.cell({ row: { original: user } })
+ );
+ expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled();
+ });
+ });
+
+ // ── XCPasswordCell ─────────────────────────────────────────────────────────
+
+ describe('XCPasswordCell', () => {
+ const renderXCCell = (customProperties) => {
+ const XCCell = getCol('custom_properties').cell;
+ return render( customProperties} />);
+ };
+
+ it('hides password by default (shows bullets)', () => {
+ setupMocks();
+ render( );
+
+ const { getByText } = renderXCCell({ xc_password: 'mypassword' });
+ expect(getByText('••••••••')).toBeInTheDocument();
+ });
+
+ it('shows the password after clicking the eye toggle', () => {
+ setupMocks();
+ render( );
+
+ const { getByText, getByTestId } = renderXCCell({ xc_password: 'mypassword' });
+ fireEvent.click(getByTestId('icon-eye').closest('button'));
+ expect(getByText('mypassword')).toBeInTheDocument();
+ });
+
+ it('hides the password again after toggling twice', () => {
+ setupMocks();
+ render( );
+
+ const { getByText, getByTestId } = renderXCCell({ xc_password: 'mypassword' });
+ const toggleBtn = getByTestId('icon-eye').closest('button');
+ fireEvent.click(toggleBtn);
+ fireEvent.click(getByTestId('icon-eye-off').closest('button'));
+ expect(getByText('••••••••')).toBeInTheDocument();
+ });
+
+ it('shows "N/A" when no xc_password', () => {
+ setupMocks();
+ render( );
+
+ const { getByText } = renderXCCell({});
+ expect(getByText('N/A')).toBeInTheDocument();
+ });
+
+ it('does not render the eye toggle when password is N/A', () => {
+ setupMocks();
+ render( );
+
+ const { queryByTestId } = renderXCCell({});
+ expect(queryByTestId('icon-eye')).not.toBeInTheDocument();
+ });
+
+ it('shows "N/A" when custom_properties is null', () => {
+ setupMocks();
+ render( );
+
+ const { getByText } = renderXCCell(null);
+ expect(getByText('N/A')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column cell renderers ──────────────────────────────────────────────────
+
+ describe('user_level column', () => {
+ it('renders the label for ADMIN level', () => {
+ setupMocks();
+ render( );
+ const col = getCol('user_level');
+ const { getByText } = render(col.cell({ getValue: () => USER_LEVELS.ADMIN }));
+ expect(getByText(USER_LEVEL_LABELS[USER_LEVELS.ADMIN])).toBeInTheDocument();
+ });
+
+ it('renders the label for STANDARD level', () => {
+ setupMocks();
+ render( );
+ const col = getCol('user_level');
+ const { getByText } = render(col.cell({ getValue: () => USER_LEVELS.STANDARD }));
+ expect(getByText(USER_LEVEL_LABELS[USER_LEVELS.STANDARD])).toBeInTheDocument();
+ });
+ });
+
+ describe('name column (accessorFn)', () => {
+ it('combines first_name and last_name', () => {
+ setupMocks();
+ render( );
+ const col = getCol('name');
+ const value = col.accessorFn({ first_name: 'Jane', last_name: 'Doe' });
+ expect(value).toBe('Jane Doe');
+ });
+
+ it('trims when only first_name is set', () => {
+ setupMocks();
+ render( );
+ const col = getCol('name');
+ const value = col.accessorFn({ first_name: 'Jane', last_name: '' });
+ expect(value).toBe('Jane');
+ });
+
+ it('cell renders "-" when value is empty', () => {
+ setupMocks();
+ render( );
+ const col = getCol('name');
+ const { getByText } = render(col.cell({ getValue: () => '' }));
+ expect(getByText('-')).toBeInTheDocument();
+ });
+
+ it('cell renders the full name when set', () => {
+ setupMocks();
+ render( );
+ const col = getCol('name');
+ const { getByText } = render(col.cell({ getValue: () => 'Jane Doe' }));
+ expect(getByText('Jane Doe')).toBeInTheDocument();
+ });
+ });
+
+ describe('date_joined column', () => {
+ it('calls format with fullDateFormat when date is present', () => {
+ setupMocks();
+ render( );
+ const col = getCol('date_joined');
+ const { getByText } = render(col.cell({ getValue: () => '2024-01-15T10:00:00Z' }));
+ expect(vi.mocked(format)).toHaveBeenCalledWith('2024-01-15T10:00:00Z', 'MM/DD/YYYY');
+ expect(getByText('formatted:2024-01-15T10:00:00Z')).toBeInTheDocument();
+ });
+
+ it('renders "-" when date is null', () => {
+ setupMocks();
+ render( );
+ const col = getCol('date_joined');
+ const { getByText } = render(col.cell({ getValue: () => null }));
+ expect(getByText('-')).toBeInTheDocument();
+ });
+ });
+
+ describe('last_login column', () => {
+ it('calls format with fullDateTimeFormat when date is present', () => {
+ setupMocks();
+ render( );
+ const col = getCol('last_login');
+ const { getByText } = render(col.cell({ getValue: () => '2024-06-01T12:00:00Z' }));
+ expect(vi.mocked(format)).toHaveBeenCalledWith('2024-06-01T12:00:00Z', 'MM/DD/YYYY HH:mm');
+ expect(getByText('formatted:2024-06-01T12:00:00Z')).toBeInTheDocument();
+ });
+
+ it('renders "Never" when last_login is null', () => {
+ setupMocks();
+ render( );
+ const col = getCol('last_login');
+ const { getByText } = render(col.cell({ getValue: () => null }));
+ expect(getByText('Never')).toBeInTheDocument();
+ });
+ });
+
+ describe('channel_profiles column', () => {
+ it('renders "All" badge when user has no profiles assigned', () => {
+ setupMocks({ profiles: { 10: { id: 10, name: 'HD Profile' } } });
+ render( );
+ const col = getCol('channel_profiles');
+ const { getByText } = render(col.cell({ getValue: () => [] }));
+ expect(getByText('All')).toBeInTheDocument();
+ });
+
+ it('renders a badge for each assigned profile', () => {
+ setupMocks({
+ profiles: { 10: { id: 10, name: 'HD Profile' }, 20: { id: 20, name: 'SD Profile' } },
+ });
+ render( );
+ const col = getCol('channel_profiles');
+ const { getByText } = render(col.cell({ getValue: () => [10, 20] }));
+ expect(getByText('HD Profile')).toBeInTheDocument();
+ expect(getByText('SD Profile')).toBeInTheDocument();
+ });
+
+ it('renders "All" when profile ids do not match any profiles', () => {
+ setupMocks({ profiles: {} });
+ render( );
+ const col = getCol('channel_profiles');
+ const { getByText } = render(col.cell({ getValue: () => [99] }));
+ expect(getByText('All')).toBeInTheDocument();
+ });
+ });
+
+ // ── useTable options ───────────────────────────────────────────────────────
+
+ describe('useTable options', () => {
+ it('passes enablePagination: false', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enablePagination).toBe(false);
+ });
+
+ it('passes enableRowSelection: false', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enableRowSelection).toBe(false);
+ });
+
+ it('passes manualSorting: false', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.manualSorting).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx b/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx
new file mode 100644
index 00000000..3375121e
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx
@@ -0,0 +1,1026 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/vodLogos', () => ({ default: vi.fn() }));
+
+// ── Hook mocks ─────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(() => ['default', vi.fn()]),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+// ── Child component mocks ──────────────────────────────────────────────────────
+vi.mock('../../ConfirmationDialog', () => ({
+ default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) =>
+ opened ? (
+
+ {title}
+ onConfirm(false)}
+ disabled={loading}
+ >
+ {confirmLabel}
+
+
+ {cancelLabel}
+
+
+ ) : null,
+}));
+
+vi.mock('../CustomTable', () => ({
+ CustomTable: () =>
,
+ useTable: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, style }) => {children}
,
+ Button: ({ children, onClick, leftSection, disabled, loading }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Center: ({ children, style }) => {children}
,
+ Checkbox: ({ checked, indeterminate, onChange }) => (
+
+ ),
+ Group: ({ children, style }) => (
+ {children}
+ ),
+ Image: ({ src, alt, fallbackSrc }) => (
+
+ ),
+ LoadingOverlay: ({ visible }) =>
+ visible ?
: null,
+ NativeSelect: ({ value, data, onChange }) => (
+
+ {(data || []).map((d) => (
+
+ {d}
+
+ ))}
+
+ ),
+ Pagination: ({ total, value, onChange }) => (
+
+ onChange(value - 1)}
+ disabled={value <= 1}
+ >
+ Prev
+
+ {value}
+ onChange(value + 1)}
+ disabled={value >= total}
+ >
+ Next
+
+
+ ),
+ Paper: ({ children, style }) => {children}
,
+ Select: ({ value, onChange, data }) => (
+ onChange(e.target.value)}
+ >
+ {(data || []).map((d) => (
+
+ {d.label}
+
+ ))}
+
+ ),
+ Stack: ({ children, style }) => {children}
,
+ Text: ({ children, style, name }) => (
+
+ {children}
+
+ ),
+ TextInput: ({ value, onChange, placeholder }) => (
+
+ ),
+ Tooltip: ({ children, label }) => (
+ {children}
+ ),
+ useMantineTheme: vi.fn(() => ({
+ tailwind: {
+ red: { 6: '#dc2626' },
+ },
+ })),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ ExternalLink: () => ,
+ SquareMinus: () => ,
+ Trash: () => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useVODLogosStore from '../../../store/vodLogos';
+import { useTable } from '../CustomTable';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import VODLogosTable from '../VODLogosTable';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeLogo = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Logo',
+ url: 'http://example.com/logo.png',
+ cache_url: '/cache/logo.png',
+ movie_count: 0,
+ series_count: 0,
+ item_names: [],
+ ...overrides,
+});
+
+let capturedTableOptions = null;
+
+const setupMocks = ({
+ logos = [makeLogo()],
+ totalCount = 1,
+ isLoading = false,
+ unusedCount = 0,
+} = {}) => {
+ const fetchVODLogos = vi.fn().mockResolvedValue(undefined);
+ const deleteVODLogo = vi.fn().mockResolvedValue(undefined);
+ const deleteVODLogos = vi.fn().mockResolvedValue(undefined);
+ const cleanupUnusedVODLogos = vi.fn().mockResolvedValue({ deleted_count: 3 });
+ const getUnusedLogosCount = vi.fn().mockResolvedValue(unusedCount);
+
+ vi.mocked(useVODLogosStore).mockReturnValue({
+ logos,
+ totalCount,
+ isLoading,
+ fetchVODLogos,
+ deleteVODLogo,
+ deleteVODLogos,
+ cleanupUnusedVODLogos,
+ getUnusedLogosCount,
+ });
+
+ vi.mocked(useTable).mockImplementation((opts) => {
+ capturedTableOptions = opts;
+ return {
+ getRowModel: () => ({ rows: [] }),
+ getHeaderGroups: () => [],
+ setSelectedTableIds: vi.fn(),
+ };
+ });
+
+ return {
+ fetchVODLogos,
+ deleteVODLogo,
+ deleteVODLogos,
+ cleanupUnusedVODLogos,
+ getUnusedLogosCount,
+ };
+};
+
+const getCol = (key) =>
+ capturedTableOptions.columns.find(
+ (c) => c.accessorKey === key || c.id === key
+ );
+
+// ══════════════════════════════════════════════════════════════════════════════
+// Tests
+// ══════════════════════════════════════════════════════════════════════════════
+
+describe('VODLogosTable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedTableOptions = null;
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the name filter input', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('name-filter')).toBeInTheDocument();
+ });
+
+ it('renders the usage filter select', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('usage-filter')).toBeInTheDocument();
+ });
+
+ it('renders the custom table', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('renders pagination controls', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('pagination')).toBeInTheDocument();
+ expect(screen.getByTestId('page-size-select')).toBeInTheDocument();
+ });
+
+ it('does not render confirmation dialogs on initial load', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+
+ it('renders "Cleanup Unused" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText(/Cleanup Unused/)).toBeInTheDocument();
+ });
+
+ it('renders "Delete" button', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText(/^Delete/)).toBeInTheDocument();
+ });
+
+ it('shows LoadingOverlay when isLoading is true', () => {
+ setupMocks({ isLoading: true });
+ render( );
+ expect(screen.getByTestId('loading-overlay')).toBeInTheDocument();
+ });
+
+ it('does not show LoadingOverlay when isLoading is false', () => {
+ setupMocks({ isLoading: false });
+ render( );
+ expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
+ });
+
+ it('renders pagination string on initial load', () => {
+ setupMocks({ totalCount: 50 });
+ render( );
+ expect(screen.getByText('1 to 25 of 50')).toBeInTheDocument();
+ });
+ });
+
+ // ── Initial data fetch ─────────────────────────────────────────────────────
+
+ describe('initial data fetch', () => {
+ it('calls fetchVODLogos with default params on mount', () => {
+ const { fetchVODLogos } = setupMocks();
+ render( );
+ expect(fetchVODLogos).toHaveBeenCalledWith({
+ page: 1,
+ page_size: 25,
+ name: '',
+ usage: undefined,
+ });
+ });
+
+ it('calls getUnusedLogosCount on mount', () => {
+ const { getUnusedLogosCount } = setupMocks();
+ render( );
+ expect(getUnusedLogosCount).toHaveBeenCalled();
+ });
+
+ it('"Cleanup Unused" button shows count and becomes enabled when unusedCount resolves > 0', async () => {
+ setupMocks({ unusedCount: 7 });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText(/Cleanup Unused \(7\)/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/Cleanup Unused \(7\)/).closest('button')
+ ).not.toBeDisabled();
+ });
+ });
+ });
+
+ // ── Name filter ────────────────────────────────────────────────────────────
+
+ describe('name filter', () => {
+ it('calls fetchVODLogos with updated name when filter changes', () => {
+ const { fetchVODLogos } = setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('name-filter'), {
+ target: { value: 'HBO' },
+ });
+ expect(fetchVODLogos).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'HBO', page: 1 })
+ );
+ });
+
+ it('passes name: "" when filter is cleared', () => {
+ const { fetchVODLogos } = setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('name-filter'), {
+ target: { value: 'ESPN' },
+ });
+ fireEvent.change(screen.getByTestId('name-filter'), {
+ target: { value: '' },
+ });
+ expect(fetchVODLogos).toHaveBeenLastCalledWith(
+ expect.objectContaining({ name: '' })
+ );
+ });
+ });
+
+ // ── Usage filter ───────────────────────────────────────────────────────────
+
+ describe('usage filter', () => {
+ it('passes usage: undefined when filter is "all"', () => {
+ const { fetchVODLogos } = setupMocks();
+ render( );
+ expect(fetchVODLogos).toHaveBeenCalledWith(
+ expect.objectContaining({ usage: undefined })
+ );
+ });
+
+ it('passes usage: "used" when filter changes to "used"', () => {
+ const { fetchVODLogos } = setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('usage-filter'), {
+ target: { value: 'used' },
+ });
+ expect(fetchVODLogos).toHaveBeenCalledWith(
+ expect.objectContaining({ usage: 'used' })
+ );
+ });
+
+ it('passes usage: "unused" when filter changes to "unused"', () => {
+ const { fetchVODLogos } = setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('usage-filter'), {
+ target: { value: 'unused' },
+ });
+ expect(fetchVODLogos).toHaveBeenCalledWith(
+ expect.objectContaining({ usage: 'unused' })
+ );
+ });
+ });
+
+ // ── "Cleanup Unused" button ────────────────────────────────────────────────
+
+ describe('"Cleanup Unused" button', () => {
+ it('is disabled when unusedLogosCount is 0', () => {
+ setupMocks({ unusedCount: 0 });
+ render( );
+ expect(
+ screen.getByText(/Cleanup Unused/).closest('button')
+ ).toBeDisabled();
+ });
+
+ it('opens cleanup dialog when clicked', async () => {
+ setupMocks({ unusedCount: 4 });
+ render( );
+ await waitFor(() =>
+ expect(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ ).not.toBeDisabled()
+ );
+ fireEvent.click(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ );
+ expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument();
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent(
+ 'Cleanup Unused Logos'
+ );
+ });
+
+ it('calls cleanupUnusedVODLogos when confirmed', async () => {
+ const { cleanupUnusedVODLogos } = setupMocks({ unusedCount: 4 });
+ render( );
+ await waitFor(() =>
+ expect(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ ).not.toBeDisabled()
+ );
+ fireEvent.click(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ );
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(cleanupUnusedVODLogos).toHaveBeenCalled()
+ );
+ });
+
+ it('shows success notification after cleanup', async () => {
+ setupMocks({ unusedCount: 4 });
+ render( );
+ await waitFor(() =>
+ expect(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ ).not.toBeDisabled()
+ );
+ fireEvent.click(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ );
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Success',
+ message: 'Cleaned up 3 unused VOD logos',
+ color: 'green',
+ })
+ )
+ );
+ });
+
+ it('closes cleanup dialog after confirmation', async () => {
+ setupMocks({ unusedCount: 4 });
+ render( );
+ await waitFor(() =>
+ expect(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ ).not.toBeDisabled()
+ );
+ fireEvent.click(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ );
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
+ );
+ });
+
+ it('closes cleanup dialog on cancel', async () => {
+ setupMocks({ unusedCount: 4 });
+ render( );
+ await waitFor(() =>
+ expect(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ ).not.toBeDisabled()
+ );
+ fireEvent.click(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ );
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+
+ it('shows error notification when cleanupUnusedVODLogos throws', async () => {
+ const { cleanupUnusedVODLogos } = setupMocks({ unusedCount: 4 });
+ cleanupUnusedVODLogos.mockRejectedValue(new Error('Server error'));
+ render( );
+ await waitFor(() =>
+ expect(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ ).not.toBeDisabled()
+ );
+ fireEvent.click(
+ screen.getByText(/Cleanup Unused \(4\)/).closest('button')
+ );
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ )
+ );
+ });
+ });
+
+ // ── "Delete selected" button ───────────────────────────────────────────────
+
+ describe('"Delete selected" button', () => {
+ it('is disabled when no rows are selected', () => {
+ setupMocks();
+ render( );
+ // Find the Delete button (not the cleanup button)
+ const buttons = screen.getAllByTestId('button');
+ const deleteBtn = buttons.find((b) => b.textContent.includes('Delete') && !b.textContent.includes('Cleanup'));
+ expect(deleteBtn).toBeDisabled();
+ });
+
+ it('shows row count in button label when rows are selected', async () => {
+ const logo = makeLogo({ id: 5 });
+ setupMocks({ logos: [logo] });
+ render( );
+
+ // Render row checkbox and click to select
+ const selectCol = getCol('select');
+ const { getByTestId: getRowCheckbox } = render(
+ selectCol.cell({ row: { original: logo } })
+ );
+ fireEvent.click(getRowCheckbox('checkbox'));
+
+ await waitFor(() =>
+ expect(screen.getByText(/Delete \(1\)/)).toBeInTheDocument()
+ );
+ });
+
+ it('opens delete dialog with "Delete Multiple Logos" title when multiple rows are selected', async () => {
+ const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })];
+ setupMocks({ logos });
+ render( );
+
+ // Select all via header checkbox
+ const selectCol = getCol('select');
+ const { getByTestId: getHeaderCheckbox } = render(
+ selectCol.header()
+ );
+ fireEvent.click(getHeaderCheckbox('checkbox'));
+
+ await waitFor(() =>
+ expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button'));
+
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent(
+ 'Delete Multiple Logos'
+ );
+ });
+
+ it('calls deleteVODLogos when bulk delete is confirmed', async () => {
+ const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })];
+ const { deleteVODLogos } = setupMocks({ logos });
+ render( );
+
+ const selectCol = getCol('select');
+ const { getByTestId: getHeaderCheckbox } = render(selectCol.header());
+ fireEvent.click(getHeaderCheckbox('checkbox'));
+
+ await waitFor(() =>
+ expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(deleteVODLogos).toHaveBeenCalledWith([1, 2])
+ );
+ });
+
+ it('shows success notification with count after bulk delete', async () => {
+ const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })];
+ setupMocks({ logos });
+ render( );
+
+ const selectCol = getCol('select');
+ const { getByTestId: getHeaderCheckbox } = render(selectCol.header());
+ fireEvent.click(getHeaderCheckbox('checkbox'));
+
+ await waitFor(() =>
+ expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button'));
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Success',
+ message: '2 VOD logos deleted successfully',
+ color: 'green',
+ })
+ )
+ );
+ });
+ });
+
+ // ── Delete via row actions ─────────────────────────────────────────────────
+
+ describe('delete via row actions', () => {
+ it('opens delete dialog when row delete button is clicked', () => {
+ const logo = makeLogo({ id: 5 });
+ setupMocks({ logos: [logo] });
+ render( );
+
+ const { container } = render(
+ getCol('actions').cell({ row: { original: logo } })
+ );
+ fireEvent.click(
+ within(container).getByTestId('icon-square-minus').closest('button')
+ );
+
+ expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument();
+ });
+
+ it('shows "Delete Logo" title for single row delete', () => {
+ const logo = makeLogo({ id: 5 });
+ setupMocks({ logos: [logo] });
+ render( );
+
+ const { container } = render(
+ getCol('actions').cell({ row: { original: logo } })
+ );
+ fireEvent.click(
+ within(container).getByTestId('icon-square-minus').closest('button')
+ );
+
+ expect(screen.getByTestId('confirm-title')).toHaveTextContent(
+ 'Delete Logo'
+ );
+ });
+
+ it('closes delete dialog on cancel', () => {
+ const logo = makeLogo({ id: 5 });
+ setupMocks({ logos: [logo] });
+ render( );
+
+ const { container } = render(
+ getCol('actions').cell({ row: { original: logo } })
+ );
+ fireEvent.click(
+ within(container).getByTestId('icon-square-minus').closest('button')
+ );
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Single delete confirmation ─────────────────────────────────────────────
+
+ describe('single delete confirmation', () => {
+ const setupSingleDeleteFlow = () => {
+ const logo = makeLogo({ id: 5, name: 'ESPN Logo' });
+ const mocks = setupMocks({ logos: [logo] });
+ render( );
+
+ const { container } = render(
+ getCol('actions').cell({ row: { original: logo } })
+ );
+ fireEvent.click(
+ within(container).getByTestId('icon-square-minus').closest('button')
+ );
+ return { logo, ...mocks };
+ };
+
+ it('calls deleteVODLogo with the logo id when confirmed', async () => {
+ const { deleteVODLogo } = setupSingleDeleteFlow();
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() => expect(deleteVODLogo).toHaveBeenCalledWith(5));
+ });
+
+ it('shows success notification after single delete', async () => {
+ setupSingleDeleteFlow();
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Success',
+ message: 'VOD logo deleted successfully',
+ color: 'green',
+ })
+ )
+ );
+ });
+
+ it('closes dialog after single delete', async () => {
+ setupSingleDeleteFlow();
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() =>
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
+ );
+ });
+
+ it('shows error notification when deleteVODLogo throws', async () => {
+ const logo = makeLogo({ id: 5 });
+ const { deleteVODLogo } = setupMocks({ logos: [logo] });
+ deleteVODLogo.mockRejectedValue(new Error('Network error'));
+
+ render( );
+ const { container } = render(
+ getCol('actions').cell({ row: { original: logo } })
+ );
+ fireEvent.click(
+ within(container).getByTestId('icon-square-minus').closest('button')
+ );
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Error',
+ message: 'Network error',
+ color: 'red',
+ })
+ )
+ );
+ });
+
+ it('closes dialog even when deleteVODLogo throws', async () => {
+ const logo = makeLogo({ id: 5 });
+ const { deleteVODLogo } = setupMocks({ logos: [logo] });
+ deleteVODLogo.mockRejectedValue(new Error('Network error'));
+
+ render( );
+ const { container } = render(
+ getCol('actions').cell({ row: { original: logo } })
+ );
+ fireEvent.click(
+ within(container).getByTestId('icon-square-minus').closest('button')
+ );
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+
+ await waitFor(() =>
+ expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
+ );
+ });
+ });
+
+ // ── Pagination ─────────────────────────────────────────────────────────────
+
+ describe('pagination', () => {
+ it('calls fetchVODLogos with page 2 when next page is clicked', () => {
+ const { fetchVODLogos } = setupMocks({ totalCount: 100 });
+ render( );
+ fireEvent.click(screen.getByTestId('pagination-next'));
+ expect(fetchVODLogos).toHaveBeenCalledWith(
+ expect.objectContaining({ page: 2 })
+ );
+ });
+
+ it('resets to page 1 when page size changes', () => {
+ const { fetchVODLogos } = setupMocks({ totalCount: 200 });
+ render( );
+
+ // Go to page 2 first
+ fireEvent.click(screen.getByTestId('pagination-next'));
+ expect(fetchVODLogos).toHaveBeenCalledWith(
+ expect.objectContaining({ page: 2 })
+ );
+
+ // Change page size — should reset to page 1
+ fireEvent.change(screen.getByTestId('page-size-select'), {
+ target: { value: '50' },
+ });
+ expect(fetchVODLogos).toHaveBeenCalledWith(
+ expect.objectContaining({ page: 1, page_size: 50 })
+ );
+ });
+
+ it('renders correct pagination string based on page and totalCount', () => {
+ setupMocks({ totalCount: 75 });
+ render( );
+ expect(screen.getByText('1 to 25 of 75')).toBeInTheDocument();
+ });
+
+ it('passes correct pageCount to useTable', () => {
+ setupMocks({ totalCount: 75 });
+ render( );
+ // ceil(75 / 25) = 3
+ expect(capturedTableOptions.pageCount).toBe(3);
+ });
+
+ it('updates pagination string after changing page size', () => {
+ setupMocks({ totalCount: 75 });
+ render( );
+ fireEvent.change(screen.getByTestId('page-size-select'), {
+ target: { value: '50' },
+ });
+ expect(screen.getByText('1 to 50 of 75')).toBeInTheDocument();
+ });
+ });
+
+ // ── Column cell renderers ──────────────────────────────────────────────────
+
+ describe('usage column', () => {
+ it('shows "Unused" badge when movie_count and series_count are both 0', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('usage').cell({
+ row: {
+ original: { movie_count: 0, series_count: 0, item_names: [] },
+ },
+ })
+ );
+ expect(getByText('Unused')).toBeInTheDocument();
+ });
+
+ it('shows movie count label when only movies use the logo', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('usage').cell({
+ row: {
+ original: {
+ movie_count: 2,
+ series_count: 0,
+ item_names: ['Movie A', 'Movie B'],
+ },
+ },
+ })
+ );
+ expect(getByText('2 movies')).toBeInTheDocument();
+ });
+
+ it('uses singular "movie" for exactly 1 movie', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('usage').cell({
+ row: {
+ original: { movie_count: 1, series_count: 0, item_names: ['A'] },
+ },
+ })
+ );
+ expect(getByText('1 movie')).toBeInTheDocument();
+ });
+
+ it('shows combined item count when both movies and series are used', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('usage').cell({
+ row: {
+ original: {
+ movie_count: 2,
+ series_count: 1,
+ item_names: ['A', 'B', 'C'],
+ },
+ },
+ })
+ );
+ expect(getByText('3 items')).toBeInTheDocument();
+ });
+
+ it('shows series count label when only series use the logo', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('usage').cell({
+ row: {
+ original: {
+ movie_count: 0,
+ series_count: 3,
+ item_names: ['S1', 'S2', 'S3'],
+ },
+ },
+ })
+ );
+ expect(getByText('3 series')).toBeInTheDocument();
+ });
+ });
+
+ describe('url column', () => {
+ it('renders the URL text', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('url').cell({ getValue: () => 'http://example.com/logo.png' })
+ );
+ expect(getByText('http://example.com/logo.png')).toBeInTheDocument();
+ });
+
+ it('shows ExternalLink icon for http URLs', () => {
+ setupMocks();
+ render( );
+ const { getByTestId } = render(
+ getCol('url').cell({ getValue: () => 'http://example.com/logo.png' })
+ );
+ expect(getByTestId('icon-external-link')).toBeInTheDocument();
+ });
+
+ it('hides ExternalLink for non-http (local) URLs', () => {
+ setupMocks();
+ render( );
+ const { queryByTestId } = render(
+ getCol('url').cell({ getValue: () => '/data/logos/logo.png' })
+ );
+ expect(queryByTestId('icon-external-link')).not.toBeInTheDocument();
+ });
+
+ it('opens URL in new tab when ExternalLink is clicked', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ setupMocks();
+ render( );
+ const { getByTestId } = render(
+ getCol('url').cell({ getValue: () => 'http://example.com/logo.png' })
+ );
+ fireEvent.click(getByTestId('icon-external-link').closest('button'));
+ expect(openSpy).toHaveBeenCalledWith(
+ 'http://example.com/logo.png',
+ '_blank'
+ );
+ openSpy.mockRestore();
+ });
+ });
+
+ describe('name column', () => {
+ it('renders the logo name', () => {
+ setupMocks();
+ render( );
+ const { getByText } = render(
+ getCol('name').cell({ getValue: () => 'ESPN Logo' })
+ );
+ expect(getByText('ESPN Logo')).toBeInTheDocument();
+ });
+ });
+
+ describe('cache_url (Preview) column', () => {
+ it('renders an img with the correct src and alt', () => {
+ setupMocks();
+ render( );
+ const { getByAltText } = render(
+ getCol('cache_url').cell({
+ getValue: () => '/cache/espn.png',
+ row: { original: { name: 'ESPN Logo' } },
+ })
+ );
+ const img = getByAltText('ESPN Logo');
+ expect(img).toHaveAttribute('src', '/cache/espn.png');
+ });
+ });
+
+ describe('select column', () => {
+ it('renders an unchecked header checkbox when nothing is selected', () => {
+ const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })];
+ setupMocks({ logos });
+ render( );
+ const { getByTestId } = render(getCol('select').header());
+ expect(getByTestId('checkbox')).not.toBeChecked();
+ });
+
+ it('renders an unchecked row checkbox for an unselected row', () => {
+ const logo = makeLogo({ id: 5 });
+ setupMocks({ logos: [logo] });
+ render( );
+ const { getByTestId } = render(
+ getCol('select').cell({ row: { original: logo } })
+ );
+ expect(getByTestId('checkbox')).not.toBeChecked();
+ });
+ });
+
+ // ── useTable options ───────────────────────────────────────────────────────
+
+ describe('useTable options', () => {
+ it('passes enablePagination: false', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enablePagination).toBe(false);
+ });
+
+ it('passes enableRowSelection: true', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.enableRowSelection).toBe(true);
+ });
+
+ it('passes manualPagination: true', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.manualPagination).toBe(true);
+ });
+
+ it('passes logos as data', () => {
+ const logos = [makeLogo({ id: 10 }), makeLogo({ id: 20 })];
+ setupMocks({ logos });
+ render( );
+ expect(capturedTableOptions.data).toEqual(logos);
+ });
+
+ it('passes allRowIds derived from logo ids', () => {
+ const logos = [makeLogo({ id: 10 }), makeLogo({ id: 20 })];
+ setupMocks({ logos });
+ render( );
+ expect(capturedTableOptions.allRowIds).toEqual([10, 20]);
+ });
+
+ it('passes manualSorting: false', () => {
+ setupMocks();
+ render( );
+ expect(capturedTableOptions.manualSorting).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js
index 5ab6ed48..3bc642af 100644
--- a/frontend/src/utils/__tests__/dateTimeUtils.test.js
+++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js
@@ -73,11 +73,21 @@ describe('dateTimeUtils', () => {
it('should handle strict parsing', () => {
// With strict=true, a date that doesn't match the format should be invalid
- const invalid = dateTimeUtils.initializeTime('15-01-2024', 'YYYY-MM-DD', null, true);
+ const invalid = dateTimeUtils.initializeTime(
+ '15-01-2024',
+ 'YYYY-MM-DD',
+ null,
+ true
+ );
expect(invalid.isValid()).toBe(false);
// With strict=true and a matching format, it should be valid
- const valid = dateTimeUtils.initializeTime('2024-01-15', 'YYYY-MM-DD', null, true);
+ const valid = dateTimeUtils.initializeTime(
+ '2024-01-15',
+ 'YYYY-MM-DD',
+ null,
+ true
+ );
expect(valid.isValid()).toBe(true);
});
});
@@ -787,4 +797,141 @@ describe('dateTimeUtils', () => {
expect(dateTimeUtils.getMillisecond(date)).toBe(0);
});
});
+
+ describe('formatDuration', () => {
+ describe('default precision (hms)', () => {
+ it('should return "0:00" for 0 seconds', () => {
+ expect(dateTimeUtils.formatDuration(0)).toBe('0:00');
+ });
+
+ it('should return "0:00" for falsy input', () => {
+ expect(dateTimeUtils.formatDuration(null)).toBe('0:00');
+ expect(dateTimeUtils.formatDuration(undefined)).toBe('0:00');
+ });
+
+ it('should return custom zeroValue when seconds is 0', () => {
+ expect(dateTimeUtils.formatDuration(0, { zeroValue: 'N/A' })).toBe(
+ 'N/A'
+ );
+ });
+
+ it('should format seconds under 1 minute without hours', () => {
+ expect(dateTimeUtils.formatDuration(45)).toBe('0:45');
+ });
+
+ it('should format minutes and seconds without hours when under 1 hour', () => {
+ expect(dateTimeUtils.formatDuration(90)).toBe('1:30');
+ });
+
+ it('should format minutes with padded seconds', () => {
+ expect(dateTimeUtils.formatDuration(65)).toBe('1:05');
+ });
+
+ it('should format hours when >= 1 hour', () => {
+ expect(dateTimeUtils.formatDuration(3661)).toBe('01:01:01');
+ });
+
+ it('should pad all segments when hours are present', () => {
+ expect(dateTimeUtils.formatDuration(3600 + 60 + 5)).toBe('01:01:05');
+ });
+
+ it('should handle exactly 1 hour', () => {
+ expect(dateTimeUtils.formatDuration(3600)).toBe('01:00:00');
+ });
+
+ it('should handle negative seconds by taking absolute value', () => {
+ expect(dateTimeUtils.formatDuration(-90)).toBe('1:30');
+ });
+
+ it('should show hours when alwaysShowHours is true even under 1 hour', () => {
+ expect(
+ dateTimeUtils.formatDuration(90, { alwaysShowHours: true })
+ ).toBe('00:01:30');
+ });
+
+ it('should show hours when alwaysShowHours is true for 0 minutes', () => {
+ expect(
+ dateTimeUtils.formatDuration(45, { alwaysShowHours: true })
+ ).toBe('00:00:45');
+ });
+ });
+
+ describe('precision: hm', () => {
+ it('should return minutes only when under 1 hour and no alwaysShowHours', () => {
+ expect(dateTimeUtils.formatDuration(90, { precision: 'hm' })).toBe('1');
+ });
+
+ it('should return HH:MM when >= 1 hour', () => {
+ expect(dateTimeUtils.formatDuration(3661, { precision: 'hm' })).toBe(
+ '01:01'
+ );
+ });
+
+ it('should return HH:MM when alwaysShowHours is true', () => {
+ expect(
+ dateTimeUtils.formatDuration(90, {
+ precision: 'hm',
+ alwaysShowHours: true,
+ })
+ ).toBe('00:01');
+ });
+
+ it('should return "0:00" for 0 seconds', () => {
+ expect(dateTimeUtils.formatDuration(0, { precision: 'hm' })).toBe(
+ '0:00'
+ );
+ });
+ });
+
+ describe('precision: human', () => {
+ it('should format sub-hour content as minutes with suffix', () => {
+ expect(dateTimeUtils.formatDuration(2700, { precision: 'human' })).toBe(
+ '45m'
+ );
+ });
+
+ it('should floor partial minutes for short content', () => {
+ expect(dateTimeUtils.formatDuration(90, { precision: 'human' })).toBe(
+ '1m'
+ );
+ });
+
+ it('should format hour-plus content as hours and minutes', () => {
+ expect(dateTimeUtils.formatDuration(5400, { precision: 'human' })).toBe(
+ '1h 30m'
+ );
+ });
+
+ it('should return custom zeroValue when seconds is 0', () => {
+ expect(
+ dateTimeUtils.formatDuration(0, {
+ precision: 'human',
+ zeroValue: 'Unknown',
+ })
+ ).toBe('Unknown');
+ });
+ });
+
+ describe('precision: m', () => {
+ it('should return total minutes as integer', () => {
+ expect(dateTimeUtils.formatDuration(90, { precision: 'm' })).toBe('1');
+ });
+
+ it('should return total minutes across hours', () => {
+ expect(dateTimeUtils.formatDuration(3661, { precision: 'm' })).toBe(
+ '61'
+ );
+ });
+
+ it('should truncate partial minutes', () => {
+ expect(dateTimeUtils.formatDuration(89, { precision: 'm' })).toBe('1');
+ });
+
+ it('should return "0:00" for 0 seconds', () => {
+ expect(dateTimeUtils.formatDuration(0, { precision: 'm' })).toBe(
+ '0:00'
+ );
+ });
+ });
+ });
});
diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js
index 5bec7d9a..2514571a 100644
--- a/frontend/src/utils/cards/VodConnectionCardUtils.js
+++ b/frontend/src/utils/cards/VodConnectionCardUtils.js
@@ -1,27 +1,5 @@
import { format, getNowMs, toFriendlyDuration } from '../dateTimeUtils.js';
-export const formatDuration = (seconds) => {
- if (!seconds) return 'Unknown';
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
-};
-
-// Format time for display (e.g., "1:23:45" or "23:45")
-export const formatTime = (seconds) => {
- if (!seconds || seconds === 0) return '0:00';
-
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
- } else {
- return `${minutes}:${secs.toString().padStart(2, '0')}`;
- }
-};
-
export const getMovieDisplayTitle = (vodContent) => {
return vodContent.content_name;
};
diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
index 489b781a..0f266693 100644
--- a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
+++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
@@ -2,87 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as VodConnectionCardUtils from '../VodConnectionCardUtils';
import * as dateTimeUtils from '../../dateTimeUtils.js';
-vi.mock('../../dateTimeUtils.js');
+vi.mock('../../dateTimeUtils.js', () => ({
+ getNowMs: vi.fn(),
+ format: vi.fn(),
+ toFriendlyDuration: vi.fn(),
+}));
describe('VodConnectionCardUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
- describe('formatDuration', () => {
- it('should format duration with hours and minutes when hours > 0', () => {
- const result = VodConnectionCardUtils.formatDuration(3661); // 1h 1m 1s
- expect(result).toBe('1h 1m');
- });
-
- it('should format duration with only minutes when less than an hour', () => {
- const result = VodConnectionCardUtils.formatDuration(125); // 2m 5s
- expect(result).toBe('2m');
- });
-
- it('should format duration with 0 minutes when less than 60 seconds', () => {
- const result = VodConnectionCardUtils.formatDuration(45);
- expect(result).toBe('0m');
- });
-
- it('should handle multiple hours correctly', () => {
- const result = VodConnectionCardUtils.formatDuration(7325); // 2h 2m 5s
- expect(result).toBe('2h 2m');
- });
-
- it('should return Unknown for zero seconds', () => {
- const result = VodConnectionCardUtils.formatDuration(0);
- expect(result).toBe('Unknown');
- });
-
- it('should return Unknown for null', () => {
- const result = VodConnectionCardUtils.formatDuration(null);
- expect(result).toBe('Unknown');
- });
-
- it('should return Unknown for undefined', () => {
- const result = VodConnectionCardUtils.formatDuration(undefined);
- expect(result).toBe('Unknown');
- });
- });
-
- describe('formatTime', () => {
- it('should format time with hours when hours > 0', () => {
- const result = VodConnectionCardUtils.formatTime(3665); // 1:01:05
- expect(result).toBe('1:01:05');
- });
-
- it('should format time without hours when less than an hour', () => {
- const result = VodConnectionCardUtils.formatTime(125); // 2:05
- expect(result).toBe('2:05');
- });
-
- it('should pad minutes and seconds with zeros', () => {
- const result = VodConnectionCardUtils.formatTime(3605); // 1:00:05
- expect(result).toBe('1:00:05');
- });
-
- it('should handle only seconds', () => {
- const result = VodConnectionCardUtils.formatTime(45); // 0:45
- expect(result).toBe('0:45');
- });
-
- it('should return 0:00 for zero seconds', () => {
- const result = VodConnectionCardUtils.formatTime(0);
- expect(result).toBe('0:00');
- });
-
- it('should return 0:00 for null', () => {
- const result = VodConnectionCardUtils.formatTime(null);
- expect(result).toBe('0:00');
- });
-
- it('should return 0:00 for undefined', () => {
- const result = VodConnectionCardUtils.formatTime(undefined);
- expect(result).toBe('0:00');
- });
- });
-
describe('getMovieDisplayTitle', () => {
it('should return content_name from vodContent', () => {
const vodContent = { content_name: 'The Matrix' };
diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js
index f973e0b0..2b613a32 100644
--- a/frontend/src/utils/dateTimeUtils.js
+++ b/frontend/src/utils/dateTimeUtils.js
@@ -18,7 +18,12 @@ export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
export const convertToSec = (dateTime) => dayjs(dateTime).unix();
-export const initializeTime = (dateTime, format = null, locale = null, strict = false) => {
+export const initializeTime = (
+ dateTime,
+ format = null,
+ locale = null,
+ strict = false
+) => {
if (format && locale) {
return dayjs(dateTime, format, locale, strict);
} else if (format) {
@@ -26,7 +31,7 @@ export const initializeTime = (dateTime, format = null, locale = null, strict =
} else {
return dayjs(dateTime);
}
-}
+};
export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
@@ -90,7 +95,8 @@ export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value);
export const setSecond = (dateTime, value) => dayjs(dateTime).second(value);
-export const setMillisecond = (dateTime, value) => dayjs(dateTime).millisecond(value);
+export const setMillisecond = (dateTime, value) =>
+ dayjs(dateTime).millisecond(value);
export const getMonth = (dateTime) => dayjs(dateTime).month();
@@ -372,3 +378,41 @@ export const MONTH_ABBR = [
'nov',
'dec',
];
+
+/**
+ * @param {number} seconds
+ * @param {object} [options]
+ * @param {'hms'|'hm'|'m'|'human'} [options.precision='hms'] - Segments to include
+ * @param {boolean} [options.alwaysShowHours=false] - Always include hours segment
+ * @param {string|null} [options.zeroValue=null] - Return this when seconds is 0/falsy
+ */
+export const formatDuration = (seconds, options = {}) => {
+ const {
+ precision = 'hms',
+ alwaysShowHours = false,
+ zeroValue = null,
+ } = options;
+
+ if (!seconds || seconds === 0) return zeroValue ?? '0:00';
+
+ const abs = Math.abs(seconds);
+ const h = Math.floor(abs / 3600);
+ const m = Math.floor((abs % 3600) / 60);
+ const s = Math.floor(abs % 60);
+
+ const mm = m.toString().padStart(2, '0');
+ const ss = s.toString().padStart(2, '0');
+ const hh = h.toString().padStart(2, '0');
+
+ switch (precision) {
+ case 'human':
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
+ case 'm':
+ return `${Math.floor(abs / 60)}`;
+ case 'hm':
+ return alwaysShowHours || h > 0 ? `${hh}:${mm}` : `${m}`;
+ case 'hms':
+ default:
+ return alwaysShowHours || h > 0 ? `${hh}:${mm}:${ss}` : `${m}:${ss}`;
+ }
+};
diff --git a/frontend/src/utils/forms/ChannelUtils.js b/frontend/src/utils/forms/ChannelUtils.js
index cc3743e3..7eabab0a 100644
--- a/frontend/src/utils/forms/ChannelUtils.js
+++ b/frontend/src/utils/forms/ChannelUtils.js
@@ -34,15 +34,13 @@ export const createLogo = (newLogoData) => {
const setChannelEPG = (channel, values) => {
return API.setChannelEPG(channel.id, values.epg_data_id);
};
-const updateChannel = (values) => {
+export const updateChannel = (values) => {
return API.updateChannel(values);
};
export const addChannel = (channel) => {
return API.addChannel(channel);
};
-export const requeryChannels = () => {
- API.requeryChannels();
-};
+export const requeryChannels = () => API.requeryChannels();
// PATCH semantic: `override: null` deletes the override row; per-field
// nulls only clear the matching field.
diff --git a/frontend/src/utils/forms/__tests__/ChannelUtils.test.js b/frontend/src/utils/forms/__tests__/ChannelUtils.test.js
index 88002c09..53cd0d99 100644
--- a/frontend/src/utils/forms/__tests__/ChannelUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/ChannelUtils.test.js
@@ -114,13 +114,20 @@ describe('ChannelUtils', () => {
describe('requeryChannels', () => {
it('calls API.requeryChannels', () => {
+ vi.mocked(API.requeryChannels).mockResolvedValue(undefined);
requeryChannels();
expect(API.requeryChannels).toHaveBeenCalledTimes(1);
});
- it('returns undefined', () => {
- const result = requeryChannels();
- expect(result).toBeUndefined();
+ it('returns the promise from API.requeryChannels', async () => {
+ const response = { results: [{ id: 1 }] };
+ vi.mocked(API.requeryChannels).mockResolvedValue(response);
+ await expect(requeryChannels()).resolves.toBe(response);
+ });
+
+ it('propagates rejection from API.requeryChannels', async () => {
+ vi.mocked(API.requeryChannels).mockRejectedValue(new Error('network'));
+ await expect(requeryChannels()).rejects.toThrow('network');
});
});
diff --git a/frontend/src/utils/tables/ChannelTableStreamsUtils.js b/frontend/src/utils/tables/ChannelTableStreamsUtils.js
new file mode 100644
index 00000000..8fdbc7a7
--- /dev/null
+++ b/frontend/src/utils/tables/ChannelTableStreamsUtils.js
@@ -0,0 +1,100 @@
+import API from '../../api.js';
+import { formatBytes } from '../networkUtils.js';
+import { formatDuration } from '../dateTimeUtils.js';
+
+const categoryMapping = {
+ basic: [
+ 'resolution',
+ 'video_codec',
+ 'source_fps',
+ 'audio_codec',
+ 'audio_channels',
+ ],
+ video: [
+ 'video_bitrate',
+ 'pixel_format',
+ 'width',
+ 'height',
+ 'aspect_ratio',
+ 'frame_rate',
+ ],
+ audio: [
+ 'audio_bitrate',
+ 'sample_rate',
+ 'audio_format',
+ 'audio_channels_layout',
+ ],
+ technical: [
+ 'stream_type',
+ 'container_format',
+ 'duration',
+ 'file_size',
+ 'ffmpeg_output_bitrate',
+ 'input_bitrate',
+ ],
+ other: [],
+};
+
+export const categorizeStreamStats = (stats) => {
+ if (!stats)
+ return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
+
+ const categories = {
+ basic: {},
+ video: {},
+ audio: {},
+ technical: {},
+ other: {},
+ };
+
+ Object.entries(stats).forEach(([key, value]) => {
+ let categorized = false;
+ for (const [category, keys] of Object.entries(categoryMapping)) {
+ if (keys.includes(key)) {
+ categories[category][key] = value;
+ categorized = true;
+ break;
+ }
+ }
+ if (!categorized) {
+ categories.other[key] = value;
+ }
+ });
+
+ return categories;
+};
+
+export const formatStatValue = (key, value) => {
+ if (value === null || value === undefined) return 'N/A';
+
+ switch (key) {
+ case 'video_bitrate':
+ case 'audio_bitrate':
+ case 'ffmpeg_output_bitrate':
+ return `${value} kbps`;
+ case 'source_fps':
+ case 'frame_rate':
+ return `${value} fps`;
+ case 'sample_rate':
+ return `${value} Hz`;
+ case 'file_size':
+ return typeof value === 'number' ? formatBytes(value) : value;
+ case 'duration':
+ return typeof value === 'number'
+ ? formatDuration(value, { alwaysShowHours: true })
+ : value;
+ default:
+ return value.toString();
+ }
+};
+
+export const formatStatKey = (key) => {
+ return key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
+};
+
+export const getChannelStreamStats = (channelId, since, ids) => {
+ return API.getChannelStreamStats(channelId, since, ids);
+};
+export const reorderChannelStreams = (channelId, streamIds) => {
+ return API.reorderChannelStreams(channelId, streamIds);
+};
diff --git a/frontend/src/utils/tables/ChannelsTableUtils.js b/frontend/src/utils/tables/ChannelsTableUtils.js
new file mode 100644
index 00000000..a5cadb45
--- /dev/null
+++ b/frontend/src/utils/tables/ChannelsTableUtils.js
@@ -0,0 +1,233 @@
+import {
+ normalizeFieldValue,
+ OVERRIDABLE_FIELDS,
+} from '../forms/ChannelUtils.js';
+import API from '../../api.js';
+
+// Inline edits on auto-synced channels route into the override row so
+// sync cannot overwrite them. If the new value matches the provider's,
+// clear that field's override instead of writing a duplicate. Manual
+// channels keep direct Channel.* writes.
+export const buildInlinePatch = (rowOriginal, fieldId, newValue) => {
+ if (rowOriginal?.auto_created && OVERRIDABLE_FIELDS.includes(fieldId)) {
+ // Normalize both sides so a stringified form value compares
+ // cleanly against the typed provider value.
+ const formValue = normalizeFieldValue(fieldId, newValue);
+ const providerValue = normalizeFieldValue(fieldId, rowOriginal[fieldId]);
+ const overrideFieldValue = formValue === providerValue ? null : formValue;
+ return {
+ id: rowOriginal.id,
+ override: { [fieldId]: overrideFieldValue },
+ };
+ }
+ const normalized =
+ newValue === undefined || newValue === '' ? null : newValue;
+ return {
+ id: rowOriginal.id,
+ [fieldId]: normalized,
+ };
+};
+
+export const getEpgOptions = (tvgsById, epgs) => {
+ const options = [{ value: 'null', label: 'Not Assigned' }];
+
+ // Convert tvgsById to an array and sort by EPG source name, then by tvg_id
+ const tvgsArray = Object.values(tvgsById).sort((a, b) => {
+ const aEpgName =
+ a.epg_source && epgs[a.epg_source]
+ ? epgs[a.epg_source].name
+ : a.epg_source || '';
+ const bEpgName =
+ b.epg_source && epgs[b.epg_source]
+ ? epgs[b.epg_source].name
+ : b.epg_source || '';
+ const epgCompare = aEpgName.localeCompare(bEpgName);
+ if (epgCompare !== 0) return epgCompare;
+ // Secondary sort by tvg_id
+ return (a.tvg_id || '').localeCompare(b.tvg_id || '');
+ });
+
+ tvgsArray.forEach((tvg) => {
+ const epgSourceName =
+ tvg.epg_source && epgs[tvg.epg_source]
+ ? epgs[tvg.epg_source].name
+ : tvg.epg_source;
+ const tvgName = tvg.name;
+ // Create a comprehensive label: "EPG Name | TVG-ID | TVG Name"
+ let label;
+ if (epgSourceName && tvg.tvg_id) {
+ label = `${epgSourceName} | ${tvg.tvg_id}`;
+ if (tvgName && tvgName !== tvg.tvg_id) {
+ label += ` | ${tvgName}`;
+ }
+ } else if (tvgName) {
+ label = tvgName;
+ } else {
+ label = `ID: ${tvg.id}`;
+ }
+
+ options.push({
+ value: String(tvg.id),
+ label: label,
+ });
+ });
+
+ return options;
+};
+
+export const getLogoOptions = (channelLogos) => {
+ const options = [
+ {
+ value: 'null',
+ label: 'Default',
+ logo: null,
+ },
+ ];
+
+ // Convert channelLogos object to array and sort by name
+ const logosArray = Object.values(channelLogos).sort((a, b) =>
+ (a.name || '').localeCompare(b.name || '')
+ );
+
+ logosArray.forEach((logo) => {
+ options.push({
+ value: String(logo.id),
+ label: logo.name || `Logo ${logo.id}`,
+ logo: logo,
+ });
+ });
+
+ return options;
+};
+
+export const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`;
+export const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`;
+export const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`;
+
+export const reorderChannel = (channelId, insertAfterId) => {
+ return API.reorderChannel(channelId, insertAfterId);
+};
+export const deleteChannel = (id) => {
+ return API.deleteChannel(id);
+};
+export const deleteChannels = (channelIds) => {
+ return API.deleteChannels(channelIds);
+};
+export const queryChannels = (params) => {
+ return API.queryChannels(params);
+};
+export const getAllChannelIds = (params) => {
+ return API.getAllChannelIds(params);
+};
+export const updateProfileChannels = (channelIds, profileId, enabled) => {
+ return API.updateProfileChannels(channelIds, profileId, enabled);
+};
+export const updateProfileChannel = (channelId, profileId, enabled) => {
+ return API.updateProfileChannel(channelId, profileId, enabled);
+};
+export const addChannelProfile = (values) => {
+ return API.addChannelProfile(values);
+};
+export const deleteChannelProfile = (id) => {
+ return API.deleteChannelProfile(id);
+};
+
+const getSortParam = (sorting) => {
+ let sortField = sorting[0].id;
+ // Map frontend column ids to backend ordering field names
+ const fieldMapping = {
+ channel_group: 'channel_group__name',
+ epg: 'epg_data__name',
+ };
+ if (fieldMapping[sortField]) {
+ sortField = fieldMapping[sortField];
+ }
+ const sortDirection = sorting[0].desc ? '-' : '';
+ return { sortField, sortDirection };
+};
+
+const applyDebouncedFilters = (debouncedFilters, params) => {
+ // Apply debounced filters
+ Object.entries(debouncedFilters).forEach(([key, value]) => {
+ if (value) {
+ if (Array.isArray(value)) {
+ // Convert null values to "null" string for URL parameter
+ const processedValue = value
+ .map((v) => (v === null ? 'null' : v))
+ .join(',');
+ params.append(key, processedValue);
+ } else {
+ params.append(key, value);
+ }
+ }
+ });
+};
+
+// Build URLs with parameters
+export const buildM3UUrl = (m3uParams, m3uUrl) => {
+ const params = new URLSearchParams();
+ if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false');
+ if (m3uParams.direct) params.append('direct', 'true');
+ if (m3uParams.tvg_id_source !== 'channel_number')
+ params.append('tvg_id_source', m3uParams.tvg_id_source);
+ if (m3uParams.output_format)
+ params.append('output_format', m3uParams.output_format);
+ if (m3uParams.output_profile)
+ params.append('output_profile', m3uParams.output_profile);
+
+ const baseUrl = m3uUrl;
+ return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
+};
+
+export const buildEPGUrl = (epgParams, epgUrl) => {
+ const params = new URLSearchParams();
+ if (!epgParams.cachedlogos) params.append('cachedlogos', 'false');
+ if (epgParams.tvg_id_source !== 'channel_number')
+ params.append('tvg_id_source', epgParams.tvg_id_source);
+ if (epgParams.days > 0) params.append('days', epgParams.days.toString());
+ if (epgParams.prev_days > 0)
+ params.append('prev_days', epgParams.prev_days.toString());
+
+ const baseUrl = epgUrl;
+ return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
+};
+
+export const buildFetchParams = ({
+ pagination,
+ sorting,
+ debouncedFilters,
+ selectedProfileId,
+ showDisabled,
+ showOnlyStreamlessChannels,
+ showOnlyStaleChannels,
+ showOnlyOverriddenChannels,
+ visibilityFilter,
+}) => {
+ const params = new URLSearchParams();
+ params.append('page', pagination.pageIndex + 1);
+ params.append('page_size', pagination.pageSize);
+ params.append('include_streams', 'true');
+
+ if (selectedProfileId !== '0')
+ params.append('channel_profile_id', selectedProfileId);
+ if (showDisabled) params.append('show_disabled', true);
+ if (showOnlyStreamlessChannels) params.append('only_streamless', true);
+ if (showOnlyStaleChannels) params.append('only_stale', true);
+ if (showOnlyOverriddenChannels) params.append('only_has_overrides', true);
+ if (visibilityFilter && visibilityFilter !== 'active')
+ params.append('visibility_filter', visibilityFilter);
+ if (sorting.length > 0) {
+ const { sortField, sortDirection } = getSortParam(sorting);
+ params.append('ordering', `${sortDirection}${sortField}`);
+ }
+
+ applyDebouncedFilters(debouncedFilters, params);
+ return params;
+};
+
+export const buildHDHRUrl = (hdhrOutputProfileId, hdhrUrl) => {
+ if (!hdhrOutputProfileId) return hdhrUrl;
+ // Insert output_profile segment before the trailing slash (or at end)
+ const base = hdhrUrl.replace(/\/$/, '');
+ return `${base}/output_profile/${hdhrOutputProfileId}`;
+};
diff --git a/frontend/src/utils/tables/EPGsTableUtils.js b/frontend/src/utils/tables/EPGsTableUtils.js
new file mode 100644
index 00000000..f409d1e2
--- /dev/null
+++ b/frontend/src/utils/tables/EPGsTableUtils.js
@@ -0,0 +1,55 @@
+import API from '../../api.js';
+
+// Helper function to format status text
+export const formatStatusText = (status) => {
+ if (!status) return 'Unknown';
+ return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
+};
+
+export const updateEpg = async (values, epg, isToggle) => {
+ return API.updateEPG({ ...values, id: epg.id }, isToggle);
+};
+export const deleteEpg = (id) => {
+ return API.deleteEPG(id);
+};
+export const refreshEpg = (id, force = false) => {
+ return API.refreshEPG(id, force);
+};
+
+export const getProgressLabel = (action) => {
+ switch (action) {
+ case 'downloading':
+ return 'Downloading';
+ case 'extracting':
+ return 'Extracting';
+ case 'parsing_channels':
+ return 'Parsing Channels';
+ case 'parsing_programs':
+ return 'Parsing Programs';
+ default:
+ return null;
+ }
+};
+
+export const getProgressInfo = (progress) => {
+ // Build additional info string from progress data
+ if (progress.message) {
+ return progress.message;
+ } else if (
+ progress.processed !== undefined &&
+ progress.channels !== undefined
+ ) {
+ return `${progress.processed.toLocaleString()} programs for ${progress.channels} channels`;
+ } else if (progress.processed !== undefined && progress.total !== undefined) {
+ return `${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`;
+ }
+ return null;
+};
+
+export const getSortedEpgs = (epgs, compareColumn, compareDesc) => {
+ return [...epgs].sort((a, b) => {
+ if (a[compareColumn] < b[compareColumn]) return compareDesc ? 1 : -1;
+ if (a[compareColumn] > b[compareColumn]) return compareDesc ? -1 : 1;
+ return 0;
+ });
+};
\ No newline at end of file
diff --git a/frontend/src/utils/tables/LogosTableUtils.js b/frontend/src/utils/tables/LogosTableUtils.js
new file mode 100644
index 00000000..b80e0a32
--- /dev/null
+++ b/frontend/src/utils/tables/LogosTableUtils.js
@@ -0,0 +1,62 @@
+import API from '../../api.js';
+
+export const getFilteredLogos = (logos, debouncedNameFilter, filtersUsed) => {
+ // Apply filters
+ let filteredLogos = Object.values(logos || {});
+
+ if (debouncedNameFilter) {
+ filteredLogos = filteredLogos.filter((logo) =>
+ logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase())
+ );
+ }
+
+ if (filtersUsed === 'used') {
+ filteredLogos = filteredLogos.filter((logo) => logo.is_used);
+ } else if (filtersUsed === 'unused') {
+ filteredLogos = filteredLogos.filter((logo) => !logo.is_used);
+ }
+
+ return filteredLogos.sort((a, b) => a.id - b.id);
+};
+
+export const deleteLogo = (id, deleteFile) => {
+ return API.deleteLogo(id, deleteFile);
+};
+export const deleteLogos = (ids, deleteFiles) => {
+ return API.deleteLogos(ids, deleteFiles);
+};
+export const cleanupUnusedLogos = (deleteFiles) => {
+ return API.cleanupUnusedLogos(deleteFiles);
+};
+
+// Generate smart label based on usage
+const categorizeUsage = (names) => {
+ const types = { channels: 0, movies: 0, series: 0 };
+
+ names.forEach((name) => {
+ if (name.startsWith('Channel:')) types.channels++;
+ else if (name.startsWith('Movie:')) types.movies++;
+ else if (name.startsWith('Series:')) types.series++;
+ });
+
+ return types;
+};
+
+// Analyze channel_names to categorize types
+export const generateUsageLabel = (channelNames, channelCount) => {
+ const types = categorizeUsage(channelNames);
+ const typeCount = Object.values(types).filter(
+ (count) => count > 0
+ ).length;
+ if (typeCount === 1) {
+ // Only one type - be specific
+ if (types.channels > 0)
+ return `${types.channels} ${types.channels !== 1 ? 'channels' : 'channel'}`;
+ if (types.movies > 0)
+ return `${types.movies} ${types.movies !== 1 ? 'movies' : 'movie'}`;
+ if (types.series > 0) return `${types.series} series`;
+ } else {
+ // Multiple types - use generic "items"
+ return `${channelCount} ${channelCount !== 1 ? 'items' : 'item'}`;
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/utils/tables/M3UsTableUtils.js b/frontend/src/utils/tables/M3UsTableUtils.js
new file mode 100644
index 00000000..9416d13f
--- /dev/null
+++ b/frontend/src/utils/tables/M3UsTableUtils.js
@@ -0,0 +1,142 @@
+import API from '../../api.js';
+import { format } from '../dateTimeUtils.js';
+import { formatDuration } from '../dateTimeUtils.js';
+import { formatSpeed } from '../networkUtils.js';
+
+export const refreshPlaylist = (id) => API.refreshPlaylist(id);
+
+export const getPlaylistAutoCreatedChannelsCount = (id) =>
+ API.getPlaylistAutoCreatedChannelsCount(id);
+
+export const deletePlaylist = (id) => API.deletePlaylist(id);
+
+export const updatePlaylist = (values, playlist, isToggle = false) =>
+ API.updatePlaylist({ ...values, id: playlist.id }, isToggle);
+
+export const formatStatusText = (status) => {
+ switch (status) {
+ case 'idle': return 'Idle';
+ case 'fetching': return 'Fetching';
+ case 'parsing': return 'Parsing';
+ case 'error': return 'Error';
+ case 'success': return 'Success';
+ case 'pending_setup': return 'Pending Setup';
+ default:
+ return status
+ ? status.charAt(0).toUpperCase() + status.slice(1)
+ : 'Unknown';
+ }
+};
+
+export const getStatusColor = (status) => {
+ switch (status) {
+ case 'idle': return 'gray.5';
+ case 'fetching': return 'blue.5';
+ case 'parsing': return 'indigo.5';
+ case 'error': return 'red.5';
+ case 'success': return 'green.5';
+ case 'pending_setup': return 'orange.5';
+ default: return 'gray.5';
+ }
+};
+
+export const getExpirationInfo = (daysLeft, earliest, fullDateFormat) => {
+ let color;
+ let label;
+ if (daysLeft < 0) {
+ color = 'red.7';
+ label = 'Expired';
+ } else if (daysLeft === 0) {
+ color = 'red.5';
+ label = 'Expires today';
+ } else if (daysLeft <= 7) {
+ color = 'orange.5';
+ label = `${daysLeft}d left`;
+ } else if (daysLeft <= 30) {
+ color = 'yellow.5';
+ label = `${daysLeft}d left`;
+ } else {
+ label = format(earliest, fullDateFormat);
+ }
+ return { color, label };
+};
+
+export const getExpirationTooltip = (allExpirations, fullDateTimeFormat, label) => {
+ return allExpirations.length > 0
+ ? allExpirations
+ .map(
+ (e) =>
+ `${e.profile_name}: ${format(e.exp_date, fullDateTimeFormat)}${
+ !e.is_active ? ' (inactive)' : ''
+ }`
+ )
+ .join('\n')
+ : label;
+};
+
+export const getSortedPlaylists = (playlists, compareColumn, compareDesc) => {
+ return playlists
+ .filter((playlist) => playlist.locked === false)
+ .sort((a, b) => {
+ const aVal = a[compareColumn];
+ const bVal = b[compareColumn];
+
+ if (aVal == null && bVal == null) return 0;
+ if (aVal == null) return 1;
+ if (bVal == null) return -1;
+
+ const comparison =
+ typeof aVal === 'string'
+ ? aVal.localeCompare(bVal)
+ : aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
+
+ return compareDesc ? -comparison : comparison;
+ });
+};
+
+export const getStatusContent = (data) => {
+ if (data.progress === 100) return null;
+
+ switch (data.action) {
+ case 'initializing':
+ return { type: 'initializing' };
+
+ case 'downloading':
+ if (data.progress === 0) return { type: 'simple', label: 'Downloading...' };
+ return {
+ type: 'downloading',
+ progress: parseInt(data.progress),
+ speed: formatSpeed(data.speed),
+ timeRemaining: data.time_remaining
+ ? formatDuration(data.time_remaining)
+ : 'calculating...',
+ };
+
+ case 'processing_groups':
+ if (data.progress === 0) return { type: 'simple', label: 'Processing groups...' };
+ return {
+ type: 'groups',
+ progress: parseInt(data.progress),
+ elapsedTime: formatDuration(data.elapsed_time),
+ groupsProcessed: data.groups_processed,
+ };
+
+ case 'parsing':
+ if (data.progress === 0) return { type: 'simple', label: 'Parsing...' };
+ return {
+ type: 'parsing',
+ progress: parseInt(data.progress),
+ elapsedTime: formatDuration(data.elapsed_time),
+ timeRemaining: data.time_remaining
+ ? formatDuration(data.time_remaining)
+ : 'calculating...',
+ streamsProcessed: data.streams_processed,
+ };
+
+ default:
+ return data.status === 'error'
+ ? { type: 'error', error: data.error }
+ : { type: 'simple', label: `${data.action || 'Processing'}...` };
+ }
+};
+
diff --git a/frontend/src/utils/tables/OutputProfilesTableUtils.js b/frontend/src/utils/tables/OutputProfilesTableUtils.js
new file mode 100644
index 00000000..dc2761bd
--- /dev/null
+++ b/frontend/src/utils/tables/OutputProfilesTableUtils.js
@@ -0,0 +1,6 @@
+import API from '../../api.js';
+
+export const updateOutputProfile = (values) => API.updateOutputProfile(values);
+export const deleteOutputProfile = async (id) => {
+ await API.deleteOutputProfile(id);
+};
diff --git a/frontend/src/utils/tables/StreamsTableUtils.js b/frontend/src/utils/tables/StreamsTableUtils.js
new file mode 100644
index 00000000..238446dc
--- /dev/null
+++ b/frontend/src/utils/tables/StreamsTableUtils.js
@@ -0,0 +1,127 @@
+import API from '../../api.js';
+
+export const addStreamsToChannel = (channelId, existingStreams, newStreams) => {
+ return API.addStreamsToChannel(channelId, existingStreams, newStreams);
+};
+export const queryStreamsTable = (params) => {
+ return API.queryStreamsTable(params);
+};
+export const getStreams = (streamIds) => {
+ return API.getStreams(streamIds);
+};
+export const createChannelsFromStreamsAsync = (
+ streamIds,
+ channelProfileIds,
+ startingChannelNumber
+) =>
+ API.createChannelsFromStreamsAsync(
+ streamIds,
+ channelProfileIds,
+ startingChannelNumber
+ );
+export const deleteStream = (id) => API.deleteStream(id);
+export const deleteStreams = (ids) => {
+ return API.deleteStreams(ids);
+};
+export const requeryStreams = () => {
+ return API.requeryStreams();
+};
+export const createChannelFromStream = (values) =>
+ API.createChannelFromStream(values);
+export const getAllStreamIds = (params) => {
+ return API.getAllStreamIds(params);
+};
+export const getStreamFilterOptions = (params) => {
+ return API.getStreamFilterOptions(params);
+};
+
+export const getStatsTooltip = (stats) => {
+ // Build compact display (resolution + video codec)
+ const parts = [];
+ if (stats.resolution) {
+ // Convert "1920x1080" to "1080p" format
+ const height = stats.resolution.split('x')[1];
+ if (height) parts.push(`${height}p`);
+ }
+ if (stats.video_codec) {
+ parts.push(stats.video_codec.toUpperCase());
+ }
+ const compactDisplay = parts.length > 0 ? parts.join(' ') : '-';
+
+ // Build tooltip content with friendly labels
+ const tooltipLines = [];
+ if (stats.resolution) tooltipLines.push(`Resolution: ${stats.resolution}`);
+ if (stats.video_codec)
+ tooltipLines.push(`Video Codec: ${stats.video_codec.toUpperCase()}`);
+ if (stats.video_bitrate)
+ tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`);
+ if (stats.source_fps)
+ tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`);
+ if (stats.audio_codec)
+ tooltipLines.push(`Audio Codec: ${stats.audio_codec.toUpperCase()}`);
+ if (stats.audio_channels)
+ tooltipLines.push(`Audio Channels: ${stats.audio_channels}`);
+ if (stats.audio_bitrate)
+ tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`);
+
+ const tooltipContent =
+ tooltipLines.length > 0
+ ? tooltipLines.join('\n')
+ : 'No source info available';
+ return { compactDisplay, tooltipContent };
+};
+
+export const appendFetchPageParams = (params, pagination, sorting) => {
+ params.append('page', pagination.pageIndex + 1);
+ params.append('page_size', pagination.pageSize);
+
+ if (sorting.length > 0) {
+ const columnId = sorting[0].id;
+ const fieldMapping = {
+ name: 'name',
+ group: 'channel_group__name',
+ m3u: 'm3u_account__name',
+ tvg_id: 'tvg_id',
+ };
+ const sortField = fieldMapping[columnId] || columnId;
+ const sortDirection = sorting[0].desc ? '-' : '';
+ params.append('ordering', `${sortDirection}${sortField}`);
+ }
+};
+
+export const getChannelProfileIds = (profileIds, selectedProfileId) => {
+ // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise
+ if (profileIds) {
+ if (profileIds.includes('none')) {
+ return [];
+ } else if (profileIds.includes('all')) {
+ return null;
+ } else {
+ return profileIds.map((id) => parseInt(id));
+ }
+ } else {
+ return selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null;
+ }
+};
+
+export const getChannelNumberValue = (mode, startNumber) => {
+ return mode === 'provider'
+ ? null
+ : mode === 'auto'
+ ? 0
+ : mode === 'highest'
+ ? -1
+ : Number(startNumber);
+};
+
+export const getFilterParams = (debouncedFilters) => {
+ const params = new URLSearchParams();
+ Object.entries(debouncedFilters).forEach(([key, value]) => {
+ if (typeof value === 'boolean') {
+ if (value) params.append(key, 'true');
+ } else if (value !== null && value !== undefined && value !== '') {
+ params.append(key, String(value));
+ }
+ });
+ return params;
+};
diff --git a/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js
new file mode 100644
index 00000000..f65e7f2c
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js
@@ -0,0 +1,288 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as ChannelTableStreamsUtils from '../ChannelTableStreamsUtils';
+
+// ── Dependency mocks ────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ getChannelStreamStats: vi.fn(),
+ reorderChannelStreams: vi.fn(),
+ },
+}));
+
+vi.mock('../../networkUtils.js', () => ({
+ formatBytes: vi.fn((bytes) => `${bytes} B`),
+}));
+
+vi.mock('../../dateTimeUtils.js', () => ({
+ formatDuration: vi.fn((seconds) => `duration-${seconds}`),
+}));
+
+import API from '../../../api.js';
+import { formatBytes } from '../../networkUtils.js';
+import { formatDuration } from '../../dateTimeUtils.js';
+
+describe('ChannelTableStreamsUtils', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ // ── categorizeStreamStats ───────────────────────────────────────────────────
+ describe('categorizeStreamStats', () => {
+ it('returns empty categories for null input', () => {
+ expect(ChannelTableStreamsUtils.categorizeStreamStats(null)).toEqual({
+ basic: {},
+ video: {},
+ audio: {},
+ technical: {},
+ other: {},
+ });
+ });
+
+ it('returns empty categories for undefined input', () => {
+ expect(ChannelTableStreamsUtils.categorizeStreamStats(undefined)).toEqual(
+ {
+ basic: {},
+ video: {},
+ audio: {},
+ technical: {},
+ other: {},
+ }
+ );
+ });
+
+ it('categorizes basic fields correctly', () => {
+ const stats = {
+ resolution: '1920x1080',
+ video_codec: 'h264',
+ source_fps: 30,
+ audio_codec: 'aac',
+ audio_channels: 2,
+ };
+ const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
+ expect(result.basic).toEqual(stats);
+ expect(result.video).toEqual({});
+ expect(result.audio).toEqual({});
+ expect(result.technical).toEqual({});
+ expect(result.other).toEqual({});
+ });
+
+ it('categorizes video fields correctly', () => {
+ const stats = {
+ video_bitrate: 5000,
+ pixel_format: 'yuv420p',
+ width: 1920,
+ height: 1080,
+ aspect_ratio: '16:9',
+ frame_rate: 29.97,
+ };
+ const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
+ expect(result.video).toEqual(stats);
+ expect(result.basic).toEqual({});
+ });
+
+ it('categorizes audio fields correctly', () => {
+ const stats = {
+ audio_bitrate: 192,
+ sample_rate: 48000,
+ audio_format: 'flac',
+ audio_channels_layout: 'stereo',
+ };
+ const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
+ expect(result.audio).toEqual(stats);
+ });
+
+ it('categorizes technical fields correctly', () => {
+ const stats = {
+ stream_type: 'video',
+ container_format: 'mpegts',
+ duration: 3600,
+ file_size: 1024000,
+ ffmpeg_output_bitrate: 8000,
+ input_bitrate: 7500,
+ };
+ const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
+ expect(result.technical).toEqual(stats);
+ });
+
+ it('places unknown fields in other', () => {
+ const stats = { custom_field: 'value', another_field: 42 };
+ const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
+ expect(result.other).toEqual(stats);
+ });
+
+ it('handles mixed fields across categories', () => {
+ const stats = {
+ resolution: '1080p',
+ audio_bitrate: 192,
+ duration: 7200,
+ unknown_key: 'test',
+ };
+ const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
+ expect(result.basic.resolution).toBe('1080p');
+ expect(result.audio.audio_bitrate).toBe(192);
+ expect(result.technical.duration).toBe(7200);
+ expect(result.other.unknown_key).toBe('test');
+ });
+
+ it('returns empty categories for empty stats object', () => {
+ const result = ChannelTableStreamsUtils.categorizeStreamStats({});
+ expect(result).toEqual({
+ basic: {},
+ video: {},
+ audio: {},
+ technical: {},
+ other: {},
+ });
+ });
+ });
+
+ // ── formatStatValue ─────────────────────────────────────────────────────────
+ describe('formatStatValue', () => {
+ it('returns "N/A" for null value', () => {
+ expect(ChannelTableStreamsUtils.formatStatValue('resolution', null)).toBe(
+ 'N/A'
+ );
+ });
+
+ it('returns "N/A" for undefined value', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('resolution', undefined)
+ ).toBe('N/A');
+ });
+
+ it('formats video_bitrate with kbps', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('video_bitrate', 5000)
+ ).toBe('5000 kbps');
+ });
+
+ it('formats audio_bitrate with kbps', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('audio_bitrate', 192)
+ ).toBe('192 kbps');
+ });
+
+ it('formats ffmpeg_output_bitrate with kbps', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('ffmpeg_output_bitrate', 8000)
+ ).toBe('8000 kbps');
+ });
+
+ it('formats source_fps with fps', () => {
+ expect(ChannelTableStreamsUtils.formatStatValue('source_fps', 30)).toBe(
+ '30 fps'
+ );
+ });
+
+ it('formats frame_rate with fps', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('frame_rate', 29.97)
+ ).toBe('29.97 fps');
+ });
+
+ it('formats sample_rate with Hz', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('sample_rate', 48000)
+ ).toBe('48000 Hz');
+ });
+
+ it('formats file_size using formatBytes when numeric', () => {
+ vi.mocked(formatBytes).mockReturnValue('1.0 MB');
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('file_size', 1048576)
+ ).toBe('1.0 MB');
+ expect(formatBytes).toHaveBeenCalledWith(1048576);
+ });
+
+ it('returns raw value for file_size when not numeric', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('file_size', 'unknown')
+ ).toBe('unknown');
+ expect(formatBytes).not.toHaveBeenCalled();
+ });
+
+ it('formats duration using formatDuration with alwaysShowHours when numeric', () => {
+ vi.mocked(formatDuration).mockReturnValue('01:00:00');
+ expect(ChannelTableStreamsUtils.formatStatValue('duration', 3600)).toBe(
+ '01:00:00'
+ );
+ expect(formatDuration).toHaveBeenCalledWith(3600, {
+ alwaysShowHours: true,
+ });
+ });
+
+ it('returns raw value for duration when not numeric', () => {
+ expect(ChannelTableStreamsUtils.formatStatValue('duration', 'live')).toBe(
+ 'live'
+ );
+ expect(formatDuration).not.toHaveBeenCalled();
+ });
+
+ it('converts default values to string', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatValue('resolution', '1920x1080')
+ ).toBe('1920x1080');
+ });
+
+ it('converts numeric default values to string', () => {
+ expect(ChannelTableStreamsUtils.formatStatValue('width', 1920)).toBe(
+ '1920'
+ );
+ });
+ });
+
+ // ── formatStatKey ───────────────────────────────────────────────────────────
+ describe('formatStatKey', () => {
+ it('replaces underscores with spaces and title-cases each word', () => {
+ expect(ChannelTableStreamsUtils.formatStatKey('video_bitrate')).toBe(
+ 'Video Bitrate'
+ );
+ });
+
+ it('handles single word keys', () => {
+ expect(ChannelTableStreamsUtils.formatStatKey('resolution')).toBe(
+ 'Resolution'
+ );
+ });
+
+ it('handles multi-word keys', () => {
+ expect(
+ ChannelTableStreamsUtils.formatStatKey('audio_channels_layout')
+ ).toBe('Audio Channels Layout');
+ });
+
+ it('handles already-capitalized keys', () => {
+ expect(ChannelTableStreamsUtils.formatStatKey('FPS')).toBe('FPS');
+ });
+ });
+
+ // ── API wrappers ────────────────────────────────────────────────────────────
+ describe('getChannelStreamStats', () => {
+ it('calls API.getChannelStreamStats with correct args', () => {
+ const mockReturn = Promise.resolve({ data: [] });
+ vi.mocked(API.getChannelStreamStats).mockReturnValue(mockReturn);
+ const result = ChannelTableStreamsUtils.getChannelStreamStats(
+ 'ch-1',
+ '2024-01-01',
+ [1, 2]
+ );
+ expect(API.getChannelStreamStats).toHaveBeenCalledWith(
+ 'ch-1',
+ '2024-01-01',
+ [1, 2]
+ );
+ expect(result).toBe(mockReturn);
+ });
+ });
+
+ describe('reorderChannelStreams', () => {
+ it('calls API.reorderChannelStreams with correct args', () => {
+ const mockReturn = Promise.resolve(undefined);
+ vi.mocked(API.reorderChannelStreams).mockReturnValue(mockReturn);
+ const result = ChannelTableStreamsUtils.reorderChannelStreams(
+ 'ch-1',
+ [3, 1, 2]
+ );
+ expect(API.reorderChannelStreams).toHaveBeenCalledWith('ch-1', [3, 1, 2]);
+ expect(result).toBe(mockReturn);
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js
new file mode 100644
index 00000000..c4fc9e3e
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js
@@ -0,0 +1,611 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as ChannelsTableUtils from '../ChannelsTableUtils';
+
+// ── Dependency mocks ────────────────────────────────────────────────────────
+vi.mock('../../forms/ChannelUtils.js', () => ({
+ normalizeFieldValue: vi.fn((field, value) => {
+ if (value === '' || value === null || value === undefined) return null;
+ if (field === 'channel_number') return parseFloat(value);
+ if (['channel_group_id', 'logo_id', 'epg_data_id', 'stream_profile_id'].includes(field)) {
+ return parseInt(value, 10);
+ }
+ return value;
+ }),
+ OVERRIDABLE_FIELDS: [
+ 'name',
+ 'channel_number',
+ 'channel_group_id',
+ 'logo_id',
+ 'tvg_id',
+ 'tvc_guide_stationid',
+ 'epg_data_id',
+ 'stream_profile_id',
+ ],
+}));
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ reorderChannel: vi.fn(),
+ deleteChannel: vi.fn(),
+ deleteChannels: vi.fn(),
+ queryChannels: vi.fn(),
+ getAllChannelIds: vi.fn(),
+ updateProfileChannels: vi.fn(),
+ updateProfileChannel: vi.fn(),
+ addChannelProfile: vi.fn(),
+ deleteChannelProfile: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+describe('ChannelsTableUtils', () => {
+ // ── buildInlinePatch ────────────────────────────────────────────────────────
+ describe('buildInlinePatch', () => {
+ describe('manual channel (auto_created = false)', () => {
+ const row = { id: 1, auto_created: false, name: 'CNN' };
+
+ it('returns direct patch for a string field', () => {
+ expect(ChannelsTableUtils.buildInlinePatch(row, 'name', 'BBC')).toEqual(
+ {
+ id: 1,
+ name: 'BBC',
+ }
+ );
+ });
+
+ it('normalizes empty string to null', () => {
+ expect(ChannelsTableUtils.buildInlinePatch(row, 'name', '')).toEqual({
+ id: 1,
+ name: null,
+ });
+ });
+
+ it('normalizes undefined to null', () => {
+ expect(
+ ChannelsTableUtils.buildInlinePatch(row, 'name', undefined)
+ ).toEqual({
+ id: 1,
+ name: null,
+ });
+ });
+
+ it('passes through numeric values', () => {
+ expect(
+ ChannelsTableUtils.buildInlinePatch(row, 'channel_number', 5)
+ ).toEqual({
+ id: 1,
+ channel_number: 5,
+ });
+ });
+ });
+
+ describe('auto-synced channel (auto_created = true)', () => {
+ const row = {
+ id: 2,
+ auto_created: true,
+ name: 'ESPN',
+ channel_number: 10,
+ epg_data_id: 99,
+ };
+
+ it('returns override patch when value differs from provider', () => {
+ const result = ChannelsTableUtils.buildInlinePatch(
+ row,
+ 'name',
+ 'ESPN HD'
+ );
+ expect(result).toEqual({
+ id: 2,
+ override: { name: 'ESPN HD' },
+ });
+ });
+
+ it('clears override when new value matches provider value', () => {
+ const result = ChannelsTableUtils.buildInlinePatch(row, 'name', 'ESPN');
+ expect(result).toEqual({
+ id: 2,
+ override: { name: null },
+ });
+ });
+
+ it('returns override patch for channel_number field', () => {
+ const result = ChannelsTableUtils.buildInlinePatch(
+ row,
+ 'channel_number',
+ 20
+ );
+ expect(result).toEqual({
+ id: 2,
+ override: { channel_number: 20 },
+ });
+ });
+
+ it('clears override for channel_number when value matches provider', () => {
+ const result = ChannelsTableUtils.buildInlinePatch(
+ row,
+ 'channel_number',
+ 10
+ );
+ expect(result).toEqual({
+ id: 2,
+ override: { channel_number: null },
+ });
+ });
+
+ it('uses direct patch for non-overridable field on auto-synced channel', () => {
+ const result = ChannelsTableUtils.buildInlinePatch(
+ row,
+ 'some_other_field',
+ 'value'
+ );
+ expect(result).toEqual({
+ id: 2,
+ some_other_field: 'value',
+ });
+ });
+ });
+ });
+
+ // ── getEpgOptions ───────────────────────────────────────────────────────────
+ describe('getEpgOptions', () => {
+ const epgs = {
+ 1: { id: 1, name: 'EPG Alpha' },
+ 2: { id: 2, name: 'EPG Beta' },
+ };
+
+ const tvgsById = {
+ 10: { id: 10, tvg_id: 'cnn', name: 'CNN', epg_source: 1 },
+ 11: { id: 11, tvg_id: 'bbc', name: 'BBC', epg_source: 2 },
+ 12: { id: 12, tvg_id: 'espn', name: 'ESPN', epg_source: 1 },
+ };
+
+ it('includes "Not Assigned" as the first option', () => {
+ const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs);
+ expect(options[0]).toEqual({ value: 'null', label: 'Not Assigned' });
+ });
+
+ it('returns an option for each tvg entry', () => {
+ const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs);
+ expect(options).toHaveLength(4); // 1 null + 3 tvgs
+ });
+
+ it('formats label as "EPG Name | tvg_id | tvg name" when all present and name differs from tvg_id', () => {
+ const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs);
+ const cnn = options.find((o) => o.value === '10');
+ expect(cnn?.label).toBe('EPG Alpha | cnn | CNN');
+ });
+
+ it('omits tvg name from label when name equals tvg_id', () => {
+ const tvgs = {
+ 10: { id: 10, tvg_id: 'CNN', name: 'CNN', epg_source: 1 },
+ };
+ const options = ChannelsTableUtils.getEpgOptions(tvgs, epgs);
+ const opt = options.find((o) => o.value === '10');
+ expect(opt?.label).toBe('EPG Alpha | CNN');
+ });
+
+ it('uses tvg name as label when no epg_source and no tvg_id', () => {
+ const tvgs = {
+ 20: { id: 20, tvg_id: null, name: 'Standalone', epg_source: null },
+ };
+ const options = ChannelsTableUtils.getEpgOptions(tvgs, {});
+ const opt = options.find((o) => o.value === '20');
+ expect(opt?.label).toBe('Standalone');
+ });
+
+ it('falls back to "ID: {id}" when no name and no tvg_id', () => {
+ const tvgs = {
+ 30: { id: 30, tvg_id: null, name: null, epg_source: null },
+ };
+ const options = ChannelsTableUtils.getEpgOptions(tvgs, {});
+ const opt = options.find((o) => o.value === '30');
+ expect(opt?.label).toBe('ID: 30');
+ });
+
+ it('sorts options by EPG source name then tvg_id', () => {
+ const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs);
+ // EPG Alpha entries (cnn, espn) should come before EPG Beta (bbc)
+ const labels = options.slice(1).map((o) => o.label);
+ const alphaCnn = labels.findIndex((l) => l.includes('cnn'));
+ const alphaEspn = labels.findIndex((l) => l.includes('espn'));
+ const betaBbc = labels.findIndex((l) => l.includes('bbc'));
+ expect(alphaCnn).toBeLessThan(betaBbc);
+ expect(alphaEspn).toBeLessThan(betaBbc);
+ });
+
+ it('returns only the null option for empty tvgsById', () => {
+ const options = ChannelsTableUtils.getEpgOptions({}, epgs);
+ expect(options).toHaveLength(1);
+ expect(options[0].value).toBe('null');
+ });
+ });
+
+ // ── getLogoOptions ──────────────────────────────────────────────────────────
+ describe('getLogoOptions', () => {
+ const logos = {
+ 1: { id: 1, name: 'ABC Logo' },
+ 2: { id: 2, name: 'NBC Logo' },
+ 3: { id: 3, name: null },
+ };
+
+ it('includes "Default" as the first option', () => {
+ const options = ChannelsTableUtils.getLogoOptions(logos);
+ expect(options[0]).toEqual({
+ value: 'null',
+ label: 'Default',
+ logo: null,
+ });
+ });
+
+ it('returns an option for each logo', () => {
+ const options = ChannelsTableUtils.getLogoOptions(logos);
+ expect(options).toHaveLength(4); // 1 default + 3 logos
+ });
+
+ it('uses logo name as label', () => {
+ const options = ChannelsTableUtils.getLogoOptions(logos);
+ const abc = options.find((o) => o.value === '1');
+ expect(abc?.label).toBe('ABC Logo');
+ expect(abc?.logo).toEqual({ id: 1, name: 'ABC Logo' });
+ });
+
+ it('falls back to "Logo {id}" when name is null', () => {
+ const options = ChannelsTableUtils.getLogoOptions(logos);
+ const noName = options.find((o) => o.value === '3');
+ expect(noName?.label).toBe('Logo 3');
+ });
+
+ it('sorts logos by name', () => {
+ const options = ChannelsTableUtils.getLogoOptions(logos);
+ const names = options.slice(2).map((o) => o.label);
+ expect(names[0]).toBe('ABC Logo');
+ expect(names[1]).toBe('NBC Logo');
+ });
+
+ it('returns only the default option for empty logos', () => {
+ const options = ChannelsTableUtils.getLogoOptions({});
+ expect(options).toHaveLength(1);
+ });
+ });
+
+ // ── buildM3UUrl ─────────────────────────────────────────────────────────────
+ describe('buildM3UUrl', () => {
+ const baseUrl = 'http://localhost/output/m3u';
+ const defaults = {
+ cachedlogos: true,
+ direct: false,
+ tvg_id_source: 'channel_number',
+ output_format: '',
+ output_profile: '',
+ };
+
+ it('returns base URL with no params when all defaults', () => {
+ expect(ChannelsTableUtils.buildM3UUrl(defaults, baseUrl)).toBe(baseUrl);
+ });
+
+ it('appends cachedlogos=false when disabled', () => {
+ const result = ChannelsTableUtils.buildM3UUrl(
+ { ...defaults, cachedlogos: false },
+ baseUrl
+ );
+ expect(result).toContain('cachedlogos=false');
+ });
+
+ it('appends direct=true when enabled', () => {
+ const result = ChannelsTableUtils.buildM3UUrl(
+ { ...defaults, direct: true },
+ baseUrl
+ );
+ expect(result).toContain('direct=true');
+ });
+
+ it('appends tvg_id_source when not channel_number', () => {
+ const result = ChannelsTableUtils.buildM3UUrl(
+ { ...defaults, tvg_id_source: 'tvg_id' },
+ baseUrl
+ );
+ expect(result).toContain('tvg_id_source=tvg_id');
+ });
+
+ it('does not append tvg_id_source when channel_number', () => {
+ const result = ChannelsTableUtils.buildM3UUrl(defaults, baseUrl);
+ expect(result).not.toContain('tvg_id_source');
+ });
+
+ it('appends output_format when set', () => {
+ const result = ChannelsTableUtils.buildM3UUrl(
+ { ...defaults, output_format: 'mpegts' },
+ baseUrl
+ );
+ expect(result).toContain('output_format=mpegts');
+ });
+
+ it('appends output_profile when set', () => {
+ const result = ChannelsTableUtils.buildM3UUrl(
+ { ...defaults, output_profile: '3' },
+ baseUrl
+ );
+ expect(result).toContain('output_profile=3');
+ });
+ });
+
+ // ── buildEPGUrl ─────────────────────────────────────────────────────────────
+ describe('buildEPGUrl', () => {
+ const baseUrl = 'http://localhost/output/epg';
+ const defaults = {
+ cachedlogos: true,
+ tvg_id_source: 'channel_number',
+ days: 0,
+ prev_days: 0,
+ };
+
+ it('returns base URL with no params when all defaults', () => {
+ expect(ChannelsTableUtils.buildEPGUrl(defaults, baseUrl)).toBe(baseUrl);
+ });
+
+ it('appends cachedlogos=false when disabled', () => {
+ const result = ChannelsTableUtils.buildEPGUrl(
+ { ...defaults, cachedlogos: false },
+ baseUrl
+ );
+ expect(result).toContain('cachedlogos=false');
+ });
+
+ it('appends tvg_id_source when not channel_number', () => {
+ const result = ChannelsTableUtils.buildEPGUrl(
+ { ...defaults, tvg_id_source: 'gracenote' },
+ baseUrl
+ );
+ expect(result).toContain('tvg_id_source=gracenote');
+ });
+
+ it('appends days when > 0', () => {
+ const result = ChannelsTableUtils.buildEPGUrl(
+ { ...defaults, days: 7 },
+ baseUrl
+ );
+ expect(result).toContain('days=7');
+ });
+
+ it('does not append days when 0', () => {
+ const result = ChannelsTableUtils.buildEPGUrl(defaults, baseUrl);
+ expect(result).not.toContain('days');
+ });
+
+ it('appends prev_days when > 0', () => {
+ const result = ChannelsTableUtils.buildEPGUrl(
+ { ...defaults, prev_days: 3 },
+ baseUrl
+ );
+ expect(result).toContain('prev_days=3');
+ });
+ });
+
+ // ── buildHDHRUrl ────────────────────────────────────────────────────────────
+ describe('buildHDHRUrl', () => {
+ it('returns hdhrUrl unchanged when no output profile', () => {
+ expect(ChannelsTableUtils.buildHDHRUrl('', 'http://localhost/hdhr')).toBe(
+ 'http://localhost/hdhr'
+ );
+ });
+
+ it('appends output_profile segment when profile id provided', () => {
+ expect(
+ ChannelsTableUtils.buildHDHRUrl('2', 'http://localhost/hdhr')
+ ).toBe('http://localhost/hdhr/output_profile/2');
+ });
+
+ it('strips trailing slash before appending', () => {
+ expect(
+ ChannelsTableUtils.buildHDHRUrl('1', 'http://localhost/hdhr/')
+ ).toBe('http://localhost/hdhr/output_profile/1');
+ });
+ });
+
+ // ── buildFetchParams ────────────────────────────────────────────────────────
+ describe('buildFetchParams', () => {
+ const defaults = {
+ pagination: { pageIndex: 0, pageSize: 50 },
+ sorting: [],
+ debouncedFilters: {},
+ selectedProfileId: '0',
+ showDisabled: false,
+ showOnlyStreamlessChannels: false,
+ showOnlyStaleChannels: false,
+ showOnlyOverriddenChannels: false,
+ visibilityFilter: 'active',
+ };
+
+ it('always includes page, page_size, and include_streams', () => {
+ const params = ChannelsTableUtils.buildFetchParams(defaults);
+ expect(params.get('page')).toBe('1');
+ expect(params.get('page_size')).toBe('50');
+ expect(params.get('include_streams')).toBe('true');
+ });
+
+ it('increments page by 1 from pageIndex', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ pagination: { pageIndex: 2, pageSize: 25 },
+ });
+ expect(params.get('page')).toBe('3');
+ expect(params.get('page_size')).toBe('25');
+ });
+
+ it('does not include channel_profile_id when selectedProfileId is "0"', () => {
+ const params = ChannelsTableUtils.buildFetchParams(defaults);
+ expect(params.get('channel_profile_id')).toBeNull();
+ });
+
+ it('includes channel_profile_id when selectedProfileId is not "0"', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ selectedProfileId: '3',
+ });
+ expect(params.get('channel_profile_id')).toBe('3');
+ });
+
+ it('includes show_disabled when true', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ showDisabled: true,
+ });
+ expect(params.get('show_disabled')).toBe('true');
+ });
+
+ it('includes only_streamless when true', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ showOnlyStreamlessChannels: true,
+ });
+ expect(params.get('only_streamless')).toBe('true');
+ });
+
+ it('includes only_stale when true', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ showOnlyStaleChannels: true,
+ });
+ expect(params.get('only_stale')).toBe('true');
+ });
+
+ it('includes only_has_overrides when true', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ showOnlyOverriddenChannels: true,
+ });
+ expect(params.get('only_has_overrides')).toBe('true');
+ });
+
+ it('does not include visibility_filter when "active"', () => {
+ const params = ChannelsTableUtils.buildFetchParams(defaults);
+ expect(params.get('visibility_filter')).toBeNull();
+ });
+
+ it('includes visibility_filter when not "active"', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ visibilityFilter: 'hidden',
+ });
+ expect(params.get('visibility_filter')).toBe('hidden');
+ });
+
+ it('includes ordering with ascending sort', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ sorting: [{ id: 'name', desc: false }],
+ });
+ expect(params.get('ordering')).toBe('name');
+ });
+
+ it('includes ordering with descending sort', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ sorting: [{ id: 'name', desc: true }],
+ });
+ expect(params.get('ordering')).toBe('-name');
+ });
+
+ it('maps channel_group sort field to channel_group__name', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ sorting: [{ id: 'channel_group', desc: false }],
+ });
+ expect(params.get('ordering')).toBe('channel_group__name');
+ });
+
+ it('maps epg sort field to epg_data__name', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ sorting: [{ id: 'epg', desc: false }],
+ });
+ expect(params.get('ordering')).toBe('epg_data__name');
+ });
+
+ it('applies string debounced filters', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ debouncedFilters: { name: 'CNN' },
+ });
+ expect(params.get('name')).toBe('CNN');
+ });
+
+ it('applies array debounced filters joined by comma', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ debouncedFilters: { channel_group: ['News', 'Sports'] },
+ });
+ expect(params.get('channel_group')).toBe('News,Sports');
+ });
+
+ it('converts null values in array filters to "null" string', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ debouncedFilters: { epg: [null, 'SomeEPG'] },
+ });
+ expect(params.get('epg')).toBe('null,SomeEPG');
+ });
+
+ it('skips falsy debounced filter values', () => {
+ const params = ChannelsTableUtils.buildFetchParams({
+ ...defaults,
+ debouncedFilters: { name: '' },
+ });
+ expect(params.get('name')).toBeNull();
+ });
+ });
+
+ // ── API wrapper functions ───────────────────────────────────────────────────
+ describe('API wrappers', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('reorderChannel calls API.reorderChannel', () => {
+ ChannelsTableUtils.reorderChannel(1, 2);
+ expect(API.reorderChannel).toHaveBeenCalledWith(1, 2);
+ });
+
+ it('deleteChannel calls API.deleteChannel', () => {
+ ChannelsTableUtils.deleteChannel(5);
+ expect(API.deleteChannel).toHaveBeenCalledWith(5);
+ });
+
+ it('deleteChannels calls API.deleteChannels', () => {
+ ChannelsTableUtils.deleteChannels([1, 2, 3]);
+ expect(API.deleteChannels).toHaveBeenCalledWith([1, 2, 3]);
+ });
+
+ it('queryChannels calls API.queryChannels', () => {
+ const params = new URLSearchParams();
+ ChannelsTableUtils.queryChannels(params);
+ expect(API.queryChannels).toHaveBeenCalledWith(params);
+ });
+
+ it('getAllChannelIds calls API.getAllChannelIds', () => {
+ const params = new URLSearchParams();
+ ChannelsTableUtils.getAllChannelIds(params);
+ expect(API.getAllChannelIds).toHaveBeenCalledWith(params);
+ });
+
+ it('updateProfileChannels calls API.updateProfileChannels', () => {
+ ChannelsTableUtils.updateProfileChannels([1, 2], '3', true);
+ expect(API.updateProfileChannels).toHaveBeenCalledWith([1, 2], '3', true);
+ });
+
+ it('updateProfileChannel calls API.updateProfileChannel', () => {
+ ChannelsTableUtils.updateProfileChannel(1, '3', false);
+ expect(API.updateProfileChannel).toHaveBeenCalledWith(1, '3', false);
+ });
+
+ it('addChannelProfile calls API.addChannelProfile', () => {
+ const values = { name: 'Test Profile' };
+ ChannelsTableUtils.addChannelProfile(values);
+ expect(API.addChannelProfile).toHaveBeenCalledWith(values);
+ });
+
+ it('deleteChannelProfile calls API.deleteChannelProfile', () => {
+ ChannelsTableUtils.deleteChannelProfile(4);
+ expect(API.deleteChannelProfile).toHaveBeenCalledWith(4);
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js b/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js
new file mode 100644
index 00000000..7dd0b8dc
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js
@@ -0,0 +1,238 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as EPGsTableUtils from '../EPGsTableUtils';
+
+// ── Dependency mocks ────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateEPG: vi.fn(),
+ deleteEPG: vi.fn(),
+ refreshEPG: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+describe('EPGsTableUtils', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ // ── formatStatusText ────────────────────────────────────────────────────────
+ describe('formatStatusText', () => {
+ it('returns "Unknown" for null', () => {
+ expect(EPGsTableUtils.formatStatusText(null)).toBe('Unknown');
+ });
+
+ it('returns "Unknown" for undefined', () => {
+ expect(EPGsTableUtils.formatStatusText(undefined)).toBe('Unknown');
+ });
+
+ it('returns "Unknown" for empty string', () => {
+ expect(EPGsTableUtils.formatStatusText('')).toBe('Unknown');
+ });
+
+ it('capitalizes first letter and lowercases the rest', () => {
+ expect(EPGsTableUtils.formatStatusText('idle')).toBe('Idle');
+ expect(EPGsTableUtils.formatStatusText('success')).toBe('Success');
+ expect(EPGsTableUtils.formatStatusText('error')).toBe('Error');
+ });
+
+ it('handles already-uppercase input', () => {
+ expect(EPGsTableUtils.formatStatusText('FETCHING')).toBe('Fetching');
+ });
+
+ it('handles mixed case input', () => {
+ expect(EPGsTableUtils.formatStatusText('pArSiNg')).toBe('Parsing');
+ });
+ });
+
+ // ── updateEpg ───────────────────────────────────────────────────────────────
+ describe('updateEpg', () => {
+ it('calls API.updateEPG with merged values and id', async () => {
+ API.updateEPG.mockResolvedValue({ id: 1 });
+ const epg = { id: 1, name: 'Old Name' };
+ await EPGsTableUtils.updateEpg({ is_active: false }, epg, false);
+ expect(API.updateEPG).toHaveBeenCalledWith(
+ { is_active: false, id: 1 },
+ false
+ );
+ });
+
+ it('passes isToggle=true to API.updateEPG', async () => {
+ API.updateEPG.mockResolvedValue({ id: 2 });
+ const epg = { id: 2 };
+ await EPGsTableUtils.updateEpg({ is_active: true }, epg, true);
+ expect(API.updateEPG).toHaveBeenCalledWith(
+ { is_active: true, id: 2 },
+ true
+ );
+ });
+
+ it('returns the API response', async () => {
+ const mockResponse = { id: 3, name: 'Updated' };
+ API.updateEPG.mockResolvedValue(mockResponse);
+ const result = await EPGsTableUtils.updateEpg({}, { id: 3 }, false);
+ expect(result).toBe(mockResponse);
+ });
+
+ it('propagates API errors', async () => {
+ API.updateEPG.mockRejectedValue(new Error('Network error'));
+ await expect(
+ EPGsTableUtils.updateEpg({}, { id: 1 }, false)
+ ).rejects.toThrow('Network error');
+ });
+ });
+
+ // ── deleteEpg ───────────────────────────────────────────────────────────────
+ describe('deleteEpg', () => {
+ it('calls API.deleteEPG with the correct id', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.deleteEPG.mockReturnValue(mockReturn);
+ const result = EPGsTableUtils.deleteEpg(5);
+ expect(API.deleteEPG).toHaveBeenCalledWith(5);
+ expect(result).toBe(mockReturn);
+ });
+ });
+
+ // ── refreshEpg ──────────────────────────────────────────────────────────────
+ describe('refreshEpg', () => {
+ it('calls API.refreshEPG with the correct id', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.refreshEPG.mockReturnValue(mockReturn);
+ const result = EPGsTableUtils.refreshEpg(7);
+ expect(API.refreshEPG).toHaveBeenCalledWith(7, expect.anything());
+ expect(result).toBe(mockReturn);
+ });
+ });
+
+ // ── getProgressLabel ────────────────────────────────────────────────────────
+ describe('getProgressLabel', () => {
+ it('returns "Downloading" for downloading action', () => {
+ expect(EPGsTableUtils.getProgressLabel('downloading')).toBe(
+ 'Downloading'
+ );
+ });
+
+ it('returns "Extracting" for extracting action', () => {
+ expect(EPGsTableUtils.getProgressLabel('extracting')).toBe('Extracting');
+ });
+
+ it('returns "Parsing Channels" for parsing_channels action', () => {
+ expect(EPGsTableUtils.getProgressLabel('parsing_channels')).toBe(
+ 'Parsing Channels'
+ );
+ });
+
+ it('returns "Parsing Programs" for parsing_programs action', () => {
+ expect(EPGsTableUtils.getProgressLabel('parsing_programs')).toBe(
+ 'Parsing Programs'
+ );
+ });
+
+ it('returns null for unknown action', () => {
+ expect(EPGsTableUtils.getProgressLabel('unknown_action')).toBeNull();
+ });
+
+ it('returns null for null action', () => {
+ expect(EPGsTableUtils.getProgressLabel(null)).toBeNull();
+ });
+
+ it('returns null for undefined action', () => {
+ expect(EPGsTableUtils.getProgressLabel(undefined)).toBeNull();
+ });
+ });
+
+ // ── getProgressInfo ─────────────────────────────────────────────────────────
+ describe('getProgressInfo', () => {
+ it('returns message when progress.message is set', () => {
+ expect(
+ EPGsTableUtils.getProgressInfo({ message: 'Loading data...' })
+ ).toBe('Loading data...');
+ });
+
+ it('returns programs/channels string when processed and channels are set', () => {
+ expect(
+ EPGsTableUtils.getProgressInfo({ processed: 1500, channels: 42 })
+ ).toBe('1,500 programs for 42 channels');
+ });
+
+ it('prefers message over processed/channels', () => {
+ expect(
+ EPGsTableUtils.getProgressInfo({
+ message: 'Custom message',
+ processed: 100,
+ channels: 5,
+ })
+ ).toBe('Custom message');
+ });
+
+ it('returns processed/total string when processed and total are set without channels', () => {
+ expect(
+ EPGsTableUtils.getProgressInfo({ processed: 250, total: 1000 })
+ ).toBe('250 / 1,000');
+ });
+
+ it('returns null when no relevant fields are present', () => {
+ expect(EPGsTableUtils.getProgressInfo({})).toBeNull();
+ });
+
+ it('returns null when only processed is set without channels or total', () => {
+ expect(EPGsTableUtils.getProgressInfo({ processed: 100 })).toBeNull();
+ });
+
+ it('formats large numbers with locale separators', () => {
+ const result = EPGsTableUtils.getProgressInfo({
+ processed: 1000000,
+ total: 5000000,
+ });
+ expect(result).toBe('1,000,000 / 5,000,000');
+ });
+ });
+
+ // ── getSortedEpgs ───────────────────────────────────────────────────────────
+ describe('getSortedEpgs', () => {
+ const epgs = [
+ { id: 1, name: 'Zebra EPG', is_active: true },
+ { id: 2, name: 'Alpha EPG', is_active: false },
+ { id: 3, name: 'Middle EPG', is_active: true },
+ ];
+
+ it('sorts ascending when compareDesc is false', () => {
+ const result = EPGsTableUtils.getSortedEpgs(
+ [...epgs],
+ 'is_active',
+ false
+ );
+ // active=false comes first in ascending (false < true)
+ expect(result[0].id).toBe(2);
+ });
+
+ it('sorts descending when compareDesc is true', () => {
+ const result = EPGsTableUtils.getSortedEpgs([...epgs], 'is_active', true);
+ // active=true comes first in descending
+ expect(result[0].is_active).toBe(true);
+ });
+
+ it('returns items in original relative order when values are equal', () => {
+ const items = [
+ { id: 1, name: 'Same', is_active: true },
+ { id: 2, name: 'Same', is_active: true },
+ ];
+ const result = EPGsTableUtils.getSortedEpgs([...items], 'name', false);
+ expect(result[0].id).toBe(1);
+ expect(result[1].id).toBe(2);
+ });
+
+ it('sorts by name field ascending', () => {
+ const result = EPGsTableUtils.getSortedEpgs([...epgs], 'name', false);
+ expect(result[0].name).toBe('Alpha EPG');
+ });
+
+ it('sorts by name field descending', () => {
+ const result = EPGsTableUtils.getSortedEpgs([...epgs], 'name', true);
+ expect(result[0].name).toBe('Zebra EPG');
+ });
+
+ it('returns an empty array for empty input', () => {
+ expect(EPGsTableUtils.getSortedEpgs([], 'name', false)).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js b/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js
new file mode 100644
index 00000000..332f198a
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js
@@ -0,0 +1,220 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as LogosTableUtils from '../LogosTableUtils';
+
+// ── Dependency mocks ────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ deleteLogo: vi.fn(),
+ deleteLogos: vi.fn(),
+ cleanupUnusedLogos: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+describe('LogosTableUtils', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ // ── getFilteredLogos ────────────────────────────────────────────────────────
+ describe('getFilteredLogos', () => {
+ const logos = {
+ 1: { id: 1, name: 'ABC Logo', is_used: true },
+ 2: { id: 2, name: 'NBC Logo', is_used: false },
+ 3: { id: 3, name: 'CBS Logo', is_used: true },
+ 4: { id: 4, name: 'abc sports', is_used: false },
+ };
+
+ it('returns all logos sorted by id when no filters applied', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, '', 'all');
+ expect(result.map((l) => l.id)).toEqual([1, 2, 3, 4]);
+ });
+
+ it('returns empty array for null logos', () => {
+ expect(LogosTableUtils.getFilteredLogos(null, '', 'all')).toEqual([]);
+ });
+
+ it('returns empty array for undefined logos', () => {
+ expect(LogosTableUtils.getFilteredLogos(undefined, '', 'all')).toEqual(
+ []
+ );
+ });
+
+ it('filters by name case-insensitively', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'all');
+ expect(result).toHaveLength(2);
+ expect(result.map((l) => l.id)).toEqual([1, 4]);
+ });
+
+ it('filters by exact name match', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, 'NBC Logo', 'all');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(2);
+ });
+
+ it('returns empty array when name filter matches nothing', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, 'xyz', 'all');
+ expect(result).toHaveLength(0);
+ });
+
+ it('filters to used logos only when filtersUsed is "used"', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, '', 'used');
+ expect(result.every((l) => l.is_used)).toBe(true);
+ expect(result).toHaveLength(2);
+ });
+
+ it('filters to unused logos only when filtersUsed is "unused"', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, '', 'unused');
+ expect(result.every((l) => !l.is_used)).toBe(true);
+ expect(result).toHaveLength(2);
+ });
+
+ it('applies name filter and used filter together', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'used');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+
+ it('applies name filter and unused filter together', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'unused');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(4);
+ });
+
+ it('sorts results by id ascending', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, '', 'all');
+ expect(result).toHaveLength(4);
+ for (let i = 1; i < result.length; i++) {
+ expect(result[i].id).toBeGreaterThan(result[i - 1].id);
+ }
+ });
+
+ it('returns empty array when logos object is empty', () => {
+ expect(LogosTableUtils.getFilteredLogos({}, '', 'all')).toEqual([]);
+ });
+
+ it('does not filter when debouncedNameFilter is empty string', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, '', 'all');
+ expect(result).toHaveLength(4);
+ });
+
+ it('does not apply usage filter for unrecognized filtersUsed value', () => {
+ const result = LogosTableUtils.getFilteredLogos(logos, '', 'all');
+ expect(result).toHaveLength(4);
+ });
+ });
+
+ // ── deleteLogo ──────────────────────────────────────────────────────────────
+ describe('deleteLogo', () => {
+ it('calls API.deleteLogo with id and deleteFile', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.deleteLogo.mockReturnValue(mockReturn);
+ const result = LogosTableUtils.deleteLogo(5, true);
+ expect(API.deleteLogo).toHaveBeenCalledWith(5, true);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('passes deleteFile=false correctly', () => {
+ LogosTableUtils.deleteLogo(3, false);
+ expect(API.deleteLogo).toHaveBeenCalledWith(3, false);
+ });
+ });
+
+ // ── deleteLogos ─────────────────────────────────────────────────────────────
+ describe('deleteLogos', () => {
+ it('calls API.deleteLogos with ids and deleteFiles', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.deleteLogos.mockReturnValue(mockReturn);
+ const result = LogosTableUtils.deleteLogos([1, 2, 3], true);
+ expect(API.deleteLogos).toHaveBeenCalledWith([1, 2, 3], true);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('passes deleteFiles=false correctly', () => {
+ LogosTableUtils.deleteLogos([4, 5], false);
+ expect(API.deleteLogos).toHaveBeenCalledWith([4, 5], false);
+ });
+ });
+
+ // ── cleanupUnusedLogos ──────────────────────────────────────────────────────
+ describe('cleanupUnusedLogos', () => {
+ it('calls API.cleanupUnusedLogos with deleteFiles=true', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.cleanupUnusedLogos.mockReturnValue(mockReturn);
+ const result = LogosTableUtils.cleanupUnusedLogos(true);
+ expect(API.cleanupUnusedLogos).toHaveBeenCalledWith(true);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('calls API.cleanupUnusedLogos with deleteFiles=false', () => {
+ LogosTableUtils.cleanupUnusedLogos(false);
+ expect(API.cleanupUnusedLogos).toHaveBeenCalledWith(false);
+ });
+ });
+
+ // ── generateUsageLabel ──────────────────────────────────────────────────────
+ describe('generateUsageLabel', () => {
+ describe('single type — channels only', () => {
+ it('returns singular "channel" for 1 channel', () => {
+ const names = ['Channel: HBO'];
+ expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 channel');
+ });
+
+ it('returns plural "channels" for multiple channels', () => {
+ const names = ['Channel: HBO', 'Channel: CNN', 'Channel: ESPN'];
+ expect(LogosTableUtils.generateUsageLabel(names, 3)).toBe('3 channels');
+ });
+ });
+
+ describe('single type — movies only', () => {
+ it('returns singular "movie" for 1 movie', () => {
+ const names = ['Movie: Inception'];
+ expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 movie');
+ });
+
+ it('returns plural "movies" for multiple movies', () => {
+ const names = ['Movie: Inception', 'Movie: Interstellar'];
+ expect(LogosTableUtils.generateUsageLabel(names, 2)).toBe('2 movies');
+ });
+ });
+
+ describe('single type — series only', () => {
+ it('returns "series" for 1 series', () => {
+ const names = ['Series: Breaking Bad'];
+ expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 series');
+ });
+
+ it('returns "series" for multiple series', () => {
+ const names = ['Series: Breaking Bad', 'Series: The Wire'];
+ expect(LogosTableUtils.generateUsageLabel(names, 2)).toBe('2 series');
+ });
+ });
+
+ describe('multiple types — generic items', () => {
+ it('returns singular "item" when channelCount is 1', () => {
+ const names = ['Channel: HBO', 'Movie: Inception'];
+ expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 item');
+ });
+
+ it('returns plural "items" when channelCount > 1', () => {
+ const names = ['Channel: HBO', 'Movie: Inception', 'Series: Lost'];
+ expect(LogosTableUtils.generateUsageLabel(names, 3)).toBe('3 items');
+ });
+
+ it('uses channelCount not array length for generic label', () => {
+ const names = ['Channel: HBO', 'Movie: Inception'];
+ expect(LogosTableUtils.generateUsageLabel(names, 5)).toBe('5 items');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('returns "0 items" for empty names array (no types match)', () => {
+ expect(LogosTableUtils.generateUsageLabel([], 0)).toBe('0 items');
+ });
+
+ it('ignores unrecognized name prefixes', () => {
+ const names = ['Unknown: Foo', 'Channel: HBO'];
+ expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 channel');
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js b/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js
new file mode 100644
index 00000000..de63e0e9
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js
@@ -0,0 +1,474 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as M3UsTableUtils from '../M3UsTableUtils';
+
+// ── Dependency mocks ────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ refreshPlaylist: vi.fn(),
+ getPlaylistAutoCreatedChannelsCount: vi.fn(),
+ deletePlaylist: vi.fn(),
+ updatePlaylist: vi.fn(),
+ },
+}));
+
+vi.mock('../../dateTimeUtils.js', () => ({
+ format: vi.fn((val, fmt) => `formatted:${fmt}`),
+ formatDuration: vi.fn((seconds) => `duration:${seconds}`),
+}));
+
+vi.mock('../../networkUtils.js', () => ({
+ formatSpeed: vi.fn((speed) => `speed:${speed}`),
+}));
+
+import API from '../../../api.js';
+import { format, formatDuration } from '../../dateTimeUtils.js';
+import { formatSpeed } from '../../networkUtils.js';
+
+describe('M3UsTableUtils', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ // ── API wrappers ────────────────────────────────────────────────────────────
+ describe('refreshPlaylist', () => {
+ it('calls API.refreshPlaylist with the correct id', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.refreshPlaylist.mockReturnValue(mockReturn);
+ const result = M3UsTableUtils.refreshPlaylist(3);
+ expect(API.refreshPlaylist).toHaveBeenCalledWith(3);
+ expect(result).toBe(mockReturn);
+ });
+ });
+
+ describe('getPlaylistAutoCreatedChannelsCount', () => {
+ it('calls API.getPlaylistAutoCreatedChannelsCount with the correct id', () => {
+ const mockReturn = Promise.resolve({ count: 5 });
+ API.getPlaylistAutoCreatedChannelsCount.mockReturnValue(mockReturn);
+ const result = M3UsTableUtils.getPlaylistAutoCreatedChannelsCount(7);
+ expect(API.getPlaylistAutoCreatedChannelsCount).toHaveBeenCalledWith(7);
+ expect(result).toBe(mockReturn);
+ });
+ });
+
+ describe('deletePlaylist', () => {
+ it('calls API.deletePlaylist with the correct id', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.deletePlaylist.mockReturnValue(mockReturn);
+ const result = M3UsTableUtils.deletePlaylist(2);
+ expect(API.deletePlaylist).toHaveBeenCalledWith(2);
+ expect(result).toBe(mockReturn);
+ });
+ });
+
+ describe('updatePlaylist', () => {
+ it('merges values with playlist id and passes isToggle', () => {
+ const mockReturn = Promise.resolve({});
+ API.updatePlaylist.mockReturnValue(mockReturn);
+ const result = M3UsTableUtils.updatePlaylist(
+ { is_active: false },
+ { id: 10, name: 'Test' },
+ true
+ );
+ expect(API.updatePlaylist).toHaveBeenCalledWith(
+ { is_active: false, id: 10 },
+ true
+ );
+ expect(result).toBe(mockReturn);
+ });
+
+ it('defaults isToggle to false when not provided', () => {
+ API.updatePlaylist.mockReturnValue(Promise.resolve({}));
+ M3UsTableUtils.updatePlaylist({ name: 'New' }, { id: 5 });
+ expect(API.updatePlaylist).toHaveBeenCalledWith(
+ { name: 'New', id: 5 },
+ false
+ );
+ });
+ });
+
+ // ── formatStatusText ────────────────────────────────────────────────────────
+ describe('formatStatusText', () => {
+ it.each([
+ ['idle', 'Idle'],
+ ['fetching', 'Fetching'],
+ ['parsing', 'Parsing'],
+ ['error', 'Error'],
+ ['success', 'Success'],
+ ['pending_setup', 'Pending Setup'],
+ ])('returns "%s" for status "%s"', (status, expected) => {
+ expect(M3UsTableUtils.formatStatusText(status)).toBe(expected);
+ });
+
+ it('capitalizes first letter for unknown status', () => {
+ expect(M3UsTableUtils.formatStatusText('loading')).toBe('Loading');
+ });
+
+ it('returns "Unknown" for null', () => {
+ expect(M3UsTableUtils.formatStatusText(null)).toBe('Unknown');
+ });
+
+ it('returns "Unknown" for undefined', () => {
+ expect(M3UsTableUtils.formatStatusText(undefined)).toBe('Unknown');
+ });
+
+ it('returns "Unknown" for empty string', () => {
+ expect(M3UsTableUtils.formatStatusText('')).toBe('Unknown');
+ });
+ });
+
+ // ── getStatusColor ──────────────────────────────────────────────────────────
+ describe('getStatusColor', () => {
+ it.each([
+ ['idle', 'gray.5'],
+ ['fetching', 'blue.5'],
+ ['parsing', 'indigo.5'],
+ ['error', 'red.5'],
+ ['success', 'green.5'],
+ ['pending_setup', 'orange.5'],
+ ])('returns "%s" for status "%s"', (status, expected) => {
+ expect(M3UsTableUtils.getStatusColor(status)).toBe(expected);
+ });
+
+ it('returns "gray.5" for unknown status', () => {
+ expect(M3UsTableUtils.getStatusColor('unknown')).toBe('gray.5');
+ });
+
+ it('returns "gray.5" for null', () => {
+ expect(M3UsTableUtils.getStatusColor(null)).toBe('gray.5');
+ });
+ });
+
+ // ── getExpirationInfo ───────────────────────────────────────────────────────
+ describe('getExpirationInfo', () => {
+ it('returns red.7 and "Expired" when daysLeft < 0', () => {
+ const result = M3UsTableUtils.getExpirationInfo(
+ -1,
+ '2024-01-01',
+ 'MM/DD/YYYY'
+ );
+ expect(result).toEqual({ color: 'red.7', label: 'Expired' });
+ });
+
+ it('returns red.5 and "Expires today" when daysLeft === 0', () => {
+ const result = M3UsTableUtils.getExpirationInfo(
+ 0,
+ '2024-06-01',
+ 'MM/DD/YYYY'
+ );
+ expect(result).toEqual({ color: 'red.5', label: 'Expires today' });
+ });
+
+ it('returns orange.5 and "{n}d left" when daysLeft is 1–7', () => {
+ expect(M3UsTableUtils.getExpirationInfo(1, null, 'MM/DD/YYYY')).toEqual({
+ color: 'orange.5',
+ label: '1d left',
+ });
+ expect(M3UsTableUtils.getExpirationInfo(7, null, 'MM/DD/YYYY')).toEqual({
+ color: 'orange.5',
+ label: '7d left',
+ });
+ });
+
+ it('returns yellow.5 and "{n}d left" when daysLeft is 8–30', () => {
+ expect(M3UsTableUtils.getExpirationInfo(8, null, 'MM/DD/YYYY')).toEqual({
+ color: 'yellow.5',
+ label: '8d left',
+ });
+ expect(M3UsTableUtils.getExpirationInfo(30, null, 'MM/DD/YYYY')).toEqual({
+ color: 'yellow.5',
+ label: '30d left',
+ });
+ });
+
+ it('returns formatted date label with no color when daysLeft > 30', () => {
+ format.mockReturnValue('12/31/2024');
+ const result = M3UsTableUtils.getExpirationInfo(
+ 60,
+ '2024-12-31',
+ 'MM/DD/YYYY'
+ );
+ expect(format).toHaveBeenCalledWith('2024-12-31', 'MM/DD/YYYY');
+ expect(result.label).toBe('12/31/2024');
+ expect(result.color).toBeUndefined();
+ });
+ });
+
+ // ── getExpirationTooltip ────────────────────────────────────────────────────
+ describe('getExpirationTooltip', () => {
+ it('returns the fallback label when allExpirations is empty', () => {
+ const result = M3UsTableUtils.getExpirationTooltip(
+ [],
+ 'MM/DD/YYYY HH:mm',
+ '7d left'
+ );
+ expect(result).toBe('7d left');
+ });
+
+ it('formats each expiration entry with profile name and date', () => {
+ format.mockImplementation(() => `2024-12-31`);
+ const expirations = [
+ { profile_name: 'Profile A', exp_date: '2024-12-31', is_active: true },
+ { profile_name: 'Profile B', exp_date: '2024-11-30', is_active: false },
+ ];
+ const result = M3UsTableUtils.getExpirationTooltip(
+ expirations,
+ 'MM/DD/YYYY HH:mm',
+ 'fallback'
+ );
+ expect(result).toContain('Profile A: 2024-12-31');
+ expect(result).toContain('Profile B: 2024-12-31 (inactive)');
+ });
+
+ it('does not append "(inactive)" for active profiles', () => {
+ format.mockReturnValue('2024-12-31');
+ const expirations = [
+ { profile_name: 'Active', exp_date: '2024-12-31', is_active: true },
+ ];
+ const result = M3UsTableUtils.getExpirationTooltip(
+ expirations,
+ 'MM/DD/YYYY',
+ 'fallback'
+ );
+ expect(result).not.toContain('(inactive)');
+ });
+
+ it('joins multiple entries with newline', () => {
+ format.mockReturnValue('2024-12-31');
+ const expirations = [
+ { profile_name: 'A', exp_date: '2024-12-31', is_active: true },
+ { profile_name: 'B', exp_date: '2024-12-31', is_active: true },
+ ];
+ const result = M3UsTableUtils.getExpirationTooltip(
+ expirations,
+ 'MM/DD/YYYY',
+ 'fallback'
+ );
+ expect(result.split('\n')).toHaveLength(2);
+ });
+ });
+
+ // ── getSortedPlaylists ──────────────────────────────────────────────────────
+ describe('getSortedPlaylists', () => {
+ const playlists = [
+ { id: 1, name: 'Zebra', locked: false, max_streams: 5 },
+ { id: 2, name: 'Alpha', locked: false, max_streams: 10 },
+ { id: 3, name: 'Middle', locked: true, max_streams: 1 },
+ { id: 4, name: 'Beta', locked: false, max_streams: 3 },
+ ];
+
+ it('excludes locked playlists', () => {
+ const result = M3UsTableUtils.getSortedPlaylists(
+ playlists,
+ 'name',
+ false
+ );
+ expect(result.find((p) => p.id === 3)).toBeUndefined();
+ expect(result).toHaveLength(3);
+ });
+
+ it('sorts by string column ascending', () => {
+ const result = M3UsTableUtils.getSortedPlaylists(
+ playlists,
+ 'name',
+ false
+ );
+ expect(result.map((p) => p.name)).toEqual(['Alpha', 'Beta', 'Zebra']);
+ });
+
+ it('sorts by string column descending', () => {
+ const result = M3UsTableUtils.getSortedPlaylists(playlists, 'name', true);
+ expect(result.map((p) => p.name)).toEqual(['Zebra', 'Beta', 'Alpha']);
+ });
+
+ it('sorts by numeric column ascending', () => {
+ const result = M3UsTableUtils.getSortedPlaylists(
+ playlists,
+ 'max_streams',
+ false
+ );
+ expect(result.map((p) => p.max_streams)).toEqual([3, 5, 10]);
+ });
+
+ it('sorts by numeric column descending', () => {
+ const result = M3UsTableUtils.getSortedPlaylists(
+ playlists,
+ 'max_streams',
+ true
+ );
+ expect(result.map((p) => p.max_streams)).toEqual([10, 5, 3]);
+ });
+
+ it('sorts nulls to the end regardless of direction', () => {
+ const withNulls = [
+ { id: 1, name: null, locked: false },
+ { id: 2, name: 'Alpha', locked: false },
+ { id: 3, name: null, locked: false },
+ ];
+ const asc = M3UsTableUtils.getSortedPlaylists(withNulls, 'name', false);
+ expect(asc[asc.length - 1].name).toBeNull();
+ expect(asc[asc.length - 2].name).toBeNull();
+
+ const desc = M3UsTableUtils.getSortedPlaylists(withNulls, 'name', true);
+ expect(desc[desc.length - 1].name).toBeNull();
+ });
+
+ it('returns empty array when all playlists are locked', () => {
+ const locked = [{ id: 1, name: 'Locked', locked: true }];
+ expect(M3UsTableUtils.getSortedPlaylists(locked, 'name', false)).toEqual(
+ []
+ );
+ });
+ });
+
+ // ── getStatusContent ────────────────────────────────────────────────────────
+ describe('getStatusContent', () => {
+ it('returns null when progress is 100', () => {
+ expect(
+ M3UsTableUtils.getStatusContent({ progress: 100, action: 'parsing' })
+ ).toBeNull();
+ });
+
+ it('returns initializing type for initializing action', () => {
+ expect(
+ M3UsTableUtils.getStatusContent({
+ progress: 50,
+ action: 'initializing',
+ })
+ ).toEqual({ type: 'initializing' });
+ });
+
+ describe('downloading', () => {
+ it('returns simple label when progress is 0', () => {
+ expect(
+ M3UsTableUtils.getStatusContent({
+ progress: 0,
+ action: 'downloading',
+ })
+ ).toEqual({ type: 'simple', label: 'Downloading...' });
+ });
+
+ it('returns downloading object with formatted fields when progress > 0', () => {
+ formatSpeed.mockReturnValue('speed:512');
+ formatDuration.mockReturnValue('duration:30');
+ const result = M3UsTableUtils.getStatusContent({
+ action: 'downloading',
+ progress: 50,
+ speed: 512,
+ time_remaining: 30,
+ });
+ expect(result).toEqual({
+ type: 'downloading',
+ progress: 50,
+ speed: 'speed:512',
+ timeRemaining: 'duration:30',
+ });
+ expect(formatSpeed).toHaveBeenCalledWith(512);
+ expect(formatDuration).toHaveBeenCalledWith(30);
+ });
+
+ it('returns "calculating..." when time_remaining is absent', () => {
+ formatSpeed.mockReturnValue('speed:512');
+ const result = M3UsTableUtils.getStatusContent({
+ action: 'downloading',
+ progress: 25,
+ speed: 512,
+ });
+ expect(result.timeRemaining).toBe('calculating...');
+ });
+ });
+
+ describe('processing_groups', () => {
+ it('returns simple label when progress is 0', () => {
+ expect(
+ M3UsTableUtils.getStatusContent({
+ progress: 0,
+ action: 'processing_groups',
+ })
+ ).toEqual({ type: 'simple', label: 'Processing groups...' });
+ });
+
+ it('returns groups object with formatted elapsed time', () => {
+ formatDuration.mockReturnValue('duration:120');
+ const result = M3UsTableUtils.getStatusContent({
+ action: 'processing_groups',
+ progress: 40,
+ elapsed_time: 120,
+ groups_processed: 15,
+ });
+ expect(result).toEqual({
+ type: 'groups',
+ progress: 40,
+ elapsedTime: 'duration:120',
+ groupsProcessed: 15,
+ });
+ expect(formatDuration).toHaveBeenCalledWith(120);
+ });
+ });
+
+ describe('parsing', () => {
+ it('returns simple label when progress is 0', () => {
+ expect(
+ M3UsTableUtils.getStatusContent({ progress: 0, action: 'parsing' })
+ ).toEqual({ type: 'simple', label: 'Parsing...' });
+ });
+
+ it('returns parsing object with all fields', () => {
+ formatDuration.mockReturnValue('duration:60');
+ const result = M3UsTableUtils.getStatusContent({
+ action: 'parsing',
+ progress: 75,
+ elapsed_time: 60,
+ time_remaining: 20,
+ streams_processed: 1000,
+ });
+ expect(result).toEqual({
+ type: 'parsing',
+ progress: 75,
+ elapsedTime: 'duration:60',
+ timeRemaining: 'duration:60',
+ streamsProcessed: 1000,
+ });
+ });
+
+ it('returns "calculating..." when time_remaining is absent', () => {
+ formatDuration.mockReturnValue('duration:60');
+ const result = M3UsTableUtils.getStatusContent({
+ action: 'parsing',
+ progress: 50,
+ elapsed_time: 60,
+ });
+ expect(result.timeRemaining).toBe('calculating...');
+ });
+ });
+
+ describe('default / error', () => {
+ it('returns error type when status is error', () => {
+ const result = M3UsTableUtils.getStatusContent({
+ action: 'unknown',
+ progress: 50,
+ status: 'error',
+ error: 'Something went wrong',
+ });
+ expect(result).toEqual({
+ type: 'error',
+ error: 'Something went wrong',
+ });
+ });
+
+ it('returns simple label with action name for unknown non-error action', () => {
+ const result = M3UsTableUtils.getStatusContent({
+ action: 'custom_action',
+ progress: 50,
+ status: 'running',
+ });
+ expect(result).toEqual({ type: 'simple', label: 'custom_action...' });
+ });
+
+ it('returns "Processing..." label when action is undefined', () => {
+ const result = M3UsTableUtils.getStatusContent({
+ progress: 30,
+ status: 'running',
+ });
+ expect(result).toEqual({ type: 'simple', label: 'Processing...' });
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js b/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js
new file mode 100644
index 00000000..8ef7bf3b
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js
@@ -0,0 +1,55 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as OutputProfilesTableUtils from '../OutputProfilesTableUtils';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateOutputProfile: vi.fn(),
+ deleteOutputProfile: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+describe('OutputProfilesTableUtils', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ describe('updateOutputProfile', () => {
+ it('calls API.updateOutputProfile with the provided values', () => {
+ const mockReturn = Promise.resolve({ id: 1, name: 'Updated' });
+ API.updateOutputProfile.mockReturnValue(mockReturn);
+ const values = { id: 1, name: 'Updated', is_active: true };
+ const result = OutputProfilesTableUtils.updateOutputProfile(values);
+ expect(API.updateOutputProfile).toHaveBeenCalledWith(values);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('passes through the return value from the API', async () => {
+ API.updateOutputProfile.mockResolvedValue({ id: 2, name: 'Profile' });
+ const result = await OutputProfilesTableUtils.updateOutputProfile({
+ id: 2,
+ });
+ expect(result).toEqual({ id: 2, name: 'Profile' });
+ });
+ });
+
+ describe('deleteOutputProfile', () => {
+ it('calls API.deleteOutputProfile with the correct id', async () => {
+ API.deleteOutputProfile.mockResolvedValue(undefined);
+ await OutputProfilesTableUtils.deleteOutputProfile(5);
+ expect(API.deleteOutputProfile).toHaveBeenCalledWith(5);
+ });
+
+ it('resolves without a return value', async () => {
+ API.deleteOutputProfile.mockResolvedValue(undefined);
+ const result = await OutputProfilesTableUtils.deleteOutputProfile(3);
+ expect(result).toBeUndefined();
+ });
+
+ it('propagates errors thrown by the API', async () => {
+ API.deleteOutputProfile.mockRejectedValue(new Error('Not found'));
+ await expect(
+ OutputProfilesTableUtils.deleteOutputProfile(99)
+ ).rejects.toThrow('Not found');
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js b/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js
new file mode 100644
index 00000000..dd6cf8ab
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js
@@ -0,0 +1,422 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StreamsTableUtils from '../StreamsTableUtils';
+
+// ── Dependency mocks ────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ addStreamsToChannel: vi.fn(),
+ queryStreamsTable: vi.fn(),
+ getStreams: vi.fn(),
+ createChannelsFromStreamsAsync: vi.fn(),
+ deleteStream: vi.fn(),
+ deleteStreams: vi.fn(),
+ requeryStreams: vi.fn(),
+ createChannelFromStream: vi.fn(),
+ getAllStreamIds: vi.fn(),
+ getStreamFilterOptions: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+describe('StreamsTableUtils', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ // ── API wrappers ────────────────────────────────────────────────────────────
+ describe('API wrappers', () => {
+ it('addStreamsToChannel calls API with correct args', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.addStreamsToChannel.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.addStreamsToChannel('ch-1', [1], [2, 3]);
+ expect(API.addStreamsToChannel).toHaveBeenCalledWith('ch-1', [1], [2, 3]);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('queryStreamsTable calls API with params', () => {
+ const params = new URLSearchParams({ page: '1' });
+ const mockReturn = Promise.resolve({ results: [] });
+ API.queryStreamsTable.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.queryStreamsTable(params);
+ expect(API.queryStreamsTable).toHaveBeenCalledWith(params);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('getStreams calls API with streamIds', () => {
+ const mockReturn = Promise.resolve([]);
+ API.getStreams.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.getStreams([10, 20]);
+ expect(API.getStreams).toHaveBeenCalledWith([10, 20]);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('createChannelsFromStreamsAsync calls API with correct args', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.createChannelsFromStreamsAsync.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.createChannelsFromStreamsAsync(
+ [1, 2],
+ [3],
+ 100
+ );
+ expect(API.createChannelsFromStreamsAsync).toHaveBeenCalledWith(
+ [1, 2],
+ [3],
+ 100
+ );
+ expect(result).toBe(mockReturn);
+ });
+
+ it('deleteStream calls API with id', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.deleteStream.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.deleteStream(5);
+ expect(API.deleteStream).toHaveBeenCalledWith(5);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('deleteStreams calls API with ids', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.deleteStreams.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.deleteStreams([1, 2, 3]);
+ expect(API.deleteStreams).toHaveBeenCalledWith([1, 2, 3]);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('requeryStreams calls API', () => {
+ const mockReturn = Promise.resolve(undefined);
+ API.requeryStreams.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.requeryStreams();
+ expect(API.requeryStreams).toHaveBeenCalled();
+ expect(result).toBe(mockReturn);
+ });
+
+ it('createChannelFromStream calls API with values', () => {
+ const mockReturn = Promise.resolve({ id: 1 });
+ API.createChannelFromStream.mockReturnValue(mockReturn);
+ const values = { name: 'New Channel' };
+ const result = StreamsTableUtils.createChannelFromStream(values);
+ expect(API.createChannelFromStream).toHaveBeenCalledWith(values);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('getAllStreamIds calls API with params', () => {
+ const params = new URLSearchParams();
+ const mockReturn = Promise.resolve([1, 2, 3]);
+ API.getAllStreamIds.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.getAllStreamIds(params);
+ expect(API.getAllStreamIds).toHaveBeenCalledWith(params);
+ expect(result).toBe(mockReturn);
+ });
+
+ it('getStreamFilterOptions calls API with params', () => {
+ const params = new URLSearchParams();
+ const mockReturn = Promise.resolve({});
+ API.getStreamFilterOptions.mockReturnValue(mockReturn);
+ const result = StreamsTableUtils.getStreamFilterOptions(params);
+ expect(API.getStreamFilterOptions).toHaveBeenCalledWith(params);
+ expect(result).toBe(mockReturn);
+ });
+ });
+
+ // ── getStatsTooltip ─────────────────────────────────────────────────────────
+ describe('getStatsTooltip', () => {
+ it('returns "-" compact display for empty stats', () => {
+ const { compactDisplay } = StreamsTableUtils.getStatsTooltip({});
+ expect(compactDisplay).toBe('-');
+ });
+
+ it('returns "No source info available" tooltip for empty stats', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({});
+ expect(tooltipContent).toBe('No source info available');
+ });
+
+ it('converts resolution "1920x1080" to "1080p" in compact display', () => {
+ const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
+ resolution: '1920x1080',
+ });
+ expect(compactDisplay).toBe('1080p');
+ });
+
+ it('converts resolution "1280x720" to "720p" in compact display', () => {
+ const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
+ resolution: '1280x720',
+ });
+ expect(compactDisplay).toBe('720p');
+ });
+
+ it('uppercases video_codec in compact display', () => {
+ const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
+ video_codec: 'h264',
+ });
+ expect(compactDisplay).toBe('H264');
+ });
+
+ it('combines resolution and video_codec in compact display', () => {
+ const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
+ resolution: '1920x1080',
+ video_codec: 'hevc',
+ });
+ expect(compactDisplay).toBe('1080p HEVC');
+ });
+
+ it('includes Resolution in tooltip when present', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
+ resolution: '1920x1080',
+ });
+ expect(tooltipContent).toContain('Resolution: 1920x1080');
+ });
+
+ it('includes uppercased Video Codec in tooltip', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
+ video_codec: 'h264',
+ });
+ expect(tooltipContent).toContain('Video Codec: H264');
+ });
+
+ it('includes Video Bitrate in tooltip', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
+ video_bitrate: 5000,
+ });
+ expect(tooltipContent).toContain('Video Bitrate: 5000 kbps');
+ });
+
+ it('includes Frame Rate in tooltip', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
+ source_fps: 30,
+ });
+ expect(tooltipContent).toContain('Frame Rate: 30 FPS');
+ });
+
+ it('includes uppercased Audio Codec in tooltip', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
+ audio_codec: 'aac',
+ });
+ expect(tooltipContent).toContain('Audio Codec: AAC');
+ });
+
+ it('includes Audio Channels in tooltip', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
+ audio_channels: 2,
+ });
+ expect(tooltipContent).toContain('Audio Channels: 2');
+ });
+
+ it('includes Audio Bitrate in tooltip', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
+ audio_bitrate: 192,
+ });
+ expect(tooltipContent).toContain('Audio Bitrate: 192 kbps');
+ });
+
+ it('builds multi-line tooltip joined by newlines', () => {
+ const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
+ resolution: '1920x1080',
+ video_codec: 'h264',
+ audio_codec: 'aac',
+ });
+ const lines = tooltipContent.split('\n');
+ expect(lines).toHaveLength(3);
+ });
+
+ it('handles resolution with no height part gracefully', () => {
+ const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
+ resolution: 'unknown',
+ });
+ // No 'x' separator — height is undefined, so no height part added
+ expect(compactDisplay).toBe('-');
+ });
+ });
+
+ // ── appendFetchPageParams ───────────────────────────────────────────────────
+ describe('appendFetchPageParams', () => {
+ it('appends page incremented by 1 from pageIndex', () => {
+ const params = new URLSearchParams();
+ StreamsTableUtils.appendFetchPageParams(
+ params,
+ { pageIndex: 2, pageSize: 25 },
+ []
+ );
+ expect(params.get('page')).toBe('3');
+ expect(params.get('page_size')).toBe('25');
+ });
+
+ it('does not append ordering when sorting is empty', () => {
+ const params = new URLSearchParams();
+ StreamsTableUtils.appendFetchPageParams(
+ params,
+ { pageIndex: 0, pageSize: 50 },
+ []
+ );
+ expect(params.get('ordering')).toBeNull();
+ });
+
+ it('appends ascending ordering for known column', () => {
+ const params = new URLSearchParams();
+ StreamsTableUtils.appendFetchPageParams(
+ params,
+ { pageIndex: 0, pageSize: 50 },
+ [{ id: 'name', desc: false }]
+ );
+ expect(params.get('ordering')).toBe('name');
+ });
+
+ it('appends descending ordering with "-" prefix', () => {
+ const params = new URLSearchParams();
+ StreamsTableUtils.appendFetchPageParams(
+ params,
+ { pageIndex: 0, pageSize: 50 },
+ [{ id: 'name', desc: true }]
+ );
+ expect(params.get('ordering')).toBe('-name');
+ });
+
+ it('maps "group" column to "channel_group__name"', () => {
+ const params = new URLSearchParams();
+ StreamsTableUtils.appendFetchPageParams(
+ params,
+ { pageIndex: 0, pageSize: 50 },
+ [{ id: 'group', desc: false }]
+ );
+ expect(params.get('ordering')).toBe('channel_group__name');
+ });
+
+ it('maps "m3u" column to "m3u_account__name"', () => {
+ const params = new URLSearchParams();
+ StreamsTableUtils.appendFetchPageParams(
+ params,
+ { pageIndex: 0, pageSize: 50 },
+ [{ id: 'm3u', desc: false }]
+ );
+ expect(params.get('ordering')).toBe('m3u_account__name');
+ });
+
+ it('maps "tvg_id" column to "tvg_id"', () => {
+ const params = new URLSearchParams();
+ StreamsTableUtils.appendFetchPageParams(
+ params,
+ { pageIndex: 0, pageSize: 50 },
+ [{ id: 'tvg_id', desc: false }]
+ );
+ expect(params.get('ordering')).toBe('tvg_id');
+ });
+
+ it('uses column id directly for unmapped columns', () => {
+ const params = new URLSearchParams();
+ StreamsTableUtils.appendFetchPageParams(
+ params,
+ { pageIndex: 0, pageSize: 50 },
+ [{ id: 'custom_field', desc: false }]
+ );
+ expect(params.get('ordering')).toBe('custom_field');
+ });
+ });
+
+ // ── getChannelProfileIds ────────────────────────────────────────────────────
+ describe('getChannelProfileIds', () => {
+ it('returns [] when profileIds includes "none"', () => {
+ expect(StreamsTableUtils.getChannelProfileIds(['none'], '0')).toEqual([]);
+ });
+
+ it('returns null when profileIds includes "all"', () => {
+ expect(StreamsTableUtils.getChannelProfileIds(['all'], '0')).toBeNull();
+ });
+
+ it('returns parsed integer array for specific profile ids', () => {
+ expect(
+ StreamsTableUtils.getChannelProfileIds(['1', '2', '3'], '0')
+ ).toEqual([1, 2, 3]);
+ });
+
+ it('returns [selectedProfileId as int] when profileIds is null and selectedProfileId is not "0"', () => {
+ expect(StreamsTableUtils.getChannelProfileIds(null, '3')).toEqual([3]);
+ });
+
+ it('returns null when profileIds is null and selectedProfileId is "0"', () => {
+ expect(StreamsTableUtils.getChannelProfileIds(null, '0')).toBeNull();
+ });
+
+ it('returns null when profileIds is undefined and selectedProfileId is "0"', () => {
+ expect(StreamsTableUtils.getChannelProfileIds(undefined, '0')).toBeNull();
+ });
+ });
+
+ // ── getChannelNumberValue ───────────────────────────────────────────────────
+ describe('getChannelNumberValue', () => {
+ it('returns null for "provider" mode', () => {
+ expect(
+ StreamsTableUtils.getChannelNumberValue('provider', 100)
+ ).toBeNull();
+ });
+
+ it('returns 0 for "auto" mode', () => {
+ expect(StreamsTableUtils.getChannelNumberValue('auto', 100)).toBe(0);
+ });
+
+ it('returns -1 for "highest" mode', () => {
+ expect(StreamsTableUtils.getChannelNumberValue('highest', 100)).toBe(-1);
+ });
+
+ it('returns startNumber as Number for any other mode', () => {
+ expect(StreamsTableUtils.getChannelNumberValue('manual', 42)).toBe(42);
+ });
+
+ it('converts string startNumber to number', () => {
+ expect(StreamsTableUtils.getChannelNumberValue('manual', '200')).toBe(
+ 200
+ );
+ });
+ });
+
+ // ── getFilterParams ─────────────────────────────────────────────────────────
+ describe('getFilterParams', () => {
+ it('returns empty URLSearchParams for empty filters', () => {
+ const result = StreamsTableUtils.getFilterParams({});
+ expect(result.toString()).toBe('');
+ });
+
+ it('appends string filter values', () => {
+ const result = StreamsTableUtils.getFilterParams({ name: 'CNN' });
+ expect(result.get('name')).toBe('CNN');
+ });
+
+ it('appends "true" for boolean true values', () => {
+ const result = StreamsTableUtils.getFilterParams({ is_active: true });
+ expect(result.get('is_active')).toBe('true');
+ });
+
+ it('does not append boolean false values', () => {
+ const result = StreamsTableUtils.getFilterParams({ is_active: false });
+ expect(result.get('is_active')).toBeNull();
+ });
+
+ it('does not append null values', () => {
+ const result = StreamsTableUtils.getFilterParams({ name: null });
+ expect(result.get('name')).toBeNull();
+ });
+
+ it('does not append undefined values', () => {
+ const result = StreamsTableUtils.getFilterParams({ name: undefined });
+ expect(result.get('name')).toBeNull();
+ });
+
+ it('does not append empty string values', () => {
+ const result = StreamsTableUtils.getFilterParams({ name: '' });
+ expect(result.get('name')).toBeNull();
+ });
+
+ it('appends numeric values as strings', () => {
+ const result = StreamsTableUtils.getFilterParams({ page: 2 });
+ expect(result.get('page')).toBe('2');
+ });
+
+ it('handles multiple filters', () => {
+ const result = StreamsTableUtils.getFilterParams({
+ name: 'ESPN',
+ is_active: true,
+ group: '',
+ });
+ expect(result.get('name')).toBe('ESPN');
+ expect(result.get('is_active')).toBe('true');
+ expect(result.get('group')).toBeNull();
+ });
+ });
+});