Files
ersatztv/web/src/App.test.tsx
T
timothyandClaude Opus 4.8 ff72eb4d5f feat(spa): build LocalLibraryEditScreen for local media libraries (#202 slice S6a)
Adds the Local library create/edit editor (create at /app/libraries/local/new,
edit at /app/libraries/local/{id}) wired into the S5-built LibrariesRouteScreen
dispatch switch, replacing MediaSourceEditorPlaceholder for the local-new and
local-edit sub-routes only. Remote (Plex/Jellyfin/Emby) branches are untouched
(S6b).

- Name (required) + Media Kind (create-only, disabled+annotated on edit)
- Add Path: path-exists pre-check (L7) + in-draft duplicate detection
  (mediaSources/paths.ts normalizePath)
- Delete path: draft-local removal with a media-item-count confirm dialog
- Move path: dialog filtered to same-MediaKind libraries excluding the source,
  including "(New Library)" which composes createLocalLibrary + moveLocalLibraryPath
  (surfaces the error and leaves the new empty library on a failed move, matching
  Blazor); gated on !dirty to avoid clobbering unsaved edits with the post-move
  refetch
- Draft/saved model with explicit Save (POST L3 / PUT L4), draft retained on
  422/network error, dirty-guard (registerNavigationGuard + beforeunload)
- Delete library (L5) with a media-item-count confirm; 409 refetches detail

Extended the existing App.test.tsx App-owned-popstate regression test (design
§D.2) to exercise the real screen's dirty guard instead of a manually-armed
stand-in, now that S6a has landed the editor it was stubbing out for.

Verification (web/): vitest (632 passed), eslint clean, tsc -b + vite build
clean, check:api reports no drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:24:55 +02:00

3481 lines
134 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 { 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 yet')).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.findAllByRole('button', { name: 'Add schedule' })).length).toBeGreaterThan(0);
expect(window.location.pathname).toBe('/app/schedules');
});
it('registers the multi-collection and rerun-collection nav entries', async () => {
render(<App />);
// Anchored exact names avoid matching the substring "Collections" nav entry (spa-conventions §6).
fireEvent.click(screen.getByRole('link', { name: 'Multi-Collections' }));
expect(screen.getByRole('heading', { name: 'Multi-Collections' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/multi-collections');
fireEvent.click(screen.getByRole('link', { name: 'Rerun Collections' }));
expect(screen.getByRole('heading', { name: 'Rerun Collections' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/rerun-collections');
});
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 extracted Channels screen from live channel APIs (#244)', async () => {
// Thin route/composition smoke test: the full Channels behavioral suite lives colocated in
// web/src/screens/ChannelsScreen.test.tsx. This only asserts the nav wires the extracted screen
// into the shell and it mounts + hits its own endpoint.
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'
}
],
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, 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(window.location.pathname).toBe('/app/channels');
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
});
it('renders the extracted Schedules editor from live schedule APIs (#207)', async () => {
mockDashboardApi({
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })],
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(await screen.findByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument();
expect(screen.getAllByText('Saturday Cartoons').length).toBeGreaterThan(0);
expect(window.fetch).toHaveBeenCalledWith('/api/schedules', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.any(Object));
// the flat editor fetches its own pickers (issue #207)
expect(window.fetch).toHaveBeenCalledWith('/api/languages', expect.any(Object));
});
it('honors the unsaved-changes guard on browser Back/popstate (#230 finding 1)', async () => {
mockDashboardApi({
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })],
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();
await screen.findByRole('list', { name: 'Schedule lineup' });
// Dirty the item draft so the registered navigation guard is armed.
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
expect(await screen.findByText(/unsaved changes/)).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/schedules');
// Simulate browser Back: popstate fires AFTER the URL has already moved. The guard cannot cancel
// it, so on a vetoed confirm the App handler must re-push the pre-pop path and keep the route.
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
window.history.pushState(null, '', '/app/guide');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(confirmSpy).toHaveBeenCalled();
expect(screen.getByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/schedules');
// Accept the discard on the next Back → route changes and the schedules screen (with its draft)
// unmounts, matching the sidebar-nav case.
confirmSpy.mockReturnValue(true);
window.history.pushState(null, '', '/app/guide');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(await screen.findByRole('heading', { name: 'Guide' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/guide');
expect(screen.queryByRole('heading', { name: 'Prime Time Cartoons' })).not.toBeInTheDocument();
confirmSpy.mockRestore();
});
// Design §D.2 (finding 4): the libraries route is the first that BOTH tracks its own sub-path AND
// registers a dirty guard. App owns pathname/popstate for it (the wrapper never self-listens), so a
// dirty-editor Back is vetoed by App BEFORE the wrapper can switch sub-screen. S6a landed the real
// LocalLibraryEditScreen, so this exercises its own dirty guard (typing into Name) rather than a
// manually-armed stand-in guard.
it('App-owned popstate: a dirty libraries editor vetoes Back, then navigates when confirmed', async () => {
mockDashboardApi({
mediaSources: [mediaSource({ id: 30, kind: 'Local', libraries: [], name: 'Local' })],
localLibraryDetail: {
id: 3,
name: 'Feature Films',
mediaKind: 'Movies',
isLocked: false,
mediaItemCount: 0,
paths: []
}
});
window.history.replaceState(null, '', '/app/libraries/local/3');
render(<App />);
// The S6a editor is mounted for the local-edit sub-path.
const nameInput = await screen.findByDisplayValue('Feature Films');
// Make the editor dirty for real (not a manually-armed stand-in guard).
fireEvent.change(nameInput, { target: { value: 'Feature Films (renamed)' } });
expect(await screen.findByText('Unsaved changes')).toBeInTheDocument();
// Browser Back to the hub, vetoed: URL is restored and the editor stays put (App decided BEFORE
// the wrapper could react — the wrapper only ever sees an approved sub-path).
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
window.history.pushState(null, '', '/app/libraries');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(confirmSpy).toHaveBeenCalled();
expect(window.location.pathname).toBe('/app/libraries/local/3');
expect(screen.getByDisplayValue('Feature Films (renamed)')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Media Libraries' })).not.toBeInTheDocument();
// Back again, this time confirmed: the wrapper switches to the hub and the editor unmounts.
confirmSpy.mockReturnValue(true);
window.history.pushState(null, '', '/app/libraries');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/libraries');
expect(screen.queryByDisplayValue('Feature Films (renamed)')).not.toBeInTheDocument();
confirmSpy.mockRestore();
});
it('opens the create-schedule dialog from the TopBar Add Schedule button', 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();
// TopBar primary action for the schedules route reads "Add Schedule".
fireEvent.click(screen.getByRole('button', { name: 'Add Schedule' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByLabelText('Name')).toBeInTheDocument();
});
it('shows the empty Schedules state when no schedule exists', async () => {
mockDashboardApi({ schedules: [] });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect((await screen.findAllByRole('button', { name: 'Add schedule' })).length).toBeGreaterThan(0);
});
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 extracted Libraries screen from live media source and scan status APIs (#202)', async () => {
// Thin route/composition smoke test: the full Libraries behavioral suite lives colocated in
// web/src/screens/LibrariesScreen.test.tsx. This only asserts the nav wires the extracted screen
// into the shell and it mounts + hits its own endpoints.
mockDashboardApi({
libraryScanStatuses: [{ libraryId: 31, percent: 0.5 }],
mediaSources: [
mediaSource({
libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })]
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
expect(screen.getAllByText('Movies').length).toBeGreaterThan(0);
expect(window.fetch).toHaveBeenCalledWith('/api/media-sources', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/scan-status', expect.any(Object));
});
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('deletes the selected playout with confirmation and refetches', async () => {
mockDashboardApi({
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
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 playoutFetchesBeforeDelete = fetchCount('/api/playouts');
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.objectContaining({ method: 'DELETE' }));
});
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeDelete + 1);
});
it('resets the selected channel playout with confirmation', async () => {
mockDashboardApi({
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20, channelNumber: '5.1' })], 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' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith(
'/api/channels/5.1/playout/reset',
expect.objectContaining({ method: 'POST' })
);
});
});
it('shows both erase buttons for a Block playout and posts to the right routes', async () => {
mockDashboardApi({
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Block' })], 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: 'Erase items' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith(
'/api/playouts/20/erase-items',
expect.objectContaining({ method: 'POST' })
);
});
fireEvent.click(screen.getByRole('button', { name: 'Erase items and history' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith(
'/api/playouts/20/erase-items-and-history',
expect.objectContaining({ method: 'POST' })
);
});
});
it('shows only erase-items-and-history for a Classic playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
});
it('shows no erase buttons for an ExternalJson playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'ExternalJson' }),
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'ExternalJson' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Erase items and history' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
});
it('disables mutation buttons and shows a Building cue for a locked (building) playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
playouts: { page: [listPlayout({ id: 20, isLocked: true, scheduleKind: 'Block' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByText('Building…')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Erase items' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
});
it('disables Alternate schedules for an on-demand Classic playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeDisabled();
});
it('enables Alternate schedules for a continuous Classic playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeEnabled();
});
it('opens the scheduling-context dialog for an item that has one', async () => {
mockDashboardApi({
playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
playoutDetails: playout({ id: 20 }),
playoutSchedulingContext: 'SCHEDULING_CONTEXT_BODY',
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: 'Scheduling context' }));
expect(await screen.findByText('SCHEDULING_CONTEXT_BODY')).toBeInTheDocument();
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith(
'/api/playouts/items/99/scheduling-context',
expect.any(Object)
);
});
});
it('shows an error in the scheduling-context dialog when the fetch fails', async () => {
mockDashboardApi({
playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
playoutDetails: playout({ id: 20 }),
playoutSchedulingContextFailure: { detail: 'No scheduling context available', status: 404 },
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: 'Scheduling context' }));
expect(await screen.findByText('No scheduling context available')).toBeInTheDocument();
});
it('does not show a scheduling-context button for items without one', async () => {
mockDashboardApi({
playoutItems: [playoutItem({ hasSchedulingContext: false, id: 99 })],
playoutDetails: playout({ id: 20 }),
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();
expect(screen.queryByRole('button', { name: 'Scheduling context' })).not.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();
// Wait for the initial library fan-out (Movie/TelevisionShow/Artist) to be
// ISSUED before clearing the mock — renderBuilder only awaits the search
// box, and React flushes passive effects asynchronously, so on a slow
// machine the mount fan-out can otherwise leak past mockClear and pollute
// the post-click assertion below.
await waitFor(() => {
const browseCalls = vi
.mocked(window.fetch)
.mock.calls.filter(([input]) => input.toString().startsWith('/api/library/browse'));
expect(browseCalls.length).toBeGreaterThanOrEqual(3);
});
// 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 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,
isLocked: false,
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> {
// Flat ScheduleItemResponseModel shape (issue #126/#207).
return {
id: 11,
index: 0,
startType: 'Dynamic',
startTime: null,
fixedStartTimeBehavior: null,
playoutMode: 'One',
collectionType: 'Collection',
collectionId: 2,
multiCollectionId: null,
smartCollectionId: null,
rerunCollectionId: null,
mediaItemId: null,
playlistId: null,
searchTitle: null,
searchQuery: null,
playbackOrder: 'Shuffle',
marathonGroupBy: 'None',
marathonShuffleGroups: false,
marathonShuffleItems: false,
marathonBatchSize: null,
fillWithGroupMode: 'None',
multipleMode: null,
multipleCount: null,
playoutDuration: null,
tailMode: null,
discardToFillAttempts: null,
customTitle: null,
guideMode: 'Normal',
preRollFillerId: null,
midRollFillerId: null,
postRollFillerId: null,
tailFillerId: null,
fallbackFillerId: null,
watermarkIds: [],
graphicsElementIds: [],
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
subtitleMode: null,
collectionName: 'Saturday Cartoons',
multiCollectionName: null,
smartCollectionName: null,
rerunCollectionName: null,
playlistName: null,
playlistGroupId: null,
mediaItemName: null,
preRollFillerName: null,
midRollFillerName: null,
postRollFillerName: null,
tailFillerName: null,
fallbackFillerName: null,
watermarks: [],
graphicsElements: [],
name: 'Saturday Cartoons',
durationEstimate: '00:25:00',
...overrides
};
}
function mockDashboardApi({
addScheduleItemResponse = null,
artworkUploadFailure = null,
browseHandler = null,
browseItems = [],
channels = [],
channelStates = [],
channelTemplates = [],
createChannelFailure = null,
createChannelResponse = null,
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 = [],
localLibraryDetail = null,
libraryScanStatusesAfterMutation = null,
libraryScanStatusSequence = null,
mediaSources = [],
mediaSourcesFailure = null,
mediaSourcesFailuresBeforeSuccess = 0,
multiCollections = [],
mutationFailures = {},
createPlayoutResponse = null,
updatePlayoutDetailsResponse = null,
playoutDetails = null,
playoutItems = [],
playoutItemsFailure = null,
playoutItemsFailuresBeforeSuccess = 0,
playoutItemsTotalCount = null,
playoutSchedulingContext = '{\n "sample": true\n}',
playoutSchedulingContextFailure = 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[];
createChannelFailure?: { detail?: string; status?: number; title?: string } | null;
createChannelResponse?: 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[];
localLibraryDetail?: 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;
playoutSchedulingContext?: string;
playoutSchedulingContextFailure?: { detail?: string; status?: number; title?: string } | 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' && (init?.method ?? 'GET') === 'POST') {
if (createChannelFailure) {
return Promise.resolve(jsonResponse(createChannelFailure, createChannelFailure.status ?? 422));
}
return Promise.resolve(jsonResponse(createChannelResponse ?? { id: 99 }, 201));
}
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.startsWith('/api/filler-presets')) {
return Promise.resolve(jsonResponse(fillerPresets));
}
if (path === '/api/watermarks') {
return Promise.resolve(jsonResponse(watermarks));
}
// Schedule-editor pickers (issue #207): empty defaults are enough for the App-level route tests.
if (path.startsWith('/api/rerun-collections')) {
return Promise.resolve(jsonResponse({ totalCount: 0, page: [] }));
}
if (path === '/api/playlists/groups') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/graphics-elements') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/languages') {
return Promise.resolve(jsonResponse([]));
}
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.startsWith('/api/libraries/local/')) {
if (localLibraryDetail) {
return Promise.resolve(jsonResponse(localLibraryDetail));
}
return Promise.resolve(new Response(null, { status: 404 }));
}
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;
}
// Backend contract (#232): a successful queue is 202 Accepted, not a lying 200.
return Promise.resolve(new Response(null, { status: 202 }));
}
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.match(/^\/api\/playouts\/\d+\/erase-items(-and-history)?$/)) {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(new Response(null, { status: 204 }));
}
if (path.match(/^\/api\/playouts\/items\/\d+\/scheduling-context$/)) {
if (playoutSchedulingContextFailure) {
return Promise.resolve(
jsonResponse(playoutSchedulingContextFailure, playoutSchedulingContextFailure.status ?? 404)
);
}
return Promise.resolve(jsonResponse({ context: playoutSchedulingContext }));
}
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)) })
));
}
if (method === 'DELETE') {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(new Response(null, { status: 204 }));
}
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));
});
}
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
};
}