diff --git a/web/src/routes/admin/page.stage-state.test.ts b/web/src/routes/admin/page.stage-state.test.ts new file mode 100644 index 00000000..1c37e0e9 --- /dev/null +++ b/web/src/routes/admin/page.stage-state.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { stageState } from './stage-state'; +import type { ScanStatus } from '$lib/api/admin'; + +const FINISHED_AT = '2026-05-06T12:00:00Z'; + +function makeScan(partial: Partial): ScanStatus { + return { + id: 'test-id', + started_at: '2026-05-06T11:00:00Z', + finished_at: null, + in_flight: true, + ...partial + } as ScanStatus; +} + +describe('stageState', () => { + it('returns skipped when scan is undefined', () => { + expect(stageState(undefined, 'library')).toBe('skipped'); + expect(stageState(undefined, 'cover_enrich')).toBe('skipped'); + }); + + it('in-flight + no tallies → all stages pending', () => { + const scan = makeScan({ in_flight: true }); + expect(stageState(scan, 'library')).toBe('pending'); + expect(stageState(scan, 'mbid_backfill')).toBe('pending'); + expect(stageState(scan, 'cover_enrich')).toBe('pending'); + expect(stageState(scan, 'artist_art_enrich')).toBe('pending'); + }); + + it('in-flight + only library tally → library in_progress, rest pending', () => { + const scan = makeScan({ + in_flight: true, + library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 } + }); + expect(stageState(scan, 'library')).toBe('in_progress'); + expect(stageState(scan, 'mbid_backfill')).toBe('pending'); + expect(stageState(scan, 'cover_enrich')).toBe('pending'); + expect(stageState(scan, 'artist_art_enrich')).toBe('pending'); + }); + + it('in-flight + library + mbid_backfill tallies → library done, mbid in_progress', () => { + const scan = makeScan({ + in_flight: true, + library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 }, + mbid_backfill: { processed: 50, healed: 40, skipped: 10, duplicates: 0 } + }); + expect(stageState(scan, 'library')).toBe('done'); + expect(stageState(scan, 'mbid_backfill')).toBe('in_progress'); + expect(stageState(scan, 'cover_enrich')).toBe('pending'); + expect(stageState(scan, 'artist_art_enrich')).toBe('pending'); + }); + + it('finished + all tallies → all done', () => { + const scan = makeScan({ + in_flight: false, + finished_at: FINISHED_AT, + library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 }, + mbid_backfill: { processed: 50, healed: 40, skipped: 10, duplicates: 0 }, + cover_enrich: { processed: 30, succeeded: 25, failed: 5 }, + artist_art_enrich: { processed: 20, succeeded: 18, failed: 2 } + }); + expect(stageState(scan, 'library')).toBe('done'); + expect(stageState(scan, 'mbid_backfill')).toBe('done'); + expect(stageState(scan, 'cover_enrich')).toBe('done'); + expect(stageState(scan, 'artist_art_enrich')).toBe('done'); + }); + + it('finished + missing library tally → library skipped, others done if present', () => { + const scan = makeScan({ + in_flight: false, + finished_at: FINISHED_AT, + mbid_backfill: { processed: 50, healed: 40, skipped: 10, duplicates: 0 }, + cover_enrich: { processed: 30, succeeded: 25, failed: 5 } + }); + expect(stageState(scan, 'library')).toBe('skipped'); + expect(stageState(scan, 'mbid_backfill')).toBe('done'); + expect(stageState(scan, 'cover_enrich')).toBe('done'); + expect(stageState(scan, 'artist_art_enrich')).toBe('skipped'); + }); + + it('in-flight + last stage has tally → last stage in_progress, all earlier done', () => { + const scan = makeScan({ + in_flight: true, + library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 }, + mbid_backfill: { processed: 50, healed: 40, skipped: 10, duplicates: 0 }, + cover_enrich: { processed: 30, succeeded: 25, failed: 5 }, + artist_art_enrich: { processed: 5, succeeded: 4, failed: 1 } + }); + expect(stageState(scan, 'library')).toBe('done'); + expect(stageState(scan, 'mbid_backfill')).toBe('done'); + expect(stageState(scan, 'cover_enrich')).toBe('done'); + expect(stageState(scan, 'artist_art_enrich')).toBe('in_progress'); + }); +}); diff --git a/web/src/routes/admin/stage-state.ts b/web/src/routes/admin/stage-state.ts new file mode 100644 index 00000000..7611c40b --- /dev/null +++ b/web/src/routes/admin/stage-state.ts @@ -0,0 +1,47 @@ +import type { ScanStatus } from '$lib/api/admin'; + +export type StageState = 'pending' | 'in_progress' | 'done' | 'skipped'; + +export type StageKey = + | 'library' + | 'mbid_backfill' + | 'cover_enrich' + | 'artist_art_enrich'; + +const ORDER: readonly StageKey[] = [ + 'library', + 'mbid_backfill', + 'cover_enrich', + 'artist_art_enrich' +]; + +// stageState derives the per-stage state from a ScanStatus snapshot. +// +// Truth table: +// in_progress = scan in-flight AND this stage's tally is non-empty AND +// no later stage has started yet. +// done = scan finished_at is set, OR a later stage has a tally +// (this stage ran to completion before the next started). +// pending = scan in-flight AND this stage hasn't started. +// skipped = scan finished AND this stage has no tally +// (e.g. ScanOnStartup=false skips the library walk). +export function stageState(scan: ScanStatus | undefined, stage: StageKey): StageState { + if (!scan) return 'skipped'; + const idx = ORDER.indexOf(stage); + const thisStage = scan[stage]; + const inFlight = scan.in_flight === true; + + let highestStarted = -1; + for (let i = 0; i < ORDER.length; i++) { + if (scan[ORDER[i]]) highestStarted = i; + } + + if (thisStage) { + if (!inFlight) return 'done'; + if (highestStarted > idx) return 'done'; + return 'in_progress'; + } + + if (inFlight) return 'pending'; + return 'skipped'; +}