diff --git a/frontend/src/capture/session.batch.test.ts b/frontend/src/capture/session.batch.test.ts new file mode 100644 index 0000000..cffe4a8 --- /dev/null +++ b/frontend/src/capture/session.batch.test.ts @@ -0,0 +1,154 @@ +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 (M1 bridge: mask on first photo)', async () => { + 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, 'mask'); + if (cap.k === 'mask') { + assert.ok(cap.source.blob instanceof Blob); + } + 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(); +});