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() { + ) : templateOptions.length === 0 ? ( + + No channel templates yet — create one under Templates first. + ) : ( !proposal.alreadyExists); const existingCount = proposals.length - selectable.length; - const selectedCount = selectable.filter((proposal) => selected.has(proposal.name)).length; + const selectedCount = selectable.filter((proposal) => selected.has(proposalKey(proposal))).length; const setAll = (rows: AutoTuneProposal[], on: boolean) => { setSelected((current) => { @@ -614,9 +635,9 @@ function PreviewStep({ rows.forEach((row) => { if (!row.alreadyExists) { if (on) { - next.add(row.name); + next.add(proposalKey(row)); } else { - next.delete(row.name); + next.delete(proposalKey(row)); } } }); @@ -624,13 +645,13 @@ function PreviewStep({ }); }; - const toggle = (name: string) => { + const toggle = (key: string) => { setSelected((current) => { const next = new Set(current); - if (next.has(name)) { - next.delete(name); + if (next.has(key)) { + next.delete(key); } else { - next.add(name); + next.add(key); } return next; }); @@ -663,8 +684,8 @@ function PreviewStep({ {groups.map(({ axis, rows }) => { const groupSelectable = rows.filter((row) => !row.alreadyExists); - const allOn = groupSelectable.length > 0 && groupSelectable.every((row) => selected.has(row.name)); - const someOn = groupSelectable.some((row) => selected.has(row.name)); + const allOn = groupSelectable.length > 0 && groupSelectable.every((row) => selected.has(proposalKey(row))); + const someOn = groupSelectable.some((row) => selected.has(proposalKey(row))); const AxisIcon = axis.icon; const OrderIcon = axis.shuffled ? Shuffle : ListOrdered; return ( @@ -707,10 +728,10 @@ function PreviewStep({ {rows.map((row, i) => { - const on = selected.has(row.name); + const on = selected.has(proposalKey(row)); return ( toggle(row.name)} + onChange={() => toggle(proposalKey(row))} /> {row.number}