1
0
Files
record-shop/web/test/reducer.test.ts

169 lines
5.8 KiB
TypeScript
Raw Permalink Normal View History

2026-08-29 22:25:53 +02:00
import { describe, it, expect } from 'vitest'
import { scanReducer, INITIAL_SCAN_STATE, type ScanState } from '../src/scan/reducer.js'
import type { Candidate, Item, ReleasePreview } from '../src/types.js'
const candidate: Candidate = {
id: 1001,
artist: 'The Cinematic Orchestra',
title: 'Motion',
year: 1999,
formats: ['CD'],
labels: ['Ninja Tune'],
country: 'UK',
catno: 'ZENCD012',
thumbUrl: 'https://img/x.jpg',
}
const preview: ReleasePreview = {
release: { ...candidate, genres: [], tracklist: [], coverUrl: null, barcodes: ['5021592210629'] },
duplicate: false,
ripMatch: 'not_ripped',
matchCandidates: [],
}
const item: 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 stateOf(phase: ScanState['phase']): ScanState {
let state = INITIAL_SCAN_STATE
const steps: Parameters<typeof scanReducer>[1][] = [
{ type: 'DETECT', code: '5021592210629' },
{ type: 'CANDIDATES', code: '5021592210629', candidates: [candidate] },
{ type: 'SELECT', candidate },
{ type: 'PREVIEW', preview, candidateId: 1001 },
2026-08-29 22:25:53 +02:00
{ type: 'ADD_START' },
{ type: 'ADDED', item },
]
const order: ScanState['phase'][] = ['scan', 'looking', 'candidates', 'confirm', 'confirm', 'confirm', 'added']
const idx = order.indexOf(phase)
if (idx < 0) return state
for (let i = 1; i <= idx; i++) state = scanReducer(state, steps[i - 1]!)
return state
}
describe('scanReducer', () => {
it('DETECT from scan → looking', () => {
const next = scanReducer(INITIAL_SCAN_STATE, { type: 'DETECT', code: '123' })
expect(next).toEqual({ phase: 'looking', code: '123' })
})
it('DETECT is ignored unless scanning (prevents duplicate lookups)', () => {
const looking = stateOf('looking')
expect(scanReducer(looking, { type: 'DETECT', code: '999' })).toBe(looking)
})
it('CANDIDATES from looking → candidates', () => {
const next = scanReducer(stateOf('looking'), {
type: 'CANDIDATES',
code: '5021592210629',
candidates: [candidate],
})
expect(next.phase).toBe('candidates')
})
it('NOT_FOUND from looking → error not_found', () => {
const next = scanReducer(stateOf('looking'), { type: 'NOT_FOUND', code: '123' })
expect(next).toEqual({ phase: 'error', kind: 'not_found', code: '123' })
})
it('ERROR maps kinds', () => {
const next = scanReducer(stateOf('looking'), {
type: 'ERROR',
kind: 'no_discogs_token',
code: '123',
source: 'lookup',
})
2026-08-29 22:25:53 +02:00
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)
})
2026-08-29 22:25:53 +02:00
it('SELECT from candidates → confirm with candidate, no preview yet', () => {
const next = scanReducer(stateOf('candidates'), { type: 'SELECT', candidate })
expect(next.phase).toBe('confirm')
if (next.phase === 'confirm') {
expect(next.candidate).toBe(candidate)
expect(next.preview).toBeNull()
expect(next.adding).toBe(false)
expect(next.matchAlbumId).toBeNull()
}
})
it('PREVIEW fills the confirm phase', () => {
const next = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
2026-08-29 22:25:53 +02:00
if (next.phase === 'confirm') expect(next.preview).toBe(preview)
else throw new Error('expected confirm')
})
it('SET_MATCH / CLEAR_MATCH only in confirm', () => {
const confirm = stateOf('confirm')
const set = scanReducer(confirm, { type: 'SET_MATCH', albumId: 7 })
if (set.phase === 'confirm') expect(set.matchAlbumId).toBe(7)
const cleared = scanReducer(set, { type: 'CLEAR_MATCH' })
if (cleared.phase === 'confirm') expect(cleared.matchAlbumId).toBeNull()
expect(scanReducer(stateOf('scan'), { type: 'SET_MATCH', albumId: 7 })).toBe(stateOf('scan'))
})
it('ADD_START → ADDED', () => {
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
2026-08-29 22:25:53 +02:00
const starting = scanReducer(confirm, { type: 'ADD_START' })
if (starting.phase === 'confirm') expect(starting.adding).toBe(true)
const added = scanReducer(starting, { type: 'ADDED', item })
expect(added).toEqual({ phase: 'added', item })
})
it('ADD_ERROR stores the message without leaving confirm', () => {
const confirm = scanReducer(stateOf('confirm'), { type: 'PREVIEW', preview, candidateId: 1001 })
2026-08-29 22:25:53 +02:00
const starting = scanReducer(confirm, { type: 'ADD_START' })
const next = scanReducer(starting, { type: 'ADD_ERROR', message: 'duplicate' })
if (next.phase === 'confirm') {
expect(next.adding).toBe(false)
expect(next.addError).toBe('duplicate')
} else {
throw new Error('expected confirm')
}
})
it('RESET returns to scan from anywhere', () => {
for (const phase of ['looking', 'candidates', 'confirm', 'added', 'error'] as const) {
expect(scanReducer(stateOf(phase), { type: 'RESET' })).toEqual({ phase: 'scan' })
}
})
})