fix(m4): keep tape calibration on one page (#99)
This commit is contained in:
@@ -26,7 +26,6 @@
|
|||||||
|
|
||||||
let cap: Capture = { k: 'batch', photos: [] };
|
let cap: Capture = { k: 'batch', photos: [] };
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let pngUrl: string | null = null;
|
|
||||||
let showSettings = false;
|
let showSettings = false;
|
||||||
let showSlabs = false;
|
let showSlabs = false;
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
|
|
||||||
const unsubCap = session.capture.subscribe((c) => {
|
const unsubCap = session.capture.subscribe((c) => {
|
||||||
cap = c;
|
cap = c;
|
||||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
|
||||||
});
|
});
|
||||||
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||||
|
|
||||||
@@ -228,17 +226,13 @@
|
|||||||
session.setCropFill(pct);
|
session.setCropFill(pct);
|
||||||
void updateSettings({ crop_fill: pct }).catch(() => {});
|
void updateSettings({ crop_fill: pct }).catch(() => {});
|
||||||
}}
|
}}
|
||||||
approveAxis={session.approveAxis}
|
calculateFromAxis={session.calculateFromAxis}
|
||||||
back={session.back}
|
back={session.back}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else if cap.k === 'measure'}
|
||||||
<MeasurePanel
|
<MeasurePanel
|
||||||
state={cap}
|
state={cap}
|
||||||
setLength={session.setLength}
|
|
||||||
setThickness={session.setThickness}
|
|
||||||
calculate={session.calculate}
|
|
||||||
back={session.back}
|
back={session.back}
|
||||||
{pngUrl}
|
|
||||||
onContinue={saving ? undefined : () => void continueToReview()}
|
onContinue={saving ? undefined : () => void continueToReview()}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import type { Capture, PhotoTransform } from './capture.types';
|
import type { Capture, PhotoTransform } from './capture.types';
|
||||||
|
import { eighthsToIn, inToEighths } from './capture.types';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
import {
|
import {
|
||||||
CALIBRATION_FRAME,
|
CALIBRATION_FRAME,
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
sourceH: number;
|
sourceH: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
export let setCropFill: (pct: number) => void = () => {};
|
export let setCropFill: (pct: number) => void = () => {};
|
||||||
export let approveAxis: () => void;
|
export let calculateFromAxis: (lengthEighths: number, thicknessEighths: number) => Promise<void>;
|
||||||
export let back: () => void;
|
export let back: () => void;
|
||||||
|
|
||||||
const frame = CALIBRATION_FRAME;
|
const frame = CALIBRATION_FRAME;
|
||||||
@@ -36,6 +37,9 @@
|
|||||||
let tickB = state.axis.tickEndY;
|
let tickB = state.axis.tickEndY;
|
||||||
let focus: 'A' | 'B' = 'A';
|
let focus: 'A' | 'B' = 'A';
|
||||||
let fill = cropFill;
|
let fill = cropFill;
|
||||||
|
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||||
|
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||||
|
let calcBusy = false;
|
||||||
let drag: 'tick' | 'tilt' | 'pan' | 'pinch' | null = null;
|
let drag: 'tick' | 'tilt' | 'pan' | 'pinch' | null = null;
|
||||||
let pointers = new Map<number, [number, number]>();
|
let pointers = new Map<number, [number, number]>();
|
||||||
let pinch0 = 0;
|
let pinch0 = 0;
|
||||||
@@ -266,6 +270,31 @@
|
|||||||
reseat(fill);
|
reseat(fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const positiveInches = (raw: string): number | null => {
|
||||||
|
const inches = Number.parseFloat(raw);
|
||||||
|
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitInches = (raw: string): number => {
|
||||||
|
const inches = positiveInches(raw);
|
||||||
|
return inches === null ? 0 : inToEighths(inches);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function onCalculate() {
|
||||||
|
const length = commitInches(String(lengthIn));
|
||||||
|
const thickness = commitInches(String(thicknessIn));
|
||||||
|
lengthIn = length === 0 ? '' : String(eighthsToIn(length));
|
||||||
|
thicknessIn = thickness === 0 ? '' : String(eighthsToIn(thickness));
|
||||||
|
if (length === 0 || thickness === 0) return;
|
||||||
|
calcBusy = true;
|
||||||
|
try {
|
||||||
|
publish();
|
||||||
|
await calculateFromAxis(length, thickness);
|
||||||
|
} finally {
|
||||||
|
calcBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const bmp = await decodeOrientedBitmap(state.source.blob);
|
const bmp = await decodeOrientedBitmap(state.source.blob);
|
||||||
bitmap = bmp;
|
bitmap = bmp;
|
||||||
@@ -332,9 +361,22 @@
|
|||||||
<span class="val">{fill}%</span>
|
<span class="val">{fill}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fields">
|
||||||
|
<label class="field">
|
||||||
|
<span class="ctl">Length L (tape reading)</span>
|
||||||
|
<input type="number" aria-label="Length L" inputmode="decimal" step="0.125" min="0" placeholder="0" bind:value={lengthIn} />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="ctl">Thickness</span>
|
||||||
|
<input type="number" aria-label="Thickness" inputmode="decimal" step="0.125" min="0" placeholder="0" bind:value={thicknessIn} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
|
<button type="button" class="primary" disabled={calcBusy || positiveInches(String(lengthIn)) === null || positiveInches(String(thicknessIn)) === null} on:click={onCalculate}>
|
||||||
|
{calcBusy ? 'Calculating…' : 'Calculate'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -354,6 +396,13 @@
|
|||||||
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
|
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.fit { margin-top: 8px; }
|
.fit { margin-top: 8px; }
|
||||||
.actions { display: flex; gap: 10px; margin-top: 16px; }
|
.actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.fields { display: flex; gap: 12px; margin-top: 8px; }
|
||||||
|
.field { flex: 1; }
|
||||||
|
.field input {
|
||||||
|
box-sizing: border-box; width: 100%; min-height: 44px; padding: 10px; font-size: 1rem;
|
||||||
|
border-radius: 3px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||||
|
}
|
||||||
|
.primary:disabled { background: #333; color: #777; }
|
||||||
.primary {
|
.primary {
|
||||||
flex: 2; padding: 12px; font-size: 1rem; border-radius: 3px; border: 0;
|
flex: 2; padding: 12px; font-size: 1rem; border-radius: 3px; border: 0;
|
||||||
background: #00e5ff; color: #002; font-weight: 700;
|
background: #00e5ff; color: #002; font-weight: 700;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import type { Capture } from './capture.types';
|
import type { Capture } from './capture.types';
|
||||||
import { inToEighths, eighthsToIn } from './capture.types';
|
|
||||||
import { fmtEighths } from './session';
|
import { fmtEighths } from './session';
|
||||||
import PhotoStage from './PhotoStage.svelte';
|
|
||||||
import {
|
import {
|
||||||
buildPhotosReadyLineup,
|
buildPhotosReadyLineup,
|
||||||
photosReadyPngAlt,
|
photosReadyPngAlt,
|
||||||
@@ -11,22 +9,13 @@
|
|||||||
} from './photosReadyLineup';
|
} from './photosReadyLineup';
|
||||||
|
|
||||||
export let state: Extract<Capture, { k: 'measure' }>;
|
export let state: Extract<Capture, { k: 'measure' }>;
|
||||||
export let setLength: (e: number) => void;
|
|
||||||
export let setThickness: (e: number) => void;
|
|
||||||
export let calculate: () => Promise<void>;
|
|
||||||
export let back: () => void;
|
export let back: () => void;
|
||||||
export let pngUrl: string | null;
|
|
||||||
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
|
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
|
||||||
export let onContinue: (() => void) | undefined = undefined;
|
export let onContinue: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
let calcBusy = false;
|
|
||||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
|
||||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
|
||||||
|
|
||||||
let lineupUrls: string[] = [];
|
let lineupUrls: string[] = [];
|
||||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||||
|
|
||||||
$: photosReady = !!state.results;
|
|
||||||
$: {
|
$: {
|
||||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||||
lineupUrls = [];
|
lineupUrls = [];
|
||||||
@@ -48,92 +37,17 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||||
});
|
});
|
||||||
|
|
||||||
const positiveInches = (raw: string): number | null => {
|
|
||||||
const inches = Number.parseFloat(raw);
|
|
||||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const commitLength = () => {
|
|
||||||
const inches = positiveInches(String(lengthIn));
|
|
||||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
|
||||||
setLength(eighths);
|
|
||||||
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
|
||||||
};
|
|
||||||
const commitThickness = () => {
|
|
||||||
const inches = positiveInches(String(thicknessIn));
|
|
||||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
|
||||||
setThickness(eighths);
|
|
||||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
|
||||||
};
|
|
||||||
|
|
||||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
|
|
||||||
|
|
||||||
const doCalc = async () => {
|
|
||||||
calcBusy = true;
|
|
||||||
try {
|
|
||||||
commitLength();
|
|
||||||
commitThickness();
|
|
||||||
await calculate();
|
|
||||||
} finally { calcBusy = false; }
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
|
<h1>Photos ready</h1>
|
||||||
|
|
||||||
{#if !photosReady}
|
|
||||||
<PhotoStage
|
|
||||||
blob={state.source.blob}
|
|
||||||
bbox={state.axis.bbox}
|
|
||||||
maskBitset={state.approvedMask.bitset}
|
|
||||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
|
||||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="fields">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Length L (tape reading)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
aria-label="Length L"
|
|
||||||
inputmode="decimal"
|
|
||||||
step="0.125"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
bind:value={lengthIn}
|
|
||||||
on:blur={commitLength}
|
|
||||||
on:change={commitLength}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Thickness</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
aria-label="Thickness"
|
|
||||||
inputmode="decimal"
|
|
||||||
step="0.125"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
bind:value={thicknessIn}
|
|
||||||
on:blur={commitThickness}
|
|
||||||
on:change={commitThickness}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{#if !state.results}
|
||||||
|
<p class="hint">Calculating…</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="primary"
|
|
||||||
disabled={calcBusy || !canCalc}
|
|
||||||
on:click={doCalc}
|
|
||||||
>
|
|
||||||
{calcBusy ? 'Calculating…' : 'Calculate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if state.results}
|
{:else}
|
||||||
<div class="results" aria-label="Photos ready">
|
<div class="results" aria-label="Photos ready">
|
||||||
<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>
|
||||||
@@ -169,12 +83,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</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">
|
|
||||||
<img src={pngUrl} alt="Inventory PNG" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -191,14 +99,6 @@
|
|||||||
<style>
|
<style>
|
||||||
.panel { padding: 16px; color: #eee; }
|
.panel { padding: 16px; color: #eee; }
|
||||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
|
||||||
.field { flex: 1; }
|
|
||||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
|
||||||
.field input {
|
|
||||||
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
|
|
||||||
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.row { display: flex; gap: 10px; margin-top: 20px; }
|
.row { display: flex; gap: 10px; margin-top: 20px; }
|
||||||
.primary {
|
.primary {
|
||||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||||
@@ -229,6 +129,4 @@
|
|||||||
}
|
}
|
||||||
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
|
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
|
||||||
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
|
.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 img { max-width: 100%; border-radius: 4px; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ export type Capture =
|
|||||||
k: 'axis';
|
k: 'axis';
|
||||||
source: Source;
|
source: Source;
|
||||||
approvedMask: Mask; // frozen footprint
|
approvedMask: Mask; // frozen footprint
|
||||||
axis: Axis; // live axis.bbox being nudged
|
axis: Axis; // live tape line
|
||||||
|
lengthEighths: Eighths;
|
||||||
|
thicknessEighths: Eighths;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
k: 'measure';
|
k: 'measure';
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { test } from 'node:test';
|
|||||||
import { inToEighths } from './capture.types';
|
import { inToEighths } from './capture.types';
|
||||||
|
|
||||||
const panelSrc = readFileSync(
|
const panelSrc = readFileSync(
|
||||||
|
join(dirname(fileURLToPath(import.meta.url)), 'AxisPanel.svelte'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const measureSrc = readFileSync(
|
||||||
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
|
|
||||||
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
|
test('tape page exposes typed number inputs for Length L and Thickness', () => {
|
||||||
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
|
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
|
||||||
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -31,6 +35,11 @@ test('MeasurePanel exposes typed number inputs for Length L and Thickness', () =
|
|||||||
false,
|
false,
|
||||||
'Length must not be a read-only fmtEighths span',
|
'Length must not be a read-only fmtEighths span',
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
/aria-label="Length L"/.test(measureSrc),
|
||||||
|
false,
|
||||||
|
'the separate measure page must not collect length',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
||||||
|
|||||||
@@ -358,6 +358,51 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl
|
|||||||
s.dispose();
|
s.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('calculateFromAxis stays on the tape page until the calibrated result exists', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||||
|
async () => ({ width: MASK_W, height: MASK_H, close() {} }) as ImageBitmap;
|
||||||
|
|
||||||
|
let releaseCalibration!: () => void;
|
||||||
|
const calibrationGate = new Promise<void>((resolve) => { releaseCalibration = resolve; });
|
||||||
|
const s = createCaptureSession({
|
||||||
|
draft: store,
|
||||||
|
encodeInventory: async () => {
|
||||||
|
await calibrationGate;
|
||||||
|
return { blob: new Blob(['clean'], { type: 'image/png' }), sourceBelowFloor: false };
|
||||||
|
},
|
||||||
|
encodeOverlay: async () => new Blob(['overlay'], { type: 'image/png' }),
|
||||||
|
});
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
await s.addToBatch(a);
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(0);
|
||||||
|
s.setSheet('black');
|
||||||
|
await settleOverlay(s);
|
||||||
|
s.approveMask();
|
||||||
|
assert.equal(get(s.capture).k, 'axis');
|
||||||
|
|
||||||
|
const calculating = s.calculateFromAxis(320, 16);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
const during = get(s.capture);
|
||||||
|
assert.equal(during.k, 'axis', 'tape page stays mounted while Calculate runs');
|
||||||
|
if (during.k === 'axis') {
|
||||||
|
assert.equal(during.lengthEighths, 320);
|
||||||
|
assert.equal(during.thicknessEighths, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseCalibration();
|
||||||
|
await calculating;
|
||||||
|
const after = get(s.capture);
|
||||||
|
assert.equal(after.k, 'measure');
|
||||||
|
if (after.k === 'measure') {
|
||||||
|
assert.ok(after.results, 'Photos ready opens only with a completed result');
|
||||||
|
assert.equal(after.lengthEighths, 320);
|
||||||
|
assert.equal(after.thicknessEighths, 16);
|
||||||
|
}
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
|
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
|
||||||
installFakeMaskWorker();
|
installFakeMaskWorker();
|
||||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||||
@@ -520,6 +565,53 @@ test('#61: draft resume through measure restores calibration source (not photos[
|
|||||||
s2.dispose();
|
s2.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('legacy measure draft without results returns to the tape calibration page', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
const source = tinyJpeg('legacy');
|
||||||
|
store.saveDraft({
|
||||||
|
version: 1,
|
||||||
|
stage: 'measure',
|
||||||
|
batch: [source],
|
||||||
|
calibrationIndex: 0,
|
||||||
|
source,
|
||||||
|
maskParams: { sheet: 'black', sensitivity: 50 },
|
||||||
|
mask: {
|
||||||
|
slabPx: MASK_W * MASK_H,
|
||||||
|
bbox: {
|
||||||
|
corners: [[MASK_W, 0], [MASK_W, MASK_H], [0, MASK_H], [0, 0]],
|
||||||
|
longSidePx: MASK_W,
|
||||||
|
angleDeg: 0,
|
||||||
|
},
|
||||||
|
slabBounds: { x: 0, y: 0, w: MASK_W, h: MASK_H },
|
||||||
|
},
|
||||||
|
axisBbox: {
|
||||||
|
corners: [[MASK_W, 0], [MASK_W, MASK_H], [0, MASK_H], [0, 0]],
|
||||||
|
longSidePx: MASK_W,
|
||||||
|
angleDeg: 0,
|
||||||
|
},
|
||||||
|
lengthEighths: 320,
|
||||||
|
thicknessEighths: 16,
|
||||||
|
results: null,
|
||||||
|
inventoryPng: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
await s.resumeProbe();
|
||||||
|
s.continueDraft();
|
||||||
|
const start = Date.now();
|
||||||
|
let cap = get(s.capture);
|
||||||
|
while (cap.k !== 'axis' && Date.now() - start < 1500) {
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
cap = get(s.capture);
|
||||||
|
}
|
||||||
|
assert.equal(cap.k, 'axis', 'legacy resultless measure draft must not open a dead result screen');
|
||||||
|
if (cap.k === 'axis') {
|
||||||
|
assert.equal(cap.lengthEighths, 320);
|
||||||
|
assert.equal(cap.thicknessEighths, 16);
|
||||||
|
}
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
|
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
|
||||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||||
assert.match(app, /MaskPanel/);
|
assert.match(app, /MaskPanel/);
|
||||||
|
|||||||
+161
-113
@@ -48,12 +48,14 @@ export interface CaptureSession {
|
|||||||
sourceH: number;
|
sourceH: number;
|
||||||
}): void;
|
}): void;
|
||||||
setCropFill(pct: number): void;
|
setCropFill(pct: number): void;
|
||||||
|
/** Legacy resultless transition retained for old callers/tests; the shipped UI uses calculateFromAxis. */
|
||||||
approveAxis(): void;
|
approveAxis(): void;
|
||||||
|
/** Length and thickness are entered on the tape page. Calculate leaves that page for Photos ready. */
|
||||||
|
calculateFromAxis(lengthEighths: Eighths, thicknessEighths: Eighths): Promise<void>;
|
||||||
setLength(e: Eighths): void;
|
setLength(e: Eighths): void;
|
||||||
setThickness(e: Eighths): void;
|
setThickness(e: Eighths): void;
|
||||||
calculate(): Promise<void>;
|
calculate(): Promise<void>;
|
||||||
back(): void;
|
back(): void;
|
||||||
getInventoryUrl(): string | null;
|
|
||||||
/** The current `measure` branch, or null while not on it (C2 handoff). */
|
/** The current `measure` branch, or null while not on it (C2 handoff). */
|
||||||
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
|
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -72,6 +74,19 @@ const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
|
|||||||
const batchPhotosOf = (d: Draft): Source[] =>
|
const batchPhotosOf = (d: Draft): Source[] =>
|
||||||
d.batch && d.batch.length > 0 ? d.batch : [d.source];
|
d.batch && d.batch.length > 0 ? d.batch : [d.source];
|
||||||
|
|
||||||
|
type CalibrationInput = {
|
||||||
|
source: Source;
|
||||||
|
approvedMask: Mask;
|
||||||
|
axis: Axis;
|
||||||
|
lengthEighths: Eighths;
|
||||||
|
thicknessEighths: Eighths;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalibrationOutput = {
|
||||||
|
results: Measures;
|
||||||
|
batchApply: BatchPhotoResult[];
|
||||||
|
};
|
||||||
|
|
||||||
// The one writer. Owns the worker port, the single debounce timer, and the draft.
|
// The one writer. Owns the worker port, the single debounce timer, and the draft.
|
||||||
// Every transition to an approved branch persists; no explicit "save".
|
// Every transition to an approved branch persists; no explicit "save".
|
||||||
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||||
@@ -88,7 +103,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let passStarted = 0;
|
let passStarted = 0;
|
||||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pngUrl: string | null = null;
|
|
||||||
let resumed = false;
|
let resumed = false;
|
||||||
let cropFill = 90;
|
let cropFill = 90;
|
||||||
let poseTimer: ReturnType<typeof setTimeout> | null = null;
|
let poseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -130,10 +144,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
}
|
}
|
||||||
worker = null;
|
worker = null;
|
||||||
}
|
}
|
||||||
if (pngUrl) {
|
|
||||||
URL.revokeObjectURL(pngUrl);
|
|
||||||
pngUrl = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeWorker = (blob: Blob) => {
|
const makeWorker = (blob: Blob) => {
|
||||||
@@ -239,7 +249,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
tickEndY: state.axis.tickEndY,
|
tickEndY: state.axis.tickEndY,
|
||||||
tapeStart: state.axis.tapeStart,
|
tapeStart: state.axis.tapeStart,
|
||||||
tapeEnd: state.axis.tapeEnd,
|
tapeEnd: state.axis.tapeEnd,
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: null, inventoryPng: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (state.k === 'measure') {
|
if (state.k === 'measure') {
|
||||||
@@ -345,6 +355,91 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
stub: Omit<Mask, 'bitset' | 'params'>,
|
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||||
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
|
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
|
||||||
|
|
||||||
|
const runCalibration = async (input: CalibrationInput): Promise<CalibrationOutput> => {
|
||||||
|
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = input;
|
||||||
|
if (lengthEighths === 0 || thicknessEighths === 0) {
|
||||||
|
throw new Error('length and thickness are required');
|
||||||
|
}
|
||||||
|
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||||
|
let pngBlob: Blob;
|
||||||
|
let belowFloor = false;
|
||||||
|
try {
|
||||||
|
const base = measure(
|
||||||
|
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||||
|
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||||
|
{ start: axis.tapeStart, end: axis.tapeEnd },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const encoded = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill });
|
||||||
|
pngBlob = encoded.blob;
|
||||||
|
belowFloor = encoded.sourceBelowFloor;
|
||||||
|
} catch {
|
||||||
|
pngBlob = new Blob([], { type: 'image/png' });
|
||||||
|
}
|
||||||
|
const overlayBlob = await (async () => {
|
||||||
|
try {
|
||||||
|
return await encodeOverlay({
|
||||||
|
inventoryPng: pngBlob,
|
||||||
|
bitset: approvedMask.bitset,
|
||||||
|
widthPx: source.widthPx,
|
||||||
|
heightPx: source.heightPx,
|
||||||
|
bbox: axis.bbox,
|
||||||
|
scale: base.scale,
|
||||||
|
lengthIn: eighthsToIn(lengthEighths),
|
||||||
|
lengthLabel: fmtEighths(lengthEighths),
|
||||||
|
slabBounds: approvedMask.slabBounds,
|
||||||
|
source,
|
||||||
|
cropFill,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Blob(['overlay'], { type: 'image/png' });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const results: Measures = {
|
||||||
|
...base,
|
||||||
|
inventoryPng: pngBlob,
|
||||||
|
overlayPng: overlayBlob,
|
||||||
|
sourceBelowFloor: belowFloor,
|
||||||
|
};
|
||||||
|
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
||||||
|
? calibrationIndex
|
||||||
|
: 0;
|
||||||
|
const batch = batchPhotos.length ? batchPhotos : [source];
|
||||||
|
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
|
||||||
|
status: 'error',
|
||||||
|
message: 'Batch photo was not processed',
|
||||||
|
}));
|
||||||
|
for (let index = 0; index < batch.length; index++) {
|
||||||
|
if (index === calIndex) continue;
|
||||||
|
const photo = batch[index];
|
||||||
|
try {
|
||||||
|
const derived = await rederiveMaskDetailed(photo, approvedMask.params, {
|
||||||
|
slabPx: 0,
|
||||||
|
bbox: EMPTY_BBOX,
|
||||||
|
slabBounds: EMPTY_ABOX,
|
||||||
|
});
|
||||||
|
if (derived.mask.slabPx === 0) throw new Error('empty mask: no slab pixels detected');
|
||||||
|
const siblingSource: Source = { ...photo, widthPx: derived.widthPx, heightPx: derived.heightPx };
|
||||||
|
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||||
|
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||||
|
try {
|
||||||
|
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, cropFill });
|
||||||
|
} finally {
|
||||||
|
siblingBitmap.close();
|
||||||
|
}
|
||||||
|
if (encoded.sourceBelowFloor) throw new Error('PNG size floor: shorter side is below 800 px');
|
||||||
|
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'unable to process photo';
|
||||||
|
batchApply[index] = { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { results, batchApply };
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||||
// fall back to the mask's own bbox.
|
// fall back to the mask's own bbox.
|
||||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||||
@@ -375,12 +470,14 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
try {
|
try {
|
||||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||||
if (d.stage === 'axis') {
|
if (d.stage === 'axis') {
|
||||||
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d) });
|
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.inventoryPng) {
|
// A resultless legacy measure draft predates the merged tape page. Do not
|
||||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
// open the result-only screen; return to tape calibration with its inputs.
|
||||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
if (d.stage === 'measure' && !d.results) {
|
||||||
|
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
k: 'measure', source, approvedMask: mask,
|
k: 'measure', source, approvedMask: mask,
|
||||||
@@ -414,7 +511,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
void persist();
|
void persist();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const session: CaptureSession = {
|
||||||
capture,
|
capture,
|
||||||
busy,
|
busy,
|
||||||
|
|
||||||
@@ -514,7 +611,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
teardownWorker();
|
teardownWorker();
|
||||||
const overlay = state.overlay;
|
const overlay = state.overlay;
|
||||||
const bbox = { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg };
|
const bbox = { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg };
|
||||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx) });
|
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx), lengthEighths: 0, thicknessEighths: 0 });
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -563,107 +660,54 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
// #62: Calculate the calibration photo, then apply its frozen mask params
|
// Legacy compatibility path; the shipped tape page calls calculateFromAxis.
|
||||||
// to siblings. This stays in Measure; siblings never enter Mask controls.
|
|
||||||
async calculate() {
|
async calculate() {
|
||||||
if (state.k !== 'measure') return;
|
if (state.k !== 'measure') return;
|
||||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
const input: CalibrationInput = {
|
||||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
source: state.source,
|
||||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
approvedMask: state.approvedMask,
|
||||||
const base = measure(
|
axis: state.axis,
|
||||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
lengthEighths: state.lengthEighths,
|
||||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
thicknessEighths: state.thicknessEighths,
|
||||||
{ start: axis.tapeStart, end: axis.tapeEnd },
|
|
||||||
);
|
|
||||||
let pngBlob: Blob;
|
|
||||||
let belowFloor = false;
|
|
||||||
try {
|
|
||||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill });
|
|
||||||
pngBlob = r.blob;
|
|
||||||
belowFloor = r.sourceBelowFloor;
|
|
||||||
} catch {
|
|
||||||
// CAPTURE.md: inventory is image/png even when encode is unavailable.
|
|
||||||
pngBlob = new Blob([], { type: 'image/png' });
|
|
||||||
}
|
|
||||||
bitmap.close();
|
|
||||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
|
||||||
pngUrl = URL.createObjectURL(pngBlob);
|
|
||||||
|
|
||||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
|
||||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
|
||||||
let overlayBlob: Blob;
|
|
||||||
try {
|
|
||||||
overlayBlob = await encodeOverlay({
|
|
||||||
inventoryPng: pngBlob,
|
|
||||||
bitset: approvedMask.bitset,
|
|
||||||
widthPx: source.widthPx,
|
|
||||||
heightPx: source.heightPx,
|
|
||||||
bbox: axis.bbox,
|
|
||||||
scale: base.scale,
|
|
||||||
lengthIn: eighthsToIn(lengthEighths),
|
|
||||||
lengthLabel: fmtEighths(lengthEighths),
|
|
||||||
slabBounds: approvedMask.slabBounds,
|
|
||||||
source,
|
|
||||||
cropFill,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
|
||||||
// return a distinct image/png so callers never confuse it with clean.
|
|
||||||
overlayBlob = new Blob(['overlay'], { type: 'image/png' });
|
|
||||||
}
|
|
||||||
const results: Measures = {
|
|
||||||
...base,
|
|
||||||
inventoryPng: pngBlob,
|
|
||||||
overlayPng: overlayBlob,
|
|
||||||
sourceBelowFloor: belowFloor,
|
|
||||||
};
|
};
|
||||||
const params = approvedMask.params;
|
const output = await runCalibration(input);
|
||||||
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
if (state.k !== 'measure') return;
|
||||||
? calibrationIndex
|
set({ ...state, ...output });
|
||||||
: 0;
|
void persist();
|
||||||
const batch = batchPhotos.length ? batchPhotos : [source];
|
},
|
||||||
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
|
|
||||||
status: 'error',
|
|
||||||
message: 'Batch photo was not processed',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Process serially so each worker owns exactly one sibling photo and a
|
async calculateFromAxis(lengthEighths, thicknessEighths) {
|
||||||
// failing decode/mask/encode cannot prevent later siblings from running.
|
if (state.k !== 'axis') return;
|
||||||
for (let index = 0; index < batch.length; index++) {
|
const length = Math.max(0, Math.round(lengthEighths));
|
||||||
if (index === calIndex) continue;
|
const thickness = Math.max(0, Math.round(thicknessEighths));
|
||||||
const photo = batch[index];
|
if (length === 0 || thickness === 0) return;
|
||||||
try {
|
if (poseTimer) {
|
||||||
const derived = await rederiveMaskDetailed(photo, params, {
|
clearTimeout(poseTimer);
|
||||||
slabPx: 0,
|
poseTimer = null;
|
||||||
bbox: EMPTY_BBOX,
|
|
||||||
slabBounds: EMPTY_ABOX,
|
|
||||||
});
|
|
||||||
if (derived.mask.slabPx === 0) {
|
|
||||||
throw new Error('empty mask: no slab pixels detected');
|
|
||||||
}
|
}
|
||||||
const siblingSource: Source = {
|
const input: CalibrationInput = {
|
||||||
...photo,
|
source: state.source,
|
||||||
widthPx: derived.widthPx,
|
approvedMask: state.approvedMask,
|
||||||
heightPx: derived.heightPx,
|
axis: state.axis,
|
||||||
|
lengthEighths: length,
|
||||||
|
thicknessEighths: thickness,
|
||||||
};
|
};
|
||||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
// Keep the tape page mounted while the PNG, overlay, and batch apply run.
|
||||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
// The old measure form was a real screen; the result state is not entered
|
||||||
try {
|
// until there is something complete to show.
|
||||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, cropFill });
|
set({ ...state, lengthEighths: length, thicknessEighths: thickness });
|
||||||
} finally {
|
const output = await runCalibration(input);
|
||||||
siblingBitmap.close();
|
if (state.k !== 'axis') return;
|
||||||
}
|
set({
|
||||||
if (encoded.sourceBelowFloor) {
|
k: 'measure',
|
||||||
throw new Error('PNG size floor: shorter side is below 800 px');
|
source: input.source,
|
||||||
}
|
approvedMask: input.approvedMask,
|
||||||
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
axis: input.axis,
|
||||||
} catch (error) {
|
lengthEighths: input.lengthEighths,
|
||||||
const message = error instanceof Error ? error.message : 'unable to process photo';
|
thicknessEighths: input.thicknessEighths,
|
||||||
batchApply[index] = { status: 'error', message };
|
results: output.results,
|
||||||
}
|
batchApply: output.batchApply,
|
||||||
}
|
});
|
||||||
|
|
||||||
set({ ...state, results, batchApply });
|
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -682,14 +726,17 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
const m = state.approvedMask;
|
const m = state.approvedMask;
|
||||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||||
} else if (state.k === 'measure') {
|
} else if (state.k === 'measure') {
|
||||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: state.axis });
|
set({
|
||||||
|
k: 'axis',
|
||||||
|
source: state.source,
|
||||||
|
approvedMask: state.approvedMask,
|
||||||
|
axis: state.axis,
|
||||||
|
lengthEighths: state.lengthEighths,
|
||||||
|
thicknessEighths: state.thicknessEighths,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getInventoryUrl() {
|
|
||||||
return pngUrl;
|
|
||||||
},
|
|
||||||
|
|
||||||
currentMeasure() {
|
currentMeasure() {
|
||||||
return state.k === 'measure' ? state : null;
|
return state.k === 'measure' ? state : null;
|
||||||
},
|
},
|
||||||
@@ -698,6 +745,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
teardownWorker();
|
teardownWorker();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display helper: format eighths as inches with fractions.
|
// Display helper: format eighths as inches with fractions.
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=
|
|||||||
| `01-batch.png` | Batch with fixtures loaded |
|
| `01-batch.png` | Batch with fixtures loaded |
|
||||||
| `02-pick-calibration.png` | Pick calibration |
|
| `02-pick-calibration.png` | Pick calibration |
|
||||||
| `03-mask.png` | Mask (Black sheet) ready to approve |
|
| `03-mask.png` | Mask (Black sheet) ready to approve |
|
||||||
| `04-axis.png` | Length axis |
|
| `04-axis.png` | Tape calibration page |
|
||||||
| `05-measure.png` | Measure with L / thickness filled |
|
| `05-tape-calibration.png` | Length and thickness entered on the tape page |
|
||||||
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
||||||
|
|
||||||
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
|
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
|
||||||
// the Resume path restores straight to the Photos-ready Measure step — the
|
// the Resume path restores straight to the Photos-ready result step — the
|
||||||
// deterministic driver for the create→review→publish flow (no live camera/canvas
|
// deterministic driver for the create→review→publish flow (no live camera/canvas
|
||||||
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
|
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
|
||||||
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
|
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||||
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
|
test('batch → pick → mask → tape calibration → photos ready + screenshots', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||||
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
|
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
|
||||||
@@ -87,20 +87,16 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
|||||||
await shot(page, '03-mask.png');
|
await shot(page, '03-mask.png');
|
||||||
await page.getByRole('button', { name: 'Approve mask' }).click();
|
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Measure' })).toHaveCount(0);
|
||||||
await shot(page, '04-axis.png');
|
await shot(page, '04-axis.png');
|
||||||
await page.getByRole('button', { name: 'Approve axis' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
|
|
||||||
await page.getByLabel('Length L').fill('48');
|
|
||||||
await page.getByLabel('Thickness').fill('2');
|
|
||||||
// blur/change commits eighths into session before Calculate.
|
|
||||||
await page.getByLabel('Length L').blur();
|
|
||||||
await page.getByLabel('Thickness').blur();
|
|
||||||
await shot(page, '05-measure.png');
|
|
||||||
|
|
||||||
const calc = page.getByRole('button', { name: 'Calculate' });
|
const calc = page.getByRole('button', { name: 'Calculate' });
|
||||||
|
await expect(calc).toBeDisabled();
|
||||||
|
await page.getByLabel('Length L').fill('48');
|
||||||
|
await page.getByLabel('Thickness').fill('2');
|
||||||
await expect(calc).toBeEnabled();
|
await expect(calc).toBeEnabled();
|
||||||
|
await shot(page, '05-tape-calibration.png');
|
||||||
await calc.click();
|
await calc.click();
|
||||||
|
|
||||||
// Calculate encodes calibration PNG + overlay + sibling batch apply.
|
// Calculate encodes calibration PNG + overlay + sibling batch apply.
|
||||||
@@ -117,7 +113,7 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
|||||||
|
|
||||||
// Write a short run log for parent AI visual review (not a substitute for eyes).
|
// Write a short run log for parent AI visual review (not a substitute for eyes).
|
||||||
const logPath = path.join(ARTIFACTS, 'run-log.txt');
|
const logPath = path.join(ARTIFACTS, 'run-log.txt');
|
||||||
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
|
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-tape-calibration.png', '06-photos-ready.png'];
|
||||||
const lines = [
|
const lines = [
|
||||||
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
||||||
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user