From 7908a99ec3163e2ef5941dee5645dec9c3cec2f4 Mon Sep 17 00:00:00 2001 From: Ty Date: Thu, 24 Sep 2026 22:33:23 -0700 Subject: [PATCH] fix(m4): keep tape calibration on one page (#99) --- frontend/src/App.svelte | 10 +- frontend/src/capture/AxisPanel.svelte | 53 +++- frontend/src/capture/MeasurePanel.svelte | 110 +------ frontend/src/capture/capture.types.ts | 4 +- frontend/src/capture/measureInputs.test.ts | 11 +- .../src/capture/session.calibrate.test.ts | 92 ++++++ frontend/src/capture/session.ts | 276 ++++++++++-------- frontend/tests/e2e/artifacts/README.md | 4 +- frontend/tests/e2e/helpers/draft.ts | 2 +- frontend/tests/e2e/photos-ready.spec.ts | 20 +- 10 files changed, 335 insertions(+), 247 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index ea2e0b5..d7373a9 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -26,7 +26,6 @@ let cap: Capture = { k: 'batch', photos: [] }; let busy = false; - let pngUrl: string | null = null; let showSettings = false; let showSlabs = false; @@ -37,7 +36,6 @@ const unsubCap = session.capture.subscribe((c) => { cap = c; - pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null; }); const unsubBusy = session.busy.subscribe((b) => (busy = b)); @@ -228,17 +226,13 @@ session.setCropFill(pct); void updateSettings({ crop_fill: pct }).catch(() => {}); }} - approveAxis={session.approveAxis} + calculateFromAxis={session.calculateFromAxis} back={session.back} /> - {:else} + {:else if cap.k === 'measure'} void continueToReview()} /> {/if} diff --git a/frontend/src/capture/AxisPanel.svelte b/frontend/src/capture/AxisPanel.svelte index 4abf964..e9ed030 100644 --- a/frontend/src/capture/AxisPanel.svelte +++ b/frontend/src/capture/AxisPanel.svelte @@ -1,6 +1,7 @@
-

{photosReady ? 'Photos ready' : 'Measure'}

- - {#if !photosReady} - 0 ? state.source.widthPx : 0} - maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0} - /> - -
-
- Length L (tape reading) - -
-
- Thickness - -
-
+

Photos ready

+ {#if !state.results} +

Calculating…

-
- {:else if state.results} + {:else}
Widths{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})
@@ -169,12 +83,6 @@ {/each} - - {#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor} -
- Inventory PNG -
- {/if}
@@ -191,14 +99,6 @@ diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index b05342a..db28ce9 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -108,7 +108,9 @@ export type Capture = k: 'axis'; source: Source; approvedMask: Mask; // frozen footprint - axis: Axis; // live axis.bbox being nudged + axis: Axis; // live tape line + lengthEighths: Eighths; + thicknessEighths: Eighths; } | { k: 'measure'; diff --git a/frontend/src/capture/measureInputs.test.ts b/frontend/src/capture/measureInputs.test.ts index 1644f05..848488f 100644 --- a/frontend/src/capture/measureInputs.test.ts +++ b/frontend/src/capture/measureInputs.test.ts @@ -6,11 +6,15 @@ import { test } from 'node:test'; import { inToEighths } from './capture.types'; const panelSrc = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), 'AxisPanel.svelte'), + 'utf8', +); +const measureSrc = readFileSync( join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'), '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(/]*>/g) ?? []; assert.ok(inputs.length >= 2, 'expected at least two fields'); assert.ok( @@ -31,6 +35,11 @@ test('MeasurePanel exposes typed number inputs for Length L and Thickness', () = false, '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', () => { diff --git a/frontend/src/capture/session.calibrate.test.ts b/frontend/src/capture/session.calibrate.test.ts index 22d6441..34c77ee 100644 --- a/frontend/src/capture/session.calibrate.test.ts +++ b/frontend/src/capture/session.calibrate.test.ts @@ -358,6 +358,51 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl 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((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 () => { installFakeMaskWorker(); (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(); }); +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)', () => { const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); assert.match(app, /MaskPanel/); diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index b1d33b6..501a2d8 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -48,12 +48,14 @@ export interface CaptureSession { sourceH: number; }): void; setCropFill(pct: number): void; + /** Legacy resultless transition retained for old callers/tests; the shipped UI uses calculateFromAxis. */ approveAxis(): void; + /** Length and thickness are entered on the tape page. Calculate leaves that page for Photos ready. */ + calculateFromAxis(lengthEighths: Eighths, thicknessEighths: Eighths): Promise; setLength(e: Eighths): void; setThickness(e: Eighths): void; calculate(): Promise; back(): void; - getInventoryUrl(): string | null; /** The current `measure` branch, or null while not on it (C2 handoff). */ currentMeasure(): Extract | null; dispose(): void; @@ -72,6 +74,19 @@ const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft }; const batchPhotosOf = (d: Draft): 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. // Every transition to an approved branch persists; no explicit "save". export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { @@ -88,7 +103,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { let timer: ReturnType | null = null; let passStarted = 0; let busyTimer: ReturnType | null = null; - let pngUrl: string | null = null; let resumed = false; let cropFill = 90; let poseTimer: ReturnType | null = null; @@ -130,10 +144,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { } worker = null; } - if (pngUrl) { - URL.revokeObjectURL(pngUrl); - pngUrl = null; - } }; const makeWorker = (blob: Blob) => { @@ -239,7 +249,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { tickEndY: state.axis.tickEndY, tapeStart: state.axis.tapeStart, 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') { @@ -345,6 +355,91 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { stub: Omit, ): Promise => rederiveMaskDetailed(source, params, stub).then((r) => r.mask); + const runCalibration = async (input: CalibrationInput): Promise => { + 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; // fall back to the mask's own bbox. const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox; @@ -375,12 +470,14 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { try { const mask = await rederiveMask(source, d.maskParams, d.mask); 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; } - if (d.inventoryPng) { - if (pngUrl) URL.revokeObjectURL(pngUrl); - pngUrl = URL.createObjectURL(d.inventoryPng); + // A resultless legacy measure draft predates the merged tape page. Do not + // open the result-only screen; return to tape calibration with its inputs. + 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({ k: 'measure', source, approvedMask: mask, @@ -414,7 +511,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { void persist(); }; - return { + const session: CaptureSession = { capture, busy, @@ -514,7 +611,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { teardownWorker(); const overlay = state.overlay; 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(); }, @@ -563,107 +660,54 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { void persist(); }, - // #62: Calculate the calibration photo, then apply its frozen mask params - // to siblings. This stays in Measure; siblings never enter Mask controls. + // Legacy compatibility path; the shipped tape page calls calculateFromAxis. async calculate() { if (state.k !== 'measure') return; - const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state; - if (lengthEighths === 0 || thicknessEighths === 0) return; - const bitmap = await decodeOrientedBitmap(source.blob); - const base = measure( - approvedMask.bitset, source.widthPx, source.heightPx, - axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, - { 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 input: CalibrationInput = { + source: state.source, + approvedMask: state.approvedMask, + axis: state.axis, + lengthEighths: state.lengthEighths, + thicknessEighths: state.thicknessEighths, }; - const params = approvedMask.params; - 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', - })); + const output = await runCalibration(input); + if (state.k !== 'measure') return; + set({ ...state, ...output }); + void persist(); + }, - // Process serially so each worker owns exactly one sibling photo and a - // failing decode/mask/encode cannot prevent later siblings from running. - for (let index = 0; index < batch.length; index++) { - if (index === calIndex) continue; - const photo = batch[index]; - try { - const derived = await rederiveMaskDetailed(photo, 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 }; - } + async calculateFromAxis(lengthEighths, thicknessEighths) { + if (state.k !== 'axis') return; + const length = Math.max(0, Math.round(lengthEighths)); + const thickness = Math.max(0, Math.round(thicknessEighths)); + if (length === 0 || thickness === 0) return; + if (poseTimer) { + clearTimeout(poseTimer); + poseTimer = null; } - - set({ ...state, results, batchApply }); + const input: CalibrationInput = { + source: state.source, + approvedMask: state.approvedMask, + axis: state.axis, + lengthEighths: length, + thicknessEighths: thickness, + }; + // Keep the tape page mounted while the PNG, overlay, and batch apply run. + // The old measure form was a real screen; the result state is not entered + // until there is something complete to show. + set({ ...state, lengthEighths: length, thicknessEighths: thickness }); + const output = await runCalibration(input); + if (state.k !== 'axis') return; + set({ + k: 'measure', + source: input.source, + approvedMask: input.approvedMask, + axis: input.axis, + lengthEighths: input.lengthEighths, + thicknessEighths: input.thicknessEighths, + results: output.results, + batchApply: output.batchApply, + }); void persist(); }, @@ -682,14 +726,17 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { const m = state.approvedMask; set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false }); } 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() { return state.k === 'measure' ? state : null; }, @@ -698,6 +745,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { teardownWorker(); }, }; + return session; } // Display helper: format eighths as inches with fractions. diff --git a/frontend/tests/e2e/artifacts/README.md b/frontend/tests/e2e/artifacts/README.md index 469f6c0..2a1780d 100644 --- a/frontend/tests/e2e/artifacts/README.md +++ b/frontend/tests/e2e/artifacts/README.md @@ -28,8 +28,8 @@ Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L= | `01-batch.png` | Batch with fixtures loaded | | `02-pick-calibration.png` | Pick calibration | | `03-mask.png` | Mask (Black sheet) ready to approve | -| `04-axis.png` | Length axis | -| `05-measure.png` | Measure with L / thickness filled | +| `04-axis.png` | Tape calibration page | +| `05-tape-calibration.png` | Length and thickness entered on the tape page | | `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) | Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored. diff --git a/frontend/tests/e2e/helpers/draft.ts b/frontend/tests/e2e/helpers/draft.ts index a266a61..8ad48ce 100644 --- a/frontend/tests/e2e/helpers/draft.ts +++ b/frontend/tests/e2e/helpers/draft.ts @@ -1,5 +1,5 @@ // 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 // in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval', // key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` + diff --git a/frontend/tests/e2e/photos-ready.spec.ts b/frontend/tests/e2e/photos-ready.spec.ts index 44c5d43..0face16 100644 --- a/frontend/tests/e2e/photos-ready.spec.ts +++ b/frontend/tests/e2e/photos-ready.spec.ts @@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => { }; 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); expect(fs.existsSync(FACE_A), 'fixture face-a').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 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 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' }); + await expect(calc).toBeDisabled(); + await page.getByLabel('Length L').fill('48'); + await page.getByLabel('Thickness').fill('2'); await expect(calc).toBeEnabled(); + await shot(page, '05-tape-calibration.png'); await calc.click(); // 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). 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 = [ `M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`, `baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,