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>
3481 lines
134 KiB
TypeScript
3481 lines
134 KiB
TypeScript
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
|
||
};
|
||
}
|