Files
ersatztv/web/src/App.test.tsx
T
timothy 7b21cd98ff feat(60): activate channel preview on the channels list
Wires the ChannelPreviewPanel (Task 4) into ChannelsScreen: a single
panel instance is rendered per screen and its `channel` prop is swapped
via previewChannelId state rather than remounting per row. The Play
button now reads the server-derived channel.preview.availability
(Task 2) instead of being permanently disabled -- Unavailable stays
disabled with the server's unavailableReason surfaced in the title;
Available and ForcedHlsOnly both enable it, since the panel itself
handles the forced-HLS opt-in and caveat.

Also fixes App.test.tsx's #244 channel fixture, which lacked the now-
required preview field and crashed once ChannelTableRow started
reading it.
2026-07-21 23:13:08 +02:00

1436 lines
54 KiB
TypeScript

import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from './App';
import {
applyDesignSystemTheme,
designSystemStylesheet,
getStoredDesignSystemTheme
} from './designSystem';
describe('ChicoryTV SPA scaffold', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
window.history.replaceState(null, '', '/');
});
beforeEach(() => {
window.localStorage.clear();
// #396: the sidebar's Media/System nav groups now default to COLLAPSED. The shell/nav tests
// below click nav links inside those groups directly, so keep them expanded here; the
// collapse/accordion behavior itself is covered in its own describe block at the end.
window.localStorage.setItem(
'ctv-sidebar-groups',
JSON.stringify({ media: false, system: false })
);
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();
// The Dashboard declares no primaryAction, so the TopBar renders no primary-action button
// here (issue #238 — the old code rendered a dead "Add Channel" button on every screen).
expect(screen.queryByRole('button', { name: 'Add Channel' })).not.toBeInTheDocument();
const sidebar = screen.getByRole('complementary');
expect(await within(sidebar).findByText('Healthy')).toBeInTheDocument();
expect(await within(sidebar).findByText('26.4.0')).toBeInTheDocument();
expect(designSystemStylesheet).toBe('../../design-system/styles.css');
});
// Data-driven over every WIRED create screen whose primary action navigates to a URL — this
// catches a typo'd route-id in usePrimaryAction on any of them (the button would render but do
// nothing). The button lives in the shell header (role="banner"), scoped so an in-body Add
// control can't satisfy the assertion. (#238)
it.each([
['/app/channels', 'Add Channel', '/app/new-channel'],
['/app/filler-presets', 'Add Filler Preset', '/app/filler-presets/add'],
['/app/ffmpeg-profiles', 'Add Profile', '/app/ffmpeg-profiles/add'],
['/app/watermarks', 'Add Watermark', '/app/watermarks/add']
])('wires the TopBar create button on %s → %s (#238)', async (path, label, target) => {
window.history.pushState(null, '', path);
render(<App />);
const banner = await screen.findByRole('banner');
fireEvent.click(within(banner).getByRole('button', { name: label }));
expect(window.location.pathname).toBe(target);
});
it('drops the TopBar primary-action button on action screens (#238)', async () => {
render(<App />);
// An "action" screen (Logs) declares no primaryAction → no TopBar button at all
// (its Refresh control lives in-body, where the "+" primary styling would be wrong).
fireEvent.click(screen.getByRole('link', { name: 'Logs' }));
expect(await screen.findByRole('heading', { name: 'Logs' })).toBeInTheDocument();
expect(within(screen.getByRole('banner')).queryByRole('button', { name: /Refresh/ })).toBeNull();
});
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('dispatches the Channel Builder from its initial route', async () => {
window.history.replaceState(null, '', '/app/new-channel');
render(<App />);
expect(
await screen.findByText('New Channel', { selector: '.ctv-builder-title' })
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search shows & movies…')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
});
it('routes to the Auto-Tune wizard from its sidebar nav entry (#69)', async () => {
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Auto-Tune' }));
expect(window.location.pathname).toBe('/app/auto-tune');
// The wizard mounts on its Configure step with an in-body Preview action (no TopBar "+").
expect(await screen.findByRole('button', { name: /TV Shows/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Preview channels/ })).toBeInTheDocument();
// Loads channel templates on mount (its own endpoint dependency).
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
});
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('renders the extracted Guide screen at its route (#246)', async () => {
// Thin route/composition smoke test: detailed Guide behavior lives in GuideScreen.test.tsx.
window.history.replaceState(null, '', '/app/guide');
render(<App />);
expect(screen.getByRole('heading', { name: 'Guide' })).toBeInTheDocument();
expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Guide' })).toHaveAttribute('aria-current', 'page');
expect(vi.mocked(window.fetch).mock.calls.some(([input]) => input.toString().startsWith('/api/v1/guide?'))).toBe(true);
});
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('routes to the extracted Dashboard screen and mounts its query (#246)', async () => {
window.history.replaceState(null, '', '/app/channels');
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Dashboard' }));
expect(await screen.findByRole('heading', { name: 'On air now' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app');
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels', 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',
// Server-derived preview eligibility (Task 2/60): required on the ChannelResponseModel row.
preview: { availability: 'Unavailable', manifestUrl: null, unavailableReason: null },
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/v1/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/v1/schedules', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/v1/schedules/5/items', expect.any(Object));
// the flat editor fetches its own pickers (issue #207)
expect(window.fetch).toHaveBeenCalledWith('/api/v1/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('successful remote connection save clears App guard and mounts the source screen (#344)', async () => {
mockDashboardApi({
jellyfinConnection: { address: 'http://jf:8096', hasApiKey: true },
jellyfinState: {
isAuthorized: true,
isLocked: false,
servers: [{ id: 2, name: 'Test Jellyfin', address: 'http://jf:9000' }]
}
});
window.history.replaceState(null, '', '/app/libraries/jellyfin/connection');
render(<App />);
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
fireEvent.change(address, { target: { value: 'http://jf:9000' } });
const confirmSpy = vi.mocked(window.confirm);
confirmSpy.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Save connection' }));
expect(await screen.findByRole('heading', { name: 'Jellyfin' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/libraries/jellyfin');
expect(screen.getByRole('button', { name: 'Edit connection' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save connection' })).not.toBeInTheDocument();
expect(confirmSpy).not.toHaveBeenCalled();
});
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('routes to the extracted Playouts screen and mounts its query', async () => {
mockDashboardApi({
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();
expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/20', 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/v1/media-sources', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/v1/libraries/scan-status', expect.any(Object));
});
// 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 screens/PlayoutsScreen.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('updates sibling Playouts editors on popstate while App retains the stable route object', async () => {
mockDashboardApi({
playoutAlternateSchedules: [],
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
playoutTemplateItems: [],
schedules: [],
templates: []
});
window.history.replaceState(null, '', '/app/playouts/20/alternate-schedules');
render(<App />);
expect(
await screen.findByText('Retro Cartoons / Alternate schedules', {
selector: '.ctv-collections-detail-title'
})
).toBeInTheDocument();
window.history.pushState(null, '', '/app/playouts/20/templates');
fireEvent(window, new PopStateEvent('popstate'));
expect(screen.getByText('Loading playout templates…')).toBeInTheDocument();
expect(
screen.queryByText('Retro Cartoons / Alternate schedules', {
selector: '.ctv-collections-detail-title'
})
).not.toBeInTheDocument();
expect(
await screen.findByText('Retro Cartoons / Templates', {
selector: '.ctv-collections-detail-title'
})
).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/playouts/20/templates');
});
it('renders the extracted Settings screen from an initial query-bearing route', async () => {
window.history.replaceState(null, '', '/app/settings?source=deep-link');
render(<App />);
expect(
await screen.findByText(
'Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.'
)
).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/settings');
expect(window.location.search).toBe('?source=deep-link');
expect(window.fetch).toHaveBeenCalledWith('/api/v1/settings/ui', expect.any(Object));
});
it('dispatches the extracted Settings screen from an initial section sub-path', async () => {
window.history.replaceState(null, '', '/app/settings/scanner');
render(<App />);
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/settings/scanner');
expect(window.fetch).toHaveBeenCalledWith('/api/v1/settings/scanner', expect.any(Object));
});
// #396 — collapsible sidebar + collapsible nav groups. These tests want the real defaults
// (Media/System groups collapsed), so undo the outer beforeEach's "keep groups open" seed.
describe('collapsible sidebar (#396)', () => {
beforeEach(() => {
window.localStorage.removeItem('ctv-sidebar-groups');
});
it('collapses Media/System nav groups by default, leaving Primary visible', () => {
render(<App />);
// Primary group items always render.
expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Channels' })).toBeInTheDocument();
// Labeled groups start collapsed → their items are not rendered, but the accordion headers are.
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Media', expanded: false })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'System', expanded: false })).toBeInTheDocument();
});
it('expands a nav group when its header is clicked and persists the open state', () => {
render(<App />);
const systemHeader = screen.getByRole('button', { name: 'System', expanded: false });
fireEvent.click(systemHeader);
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
const stored = JSON.parse(window.localStorage.getItem('ctv-sidebar-groups') ?? '{}');
expect(stored.system).toBe(false);
});
it('restores expanded groups from localStorage on load', () => {
window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify({ media: false }));
render(<App />);
// Media restored open; System still default-collapsed.
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
});
it('collapses the sidebar to the icon rail and persists it', () => {
const { container } = render(<App />);
const shell = container.querySelector('.ctv-app-shell');
expect(shell).not.toHaveClass('ctv-app-shell-collapsed');
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
expect(shell).toHaveClass('ctv-app-shell-collapsed');
expect(window.localStorage.getItem('ctv-sidebar-collapsed')).toBe('1');
// The toggle now offers to expand again.
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
});
it('shows every group item in the rail regardless of accordion state (labels kept for a11y)', () => {
// Sidebar collapsed on load; groups at their default (collapsed) accordion state.
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
const { container } = render(<App />);
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
// Accordions do not apply in the rail: Media/System items are all present (label stays in the
// a11y tree so the link keeps its accessible name), and no accordion header is rendered.
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'System' })).not.toBeInTheDocument();
});
it('restores the collapsed sidebar from localStorage on load', () => {
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
const { container } = render(<App />);
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
});
it('reveals + marks the active route even when its group is default-collapsed, without persisting', () => {
// Deep-link into a route inside the default-collapsed System group (expanded sidebar).
window.history.replaceState(null, '', '/app/settings');
render(<App />);
// The active item is rendered and marked, and its group header shows expanded…
const settingsLink = screen.getByRole('link', { name: 'Settings' });
expect(settingsLink).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
// …but a sibling default-collapsed group (Media) stays collapsed…
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
// …and the reveal did NOT write a stored preference (it reverts on navigating away).
expect(window.localStorage.getItem('ctv-sidebar-groups')).toBeNull();
});
});
});
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
};
}
// 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 defaultIptvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
baseUrl: '',
...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,
channels = [],
channelStates = [],
playoutAlternateSchedules = [],
playoutTemplateItems = [],
templates = [],
decoTemplates = [],
ffmpegProfiles = [],
watermarks = [],
collections = [],
confirm = false,
fillerPresets = [],
health = [],
libraryScanStatusFailAfterTrigger = false,
libraryScanStatuses = [],
jellyfinConnection = null,
jellyfinState = null,
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' }
}: {
addScheduleItemResponse?: unknown;
channels?: unknown[];
channelStates?: unknown[];
playoutAlternateSchedules?: unknown[];
playoutTemplateItems?: unknown[];
templates?: unknown[];
decoTemplates?: unknown[];
ffmpegProfiles?: unknown[];
watermarks?: unknown[];
collections?: unknown[];
confirm?: boolean;
fillerPresets?: unknown[];
health?: unknown[];
libraryScanStatusFailAfterTrigger?: boolean;
libraryScanStatuses?: unknown[];
jellyfinConnection?: { address: string | null; hasApiKey: boolean } | null;
jellyfinState?: 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;
} = {}) {
vi.spyOn(window, 'confirm').mockReturnValue(confirm);
vi.spyOn(window, 'prompt').mockReturnValue(prompt);
let currentLibraryScanStatuses = libraryScanStatuses;
let currentJellyfinConnection = jellyfinConnection;
let currentScheduleItems = scheduleItems;
const settingsResponses: Record<string, unknown> = {
'/api/v1/settings/ffmpeg': defaultFfmpegSettings(),
'/api/v1/settings/hdhr': defaultHdhrSettings(),
'/api/v1/settings/iptv': defaultIptvSettings(),
'/api/v1/settings/logging': defaultLoggingSettings(),
'/api/v1/settings/playout': defaultPlayoutSettings(),
'/api/v1/settings/resolutions': defaultResolutions(),
'/api/v1/settings/scanner': defaultScannerSettings(),
'/api/v1/settings/ui': defaultUiSettings(),
'/api/v1/settings/xmltv': defaultXmltvSettings()
};
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
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/v1/channels') {
return Promise.resolve(jsonResponse(channels));
}
if (path === '/api/v1/channels/state') {
return Promise.resolve(jsonResponse(channelStates));
}
if (path === '/api/v1/channels/from-lineup') {
return Promise.resolve(
jsonResponse({ channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 }, 201)
);
}
if (path.startsWith('/api/v1/library/browse')) {
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
}
if (path === '/api/v1/channel-templates') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/channel-templates/default') {
return Promise.resolve(jsonResponse(null, 404));
}
if (path === '/api/v1/ffmpeg/profiles') {
return Promise.resolve(jsonResponse(ffmpegProfiles));
}
if (path === '/api/v1/artwork/uploads') {
return Promise.resolve(jsonResponse({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' }, 201));
}
if (path.startsWith('/api/v1/guide?')) {
const guideUrl = new URL(path, window.location.origin);
return Promise.resolve(jsonResponse({
channels: [],
end: guideUrl.searchParams.get('end'),
start: guideUrl.searchParams.get('start')
}));
}
if (path === '/api/v1/schedules') {
return Promise.resolve(jsonResponse(schedules));
}
if (path.match(/^\/api\/v1\/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\/v1\/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/v1/collections') {
return Promise.resolve(jsonResponse(collections));
}
if (path === '/api/v1/smart-collections') {
return Promise.resolve(jsonResponse(smartCollections));
}
if (path === '/api/v1/multi-collections') {
return Promise.resolve(jsonResponse(multiCollections));
}
if (path.startsWith('/api/v1/filler-presets')) {
return Promise.resolve(jsonResponse(fillerPresets));
}
if (path === '/api/v1/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/v1/rerun-collections')) {
return Promise.resolve(jsonResponse({ totalCount: 0, page: [] }));
}
if (path === '/api/v1/playlists/groups') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/graphics-elements') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/languages') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/media-sources/jellyfin/connection') {
if ((init?.method ?? 'GET') === 'PUT') {
const body = JSON.parse(String(init?.body ?? '{}')) as { address?: string };
currentJellyfinConnection = { address: body.address ?? null, hasApiKey: true };
return Promise.resolve(jsonResponse(currentJellyfinConnection));
}
return Promise.resolve(
currentJellyfinConnection
? jsonResponse(currentJellyfinConnection)
: jsonResponse(null, 404)
);
}
if (path === '/api/v1/media-sources/jellyfin') {
return Promise.resolve(jsonResponse(jellyfinState ?? {
isAuthorized: false,
isLocked: false,
servers: []
}));
}
if (path === '/api/v1/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/v1/libraries/local/')) {
if (localLibraryDetail) {
return Promise.resolve(jsonResponse(localLibraryDetail));
}
return Promise.resolve(new Response(null, { status: 404 }));
}
if (path === '/api/v1/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\/v1\/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/v1/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/v1/playouts/warnings/count') {
return Promise.resolve(jsonResponse(playoutWarningsCount));
}
if (path === '/api/v1/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/v1/playouts/reset-all is an unconditional 202 Accepted.
return Promise.resolve(new Response(null, { status: 202 }));
}
if (path.match(/^\/api\/v1\/playouts\/\d+\/alternate-schedules$/)) {
return Promise.resolve(jsonResponse(playoutAlternateSchedules));
}
if (path.match(/^\/api\/v1\/playouts\/\d+\/templates$/)) {
return Promise.resolve(jsonResponse(playoutTemplateItems));
}
if (path.match(/^\/api\/v1\/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\/v1\/playouts\/items\/\d+\/scheduling-context$/)) {
if (playoutSchedulingContextFailure) {
return Promise.resolve(
jsonResponse(playoutSchedulingContextFailure, playoutSchedulingContextFailure.status ?? 404)
);
}
return Promise.resolve(jsonResponse({ context: playoutSchedulingContext }));
}
if (path === '/api/v1/templates') {
return Promise.resolve(jsonResponse(templates));
}
if (path === '/api/v1/deco-templates') {
return Promise.resolve(jsonResponse(decoTemplates));
}
const itemsMatch = path.match(/^\/api\/v1\/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\/v1\/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.startsWith('/api/v1/health')) {
return Promise.resolve(jsonResponse(health));
}
if (path === '/api/v1/version') {
return Promise.resolve(jsonResponse(version));
}
if (path in settingsResponses) {
return Promise.resolve(jsonResponse(settingsResponses[path]));
}
if (path in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
}
if (
path === '/api/v1/channels/bulk/renumber' ||
path === '/api/v1/channels/bulk/group' ||
path === '/api/v1/channels/bulk/delete' ||
path.startsWith('/api/v1/channels/')
) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(jsonResponse(null, 404));
});
}