diff --git a/frontend/src/capture/photosReadyLineup.test.ts b/frontend/src/capture/photosReadyLineup.test.ts new file mode 100644 index 0000000..42d9c5c --- /dev/null +++ b/frontend/src/capture/photosReadyLineup.test.ts @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import type { BatchPhotoResult } from './capture.types'; +import { + buildPhotosReadyLineup, + type PhotosReadyLineupItem, +} from './photosReadyLineup'; + +const here = dirname(fileURLToPath(import.meta.url)); + +function png(label: string): Blob { + return new Blob([label], { type: 'image/png' }); +} + +function jpeg(label: string): Blob { + return new Blob([label], { type: 'image/jpeg' }); +} + +function roles(items: PhotosReadyLineupItem[]): string[] { + return items.map((it) => + it.kind === 'png' ? `png:${it.role}` : `error:${it.batchIndex}`, + ); +} + +// --- pure helper: lineup order + error slots (#64) --- + +test('test(photos): lineup is clean cal → overlay → other batch PNGs in capture order (#64)', () => { + const clean = png('clean-cal'); + const overlay = png('overlay-cal'); + const sib0 = png('sib-0'); + const sib2 = png('sib-2'); + const batchApply: BatchPhotoResult[] = [ + { status: 'ok', inventoryPng: sib0 }, + null, // calibration at index 1 + { status: 'ok', inventoryPng: sib2 }, + ]; + + const lineup = buildPhotosReadyLineup({ + inventoryPng: clean, + overlayPng: overlay, + batchApply, + }); + + assert.deepEqual(roles(lineup), [ + 'png:calibration', + 'png:overlay', + 'png:batch', + 'png:batch', + ]); + assert.equal(lineup[0].kind, 'png'); + if (lineup[0].kind === 'png') { + assert.equal(lineup[0].blob, clean); + assert.equal(lineup[0].role, 'calibration'); + } + assert.equal(lineup[1].kind, 'png'); + if (lineup[1].kind === 'png') { + assert.equal(lineup[1].blob, overlay); + assert.equal(lineup[1].role, 'overlay'); + } + assert.equal(lineup[2].kind, 'png'); + if (lineup[2].kind === 'png') { + assert.equal(lineup[2].blob, sib0); + assert.equal(lineup[2].batchIndex, 0); + } + assert.equal(lineup[3].kind, 'png'); + if (lineup[3].kind === 'png') { + assert.equal(lineup[3].blob, sib2); + assert.equal(lineup[3].batchIndex, 2); + } +}); + +test('test(photos): single-photo batch lineup is clean + overlay only (#64)', () => { + const clean = png('only-clean'); + const overlay = png('only-overlay'); + const lineup = buildPhotosReadyLineup({ + inventoryPng: clean, + overlayPng: overlay, + batchApply: [null], + }); + assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay']); + assert.ok(lineup.every((it) => it.kind === 'png')); +}); + +test('test(photos): batchApply errors become clear error slots (empty mask / floor) (#64)', () => { + const clean = png('clean'); + const overlay = png('overlay'); + const ok = png('ok-sib'); + const batchApply: BatchPhotoResult[] = [ + null, + { status: 'error', message: 'empty mask: no slab pixels detected' }, + { status: 'ok', inventoryPng: ok }, + { status: 'error', message: 'PNG size floor: shorter side is below 800 px' }, + ]; + + const lineup = buildPhotosReadyLineup({ + inventoryPng: clean, + overlayPng: overlay, + batchApply, + }); + + assert.deepEqual(roles(lineup), [ + 'png:calibration', + 'png:overlay', + 'error:1', + 'png:batch', + 'error:3', + ]); + + const err1 = lineup[2]; + assert.equal(err1.kind, 'error'); + if (err1.kind === 'error') { + assert.match(err1.message, /empty mask/i); + assert.equal(err1.batchIndex, 1); + } + const err3 = lineup[4]; + assert.equal(err3.kind, 'error'); + if (err3.kind === 'error') { + assert.match(err3.message, /floor|800/i); + } + + // Successful sibling still appears after cal+overlay, in capture order among siblings + const okItem = lineup[3]; + assert.equal(okItem.kind, 'png'); + if (okItem.kind === 'png') { + assert.equal(okItem.blob, ok); + assert.equal(okItem.batchIndex, 2); + } +}); + +test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)', () => { + const clean = png('clean'); + const overlay = png('overlay'); + const thumb = jpeg('source-thumb'); + // Dishonest proxy would pass source thumbs; helper must only emit image/png blobs for png slots + const lineup = buildPhotosReadyLineup({ + inventoryPng: clean, + overlayPng: overlay, + batchApply: [ + null, + { status: 'ok', inventoryPng: png('sib') }, + ], + }); + for (const it of lineup) { + if (it.kind === 'png') { + assert.equal(it.blob.type, 'image/png', 'lineup PNG slots must be inventory PNGs'); + assert.notEqual(it.blob, thumb); + } + } +}); + +test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => { + const lineup = buildPhotosReadyLineup({ + inventoryPng: png('below'), + overlayPng: png('overlay'), + batchApply: [null, { status: 'ok', inventoryPng: png('sib') }], + sourceBelowFloor: true, + }); + assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']); +}); + +test('test(photos): empty/missing results yield empty lineup (#64)', () => { + assert.deepEqual(buildPhotosReadyLineup({}), []); + assert.deepEqual( + buildPhotosReadyLineup({ inventoryPng: null, overlayPng: null, batchApply: undefined }), + [], + ); +}); + +// --- light UI / session wiring (#64) --- + +test('test(photos): MeasurePanel shows Photos ready lineup + M1 numbers + batch errors (#64)', () => { + const panel = readFileSync(join(here, 'MeasurePanel.svelte'), 'utf8'); + assert.match(panel, /Photos ready|photos.?ready/i, 'end screen title after Calculate'); + assert.match(panel, /buildPhotosReadyLineup/, 'must use pure lineup helper'); + assert.match(panel, /Widths/, 'M1 widths still visible'); + assert.match(panel, /Sqft/, 'M1 sqft still visible'); + assert.match(panel, /Bdft/, 'M1 bdft still visible'); + assert.match(panel, /Scale/, 'M1 scale still visible'); + // Dishonest proxies + assert.ok( + !/lineup[\s\S]{0,200}state\.source\.blob/.test(panel), + 'must not lineup source thumbs', + ); + assert.match( + panel, + /error|batchApply|message/i, + 'per-photo batchApply errors must surface in the panel', + ); + assert.match(panel, /Back/, 'Back must still work from Photos ready'); +}); + +test('test(photos): App still routes measure results through Measure (or PhotosReady) with Back (#64)', () => { + const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); + // Stay on Measure with results, or dedicated panel — either is fine + assert.ok( + /MeasurePanel/.test(app) || /PhotosReady/.test(app), + 'App must render Photos-ready end screen', + ); + // Must not invent a Mask loop for the lineup + assert.ok( + !/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app), + 'App must not #each batch into Mask for Photos ready', + ); +});