Compare commits

...
Author SHA1 Message Date
timothyandOpenAI Codex 7257c6c23c test(web): colocate screen behavior coverage (#247)
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:50:17 +02:00
4 changed files with 1542 additions and 1330 deletions
+53 -1312
View File
File diff suppressed because it is too large Load Diff
+796 -18
View File
@@ -1,5 +1,6 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyDesignSystemTheme } from '../designSystem';
import { ChannelBuilderScreen } from './ChannelBuilder';
function jsonResponse(body: unknown, status = 200): Response {
@@ -9,42 +10,208 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
// Mocks every endpoint the builder loads on mount so it can render its library
// browser, plus /api/v1/library/browse which the browse hook fans out across.
function mockBuilderApi() {
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
// ---- 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));
}
if (url.startsWith('/api/v1/library/browse')) {
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
// 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>;
}
function mockBuilderApi({
artworkUploadFailure = null,
browseHandler = null,
browseItems = [],
channels = [],
channelTemplates = [],
createTemplateResponse = null,
defaultChannelTemplate = null,
ffmpegProfiles = [],
fromLineupFailure = null,
fromLineupResponse = { channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 }
}: {
artworkUploadFailure?: { status: number } | null;
browseHandler?: ((search: URLSearchParams) => { page: unknown[]; totalCount: number }) | null;
browseItems?: unknown[];
channels?: unknown[];
channelTemplates?: unknown[];
createTemplateResponse?: unknown;
defaultChannelTemplate?: unknown;
ffmpegProfiles?: unknown[];
fromLineupFailure?: { detail?: string; status?: number; title?: string } | null;
fromLineupResponse?: unknown;
} = {}) {
let currentChannelTemplates = channelTemplates;
return 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 (url === '/api/v1/channel-templates/default') {
// Builder treats a 404 here as "no default template".
return Promise.resolve(jsonResponse({ status: 404, title: 'Not Found' }, 404));
if (path === '/api/v1/channels/from-lineup') {
if (fromLineupFailure) {
return Promise.resolve(jsonResponse(fromLineupFailure, fromLineupFailure.status ?? 422));
}
return Promise.resolve(jsonResponse(fromLineupResponse, 201));
}
// channels, channel-templates, ffmpeg/profiles, filler-presets, watermarks, media-sources
return Promise.resolve(jsonResponse([]));
if (path.startsWith('/api/v1/library/browse')) {
const search = new URL(path, window.location.origin).searchParams;
if (browseHandler) {
return Promise.resolve(jsonResponse(browseHandler(search)));
}
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/v1/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/v1/channel-templates/default') {
return Promise.resolve(
defaultChannelTemplate ? jsonResponse(defaultChannelTemplate) : jsonResponse(null, 404)
);
}
if (path === '/api/v1/ffmpeg/profiles') {
return Promise.resolve(jsonResponse(ffmpegProfiles));
}
if (path.startsWith('/api/v1/filler-presets')) {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/watermarks' || path === '/api/v1/media-sources') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/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)
);
}
return Promise.resolve(jsonResponse(null, 404));
});
}
function browseMediaTypes(fetchMock: ReturnType<typeof mockBuilderApi>): string[] {
return fetchMock.mock.calls
.map(([u]) => u.toString())
.filter((u) => u.startsWith('/api/v1/library/browse'))
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
.map(([url]) => url.toString())
.filter((url) => url.startsWith('/api/v1/library/browse'))
.map((url) => new URL(url, 'http://localhost').searchParams.get('mediaType'))
.filter((mediaType): mediaType is string => mediaType != null && mediaType !== '');
}
describe('ChannelBuilder library browse', () => {
describe('Channel Builder (#89)', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
window.history.replaceState(null, '', '/');
});
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app/new-channel');
vi.restoreAllMocks();
});
it('fans out over movies/shows/artists and never requests TelevisionSeason', async () => {
@@ -52,7 +219,6 @@ describe('ChannelBuilder library browse', () => {
render(<ChannelBuilderScreen />);
// Wait until the initial library browse fan-out has fired.
await waitFor(() => {
expect(browseMediaTypes(fetchMock).length).toBeGreaterThan(0);
});
@@ -61,4 +227,616 @@ describe('ChannelBuilder library browse', () => {
expect(types).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
expect(types.has('TelevisionSeason')).toBe(false);
});
// ---- Channel Builder (#89) -----------------------------------------------
const renderBuilder = async () => {
window.history.replaceState(null, '', '/app/new-channel');
const utils = render(<ChannelBuilderScreen />);
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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({ ...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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({ ...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 () => {
mockBuilderApi({ ...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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({
...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/v1/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 () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({ ...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/v1/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/v1/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
})
};
mockBuilderApi({
...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/v1/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/v1/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' })]
];
mockBuilderApi({
...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
});
mockBuilderApi({
...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/v1/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 () => {
mockBuilderApi({
...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/v1/channels/from-lineup')
).toBe(false);
expect(createBtn()).toBeEnabled();
});
it('renders the empty-templates state and keeps Create disabled with no templates', async () => {
mockBuilderApi({
...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 () => {
mockBuilderApi({
...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/v1/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 () => {
mockBuilderApi({ ...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();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProgressBar } from './dataDisplay';
import { Toast, Tooltip } from './feedback';
import { Button, Checkbox, Input, Switch } from './forms';
import { Tabs } from './navigation';
describe('component primitives', () => {
afterEach(cleanup);
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();
});
});
+631
View File
@@ -0,0 +1,631 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingsScreen } from './SettingsScreen';
describe('Settings screen (#93)', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
window.history.replaceState(null, '', '/');
});
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app/settings/general');
vi.restoreAllMocks();
});
const openSettings = async () => {
render(<SettingsScreen />);
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 () => {
mockSettingsApi();
render(<SettingsScreen />);
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 () => {
mockSettingsApi();
window.history.replaceState(null, '', '/app/settings/scanner');
render(<SettingsScreen />);
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
});
it('loads values from the API into the General pane', async () => {
mockSettingsApi({ 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 () => {
mockSettingsApi();
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 });
mockSettingsApi();
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/v1/settings/ui')).toMatchObject({ language: 'de-DE' });
expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/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 () => {
mockSettingsApi({ 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 () => {
mockSettingsApi();
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/v1/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 () => {
mockSettingsApi();
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 () => {
mockSettingsApi({
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 () => {
mockSettingsApi();
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 () => {
mockSettingsApi({ 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/v1/settings/ui')).toMatchObject({ language: 'nl-BE' });
});
it('shows the error state with a working retry when a settings group (tier-1) fails', async () => {
mockSettingsApi({ settingsGetFailuresBeforeSuccess: { '/api/v1/settings/scanner': 1 } });
render(<SettingsScreen />);
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 () => {
mockSettingsApi();
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/v1/settings/ui')).toMatchObject({ language: 'de-DE' });
expect(requestBodyFor('/api/v1/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/v1/settings/ui')).toBe(1);
expect(putCallCount('/api/v1/settings/playout')).toBe(1);
expect(putCallCount('/api/v1/settings/ffmpeg')).toBe(0);
expect(putCallCount('/api/v1/settings/xmltv')).toBe(0);
expect(putCallCount('/api/v1/settings/scanner')).toBe(0);
expect(putCallCount('/api/v1/settings/logging')).toBe(0);
expect(putCallCount('/api/v1/settings/hdhr')).toBe(0);
});
it('on partial save failure, keeps only the failed group dirty and surfaces its error', async () => {
mockSettingsApi({
settingsMutationFailures: { '/api/v1/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 () => {
mockSettingsApi({ 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 () => {
mockSettingsApi({ 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 () => {
mockSettingsApi();
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 () => {
mockSettingsApi();
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 () => {
mockSettingsApi({ 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 () => {
mockSettingsApi({ 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/v1/settings/ffmpeg')).toMatchObject({ globalWatermarkId: 5 });
});
it('renders the most recent last-scan time per media source', async () => {
mockSettingsApi({
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 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
};
}
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>;
}
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 }
];
}
function mockSettingsApi({
ffmpegProfiles = [],
ffmpegSettings = defaultFfmpegSettings(),
hdhrSettings = defaultHdhrSettings(),
loggingSettings = defaultLoggingSettings(),
mediaSources = [],
mediaSourcesFailuresBeforeSuccess = 0,
playoutSettings = defaultPlayoutSettings(),
resolutionCreateFailure = null,
resolutionDeleteFailure = null,
resolutions = defaultResolutions(),
scannerSettings = defaultScannerSettings(),
settingsGetFailuresBeforeSuccess = {},
settingsMutationFailures = {},
uiSettings = defaultUiSettings(),
watermarks = [],
xmltvSettings = defaultXmltvSettings()
}: {
ffmpegProfiles?: unknown[];
ffmpegSettings?: Record<string, unknown>;
hdhrSettings?: Record<string, unknown>;
loggingSettings?: Record<string, unknown>;
mediaSources?: unknown[];
mediaSourcesFailuresBeforeSuccess?: number;
playoutSettings?: Record<string, unknown>;
resolutionCreateFailure?: { detail?: string; status?: number; title?: string } | null;
resolutionDeleteFailure?: { detail?: string; status?: number; title?: string } | null;
resolutions?: Array<Record<string, unknown>>;
scannerSettings?: Record<string, unknown>;
settingsGetFailuresBeforeSuccess?: Record<string, number>;
settingsMutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
uiSettings?: Record<string, unknown>;
watermarks?: unknown[];
xmltvSettings?: Record<string, unknown>;
} = {}) {
let currentResolutions = resolutions;
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
const remainingSettingsGetFailures = { ...settingsGetFailuresBeforeSuccess };
const currentSettings: Record<string, Record<string, unknown>> = {
'/api/v1/settings/ffmpeg': ffmpegSettings,
'/api/v1/settings/hdhr': hdhrSettings,
'/api/v1/settings/logging': loggingSettings,
'/api/v1/settings/playout': playoutSettings,
'/api/v1/settings/scanner': scannerSettings,
'/api/v1/settings/ui': uiSettings,
'/api/v1/settings/xmltv': xmltvSettings
};
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const path = input.toString();
const method = init?.method ?? 'GET';
if (path in currentSettings) {
if (method === 'GET' && (remainingSettingsGetFailures[path] ?? 0) > 0) {
remainingSettingsGetFailures[path] -= 1;
return Promise.resolve(new Response(null, { status: 500 }));
}
if (method === 'PUT') {
const failure = settingsMutationFailures[path];
if (failure) {
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentSettings[path] = {
...currentSettings[path],
...JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
};
}
return Promise.resolve(jsonResponse(currentSettings[path]));
}
if (path === '/api/v1/settings/resolutions') {
if (method === 'POST') {
if (resolutionCreateFailure) {
return Promise.resolve(
jsonResponse(resolutionCreateFailure, resolutionCreateFailure.status ?? 422)
);
}
const body = JSON.parse(String(init?.body ?? '{}')) 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\/v1\/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 === '/api/v1/media-sources') {
if (remainingMediaSourcesFailures > 0) {
remainingMediaSourcesFailures -= 1;
return Promise.resolve(new Response(null, { status: 500 }));
}
return Promise.resolve(jsonResponse(mediaSources));
}
if (path === '/api/v1/ffmpeg/profiles') {
return Promise.resolve(jsonResponse(ffmpegProfiles));
}
if (path === '/api/v1/watermarks') {
return Promise.resolve(jsonResponse(watermarks));
}
if (path.startsWith('/api/v1/filler-presets')) {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/version') {
return Promise.resolve(jsonResponse({ apiVersion: 3, appVersion: '26.4.0' }));
}
if (path === '/api/v1/health') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(jsonResponse(null, 404));
});
}