feat: scan flow page with candidates, confirm view and error guidance
This commit is contained in:
26
web/src/components/CandidateCard.tsx
Normal file
26
web/src/components/CandidateCard.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Candidate } from '../types.js'
|
||||||
|
import Cover from './Cover.js'
|
||||||
|
|
||||||
|
export default function CandidateCard({
|
||||||
|
candidate,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
candidate: Candidate
|
||||||
|
onSelect: (candidate: Candidate) => void
|
||||||
|
}) {
|
||||||
|
const meta = [candidate.year, candidate.formats[0], candidate.labels[0]].filter(Boolean).join(' · ')
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(candidate)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3 text-left transition-colors hover:border-neutral-600"
|
||||||
|
>
|
||||||
|
<Cover src={candidate.thumbUrl} alt="" className="size-16 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{candidate.title}</p>
|
||||||
|
<p className="truncate text-sm text-neutral-400">{candidate.artist}</p>
|
||||||
|
{meta && <p className="truncate text-xs text-neutral-500">{meta}</p>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
web/src/components/Cover.tsx
Normal file
25
web/src/components/Cover.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default function Cover({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className = 'size-16',
|
||||||
|
}: {
|
||||||
|
src: string | null
|
||||||
|
alt: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center rounded-lg bg-neutral-800 text-neutral-600 ${className}`}
|
||||||
|
aria-label={alt}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="size-1/2" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <img src={src} alt={alt} loading="lazy" className={`rounded-lg bg-neutral-800 object-cover ${className}`} />
|
||||||
|
}
|
||||||
@@ -1,3 +1,201 @@
|
|||||||
export default function ScanPage() {
|
import { useCallback, useEffect, useReducer } from 'react'
|
||||||
return <p className="text-neutral-400">Scanner goes here.</p>
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api, ApiError } from '../api.js'
|
||||||
|
import Scanner from '../components/Scanner.js'
|
||||||
|
import CandidateCard from '../components/CandidateCard.js'
|
||||||
|
import ConfirmView from '../scan/ConfirmView.js'
|
||||||
|
import Cover from '../components/Cover.js'
|
||||||
|
import { scanReducer, INITIAL_SCAN_STATE, type ScanErrorKind } from '../scan/reducer.js'
|
||||||
|
|
||||||
|
function toErrorKind(err: unknown): ScanErrorKind {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (err.code === 'not_found') return 'not_found'
|
||||||
|
if (err.code === 'no_discogs_token') return 'no_discogs_token'
|
||||||
|
if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
|
||||||
|
}
|
||||||
|
return 'server'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScanPage() {
|
||||||
|
const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE)
|
||||||
|
|
||||||
|
const onDetect = useCallback((code: string) => {
|
||||||
|
dispatch({ type: 'DETECT', code })
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const { candidates } = await api.lookupBarcode(code)
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
dispatch({ type: 'NOT_FOUND', code })
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'CANDIDATES', code, candidates })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({ type: 'ERROR', kind: toErrorKind(err), code })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load the release preview once a candidate is selected.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.phase !== 'confirm' || state.preview) return
|
||||||
|
const candidateId = state.candidate.id
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const preview = await api.getReleasePreview(candidateId)
|
||||||
|
dispatch({ type: 'PREVIEW', preview })
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
const add = useCallback(() => {
|
||||||
|
if (state.phase !== 'confirm' || !state.preview || state.adding) return
|
||||||
|
const { candidate, code, matchAlbumId } = state
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const item = await api.addToCollection({
|
||||||
|
releaseId: candidate.id,
|
||||||
|
...(code ? { barcode: code } : {}),
|
||||||
|
...(matchAlbumId !== null ? { matchAlbumId } : {}),
|
||||||
|
})
|
||||||
|
dispatch({ type: 'ADDED', item })
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.code === 'duplicate'
|
||||||
|
? 'Already in your collection.'
|
||||||
|
: err.detail ?? err.code
|
||||||
|
: 'Something went wrong'
|
||||||
|
dispatch({ type: 'ADD_ERROR', message })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{state.phase === 'scan' && <Scanner enabled onDetect={onDetect} />}
|
||||||
|
|
||||||
|
{state.phase === 'looking' && (
|
||||||
|
<p className="py-8 text-center text-neutral-400">Looking up {state.code}…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'candidates' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-neutral-400">Which release is it?</p>
|
||||||
|
{state.candidates.map((c) => (
|
||||||
|
<CandidateCard key={c.id} candidate={c} onSelect={(cand) => dispatch({ type: 'SELECT', candidate: cand })} />
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'RESET' })}
|
||||||
|
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Scan another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'confirm' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!state.preview && <p className="py-8 text-center text-neutral-400">Checking release…</p>}
|
||||||
|
{state.preview && (
|
||||||
|
<ConfirmView
|
||||||
|
code={state.code}
|
||||||
|
preview={state.preview}
|
||||||
|
matchAlbumId={state.matchAlbumId}
|
||||||
|
onSetMatch={(albumId) => dispatch({ type: 'SET_MATCH', albumId })}
|
||||||
|
onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })}
|
||||||
|
onAdd={add}
|
||||||
|
adding={state.adding}
|
||||||
|
addError={state.addError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'RESET' })}
|
||||||
|
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'added' && (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<Cover src={state.item.artworkUrl} alt="" className="mx-auto size-32" />
|
||||||
|
<p className="text-lg font-medium">Added to collection ✓</p>
|
||||||
|
<p className="text-sm text-neutral-400">
|
||||||
|
{state.item.artist} — {state.item.title}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'RESET' })}
|
||||||
|
className="flex-1 rounded-xl bg-emerald-500 py-2 font-medium text-neutral-950"
|
||||||
|
>
|
||||||
|
Scan another
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={`/item/${state.item.id}`}
|
||||||
|
className="flex-1 rounded-xl border border-neutral-700 py-2 text-center text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
View item
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'error' && state.kind === 'not_found' && (
|
||||||
|
<div className="space-y-3 py-8 text-center">
|
||||||
|
<p className="text-lg font-medium">Nothing found</p>
|
||||||
|
<p className="text-sm text-neutral-400">
|
||||||
|
Discogs has no release for barcode {state.code}. Older vinyl often isn't listed by barcode.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to={`/add?q=${encodeURIComponent(state.code ?? '')}`}
|
||||||
|
className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950"
|
||||||
|
>
|
||||||
|
Search manually
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'error' && state.kind === 'no_discogs_token' && (
|
||||||
|
<div className="space-y-3 py-8 text-center">
|
||||||
|
<p className="text-lg font-medium">Add your Discogs token first</p>
|
||||||
|
<Link to="/settings" className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950">
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'error' && state.kind === 'rate_limited' && (
|
||||||
|
<div className="space-y-3 py-8 text-center">
|
||||||
|
<p className="text-lg font-medium">Slow down</p>
|
||||||
|
<p className="text-sm text-neutral-400">Discogs is rate limiting us. Try again in a moment.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'RESET' })}
|
||||||
|
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'error' && state.kind === 'server' && (
|
||||||
|
<div className="space-y-3 py-8 text-center">
|
||||||
|
<p className="text-lg font-medium">Lookup failed</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'RESET' })}
|
||||||
|
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
100
web/src/scan/ConfirmView.tsx
Normal file
100
web/src/scan/ConfirmView.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { ReleasePreview } from '../types.js'
|
||||||
|
import Cover from '../components/Cover.js'
|
||||||
|
|
||||||
|
export default function ConfirmView({
|
||||||
|
code,
|
||||||
|
preview,
|
||||||
|
matchAlbumId,
|
||||||
|
onSetMatch,
|
||||||
|
onClearMatch,
|
||||||
|
onAdd,
|
||||||
|
adding,
|
||||||
|
addError,
|
||||||
|
}: {
|
||||||
|
code: string | null
|
||||||
|
preview: ReleasePreview
|
||||||
|
matchAlbumId: number | null
|
||||||
|
onSetMatch: (albumId: number) => void
|
||||||
|
onClearMatch: () => void
|
||||||
|
onAdd: () => void
|
||||||
|
adding: boolean
|
||||||
|
addError: string | null
|
||||||
|
}) {
|
||||||
|
const { release, duplicate, ripMatch, matchCandidates } = preview
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Cover src={release.coverUrl ?? release.thumbUrl} alt="" className="size-28 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold leading-tight">{release.title}</h2>
|
||||||
|
<p className="text-neutral-400">{release.artist}</p>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{[release.year, release.formats.join(', '), release.labels[0], release.catno]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</p>
|
||||||
|
{code && <p className="mt-1 text-xs text-neutral-500">Barcode {code}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ripMatch === 'ripped' && (
|
||||||
|
<p className="rounded-xl bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
|
||||||
|
In your digital collection ✓
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ripMatch === 'not_ripped' && (
|
||||||
|
<p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">Not ripped yet</p>
|
||||||
|
)}
|
||||||
|
{ripMatch === 'ambiguous' && (
|
||||||
|
<fieldset className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">
|
||||||
|
<legend className="px-1">Possible matches in your library — which one is it?</legend>
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
{matchCandidates.map((m) => (
|
||||||
|
<label key={m.id} className="flex items-center gap-2 text-neutral-200">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="match"
|
||||||
|
checked={matchAlbumId === m.id}
|
||||||
|
onChange={() => onSetMatch(m.id)}
|
||||||
|
/>
|
||||||
|
{m.artist} — {m.title}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label className="flex items-center gap-2 text-neutral-200">
|
||||||
|
<input type="radio" name="match" checked={matchAlbumId === null} onChange={onClearMatch} />
|
||||||
|
None of these — just add it
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{duplicate && (
|
||||||
|
<p className="rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
Heads up: this release is already in your collection.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
|
||||||
|
<summary className="cursor-pointer text-sm text-neutral-300">Tracklist</summary>
|
||||||
|
<ol className="mt-2 space-y-1 text-sm text-neutral-400">
|
||||||
|
{release.tracklist.map((t, i) => (
|
||||||
|
<li key={i} className="flex gap-2">
|
||||||
|
<span className="w-8 shrink-0 text-neutral-600">{t.position}</span>
|
||||||
|
<span>{t.title}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{addError && <p className="text-sm text-red-400">{addError}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={adding}
|
||||||
|
className="w-full rounded-xl bg-emerald-500 py-3 font-medium text-neutral-950 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{adding ? 'Adding…' : 'Add to collection'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
web/test/scanPage.test.tsx
Normal file
161
web/test/scanPage.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
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'
|
||||||
|
|
||||||
|
vi.mock('../src/components/Scanner.js', () => ({
|
||||||
|
default: ({ onDetect }: { onDetect: (code: string) => void }) => (
|
||||||
|
<button type="button" onClick={() => onDetect('5021592210629')}>
|
||||||
|
fake-scan
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/api.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../src/api.js')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
lookupBarcode: vi.fn(),
|
||||||
|
getReleasePreview: vi.fn(),
|
||||||
|
addToCollection: vi.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { api, ApiError } from '../src/api.js'
|
||||||
|
|
||||||
|
const candidate: Candidate = {
|
||||||
|
id: 1001,
|
||||||
|
artist: 'The Cinematic Orchestra',
|
||||||
|
title: 'Motion',
|
||||||
|
year: 1999,
|
||||||
|
formats: ['CD'],
|
||||||
|
labels: ['Ninja Tune'],
|
||||||
|
country: 'UK',
|
||||||
|
catno: 'ZENCD012',
|
||||||
|
thumbUrl: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview: ReleasePreview = {
|
||||||
|
release: { ...candidate, genres: [], tracklist: [{ position: '1', title: 'Overture' }], coverUrl: null, barcodes: ['5021592210629'] },
|
||||||
|
duplicate: false,
|
||||||
|
ripMatch: 'not_ripped',
|
||||||
|
matchCandidates: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// The api module is mocked directly, so mocks resolve with parsed bodies.
|
||||||
|
function jsonOk(body: unknown) {
|
||||||
|
return Promise.resolve(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.lookupBarcode).mockReset()
|
||||||
|
vi.mocked(api.getReleasePreview).mockReset()
|
||||||
|
vi.mocked(api.addToCollection).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderScan() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/scan']}>
|
||||||
|
<ScanPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
renderScan()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /motion/i })).toBeTruthy())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /motion/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy())
|
||||||
|
expect(screen.getByText('Overture')).toBeTruthy()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
|
||||||
|
expect(api.addToCollection).toHaveBeenCalledWith({
|
||||||
|
releaseId: 1001,
|
||||||
|
barcode: '5021592210629',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows not-found guidance with a manual-search escape hatch', async () => {
|
||||||
|
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(404, 'not_found'))
|
||||||
|
renderScan()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy())
|
||||||
|
expect(screen.getByRole('link', { name: /search manually/i }).getAttribute('href')).toBe(
|
||||||
|
'/add?q=5021592210629'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the picked match candidate for ambiguous rip matches', async () => {
|
||||||
|
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
|
||||||
|
vi.mocked(api.getReleasePreview).mockResolvedValue(
|
||||||
|
jsonOk({
|
||||||
|
...preview,
|
||||||
|
ripMatch: 'ambiguous',
|
||||||
|
matchCandidates: [{ id: 77, title: 'Motion', artist: 'Somebody Else' }],
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never)
|
||||||
|
|
||||||
|
renderScan()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
|
||||||
|
await waitFor(() => expect(screen.getByRole('radio', { name: /somebody else/i })).toBeTruthy())
|
||||||
|
await userEvent.click(screen.getByRole('radio', { name: /somebody else/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /add to collection/i }))
|
||||||
|
await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({
|
||||||
|
releaseId: 1001,
|
||||||
|
barcode: '5021592210629',
|
||||||
|
matchAlbumId: 77,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a duplicate warning from the preview', async () => {
|
||||||
|
vi.mocked(api.lookupBarcode).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never)
|
||||||
|
vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk({ ...preview, duplicate: true }) as never)
|
||||||
|
renderScan()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /motion/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/already in your collection/i)).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to settings when no discogs token is configured', async () => {
|
||||||
|
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(409, 'no_discogs_token'))
|
||||||
|
renderScan()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/discogs token/i)).toBeTruthy())
|
||||||
|
expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user