diff --git a/frontend/src/capture/session.batchApply.test.ts b/frontend/src/capture/session.batchApply.test.ts new file mode 100644 index 0000000..131cbe8 --- /dev/null +++ b/frontend/src/capture/session.batchApply.test.ts @@ -0,0 +1,497 @@ +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, afterEach } from 'node:test'; +import { get } from 'svelte/store'; +import type { Draft, Mask, MaskParams, Source, WorkerIn, WorkerOut } 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 } { + let mem: Draft | null = null; + return { + loadDraft: async () => mem, + saveDraft: async (d) => { + mem = d; + }, + clearDraft: async () => { + mem = null; + }, + peek: () => mem, + }; +} + +let store: ReturnType; + +beforeEach(() => { + store = memStore(); +}); + +/** Controllable FakeWorker for #62 batch-apply paths. */ +type MaskBehavior = { + w: number; + h: number; + /** slabPx; 0 ⇒ empty mask */ + slabPx: number; + slabBounds?: { x: number; y: number; w: number; h: number }; +}; + +class FakeMaskWorker { + onmessage: ((ev: MessageEvent) => void) | null = null; + static inits: Blob[] = []; + static maskCalls: { sheet: string; sensitivity: number; blob: Blob | null }[] = []; + static lastInitBlob: Blob | null = null; + /** Per-init blob → behavior. Default: full mask at LARGE dims (above PNG floor). */ + static behaviorFor: (blob: Blob) => MaskBehavior = () => ({ + w: 1000, + h: 900, + slabPx: 1000 * 900, + slabBounds: { x: 0, y: 0, w: 1000, h: 900 }, + }); + + constructor(_url: URL | string, _opts?: WorkerOptions) {} + + postMessage(msg: WorkerIn) { + if (msg.kind === 'init') { + FakeMaskWorker.inits.push(msg.blob); + FakeMaskWorker.lastInitBlob = msg.blob; + const id = msg.id; + const b = FakeMaskWorker.behaviorFor(msg.blob); + queueMicrotask(() => { + this.onmessage?.({ data: { id, kind: 'decoded', w: b.w, h: b.h } } as MessageEvent); + }); + return; + } + if (msg.kind === 'mask') { + const id = msg.id; + const blob = FakeMaskWorker.lastInitBlob; + FakeMaskWorker.maskCalls.push({ + sheet: msg.sheet, + sensitivity: msg.sensitivity, + blob, + }); + const b = blob ? FakeMaskWorker.behaviorFor(blob) : { w: 40, h: 20, slabPx: 0 }; + const bitset = new Uint8Array(Math.max(1, b.w * b.h)).fill(b.slabPx > 0 ? 1 : 0); + const bbox = { + corners: [ + [b.w, 0], + [b.w, b.h], + [0, b.h], + [0, 0], + ] as [number, number][], + longSidePx: b.w, + angleDeg: 0, + }; + const slabBounds = b.slabBounds ?? { x: 0, y: 0, w: b.w, h: b.h }; + queueMicrotask(() => { + this.onmessage?.({ + data: { + id, + kind: 'ready', + slabPx: b.slabPx, + bitset, + bbox, + slabBounds, + }, + } as MessageEvent); + }); + return; + } + } + + terminate() {} +} + +const RealWorker = globalThis.Worker; +const RealCreateImageBitmap = globalThis.createImageBitmap; + +function installFakes() { + FakeMaskWorker.inits = []; + FakeMaskWorker.maskCalls = []; + FakeMaskWorker.lastInitBlob = null; + FakeMaskWorker.behaviorFor = () => ({ + w: 1000, + h: 900, + slabPx: 1000 * 900, + slabBounds: { x: 0, y: 0, w: 1000, h: 900 }, + }); + (globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker; + (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = + async (blob: ImageBitmapSource) => { + const b = blob instanceof Blob ? FakeMaskWorker.behaviorFor(blob) : { w: 1000, h: 900 }; + return { + width: b.w, + height: b.h, + close() {}, + } as ImageBitmap; + }; +} + +function restoreGlobals() { + 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; + } +} + +afterEach(() => { + restoreGlobals(); +}); + +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'); +} + +async function driveToMeasure( + s: ReturnType, + photos: File[], + calIndex: number, + maskParams: Partial = { sheet: 'green', sensitivity: 50 }, +) { + for (const p of photos) await s.addToBatch(p); + s.continueBatch(); + s.pickCalibration(calIndex); + if (maskParams.sheet) s.setSheet(maskParams.sheet); + if (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity); + // setSheet already runs a pass; if only sensitivity, force via setSheet after + if (!maskParams.sheet) s.setSheet('auto'); + await settleOverlay(s); + s.approveMask(); + s.approveAxis(); + s.setLength(320); // 40 in + s.setThickness(16); // 2 in +} + +type BatchApplySlot = + | null + | { status: 'ok'; inventoryPng: Blob } + | { status: 'error'; message: string }; + +function batchApplyOf(cap: ReturnType['capture']>>): BatchApplySlot[] | undefined { + if (cap.k !== 'measure') return undefined; + return (cap as { batchApply?: BatchApplySlot[] }).batchApply; +} + +// --- #62: apply approved mask + crop to non-calibration batch photos after Calculate --- + +test('#62: Calculate applies frozen mask params to every non-calibration batch photo', async () => { + installFakes(); + const encodeCalls: { widthPx: number; heightPx: number; sheet: string; sensitivity: number }[] = []; + const s = createCaptureSession({ + draft: store, + encodeInventory: async (input) => { + encodeCalls.push({ + widthPx: input.source.widthPx, + heightPx: input.source.heightPx, + sheet: (input.mask as Mask).params?.sheet ?? '?', + sensitivity: (input.mask as Mask).params?.sensitivity ?? -1, + }); + // Distinctive PNG blob so tests cannot confuse with source JPEG thumbs + return { + blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }), + sourceBelowFloor: false, + }; + }, + } as Parameters[0]); + + const a = tinyJpeg('a'); + const b = tinyJpeg('b'); // calibration + const c = tinyJpeg('c'); + await driveToMeasure(s, [a, b, c], 1, { sheet: 'black', sensitivity: 72 }); + await s.calculate(); + + const cap = get(s.capture); + assert.equal(cap.k, 'measure', 'must stay on Measure — do not reopen Mask'); + const apply = batchApplyOf(cap); + assert.ok(Array.isArray(apply), 'measure.batchApply must exist after Calculate'); + assert.equal(apply!.length, 3, 'one slot per batch photo'); + assert.equal(apply![1], null, 'calibration index is null (uses results.inventoryPng)'); + + assert.ok(apply![0] && apply![0].status === 'ok', 'batch[0] applied ok'); + assert.ok(apply![2] && apply![2].status === 'ok', 'batch[2] applied ok'); + if (apply![0]?.status === 'ok') { + assert.equal(apply![0].inventoryPng.type, 'image/png'); + assert.notEqual(apply![0].inventoryPng, a, 'must not proxy source thumb as inventory PNG'); + } + if (apply![2]?.status === 'ok') { + assert.equal(apply![2].inventoryPng.type, 'image/png'); + assert.notEqual(apply![2].inventoryPng, c); + } + + // Frozen params: every sibling mask call used calibration sheet/sensitivity + const siblingBlobs = new Set([a, c]); + const siblingMasks = FakeMaskWorker.maskCalls.filter((m) => m.blob && siblingBlobs.has(m.blob as File)); + assert.ok(siblingMasks.length >= 2, 'mask worker must run on non-calibration photos'); + for (const m of siblingMasks) { + assert.equal(m.sheet, 'black', 'reuse approved sheet'); + assert.equal(m.sensitivity, 72, 'reuse approved sensitivity'); + } + + s.dispose(); +}); + +test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => { + installFakes(); + const a = tinyJpeg('a'); // will empty-mask + const b = tinyJpeg('b'); // cal + const c = tinyJpeg('c'); // ok + + FakeMaskWorker.behaviorFor = (blob) => { + if (blob === a) { + return { w: 1000, h: 900, slabPx: 0, slabBounds: { x: 0, y: 0, w: 0, h: 0 } }; + } + return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } }; + }; + + const s = createCaptureSession({ + draft: store, + encodeInventory: async () => ({ + blob: new Blob(['ok-png'], { type: 'image/png' }), + sourceBelowFloor: false, + }), + } as Parameters[0]); + + await driveToMeasure(s, [a, b, c], 1, { sheet: 'green' }); + await s.calculate(); + + const apply = batchApplyOf(get(s.capture)); + assert.ok(apply); + assert.equal(apply![1], null); + assert.ok(apply![0] && apply![0].status === 'error', 'empty mask → error'); + if (apply![0]?.status === 'error') { + assert.match(apply![0].message, /empty|mask/i, 'clear per-photo empty-mask error'); + } + assert.ok(apply![2] && apply![2].status === 'ok', 'other photos still proceed'); + s.dispose(); +}); + +test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => { + installFakes(); + const a = tinyJpeg('a'); // ok + const b = tinyJpeg('b'); // cal + const c = tinyJpeg('c'); // below floor + + FakeMaskWorker.behaviorFor = (blob) => { + if (blob === c) { + return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } }; + } + return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } }; + }; + + const s = createCaptureSession({ + draft: store, + encodeInventory: async (input) => { + const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800; + return { + blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }), + sourceBelowFloor: below, + }; + }, + } as Parameters[0]); + + await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' }); + await s.calculate(); + + const apply = batchApplyOf(get(s.capture)); + assert.ok(apply); + assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok'); + assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error'); + if (apply![2]?.status === 'error') { + assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error'); + } + s.dispose(); +}); + +test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are visual)', async () => { + installFakes(); + const s = createCaptureSession({ + draft: store, + encodeInventory: async () => ({ + blob: new Blob(['png'], { type: 'image/png' }), + sourceBelowFloor: false, + }), + } as Parameters[0]); + + const a = tinyJpeg('a'); + const b = tinyJpeg('b'); + await driveToMeasure(s, [a, b], 1, { sheet: 'green' }); + await s.calculate(); + + const cap = get(s.capture); + assert.equal(cap.k, 'measure'); + if (cap.k === 'measure') { + assert.ok(cap.results); + assert.equal(typeof cap.results!.scale, 'number'); + assert.ok(cap.results!.scale > 0); + assert.equal(typeof cap.results!.sqft, 'number'); + assert.equal(typeof cap.results!.bdft, 'number'); + assert.ok(cap.results!.widths); + // batchApply ok slots must NOT carry measure fields + const apply = (cap as { batchApply?: BatchApplySlot[] }).batchApply; + assert.ok(apply); + const slot = apply![0]; + assert.ok(slot && slot.status === 'ok'); + if (slot && slot.status === 'ok') { + assert.equal( + 'scale' in slot, + false, + 'non-cal apply must not attach scale', + ); + assert.equal('sqft' in slot, false); + assert.equal('bdft' in slot, false); + assert.equal('widths' in slot, false); + } + } + s.dispose(); +}); + +test('#62: draft persists batchApply after Calculate (for #64 UI later)', async () => { + installFakes(); + const s = createCaptureSession({ + draft: store, + encodeInventory: async () => ({ + blob: new Blob(['draft-png'], { type: 'image/png' }), + sourceBelowFloor: false, + }), + } as Parameters[0]); + + const a = tinyJpeg('a'); + const b = tinyJpeg('b'); + const c = tinyJpeg('c'); + await driveToMeasure(s, [a, b, c], 0, { sheet: 'black' }); // cal = first + await s.calculate(); + await new Promise((r) => setTimeout(r, 30)); + + const d = store.peek(); + assert.ok(d); + assert.equal(d!.stage, 'measure'); + assert.ok(Array.isArray(d!.batchApply), 'draft.batchApply must be persisted'); + assert.equal(d!.batchApply!.length, 3); + assert.equal(d!.batchApply![0], null, 'calibration slot null on draft'); + assert.ok(d!.batchApply![1] && (d!.batchApply![1] as { status: string }).status === 'ok'); + assert.ok(d!.batchApply![2] && (d!.batchApply![2] as { status: string }).status === 'ok'); + s.dispose(); +}); + +test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mask)', async () => { + installFakes(); + const s = createCaptureSession({ + draft: store, + encodeInventory: async () => ({ + blob: new Blob(['only'], { type: 'image/png' }), + sourceBelowFloor: false, + }), + } as Parameters[0]); + + const only = tinyJpeg('only'); + await driveToMeasure(s, [only], 0, { sheet: 'auto' }); + const initsBefore = FakeMaskWorker.inits.length; + await s.calculate(); + + const apply = batchApplyOf(get(s.capture)); + assert.ok(apply); + assert.deepEqual(apply, [null]); + + // No extra mask inits for non-existent siblings after Calculate + const siblingInits = FakeMaskWorker.inits.slice(initsBefore).filter((blob) => blob !== only); + assert.equal(siblingInits.length, 0, 'must not invent Mask work for absent siblings'); + s.dispose(); +}); + +test('#62: Calculate does not re-enter Mask / does not open Mask controls for siblings', async () => { + installFakes(); + const s = createCaptureSession({ + draft: store, + encodeInventory: async () => ({ + blob: new Blob(['x'], { type: 'image/png' }), + sourceBelowFloor: false, + }), + } as Parameters[0]); + + await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'green' }); + await s.calculate(); + assert.equal(get(s.capture).k, 'measure'); + + // Session source must never loop enterMaskOnSource over batch after Calculate + const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8'); + assert.ok( + /batchApply|applyBatch|apply.*non-?calibrat/i.test(sessionSrc), + 'session must implement batch apply after Calculate (#62)', + ); + // Dishonest proxy: must not set k:'mask' inside calculate for each photo + const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/); + assert.ok(calcBody, 'calculate() must exist'); + assert.ok( + !/set\(\s*\{\s*k:\s*'mask'/.test(calcBody![0]), + 'calculate must not reopen Mask state for siblings', + ); + + const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); + assert.ok( + !/\{\s*#each\s+[^}]*batchApply[^}]*\}[\s\S]*MaskPanel/i.test(app) && + !/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app), + 'App must not #each photos into MaskPanel', + ); + s.dispose(); +}); + +test('#62: resume restores batchApply alongside calibration results', async () => { + installFakes(); + const s1 = createCaptureSession({ + draft: store, + encodeInventory: async () => ({ + blob: new Blob(['resume-png'], { type: 'image/png' }), + sourceBelowFloor: false, + }), + } as Parameters[0]); + + const a = tinyJpeg('a'); + const b = tinyJpeg('b'); + await driveToMeasure(s1, [a, b], 1, { sheet: 'green' }); + await s1.calculate(); + await new Promise((r) => setTimeout(r, 30)); + s1.dispose(); + + FakeMaskWorker.inits = []; + const s2 = createCaptureSession({ draft: store } as Parameters[0]); + await s2.resumeProbe(); + s2.continueDraft(); + 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'); + const apply = batchApplyOf(cap); + assert.ok(apply, 'restored measure must include batchApply'); + assert.equal(apply!.length, 2); + assert.equal(apply![1], null); + assert.ok(apply![0] && apply![0].status === 'ok'); + if (apply![0]?.status === 'ok') { + assert.equal(apply![0].inventoryPng.type, 'image/png'); + } + s2.dispose(); +});