diff --git a/web/src/pages/ScanPage.tsx b/web/src/pages/ScanPage.tsx index 28cf317..995c51f 100644 --- a/web/src/pages/ScanPage.tsx +++ b/web/src/pages/ScanPage.tsx @@ -30,7 +30,7 @@ export default function ScanPage() { dispatch({ type: 'CANDIDATES', code, candidates }) } } catch (err) { - dispatch({ type: 'ERROR', kind: toErrorKind(err), code }) + dispatch({ type: 'ERROR', kind: toErrorKind(err), code, source: 'lookup' }) } })() }, []) @@ -42,9 +42,9 @@ export default function ScanPage() { void (async () => { try { const preview = await api.getReleasePreview(candidateId) - dispatch({ type: 'PREVIEW', preview }) + dispatch({ type: 'PREVIEW', preview, candidateId }) } catch (err) { - dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null }) + dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null, source: 'preview', candidateId }) } })() }, [state]) @@ -52,6 +52,7 @@ export default function ScanPage() { const add = useCallback(() => { if (state.phase !== 'confirm' || !state.preview || state.adding) return const { candidate, code, matchAlbumId } = state + dispatch({ type: 'ADD_START' }) void (async () => { try { const item = await api.addToCollection({ diff --git a/web/src/scan/reducer.ts b/web/src/scan/reducer.ts index 6221100..c009246 100644 --- a/web/src/scan/reducer.ts +++ b/web/src/scan/reducer.ts @@ -24,9 +24,9 @@ export type ScanAction = | { type: 'DETECT'; code: string } | { type: 'CANDIDATES'; code: string; candidates: Candidate[] } | { type: 'NOT_FOUND'; code: string } - | { type: 'ERROR'; kind: ScanErrorKind; code: string | null } + | { type: 'ERROR'; kind: ScanErrorKind; code: string | null; source: 'lookup' | 'preview'; candidateId?: number } | { type: 'SELECT'; candidate: Candidate } - | { type: 'PREVIEW'; preview: ReleasePreview } + | { type: 'PREVIEW'; preview: ReleasePreview; candidateId: number } | { type: 'SET_MATCH'; albumId: number } | { type: 'CLEAR_MATCH' } | { type: 'ADD_START' } @@ -43,7 +43,10 @@ export function scanReducer(state: ScanState, action: ScanAction): ScanState { case 'NOT_FOUND': return state.phase === 'looking' ? { phase: 'error', kind: 'not_found', code: action.code } : state case 'ERROR': - return state.phase === 'looking' || state.phase === 'candidates' || state.phase === 'confirm' + if (action.source === 'lookup') { + return state.phase === 'looking' ? { phase: 'error', kind: action.kind, code: action.code } : state + } + return state.phase === 'confirm' && state.candidate.id === action.candidateId ? { phase: 'error', kind: action.kind, code: action.code } : state case 'SELECT': @@ -59,7 +62,9 @@ export function scanReducer(state: ScanState, action: ScanAction): ScanState { } : state case 'PREVIEW': - return state.phase === 'confirm' ? { ...state, preview: action.preview } : state + return state.phase === 'confirm' && state.candidate.id === action.candidateId + ? { ...state, preview: action.preview } + : state case 'SET_MATCH': return state.phase === 'confirm' ? { ...state, matchAlbumId: action.albumId } : state case 'CLEAR_MATCH': diff --git a/web/test/reducer.test.ts b/web/test/reducer.test.ts index ae9ea53..7144702 100644 --- a/web/test/reducer.test.ts +++ b/web/test/reducer.test.ts @@ -46,7 +46,7 @@ function stateOf(phase: ScanState['phase']): ScanState { { type: 'DETECT', code: '5021592210629' }, { type: 'CANDIDATES', code: '5021592210629', candidates: [candidate] }, { type: 'SELECT', candidate }, - { type: 'PREVIEW', preview }, + { type: 'PREVIEW', preview, candidateId: 1001 }, { type: 'ADD_START' }, { type: 'ADDED', item }, ] @@ -83,10 +83,37 @@ describe('scanReducer', () => { }) it('ERROR maps kinds', () => { - const next = scanReducer(stateOf('looking'), { type: 'ERROR', kind: 'no_discogs_token', code: '123' }) + const next = scanReducer(stateOf('looking'), { + type: 'ERROR', + kind: 'no_discogs_token', + code: '123', + source: 'lookup', + }) expect(next).toEqual({ phase: 'error', kind: 'no_discogs_token', code: '123' }) }) + it('ignores a stale preview for a different candidate', () => { + const confirm = stateOf('confirm') + const stale = scanReducer(confirm, { type: 'PREVIEW', preview, candidateId: 999 }) + expect(stale).toBe(confirm) + }) + + it('ignores a preview-source error in the looking phase', () => { + const looking = stateOf('looking') + const next = scanReducer(looking, { + type: 'ERROR', kind: 'server', code: null, source: 'preview', candidateId: 1001, + }) + expect(next).toBe(looking) + }) + + it('ignores a lookup-source error after the user moved past looking', () => { + const candidates = stateOf('candidates') + const next = scanReducer(candidates, { + type: 'ERROR', kind: 'server', code: '123', source: 'lookup', + }) + expect(next).toBe(candidates) + }) + it('SELECT from candidates → confirm with candidate, no preview yet', () => { const next = scanReducer(stateOf('candidates'), { type: 'SELECT', candidate }) expect(next.phase).toBe('confirm') @@ -99,7 +126,7 @@ describe('scanReducer', () => { }) it('PREVIEW fills the confirm phase', () => { - const next = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview }) + const next = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 }) if (next.phase === 'confirm') expect(next.preview).toBe(preview) else throw new Error('expected confirm') }) @@ -114,7 +141,7 @@ describe('scanReducer', () => { }) it('ADD_START → ADDED', () => { - const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview }) + const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 }) const starting = scanReducer(confirm, { type: 'ADD_START' }) if (starting.phase === 'confirm') expect(starting.adding).toBe(true) const added = scanReducer(starting, { type: 'ADDED', item }) @@ -122,7 +149,7 @@ describe('scanReducer', () => { }) it('ADD_ERROR stores the message without leaving confirm', () => { - const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview }) + const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 }) const starting = scanReducer(confirm, { type: 'ADD_START' }) const next = scanReducer(starting, { type: 'ADD_ERROR', message: 'duplicate' }) if (next.phase === 'confirm') { diff --git a/web/test/scanPage.test.tsx b/web/test/scanPage.test.tsx index 1aeb307..bb6eb66 100644 --- a/web/test/scanPage.test.tsx +++ b/web/test/scanPage.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import ScanPage from '../src/pages/ScanPage.js' -import type { Candidate, ReleasePreview } from '../src/types.js' +import type { Candidate, Item, ReleasePreview } from '../src/types.js' vi.mock('../src/components/Scanner.js', () => ({ default: ({ onDetect }: { onDetect: (code: string) => void }) => ( @@ -48,6 +48,25 @@ const preview: ReleasePreview = { } // The api module is mocked directly, so mocks resolve with parsed bodies. +const addedItem: Item = { + id: 1, + discogsReleaseId: 1001, + title: 'Motion', + artist: 'The Cinematic Orchestra', + year: 1999, + formats: ['CD'], + genres: [], + labels: ['Ninja Tune'], + tracklist: [], + catno: 'ZENCD012', + country: 'UK', + artworkUrl: null, + barcodes: ['5021592210629'], + dateAdded: '2026-08-29', + ripOverride: null, + ripStatus: 'not_ripped', +} + function jsonOk(body: unknown) { return Promise.resolve(body) } @@ -70,26 +89,7 @@ describe('ScanPage flow', () => { it('scan → candidates → confirm → added', async () => { vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never) vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never) - vi.mocked(api.addToCollection).mockResolvedValue( - jsonOk({ - id: 1, - discogsReleaseId: 1001, - title: 'Motion', - artist: 'The Cinematic Orchestra', - year: 1999, - formats: ['CD'], - genres: [], - labels: ['Ninja Tune'], - tracklist: [], - catno: 'ZENCD012', - country: 'UK', - artworkUrl: null, - barcodes: ['5021592210629'], - dateAdded: '2026-08-29', - ripOverride: null, - ripStatus: 'not_ripped', - }) as never - ) + vi.mocked(api.addToCollection).mockResolvedValue(jsonOk(addedItem) as never) renderScan() await userEvent.click(screen.getByRole('button', { name: 'fake-scan' })) @@ -158,4 +158,23 @@ describe('ScanPage flow', () => { await waitFor(() => expect(screen.getByText(/discogs token/i)).toBeTruthy()) expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings') }) + + it('does not add twice when the button is clicked rapidly', async () => { + let resolveAdd: (v: unknown) => void = () => {} + vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never) + vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never) + vi.mocked(api.addToCollection).mockImplementation( + () => new Promise((resolve) => { resolveAdd = resolve }) as never + ) + renderScan() + await userEvent.click(screen.getByRole('button', { name: 'fake-scan' })) + await userEvent.click(await screen.findByRole('button', { name: /motion/i })) + const addBtn = await screen.findByRole('button', { name: /add to collection/i }) + await userEvent.click(addBtn) + // adding=true → button is disabled ('Adding…'); a second rapid click must not re-fire the request + await userEvent.click(addBtn).catch(() => {}) + resolveAdd(addedItem) + await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy()) + expect(api.addToCollection).toHaveBeenCalledTimes(1) + }) })