test(photos): batch apply approved mask after Calculate (#62)
Prove frozen calibration mask reuse, per-photo errors, inventory PNG results, calibration-only measures, draft persistence, and no Mask re-prompting for batch siblings.
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
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, Source, 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();
|
||||
});
|
||||
|
||||
/** Controllable FakeWorker for #62 batch-apply paths. */
|
||||
type MaskBehavior = {
|
||||
w: number;
|
||||
h: number;
|
||||
/** slabPx; 0 ⇒ empty mask */
|
||||
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;
|
||||
/** Per-init blob → behavior. Default: full mask at LARGE dims (above PNG floor). */
|
||||
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 = 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');
|
||||
}
|
||||
|
||||
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 (maskParams.sheet) s.setSheet(maskParams.sheet);
|
||||
if (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity);
|
||||
// setSheet already runs a pass; if only sensitivity, force via setSheet after
|
||||
if (!maskParams.sheet) s.setSheet('auto');
|
||||
await settleOverlay(s);
|
||||
s.approveMask();
|
||||
s.approveAxis();
|
||||
s.setLength(320); // 40 in
|
||||
s.setThickness(16); // 2 in
|
||||
}
|
||||
|
||||
type BatchApplySlot =
|
||||
| null
|
||||
| { status: 'ok'; inventoryPng: Blob }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
function batchApplyOf(cap: ReturnType<typeof get<ReturnType<typeof createCaptureSession>['capture']>>): BatchApplySlot[] | undefined {
|
||||
if (cap.k !== 'measure') return undefined;
|
||||
return (cap as { batchApply?: BatchApplySlot[] }).batchApply;
|
||||
}
|
||||
|
||||
// --- #62: apply approved mask + crop to non-calibration batch photos after Calculate ---
|
||||
|
||||
test('#62: Calculate applies frozen mask params to every non-calibration batch photo', async () => {
|
||||
installFakes();
|
||||
const encodeCalls: { widthPx: number; heightPx: number; sheet: string; sensitivity: number }[] = [];
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
encodeCalls.push({
|
||||
widthPx: input.source.widthPx,
|
||||
heightPx: input.source.heightPx,
|
||||
sheet: (input.mask as Mask).params?.sheet ?? '?',
|
||||
sensitivity: (input.mask as Mask).params?.sensitivity ?? -1,
|
||||
});
|
||||
// Distinctive PNG blob so tests cannot confuse with source JPEG thumbs
|
||||
return {
|
||||
blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b'); // calibration
|
||||
const c = tinyJpeg('c');
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'black', sensitivity: 72 });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure', 'must stay on Measure — do not reopen Mask');
|
||||
const apply = batchApplyOf(cap);
|
||||
assert.ok(Array.isArray(apply), 'measure.batchApply must exist after Calculate');
|
||||
assert.equal(apply!.length, 3, 'one slot per batch photo');
|
||||
assert.equal(apply![1], null, 'calibration index is null (uses results.inventoryPng)');
|
||||
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'batch[0] applied ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'batch[2] applied ok');
|
||||
if (apply![0]?.status === 'ok') {
|
||||
assert.equal(apply![0].inventoryPng.type, 'image/png');
|
||||
assert.notEqual(apply![0].inventoryPng, a, 'must not proxy source thumb as inventory PNG');
|
||||
}
|
||||
if (apply![2]?.status === 'ok') {
|
||||
assert.equal(apply![2].inventoryPng.type, 'image/png');
|
||||
assert.notEqual(apply![2].inventoryPng, c);
|
||||
}
|
||||
|
||||
// Frozen params: every sibling mask call used calibration sheet/sensitivity
|
||||
const siblingBlobs = new Set([a, c]);
|
||||
const siblingMasks = FakeMaskWorker.maskCalls.filter((m) => m.blob && siblingBlobs.has(m.blob as File));
|
||||
assert.ok(siblingMasks.length >= 2, 'mask worker must run on non-calibration photos');
|
||||
for (const m of siblingMasks) {
|
||||
assert.equal(m.sheet, 'black', 'reuse approved sheet');
|
||||
assert.equal(m.sensitivity, 72, 'reuse approved sensitivity');
|
||||
}
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // will empty-mask
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // ok
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === a) {
|
||||
return { w: 1000, h: 900, slabPx: 0, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['ok-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.equal(apply![1], null);
|
||||
assert.ok(apply![0] && apply![0].status === 'error', 'empty mask → error');
|
||||
if (apply![0]?.status === 'error') {
|
||||
assert.match(apply![0].message, /empty|mask/i, 'clear per-photo empty-mask error');
|
||||
}
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'other photos still proceed');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // ok
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // below floor
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === c) {
|
||||
return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800;
|
||||
return {
|
||||
blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowFloor: below,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' });
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error');
|
||||
if (apply![2]?.status === 'error') {
|
||||
assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are visual)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
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') {
|
||||
assert.ok(cap.results);
|
||||
assert.equal(typeof cap.results!.scale, 'number');
|
||||
assert.ok(cap.results!.scale > 0);
|
||||
assert.equal(typeof cap.results!.sqft, 'number');
|
||||
assert.equal(typeof cap.results!.bdft, 'number');
|
||||
assert.ok(cap.results!.widths);
|
||||
// batchApply ok slots must NOT carry measure fields
|
||||
const apply = (cap as { batchApply?: BatchApplySlot[] }).batchApply;
|
||||
assert.ok(apply);
|
||||
const slot = apply![0];
|
||||
assert.ok(slot && slot.status === 'ok');
|
||||
if (slot && slot.status === 'ok') {
|
||||
assert.equal(
|
||||
'scale' in slot,
|
||||
false,
|
||||
'non-cal apply must not attach scale',
|
||||
);
|
||||
assert.equal('sqft' in slot, false);
|
||||
assert.equal('bdft' in slot, false);
|
||||
assert.equal('widths' in slot, false);
|
||||
}
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: draft persists batchApply after Calculate (for #64 UI later)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
const c = tinyJpeg('c');
|
||||
await driveToMeasure(s, [a, b, c], 0, { sheet: 'black' }); // cal = first
|
||||
await s.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const d = store.peek();
|
||||
assert.ok(d);
|
||||
assert.equal(d!.stage, 'measure');
|
||||
assert.ok(Array.isArray(d!.batchApply), 'draft.batchApply must be persisted');
|
||||
assert.equal(d!.batchApply!.length, 3);
|
||||
assert.equal(d!.batchApply![0], null, 'calibration slot null on draft');
|
||||
assert.ok(d!.batchApply![1] && (d!.batchApply![1] as { status: string }).status === 'ok');
|
||||
assert.ok(d!.batchApply![2] && (d!.batchApply![2] as { status: string }).status === 'ok');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mask)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const only = tinyJpeg('only');
|
||||
await driveToMeasure(s, [only], 0, { sheet: 'auto' });
|
||||
const initsBefore = FakeMaskWorker.inits.length;
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.deepEqual(apply, [null]);
|
||||
|
||||
// No extra mask inits for non-existent siblings after Calculate
|
||||
const siblingInits = FakeMaskWorker.inits.slice(initsBefore).filter((blob) => blob !== only);
|
||||
assert.equal(siblingInits.length, 0, 'must not invent Mask work for absent siblings');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: Calculate does not re-enter Mask / does not open Mask controls for siblings', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['x'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
assert.equal(get(s.capture).k, 'measure');
|
||||
|
||||
// Session source must never loop enterMaskOnSource over batch after Calculate
|
||||
const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8');
|
||||
assert.ok(
|
||||
/batchApply|applyBatch|apply.*non-?calibrat/i.test(sessionSrc),
|
||||
'session must implement batch apply after Calculate (#62)',
|
||||
);
|
||||
// Dishonest proxy: must not set k:'mask' inside calculate for each photo
|
||||
const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/);
|
||||
assert.ok(calcBody, 'calculate() must exist');
|
||||
assert.ok(
|
||||
!/set\(\s*\{\s*k:\s*'mask'/.test(calcBody![0]),
|
||||
'calculate must not reopen Mask state for siblings',
|
||||
);
|
||||
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
assert.ok(
|
||||
!/\{\s*#each\s+[^}]*batchApply[^}]*\}[\s\S]*MaskPanel/i.test(app) &&
|
||||
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
|
||||
'App must not #each photos into MaskPanel',
|
||||
);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: resume restores batchApply alongside calibration results', async () => {
|
||||
installFakes();
|
||||
const s1 = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['resume-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await driveToMeasure(s1, [a, b], 1, { sheet: 'green' });
|
||||
await s1.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
s1.dispose();
|
||||
|
||||
FakeMaskWorker.inits = [];
|
||||
const s2 = createCaptureSession({ draft: store } as Parameters<typeof createCaptureSession>[0]);
|
||||
await s2.resumeProbe();
|
||||
s2.continueDraft();
|
||||
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');
|
||||
const apply = batchApplyOf(cap);
|
||||
assert.ok(apply, 'restored measure must include batchApply');
|
||||
assert.equal(apply!.length, 2);
|
||||
assert.equal(apply![1], null);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok');
|
||||
if (apply![0]?.status === 'ok') {
|
||||
assert.equal(apply![0].inventoryPng.type, 'image/png');
|
||||
}
|
||||
s2.dispose();
|
||||
});
|
||||
Reference in New Issue
Block a user