From 4fa8126471fdb1978b1763963e8566acdff32cce Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Tue, 15 Sep 2026 21:10:14 +0000 Subject: [PATCH] test(photos): pick calibration before Mask (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/src/capture/session.batch.test.ts | 9 +- .../src/capture/session.calibrate.test.ts | 221 ++++++++++++++++++ 2 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 frontend/src/capture/session.calibrate.test.ts diff --git a/frontend/src/capture/session.batch.test.ts b/frontend/src/capture/session.batch.test.ts index cffe4a8..614b2df 100644 --- a/frontend/src/capture/session.batch.test.ts +++ b/frontend/src/capture/session.batch.test.ts @@ -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(); }); diff --git a/frontend/src/capture/session.calibrate.test.ts b/frontend/src/capture/session.calibrate.test.ts new file mode 100644 index 0000000..9542c5a --- /dev/null +++ b/frontend/src/capture/session.calibrate.test.ts @@ -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; + +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'); +});