import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test, beforeEach } from 'node:test'; import { get } from 'svelte/store'; import type { Draft, Source } from './capture.types'; import { createCaptureSession, type DraftStore } from './session'; const here = dirname(fileURLToPath(import.meta.url)); function tinyJpeg(label: string): File { // Minimal valid-ish JPEG bytes are unnecessary; Blob type is what session stores. const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]); return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' }); } function memStore(): DraftStore & { peek: () => Draft | null; reset: () => void } { let mem: Draft | null = null; return { loadDraft: async () => mem, saveDraft: async (d) => { mem = d; }, clearDraft: async () => { mem = null; }, peek: () => mem, reset: () => { mem = null; }, }; } let store: ReturnType; beforeEach(() => { store = memStore(); }); test('session starts on batch with zero photos (replaces single addPhoto)', () => { const s = createCaptureSession({ draft: store }); const cap = get(s.capture); assert.equal(cap.k, 'batch'); if (cap.k === 'batch') assert.equal(cap.photos.length, 0); s.dispose(); }); test('addToBatch accumulates photos and stays on batch', async () => { const s = createCaptureSession({ draft: store }); await s.addToBatch(tinyJpeg('a')); await s.addToBatch(tinyJpeg('b')); const cap = get(s.capture); assert.equal(cap.k, 'batch'); if (cap.k === 'batch') { assert.equal(cap.photos.length, 2); assert.ok(cap.photos[0].blob instanceof Blob); assert.ok(cap.photos[1].blob instanceof Blob); assert.notEqual(cap.photos[0].blob, cap.photos[1].blob); } s.dispose(); }); test('continueBatch is a no-op when batch is empty', () => { const s = createCaptureSession({ draft: store }); s.continueBatch(); assert.equal(get(s.capture).k, 'batch'); s.dispose(); }); test('continueBatch with ≥1 photo advances to pickCalibration (not Mask)', async () => { // #60: Continue must not silently Mask the first photo — user picks calibration next. const s = createCaptureSession({ draft: store }); await s.addToBatch(tinyJpeg('a')); await s.addToBatch(tinyJpeg('b')); s.continueBatch(); const cap = get(s.capture); assert.equal(cap.k, 'pickCalibration'); if (cap.k === 'pickCalibration') { assert.equal(cap.photos.length, 2); } s.dispose(); }); test('adding photos persists batch blobs in device draft', async () => { const s = createCaptureSession({ draft: store }); await s.addToBatch(tinyJpeg('a')); await s.addToBatch(tinyJpeg('b')); // allow async persist await new Promise((r) => setTimeout(r, 20)); const d = store.peek(); assert.ok(d, 'draft must exist after adding photos'); assert.equal(d!.stage, 'batch'); const batch = d!.batch ?? []; assert.equal(batch.length, 2, 'draft must store both batch blobs (not a UI-only list)'); assert.ok(batch[0].blob instanceof Blob); assert.ok(batch[1].blob instanceof Blob); s.dispose(); }); test('refresh: resumeProbe + Continue restores the batch photos', async () => { const s1 = createCaptureSession({ draft: store }); await s1.addToBatch(tinyJpeg('a')); await s1.addToBatch(tinyJpeg('b')); await new Promise((r) => setTimeout(r, 20)); s1.dispose(); const s2 = createCaptureSession({ draft: store }); await s2.resumeProbe(); assert.equal(get(s2.capture).k, 'resume'); s2.continueDraft(); await new Promise((r) => setTimeout(r, 20)); const cap = get(s2.capture); assert.equal(cap.k, 'batch'); if (cap.k === 'batch') { assert.equal(cap.photos.length, 2); assert.ok(cap.photos.every((p: Source) => p.blob instanceof Blob)); } s2.dispose(); }); test('Batch UI: AddPhoto/Batch panel supports multi-add and Continue when ≥1', () => { // Prefer BatchPhotos.svelte; fall back to AddPhoto.svelte if renamed later. const candidates = ['BatchPhotos.svelte', 'AddPhoto.svelte'].map((n) => join(here, n)); let src = ''; let found = ''; for (const p of candidates) { try { src = readFileSync(p, 'utf8'); found = p; break; } catch { /* try next */ } } assert.ok(src, 'expected BatchPhotos.svelte or AddPhoto.svelte'); assert.match(src, /Continue|continue/i, `${found} must expose Continue`); assert.match(src, /photos|batch/i, `${found} must talk about batch/photos`); // Must allow adding more than once (not a one-shot that leaves the screen). const fileInputs = src.match(/type="file"/g) ?? []; assert.ok(fileInputs.length >= 1, 'must have file picker(s)'); assert.ok( /addToBatch|onAdd|onPicked/.test(src) || /on:change/.test(src), 'must wire photo add', ); }); test('legacy addPhoto alias still appends to batch (no silent single-photo-only path)', async () => { const s = createCaptureSession({ draft: store }); await s.addPhoto(tinyJpeg('only')); const cap = get(s.capture); assert.equal(cap.k, 'batch', 'addPhoto must not jump straight to mask anymore'); if (cap.k === 'batch') assert.equal(cap.photos.length, 1); s.dispose(); });