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 { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
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';
|
||||
|
||||
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 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