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(); });