@@ -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' ,
) ;
} ) ;