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