2 Commits

Author SHA1 Message Date
grokbot_ty 2c9a50ddf9 feat(photos): Photos-ready lineup after Calculate (#64)
Add buildPhotosReadyLineup (clean cal → overlay → batch inventory PNGs
in capture order, plus empty-mask/floor error slots). MeasurePanel
switches to a Photos ready end screen with Milestone 1 numbers and the
inventory PNG lineup; Back still returns to Length axis.
2026-09-15 21:33:37 +00:00
grokbot_ty 4c3c8b49eb test(photos): Photos-ready lineup order + error slots (#64)
Failing TDD for M2 Photos-ready end screen: pure buildPhotosReadyLineup
orders clean calibration inventory PNG → dimension-overlay copy → other
batch inventory PNGs in capture order (skipping cal null), surfaces
batchApply empty-mask/floor errors, and MeasurePanel must show Photos
ready with Milestone 1 numbers (not source thumbs).
2026-09-15 21:33:00 +00:00
3 changed files with 416 additions and 53 deletions
+87 -6
View File
@@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte';
import type { Capture } from './capture.types'; import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types'; import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session'; import { fmtEighths } from './session';
import PhotoStage from './PhotoStage.svelte'; import PhotoStage from './PhotoStage.svelte';
import {
buildPhotosReadyLineup,
photosReadyPngAlt,
type PhotosReadyLineupItem,
} from './photosReadyLineup';
export let state: Extract<Capture, { k: 'measure' }>; export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void; export let setLength: (e: number) => void;
@@ -15,6 +21,32 @@
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths)); let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths)); let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let lineupUrls: string[] = [];
let lineupItems: PhotosReadyLineupItem[] = [];
$: photosReady = !!state.results;
$: {
for (const u of lineupUrls) URL.revokeObjectURL(u);
lineupUrls = [];
if (state.results) {
lineupItems = buildPhotosReadyLineup({
inventoryPng: state.results.inventoryPng,
overlayPng: state.results.overlayPng,
batchApply: state.batchApply,
sourceBelowFloor: state.results.sourceBelowFloor,
});
lineupUrls = lineupItems.map((it) =>
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
);
} else {
lineupItems = [];
}
}
onDestroy(() => {
for (const u of lineupUrls) URL.revokeObjectURL(u);
});
const positiveInches = (raw: string): number | null => { const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw); const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null; return Number.isFinite(inches) && inches > 0 ? inches : null;
@@ -46,7 +78,9 @@
</script> </script>
<section class="panel"> <section class="panel">
<h1>Measure</h1> <h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
{#if !photosReady}
<PhotoStage <PhotoStage
blob={state.source.blob} blob={state.source.blob}
bbox={state.axis.bbox} bbox={state.axis.bbox}
@@ -97,9 +131,8 @@
{calcBusy ? 'Calculating…' : 'Calculate'} {calcBusy ? 'Calculating…' : 'Calculate'}
</button> </button>
</div> </div>
{:else if state.results}
{#if state.results} <div class="results" aria-label="Photos ready">
<div class="results">
<div class="nums"> <div class="nums">
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div> <div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div> <div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
@@ -109,12 +142,42 @@
{#if state.results.sourceBelowFloor} {#if state.results.sourceBelowFloor}
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div> <div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
{/if} {/if}
{#if pngUrl && !state.results.sourceBelowFloor}
<ol class="lineup" aria-label="Inventory PNG lineup">
{#each lineupItems as item, i (i)}
{#if item.kind === 'png'}
<li class="lineup-item png">
<img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} />
<span class="role">
{#if item.role === 'calibration'}
Clean calibration
{:else if item.role === 'overlay'}
Dimension overlay
{:else}
Batch photo {(item.batchIndex ?? 0) + 1}
{/if}
</span>
</li>
{:else}
<li class="lineup-item error" role="alert">
<span class="err-title">Photo {item.batchIndex + 1} failed</span>
<span class="err-msg">{item.message}</span>
</li>
{/if}
{/each}
</ol>
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
<div class="png-wrap"> <div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" /> <img src={pngUrl} alt="Inventory PNG" />
</div> </div>
{/if} {/if}
</div> </div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
</div>
{/if} {/if}
</section> </section>
@@ -136,11 +199,29 @@
} }
.primary:disabled { background: #333; color: #777; } .primary:disabled { background: #333; color: #777; }
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; } .ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.results { margin-top: 20px; } .results { margin-top: 8px; }
.nums { display: flex; flex-direction: column; gap: 8px; } .nums { display: flex; flex-direction: column; gap: 8px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; } .nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
.nums span:first-child { color: #aaa; } .nums span:first-child { color: #aaa; }
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; } .warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
.lineup {
list-style: none; padding: 0; margin: 16px 0 0;
display: flex; flex-direction: column; gap: 14px;
}
.lineup-item.png {
text-align: center;
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0;
background-color: #eee; border-radius: 8px; padding: 8px;
}
.lineup-item.png img { max-width: 100%; border-radius: 4px; display: block; margin: 0 auto; }
.role { display: block; margin-top: 8px; font-size: 0.85rem; color: #222; font-weight: 600; }
.lineup-item.error {
border: 1px solid #8a3a2a; background: #2a1512; border-radius: 8px;
padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
}
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; } .png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
.png-wrap img { max-width: 100%; border-radius: 4px; } .png-wrap img { max-width: 100%; border-radius: 4px; }
</style> </style>
@@ -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',
);
});
+75
View File
@@ -0,0 +1,75 @@
import type { BatchPhotoResult } from './capture.types';
/** One slot in the Photos-ready end screen (#64 / docs/PHOTOS.md). */
export type PhotosReadyLineupItem =
| {
kind: 'png';
role: 'calibration' | 'overlay' | 'batch';
blob: Blob;
/** Capture-order index for non-calibration batch inventory PNGs. */
batchIndex?: number;
}
| {
kind: 'error';
batchIndex: number;
message: string;
};
export interface PhotosReadyLineupInput {
inventoryPng?: Blob | null;
overlayPng?: Blob | null;
batchApply?: BatchPhotoResult[] | null;
/** When true, omit the clean calibration PNG (M1 floor warn path). */
sourceBelowFloor?: boolean;
}
/**
* Build the Photos-ready lineup:
* 1. Clean calibration inventory PNG
* 2. Dimension-overlay copy
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
* with error slots for empty mask / floor failures.
*/
export function buildPhotosReadyLineup(
input: PhotosReadyLineupInput,
): PhotosReadyLineupItem[] {
const items: PhotosReadyLineupItem[] = [];
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
if (inventoryPng && !sourceBelowFloor) {
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
}
if (overlayPng) {
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
}
if (batchApply) {
for (let i = 0; i < batchApply.length; i++) {
const slot = batchApply[i];
if (slot === null) continue; // calibration lives in results.inventoryPng
if (slot.status === 'ok') {
items.push({
kind: 'png',
role: 'batch',
blob: slot.inventoryPng,
batchIndex: i,
});
} else {
items.push({
kind: 'error',
batchIndex: i,
message: slot.message,
});
}
}
}
return items;
}
export function photosReadyPngAlt(item: Extract<PhotosReadyLineupItem, { kind: 'png' }>): string {
if (item.role === 'calibration') return 'Calibration inventory PNG';
if (item.role === 'overlay') return 'Calibration dimension-overlay PNG';
const n = (item.batchIndex ?? 0) + 1;
return `Batch inventory PNG ${n}`;
}