test(photos): M1 mask/axis/measure on calibration pick (#61)
Proving tests: after pick of index N≠0, Mask→Axis→Measure operate on that blob only; Calculate yields inventory PNG + M1 numbers; other batch photos stay in draft.batch untouched; resume through measure restores calibration source. FakeWorker drives the path in Node. No production change yet (TDD).
This commit is contained in:
@@ -2,9 +2,9 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { test, beforeEach } from 'node:test';
|
import { test, beforeEach, afterEach } from 'node:test';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { Draft, Source } from './capture.types';
|
import type { Draft, Mask, Source, WorkerIn, WorkerOut } from './capture.types';
|
||||||
import { createCaptureSession, type DraftStore } from './session';
|
import { createCaptureSession, type DraftStore } from './session';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -219,3 +219,317 @@ test('App wires pickCalibration state to PickCalibration panel', () => {
|
|||||||
assert.match(app, /PickCalibration/, 'App must import/render PickCalibration');
|
assert.match(app, /PickCalibration/, 'App must import/render PickCalibration');
|
||||||
assert.match(app, /pickCalibration/, 'App must branch on pickCalibration state');
|
assert.match(app, /pickCalibration/, 'App must branch on pickCalibration state');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- #61 M2-4: wire M1 mask/axis/measure onto the calibration pick ---
|
||||||
|
|
||||||
|
const MASK_W = 40;
|
||||||
|
const MASK_H = 20;
|
||||||
|
|
||||||
|
/** Fake Worker so Node can drive Mask → Axis → Measure without canvas decode. */
|
||||||
|
class FakeMaskWorker {
|
||||||
|
onmessage: ((ev: MessageEvent<WorkerOut>) => void) | null = null;
|
||||||
|
static inits: Blob[] = [];
|
||||||
|
|
||||||
|
constructor(_url: URL | string, _opts?: WorkerOptions) {}
|
||||||
|
|
||||||
|
postMessage(msg: WorkerIn) {
|
||||||
|
if (msg.kind === 'init') {
|
||||||
|
FakeMaskWorker.inits.push(msg.blob);
|
||||||
|
const id = msg.id;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.onmessage?.({ data: { id, kind: 'decoded', w: MASK_W, h: MASK_H } } as MessageEvent<WorkerOut>);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.kind === 'mask') {
|
||||||
|
const id = msg.id;
|
||||||
|
const bitset = new Uint8Array(MASK_W * MASK_H).fill(1);
|
||||||
|
const bbox = {
|
||||||
|
corners: [
|
||||||
|
[MASK_W, 0],
|
||||||
|
[MASK_W, MASK_H],
|
||||||
|
[0, MASK_H],
|
||||||
|
[0, 0],
|
||||||
|
] as [number, number][],
|
||||||
|
longSidePx: MASK_W,
|
||||||
|
angleDeg: 0,
|
||||||
|
};
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.onmessage?.({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
kind: 'ready',
|
||||||
|
slabPx: MASK_W * MASK_H,
|
||||||
|
bitset,
|
||||||
|
bbox,
|
||||||
|
slabBounds: { x: 0, y: 0, w: MASK_W, h: MASK_H },
|
||||||
|
},
|
||||||
|
} as MessageEvent<WorkerOut>);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// invalidate: no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RealWorker = globalThis.Worker;
|
||||||
|
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||||
|
|
||||||
|
function installFakeMaskWorker() {
|
||||||
|
FakeMaskWorker.inits = [];
|
||||||
|
(globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreWorkerGlobals() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleOverlay(
|
||||||
|
s: ReturnType<typeof createCaptureSession>,
|
||||||
|
ms = 800,
|
||||||
|
): 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreWorkerGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob only', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
const b = tinyJpeg('b');
|
||||||
|
const c = tinyJpeg('c');
|
||||||
|
await s.addToBatch(a);
|
||||||
|
await s.addToBatch(b);
|
||||||
|
await s.addToBatch(c);
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(1);
|
||||||
|
|
||||||
|
let cap = get(s.capture);
|
||||||
|
assert.equal(cap.k, 'mask');
|
||||||
|
if (cap.k === 'mask') {
|
||||||
|
assert.equal(cap.source.blob, b, 'Mask must open on picked photo index 1');
|
||||||
|
assert.notEqual(cap.source.blob, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick an immediate mask pass (skip 2s sensitivity debounce).
|
||||||
|
s.setSheet('green');
|
||||||
|
await settleOverlay(s);
|
||||||
|
|
||||||
|
// Worker must have been inited only for the calibration blob — not a, not c.
|
||||||
|
assert.ok(FakeMaskWorker.inits.length >= 1, 'mask worker must init');
|
||||||
|
for (const blob of FakeMaskWorker.inits) {
|
||||||
|
assert.equal(blob, b, 'Mask worker must never init on non-calibration batch photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
s.approveMask();
|
||||||
|
cap = get(s.capture);
|
||||||
|
assert.equal(cap.k, 'axis', 'approve Mask → Length axis');
|
||||||
|
if (cap.k === 'axis') {
|
||||||
|
assert.equal(cap.source.blob, b, 'Axis must keep the calibration source');
|
||||||
|
}
|
||||||
|
|
||||||
|
s.approveAxis();
|
||||||
|
cap = get(s.capture);
|
||||||
|
assert.equal(cap.k, 'measure', 'approve Axis → Measure');
|
||||||
|
if (cap.k === 'measure') {
|
||||||
|
assert.equal(cap.source.blob, b, 'Measure must keep the calibration source');
|
||||||
|
}
|
||||||
|
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||||
|
async () =>
|
||||||
|
({
|
||||||
|
width: MASK_W,
|
||||||
|
height: MASK_H,
|
||||||
|
close() {},
|
||||||
|
}) as ImageBitmap;
|
||||||
|
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
const b = tinyJpeg('b');
|
||||||
|
await s.addToBatch(a);
|
||||||
|
await s.addToBatch(b);
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(1);
|
||||||
|
s.setSheet('black');
|
||||||
|
await settleOverlay(s);
|
||||||
|
s.approveMask();
|
||||||
|
s.approveAxis();
|
||||||
|
|
||||||
|
// L = 40 in (320 eighths), thickness = 2 in (16 eighths) — scale ≈ longest_axis_px / L
|
||||||
|
s.setLength(320);
|
||||||
|
s.setThickness(16);
|
||||||
|
await s.calculate();
|
||||||
|
|
||||||
|
const cap = get(s.capture);
|
||||||
|
assert.equal(cap.k, 'measure');
|
||||||
|
if (cap.k === 'measure') {
|
||||||
|
assert.equal(cap.source.blob, b);
|
||||||
|
assert.ok(cap.results, 'Calculate must produce results');
|
||||||
|
const r = cap.results!;
|
||||||
|
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
||||||
|
assert.equal(typeof r.scale, 'number');
|
||||||
|
assert.ok(r.scale > 0, 'scale px/in > 0');
|
||||||
|
assert.equal(typeof r.sqft, 'number');
|
||||||
|
assert.equal(typeof r.bdft, 'number');
|
||||||
|
// CAPTURE.md: bdft = sqft × thickness_in
|
||||||
|
const thicknessIn = 16 / 8;
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(r.bdft - r.sqft * thicknessIn) < 0.011,
|
||||||
|
`bdft ${r.bdft} must equal sqft×thickness (${r.sqft}*${thicknessIn})`,
|
||||||
|
);
|
||||||
|
assert.ok(r.widths, 'widths present');
|
||||||
|
assert.equal(r.lengthEighths, 320);
|
||||||
|
assert.equal(r.thicknessEighths, 16);
|
||||||
|
assert.equal(cap.source.widthPx, MASK_W, 'measure source dims from calibration decode');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still only calibration blob through the mask worker.
|
||||||
|
for (const blob of FakeMaskWorker.inits) {
|
||||||
|
assert.equal(blob, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#61: other batch photos stay in draft.batch untouched by mask approve', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
const b = tinyJpeg('b');
|
||||||
|
const c = tinyJpeg('c');
|
||||||
|
await s.addToBatch(a);
|
||||||
|
await s.addToBatch(b);
|
||||||
|
await s.addToBatch(c);
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(1);
|
||||||
|
s.setSheet('auto');
|
||||||
|
await settleOverlay(s);
|
||||||
|
|
||||||
|
const before = store.peek();
|
||||||
|
assert.ok(before);
|
||||||
|
assert.equal((before!.batch ?? []).length, 3);
|
||||||
|
assert.equal(before!.batch![0].blob, a);
|
||||||
|
assert.equal(before!.batch![1].blob, b);
|
||||||
|
assert.equal(before!.batch![2].blob, c);
|
||||||
|
|
||||||
|
s.approveMask();
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
|
const after = store.peek();
|
||||||
|
assert.ok(after);
|
||||||
|
assert.equal(after!.stage, 'axis');
|
||||||
|
assert.equal(after!.calibrationIndex, 1);
|
||||||
|
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
|
||||||
|
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
|
||||||
|
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
||||||
|
assert.equal(after!.batch![1].blob, b);
|
||||||
|
assert.equal(after!.batch![2].blob, c, 'batch[2] untouched — no Mask UI on other shots');
|
||||||
|
|
||||||
|
// Approve axis too — batch still untouched.
|
||||||
|
s.approveAxis();
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const meas = store.peek();
|
||||||
|
assert.equal(meas!.stage, 'measure');
|
||||||
|
assert.equal((meas!.batch ?? []).length, 3);
|
||||||
|
assert.equal(meas!.batch![0].blob, a);
|
||||||
|
assert.equal(meas!.batch![2].blob, c);
|
||||||
|
assert.equal(meas!.calibrationIndex, 1);
|
||||||
|
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#61: draft resume through measure restores calibration source (not photos[0])', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||||
|
async () =>
|
||||||
|
({
|
||||||
|
width: MASK_W,
|
||||||
|
height: MASK_H,
|
||||||
|
close() {},
|
||||||
|
}) as ImageBitmap;
|
||||||
|
|
||||||
|
const s1 = createCaptureSession({ draft: store });
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
const b = tinyJpeg('b');
|
||||||
|
await s1.addToBatch(a);
|
||||||
|
await s1.addToBatch(b);
|
||||||
|
s1.continueBatch();
|
||||||
|
s1.pickCalibration(1);
|
||||||
|
s1.setSheet('green');
|
||||||
|
await settleOverlay(s1);
|
||||||
|
s1.approveMask();
|
||||||
|
s1.approveAxis();
|
||||||
|
s1.setLength(320);
|
||||||
|
s1.setThickness(16);
|
||||||
|
await s1.calculate();
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
s1.dispose();
|
||||||
|
|
||||||
|
FakeMaskWorker.inits = [];
|
||||||
|
const s2 = createCaptureSession({ draft: store });
|
||||||
|
await s2.resumeProbe();
|
||||||
|
assert.equal(get(s2.capture).k, 'resume');
|
||||||
|
s2.continueDraft();
|
||||||
|
// rederiveMask via FakeWorker
|
||||||
|
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', 'Continue must restore Measure');
|
||||||
|
if (cap.k === 'measure') {
|
||||||
|
assert.equal(cap.source.blob, b, 'restored Measure source is calibration pick');
|
||||||
|
assert.ok(cap.results, 'results restored');
|
||||||
|
}
|
||||||
|
assert.equal(store.peek()?.calibrationIndex, 1);
|
||||||
|
// Resume rederive may re-init worker on calibration source only.
|
||||||
|
for (const blob of FakeMaskWorker.inits) {
|
||||||
|
assert.equal(blob, b, 'resume must not mask non-calibration batch photos');
|
||||||
|
}
|
||||||
|
s2.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
|
||||||
|
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||||
|
assert.match(app, /MaskPanel/);
|
||||||
|
assert.match(app, /AxisPanel/);
|
||||||
|
assert.match(app, /MeasurePanel/);
|
||||||
|
// Dishonest proxy: must not iterate batch photos into Mask UI.
|
||||||
|
assert.ok(
|
||||||
|
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
|
||||||
|
'App must not #each batch photos into MaskPanel',
|
||||||
|
);
|
||||||
|
const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
!/for\s*\([^)]*batch[^)]*\)[\s\S]{0,120}(enterMaskOnSource|approveMask|k:\s*'mask')/.test(
|
||||||
|
sessionSrc,
|
||||||
|
),
|
||||||
|
'session must not loop Mask over every batch photo in this issue',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user