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, 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(); }); type MaskBehavior = { w: number; h: number; 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; 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 = 2500, ): 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 (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity); // setSheet runs an immediate pass (after sensitivity so the pass sees frozen params) s.setSheet(maskParams.sheet ?? 'auto'); await settleOverlay(s); s.approveMask(); s.approveAxis(); s.setLength(320); // 40 in s.setThickness(16); // 2 in } test('test(photos): Calculate stores overlayPng on results, clean inventory untouched (#63)', async () => { installFakes(); let encodeN = 0; const s = createCaptureSession({ draft: store, encodeInventory: async () => { encodeN++; return { blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }), sourceBelowFloor: false, }; }, } as Parameters[0]); const a = tinyJpeg('a'); const b = tinyJpeg('b'); // cal 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') return; assert.ok(cap.results, 'results present'); assert.ok(cap.results!.inventoryPng, 'clean cal PNG in results.inventoryPng'); assert.equal(cap.results!.inventoryPng.type, 'image/png'); const overlay = (cap.results as { overlayPng?: Blob }).overlayPng; assert.ok(overlay, 'results.overlayPng must exist after Calculate (#63)'); assert.equal(overlay!.type, 'image/png'); assert.notEqual(overlay, cap.results!.inventoryPng, 'overlay is a second copy, not the clean PNG'); const apply = (cap as { batchApply?: unknown[] }).batchApply; assert.ok(Array.isArray(apply)); assert.equal(apply![1], null, 'batchApply[cal] stays null'); assert.ok(apply![0] && (apply![0] as { status: string }).status === 'ok', 'siblings unchanged'); s.dispose(); }); test('test(photos): single-photo batch still yields clean + overlayPng (#63)', async () => { installFakes(); const s = createCaptureSession({ draft: store, encodeInventory: async () => ({ blob: new Blob(['only-clean'], { type: 'image/png' }), sourceBelowFloor: false, }), } as Parameters[0]); await driveToMeasure(s, [tinyJpeg('only')], 0, { sheet: 'auto' }); await s.calculate(); const cap = get(s.capture); assert.equal(cap.k, 'measure'); if (cap.k !== 'measure') return; assert.ok(cap.results!.inventoryPng); assert.ok((cap.results as { overlayPng?: Blob }).overlayPng, 'overlay even for batch of one'); assert.deepEqual((cap as { batchApply?: unknown[] }).batchApply, [null]); s.dispose(); }); test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', async () => { installFakes(); const s = createCaptureSession({ draft: store, encodeInventory: async () => ({ blob: new Blob(['draft-clean'], { type: 'image/png' }), sourceBelowFloor: false, }), } as Parameters[0]); await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'black' }); await s.calculate(); await new Promise((r) => setTimeout(r, 30)); const d = store.peek(); assert.ok(d); assert.equal(d!.stage, 'measure'); assert.ok(d!.inventoryPng, 'draft keeps clean inventory'); assert.ok( (d as { overlayPng?: Blob | null }).overlayPng, 'draft.overlayPng must be persisted for #64 lineup', ); assert.equal(d!.batchApply![0], null, 'cal slot still null on draft'); s.dispose(); }); test('test(photos): dishonest proxies absent — no raw-photo overlay / no cal-slot stash (#63)', async () => { const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8'); const overlaySrcPath = join(here, '..', 'png', 'overlay.ts'); let overlaySrc = ''; try { overlaySrc = readFileSync(overlaySrcPath, 'utf8'); } catch { overlaySrc = ''; } assert.ok( /overlayPng|encodeOverlay|dimensionOverlay|buildOverlayPlan/i.test(sessionSrc) || overlaySrc.length > 0, 'session or png/overlay must implement dimension overlay (#63)', ); const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/); assert.ok(calcBody, 'calculate() must exist'); assert.ok( !/batchApply\s*\[\s*calIndex\s*\]\s*=\s*\{[^}]*overlay/i.test(calcBody![0]), 'must not put overlay into batchApply[cal]', ); });