Files
ersatztv/web/src/api/playouts.test.ts
T
timothyandClaude Fable 5 91e23bcd57 feat(spa): wire playout delete/reset/erase + scheduling context + templates preview calendar (#210)
Adds the SPA slice for the newly-merged playout endpoints:
- api/playouts: deletePlayout, resetChannelPlayout, erasePlayoutItems,
  erasePlayoutItemsAndHistory, getPlayoutItemSchedulingContext (+ types).
- PlayoutsScreen: enable per-playout Reset (channel playout reset), Delete
  (danger, confirm), and erase-by-kind buttons (Block = items + items&history;
  Classic/Sequential/Scripted = items&history; none for ExternalJson); replaces
  the stale "no per-playout reset endpoint" block. Disable Alternate schedules
  for OnDemand Classic playouts (Blazor parity).
- Per-item scheduling-context dialog (info button when hasSchedulingContext &&
  id != null), rendering the decoded JSON in a scrollable <pre>.
- Playout-templates editor: "Preview calendar" toggle with a Monday-first month
  grid; winning template per day computed client-side via a colocated
  appliesToDate/firstMatchingIndex helper that ports
  AlternateScheduleSelector.GetScheduleForDate / PlayoutTemplateEditViewModel
  .AppliesToDate exactly (inclusive range, year-wrap reverse, start-day rollover,
  end-day clamp, explicit-year override).
- Button gains a title prop (parity with IconButton) for the disabled-button hint.

Tests: api client URL/method assertions, PlayoutsScreen action flows
(delete/reset/erase per kind, OnDemand gating, scheduling-context dialog +
error), calendar-helper unit tests, and a preview-calendar render test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:30:28 +02:00

247 lines
8.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createPlayout,
deletePlayout,
erasePlayoutItems,
erasePlayoutItemsAndHistory,
getAlternateSchedules,
getPlayoutItemSchedulingContext,
getPlayoutTemplates,
replaceAlternateSchedules,
replacePlayoutTemplates,
resetChannelPlayout,
updatePlayoutDefaultDeco,
updatePlayoutDetails,
type CreatePlayoutRequest
} from './playouts';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function noContentResponse(): Response {
return new Response(null, { status: 204 });
}
describe('playouts api client', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('createPlayout POSTs a classic playout request', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 7 }, 201));
const body: CreatePlayoutRequest = {
channelId: 1,
programScheduleId: 2,
scheduleFile: null,
scheduleKind: 'Classic'
};
await expect(createPlayout(body)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toMatchObject({
channelId: 1,
programScheduleId: 2,
scheduleKind: 'Classic'
});
});
it('createPlayout POSTs a sequential playout request with a schedule file', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 9 }, 201));
const body: CreatePlayoutRequest = {
channelId: 3,
programScheduleId: null,
scheduleFile: '/config/schedule.yml',
scheduleKind: 'Sequential'
};
await createPlayout(body);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toMatchObject({
channelId: 3,
scheduleFile: '/config/schedule.yml',
scheduleKind: 'Sequential'
});
});
it('updatePlayoutDetails PUTs to the id route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
await updatePlayoutDetails(3, { dailyRebuildTime: '04:00:00', scheduleFile: null });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/3');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: '04:00:00' });
});
it('updatePlayoutDetails sends null dailyRebuildTime to clear the daily reset', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
await updatePlayoutDetails(3, { dailyRebuildTime: null, scheduleFile: null });
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: null });
});
it('updatePlayoutDefaultDeco PUTs the deco id to the deco route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
await updatePlayoutDefaultDeco(3, 7);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/3/deco');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toMatchObject({ decoId: 7 });
});
it('deletePlayout DELETEs the id route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await expect(deletePlayout(4)).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/4');
expect(init).toMatchObject({ method: 'DELETE' });
});
it('resetChannelPlayout POSTs to the channel playout reset route without a mode', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('12.3');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/12.3/playout/reset');
expect(init).toMatchObject({ method: 'POST' });
});
it('resetChannelPlayout url-encodes the channel number', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('a b');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/a%20b/playout/reset');
});
it('erasePlayoutItems POSTs to the erase-items route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await erasePlayoutItems(6);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/6/erase-items');
expect(init).toMatchObject({ method: 'POST' });
});
it('erasePlayoutItemsAndHistory POSTs to the erase-items-and-history route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await erasePlayoutItemsAndHistory(6);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/6/erase-items-and-history');
expect(init).toMatchObject({ method: 'POST' });
});
it('getPlayoutItemSchedulingContext GETs the item scheduling-context route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ context: '{ "x": 1 }' }));
await expect(getPlayoutItemSchedulingContext(42)).resolves.toMatchObject({ context: '{ "x": 1 }' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/items/42/scheduling-context');
expect(init?.method ?? 'GET').toBe('GET');
});
it('getAlternateSchedules GETs the alternate-schedules route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getAlternateSchedules(5);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/5/alternate-schedules');
expect(init?.method ?? 'GET').toBe('GET');
});
it('replaceAlternateSchedules PUTs items with day-name strings', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await replaceAlternateSchedules(5, {
items: [
{
id: 0,
programScheduleId: 2,
daysOfWeek: ['Monday', 'Tuesday'],
daysOfMonth: [1, 2],
monthsOfYear: [1],
limitToDateRange: false,
startMonth: 1,
startDay: 1,
startYear: null,
endMonth: 12,
endDay: 31,
endYear: null
}
]
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/5/alternate-schedules');
expect(init).toMatchObject({ method: 'PUT' });
const body = JSON.parse(String(init?.body));
expect(body.items[0].daysOfWeek).toEqual(['Monday', 'Tuesday']);
expect(body.items[0].programScheduleId).toBe(2);
});
it('getPlayoutTemplates GETs the templates route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await getPlayoutTemplates(5);
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/5/templates');
});
it('replacePlayoutTemplates PUTs template items', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
await replacePlayoutTemplates(5, {
items: [
{
id: 0,
templateId: 3,
decoTemplateId: null,
daysOfWeek: ['Sunday'],
daysOfMonth: [1],
monthsOfYear: [1],
limitToDateRange: false,
startMonth: 1,
startDay: 1,
startYear: null,
endMonth: 12,
endDay: 31,
endYear: null
}
]
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/5/templates');
expect(init).toMatchObject({ method: 'PUT' });
const body = JSON.parse(String(init?.body));
expect(body.items[0].templateId).toBe(3);
expect(body.items[0].decoTemplateId).toBeNull();
});
});