test(photos): batch add + draft restore before green (#59)

Failing session/UI tests for M2-2: multi-photo batch state, Continue
when ≥1, device-draft persistence of batch blobs, Resume Continue
restores. No production change yet (TDD).
This commit is contained in:
2026-09-15 21:07:17 +00:00
parent ab5bdafbd8
commit e919fe9a35
+154
View File
@@ -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<typeof memStore>;
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();
});