fix: dispatch ADD_START, correlate preview/lookup responses with the current attempt
This commit is contained in:
@@ -30,7 +30,7 @@ export default function ScanPage() {
|
|||||||
dispatch({ type: 'CANDIDATES', code, candidates })
|
dispatch({ type: 'CANDIDATES', code, candidates })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const preview = await api.getReleasePreview(candidateId)
|
const preview = await api.getReleasePreview(candidateId)
|
||||||
dispatch({ type: 'PREVIEW', preview })
|
dispatch({ type: 'PREVIEW', preview, candidateId })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null })
|
dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null, source: 'preview', candidateId })
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [state])
|
}, [state])
|
||||||
@@ -52,6 +52,7 @@ export default function ScanPage() {
|
|||||||
const add = useCallback(() => {
|
const add = useCallback(() => {
|
||||||
if (state.phase !== 'confirm' || !state.preview || state.adding) return
|
if (state.phase !== 'confirm' || !state.preview || state.adding) return
|
||||||
const { candidate, code, matchAlbumId } = state
|
const { candidate, code, matchAlbumId } = state
|
||||||
|
dispatch({ type: 'ADD_START' })
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const item = await api.addToCollection({
|
const item = await api.addToCollection({
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ export type ScanAction =
|
|||||||
| { type: 'DETECT'; code: string }
|
| { type: 'DETECT'; code: string }
|
||||||
| { type: 'CANDIDATES'; code: string; candidates: Candidate[] }
|
| { type: 'CANDIDATES'; code: string; candidates: Candidate[] }
|
||||||
| { type: 'NOT_FOUND'; code: string }
|
| { 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: 'SELECT'; candidate: Candidate }
|
||||||
| { type: 'PREVIEW'; preview: ReleasePreview }
|
| { type: 'PREVIEW'; preview: ReleasePreview; candidateId: number }
|
||||||
| { type: 'SET_MATCH'; albumId: number }
|
| { type: 'SET_MATCH'; albumId: number }
|
||||||
| { type: 'CLEAR_MATCH' }
|
| { type: 'CLEAR_MATCH' }
|
||||||
| { type: 'ADD_START' }
|
| { type: 'ADD_START' }
|
||||||
@@ -43,7 +43,10 @@ export function scanReducer(state: ScanState, action: ScanAction): ScanState {
|
|||||||
case 'NOT_FOUND':
|
case 'NOT_FOUND':
|
||||||
return state.phase === 'looking' ? { phase: 'error', kind: 'not_found', code: action.code } : state
|
return state.phase === 'looking' ? { phase: 'error', kind: 'not_found', code: action.code } : state
|
||||||
case 'ERROR':
|
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 }
|
? { phase: 'error', kind: action.kind, code: action.code }
|
||||||
: state
|
: state
|
||||||
case 'SELECT':
|
case 'SELECT':
|
||||||
@@ -59,7 +62,9 @@ export function scanReducer(state: ScanState, action: ScanAction): ScanState {
|
|||||||
}
|
}
|
||||||
: state
|
: state
|
||||||
case 'PREVIEW':
|
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':
|
case 'SET_MATCH':
|
||||||
return state.phase === 'confirm' ? { ...state, matchAlbumId: action.albumId } : state
|
return state.phase === 'confirm' ? { ...state, matchAlbumId: action.albumId } : state
|
||||||
case 'CLEAR_MATCH':
|
case 'CLEAR_MATCH':
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function stateOf(phase: ScanState['phase']): ScanState {
|
|||||||
{ type: 'DETECT', code: '5021592210629' },
|
{ type: 'DETECT', code: '5021592210629' },
|
||||||
{ type: 'CANDIDATES', code: '5021592210629', candidates: [candidate] },
|
{ type: 'CANDIDATES', code: '5021592210629', candidates: [candidate] },
|
||||||
{ type: 'SELECT', candidate },
|
{ type: 'SELECT', candidate },
|
||||||
{ type: 'PREVIEW', preview },
|
{ type: 'PREVIEW', preview, candidateId: 1001 },
|
||||||
{ type: 'ADD_START' },
|
{ type: 'ADD_START' },
|
||||||
{ type: 'ADDED', item },
|
{ type: 'ADDED', item },
|
||||||
]
|
]
|
||||||
@@ -83,10 +83,37 @@ describe('scanReducer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ERROR maps kinds', () => {
|
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' })
|
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', () => {
|
it('SELECT from candidates → confirm with candidate, no preview yet', () => {
|
||||||
const next = scanReducer(stateOf('candidates'), { type: 'SELECT', candidate })
|
const next = scanReducer(stateOf('candidates'), { type: 'SELECT', candidate })
|
||||||
expect(next.phase).toBe('confirm')
|
expect(next.phase).toBe('confirm')
|
||||||
@@ -99,7 +126,7 @@ describe('scanReducer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('PREVIEW fills the confirm phase', () => {
|
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)
|
if (next.phase === 'confirm') expect(next.preview).toBe(preview)
|
||||||
else throw new Error('expected confirm')
|
else throw new Error('expected confirm')
|
||||||
})
|
})
|
||||||
@@ -114,7 +141,7 @@ describe('scanReducer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ADD_START → ADDED', () => {
|
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' })
|
const starting = scanReducer(confirm, { type: 'ADD_START' })
|
||||||
if (starting.phase === 'confirm') expect(starting.adding).toBe(true)
|
if (starting.phase === 'confirm') expect(starting.adding).toBe(true)
|
||||||
const added = scanReducer(starting, { type: 'ADDED', item })
|
const added = scanReducer(starting, { type: 'ADDED', item })
|
||||||
@@ -122,7 +149,7 @@ describe('scanReducer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ADD_ERROR stores the message without leaving confirm', () => {
|
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 starting = scanReducer(confirm, { type: 'ADD_START' })
|
||||||
const next = scanReducer(starting, { type: 'ADD_ERROR', message: 'duplicate' })
|
const next = scanReducer(starting, { type: 'ADD_ERROR', message: 'duplicate' })
|
||||||
if (next.phase === 'confirm') {
|
if (next.phase === 'confirm') {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import ScanPage from '../src/pages/ScanPage.js'
|
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', () => ({
|
vi.mock('../src/components/Scanner.js', () => ({
|
||||||
default: ({ onDetect }: { onDetect: (code: string) => void }) => (
|
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.
|
// 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) {
|
function jsonOk(body: unknown) {
|
||||||
return Promise.resolve(body)
|
return Promise.resolve(body)
|
||||||
}
|
}
|
||||||
@@ -70,26 +89,7 @@ describe('ScanPage flow', () => {
|
|||||||
it('scan → candidates → confirm → added', async () => {
|
it('scan → candidates → confirm → added', async () => {
|
||||||
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
|
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
|
||||||
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
|
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never)
|
||||||
vi.mocked(api.addToCollection).mockResolvedValue(
|
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk(addedItem) as never)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
renderScan()
|
renderScan()
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
|
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())
|
await waitFor(() => expect(screen.getByText(/discogs token/i)).toBeTruthy())
|
||||||
expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user