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
181 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|