feat: pure scan-flow state machine
This commit is contained in:
76
web/src/scan/reducer.ts
Normal file
76
web/src/scan/reducer.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Candidate, Item, ReleasePreview } from '../types.js'
|
||||||
|
|
||||||
|
export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'rate_limited' | 'server'
|
||||||
|
|
||||||
|
export type ScanState =
|
||||||
|
| { phase: 'scan' }
|
||||||
|
| { phase: 'looking'; code: string }
|
||||||
|
| { phase: 'candidates'; code: string; candidates: Candidate[] }
|
||||||
|
| {
|
||||||
|
phase: 'confirm'
|
||||||
|
code: string | null
|
||||||
|
candidate: Candidate
|
||||||
|
preview: ReleasePreview | null
|
||||||
|
matchAlbumId: number | null
|
||||||
|
adding: boolean
|
||||||
|
addError: string | null
|
||||||
|
}
|
||||||
|
| { phase: 'added'; item: Item }
|
||||||
|
| { phase: 'error'; kind: ScanErrorKind; code: string | null }
|
||||||
|
|
||||||
|
export const INITIAL_SCAN_STATE: ScanState = { phase: 'scan' }
|
||||||
|
|
||||||
|
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: 'SELECT'; candidate: Candidate }
|
||||||
|
| { type: 'PREVIEW'; preview: ReleasePreview }
|
||||||
|
| { type: 'SET_MATCH'; albumId: number }
|
||||||
|
| { type: 'CLEAR_MATCH' }
|
||||||
|
| { type: 'ADD_START' }
|
||||||
|
| { type: 'ADDED'; item: Item }
|
||||||
|
| { type: 'ADD_ERROR'; message: string }
|
||||||
|
| { type: 'RESET' }
|
||||||
|
|
||||||
|
export function scanReducer(state: ScanState, action: ScanAction): ScanState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'DETECT':
|
||||||
|
return state.phase === 'scan' ? { phase: 'looking', code: action.code } : state
|
||||||
|
case 'CANDIDATES':
|
||||||
|
return state.phase === 'looking' ? { phase: 'candidates', code: action.code, candidates: action.candidates } : state
|
||||||
|
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'
|
||||||
|
? { phase: 'error', kind: action.kind, code: action.code }
|
||||||
|
: state
|
||||||
|
case 'SELECT':
|
||||||
|
return state.phase === 'candidates'
|
||||||
|
? {
|
||||||
|
phase: 'confirm',
|
||||||
|
code: state.code,
|
||||||
|
candidate: action.candidate,
|
||||||
|
preview: null,
|
||||||
|
matchAlbumId: null,
|
||||||
|
adding: false,
|
||||||
|
addError: null,
|
||||||
|
}
|
||||||
|
: state
|
||||||
|
case 'PREVIEW':
|
||||||
|
return state.phase === 'confirm' ? { ...state, preview: action.preview } : state
|
||||||
|
case 'SET_MATCH':
|
||||||
|
return state.phase === 'confirm' ? { ...state, matchAlbumId: action.albumId } : state
|
||||||
|
case 'CLEAR_MATCH':
|
||||||
|
return state.phase === 'confirm' ? { ...state, matchAlbumId: null } : state
|
||||||
|
case 'ADD_START':
|
||||||
|
return state.phase === 'confirm' && state.preview ? { ...state, adding: true, addError: null } : state
|
||||||
|
case 'ADDED':
|
||||||
|
return state.phase === 'confirm' ? { phase: 'added', item: action.item } : state
|
||||||
|
case 'ADD_ERROR':
|
||||||
|
return state.phase === 'confirm' ? { ...state, adding: false, addError: action.message } : state
|
||||||
|
case 'RESET':
|
||||||
|
return INITIAL_SCAN_STATE
|
||||||
|
}
|
||||||
|
}
|
||||||
141
web/test/reducer.test.ts
Normal file
141
web/test/reducer.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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 },
|
||||||
|
{ 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' })
|
||||||
|
expect(next).toEqual({ phase: 'error', kind: 'no_discogs_token', code: '123' })
|
||||||
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
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 })
|
||||||
|
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 })
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user