Wires the ChannelPreviewPanel (Task 4) into ChannelsScreen: a single panel instance is rendered per screen and its `channel` prop is swapped via previewChannelId state rather than remounting per row. The Play button now reads the server-derived channel.preview.availability (Task 2) instead of being permanently disabled -- Unavailable stays disabled with the server's unavailableReason surfaced in the title; Available and ForcedHlsOnly both enable it, since the panel itself handles the forced-HLS opt-in and caveat. Also fixes App.test.tsx's #244 channel fixture, which lacked the now- required preview field and crashed once ChannelTableRow started reading it.
1436 lines
54 KiB
TypeScript
1436 lines
54 KiB
TypeScript
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { App } from './App';
|
|
import {
|
|
applyDesignSystemTheme,
|
|
designSystemStylesheet,
|
|
getStoredDesignSystemTheme
|
|
} from './designSystem';
|
|
|
|
describe('ChicoryTV SPA scaffold', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
window.history.replaceState(null, '', '/');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
// #396: the sidebar's Media/System nav groups now default to COLLAPSED. The shell/nav tests
|
|
// below click nav links inside those groups directly, so keep them expanded here; the
|
|
// collapse/accordion behavior itself is covered in its own describe block at the end.
|
|
window.localStorage.setItem(
|
|
'ctv-sidebar-groups',
|
|
JSON.stringify({ media: false, system: false })
|
|
);
|
|
document.documentElement.removeAttribute('data-theme');
|
|
window.history.replaceState(null, '', '/app');
|
|
vi.restoreAllMocks();
|
|
mockDashboardApi();
|
|
});
|
|
|
|
it('renders the admin shell chrome and loads the design system stylesheet', async () => {
|
|
render(<App />);
|
|
|
|
expect(screen.getByRole('img', { name: 'ChicoryTV' })).toBeInTheDocument();
|
|
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument();
|
|
expect(screen.getByRole('banner')).toBeInTheDocument();
|
|
expect(screen.getByRole('searchbox', { name: 'Search' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
|
|
// The Dashboard declares no primaryAction, so the TopBar renders no primary-action button
|
|
// here (issue #238 — the old code rendered a dead "Add Channel" button on every screen).
|
|
expect(screen.queryByRole('button', { name: 'Add Channel' })).not.toBeInTheDocument();
|
|
const sidebar = screen.getByRole('complementary');
|
|
expect(await within(sidebar).findByText('Healthy')).toBeInTheDocument();
|
|
expect(await within(sidebar).findByText('26.4.0')).toBeInTheDocument();
|
|
expect(designSystemStylesheet).toBe('../../design-system/styles.css');
|
|
});
|
|
|
|
// Data-driven over every WIRED create screen whose primary action navigates to a URL — this
|
|
// catches a typo'd route-id in usePrimaryAction on any of them (the button would render but do
|
|
// nothing). The button lives in the shell header (role="banner"), scoped so an in-body Add
|
|
// control can't satisfy the assertion. (#238)
|
|
it.each([
|
|
['/app/channels', 'Add Channel', '/app/new-channel'],
|
|
['/app/filler-presets', 'Add Filler Preset', '/app/filler-presets/add'],
|
|
['/app/ffmpeg-profiles', 'Add Profile', '/app/ffmpeg-profiles/add'],
|
|
['/app/watermarks', 'Add Watermark', '/app/watermarks/add']
|
|
])('wires the TopBar create button on %s → %s (#238)', async (path, label, target) => {
|
|
window.history.pushState(null, '', path);
|
|
render(<App />);
|
|
|
|
const banner = await screen.findByRole('banner');
|
|
fireEvent.click(within(banner).getByRole('button', { name: label }));
|
|
expect(window.location.pathname).toBe(target);
|
|
});
|
|
|
|
it('drops the TopBar primary-action button on action screens (#238)', async () => {
|
|
render(<App />);
|
|
|
|
// An "action" screen (Logs) declares no primaryAction → no TopBar button at all
|
|
// (its Refresh control lives in-body, where the "+" primary styling would be wrong).
|
|
fireEvent.click(screen.getByRole('link', { name: 'Logs' }));
|
|
expect(await screen.findByRole('heading', { name: 'Logs' })).toBeInTheDocument();
|
|
expect(within(screen.getByRole('banner')).queryByRole('button', { name: /Refresh/ })).toBeNull();
|
|
});
|
|
|
|
it('routes between shell screen slots without a page reload', async () => {
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
|
|
|
|
expect(screen.getByRole('heading', { name: 'Channels' })).toBeInTheDocument();
|
|
expect(await screen.findByText('No channels yet')).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Channels' })).toHaveAttribute(
|
|
'aria-current',
|
|
'page'
|
|
);
|
|
expect(window.location.pathname).toBe('/app/channels');
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
|
|
|
expect(screen.getByRole('heading', { name: 'Schedules' })).toBeInTheDocument();
|
|
expect((await screen.findAllByRole('button', { name: 'Add schedule' })).length).toBeGreaterThan(0);
|
|
expect(window.location.pathname).toBe('/app/schedules');
|
|
});
|
|
|
|
it('dispatches the Channel Builder from its initial route', async () => {
|
|
window.history.replaceState(null, '', '/app/new-channel');
|
|
|
|
render(<App />);
|
|
|
|
expect(
|
|
await screen.findByText('New Channel', { selector: '.ctv-builder-title' })
|
|
).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('Search shows & movies…')).toBeInTheDocument();
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
|
|
});
|
|
|
|
it('routes to the Auto-Tune wizard from its sidebar nav entry (#69)', async () => {
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Auto-Tune' }));
|
|
|
|
expect(window.location.pathname).toBe('/app/auto-tune');
|
|
// The wizard mounts on its Configure step with an in-body Preview action (no TopBar "+").
|
|
expect(await screen.findByRole('button', { name: /TV Shows/ })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Preview channels/ })).toBeInTheDocument();
|
|
// Loads channel templates on mount (its own endpoint dependency).
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
|
|
});
|
|
|
|
it('registers the multi-collection and rerun-collection nav entries', async () => {
|
|
render(<App />);
|
|
|
|
// Anchored exact names avoid matching the substring "Collections" nav entry (spa-conventions §6).
|
|
fireEvent.click(screen.getByRole('link', { name: 'Multi-Collections' }));
|
|
expect(screen.getByRole('heading', { name: 'Multi-Collections' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/multi-collections');
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Rerun Collections' }));
|
|
expect(screen.getByRole('heading', { name: 'Rerun Collections' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/rerun-collections');
|
|
});
|
|
|
|
it('preserves native browser behavior for modified nav link clicks', () => {
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Channels' }), { ctrlKey: true });
|
|
|
|
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app');
|
|
});
|
|
|
|
it('renders the extracted Guide screen at its route (#246)', async () => {
|
|
// Thin route/composition smoke test: detailed Guide behavior lives in GuideScreen.test.tsx.
|
|
window.history.replaceState(null, '', '/app/guide');
|
|
|
|
render(<App />);
|
|
|
|
expect(screen.getByRole('heading', { name: 'Guide' })).toBeInTheDocument();
|
|
expect(await screen.findByRole('grid', { name: 'Channel guide' })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Guide' })).toHaveAttribute('aria-current', 'page');
|
|
expect(vi.mocked(window.fetch).mock.calls.some(([input]) => input.toString().startsWith('/api/v1/guide?'))).toBe(true);
|
|
});
|
|
|
|
it('shows a not-found screen for unknown app routes', () => {
|
|
window.history.replaceState(null, '', '/app/channels/42');
|
|
|
|
render(<App />);
|
|
|
|
expect(screen.getByRole('heading', { name: 'Page not found' })).toBeInTheDocument();
|
|
expect(screen.getByText('/app/channels/42')).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Dashboard' })).not.toHaveAttribute(
|
|
'aria-current',
|
|
'page'
|
|
);
|
|
});
|
|
|
|
it('opens the Connect menu with absolute player endpoints and live channel count', async () => {
|
|
mockDashboardApi({
|
|
channels: [
|
|
{ id: 1, name: 'Retro Cartoons', number: '5.1' },
|
|
{ id: 2, name: 'News 24', number: '24' }
|
|
]
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
|
|
|
|
expect(screen.getByRole('dialog', { name: 'Connect a player' })).toBeInTheDocument();
|
|
expect(screen.getByText('M3U playlist')).toBeInTheDocument();
|
|
expect(screen.getByText('XMLTV guide')).toBeInTheDocument();
|
|
expect(screen.getByText(`${window.location.origin}/iptv/channels.m3u`)).toBeInTheDocument();
|
|
expect(screen.getByText(`${window.location.origin}/iptv/xmltv.xml`)).toBeInTheDocument();
|
|
expect(await screen.findByText('2 channels')).toBeInTheDocument();
|
|
expect(screen.queryByRole('checkbox', { name: 'Only enabled channels' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('focuses the Connect menu and closes it with Escape', () => {
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
|
|
|
|
const dialog = screen.getByRole('dialog', { name: 'Connect a player' });
|
|
expect(dialog).toHaveFocus();
|
|
|
|
fireEvent.keyDown(dialog, { key: 'Escape' });
|
|
|
|
expect(screen.queryByRole('dialog', { name: 'Connect a player' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('applies the warm design-system default by removing the theme attribute', () => {
|
|
document.documentElement.dataset.theme = 'cool';
|
|
|
|
applyDesignSystemTheme('warm');
|
|
|
|
expect(document.documentElement).not.toHaveAttribute('data-theme');
|
|
expect(window.localStorage.getItem('ctv-theme')).toBe('warm');
|
|
});
|
|
|
|
it('applies and persists alternate design-system themes', () => {
|
|
applyDesignSystemTheme('cool');
|
|
|
|
expect(document.documentElement).toHaveAttribute('data-theme', 'cool');
|
|
expect(window.localStorage.getItem('ctv-theme')).toBe('cool');
|
|
});
|
|
|
|
it('ignores invalid persisted themes', () => {
|
|
window.localStorage.setItem('ctv-theme', 'unknown');
|
|
|
|
expect(getStoredDesignSystemTheme()).toBe('warm');
|
|
});
|
|
|
|
it('switches themes from the shell controls', () => {
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Dual accent theme' }));
|
|
|
|
expect(document.documentElement).toHaveAttribute('data-theme', 'dual');
|
|
expect(window.localStorage.getItem('ctv-theme')).toBe('dual');
|
|
expect(screen.getByRole('button', { name: 'Dual accent theme' })).toHaveAttribute(
|
|
'aria-pressed',
|
|
'true'
|
|
);
|
|
});
|
|
|
|
it('routes to the extracted Dashboard screen and mounts its query (#246)', async () => {
|
|
window.history.replaceState(null, '', '/app/channels');
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Dashboard' }));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'On air now' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app');
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels', expect.any(Object));
|
|
});
|
|
|
|
it('renders the extracted Channels screen from live channel APIs (#244)', async () => {
|
|
// Thin route/composition smoke test: the full Channels behavioral suite lives colocated in
|
|
// web/src/screens/ChannelsScreen.test.tsx. This only asserts the nav wires the extracted screen
|
|
// into the shell and it mounts + hits its own endpoint.
|
|
mockDashboardApi({
|
|
channels: [
|
|
{
|
|
ffmpegProfile: 'HLS Direct',
|
|
group: 'Kids',
|
|
id: 1,
|
|
isEnabled: true,
|
|
language: 'en',
|
|
name: 'Retro Cartoons',
|
|
number: '5.1',
|
|
// Server-derived preview eligibility (Task 2/60): required on the ChannelResponseModel row.
|
|
preview: { availability: 'Unavailable', manifestUrl: null, unavailableReason: null },
|
|
showInEpg: true,
|
|
sortNumber: 5.1,
|
|
streamingMode: 'HLS Direct'
|
|
}
|
|
],
|
|
channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }]
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Channels' })).toBeInTheDocument();
|
|
expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument();
|
|
expect(screen.getByText('Retro Cartoons')).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/channels');
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channels/state', expect.any(Object));
|
|
});
|
|
|
|
it('renders the extracted Schedules editor from live schedule APIs (#207)', async () => {
|
|
mockDashboardApi({
|
|
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })],
|
|
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
|
|
expect(await screen.findByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument();
|
|
expect(screen.getAllByText('Saturday Cartoons').length).toBeGreaterThan(0);
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/schedules', expect.any(Object));
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/schedules/5/items', expect.any(Object));
|
|
// the flat editor fetches its own pickers (issue #207)
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/languages', expect.any(Object));
|
|
});
|
|
|
|
it('honors the unsaved-changes guard on browser Back/popstate (#230 finding 1)', async () => {
|
|
mockDashboardApi({
|
|
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })],
|
|
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
|
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
|
|
await screen.findByRole('list', { name: 'Schedule lineup' });
|
|
|
|
// Dirty the item draft so the registered navigation guard is armed.
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
|
|
expect(await screen.findByText(/unsaved changes/)).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/schedules');
|
|
|
|
// Simulate browser Back: popstate fires AFTER the URL has already moved. The guard cannot cancel
|
|
// it, so on a vetoed confirm the App handler must re-push the pre-pop path and keep the route.
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
window.history.pushState(null, '', '/app/guide');
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
|
|
expect(confirmSpy).toHaveBeenCalled();
|
|
expect(screen.getByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/schedules');
|
|
|
|
// Accept the discard on the next Back → route changes and the schedules screen (with its draft)
|
|
// unmounts, matching the sidebar-nav case.
|
|
confirmSpy.mockReturnValue(true);
|
|
window.history.pushState(null, '', '/app/guide');
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Guide' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/guide');
|
|
expect(screen.queryByRole('heading', { name: 'Prime Time Cartoons' })).not.toBeInTheDocument();
|
|
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
// Design §D.2 (finding 4): the libraries route is the first that BOTH tracks its own sub-path AND
|
|
// registers a dirty guard. App owns pathname/popstate for it (the wrapper never self-listens), so a
|
|
// dirty-editor Back is vetoed by App BEFORE the wrapper can switch sub-screen. S6a landed the real
|
|
// LocalLibraryEditScreen, so this exercises its own dirty guard (typing into Name) rather than a
|
|
// manually-armed stand-in guard.
|
|
it('App-owned popstate: a dirty libraries editor vetoes Back, then navigates when confirmed', async () => {
|
|
mockDashboardApi({
|
|
mediaSources: [mediaSource({ id: 30, kind: 'Local', libraries: [], name: 'Local' })],
|
|
localLibraryDetail: {
|
|
id: 3,
|
|
name: 'Feature Films',
|
|
mediaKind: 'Movies',
|
|
isLocked: false,
|
|
mediaItemCount: 0,
|
|
paths: []
|
|
}
|
|
});
|
|
|
|
window.history.replaceState(null, '', '/app/libraries/local/3');
|
|
render(<App />);
|
|
|
|
// The S6a editor is mounted for the local-edit sub-path.
|
|
const nameInput = await screen.findByDisplayValue('Feature Films');
|
|
|
|
// Make the editor dirty for real (not a manually-armed stand-in guard).
|
|
fireEvent.change(nameInput, { target: { value: 'Feature Films (renamed)' } });
|
|
expect(await screen.findByText('Unsaved changes')).toBeInTheDocument();
|
|
|
|
// Browser Back to the hub, vetoed: URL is restored and the editor stays put (App decided BEFORE
|
|
// the wrapper could react — the wrapper only ever sees an approved sub-path).
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
window.history.pushState(null, '', '/app/libraries');
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
|
|
expect(confirmSpy).toHaveBeenCalled();
|
|
expect(window.location.pathname).toBe('/app/libraries/local/3');
|
|
expect(screen.getByDisplayValue('Feature Films (renamed)')).toBeInTheDocument();
|
|
expect(screen.queryByRole('heading', { name: 'Media Libraries' })).not.toBeInTheDocument();
|
|
|
|
// Back again, this time confirmed: the wrapper switches to the hub and the editor unmounts.
|
|
confirmSpy.mockReturnValue(true);
|
|
window.history.pushState(null, '', '/app/libraries');
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/libraries');
|
|
expect(screen.queryByDisplayValue('Feature Films (renamed)')).not.toBeInTheDocument();
|
|
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it('successful remote connection save clears App guard and mounts the source screen (#344)', async () => {
|
|
mockDashboardApi({
|
|
jellyfinConnection: { address: 'http://jf:8096', hasApiKey: true },
|
|
jellyfinState: {
|
|
isAuthorized: true,
|
|
isLocked: false,
|
|
servers: [{ id: 2, name: 'Test Jellyfin', address: 'http://jf:9000' }]
|
|
}
|
|
});
|
|
window.history.replaceState(null, '', '/app/libraries/jellyfin/connection');
|
|
render(<App />);
|
|
|
|
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
|
|
fireEvent.change(address, { target: { value: 'http://jf:9000' } });
|
|
|
|
const confirmSpy = vi.mocked(window.confirm);
|
|
confirmSpy.mockClear();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save connection' }));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Jellyfin' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/libraries/jellyfin');
|
|
expect(screen.getByRole('button', { name: 'Edit connection' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Save connection' })).not.toBeInTheDocument();
|
|
expect(confirmSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('opens the create-schedule dialog from the TopBar Add Schedule button', async () => {
|
|
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] });
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
|
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
|
|
|
|
// TopBar primary action for the schedules route reads "Add Schedule".
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add Schedule' }));
|
|
|
|
const dialog = await screen.findByRole('dialog');
|
|
expect(within(dialog).getByLabelText('Name')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the empty Schedules state when no schedule exists', async () => {
|
|
mockDashboardApi({ schedules: [] });
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
|
|
|
expect((await screen.findAllByRole('button', { name: 'Add schedule' })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('routes to the extracted Playouts screen and mounts its query', async () => {
|
|
mockDashboardApi({
|
|
playoutItems: [playoutItem()],
|
|
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/playouts/20', expect.any(Object));
|
|
});
|
|
|
|
it('renders the extracted Libraries screen from live media source and scan status APIs (#202)', async () => {
|
|
// Thin route/composition smoke test: the full Libraries behavioral suite lives colocated in
|
|
// web/src/screens/LibrariesScreen.test.tsx. This only asserts the nav wires the extracted screen
|
|
// into the shell and it mounts + hits its own endpoints.
|
|
mockDashboardApi({
|
|
libraryScanStatuses: [{ libraryId: 31, percent: 0.5 }],
|
|
mediaSources: [
|
|
mediaSource({
|
|
libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })]
|
|
})
|
|
]
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
|
|
expect(screen.getAllByText('Movies').length).toBeGreaterThan(0);
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/media-sources', expect.any(Object));
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/libraries/scan-status', expect.any(Object));
|
|
});
|
|
|
|
// Regression coverage for the live-E2E finding: clicking "Alternate schedules" / "Templates"
|
|
// used to update the URL via navigateToPath's pushState + synthetic popstate but never swap
|
|
// the rendered screen, because routeFromLocation() returns the SAME ScreenRoute object
|
|
// reference for /app/playouts and every /app/playouts/{id}/* sub-path, so React's
|
|
// setActiveRoute(sameRef) bailed via Object.is and ScreenContent never re-ran. The fix (see
|
|
// PlayoutsRouteScreen in screens/PlayoutsScreen.tsx) mirrors
|
|
// BlocksScreen/TemplatesScreen/DecosScreen: it owns its own pathname state and popstate listener
|
|
// instead of relying on the shell's activeRoute.
|
|
it('client-side navigates into and back out of the alternate-schedules editor without a reload', async () => {
|
|
mockDashboardApi({
|
|
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
|
|
playoutItems: [playoutItem()],
|
|
playouts: { page: [listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' })], totalCount: 1 },
|
|
schedules: [{ id: 7, name: 'Alpha' }],
|
|
playoutAlternateSchedules: [
|
|
{
|
|
id: 1,
|
|
index: 0,
|
|
programScheduleId: 7,
|
|
daysOfWeek: [],
|
|
daysOfMonth: [],
|
|
monthsOfYear: [],
|
|
limitToDateRange: false,
|
|
startMonth: 1,
|
|
startDay: 1,
|
|
startYear: null,
|
|
endMonth: 12,
|
|
endDay: 31,
|
|
endYear: null
|
|
}
|
|
]
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
|
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Alternate schedules' }));
|
|
|
|
// Synchronous assertion (no findBy/await): the editor's initial render happens in the same
|
|
// tick as the click, before its data fetch resolves - proving the screen actually swapped
|
|
// instead of silently staying on the Playouts list while only the URL changed.
|
|
expect(screen.getByText('Loading alternate schedules…')).toBeInTheDocument();
|
|
expect(screen.queryByRole('heading', { name: 'Retro Cartoons' })).not.toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/playouts/20/alternate-schedules');
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'All playouts' }));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/playouts');
|
|
});
|
|
|
|
it('client-side navigates into and back out of the block-templates editor without a reload', async () => {
|
|
mockDashboardApi({
|
|
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleKind: 'Block' }),
|
|
playoutItems: [playoutItem()],
|
|
playouts: {
|
|
page: [listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleKind: 'Block' })],
|
|
totalCount: 1
|
|
},
|
|
templates: [{ id: 3, templateGroupId: 1, groupName: 'Grp', name: 'Weekdays' }],
|
|
playoutTemplateItems: [
|
|
{
|
|
id: 1,
|
|
index: 0,
|
|
templateId: 3,
|
|
templateName: 'Weekdays',
|
|
templateGroupName: 'Grp',
|
|
decoTemplateId: null,
|
|
decoTemplateName: null,
|
|
decoTemplateGroupName: null,
|
|
daysOfWeek: [],
|
|
daysOfMonth: [],
|
|
monthsOfYear: [],
|
|
limitToDateRange: false,
|
|
startMonth: 1,
|
|
startDay: 1,
|
|
startYear: null,
|
|
endMonth: 12,
|
|
endDay: 31,
|
|
endYear: null
|
|
}
|
|
]
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
|
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Templates' }));
|
|
|
|
expect(screen.getByText('Loading playout templates…')).toBeInTheDocument();
|
|
expect(screen.queryByRole('heading', { name: 'Retro Cartoons' })).not.toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/playouts/20/templates');
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'All playouts' }));
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/playouts');
|
|
});
|
|
|
|
it('updates sibling Playouts editors on popstate while App retains the stable route object', async () => {
|
|
mockDashboardApi({
|
|
playoutAlternateSchedules: [],
|
|
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
|
|
playoutTemplateItems: [],
|
|
schedules: [],
|
|
templates: []
|
|
});
|
|
window.history.replaceState(null, '', '/app/playouts/20/alternate-schedules');
|
|
|
|
render(<App />);
|
|
|
|
expect(
|
|
await screen.findByText('Retro Cartoons / Alternate schedules', {
|
|
selector: '.ctv-collections-detail-title'
|
|
})
|
|
).toBeInTheDocument();
|
|
|
|
window.history.pushState(null, '', '/app/playouts/20/templates');
|
|
fireEvent(window, new PopStateEvent('popstate'));
|
|
|
|
expect(screen.getByText('Loading playout templates…')).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByText('Retro Cartoons / Alternate schedules', {
|
|
selector: '.ctv-collections-detail-title'
|
|
})
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
await screen.findByText('Retro Cartoons / Templates', {
|
|
selector: '.ctv-collections-detail-title'
|
|
})
|
|
).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/playouts/20/templates');
|
|
});
|
|
|
|
it('renders the extracted Settings screen from an initial query-bearing route', async () => {
|
|
window.history.replaceState(null, '', '/app/settings?source=deep-link');
|
|
|
|
render(<App />);
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
'Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.'
|
|
)
|
|
).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/settings');
|
|
expect(window.location.search).toBe('?source=deep-link');
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/settings/ui', expect.any(Object));
|
|
});
|
|
|
|
it('dispatches the extracted Settings screen from an initial section sub-path', async () => {
|
|
window.history.replaceState(null, '', '/app/settings/scanner');
|
|
|
|
render(<App />);
|
|
|
|
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
|
|
expect(window.location.pathname).toBe('/app/settings/scanner');
|
|
expect(window.fetch).toHaveBeenCalledWith('/api/v1/settings/scanner', expect.any(Object));
|
|
});
|
|
|
|
// #396 — collapsible sidebar + collapsible nav groups. These tests want the real defaults
|
|
// (Media/System groups collapsed), so undo the outer beforeEach's "keep groups open" seed.
|
|
describe('collapsible sidebar (#396)', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.removeItem('ctv-sidebar-groups');
|
|
});
|
|
|
|
it('collapses Media/System nav groups by default, leaving Primary visible', () => {
|
|
render(<App />);
|
|
|
|
// Primary group items always render.
|
|
expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Channels' })).toBeInTheDocument();
|
|
|
|
// Labeled groups start collapsed → their items are not rendered, but the accordion headers are.
|
|
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Media', expanded: false })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'System', expanded: false })).toBeInTheDocument();
|
|
});
|
|
|
|
it('expands a nav group when its header is clicked and persists the open state', () => {
|
|
render(<App />);
|
|
|
|
const systemHeader = screen.getByRole('button', { name: 'System', expanded: false });
|
|
fireEvent.click(systemHeader);
|
|
|
|
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
|
|
|
|
const stored = JSON.parse(window.localStorage.getItem('ctv-sidebar-groups') ?? '{}');
|
|
expect(stored.system).toBe(false);
|
|
});
|
|
|
|
it('restores expanded groups from localStorage on load', () => {
|
|
window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify({ media: false }));
|
|
|
|
render(<App />);
|
|
|
|
// Media restored open; System still default-collapsed.
|
|
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('collapses the sidebar to the icon rail and persists it', () => {
|
|
const { container } = render(<App />);
|
|
|
|
const shell = container.querySelector('.ctv-app-shell');
|
|
expect(shell).not.toHaveClass('ctv-app-shell-collapsed');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
|
|
|
|
expect(shell).toHaveClass('ctv-app-shell-collapsed');
|
|
expect(window.localStorage.getItem('ctv-sidebar-collapsed')).toBe('1');
|
|
// The toggle now offers to expand again.
|
|
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows every group item in the rail regardless of accordion state (labels kept for a11y)', () => {
|
|
// Sidebar collapsed on load; groups at their default (collapsed) accordion state.
|
|
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
|
|
|
|
const { container } = render(<App />);
|
|
|
|
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
|
|
// Accordions do not apply in the rail: Media/System items are all present (label stays in the
|
|
// a11y tree so the link keeps its accessible name), and no accordion header is rendered.
|
|
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'System' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('restores the collapsed sidebar from localStorage on load', () => {
|
|
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
|
|
|
|
const { container } = render(<App />);
|
|
|
|
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
|
|
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('reveals + marks the active route even when its group is default-collapsed, without persisting', () => {
|
|
// Deep-link into a route inside the default-collapsed System group (expanded sidebar).
|
|
window.history.replaceState(null, '', '/app/settings');
|
|
|
|
render(<App />);
|
|
|
|
// The active item is rendered and marked, and its group header shows expanded…
|
|
const settingsLink = screen.getByRole('link', { name: 'Settings' });
|
|
expect(settingsLink).toHaveAttribute('aria-current', 'page');
|
|
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
|
|
// …but a sibling default-collapsed group (Media) stays collapsed…
|
|
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
|
|
// …and the reveal did NOT write a stored preference (it reverts on navigating away).
|
|
expect(window.localStorage.getItem('ctv-sidebar-groups')).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
status
|
|
});
|
|
}
|
|
|
|
function schedule(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
fixedStartTimeBehavior: 'Flexible',
|
|
id: 1,
|
|
keepMultiPartEpisodesTogether: true,
|
|
name: 'Default Schedule',
|
|
randomStartPoint: false,
|
|
shuffleScheduleItems: false,
|
|
treatCollectionsAsShows: false,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function library(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
id: 31,
|
|
itemCount: 0,
|
|
lastScan: null,
|
|
mediaKind: 'Movies',
|
|
name: 'Movies',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function mediaSource(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
connectionAddress: null,
|
|
id: 30,
|
|
kind: 'Local',
|
|
libraries: [],
|
|
name: 'Local',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
// PlayoutListItemResponseModel: rail rows have no detail-only fields (playoutMode/scheduleFile).
|
|
function listPlayout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
buildStatus: {
|
|
lastBuild: '2026-07-05T19:00:00Z',
|
|
message: null,
|
|
success: true
|
|
},
|
|
channelName: 'Retro Cartoons',
|
|
channelNumber: '5.1',
|
|
dailyRebuildTime: '04:00:00',
|
|
id: 20,
|
|
isLocked: false,
|
|
scheduleKind: 'Classic',
|
|
scheduleName: 'Prime Time Cartoons',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
// PlayoutResponseModel: the detail shape adds playoutMode/scheduleFile on top of the list row.
|
|
function playout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return listPlayout({
|
|
playoutMode: 'Continuous',
|
|
scheduleFile: null,
|
|
...overrides
|
|
});
|
|
}
|
|
|
|
// ---- Settings (#93) fixtures ----
|
|
function defaultFfmpegSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
defaultFFmpegProfileId: 1,
|
|
defaultMpegTsScript: '',
|
|
extractEmbeddedSubtitles: false,
|
|
fFmpegPath: '/usr/bin/ffmpeg',
|
|
fFprobePath: '/usr/bin/ffprobe',
|
|
globalFallbackFillerId: null,
|
|
globalWatermarkId: null,
|
|
hlsDirectOutputFormat: 'MpegTs',
|
|
hlsSegmenterIdleTimeout: 60,
|
|
initialSegmentCount: 1,
|
|
preferredAudioLanguageCode: 'eng',
|
|
probeForInterlacedFrames: true,
|
|
saveReports: false,
|
|
useEmbeddedSubtitles: true,
|
|
workAheadSegmenterLimit: 1,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function defaultPlayoutSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
daysToBuild: 2,
|
|
scriptedScheduleTimeoutSeconds: 30,
|
|
skipMissingItems: true,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function defaultXmltvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
blockBehavior: 'SplitTimeEvenly',
|
|
daysToBuild: 2,
|
|
timeZone: 'Local',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function defaultScannerSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
libraryRefreshInterval: 6,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function defaultLoggingSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
defaultMinimumLogLevel: 'Information',
|
|
httpMinimumLogLevel: 'Warning',
|
|
scanningMinimumLogLevel: 'Information',
|
|
schedulingMinimumLogLevel: 'Information',
|
|
searchingMinimumLogLevel: 'Information',
|
|
streamingMinimumLogLevel: 'Information',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function defaultUiSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
isDarkMode: true,
|
|
language: 'en-US',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function defaultHdhrSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
tunerCount: 2,
|
|
uuid: '6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function defaultIptvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
baseUrl: '',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function defaultResolutions(): Array<Record<string, unknown>> {
|
|
return [
|
|
{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 },
|
|
{ height: 720, id: 2, isCustom: false, name: '1280x720', width: 1280 }
|
|
];
|
|
}
|
|
|
|
// The backend always emits fillerKind: real content items carry 'None' (never null).
|
|
function playoutItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
duration: '00:30:00',
|
|
fillerKind: 'None',
|
|
finish: '2026-07-05T20:30:00Z',
|
|
start: '2026-07-05T20:00:00Z',
|
|
title: 'Saturday Morning Cartoons',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function scheduleItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
// Flat ScheduleItemResponseModel shape (issue #126/#207).
|
|
return {
|
|
id: 11,
|
|
index: 0,
|
|
startType: 'Dynamic',
|
|
startTime: null,
|
|
fixedStartTimeBehavior: null,
|
|
playoutMode: 'One',
|
|
collectionType: 'Collection',
|
|
collectionId: 2,
|
|
multiCollectionId: null,
|
|
smartCollectionId: null,
|
|
rerunCollectionId: null,
|
|
mediaItemId: null,
|
|
playlistId: null,
|
|
searchTitle: null,
|
|
searchQuery: null,
|
|
playbackOrder: 'Shuffle',
|
|
marathonGroupBy: 'None',
|
|
marathonShuffleGroups: false,
|
|
marathonShuffleItems: false,
|
|
marathonBatchSize: null,
|
|
fillWithGroupMode: 'None',
|
|
multipleMode: null,
|
|
multipleCount: null,
|
|
playoutDuration: null,
|
|
tailMode: null,
|
|
discardToFillAttempts: null,
|
|
customTitle: null,
|
|
guideMode: 'Normal',
|
|
preRollFillerId: null,
|
|
midRollFillerId: null,
|
|
postRollFillerId: null,
|
|
tailFillerId: null,
|
|
fallbackFillerId: null,
|
|
watermarkIds: [],
|
|
graphicsElementIds: [],
|
|
preferredAudioLanguageCode: null,
|
|
preferredAudioTitle: null,
|
|
preferredSubtitleLanguageCode: null,
|
|
subtitleMode: null,
|
|
collectionName: 'Saturday Cartoons',
|
|
multiCollectionName: null,
|
|
smartCollectionName: null,
|
|
rerunCollectionName: null,
|
|
playlistName: null,
|
|
playlistGroupId: null,
|
|
mediaItemName: null,
|
|
preRollFillerName: null,
|
|
midRollFillerName: null,
|
|
postRollFillerName: null,
|
|
tailFillerName: null,
|
|
fallbackFillerName: null,
|
|
watermarks: [],
|
|
graphicsElements: [],
|
|
name: 'Saturday Cartoons',
|
|
durationEstimate: '00:25:00',
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function mockDashboardApi({
|
|
addScheduleItemResponse = null,
|
|
channels = [],
|
|
channelStates = [],
|
|
playoutAlternateSchedules = [],
|
|
playoutTemplateItems = [],
|
|
templates = [],
|
|
decoTemplates = [],
|
|
ffmpegProfiles = [],
|
|
watermarks = [],
|
|
collections = [],
|
|
confirm = false,
|
|
fillerPresets = [],
|
|
health = [],
|
|
libraryScanStatusFailAfterTrigger = false,
|
|
libraryScanStatuses = [],
|
|
jellyfinConnection = null,
|
|
jellyfinState = null,
|
|
localLibraryDetail = null,
|
|
libraryScanStatusesAfterMutation = null,
|
|
libraryScanStatusSequence = null,
|
|
mediaSources = [],
|
|
mediaSourcesFailure = null,
|
|
mediaSourcesFailuresBeforeSuccess = 0,
|
|
multiCollections = [],
|
|
mutationFailures = {},
|
|
createPlayoutResponse = null,
|
|
updatePlayoutDetailsResponse = null,
|
|
playoutDetails = null,
|
|
playoutItems = [],
|
|
playoutItemsFailure = null,
|
|
playoutItemsFailuresBeforeSuccess = 0,
|
|
playoutItemsTotalCount = null,
|
|
playoutSchedulingContext = '{\n "sample": true\n}',
|
|
playoutSchedulingContextFailure = null,
|
|
playoutWarningsCount = 0,
|
|
playouts = { page: [], totalCount: 0 },
|
|
prompt = null,
|
|
replaceScheduleItemsResponse = null,
|
|
scheduleItemFailure = null,
|
|
scheduleItemFailuresBeforeSuccess = 0,
|
|
scheduleItems = [],
|
|
scheduleItemsAfterAdd = null,
|
|
scheduleItemsAfterDelete = null,
|
|
scheduleItemsTotalDuration = null,
|
|
schedules = [],
|
|
smartCollections = [],
|
|
version = { apiVersion: 3, appVersion: '26.4.0' }
|
|
}: {
|
|
addScheduleItemResponse?: unknown;
|
|
channels?: unknown[];
|
|
channelStates?: unknown[];
|
|
playoutAlternateSchedules?: unknown[];
|
|
playoutTemplateItems?: unknown[];
|
|
templates?: unknown[];
|
|
decoTemplates?: unknown[];
|
|
ffmpegProfiles?: unknown[];
|
|
watermarks?: unknown[];
|
|
collections?: unknown[];
|
|
confirm?: boolean;
|
|
fillerPresets?: unknown[];
|
|
health?: unknown[];
|
|
libraryScanStatusFailAfterTrigger?: boolean;
|
|
libraryScanStatuses?: unknown[];
|
|
jellyfinConnection?: { address: string | null; hasApiKey: boolean } | null;
|
|
jellyfinState?: unknown;
|
|
localLibraryDetail?: unknown;
|
|
libraryScanStatusesAfterMutation?: unknown[] | null;
|
|
libraryScanStatusSequence?: unknown[][] | null;
|
|
mediaSources?: unknown[];
|
|
mediaSourcesFailure?: unknown;
|
|
mediaSourcesFailuresBeforeSuccess?: number;
|
|
multiCollections?: unknown[];
|
|
mutationFailures?: Record<string, unknown>;
|
|
createPlayoutResponse?: unknown;
|
|
updatePlayoutDetailsResponse?: unknown;
|
|
playoutDetails?: unknown;
|
|
playoutItems?: unknown[];
|
|
playoutItemsFailure?: unknown;
|
|
playoutItemsFailuresBeforeSuccess?: number;
|
|
playoutItemsTotalCount?: number | null;
|
|
playoutSchedulingContext?: string;
|
|
playoutSchedulingContextFailure?: { detail?: string; status?: number; title?: string } | null;
|
|
playoutWarningsCount?: number;
|
|
playouts?: unknown;
|
|
prompt?: string | null;
|
|
replaceScheduleItemsResponse?: unknown;
|
|
scheduleItemFailure?: unknown;
|
|
scheduleItemFailuresBeforeSuccess?: number;
|
|
scheduleItems?: unknown[];
|
|
scheduleItemsAfterAdd?: unknown[] | null;
|
|
scheduleItemsAfterDelete?: unknown[] | null;
|
|
scheduleItemsTotalDuration?: string | null;
|
|
schedules?: unknown[];
|
|
smartCollections?: unknown[];
|
|
version?: unknown;
|
|
} = {}) {
|
|
vi.spyOn(window, 'confirm').mockReturnValue(confirm);
|
|
vi.spyOn(window, 'prompt').mockReturnValue(prompt);
|
|
let currentLibraryScanStatuses = libraryScanStatuses;
|
|
let currentJellyfinConnection = jellyfinConnection;
|
|
let currentScheduleItems = scheduleItems;
|
|
const settingsResponses: Record<string, unknown> = {
|
|
'/api/v1/settings/ffmpeg': defaultFfmpegSettings(),
|
|
'/api/v1/settings/hdhr': defaultHdhrSettings(),
|
|
'/api/v1/settings/iptv': defaultIptvSettings(),
|
|
'/api/v1/settings/logging': defaultLoggingSettings(),
|
|
'/api/v1/settings/playout': defaultPlayoutSettings(),
|
|
'/api/v1/settings/resolutions': defaultResolutions(),
|
|
'/api/v1/settings/scanner': defaultScannerSettings(),
|
|
'/api/v1/settings/ui': defaultUiSettings(),
|
|
'/api/v1/settings/xmltv': defaultXmltvSettings()
|
|
};
|
|
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
|
|
let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess;
|
|
let remainingPlayoutItemsFailures = playoutItemsFailuresBeforeSuccess;
|
|
let scanStatusSequenceIndex = 0;
|
|
let scanStatusShouldFail = false;
|
|
|
|
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
|
const path = input.toString();
|
|
|
|
if (path === '/api/v1/channels') {
|
|
return Promise.resolve(jsonResponse(channels));
|
|
}
|
|
|
|
if (path === '/api/v1/channels/state') {
|
|
return Promise.resolve(jsonResponse(channelStates));
|
|
}
|
|
|
|
if (path === '/api/v1/channels/from-lineup') {
|
|
return Promise.resolve(
|
|
jsonResponse({ channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 }, 201)
|
|
);
|
|
}
|
|
|
|
if (path.startsWith('/api/v1/library/browse')) {
|
|
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
|
}
|
|
|
|
if (path === '/api/v1/channel-templates') {
|
|
return Promise.resolve(jsonResponse([]));
|
|
}
|
|
|
|
if (path === '/api/v1/channel-templates/default') {
|
|
return Promise.resolve(jsonResponse(null, 404));
|
|
}
|
|
|
|
if (path === '/api/v1/ffmpeg/profiles') {
|
|
return Promise.resolve(jsonResponse(ffmpegProfiles));
|
|
}
|
|
|
|
if (path === '/api/v1/artwork/uploads') {
|
|
return Promise.resolve(jsonResponse({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' }, 201));
|
|
}
|
|
|
|
if (path.startsWith('/api/v1/guide?')) {
|
|
const guideUrl = new URL(path, window.location.origin);
|
|
return Promise.resolve(jsonResponse({
|
|
channels: [],
|
|
end: guideUrl.searchParams.get('end'),
|
|
start: guideUrl.searchParams.get('start')
|
|
}));
|
|
}
|
|
|
|
if (path === '/api/v1/schedules') {
|
|
return Promise.resolve(jsonResponse(schedules));
|
|
}
|
|
|
|
if (path.match(/^\/api\/v1\/schedules\/\d+\/items$/)) {
|
|
const method = init?.method ?? 'GET';
|
|
|
|
if (method !== 'GET' && path in mutationFailures) {
|
|
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
|
|
}
|
|
|
|
if (method === 'POST') {
|
|
currentScheduleItems = scheduleItemsAfterAdd ?? (addScheduleItemResponse ? [...currentScheduleItems, addScheduleItemResponse] : currentScheduleItems);
|
|
return Promise.resolve(jsonResponse(addScheduleItemResponse ?? null, 201));
|
|
}
|
|
|
|
if (method === 'PUT') {
|
|
currentScheduleItems = (replaceScheduleItemsResponse as unknown[] | null) ?? currentScheduleItems;
|
|
return Promise.resolve(jsonResponse(currentScheduleItems));
|
|
}
|
|
|
|
if (remainingScheduleItemFailures > 0) {
|
|
remainingScheduleItemFailures -= 1;
|
|
return Promise.resolve(jsonResponse(scheduleItemFailure, 404));
|
|
}
|
|
|
|
return Promise.resolve(jsonResponse({
|
|
items: currentScheduleItems,
|
|
totalDurationEstimate: scheduleItemsTotalDuration
|
|
}));
|
|
}
|
|
|
|
if (path.match(/^\/api\/v1\/schedules\/\d+\/items\/\d+$/)) {
|
|
if (path in mutationFailures) {
|
|
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
|
|
}
|
|
|
|
currentScheduleItems = scheduleItemsAfterDelete ?? currentScheduleItems;
|
|
return Promise.resolve(new Response(null, { status: 204 }));
|
|
}
|
|
|
|
if (path === '/api/v1/collections') {
|
|
return Promise.resolve(jsonResponse(collections));
|
|
}
|
|
|
|
if (path === '/api/v1/smart-collections') {
|
|
return Promise.resolve(jsonResponse(smartCollections));
|
|
}
|
|
|
|
if (path === '/api/v1/multi-collections') {
|
|
return Promise.resolve(jsonResponse(multiCollections));
|
|
}
|
|
|
|
if (path.startsWith('/api/v1/filler-presets')) {
|
|
return Promise.resolve(jsonResponse(fillerPresets));
|
|
}
|
|
|
|
if (path === '/api/v1/watermarks') {
|
|
return Promise.resolve(jsonResponse(watermarks));
|
|
}
|
|
|
|
// Schedule-editor pickers (issue #207): empty defaults are enough for the App-level route tests.
|
|
if (path.startsWith('/api/v1/rerun-collections')) {
|
|
return Promise.resolve(jsonResponse({ totalCount: 0, page: [] }));
|
|
}
|
|
|
|
if (path === '/api/v1/playlists/groups') {
|
|
return Promise.resolve(jsonResponse([]));
|
|
}
|
|
|
|
if (path === '/api/v1/graphics-elements') {
|
|
return Promise.resolve(jsonResponse([]));
|
|
}
|
|
|
|
if (path === '/api/v1/languages') {
|
|
return Promise.resolve(jsonResponse([]));
|
|
}
|
|
|
|
if (path === '/api/v1/media-sources/jellyfin/connection') {
|
|
if ((init?.method ?? 'GET') === 'PUT') {
|
|
const body = JSON.parse(String(init?.body ?? '{}')) as { address?: string };
|
|
currentJellyfinConnection = { address: body.address ?? null, hasApiKey: true };
|
|
return Promise.resolve(jsonResponse(currentJellyfinConnection));
|
|
}
|
|
|
|
return Promise.resolve(
|
|
currentJellyfinConnection
|
|
? jsonResponse(currentJellyfinConnection)
|
|
: jsonResponse(null, 404)
|
|
);
|
|
}
|
|
|
|
if (path === '/api/v1/media-sources/jellyfin') {
|
|
return Promise.resolve(jsonResponse(jellyfinState ?? {
|
|
isAuthorized: false,
|
|
isLocked: false,
|
|
servers: []
|
|
}));
|
|
}
|
|
|
|
if (path === '/api/v1/media-sources') {
|
|
if (remainingMediaSourcesFailures > 0) {
|
|
remainingMediaSourcesFailures -= 1;
|
|
|
|
return Promise.resolve(
|
|
mediaSourcesFailure ? jsonResponse(mediaSourcesFailure, 500) : new Response(null, { status: 500 })
|
|
);
|
|
}
|
|
|
|
return Promise.resolve(jsonResponse(mediaSources));
|
|
}
|
|
|
|
if (path.startsWith('/api/v1/libraries/local/')) {
|
|
if (localLibraryDetail) {
|
|
return Promise.resolve(jsonResponse(localLibraryDetail));
|
|
}
|
|
return Promise.resolve(new Response(null, { status: 404 }));
|
|
}
|
|
|
|
if (path === '/api/v1/libraries/scan-status') {
|
|
if (libraryScanStatusFailAfterTrigger && scanStatusShouldFail) {
|
|
return Promise.resolve(new Response(null, { status: 500 }));
|
|
}
|
|
|
|
if (libraryScanStatusSequence) {
|
|
const sequenceValue = libraryScanStatusSequence[Math.min(scanStatusSequenceIndex, libraryScanStatusSequence.length - 1)];
|
|
scanStatusSequenceIndex += 1;
|
|
currentLibraryScanStatuses = sequenceValue;
|
|
return Promise.resolve(jsonResponse(sequenceValue));
|
|
}
|
|
|
|
return Promise.resolve(jsonResponse(currentLibraryScanStatuses));
|
|
}
|
|
|
|
if (path.match(/^\/api\/v1\/libraries\/\d+\/scan$/)) {
|
|
if (path in mutationFailures) {
|
|
const failure = mutationFailures[path] as { detail?: string; status?: number; title?: string };
|
|
const status = failure.status ?? 422;
|
|
|
|
if (failure.detail || failure.title) {
|
|
return Promise.resolve(jsonResponse(failure, status));
|
|
}
|
|
|
|
return Promise.resolve(new Response(null, { status }));
|
|
}
|
|
|
|
currentLibraryScanStatuses = libraryScanStatusesAfterMutation ?? currentLibraryScanStatuses;
|
|
|
|
if (libraryScanStatusFailAfterTrigger) {
|
|
scanStatusShouldFail = true;
|
|
}
|
|
|
|
// Backend contract (#232): a successful queue is 202 Accepted, not a lying 200.
|
|
return Promise.resolve(new Response(null, { status: 202 }));
|
|
}
|
|
|
|
if (path === '/api/v1/playouts') {
|
|
const method = init?.method ?? 'GET';
|
|
|
|
if (method === 'POST') {
|
|
if (path in mutationFailures) {
|
|
const failure = mutationFailures[path] as { status?: number };
|
|
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
|
}
|
|
|
|
return Promise.resolve(jsonResponse(createPlayoutResponse ?? playout({ id: 99 }), 201));
|
|
}
|
|
|
|
return Promise.resolve(jsonResponse(playouts));
|
|
}
|
|
|
|
if (path === '/api/v1/playouts/warnings/count') {
|
|
return Promise.resolve(jsonResponse(playoutWarningsCount));
|
|
}
|
|
|
|
if (path === '/api/v1/playouts/reset-all') {
|
|
if (path in mutationFailures) {
|
|
const failure = mutationFailures[path] as { status?: number };
|
|
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
|
}
|
|
|
|
// Backend contract: POST /api/v1/playouts/reset-all is an unconditional 202 Accepted.
|
|
return Promise.resolve(new Response(null, { status: 202 }));
|
|
}
|
|
|
|
if (path.match(/^\/api\/v1\/playouts\/\d+\/alternate-schedules$/)) {
|
|
return Promise.resolve(jsonResponse(playoutAlternateSchedules));
|
|
}
|
|
|
|
if (path.match(/^\/api\/v1\/playouts\/\d+\/templates$/)) {
|
|
return Promise.resolve(jsonResponse(playoutTemplateItems));
|
|
}
|
|
|
|
if (path.match(/^\/api\/v1\/playouts\/\d+\/erase-items(-and-history)?$/)) {
|
|
if (path in mutationFailures) {
|
|
const failure = mutationFailures[path] as { status?: number };
|
|
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
|
}
|
|
return Promise.resolve(new Response(null, { status: 204 }));
|
|
}
|
|
|
|
if (path.match(/^\/api\/v1\/playouts\/items\/\d+\/scheduling-context$/)) {
|
|
if (playoutSchedulingContextFailure) {
|
|
return Promise.resolve(
|
|
jsonResponse(playoutSchedulingContextFailure, playoutSchedulingContextFailure.status ?? 404)
|
|
);
|
|
}
|
|
return Promise.resolve(jsonResponse({ context: playoutSchedulingContext }));
|
|
}
|
|
|
|
if (path === '/api/v1/templates') {
|
|
return Promise.resolve(jsonResponse(templates));
|
|
}
|
|
|
|
if (path === '/api/v1/deco-templates') {
|
|
return Promise.resolve(jsonResponse(decoTemplates));
|
|
}
|
|
|
|
const itemsMatch = path.match(/^\/api\/v1\/playouts\/\d+\/items(\?(?<query>.*))?$/);
|
|
|
|
if (itemsMatch) {
|
|
if (remainingPlayoutItemsFailures > 0) {
|
|
remainingPlayoutItemsFailures -= 1;
|
|
return Promise.resolve(jsonResponse(playoutItemsFailure, 404));
|
|
}
|
|
|
|
// Mirror the backend: filler items are only included when ?showFiller=true is sent.
|
|
const showFiller = new URLSearchParams(itemsMatch.groups?.query ?? '').get('showFiller') === 'true';
|
|
const page = showFiller
|
|
? playoutItems
|
|
: playoutItems.filter((item) => {
|
|
const fillerKind = (item as { fillerKind?: null | string }).fillerKind;
|
|
return fillerKind == null || fillerKind === 'None';
|
|
});
|
|
|
|
return Promise.resolve(jsonResponse({
|
|
page,
|
|
totalCount: playoutItemsTotalCount ?? page.length
|
|
}));
|
|
}
|
|
|
|
if (path.match(/^\/api\/v1\/playouts\/\d+$/)) {
|
|
const method = init?.method ?? 'GET';
|
|
|
|
if (method === 'PUT') {
|
|
if (path in mutationFailures) {
|
|
const failure = mutationFailures[path] as { status?: number };
|
|
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
|
}
|
|
|
|
return Promise.resolve(jsonResponse(
|
|
updatePlayoutDetailsResponse ?? playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })
|
|
));
|
|
}
|
|
|
|
if (method === 'DELETE') {
|
|
if (path in mutationFailures) {
|
|
const failure = mutationFailures[path] as { status?: number };
|
|
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
|
}
|
|
return Promise.resolve(new Response(null, { status: 204 }));
|
|
}
|
|
|
|
return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
|
|
}
|
|
|
|
if (path.startsWith('/api/v1/health')) {
|
|
return Promise.resolve(jsonResponse(health));
|
|
}
|
|
|
|
if (path === '/api/v1/version') {
|
|
return Promise.resolve(jsonResponse(version));
|
|
}
|
|
|
|
if (path in settingsResponses) {
|
|
return Promise.resolve(jsonResponse(settingsResponses[path]));
|
|
}
|
|
|
|
if (path in mutationFailures) {
|
|
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
|
|
}
|
|
|
|
if (
|
|
path === '/api/v1/channels/bulk/renumber' ||
|
|
path === '/api/v1/channels/bulk/group' ||
|
|
path === '/api/v1/channels/bulk/delete' ||
|
|
path.startsWith('/api/v1/channels/')
|
|
) {
|
|
return Promise.resolve(new Response(null, { status: 204 }));
|
|
}
|
|
|
|
return Promise.resolve(jsonResponse(null, 404));
|
|
});
|
|
}
|