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:
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user