From 890e7da3a7c2486c9002059c7dc9f408a5b3c223 Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 22:25:53 +0200 Subject: [PATCH] feat: pure scan-flow state machine --- web/src/scan/reducer.ts | 76 +++++++++++++++++++++ web/test/reducer.test.ts | 141 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 web/src/scan/reducer.ts create mode 100644 web/test/reducer.test.ts diff --git a/web/src/scan/reducer.ts b/web/src/scan/reducer.ts new file mode 100644 index 0000000..6221100 --- /dev/null +++ b/web/src/scan/reducer.ts @@ -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 + } +} diff --git a/web/test/reducer.test.ts b/web/test/reducer.test.ts new file mode 100644 index 0000000..ae9ea53 --- /dev/null +++ b/web/test/reducer.test.ts @@ -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[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' }) + } + }) +})