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:
@@ -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]',
|
||||
);
|
||||
});
|
||||
@@ -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})`);
|
||||
});
|
||||
Reference in New Issue
Block a user