c674931f5c
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].
287 lines
9.0 KiB
TypeScript
287 lines
9.0 KiB
TypeScript
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]',
|
|
);
|
|
});
|