diff --git a/frontend/src/capture/session.calibrate.test.ts b/frontend/src/capture/session.calibrate.test.ts index 9542c5a..c6057d6 100644 --- a/frontend/src/capture/session.calibrate.test.ts +++ b/frontend/src/capture/session.calibrate.test.ts @@ -2,9 +2,9 @@ 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 { test, beforeEach, afterEach } from 'node:test'; import { get } from 'svelte/store'; -import type { Draft, Source } from './capture.types'; +import type { Draft, Mask, Source, WorkerIn, WorkerOut } from './capture.types'; import { createCaptureSession, type DraftStore } from './session'; const here = dirname(fileURLToPath(import.meta.url)); @@ -219,3 +219,317 @@ test('App wires pickCalibration state to PickCalibration panel', () => { assert.match(app, /PickCalibration/, 'App must import/render PickCalibration'); assert.match(app, /pickCalibration/, 'App must branch on pickCalibration state'); }); + +// --- #61 M2-4: wire M1 mask/axis/measure onto the calibration pick --- + +const MASK_W = 40; +const MASK_H = 20; + +/** Fake Worker so Node can drive Mask → Axis → Measure without canvas decode. */ +class FakeMaskWorker { + onmessage: ((ev: MessageEvent) => void) | null = null; + static inits: Blob[] = []; + + constructor(_url: URL | string, _opts?: WorkerOptions) {} + + postMessage(msg: WorkerIn) { + if (msg.kind === 'init') { + FakeMaskWorker.inits.push(msg.blob); + const id = msg.id; + queueMicrotask(() => { + this.onmessage?.({ data: { id, kind: 'decoded', w: MASK_W, h: MASK_H } } as MessageEvent); + }); + return; + } + if (msg.kind === 'mask') { + const id = msg.id; + const bitset = new Uint8Array(MASK_W * MASK_H).fill(1); + const bbox = { + corners: [ + [MASK_W, 0], + [MASK_W, MASK_H], + [0, MASK_H], + [0, 0], + ] as [number, number][], + longSidePx: MASK_W, + angleDeg: 0, + }; + queueMicrotask(() => { + this.onmessage?.({ + data: { + id, + kind: 'ready', + slabPx: MASK_W * MASK_H, + bitset, + bbox, + slabBounds: { x: 0, y: 0, w: MASK_W, h: MASK_H }, + }, + } as MessageEvent); + }); + return; + } + // invalidate: no-op + } + + terminate() {} +} + +const RealWorker = globalThis.Worker; +const RealCreateImageBitmap = globalThis.createImageBitmap; + +function installFakeMaskWorker() { + FakeMaskWorker.inits = []; + (globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker; +} + +function restoreWorkerGlobals() { + if (RealWorker === undefined) { + delete (globalThis as unknown as { Worker?: unknown }).Worker; + } else { + globalThis.Worker = RealWorker; + } + if (RealCreateImageBitmap === undefined) { + delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap; + } else { + globalThis.createImageBitmap = RealCreateImageBitmap; + } +} + +async function settleOverlay( + s: ReturnType, + ms = 800, +): Promise { + const start = Date.now(); + while (Date.now() - start < ms) { + const cap = get(s.capture); + if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error('mask overlay never settled'); +} + +afterEach(() => { + restoreWorkerGlobals(); +}); + +test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob only', async () => { + installFakeMaskWorker(); + 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(); + s.pickCalibration(1); + + let cap = get(s.capture); + assert.equal(cap.k, 'mask'); + if (cap.k === 'mask') { + assert.equal(cap.source.blob, b, 'Mask must open on picked photo index 1'); + assert.notEqual(cap.source.blob, a); + } + + // Kick an immediate mask pass (skip 2s sensitivity debounce). + s.setSheet('green'); + await settleOverlay(s); + + // Worker must have been inited only for the calibration blob — not a, not c. + assert.ok(FakeMaskWorker.inits.length >= 1, 'mask worker must init'); + for (const blob of FakeMaskWorker.inits) { + assert.equal(blob, b, 'Mask worker must never init on non-calibration batch photos'); + } + + s.approveMask(); + cap = get(s.capture); + assert.equal(cap.k, 'axis', 'approve Mask → Length axis'); + if (cap.k === 'axis') { + assert.equal(cap.source.blob, b, 'Axis must keep the calibration source'); + } + + s.approveAxis(); + cap = get(s.capture); + assert.equal(cap.k, 'measure', 'approve Axis → Measure'); + if (cap.k === 'measure') { + assert.equal(cap.source.blob, b, 'Measure must keep the calibration source'); + } + + s.dispose(); +}); + +test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => { + installFakeMaskWorker(); + (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = + async () => + ({ + width: MASK_W, + height: MASK_H, + close() {}, + }) as ImageBitmap; + + 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); + s.setSheet('black'); + await settleOverlay(s); + s.approveMask(); + s.approveAxis(); + + // L = 40 in (320 eighths), thickness = 2 in (16 eighths) — scale ≈ longest_axis_px / L + s.setLength(320); + s.setThickness(16); + await s.calculate(); + + const cap = get(s.capture); + assert.equal(cap.k, 'measure'); + if (cap.k === 'measure') { + assert.equal(cap.source.blob, b); + assert.ok(cap.results, 'Calculate must produce results'); + const r = cap.results!; + assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob'); + assert.equal(typeof r.scale, 'number'); + assert.ok(r.scale > 0, 'scale px/in > 0'); + assert.equal(typeof r.sqft, 'number'); + assert.equal(typeof r.bdft, 'number'); + // CAPTURE.md: bdft = sqft × thickness_in + const thicknessIn = 16 / 8; + assert.ok( + Math.abs(r.bdft - r.sqft * thicknessIn) < 0.011, + `bdft ${r.bdft} must equal sqft×thickness (${r.sqft}*${thicknessIn})`, + ); + assert.ok(r.widths, 'widths present'); + assert.equal(r.lengthEighths, 320); + assert.equal(r.thicknessEighths, 16); + assert.equal(cap.source.widthPx, MASK_W, 'measure source dims from calibration decode'); + } + + // Still only calibration blob through the mask worker. + for (const blob of FakeMaskWorker.inits) { + assert.equal(blob, b); + } + + s.dispose(); +}); + +test('#61: other batch photos stay in draft.batch untouched by mask approve', async () => { + installFakeMaskWorker(); + 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(); + s.pickCalibration(1); + s.setSheet('auto'); + await settleOverlay(s); + + const before = store.peek(); + assert.ok(before); + assert.equal((before!.batch ?? []).length, 3); + assert.equal(before!.batch![0].blob, a); + assert.equal(before!.batch![1].blob, b); + assert.equal(before!.batch![2].blob, c); + + s.approveMask(); + await new Promise((r) => setTimeout(r, 20)); + + const after = store.peek(); + assert.ok(after); + assert.equal(after!.stage, 'axis'); + assert.equal(after!.calibrationIndex, 1); + assert.equal(after!.source.blob, b, 'draft.source is calibration only'); + assert.equal((after!.batch ?? []).length, 3, 'full batch retained'); + assert.equal(after!.batch![0].blob, a, 'batch[0] untouched'); + assert.equal(after!.batch![1].blob, b); + assert.equal(after!.batch![2].blob, c, 'batch[2] untouched — no Mask UI on other shots'); + + // Approve axis too — batch still untouched. + s.approveAxis(); + await new Promise((r) => setTimeout(r, 20)); + const meas = store.peek(); + assert.equal(meas!.stage, 'measure'); + assert.equal((meas!.batch ?? []).length, 3); + assert.equal(meas!.batch![0].blob, a); + assert.equal(meas!.batch![2].blob, c); + assert.equal(meas!.calibrationIndex, 1); + + s.dispose(); +}); + +test('#61: draft resume through measure restores calibration source (not photos[0])', async () => { + installFakeMaskWorker(); + (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = + async () => + ({ + width: MASK_W, + height: MASK_H, + close() {}, + }) as ImageBitmap; + + 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); + s1.setSheet('green'); + await settleOverlay(s1); + s1.approveMask(); + s1.approveAxis(); + s1.setLength(320); + s1.setThickness(16); + await s1.calculate(); + await new Promise((r) => setTimeout(r, 30)); + s1.dispose(); + + FakeMaskWorker.inits = []; + const s2 = createCaptureSession({ draft: store }); + await s2.resumeProbe(); + assert.equal(get(s2.capture).k, 'resume'); + s2.continueDraft(); + // rederiveMask via FakeWorker + const start = Date.now(); + let cap = get(s2.capture); + while (cap.k !== 'measure' && Date.now() - start < 1500) { + await new Promise((r) => setTimeout(r, 10)); + cap = get(s2.capture); + } + assert.equal(cap.k, 'measure', 'Continue must restore Measure'); + if (cap.k === 'measure') { + assert.equal(cap.source.blob, b, 'restored Measure source is calibration pick'); + assert.ok(cap.results, 'results restored'); + } + assert.equal(store.peek()?.calibrationIndex, 1); + // Resume rederive may re-init worker on calibration source only. + for (const blob of FakeMaskWorker.inits) { + assert.equal(blob, b, 'resume must not mask non-calibration batch photos'); + } + s2.dispose(); +}); + +test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => { + const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); + assert.match(app, /MaskPanel/); + assert.match(app, /AxisPanel/); + assert.match(app, /MeasurePanel/); + // Dishonest proxy: must not iterate batch photos into Mask UI. + assert.ok( + !/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app), + 'App must not #each batch photos into MaskPanel', + ); + const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8'); + assert.ok( + !/for\s*\([^)]*batch[^)]*\)[\s\S]{0,120}(enterMaskOnSource|approveMask|k:\s*'mask')/.test( + sessionSrc, + ), + 'session must not loop Mask over every batch photo in this issue', + ); +});