4fa8126471
Failing session/UI tests for M2-3: Continue → pickCalibration (not first-photo Mask proxy), user pick enters Mask on that Source, draft remembers calibrationIndex, Resume restores. No production change yet (TDD).
156 lines
5.2 KiB
TypeScript
156 lines
5.2 KiB
TypeScript
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 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();
|
|
});
|