test(photos): dimension-overlay PNG stations + results.overlayPng (#63)

Failing TDD for M2 dimension-overlay copy of the calibration inventory PNG:
width stations every 12 in from EXIF bottom (6, 18, … ≤ L−6), axis-right
length graphic, and Calculate storing overlayPng without touching batchApply[cal].
This commit is contained in:
2026-09-15 21:29:59 +00:00
parent 3936c575b2
commit c674931f5c
2 changed files with 471 additions and 0 deletions
@@ -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<typeof memStore>;
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<WorkerOut>) => 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<WorkerOut>);
});
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<WorkerOut>);
});
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<typeof createCaptureSession>,
ms = 2500,
): Promise<Mask> {
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<typeof createCaptureSession>,
photos: File[],
calIndex: number,
maskParams: Partial<MaskParams> = { 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<typeof createCaptureSession>[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<typeof createCaptureSession>[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<typeof createCaptureSession>[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]',
);
});
+185
View File
@@ -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})`);
});