test(photos): pick calibration before Mask (#60)

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).
This commit is contained in:
2026-09-15 21:10:14 +00:00
parent ff4bb0f31e
commit 4fa8126471
2 changed files with 226 additions and 4 deletions
+5 -4
View File
@@ -68,15 +68,16 @@ test('continueBatch is a no-op when batch is empty', () => {
s.dispose();
});
test('continueBatch with ≥1 photo advances (M1 bridge: mask on first photo)', async () => {
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, 'mask');
if (cap.k === 'mask') {
assert.ok(cap.source.blob instanceof Blob);
assert.equal(cap.k, 'pickCalibration');
if (cap.k === 'pickCalibration') {
assert.equal(cap.photos.length, 2);
}
s.dispose();
});
@@ -0,0 +1,221 @@
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 {
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('continueBatch with ≥1 photo goes to pickCalibration (not mask — no first-photo proxy)', 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, 'pickCalibration', 'Continue must open pick-calibration, not skip to Mask');
if (cap.k === 'pickCalibration') {
assert.equal(cap.photos.length, 2);
}
s.dispose();
});
test('single-photo batch: Continue still opens pickCalibration so user confirms the only photo', async () => {
const s = createCaptureSession({ draft: store });
await s.addToBatch(tinyJpeg('only'));
s.continueBatch();
const cap = get(s.capture);
assert.equal(cap.k, 'pickCalibration');
if (cap.k === 'pickCalibration') {
assert.equal(cap.photos.length, 1);
}
s.dispose();
});
test('pickCalibration(index) enters Mask on that Source only (not always first)', async () => {
const s = createCaptureSession({ draft: store });
const a = tinyJpeg('a');
const b = tinyJpeg('b');
const c = tinyJpeg('c');
await s.addToBatch(a);
await s.addToBatch(b);
await s.addToBatch(c);
s.continueBatch();
assert.equal(get(s.capture).k, 'pickCalibration');
s.pickCalibration(1);
const cap = get(s.capture);
assert.equal(cap.k, 'mask');
if (cap.k === 'mask') {
// Chosen photo must be the second batch blob, not photos[0].
assert.equal(cap.source.blob, b, 'Mask source must be the user-picked photo');
assert.notEqual(cap.source.blob, a, 'must not silently use first photo');
}
s.dispose();
});
test('single-photo pick enters Mask on that photo', async () => {
const s = createCaptureSession({ draft: store });
const only = tinyJpeg('only');
await s.addToBatch(only);
s.continueBatch();
s.pickCalibration(0);
const cap = get(s.capture);
assert.equal(cap.k, 'mask');
if (cap.k === 'mask') {
assert.equal(cap.source.blob, only);
}
s.dispose();
});
test('pickCalibration out of range is a no-op', async () => {
const s = createCaptureSession({ draft: store });
await s.addToBatch(tinyJpeg('a'));
s.continueBatch();
s.pickCalibration(-1);
assert.equal(get(s.capture).k, 'pickCalibration');
s.pickCalibration(99);
assert.equal(get(s.capture).k, 'pickCalibration');
s.dispose();
});
test('draft persists pickCalibration stage with batch photos', async () => {
const s = createCaptureSession({ draft: store });
await s.addToBatch(tinyJpeg('a'));
await s.addToBatch(tinyJpeg('b'));
s.continueBatch();
await new Promise((r) => setTimeout(r, 20));
const d = store.peek();
assert.ok(d, 'draft must exist on pickCalibration');
assert.equal(d!.stage, 'pickCalibration');
assert.equal((d!.batch ?? []).length, 2);
s.dispose();
});
test('draft remembers calibration choice after pick', async () => {
const s = createCaptureSession({ draft: store });
const a = tinyJpeg('a');
const b = tinyJpeg('b');
await s.addToBatch(a);
await s.addToBatch(b);
s.continueBatch();
s.pickCalibration(1);
await new Promise((r) => setTimeout(r, 20));
const d = store.peek();
assert.ok(d);
assert.equal(d!.stage, 'mask');
assert.equal(d!.calibrationIndex, 1, 'draft must store which batch photo is calibration');
assert.equal(d!.source.blob, b, 'draft.source must be the chosen calibration photo');
assert.equal((d!.batch ?? []).length, 2, 'full batch still stored');
s.dispose();
});
test('refresh: resumeProbe + Continue restores pickCalibration with batch', async () => {
const s1 = createCaptureSession({ draft: store });
await s1.addToBatch(tinyJpeg('a'));
await s1.addToBatch(tinyJpeg('b'));
s1.continueBatch();
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, 'pickCalibration');
if (cap.k === 'pickCalibration') {
assert.equal(cap.photos.length, 2);
assert.ok(cap.photos.every((p: Source) => p.blob instanceof Blob));
}
s2.dispose();
});
test('refresh: resume after pick restores Mask on the chosen calibration photo', async () => {
const s1 = createCaptureSession({ draft: store });
const a = tinyJpeg('a');
const b = tinyJpeg('b');
await s1.addToBatch(a);
await s1.addToBatch(b);
s1.continueBatch();
s1.pickCalibration(1);
await new Promise((r) => setTimeout(r, 20));
s1.dispose();
const s2 = createCaptureSession({ draft: store });
await s2.resumeProbe();
s2.continueDraft();
await new Promise((r) => setTimeout(r, 40));
const cap = get(s2.capture);
assert.equal(cap.k, 'mask');
if (cap.k === 'mask') {
// Blob identity may round-trip as a new Blob with same bytes; compare size/type content.
assert.ok(cap.source.blob instanceof Blob);
const chosen = new Uint8Array(await b.arrayBuffer());
const got = new Uint8Array(await cap.source.blob.arrayBuffer());
assert.deepEqual(got, chosen, 'restored Mask source must be the picked calibration photo');
}
// calibrationIndex still on draft
const d = store.peek();
assert.equal(d?.calibrationIndex, 1);
s2.dispose();
});
test('PickCalibration UI: thumbs over batch + pick action (not auto-first)', () => {
const path = join(here, 'PickCalibration.svelte');
let src = '';
try {
src = readFileSync(path, 'utf8');
} catch {
/* missing = fail */
}
assert.ok(src, 'expected PickCalibration.svelte');
assert.match(src, /calibrat/i, 'UI must mention calibration');
assert.match(src, /photos|thumb/i, 'UI must show batch thumbs');
assert.ok(
/onPick|pickCalibration|on:click/.test(src),
'UI must wire a per-photo pick action',
);
// Must not hardcode always-first without user interaction.
assert.ok(
!/photos\[0\].*mask|always.*first/i.test(src) || /onPick|on:click/.test(src),
'must require user pick',
);
});
test('App wires pickCalibration state to PickCalibration panel', () => {
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
assert.match(app, /PickCalibration/, 'App must import/render PickCalibration');
assert.match(app, /pickCalibration/, 'App must branch on pickCalibration state');
});