Files
ersatztv/web/src/api/autoTune.test.ts
T
timothy f44eee85c5 feat(386): Auto-Tune per-channel DetailPanel slide-over (SPA)
Adds a right-hand "Configure" slide-over to each Auto-Tune Preview row, making
a proposed channel editable before bulk-create — against the shipped #384/#385
backend only, so no control lacks a wire target.

- New reusable SlideOver primitive (components/overlay.tsx), sharing a
  useOverlayBehavior hook (focus/scroll-lock/Escape/scrim) with Dialog.
- Extract the Channel Builder's advanced-options model to builder/advancedOptions.tsx
  (enum catalogs, ADVANCED_KEYS, effectiveValue, INHERIT/omit useAdvancedOverrides
  hook); ChannelBuilder imports it unchanged (its tests pass byte-for-byte). The
  DetailPanel writes its own field JSX over the same hook — shared logic, per-screen
  layout.
- Panes: identity (name/number + logo upload), Playback (Shuffle/Always-playing →
  advanced.playbackOrder/playoutMode), per-channel template picker, Advanced
  disclosure, lean read-only Query&size, read-only Content-sources via GET /members.
- getAutoTuneChannelMembers API client (#384 read endpoint) + tests.
- Screen-scoped §8 unsaved-changes guard + "Edited" row badge.
- Dropped as backend-less decoration: MiniEpg, bug-initials generator, query text.
  Deferred to #425 with an in-pane hint: per-source weight steppers + corrections.
- Docs: spa-conventions §11 (SlideOver + shared advanced-options), decisions.md.

Refs #386
2026-07-18 01:31:40 +02:00

181 lines
6.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createAutoTunedChannels, getAutoTuneChannelMembers, previewAutoTune } from './autoTune';
const sampleMembers = {
totalCount: 3,
page: [
{ id: 1, mediaType: 'TelevisionShow', title: 'The Office', artwork: '/artwork/1' },
{ id: 2, mediaType: 'TelevisionShow', title: 'Parks and Rec', artwork: '/artwork/2' }
]
};
const sampleProposals = [
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501', itemCount: 640, alreadyExists: true }
];
const sampleResult = {
results: [{ name: 'The Office', status: 'Created', channelId: 80, reason: null }],
createdCount: 1,
skippedCount: 0,
failedCount: 0
};
describe('previewAutoTune', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs /api/v1/channels/auto-tune/preview with the axes/minItems/startingNumber body', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleProposals), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(
previewAutoTune({ axes: ['TvShow', 'TvGenre'], minItems: 5, startingNumber: 500 })
).resolves.toHaveLength(2);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/channels/auto-tune/preview');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({
axes: ['TvShow', 'TvGenre'],
minItems: 5,
startingNumber: 500
});
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
);
await expect(
previewAutoTune({ axes: ['TvShow'], minItems: 1, startingNumber: 1 })
).rejects.toMatchObject({ status: 500 });
});
});
describe('createAutoTunedChannels', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs /api/v1/channels/auto-tune with templateId, group and echoed channels', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResult), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(
createAutoTunedChannels({
templateId: 3,
group: 'Auto-Tuned',
channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
})
).resolves.toMatchObject({ createdCount: 1 });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/channels/auto-tune');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({
templateId: 3,
group: 'Auto-Tuned',
channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
});
});
it('sends per-channel overrides (templateId, logo, advanced) verbatim when present', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResult), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await createAutoTunedChannels({
templateId: 3,
group: 'Auto-Tuned',
channels: [
{
axis: 'TvGenre',
value: 'Comedy',
name: 'Comedy',
number: '501',
templateId: 9,
logo: { path: 'logos/comedy.png', contentType: 'image/png' },
advanced: { shuffleScheduleItems: true, ffmpegProfileId: 4 }
}
]
});
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(String(init?.body)).channels[0]).toEqual({
axis: 'TvGenre',
value: 'Comedy',
name: 'Comedy',
number: '501',
templateId: 9,
logo: { path: 'logos/comedy.png', contentType: 'image/png' },
advanced: { shuffleScheduleItems: true, ffmpegProfileId: 4 }
});
});
});
describe('getAutoTuneChannelMembers', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/v1/channels/auto-tune/members with axis+value and optional paging', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleMembers), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(
getAutoTuneChannelMembers({ axis: 'TvGenre', value: 'Comedy', pageNum: 1, pageSize: 50 })
).resolves.toMatchObject({ totalCount: 3 });
const [url, init] = fetchSpy.mock.calls[0];
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
const parsed = new URL(String(url), 'http://localhost');
expect(parsed.pathname).toBe('/api/v1/channels/auto-tune/members');
expect(parsed.searchParams.get('axis')).toBe('TvGenre');
expect(parsed.searchParams.get('value')).toBe('Comedy');
expect(parsed.searchParams.get('pageNum')).toBe('1');
expect(parsed.searchParams.get('pageSize')).toBe('50');
});
it('omits paging params when not provided but always sends axis+value', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleMembers), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getAutoTuneChannelMembers({ axis: 'TvShow', value: 'The Office' });
const [url] = fetchSpy.mock.calls[0];
const parsed = new URL(String(url), 'http://localhost');
expect(parsed.searchParams.get('axis')).toBe('TvShow');
expect(parsed.searchParams.get('value')).toBe('The Office');
expect(parsed.searchParams.has('pageNum')).toBe(false);
expect(parsed.searchParams.has('pageSize')).toBe(false);
});
});