diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 8f800880e..aa5fb83a5 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1993,6 +1993,584 @@ describe('ChicoryTV SPA scaffold', () => { expect(container.querySelector('.lucide-triangle-alert')).toBeInTheDocument(); expect(container.querySelector('.lucide-circle-x')).toBeInTheDocument(); }); + + // ---- Channel Builder (#89) ----------------------------------------------- + const renderBuilder = async () => { + window.history.replaceState(null, '', '/app/new-channel'); + const utils = render(); + 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 () => { + mockDashboardApi({ + ...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 () => { + mockDashboardApi({ + ...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 () => { + mockDashboardApi({ ...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 () => { + mockDashboardApi({ + ...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 () => { + mockDashboardApi({ + ...builderDefaults(), + browseHandler: (search) => { + const query = (search.get('query') ?? '').toLowerCase(); + const all = [ + browseItem(), + browseItem({ id: 2, mediaItemId: 2, mediaType: 'Movie', collectionType: 'Movie', title: 'Blade Runner', libraryName: 'Movies' }) + ]; + const page = query ? all.filter((item) => String(item.title).toLowerCase().includes(query)) : all; + 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 () => { + mockDashboardApi({ + ...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 () => { + mockDashboardApi({ + ...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 () => { + mockDashboardApi({ ...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 () => { + mockDashboardApi({ ...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 () => { + mockDashboardApi({ + ...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 () => { + mockDashboardApi({ + ...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')); + fireEvent.doubleClick(screen.getByText('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 () => { + mockDashboardApi({ + ...builderDefaults(), + browseItems: [ + browseItem({ id: 7, title: 'Prime Time', mediaType: 'MultiCollection', collectionType: 'MultiCollection', multiCollectionId: 7, mediaItemId: undefined }) + ] + }); + + const { container } = await renderBuilder(); + + 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 () => { + mockDashboardApi({ + ...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/channels/from-lineup'); + expect(body.name).toBe('Retro Cartoons'); + expect(body.number).toBe('6'); + expect(body.group).toBe('ErsatzTV'); + expect(body.templateId).toBe(10); + expect(body.logo).toEqual({ contentType: '', path: '' }); + expect((body.advanced as Record).playbackOrder).toBe('Chronological'); + expect((body.advanced as Record).playoutMode).toBe('Continuous'); + const lineup = body.lineup as Array>; + 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 () => { + mockDashboardApi({ + ...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]