Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7257c6c23c |
+53
-1312
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user