diff --git a/frontend/src/capture/session.overlay.test.ts b/frontend/src/capture/session.overlay.test.ts new file mode 100644 index 0000000..c43d1ce --- /dev/null +++ b/frontend/src/capture/session.overlay.test.ts @@ -0,0 +1,286 @@ +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]', + ); +}); diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts new file mode 100644 index 0000000..c0a6251 --- /dev/null +++ b/frontend/src/png/overlay.test.ts @@ -0,0 +1,185 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + overlayWidthStations, + pickBottomTowardTop, + axisRightUnit, + buildOverlayPlan, + type OverlayPlan, +} from './overlay.ts'; +import { orientedRect, minAreaRect } from '../geometry/rect.ts'; + +// --- Station math (#63) --- + +test('overlayWidthStations: every 12 in from 6 while ≤ L−6 (#63)', () => { + assert.deepEqual(overlayWidthStations(48), [6, 18, 30, 42]); + assert.deepEqual(overlayWidthStations(40), [6, 18, 30]); + assert.deepEqual(overlayWidthStations(12), [6]); + assert.deepEqual(overlayWidthStations(18), [6]); + assert.deepEqual(overlayWidthStations(19), [6, 18]); + assert.deepEqual(overlayWidthStations(11), []); + assert.deepEqual(overlayWidthStations(10), []); + assert.deepEqual(overlayWidthStations(6), []); +}); + +test('overlayWidthStations: never tip-inclusive 0 or L (#63)', () => { + for (const L of [24, 36, 48, 60, 72]) { + const stations = overlayWidthStations(L); + assert.ok(!stations.includes(0), `L=${L} must not include tip 0`); + assert.ok(!stations.includes(L), `L=${L} must not include tip L`); + for (const s of stations) { + assert.ok(s >= 6 && s <= L - 6, `station ${s} within [6, L−6] for L=${L}`); + assert.equal((s - 6) % 12, 0, `station ${s} is 6+12k`); + } + } +}); + +// --- EXIF-oriented bottom along approved axis (#63) --- + +test('pickBottomTowardTop: larger y is bottom (EXIF-oriented photo, y↓) (#63)', () => { + // Axis along +y (down the photo): start at top, end at bottom of span + const start: [number, number] = [100, 50]; // top of photo + const end: [number, number] = [100, 450]; // bottom of photo + const { bottom, top, ux, uy } = pickBottomTowardTop(start, end); + assert.deepEqual(bottom, end, 'end nearer photo bottom is bottom'); + assert.deepEqual(top, start); + // Unit from bottom → top points up (−y) + assert.ok(Math.abs(ux) < 1e-9); + assert.ok(uy < 0, `uy=${uy} must point toward photo top`); +}); + +test('pickBottomTowardTop: flipped axis endpoints still pick EXIF bottom (#63)', () => { + const a: [number, number] = [50, 400]; // bottom-ish + const b: [number, number] = [50, 40]; // top-ish + const r1 = pickBottomTowardTop(a, b); + const r2 = pickBottomTowardTop(b, a); + assert.deepEqual(r1.bottom, a); + assert.deepEqual(r2.bottom, a); + assert.deepEqual(r1.top, b); + assert.deepEqual(r2.top, b); +}); + +test('pickBottomTowardTop: length-axis direction stays on approved span (not photo upright) (#63)', () => { + // Diagonal approved axis — bottom is still the larger-y end, unit follows the diagonal + const start: [number, number] = [10, 10]; + const end: [number, number] = [210, 410]; + const { bottom, top, ux, uy } = pickBottomTowardTop(start, end); + assert.deepEqual(bottom, end); + assert.deepEqual(top, start); + const len = Math.hypot(top[0] - bottom[0], top[1] - bottom[1]); + assert.ok(Math.abs(Math.hypot(ux, uy) - 1) < 1e-9); + assert.ok(Math.abs(ux - (top[0] - bottom[0]) / len) < 1e-9); + assert.ok(Math.abs(uy - (top[1] - bottom[1]) / len) < 1e-9); +}); + +test('axisRightUnit: right of bottom→top travel in canvas (y↓) (#63)', () => { + // Travel up (−y): facing up, right is +x + const [rx, ry] = axisRightUnit(0, -1); + assert.ok(rx > 0.99 && Math.abs(ry) < 1e-9, `right of up should be +x, got (${rx},${ry})`); + // Travel +x (right): facing right, right is +y (down) + const [rx2, ry2] = axisRightUnit(1, 0); + assert.ok(Math.abs(rx2) < 1e-9 && ry2 > 0.99, `right of +x should be +y, got (${rx2},${ry2})`); +}); + +// --- Overlay plan geometry (#63) --- + +test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in stations (#63)', () => { + // Uniform 480×100 slab @ 10 px/in → L=48in, axis +x. Photo taller so bottom is clear. + // Place slab in a tall canvas so EXIF bottom (large y) picks the +x? Wait axis is +x (horizontal). + // For horizontal axis both ends have same y — tie-break must be stable. + const W = 480, H = 200; + const bitset = new Uint8Array(W * H); + // Slab occupies y=50..149, x=0..479 (full width strip) + for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1; + const box = orientedRect(minAreaRect(bitset, W, H)!, 0); // long side +x + const scale = 10; // px/in + const lengthIn = 48; + const plan = buildOverlayPlan({ + bitset, + widthPx: W, + heightPx: H, + bbox: box, + scale, + lengthIn, + lengthLabel: '48"', + // Identity inventory map (source == inventory) for unit geometry + mapToInventory: (x, y) => [x, y], + }); + + assert.ok(plan.length, 'must include length dimension'); + assert.equal(plan.widths.length, 4, 'stations 6,18,30,42'); + assert.deepEqual( + plan.widths.map((w) => w.stationIn), + [6, 18, 30, 42], + ); + + // Length graphic must sit to the axis-right of the wood (outside edge) + const mid = plan.length; + // For axis +x, bottom tie-break: if y equal, pickBottomTowardTop uses a documented rule. + // Right of bottom→top should be outside the slab along the short axis. + assert.ok( + mid.outside, + 'length dimension must be flagged outside the slab on axis-right', + ); + + // Width arrows: tips at left/right slab edges (perp extent), text on line + for (const w of plan.widths) { + assert.ok(w.left && w.right, 'width has left/right tips'); + assert.ok(typeof w.label === 'string' && w.label.length > 0, 'width has measurement text'); + // Across wood: left and right differ primarily along short axis (±y for +x length) + const dx = w.right[0] - w.left[0]; + const dy = w.right[1] - w.left[1]; + assert.ok(Math.abs(dy) > Math.abs(dx), 'width span is across (perp to +x length)'); + } + + // Color contract for draw consumers + assert.equal(plan.stroke, '#000000'); + assert.ok(plan.fontPx >= 24, `fontPx ${plan.fontPx} large enough to read`); +}); + +test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => { + // Vertical slab: length along +y (photo upright matches axis). Bottom = larger y. + const W = 100, H = 480; + const bitset = new Uint8Array(W * H).fill(1); + const box = orientedRect(minAreaRect(bitset, W, H)!, 90); // long side +y + const plan = buildOverlayPlan({ + bitset, + widthPx: W, + heightPx: H, + bbox: box, + scale: 10, + lengthIn: 48, + lengthLabel: '48"', + mapToInventory: (x, y) => [x, y], + }); + assert.deepEqual( + plan.widths.map((w) => w.stationIn), + [6, 18, 30, 42], + 'must be 12in overlay stations, not M1 every-6 tip-trimmed list', + ); + // First station is 6in up from photo-bottom end of span + const first = plan.widths[0]; + // Bottom of photo is y=H-ish; 6in @ 10px/in = 60px toward top → y ≈ H-1 - 60 + assert.ok(first.center[1] > H / 2, 'first station nearer the photo bottom half'); +}); + +test('buildOverlayPlan: dishonest upright-photo axes rejected — uses approved bbox axis (#63)', () => { + // Slab long along +x but we approve axis at 0; plan length direction must follow bbox, not image H. + const W = 400, H = 120; + const bitset = new Uint8Array(W * H).fill(1); + const box = orientedRect(minAreaRect(bitset, W, H)!, 0); + const plan: OverlayPlan = buildOverlayPlan({ + bitset, + widthPx: W, + heightPx: H, + bbox: box, + scale: 10, + lengthIn: 40, + lengthLabel: '40"', + mapToInventory: (x, y) => [x, y], + }); + // Length line endpoints should differ mainly in x (along approved axis), not y + const lx = Math.abs(plan.length.a[0] - plan.length.b[0]); + const ly = Math.abs(plan.length.a[1] - plan.length.b[1]); + assert.ok(lx > ly * 2, `length graphic follows approved +x axis (dx=${lx}, dy=${ly})`); +});