diff --git a/web/src/screens/AutoTuneScreen.test.tsx b/web/src/screens/AutoTuneScreen.test.tsx index 253f0be38..2d718e0f9 100644 --- a/web/src/screens/AutoTuneScreen.test.tsx +++ b/web/src/screens/AutoTuneScreen.test.tsx @@ -150,4 +150,54 @@ describe('AutoTuneScreen', () => { { axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '502' } ]); }); + + it('keys selection by axis+value so same-named proposals stay independent', async () => { + // A TV show literally titled "Comedy" and the "Comedy" TV genre — identical names, different + // axes. Keying selection on the name would couple them (toggling one flips both). + const collision = [ + { axis: 'TvShow', value: 'Comedy', name: 'Comedy', number: '500', itemCount: 30, alreadyExists: false }, + { axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501', itemCount: 640, alreadyExists: false } + ]; + const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const path = input.toString(); + const method = (init?.method ?? 'GET').toUpperCase(); + if (path === '/api/v1/channel-templates') { + return Promise.resolve(jsonResponse(templates)); + } + if (path === '/api/v1/channel-templates/default') { + return Promise.resolve(jsonResponse(templates[0])); + } + if (path === '/api/v1/channels/auto-tune/preview' && method === 'POST') { + return Promise.resolve(jsonResponse(collision)); + } + if (path === '/api/v1/channels/auto-tune' && method === 'POST') { + return Promise.resolve(jsonResponse({ results: [], createdCount: 0, skippedCount: 0, failedCount: 0 })); + } + return Promise.resolve(jsonResponse(null, 404)); + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: /Preview channels/ })); + await screen.findByText('TV Shows'); + + // Two distinct row checkboxes both named "Comedy" (one per axis), both selected by default. + const boxes = screen.getAllByRole('checkbox', { name: 'Comedy' }); + expect(boxes).toHaveLength(2); + expect(boxes[0]).toHaveAttribute('aria-checked', 'true'); + expect(boxes[1]).toHaveAttribute('aria-checked', 'true'); + + // Deselecting the TV-show "Comedy" must NOT deselect the genre "Comedy". + fireEvent.click(boxes[0]); + const after = screen.getAllByRole('checkbox', { name: 'Comedy' }); + expect(after[0]).toHaveAttribute('aria-checked', 'false'); + expect(after[1]).toHaveAttribute('aria-checked', 'true'); + + // Exactly one remains selected → create posts only the still-selected genre proposal. + fireEvent.click(screen.getByRole('button', { name: /Create 1 channel/ })); + const createCall = fetchSpy.mock.calls.find( + (call) => call[0] === '/api/v1/channels/auto-tune' && (call[1]?.method ?? '').toUpperCase() === 'POST' + ); + const body = JSON.parse(String(createCall?.[1]?.body)); + expect(body.channels).toEqual([{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501' }]); + }); }); diff --git a/web/src/screens/AutoTuneScreen.tsx b/web/src/screens/AutoTuneScreen.tsx index 683e403e6..0dc7f9c61 100644 --- a/web/src/screens/AutoTuneScreen.tsx +++ b/web/src/screens/AutoTuneScreen.tsx @@ -61,6 +61,12 @@ const eyebrowStyle: CSSProperties = { const monoStyle: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' }; +// Selection identity — axis+value uniquely identifies a proposal. Keying on the display +// name would couple two same-named proposals (e.g. a TV show titled "Comedy" and the +// "Comedy" genre), flipping/creating both. An axis id never contains a space, so a single +// space separates it unambiguously from the metadata value. +const proposalKey = (proposal: { axis: string; value: string }) => `${proposal.axis} ${proposal.value}`; + type TemplatesState = | { status: 'loading' } | { status: 'error'; message: string } @@ -163,7 +169,7 @@ export function AutoTuneScreen() { } setPreviewState({ status: 'success', proposals }); // Default selection: every proposal that doesn't already exist. - setSelected(new Set(proposals.filter((proposal) => !proposal.alreadyExists).map((proposal) => proposal.name))); + setSelected(new Set(proposals.filter((proposal) => !proposal.alreadyExists).map(proposalKey))); }) .catch((error: unknown) => { if (activeRef.current && id === previewSeqRef.current) { @@ -173,7 +179,9 @@ export function AutoTuneScreen() { }; const proposals = previewState.status === 'success' ? previewState.proposals : []; - const selectedProposals = proposals.filter((proposal) => !proposal.alreadyExists && selected.has(proposal.name)); + const selectedProposals = proposals.filter( + (proposal) => !proposal.alreadyExists && selected.has(proposalKey(proposal)) + ); const runCreate = () => { if (selectedProposals.length === 0 || templateId === '') { @@ -276,7 +284,13 @@ export function AutoTuneScreen() {