feat(web/m7-scan-progress): stageState inference + tests

Pure-function module that derives per-stage StageState (one of
pending / in_progress / done / skipped) from the existing ScanStatus
payload. No new query, no new endpoint — reuses what
createScanStatusQuery already polls.

Truth table covered by tests: scan undefined → skipped; in-flight
with no tallies → all pending; in-flight with library tally only →
library in_progress, rest pending; in-flight with library +
mbid_backfill → library done, mbid in_progress; finished with all
tallies → all done; finished with missing library tally → library
skipped, others done; in-flight with last stage tally → only last
stage in_progress (all earlier done).
This commit is contained in:
2026-05-06 21:16:30 -04:00
parent 3e9e46f190
commit a368c74a9a
2 changed files with 142 additions and 0 deletions
@@ -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>): 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');
});
});
+47
View File
@@ -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';
}