Files
ersatztv/web/src/App.test.tsx
T
timothy 0bc74f9b1d fix(web): playout alternate-schedule/template links didn't render on click
routeFromLocation() returns the same ScreenRoute object reference for
/app/playouts and any /app/playouts/{id}/* sub-path, so navigateToPath's
pushState + synthetic popstate caused App's setActiveRoute(routeFromLocation())
to bail via Object.is and never re-invoke ScreenContent. Only a hard reload
picked up the new sub-route.

Mirror the existing BlocksScreen/TemplatesScreen/DecosScreen pattern: add a
PlayoutsRouteScreen wrapper that owns its own pathname state and popstate
listener, so client-side navigation into and out of the alternate-schedules
and templates editors re-renders correctly.
2026-07-07 20:56:32 +02:00

4170 lines
157 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from './App';
import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components';
import {
applyDesignSystemTheme,
designSystemStylesheet,
getStoredDesignSystemTheme
} from './designSystem';
describe('ChicoryTV SPA scaffold', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
window.history.replaceState(null, '', '/');
});
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app');
vi.restoreAllMocks();
mockDashboardApi();
});
it('renders the admin shell chrome and loads the design system stylesheet', async () => {
render(<App />);
expect(screen.getByRole('img', { name: 'ChicoryTV' })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument();
expect(screen.getByRole('banner')).toBeInTheDocument();
expect(screen.getByRole('searchbox', { name: 'Search' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument();
expect(await screen.findAllByText('Healthy')).toHaveLength(2);
expect(designSystemStylesheet).toBe('../../design-system/styles.css');
});
it('routes between shell screen slots without a page reload', async () => {
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(screen.getByRole('heading', { name: 'Channels' })).toBeInTheDocument();
expect(await screen.findByText('No channels returned')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Channels' })).toHaveAttribute(
'aria-current',
'page'
);
expect(window.location.pathname).toBe('/app/channels');
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(screen.getByRole('heading', { name: 'Schedules' })).toBeInTheDocument();
expect(await screen.findByText('No schedules returned')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/schedules');
});
it('preserves native browser behavior for modified nav link clicks', () => {
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }), { ctrlKey: true });
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app');
});
it('selects the active screen from the current location', () => {
window.history.replaceState(null, '', '/app/guide');
render(<App />);
expect(screen.getByRole('heading', { name: 'Guide' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Loading guide' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Guide' })).toHaveAttribute('aria-current', 'page');
});
it('renders the Guide screen from a bounded JSON guide window', async () => {
vi.setSystemTime(new Date('2026-07-05T20:30:00Z'));
mockDashboardApi({
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true,
nowPlaying: {
finishUtc: '2026-07-05T21:00:00Z',
startUtc: '2026-07-05T20:00:00Z',
title: 'Saturday Morning Cartoons - s01e01 - Pilot'
}
},
{ channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null }
],
guide: guideFixture()
});
const { container } = render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Guide' }));
expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getByText('News 24')).toBeInTheDocument();
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
expect(screen.getByText('Pilot')).toBeInTheDocument();
expect(screen.getAllByText('Kids').length).toBeGreaterThan(0);
expect(screen.getByText('Early Show')).toBeInTheDocument();
expect(screen.getByText('No programmes in this window')).toBeInTheDocument();
expect(screen.queryByText('Filler')).not.toBeInTheDocument();
expect(container.querySelectorAll('.ctv-epg-programme-live')).toHaveLength(1);
const guideCall = fetchCallsStartingWith('/api/guide?')[0];
expect(guideCall).toBeDefined();
const guideUrl = new URL(guideCall, window.location.origin);
expect(guideUrl.searchParams.get('start')).toBe('2026-07-05T19:30:00.000Z');
expect(guideUrl.searchParams.get('end')).toBe('2026-07-06T08:30:00.000Z');
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
});
it('renders the Guide screen when an on-air channel omits nowPlaying', async () => {
mockDashboardApi({
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true
}
],
guide: guideFixture()
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Guide' }));
expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
});
it('moves the Guide now marker on timer ticks without polling the guide endpoint', async () => {
const intervalHandlers: Array<() => void> = [];
vi.setSystemTime(new Date('2026-07-05T20:30:00Z'));
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler) => {
if (typeof handler === 'function') {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
mockDashboardApi({ guide: guideFixture() });
const { container } = render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Guide' }));
expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
const guideFetchesBeforeTick = fetchCallsStartingWith('/api/guide?').length;
const marker = container.querySelector('.ctv-epg-now-marker') as HTMLElement;
expect(marker.style.left).toBe('508px');
vi.setSystemTime(new Date('2026-07-05T21:00:00Z'));
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(marker.style.left).toBe('664px');
});
expect(fetchCallsStartingWith('/api/guide?')).toHaveLength(guideFetchesBeforeTick);
});
it('fetches one Guide window per explicit navigation and jumps back to now', async () => {
vi.setSystemTime(new Date('2026-07-05T20:30:00Z'));
mockDashboardApi({ guide: guideFixture() });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Guide' }));
expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
expect(fetchCallsStartingWith('/api/guide?')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Next guide window' }));
await waitFor(() => {
expect(fetchCallsStartingWith('/api/guide?')).toHaveLength(2);
});
const nextUrl = new URL(fetchCallsStartingWith('/api/guide?')[1], window.location.origin);
expect(nextUrl.searchParams.get('start')).toBe('2026-07-06T08:30:00.000Z');
expect(nextUrl.searchParams.get('end')).toBe('2026-07-06T21:30:00.000Z');
fireEvent.click(screen.getByRole('button', { name: 'Jump to now' }));
await waitFor(() => {
expect(fetchCallsStartingWith('/api/guide?')).toHaveLength(3);
});
const nowUrl = new URL(fetchCallsStartingWith('/api/guide?')[2], window.location.origin);
expect(nowUrl.searchParams.get('start')).toBe('2026-07-05T19:30:00.000Z');
expect(nowUrl.searchParams.get('end')).toBe('2026-07-06T08:30:00.000Z');
});
it('shows Guide API errors and retries', async () => {
mockDashboardApi({
guide: guideFixture(),
guideFailuresBeforeSuccess: 1
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Guide' }));
expect(await screen.findByText('Request failed with status 500')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
});
it('shows a not-found screen for unknown app routes', () => {
window.history.replaceState(null, '', '/app/channels/42');
render(<App />);
expect(screen.getByRole('heading', { name: 'Page not found' })).toBeInTheDocument();
expect(screen.getByText('/app/channels/42')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Dashboard' })).not.toHaveAttribute(
'aria-current',
'page'
);
});
it('opens the Connect menu with absolute player endpoints and live channel count', async () => {
mockDashboardApi({
channels: [
{ id: 1, name: 'Retro Cartoons', number: '5.1' },
{ id: 2, name: 'News 24', number: '24' }
]
});
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
expect(screen.getByRole('dialog', { name: 'Connect a player' })).toBeInTheDocument();
expect(screen.getByText('M3U playlist')).toBeInTheDocument();
expect(screen.getByText('XMLTV guide')).toBeInTheDocument();
expect(screen.getByText(`${window.location.origin}/iptv/channels.m3u`)).toBeInTheDocument();
expect(screen.getByText(`${window.location.origin}/iptv/xmltv.xml`)).toBeInTheDocument();
expect(await screen.findByText('2 channels')).toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: 'Only enabled channels' })).not.toBeInTheDocument();
});
it('focuses the Connect menu and closes it with Escape', () => {
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
const dialog = screen.getByRole('dialog', { name: 'Connect a player' });
expect(dialog).toHaveFocus();
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Connect a player' })).not.toBeInTheDocument();
});
it('applies the warm design-system default by removing the theme attribute', () => {
document.documentElement.dataset.theme = 'cool';
applyDesignSystemTheme('warm');
expect(document.documentElement).not.toHaveAttribute('data-theme');
expect(window.localStorage.getItem('ctv-theme')).toBe('warm');
});
it('applies and persists alternate design-system themes', () => {
applyDesignSystemTheme('cool');
expect(document.documentElement).toHaveAttribute('data-theme', 'cool');
expect(window.localStorage.getItem('ctv-theme')).toBe('cool');
});
it('ignores invalid persisted themes', () => {
window.localStorage.setItem('ctv-theme', 'unknown');
expect(getStoredDesignSystemTheme()).toBe('warm');
});
it('switches themes from the shell controls', () => {
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Dual accent theme' }));
expect(document.documentElement).toHaveAttribute('data-theme', 'dual');
expect(window.localStorage.getItem('ctv-theme')).toBe('dual');
expect(screen.getByRole('button', { name: 'Dual accent theme' })).toHaveAttribute(
'aria-pressed',
'true'
);
});
it('renders routed placeholder cards for the downstream screen work', async () => {
render(<App />);
expect(await screen.findByText('On air now')).toBeInTheDocument();
expect(screen.getByText('System health')).toBeInTheDocument();
expect(screen.queryByText('Recent activity')).not.toBeInTheDocument();
expect(screen.queryByText('Release notes')).not.toBeInTheDocument();
});
it('renders dashboard cards and stats from live API responses', async () => {
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
id: 1,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
streamingMode: 'HLS Direct'
},
{
fFmpegProfile: 'MPEG-TS',
id: 2,
language: 'fr',
name: 'News 24',
number: '24',
streamingMode: 'MPEG-TS'
}
],
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true,
nowPlaying: {
finishUtc: '2026-07-04T21:30:00Z',
startUtc: '2026-07-04T21:00:00Z',
title: 'Saturday Morning Cartoons'
}
},
{
channelId: 2,
channelNumber: '24',
onAir: false,
nowPlaying: null
}
],
health: [
{
detail: 'SQLite is reachable',
link: null,
status: 'pass',
title: 'Database'
},
{
detail: 'FFmpeg path is missing',
link: null,
status: 'warn',
title: 'FFmpeg'
}
],
mediaSources: [
{
connectionAddress: null,
id: 30,
kind: 'Local',
libraries: [
{ id: 31, kind: 'Movies', name: 'Movies' },
{ id: 32, kind: 'Shows', name: 'Shows' }
],
name: 'Local'
}
],
playouts: {
page: [
{
buildStatus: {
lastBuild: '2026-07-04T20:00:00Z',
message: null,
success: true
},
channelName: 'Retro Cartoons',
channelNumber: '5.1',
dailyRebuildTime: null,
id: 20,
scheduleKind: 'Classic',
scheduleName: 'Default Schedule'
}
],
totalCount: 1
},
version: { apiVersion: 3, appVersion: '26.4.0-test' }
});
render(<App />);
expect(await screen.findByRole('heading', { name: 'On air now' })).toBeInTheDocument();
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getByText('5.1')).toBeInTheDocument();
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
expect(screen.getByText('1 on air')).toBeInTheDocument();
expect(screen.getAllByText('1 warning')).toHaveLength(2);
expect(screen.getAllByText('2')).toHaveLength(2);
expect(screen.getByText('26.4.0-test')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/media-sources', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/health', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/version', expect.any(Object));
expect(window.fetch).not.toHaveBeenCalledWith('/api/sessions', expect.any(Object));
expect(window.fetch).not.toHaveBeenCalledWith('/api/schedules', expect.any(Object));
});
it('renders the Channels screen from live channel and state APIs', async () => {
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
fFmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true,
nowPlaying: {
finishUtc: '2026-07-04T21:30:00Z',
startUtc: '2026-07-04T21:00:00Z',
title: 'Saturday Morning Cartoons'
}
},
{
channelId: 2,
channelNumber: '24',
onAir: false,
nowPlaying: null
}
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByRole('heading', { name: 'Channels' })).toBeInTheDocument();
expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument();
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
expect(screen.getByText('News 24')).toBeInTheDocument();
expect(screen.getByText('Off air')).toBeInTheDocument();
expect(screen.getByTitle('Disabled')).toHaveTextContent('D');
expect(screen.getByTitle('Hidden from EPG')).toHaveTextContent('H');
expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();
expect(screen.getByText('2 of 2 channels')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
});
it('polls only channel state after the Channels screen initial load', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler) => {
if (typeof handler === 'function') {
intervalHandlers.push(handler as () => void);
}
return 1;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Segmenter'
}
],
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
const channelFetchesBeforePoll = fetchCount('/api/channels');
const stateFetchesBeforePoll = fetchCount('/api/channels/state');
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll);
});
expect(fetchCount('/api/channels')).toBe(channelFetchesBeforePoll);
});
it('keeps preview disabled because the list DTO lacks legacy preview support data', async () => {
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Segmenter'
}
],
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Preview unavailable for Retro Cartoons' })).toBeDisabled();
});
it('filters the Channels screen by on-air and disabled rows', async () => {
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
fFmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
channelStates: [
{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null },
{ channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null }
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Disabled 1' }));
expect(screen.queryByText('Retro Cartoons')).not.toBeInTheDocument();
expect(screen.getByText('News 24')).toBeInTheDocument();
});
it('runs channel row delete and refetches the Channels screen', async () => {
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
}
],
confirm: true
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete Retro Cartoons' }));
await screen.findByText('Retro Cartoons');
expect(window.fetch).toHaveBeenCalledWith('/api/channels/1', expect.objectContaining({ method: 'DELETE' }));
expect(fetchCount('/api/channels')).toBeGreaterThan(2);
});
it('runs bulk move and delete actions with ProblemDetails error display', async () => {
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
}
],
confirm: true,
mutationFailures: {
'/api/channels/bulk/delete': {
detail: 'Channel 1 cannot be deleted while active',
status: 422,
title: 'Validation failed'
}
},
prompt: 'Movies'
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Move to group' }));
expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/group', expect.objectContaining({
body: JSON.stringify({ channelIds: [1], group: 'Movies' }),
method: 'POST'
}));
expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
'aria-checked',
'false'
);
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Delete selected' }));
expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument();
});
it('runs bulk renumber and clears the selection afterward', async () => {
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
fFmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: false,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: false,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
prompt: '10'
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
fireEvent.click(screen.getByRole('button', { name: 'Renumber' }));
expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/renumber', expect.objectContaining({
body: JSON.stringify({ channels: [{ id: 1, number: '10' }, { id: 2, number: '11' }] }),
method: 'POST'
}));
expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
'aria-checked',
'false'
);
expect(screen.getByRole('checkbox', { name: 'Select News 24' })).toHaveAttribute(
'aria-checked',
'false'
);
});
it('clears the selection when the channel view filter changes', async () => {
mockDashboardApi({
channels: [
{
fFmpegProfile: 'HLS Direct',
group: 'Kids',
id: 1,
isEnabled: true,
language: 'en',
name: 'Retro Cartoons',
number: '5.1',
showInEpg: true,
sortNumber: 5.1,
streamingMode: 'HLS Direct'
},
{
fFmpegProfile: 'MPEG-TS',
group: 'News',
id: 2,
isEnabled: true,
language: 'fr',
name: 'News 24',
number: '24',
showInEpg: true,
sortNumber: 24,
streamingMode: 'MPEG-TS'
}
],
channelStates: [
{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null },
{ channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null }
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
expect(screen.getByRole('button', { name: 'Delete selected' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument();
});
it('renders the Schedule editor from live schedule APIs', async () => {
mockDashboardApi({
scheduleItems: [
scheduleItem({ durationEstimate: '01:30:00', id: 11, name: 'Saturday Cartoons' }),
scheduleItem({
collection: { id: 6, name: 'Station IDs' },
collectionType: 'Collection',
durationEstimate: null,
guideMode: 'Filler',
id: 12,
name: 'Station IDs',
playoutMode: 'Flood'
})
],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })],
scheduleItemsTotalDuration: '01:30:00'
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
expect(screen.getByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument();
expect(screen.getAllByText('Saturday Cartoons').length).toBeGreaterThan(0);
expect(screen.getAllByText('01:30:00').length).toBeGreaterThan(0);
expect(screen.getAllByText('Station IDs').length).toBeGreaterThan(0);
expect(screen.getByText('unknown')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Content' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getAllByDisplayValue('Prime Time Cartoons').length).toBeGreaterThan(0);
expect(window.fetch).toHaveBeenCalledWith('/api/schedules', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/collections', expect.any(Object));
expect(window.fetch).not.toHaveBeenCalledWith('/api/languages', expect.any(Object));
});
it('fetches the schedules screen data exactly once on mount', async () => {
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
expect(fetchCount('/api/schedules')).toBe(1);
});
it('shows the Schedule editor error state when a picker endpoint fails', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
if (path === '/api/schedules') {
return Promise.resolve(jsonResponse([schedule({ id: 5, name: 'Prime Time Cartoons' })]));
}
if (path === '/api/schedules/5/items') {
return Promise.resolve(jsonResponse({ items: [], totalDurationEstimate: null }));
}
if (path === '/api/collections') {
return Promise.resolve(jsonResponse({
detail: 'Collections service is unavailable',
status: 500,
title: 'Internal error'
}, 500));
}
return Promise.resolve(jsonResponse([]));
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('Collections service is unavailable')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
it('shows the Schedule editor loading state', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
if (path === '/api/schedules') {
return new Promise<Response>(() => {});
}
return Promise.resolve(jsonResponse([]));
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('Loading schedules')).toBeInTheDocument();
});
it('shows Schedule editor API errors and retries 404 parent handling', async () => {
mockDashboardApi({
scheduleItemFailuresBeforeSuccess: 1,
scheduleItemFailure: {
detail: 'Schedule 5 was not found',
status: 404,
title: 'Not found'
},
schedules: [schedule({ id: 5, name: 'Missing Schedule' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('Schedule 5 was not found')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
expect(fetchCount('/api/schedules/5/items')).toBe(2);
});
it('shows an empty Schedule editor state when no schedule exists', async () => {
mockDashboardApi({ schedules: [] });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('No schedules returned')).toBeInTheDocument();
});
it('shows an empty Schedule editor lineup for a schedule with no items', async () => {
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Empty Schedule' })] });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add item' })).toBeInTheDocument();
});
it('reorders schedule items with keyboard controls and sends the exact replace-all body', async () => {
const first = scheduleItem({ id: 11, name: 'Saturday Cartoons' });
const second = scheduleItem({
collection: { id: 6, name: 'Station IDs' },
durationEstimate: null,
guideMode: 'Filler',
id: 12,
name: 'Station IDs',
playoutMode: 'Flood'
});
mockDashboardApi({
replaceScheduleItemsResponse: [second, first],
scheduleItems: [first, second],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
const collectionsFetchesBeforeReorder = fetchCount('/api/collections');
fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({
body: JSON.stringify({ items: [scheduleItemRequest(second), scheduleItemRequest(first)] }),
method: 'PUT'
}));
});
const lineup = screen.getByRole('list', { name: 'Schedule lineup' });
const rows = within(lineup).getAllByRole('listitem');
expect(rows[0]).toHaveTextContent('Station IDs');
expect(rows[1]).toHaveTextContent('Saturday Cartoons');
expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeReorder);
});
it('rolls back schedule reorder and shows ProblemDetails on replace failure', async () => {
mockDashboardApi({
mutationFailures: {
'/api/schedules/5/items': {
detail: 'Schedule items are locked',
status: 422,
title: 'Validation failed'
}
},
scheduleItems: [
scheduleItem({ id: 11, name: 'Saturday Cartoons' }),
scheduleItem({ collection: { id: 6, name: 'Station IDs' }, id: 12, name: 'Station IDs' })
],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' }));
expect(await screen.findByText('Schedule items are locked')).toBeInTheDocument();
const lineup = screen.getByRole('list', { name: 'Schedule lineup' });
expect(within(lineup).getAllByRole('listitem')[0]).toHaveTextContent('Saturday Cartoons');
});
it('adds schedule items using the selected (non-first) collection', async () => {
const added = scheduleItem({ collection: { id: 4, name: 'Nature Docs' }, id: 20, name: 'Nature Docs' });
mockDashboardApi({
addScheduleItemResponse: added,
collections: [
collection({ id: 3, name: 'Movie Mix' }),
collection({ id: 4, name: 'Nature Docs' })
],
scheduleItems: [],
scheduleItemsAfterAdd: [added],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
const collectionsFetchesBeforeAdd = fetchCount('/api/collections');
fireEvent.change(screen.getByRole('combobox', { name: 'Collection for new item' }), {
target: { value: '4' }
});
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
expect(await screen.findAllByText('Nature Docs')).not.toHaveLength(0);
const expectedBody = scheduleItemRequest(added);
expect(expectedBody.collectionId).toBe(4);
expect(expectedBody.collectionType).toBe('Collection');
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({
body: JSON.stringify(expectedBody),
method: 'POST'
}));
expect(fetchCount('/api/schedules/5/items')).toBeGreaterThan(2);
expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeAdd);
});
it('shows ProblemDetails when adding a schedule item fails', async () => {
mockDashboardApi({
collections: [collection({ id: 3, name: 'Movie Mix' })],
mutationFailures: {
'/api/schedules/5/items': {
detail: 'Collection is required',
status: 422,
title: 'Validation failed'
}
},
scheduleItems: [],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
expect(await screen.findByText('Collection is required')).toBeInTheDocument();
});
it('deletes schedule items and refetches after success', async () => {
mockDashboardApi({
confirm: true,
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })],
scheduleItemsAfterDelete: [],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' }));
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items/11', expect.objectContaining({
method: 'DELETE'
}));
});
it('shows ProblemDetails when deleting a schedule item fails', async () => {
mockDashboardApi({
confirm: true,
mutationFailures: {
'/api/schedules/5/items/11': {
detail: 'Item is in use by an active playout',
status: 422,
title: 'Validation failed'
}
},
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' }));
expect(await screen.findByText('Item is in use by an active playout')).toBeInTheDocument();
});
it('renders the Playouts monitor from live playout and channel state APIs', async () => {
mockDashboardApi({
channelStates: [
{
channelId: 1,
channelNumber: '5.1',
onAir: true,
nowPlaying: {
finishUtc: '2026-07-05T20:30:00Z',
startUtc: '2026-07-05T20:00:00Z',
title: 'Saturday Morning Cartoons'
}
}
],
playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleFile: '/config/schedules/retro.json' }),
playoutItems: [
playoutItem({ title: 'Saturday Morning Cartoons', start: '2026-07-05T20:00:00Z', finish: '2026-07-05T20:30:00Z' }),
playoutItem({ title: 'Station ID', start: '2026-07-05T20:30:00Z', finish: '2026-07-05T20:31:00Z', duration: '00:01:00' }),
playoutItem({ title: 'Moon Patrol', start: '2026-07-05T20:31:00Z', finish: '2026-07-05T21:00:00Z' })
],
playoutWarningsCount: 3,
playouts: {
page: [
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleName: 'Prime Time Cartoons' }),
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
],
totalCount: 2
}
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByText('2 playouts loaded')).toBeInTheDocument();
expect(screen.getByText('3 warnings')).toBeInTheDocument();
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
expect(screen.getAllByText('Moon Patrol').length).toBeGreaterThan(0);
expect(screen.getAllByText('Station ID').length).toBeGreaterThan(0);
// Real items carry fillerKind 'None' and must not be badged as filler.
expect(screen.queryByText('Filler')).not.toBeInTheDocument();
expect(screen.getByText('Metadata preview only')).toBeInTheDocument();
expect(screen.getByDisplayValue('Continuous')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20/items', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/warnings/count', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
});
it('renders the Libraries screen from live media source and scan status APIs', async () => {
mockDashboardApi({
libraryScanStatuses: [{ libraryId: 31, percent: 0.625 }],
mediaSources: [
mediaSource({
connectionAddress: null,
id: 30,
kind: 'Local',
libraries: [
library({ id: 31, itemCount: 1250, lastScan: '2026-07-05T14:30:00Z', mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 14, lastScan: null, mediaKind: 'OtherVideos', name: 'Station IDs' })
],
name: 'Local'
}),
mediaSource({
connectionAddress: 'https://plex.example.test',
id: 40,
kind: 'Plex',
libraries: [
library({ id: 41, itemCount: 80, lastScan: '2026-07-04T08:00:00Z', mediaKind: 'Shows', name: 'TV Shows' })
],
name: 'Plex Server'
}),
mediaSource({
connectionAddress: 'https://jellyfin.example.test',
id: 50,
kind: 'Jellyfin',
libraries: [],
name: 'Jellyfin Home'
}),
mediaSource({
connectionAddress: 'https://emby.example.test',
id: 60,
kind: 'Emby',
libraries: [
library({ id: 61, itemCount: 22, lastScan: '2026-07-01T00:00:00Z', mediaKind: 'MusicVideos', name: 'Music Videos' })
],
name: 'Emby Archive'
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
expect(screen.getByText('4 sources')).toBeInTheDocument();
expect(screen.getByText('1,366 items')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Local media source' })).toBeInTheDocument();
expect(screen.getByText('Local connection')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Plex Server media source' })).toBeInTheDocument();
expect(screen.getByText('https://plex.example.test')).toBeInTheDocument();
expect(screen.getAllByText('Movies').length).toBeGreaterThan(0);
expect(screen.getByText('Station IDs')).toBeInTheDocument();
expect(screen.getByText('TV Shows')).toBeInTheDocument();
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
expect(screen.getByText('Other Videos')).toBeInTheDocument();
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
// "Scanning" now appears twice: once as the source-header status label (paired with
// the StatusDot, not color alone) and once as the per-library scanning Badge.
expect(screen.getAllByText('Scanning').length).toBeGreaterThan(0);
// Wire contract: percent is a 0-1 fraction (0.625) - the UI must render it as 63%, not 0.625%.
expect(screen.getByText('63%')).toBeInTheDocument();
expect(screen.getAllByText('Synced').length).toBeGreaterThan(0);
expect(screen.getAllByText('Never scanned').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Add Source' })).toBeDisabled();
expect(screen.getByText('Adding sources is deferred to the existing server setup screens.')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/media-sources', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/scan-status', expect.any(Object));
});
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
mockDashboardApi({
libraryScanStatuses: [
{ libraryId: 31, percent: 0 },
{ libraryId: 32, percent: 1 }
],
mediaSources: [
mediaSource({
libraries: [
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
]
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('0%')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('fetches Libraries data as a route delta and does not poll sources', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
mockDashboardApi({
libraryScanStatuses: [{ libraryId: 31, percent: 0.1 }],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
await waitFor(() => {
expect(fetchCount('/api/media-sources')).toBeGreaterThan(0);
});
const sourceFetchesBeforeNavigation = fetchCount('/api/media-sources');
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeNavigation + 1);
expect(fetchCount('/api/libraries/scan-status')).toBe(1);
const sourceFetchesBeforePoll = fetchCount('/api/media-sources');
await runPollTick(intervalHandlers);
expect(fetchCount('/api/libraries/scan-status')).toBeGreaterThan(1);
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforePoll);
});
it('stops Libraries scan polling and refreshes sources once when scans complete', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatusSequence: [
[{ libraryId: 31, percent: 0.75 }],
[]
],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('75%')).toBeInTheDocument();
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
await runPollTick(intervalHandlers);
expect(screen.queryByText('75%')).not.toBeInTheDocument();
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
expect(clearCount).toBeGreaterThan(0);
});
it('triggers a library scan, disables that library while in flight, and starts polling scan status', async () => {
const scanStatusesAfterMutation = [{ libraryId: 31, percent: 0.05 }];
mockDashboardApi({
libraryScanStatuses: [],
libraryScanStatusesAfterMutation: scanStatusesAfterMutation,
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
expect(await screen.findByText('5%')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
});
it('keeps the scan button disabled and polling armed through the queue-to-start race, then clears once the scan finishes', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatusSequence: [
[], // initial screen load
[], // trigger's immediate post-POST fetch: scanner hasn't started yet
[{ libraryId: 31, percent: 0.3 }], // first poll tick: now active
[] // second poll tick: scan finished
],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
// The immediate post-trigger status fetch returned [] - without pending-id tracking
// the button would re-enable here and polling would never start. Both must hold.
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
await runPollTick(intervalHandlers);
expect(screen.getByText('30%')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
await runPollTick(intervalHandlers);
expect(screen.queryByText('30%')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
expect(clearCount).toBeGreaterThan(0);
});
it('re-enables the scan button and stops polling when a queued scan never appears (grace window expiry)', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatuses: [],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
// The id never shows up in scan-status. It survives a couple of ticks...
await runPollTick(intervalHandlers);
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
// ...but expires once the grace window runs out, freeing the button and the poll.
await runPollTick(intervalHandlers);
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
expect(clearCount).toBeGreaterThan(0);
});
it('re-enables the scan button and stops polling when scan-status errors repeatedly after a trigger (grace window on error)', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatuses: [],
libraryScanStatusFailAfterTrigger: true,
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
// The immediate post-trigger status fetch errors (first grace-tick burn), but the
// button must stay disabled and the poll must stay armed - a transient failure must
// not be indistinguishable from "give up immediately".
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
// scan-status keeps erroring on every poll tick...
await runPollTick(intervalHandlers);
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
// ...but the same grace budget burns down on failures too, so persistent failure
// eventually frees the button and stops the interval instead of polling forever.
await runPollTick(intervalHandlers);
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
expect(clearCount).toBeGreaterThan(0);
});
it('shows Libraries API errors and retries', async () => {
// The backend has no exception middleware, so a real failure is a bare 500 with no
// ProblemDetails body - do not invent one here (mirrors the scan-trigger-404 test below).
mockDashboardApi({
mediaSourcesFailuresBeforeSuccess: 2,
mediaSources: [mediaSource({ libraries: [] })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('Request failed with status 500')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
});
it('shows scan trigger failures without inventing application ProblemDetails', async () => {
mockDashboardApi({
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
],
mutationFailures: {
'/api/libraries/31/scan': {
status: 404
}
}
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
expect(await screen.findByText('Request failed with status 404')).toBeInTheDocument();
});
it('scans every library in a source when its Scan-all button is clicked', async () => {
mockDashboardApi({
mediaSources: [
mediaSource({
id: 30,
libraries: [
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
],
name: 'Local'
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('TV Shows')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Scan all libraries in Local' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/32/scan', expect.objectContaining({ method: 'POST' }));
});
expect(fetchCount('/api/libraries/31/scan')).toBe(1);
expect(fetchCount('/api/libraries/32/scan')).toBe(1);
});
it('shows an empty Libraries state when no media sources exist', async () => {
mockDashboardApi({ mediaSources: [] });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('No media sources returned')).toBeInTheDocument();
});
it('toggles Show filler to refetch playout items with showFiller=true and badge filler rows', async () => {
mockDashboardApi({
playoutItems: [
playoutItem({ title: 'Saturday Morning Cartoons', start: '2026-07-05T20:00:00Z', finish: '2026-07-05T20:30:00Z' }),
playoutItem({ title: 'Station ID', start: '2026-07-05T20:30:00Z', finish: '2026-07-05T20:31:00Z', duration: '00:01:00', fillerKind: 'PreRoll' })
],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
// Toggle off: the filler item is not requested, so no Filler badge renders.
expect(screen.queryByText('Station ID')).not.toBeInTheDocument();
expect(screen.queryByText('Filler')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', { name: 'Show filler' }));
expect((await screen.findAllByText('Station ID')).length).toBeGreaterThan(0);
expect(screen.getByText('Filler')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20/items?showFiller=true', expect.any(Object));
});
it('fetches the Playouts screen data exactly once on mount and only polls channel state', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler) => {
if (typeof handler === 'function') {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
mockDashboardApi({
channelStates: [
{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }
],
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
// The Dashboard also fetches /api/playouts on load; measure the Playouts
// screen's own contribution as a delta instead of absorbing that prefetch.
await waitFor(() => {
expect(fetchCount('/api/playouts')).toBeGreaterThan(0);
});
const playoutFetchesBeforeNavigation = fetchCount('/api/playouts');
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeNavigation + 1);
expect(fetchCount('/api/playouts/20')).toBe(1);
expect(fetchCount('/api/playouts/20/items')).toBe(1);
const playoutFetchesBeforePoll = fetchCount('/api/playouts');
const stateFetchesBeforePoll = fetchCount('/api/channels/state');
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll);
});
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforePoll);
expect(fetchCount('/api/playouts/20/items')).toBe(1);
});
it('switches selected playouts and fetches only the selected playout detail and items', async () => {
mockDashboardApi({
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
playoutItems: [playoutItem({ title: 'Saturday Morning Cartoons' })],
playouts: {
page: [
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24' })
],
totalCount: 2
}
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /News 24/ }));
expect(await screen.findByRole('heading', { name: 'News 24' })).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/21', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/21/items', expect.any(Object));
expect(fetchCount('/api/playouts')).toBe(2);
});
// Regression coverage for the live-E2E finding: clicking "Alternate schedules" / "Templates"
// used to update the URL via navigateToPath's pushState + synthetic popstate but never swap
// the rendered screen, because routeFromLocation() returns the SAME ScreenRoute object
// reference for /app/playouts and every /app/playouts/{id}/* sub-path, so React's
// setActiveRoute(sameRef) bailed via Object.is and ScreenContent never re-ran. The fix (see
// PlayoutsRouteScreen in App.tsx) mirrors BlocksScreen/TemplatesScreen/DecosScreen: it owns its
// own pathname state and popstate listener instead of relying on the shell's activeRoute.
it('client-side navigates into and back out of the alternate-schedules editor without a reload', async () => {
mockDashboardApi({
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' })], totalCount: 1 },
schedules: [{ id: 7, name: 'Alpha' }],
playoutAlternateSchedules: [
{
id: 1,
index: 0,
programScheduleId: 7,
daysOfWeek: [],
daysOfMonth: [],
monthsOfYear: [],
limitToDateRange: false,
startMonth: 1,
startDay: 1,
startYear: null,
endMonth: 12,
endDay: 31,
endYear: null
}
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Alternate schedules' }));
// Synchronous assertion (no findBy/await): the editor's initial render happens in the same
// tick as the click, before its data fetch resolves - proving the screen actually swapped
// instead of silently staying on the Playouts list while only the URL changed.
expect(screen.getByText('Loading alternate schedules…')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Retro Cartoons' })).not.toBeInTheDocument();
expect(window.location.pathname).toBe('/app/playouts/20/alternate-schedules');
fireEvent.click(await screen.findByRole('button', { name: 'All playouts' }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/playouts');
});
it('client-side navigates into and back out of the block-templates editor without a reload', async () => {
mockDashboardApi({
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleKind: 'Block' }),
playoutItems: [playoutItem()],
playouts: {
page: [listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleKind: 'Block' })],
totalCount: 1
},
templates: [{ id: 3, templateGroupId: 1, groupName: 'Grp', name: 'Weekdays' }],
playoutTemplateItems: [
{
id: 1,
index: 0,
templateId: 3,
templateName: 'Weekdays',
templateGroupName: 'Grp',
decoTemplateId: null,
decoTemplateName: null,
decoTemplateGroupName: null,
daysOfWeek: [],
daysOfMonth: [],
monthsOfYear: [],
limitToDateRange: false,
startMonth: 1,
startDay: 1,
startYear: null,
endMonth: 12,
endDay: 31,
endYear: null
}
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: 'Templates' }));
expect(screen.getByText('Loading playout templates…')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Retro Cartoons' })).not.toBeInTheDocument();
expect(window.location.pathname).toBe('/app/playouts/20/templates');
fireEvent.click(await screen.findByRole('button', { name: 'All playouts' }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/playouts');
});
it('shows the Playouts loading state', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
if (path === '/api/playouts') {
return new Promise<Response>(() => {});
}
if (path === '/api/channels/state') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/playouts/warnings/count') {
return Promise.resolve(jsonResponse(0));
}
return Promise.resolve(jsonResponse([]));
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect((await screen.findAllByText('Loading playouts')).length).toBeGreaterThan(0);
});
it('shows Playouts API errors and retries 404 parent handling', async () => {
mockDashboardApi({
playoutItemsFailure: {
detail: 'Playout 20 was not found',
status: 404,
title: 'Not found'
},
playoutItemsFailuresBeforeSuccess: 1,
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByText('Playout 20 was not found')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
});
it('shows an empty Playouts state when no playouts exist', async () => {
mockDashboardApi({ playouts: { page: [], totalCount: 0 } });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect((await screen.findAllByText('No playouts returned')).length).toBeGreaterThan(0);
});
it('resets all playouts with confirmation and refetches playout-scoped data', async () => {
mockDashboardApi({
confirm: true,
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
const playoutFetchesBeforeReset = fetchCount('/api/playouts');
const itemFetchesBeforeReset = fetchCount('/api/playouts/20/items');
fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/reset-all', expect.objectContaining({ method: 'POST' }));
});
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeReset + 1);
expect(fetchCount('/api/playouts/20/items')).toBe(itemFetchesBeforeReset + 1);
});
it('shows ProblemDetails when the reset-all transport fails with a 5xx', async () => {
// Backend contract: POST /api/playouts/reset-all is an unconditional 202 — there is no
// application-level error path today. This test guards client robustness only, against
// transport/server failures (proxy errors, unhandled exceptions).
mockDashboardApi({
confirm: true,
mutationFailures: {
'/api/playouts/reset-all': {
detail: 'The playout worker crashed while queueing rebuilds',
status: 500,
title: 'Internal Server Error'
}
},
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' }));
expect(await screen.findByText('The playout worker crashed while queueing rebuilds')).toBeInTheDocument();
});
it('filters the playout selector rail by channel name', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playouts: {
page: [
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
],
totalCount: 2
}
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
const rail = screen.getByRole('complementary', { name: 'Playout selector' });
expect(within(rail).getByRole('button', { name: /Retro Cartoons/ })).toBeInTheDocument();
expect(within(rail).getByRole('button', { name: /News 24/ })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Filter playouts'), { target: { value: 'News' } });
expect(within(rail).queryByRole('button', { name: /Retro Cartoons/ })).not.toBeInTheDocument();
expect(within(rail).getByRole('button', { name: /News 24/ })).toBeInTheDocument();
expect(screen.getByText('1 of 2 playouts')).toBeInTheDocument();
});
it('renders honestly when totalCount exceeds the returned page length', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutItemsTotalCount: 500,
playouts: {
page: [
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24' })
],
totalCount: 150
}
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
// Only 2 of 150 rows were returned; the count label must not claim all 150 loaded.
expect(screen.getByText('2 of 150 playouts')).toBeInTheDocument();
// Items page is also partial (1 of 500); the monitor must still render without crashing.
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
});
it('creates a classic playout from the Add Playout dialog and selects it', async () => {
mockDashboardApi({
channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
createPlayoutResponse: playout({ id: 42 }),
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
schedules: [schedule({ id: 7, name: 'Weekend Lineup' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
expect(await screen.findByText('Add playout')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Channel'), { target: { value: '1' } });
await screen.findByRole('option', { name: 'Weekend Lineup' });
fireEvent.change(screen.getByLabelText('Classic schedule'), { target: { value: '7' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(requestBodyFor('/api/playouts')).toMatchObject({
channelId: 1,
programScheduleId: 7,
scheduleKind: 'Classic'
});
});
expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.objectContaining({ method: 'POST' }));
await waitFor(() => {
expect(screen.queryByText('Add playout')).not.toBeInTheDocument();
});
});
it('shows the create-playout error inline when the API rejects the request', async () => {
mockDashboardApi({
channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
mutationFailures: {
'/api/playouts': {
detail: 'Channel already has one playout',
status: 422,
title: 'Validation failed'
}
},
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
schedules: [schedule({ id: 7, name: 'Weekend Lineup' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
expect(await screen.findByText('Add playout')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Channel'), { target: { value: '1' } });
await screen.findByRole('option', { name: 'Weekend Lineup' });
fireEvent.change(screen.getByLabelText('Classic schedule'), { target: { value: '7' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(await screen.findByText('Channel already has one playout')).toBeInTheDocument();
});
it('edits daily rebuild time from the playout detail panel', async () => {
mockDashboardApi({
playoutDetails: playout({ id: 20, dailyRebuildTime: '04:00:00' }),
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
updatePlayoutDetailsResponse: playout({ id: 20, dailyRebuildTime: '05:00:00' })
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
expect(await screen.findByText('Edit playout details')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Daily reset time'), { target: { value: '05:00:00' } });
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => {
expect(requestBodyFor('/api/playouts/20')).toMatchObject({ dailyRebuildTime: '05:00:00' });
});
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.objectContaining({ method: 'PUT' }));
await waitFor(() => {
expect(screen.queryByText('Edit playout details')).not.toBeInTheDocument();
});
});
it('shows the schedule-file field only for file-backed playout kinds when editing', async () => {
mockDashboardApi({
playoutDetails: playout({ id: 20, scheduleFile: '/config/schedule.yml', scheduleKind: 'Sequential' }),
playoutItems: [playoutItem()],
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Sequential' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
expect(await screen.findByText('Edit playout details')).toBeInTheDocument();
expect(screen.getByLabelText('Sequential schedule')).toBeInTheDocument();
});
it('shows the dashboard loading state while requests are pending', async () => {
vi.spyOn(window, 'fetch').mockImplementation(() => new Promise<Response>(() => {}));
render(<App />);
expect(await screen.findByText('Loading dashboard')).toBeInTheDocument();
});
it('shows an empty on-air state when no channel state is on air', async () => {
mockDashboardApi({
channels: [{ id: 1, name: 'Retro Cartoons', number: '5.1' }],
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: false, nowPlaying: null }]
});
render(<App />);
expect(await screen.findByText('No on-air channels reported')).toBeInTheDocument();
});
it('shows failing health checks with error styling, distinct from neutral info checks', async () => {
mockDashboardApi({
health: [
{
detail: 'SQLite is reachable',
link: null,
status: 'pass',
title: 'Database'
},
{
detail: 'FFmpeg path is missing',
link: null,
status: 'fail',
title: 'FFmpeg'
},
{
detail: 'Scheduled maintenance window active',
link: null,
status: 'info',
title: 'Maintenance'
}
]
});
const { container } = render(<App />);
expect(await screen.findByText('FFmpeg path is missing')).toBeInTheDocument();
expect(screen.getAllByText('1 failing')).toHaveLength(2);
expect(container.querySelectorAll('.ctv-health-icon-error')).toHaveLength(1);
expect(container.querySelectorAll('.ctv-health-icon-idle').length).toBeGreaterThanOrEqual(1);
});
it('refreshes health on demand without polling it', async () => {
mockDashboardApi({
health: [
{
detail: 'All checks passed',
link: null,
status: 'pass',
title: 'System'
}
]
});
render(<App />);
expect(await screen.findByText('All checks passed')).toBeInTheDocument();
expect(fetchCount('/api/health')).toBe(1);
fireEvent.click(screen.getByRole('button', { name: 'Refresh health' }));
expect(await screen.findByText('All checks passed')).toBeInTheDocument();
expect(fetchCount('/api/health')).toBe(2);
});
it('shows the API error detail when dashboard loading fails', async () => {
vi.spyOn(window, 'fetch').mockImplementation(() =>
Promise.resolve(new Response(
JSON.stringify({
detail: 'API write key is invalid',
status: 401,
title: 'Unauthorized'
}),
{
headers: { 'Content-Type': 'application/json' },
status: 401
}
))
);
render(<App />);
expect(await screen.findByText('API write key is invalid')).toBeInTheDocument();
});
it('exports typed primitives with expected interactions', () => {
const onSwitch = vi.fn();
const onCheckbox = vi.fn();
const onTab = vi.fn();
render(
<>
<Button variant="primary" loading>
Saving
</Button>
<Switch checked={false} onChange={onSwitch} label="Show disabled" />
<Checkbox indeterminate onChange={onCheckbox} label="Select all" />
<Input label="Channel number" value="5.1" error="Already in use" onChange={() => {}} />
<ProgressBar value={62} showLabel />
<Tabs
value="streaming"
onChange={onTab}
tabs={[{ value: 'streaming', label: 'Streaming' }]}
/>
<Tooltip label="Reset playout">
<button type="button">Reset</button>
</Tooltip>
</>
);
expect(screen.getByRole('button', { name: 'Saving' })).toBeDisabled();
fireEvent.click(screen.getByRole('switch', { name: 'Show disabled' }));
expect(onSwitch).toHaveBeenCalledWith(true);
expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveAttribute(
'aria-checked',
'mixed'
);
expect(screen.getByText('Already in use')).toBeInTheDocument();
expect(screen.getByText('62%')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Streaming' }));
expect(onTab).toHaveBeenCalledWith('streaming');
fireEvent.mouseEnter(screen.getByText('Reset'));
expect(screen.getByRole('tooltip')).toHaveTextContent('Reset playout');
});
it('uses distinct semantic icons for warning and error toasts', () => {
const { container } = render(
<>
<Toast tone="warn" title="Check settings" />
<Toast tone="error" title="Save failed" />
</>
);
expect(container.querySelector('.lucide-triangle-alert')).toBeInTheDocument();
expect(container.querySelector('.lucide-circle-x')).toBeInTheDocument();
});
// ---- Channel Builder (#89) -----------------------------------------------
const renderBuilder = async () => {
window.history.replaceState(null, '', '/app/new-channel');
const utils = render(<App />);
await screen.findByPlaceholderText('Search shows & movies…');
return utils;
};
// The shell's nav link and top-bar primary action also read "New Channel" /
// "Create Channel"; scope these to the builder toolbar.
const createBtn = () =>
within(document.querySelector('.ctv-builder-toolbar') as HTMLElement).getByRole('button', {
name: 'Create Channel'
});
const builderTitle = () => screen.getByText('New Channel', { selector: '.ctv-builder-title' });
const numberField = () => screen.getByLabelText('Number', { exact: false }) as HTMLInputElement;
const builderDefaults = () => ({
channels: [channelSummary()],
channelTemplates: [channelTemplate()],
defaultChannelTemplate: channelTemplate(),
ffmpegProfiles: [ffmpegProfile()]
});
it('renders the builder three-column layout with library items and the default template', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
expect(builderTitle()).toBeInTheDocument();
expect(screen.getByText('Lineup')).toBeInTheDocument();
expect(screen.getByText('Channel Template')).toBeInTheDocument();
expect(screen.getByText('Standard')).toBeInTheDocument();
expect(await screen.findByText('Looney Tunes')).toBeInTheDocument();
expect(screen.getByText('Tom & Jerry')).toBeInTheDocument();
expect(container.querySelectorAll('.ctv-builder-col')).toHaveLength(3);
expect(screen.getByText('This channel has no content yet. Double-click or drag titles from the library to build the lineup.')).toBeInTheDocument();
});
it('computes the AUTO channel number from the max integer part of existing channels', async () => {
mockDashboardApi({
...builderDefaults(),
channels: [channelSummary({ id: 1, number: '5' }), channelSummary({ id: 2, number: '13.1' })],
browseItems: [browseItem()]
});
await renderBuilder();
const numberInput = numberField();
expect(numberInput.value).toBe('14');
expect(screen.getByText('AUTO')).toBeInTheDocument();
});
it('adds a library item to the lineup on double-click and dedupes repeats', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
expect(container.querySelector('.ctv-builder-summary')?.textContent).toContain('1 in lineup');
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
fireEvent.doubleClick(screen.getAllByText('Looney Tunes')[0]);
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
});
it('removes a lineup item and clears the lineup through the confirm dialog', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(2);
fireEvent.click(within(container.querySelectorAll('.ctv-builder-lineup-row')[0] as HTMLElement).getByRole('button', { name: 'Remove' }));
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
fireEvent.click(await screen.findByRole('button', { name: 'Clear lineup' }));
await waitFor(() => expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(0));
});
it('filters library results through the debounced search query', async () => {
mockDashboardApi({
...builderDefaults(),
browseHandler: (search) => {
const query = (search.get('query') ?? '').toLowerCase();
const mediaType = search.get('mediaType');
const all = [
browseItem(),
browseItem({ id: 2, mediaItemId: 2, mediaType: 'Movie', collectionType: 'Movie', title: 'Blade Runner', libraryName: 'Movies' })
];
const page = all
.filter((item) => !mediaType || item.mediaType === mediaType)
.filter((item) => !query || String(item.title).toLowerCase().includes(query));
return { page, totalCount: page.length };
}
});
await renderBuilder();
expect(await screen.findByText('Blade Runner')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search shows & movies…'), { target: { value: 'blade' } });
await waitFor(() => expect(screen.queryByText('Looney Tunes')).not.toBeInTheDocument());
expect(screen.getByText('Blade Runner')).toBeInTheDocument();
});
it('reorders lineup items with native drag and drop', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
let rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes');
fireEvent.dragStart(rows[0]);
fireEvent.dragOver(rows[1]);
fireEvent.drop(rows[1]);
rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Tom & Jerry');
expect(rows[1].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes');
});
it('validates the channel number for format and uniqueness', async () => {
mockDashboardApi({
...builderDefaults(),
channels: [channelSummary({ number: '5' })],
browseItems: [browseItem()]
});
await renderBuilder();
const numberInput = numberField();
fireEvent.change(numberInput, { target: { value: 'abc' } });
expect(await screen.findByText(/Use digits/)).toBeInTheDocument();
fireEvent.change(numberInput, { target: { value: '5' } });
expect(await screen.findByText(/already in use/)).toBeInTheDocument();
});
it('keeps Create disabled until a name and at least one lineup item exist', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
await renderBuilder();
const createButton = createBtn();
expect(createButton).toBeDisabled();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } });
expect(createButton).toBeEnabled();
});
it('preselects the default template and flags an override when the Shuffle toggle diverges', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
// default template has shuffleScheduleItems=false -> no override tag yet
expect(screen.queryByText('overrides template')).not.toBeInTheDocument();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
expect(await screen.findByText('overrides template')).toBeInTheDocument();
});
it('applies a selected template shuffle default and clears the override', async () => {
mockDashboardApi({
...builderDefaults(),
channelTemplates: [
channelTemplate(),
channelTemplate({ id: 11, isDefault: false, name: 'Music videos', shuffleScheduleItems: true })
],
browseItems: [browseItem()]
});
const { container } = await renderBuilder();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
expect(await screen.findByText('overrides template')).toBeInTheDocument();
// open the template picker and select the shuffle-by-default template
fireEvent.click(screen.getByText('Standard'));
fireEvent.click(await screen.findByText('Music videos'));
await waitFor(() => expect(screen.queryByText('overrides template')).not.toBeInTheDocument());
});
it('marks a rerun-collection row invalid in a multi-item lineup and blocks Create', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [
browseItem(),
browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', collectionKind: 'Rerun', rerunCollectionId: 9, mediaItemId: undefined })
]
});
await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
// RerunCollection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Saturday Block'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Mixed' } });
expect(await screen.findByText('Only valid alone')).toBeInTheDocument();
expect(createBtn()).toBeDisabled();
});
it('clears a MultiCollection shuffle requirement when the Shuffle toggle is turned on', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [
browseItem({ id: 7, title: 'Prime Time', mediaType: 'MultiCollection', collectionType: 'MultiCollection', multiCollectionId: 7, mediaItemId: undefined })
]
});
const { container } = await renderBuilder();
// MultiCollection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Prime Time'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Multi' } });
expect(await screen.findByText('Requires Shuffle')).toBeInTheDocument();
expect(createBtn()).toBeDisabled();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
await waitFor(() => expect(screen.queryByText('Requires Shuffle')).not.toBeInTheDocument());
expect(createBtn()).toBeEnabled();
});
it('posts a well-formed create request and navigates to channels on success', async () => {
mockDashboardApi({
...builderDefaults(),
channels: [channelSummary({ number: '5' })],
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: ' Retro Cartoons ' } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const body = requestBodyFor('/api/channels/from-lineup');
expect(body.name).toBe('Retro Cartoons');
expect(body.number).toBe('6');
expect(body.group).toBe('ChicoryTV');
expect(body.templateId).toBe(10);
expect(body.logo).toEqual({ contentType: '', path: '' });
expect((body.advanced as Record<string, unknown>).playbackOrder).toBe('Chronological');
expect((body.advanced as Record<string, unknown>).playoutMode).toBe('Continuous');
const lineup = body.lineup as Array<Record<string, unknown>>;
expect(lineup).toHaveLength(2);
expect(lineup[0].mediaItemId).toBe(1);
expect(lineup[1].mediaItemId).toBe(2);
expect(lineup[0].mediaType).toBe('TelevisionShow');
});
it('highlights the offending lineup row and shows the detail on a 422 ProblemDetails', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })],
// Verbatim wire shape: ApiResults hard-codes the 422 title "Validation failed",
// and the handler's not-found detail is "lineup[i] <Label> <id> does not exist."
fromLineupFailure: { detail: 'lineup[1] TelevisionShow 999 does not exist.', status: 422, title: 'Validation failed' }
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
fireEvent.click(createBtn());
expect(await screen.findByText('lineup[1] TelevisionShow 999 does not exist.')).toBeInTheDocument();
await waitFor(() => {
const rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[1].classList.contains('ctv-builder-lineup-row-error')).toBe(true);
});
expect(window.location.pathname).toBe('/app/new-channel');
});
it('uploads the channel image then references the returned path in the create body', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const uploadCall = vi
.mocked(window.fetch)
.mock.calls.find(([input]) => input.toString() === '/api/artwork/uploads');
expect(uploadCall).toBeDefined();
const formData = uploadCall?.[1]?.body as FormData;
expect(formData).toBeInstanceOf(FormData);
expect(formData.get('target')).toBe('logo');
expect((formData.get('file') as File).name).toBe('logo.png');
const body = requestBodyFor('/api/channels/from-lineup');
expect(body.logo).toEqual({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' });
});
it('fans out 5 typed browse requests in Collections mode and renders the merged, title-sorted items', async () => {
const byType: Record<string, Record<string, unknown>> = {
Collection: browseItem({
id: 2,
title: 'Saturday Cartoons',
mediaType: 'Collection',
collectionType: 'Collection',
collectionKind: 'Manual',
collectionId: 2,
mediaItemId: undefined
}),
SmartCollection: browseItem({
id: 3,
title: 'Action Movies',
mediaType: 'SmartCollection',
collectionType: 'SmartCollection',
collectionKind: 'Smart',
smartCollectionId: 3,
mediaItemId: undefined
}),
MultiCollection: browseItem({
id: 7,
title: 'Prime Time',
mediaType: 'MultiCollection',
collectionType: 'MultiCollection',
collectionKind: 'Multi',
multiCollectionId: 7,
mediaItemId: undefined
}),
RerunCollection: browseItem({
id: 9,
title: 'Saturday Block',
mediaType: 'RerunCollection',
collectionType: 'RerunFirstRun',
collectionKind: 'Rerun',
rerunCollectionId: 9,
mediaItemId: undefined
}),
Playlist: browseItem({
id: 12,
title: 'Zzz Late Night',
mediaType: 'Playlist',
collectionType: 'Playlist',
playlistId: 12,
mediaItemId: undefined
})
};
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem()],
browseHandler: (search) => {
const mediaType = search.get('mediaType');
const item = mediaType ? byType[mediaType] : undefined;
return item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 };
}
});
const { container } = await renderBuilder();
// The initial render already fanned out over the 4 library kinds; only
// count requests made after switching to the Collections tab.
vi.mocked(window.fetch).mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
expect(await screen.findByText('Action Movies')).toBeInTheDocument();
const browseTypes = vi
.mocked(window.fetch)
.mock.calls.map(([input]) => input.toString())
.filter((url) => url.startsWith('/api/library/browse'))
.map((url) => new URL(url, window.location.origin).searchParams.get('mediaType'))
.filter((mediaType): mediaType is string => mediaType != null);
expect(browseTypes).toEqual(['Collection', 'SmartCollection', 'MultiCollection', 'RerunCollection', 'Playlist']);
const titles = Array.from(container.querySelectorAll('.ctv-builder-poster-title')).map((el) => el.textContent);
expect(titles).toEqual(['Action Movies', 'Prime Time', 'Saturday Block', 'Saturday Cartoons', 'Zzz Late Night']);
});
it('loads a second page of library results and preserves the first page', async () => {
const pages = [
[browseItem({ id: 1, mediaItemId: 1, title: 'Item A' }), browseItem({ id: 2, mediaItemId: 2, title: 'Item B' })],
[browseItem({ id: 3, mediaItemId: 3, title: 'Item C' })]
];
mockDashboardApi({
...builderDefaults(),
browseHandler: (search) => {
// The builder fans out per-kind (Movie/TelevisionShow/TelevisionSeason/Artist);
// only return real pages for the kind these fixtures use (TelevisionShow) so
// the other 3 kinds don't duplicate the same items.
if (search.get('mediaType') !== 'TelevisionShow') {
return { page: [], totalCount: 0 };
}
const pageNum = Number(search.get('pageNum') ?? '0');
return { page: pages[pageNum] ?? [], totalCount: 3 };
}
});
await renderBuilder();
expect(await screen.findByText('Item A')).toBeInTheDocument();
expect(screen.getByText('Item B')).toBeInTheDocument();
expect(screen.queryByText('Item C')).not.toBeInTheDocument();
const loadMoreButton = screen.getByRole('button', { name: 'Load more' });
fireEvent.click(loadMoreButton);
expect(await screen.findByText('Item C')).toBeInTheDocument();
expect(screen.getByText('Item A')).toBeInTheDocument();
expect(screen.getByText('Item B')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
});
it('saves the current settings as a new template and selects it', async () => {
const created = channelTemplate({
id: 55,
name: 'Saturday Special',
description: 'Custom mix.',
isDefault: false,
isSystem: false,
shuffleScheduleItems: true
});
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem()],
createTemplateResponse: created
});
const { container } = await renderBuilder();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
fireEvent.click(screen.getByText('Standard'));
fireEvent.click(await screen.findByText('Save current settings as template…'));
expect(await screen.findByText('Save as channel template')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Template name'), { target: { value: 'Saturday Special' } });
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Custom mix.' } });
fireEvent.click(screen.getByRole('button', { name: 'Save template' }));
await waitFor(() => expect(screen.queryByText('Save as channel template')).not.toBeInTheDocument());
const body = requestBodyFor('/api/channel-templates');
expect(body.name).toBe('Saturday Special');
expect(body.description).toBe('Custom mix.');
expect(body.shuffleScheduleItems).toBe(true);
expect(body.playoutMode).toBe('Continuous');
expect(screen.getByText('Saturday Special')).toBeInTheDocument();
});
it('shows a friendly error and skips channel creation when the artwork upload returns a bare 413', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem()],
artworkUploadFailure: { status: 413 }
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(createBtn());
expect(await screen.findByText('Image is too large (max 30 MB).')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/new-channel');
expect(
vi.mocked(window.fetch).mock.calls.some(([input]) => input.toString() === '/api/channels/from-lineup')
).toBe(false);
expect(createBtn()).toBeEnabled();
});
it('renders the empty-templates state and keeps Create disabled with no templates', async () => {
mockDashboardApi({
...builderDefaults(),
channelTemplates: [],
defaultChannelTemplate: null,
browseItems: [browseItem()]
});
await renderBuilder();
expect(
screen.getByText(/No channel templates exist yet\. Create one in Settings before building a channel/)
).toBeInTheDocument();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } });
expect(createBtn()).toBeDisabled();
});
it('sends a Collection lineup item with only its typed id populated', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [
browseItem({
id: 2,
title: 'Saturday Cartoons',
mediaType: 'Collection',
collectionType: 'Collection',
collectionKind: 'Manual',
collectionId: 2,
mediaItemId: undefined
})
]
});
await renderBuilder();
// Collection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Saturday Cartoons'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Marathon' } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const body = requestBodyFor('/api/channels/from-lineup');
const lineup = body.lineup as Array<Record<string, unknown>>;
expect(lineup).toHaveLength(1);
expect(lineup[0].mediaType).toBe('Collection');
expect(lineup[0].collectionType).toBe('Collection');
expect(lineup[0].collectionId).toBe(2);
expect(lineup[0].mediaItemId).toBeUndefined();
expect(lineup[0].multiCollectionId).toBeUndefined();
expect(lineup[0].smartCollectionId).toBeUndefined();
expect(lineup[0].rerunCollectionId).toBeUndefined();
expect(lineup[0].playlistId).toBeUndefined();
});
it('renders the builder under the cool and dual design themes', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
await renderBuilder();
applyDesignSystemTheme('cool');
expect(document.documentElement).toHaveAttribute('data-theme', 'cool');
expect(builderTitle()).toBeInTheDocument();
applyDesignSystemTheme('dual');
expect(document.documentElement).toHaveAttribute('data-theme', 'dual');
expect(screen.getByPlaceholderText('Search shows & movies…')).toBeInTheDocument();
});
});
describe('Settings screen (#93)', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
window.history.replaceState(null, '', '/');
});
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app');
vi.restoreAllMocks();
});
const openSettings = async () => {
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Settings' }));
expect(
await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')
).toBeInTheDocument();
};
it('renders each section on nav click', async () => {
mockDashboardApi();
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Settings' }));
expect(await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Streaming/ }));
expect(await screen.findByText('FFmpeg engine, transcoding defaults and HLS session tuning.')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/settings/streaming');
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
expect(await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Guide \(XMLTV\)/ }));
expect(await screen.findByText('Shape of the XMLTV guide data served to Jellyfin and other clients.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Scanner/ }));
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Logging/ }));
expect(await screen.findByText('Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
expect(await screen.findByText('HDHomeRun emulation, connected media sources and server info.')).toBeInTheDocument();
});
it('supports deep-linking directly to a section path', async () => {
mockDashboardApi();
window.history.replaceState(null, '', '/app/settings/scanner');
render(<App />);
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
});
it('loads values from the API into the General pane', async () => {
mockDashboardApi({ uiSettings: defaultUiSettings({ isDarkMode: false, language: 'fr' }) });
await openSettings();
expect(await screen.findByDisplayValue('Light')).toBeInTheDocument();
expect(screen.getByDisplayValue('fr')).toBeInTheDocument();
});
it('shows the save bar with the correct dirty count when editing a field, and Discard restores', async () => {
mockDashboardApi();
await openSettings();
const languageInput = screen.getByPlaceholderText('en-US');
fireEvent.change(languageInput, { target: { value: 'de-DE' } });
expect(await screen.findByText('1 unsaved change')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(screen.queryByText('1 unsaved change')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('en-US')).toBeInTheDocument();
});
it('saves only the changed group(s) and shows a confirmation', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockDashboardApi();
await openSettings();
const languageInput = screen.getByPlaceholderText('en-US');
fireEvent.change(languageInput, { target: { value: 'de-DE' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/ui')).toMatchObject({ language: 'de-DE' });
expect(window.fetch).not.toHaveBeenCalledWith('/api/settings/ffmpeg', expect.objectContaining({ method: 'PUT' }));
vi.advanceTimersByTime(2000);
await waitFor(() => expect(screen.queryByText('Settings saved')).not.toBeInTheDocument());
});
it('shows a warning callout when scanner refresh interval is 0', async () => {
mockDashboardApi({ scannerSettings: defaultScannerSettings({ libraryRefreshInterval: 0 }) });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Scanner/ }));
expect(await screen.findByText('Automatic scanning is disabled — libraries only update when scanned manually.')).toBeInTheDocument();
});
it('adds and deletes a custom resolution (delete goes through a confirm dialog)', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Custom resolutions');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
expect(await screen.findByText('2560 × 1080')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/resolutions')).toMatchObject({ height: 1080, width: 2560 });
const deleteButtons = screen.getAllByRole('button', { name: /Delete \d+×\d+/ });
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(screen.queryByText('2560 × 1080')).not.toBeInTheDocument());
});
it('does not show a delete button on non-custom resolutions', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('1920 × 1080');
expect(screen.queryByRole('button', { name: 'Delete 1920×1080' })).not.toBeInTheDocument();
});
it('renders FFmpeg profiles read-only with a link to the FFmpeg Profiles editor, and media sources', async () => {
mockDashboardApi({
ffmpegProfiles: [ffmpegProfile({ id: 1, name: '1080p H.264' }), ffmpegProfile({ id: 2, name: '720p H.264' })],
mediaSources: [mediaSource({ id: 30, kind: 'Local', name: 'Local' })]
});
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
expect((await screen.findAllByText('1080p H.264')).length).toBeGreaterThan(0);
expect(screen.getAllByText('720p H.264').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: /Manage FFmpeg profiles/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
expect(await screen.findByText('Local')).toBeInTheDocument();
});
it('shows a Classic UI link to the legacy Blazor app in the System pane (#147)', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
const link = await screen.findByRole('link', { name: /Open Classic UI/ });
expect(link).toHaveAttribute('href', '/system/health');
});
it('renders and stays editable when media sources (tier-2 reference data) fail to load', async () => {
mockDashboardApi({ mediaSourcesFailuresBeforeSuccess: 99 });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
expect(await screen.findByText("Couldn't load media sources")).toBeInTheDocument();
// The rest of the screen is fully functional: edit + save still work.
fireEvent.click(screen.getByRole('button', { name: /General/ }));
fireEvent.change(await screen.findByPlaceholderText('en-US'), { target: { value: 'nl-BE' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/ui')).toMatchObject({ language: 'nl-BE' });
});
it('shows the error state with a working retry when a settings group (tier-1) fails', async () => {
mockDashboardApi({ settingsGetFailuresBeforeSuccess: { '/api/settings/scanner': 1 } });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Settings' }));
const alert = await screen.findByRole('alert');
expect(alert).toBeInTheDocument();
expect(screen.queryByText('Loading settings…')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(
await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')
).toBeInTheDocument();
});
it('saves exactly the dirty groups when multiple groups are edited', async () => {
mockDashboardApi();
await openSettings();
fireEvent.change(screen.getByPlaceholderText('en-US'), { target: { value: 'de-DE' } });
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '5' } });
await screen.findByText('2 unsaved changes');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/ui')).toMatchObject({ language: 'de-DE' });
expect(requestBodyFor('/api/settings/playout')).toMatchObject({ daysToBuild: 5 });
const putCallCount = (path: string) =>
vi.mocked(window.fetch).mock.calls.filter(([input, init]) => input.toString() === path && init?.method === 'PUT')
.length;
expect(putCallCount('/api/settings/ui')).toBe(1);
expect(putCallCount('/api/settings/playout')).toBe(1);
expect(putCallCount('/api/settings/ffmpeg')).toBe(0);
expect(putCallCount('/api/settings/xmltv')).toBe(0);
expect(putCallCount('/api/settings/scanner')).toBe(0);
expect(putCallCount('/api/settings/logging')).toBe(0);
expect(putCallCount('/api/settings/hdhr')).toBe(0);
});
it('on partial save failure, keeps only the failed group dirty and surfaces its error', async () => {
mockDashboardApi({
settingsMutationFailures: { '/api/settings/playout': { detail: 'Playout save failed', status: 500 } }
});
await openSettings();
fireEvent.change(screen.getByPlaceholderText('en-US'), { target: { value: 'de-DE' } });
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '5' } });
await screen.findByText('2 unsaved changes');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('1 unsaved change')).toBeInTheDocument();
expect(screen.getByText(/Playout save failed/)).toBeInTheDocument();
expect(screen.queryByText('Settings saved')).not.toBeInTheDocument();
// The succeeded (ui) group's edit stuck even though the draft stayed on the Playout pane.
fireEvent.click(screen.getByRole('button', { name: /General/ }));
expect(await screen.findByDisplayValue('de-DE')).toBeInTheDocument();
});
it('surfaces an inline error (no unhandled rejection) when adding a duplicate resolution fails', async () => {
mockDashboardApi({ resolutionCreateFailure: { detail: 'Resolution already exists', status: 422 } });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Custom resolutions');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
expect(await screen.findByText('Resolution already exists')).toBeInTheDocument();
});
it('surfaces a resolution-delete failure inside the still-open confirm dialog', async () => {
mockDashboardApi({ resolutionDeleteFailure: { detail: 'Resolution is in use', status: 409 } });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('1920 × 1080');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
await screen.findByText('2560 × 1080');
const deleteButtons = screen.getAllByRole('button', { name: /Delete \d+×\d+/ });
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
expect(await within(dialog).findByText('Resolution is in use')).toBeInTheDocument();
// The dialog stays open with the resolution still present - a clean draft would
// otherwise show nothing at all, since saveError only renders inside the save bar.
expect(screen.getByText('2560 × 1080')).toBeInTheDocument();
});
it('disables Save and shows an inline error when a numeric field is cleared', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '' } });
await screen.findByText('1 unsaved change');
expect(screen.getByText('Must be a whole number ≥ 0')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save changes/ })).toBeDisabled();
});
it('treats a tunerCount of 0 as invalid (backend rejects 0)', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
await screen.findByText('HDHomeRun emulation, connected media sources and server info.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '0' } });
await screen.findByText('1 unsaved change');
expect(screen.getByText('Must be a whole number ≥ 1')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save changes/ })).toBeDisabled();
});
it('shows the raw wire value as an extra HLS Direct option when it is outside the known set', async () => {
mockDashboardApi({ ffmpegSettings: defaultFfmpegSettings({ hlsDirectOutputFormat: 'Hls' }) });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
expect(await screen.findByDisplayValue('Hls')).toBeInTheDocument();
});
it('sends a non-null globalWatermarkId through in the ffmpeg save PUT body', async () => {
mockDashboardApi({ watermarks: [{ id: 5, name: 'Bug' }] });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Global defaults');
const watermarkSelect = screen
.getAllByRole('combobox')
.find((select) => within(select).queryByText('Bug')) as HTMLSelectElement;
fireEvent.change(watermarkSelect, { target: { value: '5' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/settings/ffmpeg')).toMatchObject({ globalWatermarkId: 5 });
});
it('renders the most recent last-scan time per media source', async () => {
mockDashboardApi({
mediaSources: [
mediaSource({
id: 30,
kind: 'Local',
libraries: [
library({ id: 31, lastScan: '2026-07-01T00:00:00Z' }),
library({ id: 32, lastScan: '2026-07-05T14:30:00Z' })
],
name: 'Local'
}),
mediaSource({ id: 40, kind: 'Jellyfin', libraries: [], name: 'Jellyfin Server' })
]
});
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
expect(await screen.findByText('Local')).toBeInTheDocument();
expect(await screen.findByText('Jellyfin Server')).toBeInTheDocument();
expect(screen.getByText(/Last scan/)).toBeInTheDocument();
});
});
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function schedule(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
fixedStartTimeBehavior: 'Flexible',
id: 1,
keepMultiPartEpisodesTogether: true,
name: 'Default Schedule',
randomStartPoint: false,
shuffleScheduleItems: false,
treatCollectionsAsShows: false,
...overrides
};
}
function collection(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
collectionType: 'Collection',
id: 2,
name: 'Saturday Cartoons',
useCustomPlaybackOrder: false,
...overrides
};
}
function library(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 31,
itemCount: 0,
lastScan: null,
mediaKind: 'Movies',
name: 'Movies',
...overrides
};
}
function mediaSource(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
connectionAddress: null,
id: 30,
kind: 'Local',
libraries: [],
name: 'Local',
...overrides
};
}
// ---- Channel Builder (#89) fixtures ----------------------------------------
// The API serializes with Newtonsoft NullValueHandling.Ignore: null-valued
// members are omitted from the wire entirely. Fixtures mirror that by
// stripping null/undefined keys after overrides are applied.
function omitNullKeys(fixture: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(fixture).filter(([, value]) => value != null));
}
// ChannelResponseModel
function channelSummary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
categories: '',
fFmpegProfile: '1080p H.264',
group: 'ChicoryTV',
id: 1,
isEnabled: true,
language: 'English',
name: 'Movies',
number: '5',
showInEpg: true,
sortNumber: 5,
streamingMode: 'HttpLiveStreamingSegmenter',
...overrides
};
}
// ChannelTemplateResponseModel — nullable members (filler/watermark ids,
// preferred languages, streamSelector, musicVideoCreditsTemplate) are null on
// the Standard template and therefore absent from the serialized payload.
function channelTemplate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return omitNullKeys({
description: 'General-purpose 1080p H.264.',
fFmpegProfileId: 100,
fixedStartTimeBehavior: 'Strict',
id: 10,
idleBehavior: 'StopOnDisconnect',
isDefault: true,
isSystem: true,
musicVideoCreditsMode: 'None',
name: 'Standard',
playoutMode: 'Continuous',
playoutSource: 'Generated',
randomStartPoint: false,
shuffleScheduleItems: false,
songVideoMode: 'Default',
streamSelectorMode: 'Default',
streamingMode: 'HttpLiveStreamingSegmenter',
subtitleMode: 'None',
transcodeMode: 'OnDemand',
...overrides
});
}
// LibraryBrowseItemResponseModel - Newtonsoft omits null fields, so exactly one
// typed id is populated and nullable metadata (duration/itemCount) is absent
// unless the caller supplies it.
function browseItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return omitNullKeys({
artwork: '',
collectionType: 'TelevisionShow',
id: 1,
libraryId: 31,
libraryName: 'Cartoons',
mediaItemId: 1,
mediaType: 'TelevisionShow',
title: 'Looney Tunes',
...overrides
});
}
// FFmpegFullProfileResponseModel (only id/name are read by the builder)
function ffmpegProfile(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { id: 100, name: '1080p H.264', ...overrides };
}
// Read the JSON body captured for a mutating request to `path`.
function requestBodyFor(path: string): Record<string, unknown> {
const call = vi
.mocked(window.fetch)
.mock.calls.find(([input, init]) => input.toString() === path && init?.body != null);
if (!call) {
throw new Error(`no request captured for ${path}`);
}
return JSON.parse(call[1]?.body as string) as Record<string, unknown>;
}
// PlayoutListItemResponseModel: rail rows have no detail-only fields (playoutMode/scheduleFile).
function listPlayout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
buildStatus: {
lastBuild: '2026-07-05T19:00:00Z',
message: null,
success: true
},
channelName: 'Retro Cartoons',
channelNumber: '5.1',
dailyRebuildTime: '04:00:00',
id: 20,
scheduleKind: 'Classic',
scheduleName: 'Prime Time Cartoons',
...overrides
};
}
// PlayoutResponseModel: the detail shape adds playoutMode/scheduleFile on top of the list row.
function playout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return listPlayout({
playoutMode: 'Continuous',
scheduleFile: null,
...overrides
});
}
// ---- Settings (#93) fixtures ----
function defaultFfmpegSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
defaultFFmpegProfileId: 1,
defaultMpegTsScript: '',
extractEmbeddedSubtitles: false,
fFmpegPath: '/usr/bin/ffmpeg',
fFprobePath: '/usr/bin/ffprobe',
globalFallbackFillerId: null,
globalWatermarkId: null,
hlsDirectOutputFormat: 'MpegTs',
hlsSegmenterIdleTimeout: 60,
initialSegmentCount: 1,
preferredAudioLanguageCode: 'eng',
probeForInterlacedFrames: true,
saveReports: false,
useEmbeddedSubtitles: true,
workAheadSegmenterLimit: 1,
...overrides
};
}
function defaultPlayoutSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
daysToBuild: 2,
scriptedScheduleTimeoutSeconds: 30,
skipMissingItems: true,
...overrides
};
}
function defaultXmltvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
blockBehavior: 'SplitTimeEvenly',
daysToBuild: 2,
timeZone: 'Local',
...overrides
};
}
function defaultScannerSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
libraryRefreshInterval: 6,
...overrides
};
}
function defaultLoggingSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
defaultMinimumLogLevel: 'Information',
httpMinimumLogLevel: 'Warning',
scanningMinimumLogLevel: 'Information',
schedulingMinimumLogLevel: 'Information',
searchingMinimumLogLevel: 'Information',
streamingMinimumLogLevel: 'Information',
...overrides
};
}
function defaultUiSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
isDarkMode: true,
language: 'en-US',
...overrides
};
}
function defaultHdhrSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
tunerCount: 2,
uuid: '6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41',
...overrides
};
}
function defaultResolutions(): Array<Record<string, unknown>> {
return [
{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 },
{ height: 720, id: 2, isCustom: false, name: '1280x720', width: 1280 }
];
}
// The backend always emits fillerKind: real content items carry 'None' (never null).
function playoutItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
duration: '00:30:00',
fillerKind: 'None',
finish: '2026-07-05T20:30:00Z',
start: '2026-07-05T20:00:00Z',
title: 'Saturday Morning Cartoons',
...overrides
};
}
function scheduleItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) };
return {
collection,
collectionType: 'Collection',
customTitle: null,
discardToFillAttempts: null,
durationEstimate: '00:25:00',
fallbackFiller: null,
fillWithGroupMode: 'None',
fixedStartTimeBehavior: null,
graphicsElements: [],
guideMode: 'Normal',
id: 11,
index: 0,
marathonBatchSize: null,
marathonGroupBy: 'None',
marathonShuffleGroups: false,
marathonShuffleItems: false,
mediaItem: null,
midRollFiller: null,
multipleCount: null,
multipleMode: 'Count',
multiCollection: null,
name: collection.name,
playbackOrder: 'Shuffle',
playlist: null,
playoutDuration: null,
playoutMode: 'One',
postRollFiller: null,
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
preRollFiller: null,
rerunCollection: null,
searchQuery: null,
searchTitle: null,
smartCollection: null,
startTime: null,
startType: 'Dynamic',
subtitleMode: null,
tailFiller: null,
tailMode: 'None',
watermarkIds: [],
watermarks: [],
...overrides
};
}
function scheduleItemRequest(item: Record<string, unknown>): Record<string, unknown> {
const collection = item.collection as { id?: number } | null | undefined;
const multiCollection = item.multiCollection as { id?: number } | null | undefined;
const smartCollection = item.smartCollection as { id?: number } | null | undefined;
const rerunCollection = item.rerunCollection as { id?: number } | null | undefined;
const mediaItem = item.mediaItem as { id?: number } | null | undefined;
const playlist = item.playlist as { id?: number } | null | undefined;
const watermarks = item.watermarks as Array<{ id: number }> | null | undefined;
const graphicsElements = item.graphicsElements as Array<{ id: number }> | null | undefined;
const fillerId = (value: unknown) => (value as { id?: number } | null | undefined)?.id ?? null;
return {
collectionId: collection?.id ?? null,
collectionType: item.collectionType ?? 'Collection',
customTitle: item.customTitle ?? null,
discardToFillAttempts: item.discardToFillAttempts ?? null,
fallbackFillerId: fillerId(item.fallbackFiller),
fillWithGroupMode: item.fillWithGroupMode ?? 'None',
fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null,
graphicsElementIds: graphicsElements?.map((element) => element.id) ?? [],
guideMode: item.guideMode ?? 'Normal',
marathonBatchSize: item.marathonBatchSize ?? null,
marathonGroupBy: item.marathonGroupBy ?? 'None',
marathonShuffleGroups: item.marathonShuffleGroups ?? false,
marathonShuffleItems: item.marathonShuffleItems ?? false,
mediaItemId: mediaItem?.id ?? null,
midRollFillerId: fillerId(item.midRollFiller),
multipleCount: item.multipleCount ?? null,
multipleMode: item.multipleMode ?? 'Count',
multiCollectionId: multiCollection?.id ?? null,
playbackOrder: item.playbackOrder ?? 'Shuffle',
playlistId: playlist?.id ?? null,
playoutDuration: item.playoutDuration ?? null,
playoutMode: item.playoutMode ?? 'One',
postRollFillerId: fillerId(item.postRollFiller),
preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null,
preferredAudioTitle: item.preferredAudioTitle ?? null,
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null,
preRollFillerId: fillerId(item.preRollFiller),
rerunCollectionId: rerunCollection?.id ?? null,
searchQuery: item.searchQuery ?? null,
searchTitle: item.searchTitle ?? null,
smartCollectionId: smartCollection?.id ?? null,
startTime: item.startTime ?? null,
startType: item.startType ?? 'Dynamic',
subtitleMode: item.subtitleMode ?? null,
tailFillerId: fillerId(item.tailFiller),
tailMode: item.tailMode ?? 'None',
watermarkIds: watermarks?.map((watermark) => watermark.id) ?? [],
};
}
function mockDashboardApi({
addScheduleItemResponse = null,
artworkUploadFailure = null,
browseHandler = null,
browseItems = [],
channels = [],
channelStates = [],
channelTemplates = [],
createTemplateResponse = null,
defaultChannelTemplate = null,
playoutAlternateSchedules = [],
playoutTemplateItems = [],
templates = [],
decoTemplates = [],
ffmpegProfiles = [],
fromLineupFailure = null,
fromLineupResponse = { channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 },
watermarks = [],
collections = [],
confirm = false,
fillerPresets = [],
guide = null,
guideFailuresBeforeSuccess = 0,
health = [],
libraryScanStatusFailAfterTrigger = false,
libraryScanStatuses = [],
libraryScanStatusesAfterMutation = null,
libraryScanStatusSequence = null,
mediaSources = [],
mediaSourcesFailure = null,
mediaSourcesFailuresBeforeSuccess = 0,
multiCollections = [],
mutationFailures = {},
createPlayoutResponse = null,
updatePlayoutDetailsResponse = null,
playoutDetails = null,
playoutItems = [],
playoutItemsFailure = null,
playoutItemsFailuresBeforeSuccess = 0,
playoutItemsTotalCount = null,
playoutWarningsCount = 0,
playouts = { page: [], totalCount: 0 },
prompt = null,
replaceScheduleItemsResponse = null,
scheduleItemFailure = null,
scheduleItemFailuresBeforeSuccess = 0,
scheduleItems = [],
scheduleItemsAfterAdd = null,
scheduleItemsAfterDelete = null,
scheduleItemsTotalDuration = null,
schedules = [],
smartCollections = [],
version = { apiVersion: 3, appVersion: '26.4.0' },
ffmpegSettings = defaultFfmpegSettings(),
playoutSettings = defaultPlayoutSettings(),
xmltvSettings = defaultXmltvSettings(),
scannerSettings = defaultScannerSettings(),
loggingSettings = defaultLoggingSettings(),
uiSettings = defaultUiSettings(),
hdhrSettings = defaultHdhrSettings(),
resolutions = defaultResolutions(),
resolutionCreateFailure = null,
resolutionDeleteFailure = null,
settingsMutationFailures = {},
settingsGetFailuresBeforeSuccess = {}
}: {
addScheduleItemResponse?: unknown;
artworkUploadFailure?: { status: number } | null;
browseHandler?: ((search: URLSearchParams) => { page: unknown[]; totalCount: number }) | null;
browseItems?: unknown[];
channels?: unknown[];
channelStates?: unknown[];
channelTemplates?: unknown[];
createTemplateResponse?: unknown;
defaultChannelTemplate?: unknown;
playoutAlternateSchedules?: unknown[];
playoutTemplateItems?: unknown[];
templates?: unknown[];
decoTemplates?: unknown[];
ffmpegProfiles?: unknown[];
fromLineupFailure?: { detail?: string; status?: number; title?: string } | null;
fromLineupResponse?: unknown;
watermarks?: unknown[];
collections?: unknown[];
confirm?: boolean;
fillerPresets?: unknown[];
guide?: unknown;
guideFailuresBeforeSuccess?: number;
health?: unknown[];
libraryScanStatusFailAfterTrigger?: boolean;
libraryScanStatuses?: unknown[];
libraryScanStatusesAfterMutation?: unknown[] | null;
libraryScanStatusSequence?: unknown[][] | null;
mediaSources?: unknown[];
mediaSourcesFailure?: unknown;
mediaSourcesFailuresBeforeSuccess?: number;
multiCollections?: unknown[];
mutationFailures?: Record<string, unknown>;
createPlayoutResponse?: unknown;
updatePlayoutDetailsResponse?: unknown;
playoutDetails?: unknown;
playoutItems?: unknown[];
playoutItemsFailure?: unknown;
playoutItemsFailuresBeforeSuccess?: number;
playoutItemsTotalCount?: number | null;
playoutWarningsCount?: number;
playouts?: unknown;
prompt?: string | null;
replaceScheduleItemsResponse?: unknown;
scheduleItemFailure?: unknown;
scheduleItemFailuresBeforeSuccess?: number;
scheduleItems?: unknown[];
scheduleItemsAfterAdd?: unknown[] | null;
scheduleItemsAfterDelete?: unknown[] | null;
scheduleItemsTotalDuration?: string | null;
schedules?: unknown[];
smartCollections?: unknown[];
version?: unknown;
ffmpegSettings?: Record<string, unknown>;
playoutSettings?: Record<string, unknown>;
xmltvSettings?: Record<string, unknown>;
scannerSettings?: Record<string, unknown>;
loggingSettings?: Record<string, unknown>;
uiSettings?: Record<string, unknown>;
hdhrSettings?: Record<string, unknown>;
resolutions?: Array<Record<string, unknown>>;
resolutionCreateFailure?: { detail?: string; status?: number; title?: string } | null;
resolutionDeleteFailure?: { detail?: string; status?: number; title?: string } | null;
settingsMutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
// path -> number of GET failures (500) before the endpoint recovers; use a large
// number for a persistently-failing endpoint.
settingsGetFailuresBeforeSuccess?: Record<string, number>;
} = {}) {
vi.spyOn(window, 'confirm').mockReturnValue(confirm);
vi.spyOn(window, 'prompt').mockReturnValue(prompt);
let currentLibraryScanStatuses = libraryScanStatuses;
let currentChannelTemplates = channelTemplates;
let currentScheduleItems = scheduleItems;
const remainingSettingsGetFailures: Record<string, number> = { ...settingsGetFailuresBeforeSuccess };
let currentFfmpegSettings = ffmpegSettings;
let currentPlayoutSettings = playoutSettings;
let currentXmltvSettings = xmltvSettings;
let currentScannerSettings = scannerSettings;
let currentLoggingSettings = loggingSettings;
let currentUiSettings = uiSettings;
let currentHdhrSettings = hdhrSettings;
let currentResolutions = resolutions;
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
let remainingGuideFailures = guideFailuresBeforeSuccess;
let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess;
let remainingPlayoutItemsFailures = playoutItemsFailuresBeforeSuccess;
let scanStatusSequenceIndex = 0;
let scanStatusShouldFail = false;
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const path = input.toString();
if (path === '/api/channels') {
return Promise.resolve(jsonResponse(channels));
}
if (path === '/api/channels/state') {
return Promise.resolve(jsonResponse(channelStates));
}
// ---- Channel Builder (#89) endpoints (before the generic /api/channels/) ----
if (path === '/api/channels/from-lineup') {
if (fromLineupFailure) {
return Promise.resolve(jsonResponse(fromLineupFailure, fromLineupFailure.status ?? 422));
}
return Promise.resolve(jsonResponse(fromLineupResponse, 201));
}
if (path.startsWith('/api/library/browse')) {
const search = new URL(path, window.location.origin).searchParams;
if (browseHandler) {
return Promise.resolve(jsonResponse(browseHandler(search)));
}
// Mirror the real API: an unfiltered request returns everything, a
// mediaType-scoped request (the builder now fans out per-kind) only
// returns items of that kind.
const mediaType = search.get('mediaType');
const page = mediaType ? browseItems.filter((item) => (item as { mediaType?: string }).mediaType === mediaType) : browseItems;
return Promise.resolve(jsonResponse({ page, totalCount: page.length }));
}
if (path === '/api/channel-templates') {
if ((init?.method ?? 'GET') === 'POST') {
const created = createTemplateResponse ?? channelTemplate();
currentChannelTemplates = [...currentChannelTemplates, created];
return Promise.resolve(jsonResponse(created, 201));
}
return Promise.resolve(jsonResponse(currentChannelTemplates));
}
if (path === '/api/channel-templates/default') {
return Promise.resolve(
defaultChannelTemplate ? jsonResponse(defaultChannelTemplate) : jsonResponse(null, 404)
);
}
if (path === '/api/ffmpeg/profiles') {
return Promise.resolve(jsonResponse(ffmpegProfiles));
}
if (path === '/api/artwork/uploads') {
if (artworkUploadFailure) {
return Promise.resolve(new Response(null, { status: artworkUploadFailure.status }));
}
return Promise.resolve(jsonResponse({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' }, 201));
}
if (path.startsWith('/api/guide?')) {
if (remainingGuideFailures > 0) {
remainingGuideFailures -= 1;
return Promise.resolve(new Response(null, { status: 500 }));
}
return Promise.resolve(jsonResponse(guide ?? guideFixture()));
}
if (path === '/api/schedules') {
return Promise.resolve(jsonResponse(schedules));
}
if (path.match(/^\/api\/schedules\/\d+\/items$/)) {
const method = init?.method ?? 'GET';
if (method !== 'GET' && path in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
}
if (method === 'POST') {
currentScheduleItems = scheduleItemsAfterAdd ?? (addScheduleItemResponse ? [...currentScheduleItems, addScheduleItemResponse] : currentScheduleItems);
return Promise.resolve(jsonResponse(addScheduleItemResponse ?? null, 201));
}
if (method === 'PUT') {
currentScheduleItems = (replaceScheduleItemsResponse as unknown[] | null) ?? currentScheduleItems;
return Promise.resolve(jsonResponse(currentScheduleItems));
}
if (remainingScheduleItemFailures > 0) {
remainingScheduleItemFailures -= 1;
return Promise.resolve(jsonResponse(scheduleItemFailure, 404));
}
return Promise.resolve(jsonResponse({
items: currentScheduleItems,
totalDurationEstimate: scheduleItemsTotalDuration
}));
}
if (path.match(/^\/api\/schedules\/\d+\/items\/\d+$/)) {
if (path in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
}
currentScheduleItems = scheduleItemsAfterDelete ?? currentScheduleItems;
return Promise.resolve(new Response(null, { status: 204 }));
}
if (path === '/api/collections') {
return Promise.resolve(jsonResponse(collections));
}
if (path === '/api/smart-collections') {
return Promise.resolve(jsonResponse(smartCollections));
}
if (path === '/api/multi-collections') {
return Promise.resolve(jsonResponse(multiCollections));
}
if (path === '/api/filler-presets') {
return Promise.resolve(jsonResponse(fillerPresets));
}
if (path === '/api/watermarks') {
return Promise.resolve(jsonResponse(watermarks));
}
if (path === '/api/media-sources') {
if (remainingMediaSourcesFailures > 0) {
remainingMediaSourcesFailures -= 1;
return Promise.resolve(
mediaSourcesFailure ? jsonResponse(mediaSourcesFailure, 500) : new Response(null, { status: 500 })
);
}
return Promise.resolve(jsonResponse(mediaSources));
}
if (path === '/api/libraries/scan-status') {
if (libraryScanStatusFailAfterTrigger && scanStatusShouldFail) {
return Promise.resolve(new Response(null, { status: 500 }));
}
if (libraryScanStatusSequence) {
const sequenceValue = libraryScanStatusSequence[Math.min(scanStatusSequenceIndex, libraryScanStatusSequence.length - 1)];
scanStatusSequenceIndex += 1;
currentLibraryScanStatuses = sequenceValue;
return Promise.resolve(jsonResponse(sequenceValue));
}
return Promise.resolve(jsonResponse(currentLibraryScanStatuses));
}
if (path.match(/^\/api\/libraries\/\d+\/scan$/)) {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { detail?: string; status?: number; title?: string };
const status = failure.status ?? 422;
if (failure.detail || failure.title) {
return Promise.resolve(jsonResponse(failure, status));
}
return Promise.resolve(new Response(null, { status }));
}
currentLibraryScanStatuses = libraryScanStatusesAfterMutation ?? currentLibraryScanStatuses;
if (libraryScanStatusFailAfterTrigger) {
scanStatusShouldFail = true;
}
return Promise.resolve(new Response(null, { status: 200 }));
}
if (path === '/api/playouts') {
const method = init?.method ?? 'GET';
if (method === 'POST') {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(jsonResponse(createPlayoutResponse ?? playout({ id: 99 }), 201));
}
return Promise.resolve(jsonResponse(playouts));
}
if (path === '/api/playouts/warnings/count') {
return Promise.resolve(jsonResponse(playoutWarningsCount));
}
if (path === '/api/playouts/reset-all') {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
// Backend contract: POST /api/playouts/reset-all is an unconditional 202 Accepted.
return Promise.resolve(new Response(null, { status: 202 }));
}
if (path.match(/^\/api\/playouts\/\d+\/alternate-schedules$/)) {
return Promise.resolve(jsonResponse(playoutAlternateSchedules));
}
if (path.match(/^\/api\/playouts\/\d+\/templates$/)) {
return Promise.resolve(jsonResponse(playoutTemplateItems));
}
if (path === '/api/templates') {
return Promise.resolve(jsonResponse(templates));
}
if (path === '/api/deco-templates') {
return Promise.resolve(jsonResponse(decoTemplates));
}
const itemsMatch = path.match(/^\/api\/playouts\/\d+\/items(\?(?<query>.*))?$/);
if (itemsMatch) {
if (remainingPlayoutItemsFailures > 0) {
remainingPlayoutItemsFailures -= 1;
return Promise.resolve(jsonResponse(playoutItemsFailure, 404));
}
// Mirror the backend: filler items are only included when ?showFiller=true is sent.
const showFiller = new URLSearchParams(itemsMatch.groups?.query ?? '').get('showFiller') === 'true';
const page = showFiller
? playoutItems
: playoutItems.filter((item) => {
const fillerKind = (item as { fillerKind?: null | string }).fillerKind;
return fillerKind == null || fillerKind === 'None';
});
return Promise.resolve(jsonResponse({
page,
totalCount: playoutItemsTotalCount ?? page.length
}));
}
if (path.match(/^\/api\/playouts\/\d+$/)) {
const method = init?.method ?? 'GET';
if (method === 'PUT') {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(jsonResponse(
updatePlayoutDetailsResponse ?? playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })
));
}
return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
}
if (path === '/api/health') {
return Promise.resolve(jsonResponse(health));
}
if (path === '/api/version') {
return Promise.resolve(jsonResponse(version));
}
// ---- Settings (#93) endpoints ----
if (
path.startsWith('/api/settings/') &&
(init?.method ?? 'GET') === 'GET' &&
(remainingSettingsGetFailures[path] ?? 0) > 0
) {
remainingSettingsGetFailures[path] -= 1;
return Promise.resolve(new Response(null, { status: 500 }));
}
if (path === '/api/settings/ffmpeg') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentFfmpegSettings = { ...currentFfmpegSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentFfmpegSettings));
}
return Promise.resolve(jsonResponse(currentFfmpegSettings));
}
if (path === '/api/settings/playout') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentPlayoutSettings = { ...currentPlayoutSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentPlayoutSettings));
}
return Promise.resolve(jsonResponse(currentPlayoutSettings));
}
if (path === '/api/settings/xmltv') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentXmltvSettings = { ...currentXmltvSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentXmltvSettings));
}
return Promise.resolve(jsonResponse(currentXmltvSettings));
}
if (path === '/api/settings/scanner') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentScannerSettings = { ...currentScannerSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentScannerSettings));
}
return Promise.resolve(jsonResponse(currentScannerSettings));
}
if (path === '/api/settings/logging') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentLoggingSettings = { ...currentLoggingSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentLoggingSettings));
}
return Promise.resolve(jsonResponse(currentLoggingSettings));
}
if (path === '/api/settings/ui') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentUiSettings = { ...currentUiSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentUiSettings));
}
return Promise.resolve(jsonResponse(currentUiSettings));
}
if (path === '/api/settings/hdhr') {
if ((init?.method ?? 'GET') === 'PUT') {
if (path in settingsMutationFailures) {
const failure = settingsMutationFailures[path];
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentHdhrSettings = { ...currentHdhrSettings, ...JSON.parse(init?.body as string) };
return Promise.resolve(jsonResponse(currentHdhrSettings));
}
return Promise.resolve(jsonResponse(currentHdhrSettings));
}
if (path === '/api/settings/resolutions') {
if ((init?.method ?? 'GET') === 'POST') {
if (resolutionCreateFailure) {
return Promise.resolve(jsonResponse(resolutionCreateFailure, resolutionCreateFailure.status ?? 422));
}
const body = JSON.parse(init?.body as string) as { height: number; width: number };
const created = {
height: body.height,
id: Math.max(0, ...currentResolutions.map((resolution) => Number(resolution.id))) + 1,
isCustom: true,
name: `${body.width}x${body.height}`,
width: body.width
};
currentResolutions = [...currentResolutions, created];
return Promise.resolve(jsonResponse(created, 201));
}
return Promise.resolve(jsonResponse(currentResolutions));
}
if (path.match(/^\/api\/settings\/resolutions\/\d+$/)) {
if (resolutionDeleteFailure) {
return Promise.resolve(jsonResponse(resolutionDeleteFailure, resolutionDeleteFailure.status ?? 422));
}
const id = Number(path.split('/').at(-1));
currentResolutions = currentResolutions.filter((resolution) => Number(resolution.id) !== id);
return Promise.resolve(new Response(null, { status: 204 }));
}
if (path in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
}
if (
path === '/api/channels/bulk/renumber' ||
path === '/api/channels/bulk/group' ||
path === '/api/channels/bulk/delete' ||
path.startsWith('/api/channels/')
) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(jsonResponse(null, 404));
});
}
// Deterministically fires one captured poll tick for the mocked-setInterval polling tests.
//
// The Libraries poll interval is registered in a passive useEffect that only runs after
// an out-of-act state commit (the async fetch that flips hasActiveScans), so `findBy*`
// queries - which resolve on the DOM mutation, via MutationObserver - can win the race
// against React's effect flush and observe an empty handler list (the #157 flake).
// The leading empty act() flushes those pending effects, the length assertion turns a
// still-missing registration into a clear failure instead of a silent no-op forEach,
// and the act() around the handlers applies the fetch microtasks + state updates before
// the caller asserts, so no assertion depends on wall-clock scheduling.
async function runPollTick(intervalHandlers: Array<() => void>): Promise<void> {
await act(async () => {});
expect(intervalHandlers.length).toBeGreaterThan(0);
await act(async () => {
intervalHandlers.forEach((handler) => handler());
});
}
function fetchCount(path: string): number {
return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length;
}
function fetchCallsStartingWith(prefix: string): string[] {
return vi.mocked(window.fetch).mock.calls
.map(([input]) => input.toString())
.filter((path) => path.startsWith(prefix));
}
function guideFixture(overrides: Record<string, unknown> = {}) {
return {
channels: [
{
name: 'Retro Cartoons',
number: '5.1',
programmes: [
{
category: 'Kids',
fillerKind: 'None',
start: '2026-07-05T19:00:00Z',
stop: '2026-07-05T20:00:00Z',
subTitle: null,
title: 'Early Show'
},
{
category: 'Kids',
fillerKind: 'None',
start: '2026-07-05T20:00:00Z',
stop: '2026-07-05T21:00:00Z',
subTitle: 'Pilot',
title: 'Saturday Morning Cartoons'
},
{
category: null,
fillerKind: 'None',
start: '2026-07-05T22:00:00Z',
stop: '2026-07-05T23:00:00Z',
subTitle: null,
title: 'Saturday Morning Cartoons'
},
{
category: null,
fillerKind: 'None',
start: '2026-07-06T08:00:00Z',
stop: '2026-07-06T09:00:00Z',
subTitle: null,
title: 'Window Edge Special'
}
]
},
{
name: 'News 24',
number: '24',
programmes: []
}
],
end: '2026-07-06T08:30:00Z',
start: '2026-07-05T19:30:00Z',
...overrides
};
}