feat(review): #90 photo strip — keep/remove, drag-reorder, leftmost=primary, persist

REVIEW-5 / PUBLISH-45: owner edits the stored photo lineup in Review — tap a
card to keep/remove, drag to reorder, leftmost kept photo = primary (Woo
cover). The kept set must contain a clean calibration + its dimension-overlay
copy; publish is blocked until it does.

- backend: role column on slab_photos (schema + idempotent _migrate ALTER),
  role in photo CRUD + new DELETE endpoint, Woo image order by role-rank
  (clean→overlay→batch) per PUBLISH-45
- frontend: Review photo strip (render from listPhotos, keep/remove,
  HTML5 drag-reorder, dynamic PRIMARY badge from .primary class),
  persistLineup() re-seq kept + drop removed before entering Publish
- tests: lineup.ts unit, e2e review-photo-strip.spec, backend role + order

Refs: #90, REVIEW-5, PUBLISH-45
This commit is contained in:
Ty
2026-09-21 01:34:22 -07:00
parent fe82133ca4
commit d9af8420bc
11 changed files with 806 additions and 29 deletions
+49
View File
@@ -159,6 +159,8 @@ export interface PhotoRow {
id: string;
seq: number;
is_calibration: number;
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
role: 'calibration' | 'overlay' | 'batch';
width_px: number | null;
height_px: number | null;
has_original: number;
@@ -169,6 +171,8 @@ export interface UploadPhotoInput {
photoId: string; // client-generated UUIDv4
seq?: number;
isCalibration: boolean;
/** #90: lineup kind; defaults to batch. */
role?: 'calibration' | 'overlay' | 'batch';
original: Blob;
processed?: Blob | null;
}
@@ -178,6 +182,7 @@ export interface UploadPhotoResult {
slab_id: string;
seq: number;
is_calibration: number;
role: 'calibration' | 'overlay' | 'batch';
original_retained: boolean;
processed_retained: boolean;
}
@@ -193,6 +198,7 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
form.append('photo_id', input.photoId);
if (input.seq != null) form.append('seq', String(input.seq));
form.append('is_calibration', input.isCalibration ? '1' : '0');
form.append('role', input.role ?? 'batch');
const origName = input.original instanceof File ? input.original.name : 'original.png';
form.append('original', input.original, origName);
if (input.processed) form.append('processed', input.processed, 'processed.png');
@@ -202,6 +208,49 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
}).then((r) => parse<UploadPhotoResult>(r));
}
// #90: drop a photo the owner removed in Review (keep/remove edit).
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
method: 'DELETE',
}).then((r) => parse<void>(r));
}
/**
* #90: persist an edited Review photo lineup. `kept` is the owner's final
* order (leftmost = primary); each entry re-uploads with a fresh seq so the
* stored `seq` reflects the keep order. `removed` photos are dropped. Every
* kept photo's blob is re-sent (original + processed) because the server
* stores only the BLOB — there is no separate re-seq endpoint.
*/
export interface LineupPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
original: Blob;
processed: Blob | null;
}
export async function persistLineup(
slabId: string,
kept: LineupPhoto[],
removed: string[],
): Promise<void> {
for (const id of removed) {
await deletePhoto(slabId, id);
}
for (let i = 0; i < kept.length; i++) {
const p = kept[i];
await uploadPhoto(slabId, {
photoId: p.id,
seq: i + 1,
isCalibration: p.isCalibration,
role: p.role,
original: p.original,
processed: p.processed,
});
}
}
export function photoUrl(
slabId: string,
photoId: string,
+215 -18
View File
@@ -2,11 +2,15 @@
import { onMount, onDestroy } from 'svelte';
import {
getSlab, updateSlab, reviewSlab, lockSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
listPhotos, persistLineup, photoUrl, ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
} from '../../api/slabs';
import type { CalibratedCapture } from '../flow';
import MultiSelect from './MultiSelect.svelte';
import {
keptInOrder, moveItem, missingMinimums, roleLabel,
type LineupItem,
} from './lineup';
// Props handed off by App (one-column flow, UX.md §1).
export let slabId: string;
@@ -132,6 +136,143 @@
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
const fmt2 = (v: number) => v.toFixed(2);
// --- #90 Photo strip (REVIEW-5 / PUBLISH-45) ---
// The owner edits which photos stay/go and their order. Leftmost kept photo =
// primary (Woo cover). Kept in-memory; persisted via persistLineup() on
// "Continue to Publish". Sources are the STORED photos (listPhotos) so the
// strip survives re-entry into Review; in-memory lineup blobs (from capture)
// are preferred when present.
interface StripPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
kept: boolean;
original: Blob;
processed: Blob | null;
url: string;
}
let photos: StripPhoto[] = [];
let photosLoaded = false;
let lineupDirty = false;
let persistBusy = false;
let persistError = '';
// In-memory blobs from the capture lineup, keyed by lineup order. The stored
// photo id matches the capture's upload order only on the first entry; after
// an edit the STORED row (listPhotos) is authoritative and we re-fetch blobs.
const inMemByRole = (): Map<'calibration' | 'overlay' | 'batch', Blob[]> => {
const m = new Map<'calibration' | 'overlay' | 'batch', Blob[]>();
for (const it of capture?.lineup ?? []) {
if (it.kind !== 'png') continue;
const arr = m.get(it.role) ?? [];
arr.push(it.blob);
m.set(it.role, arr);
}
return m;
};
async function loadPhotos() {
try {
const rows: PhotoRow[] = await listPhotos(slabId);
const byRole = inMemByRole();
const seqByRole: Record<string, number> = { calibration: 0, overlay: 0, batch: 0 };
photos = rows.map((r) => {
const role = r.role ?? 'batch';
const n = seqByRole[role]++;
const blob = (byRole.get(role)?.[n]) ?? null;
const url =
blob ??
(photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed'));
return {
id: r.id,
role,
isCalibration: r.is_calibration === 1,
kept: true,
original: blob ?? new Blob(),
processed: blob ?? null,
url: String(url),
};
});
photosLoaded = true;
lineupDirty = false;
} catch {
photos = [];
photosLoaded = false;
}
}
// Whole-card tap = keep/remove toggle. A real drag (>6px) reorders and does NOT
// toggle keep/remove — the threshold lives in the pointer handlers below.
function toggleKeep(id: string) {
photos = photos.map((p) => (p.id === id ? { ...p, kept: !p.kept } : p));
lineupDirty = true;
}
function reorder(from: number, to: number) {
photos = moveItem(photos, from, to);
lineupDirty = true;
}
let dragFrom = -1;
function onDragStart(e: DragEvent, index: number) {
dragFrom = index;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}
}
function onDragOver(e: DragEvent) {
if (e.preventDefault) e.preventDefault();
}
function onDrop(e: DragEvent, index: number) {
if (e.preventDefault) e.preventDefault();
if (dragFrom >= 0 && dragFrom !== index) reorder(dragFrom, index);
dragFrom = -1;
}
// REVIEW-5: the kept set must contain a clean photo and its overlay copy. Only
// enforced once photos have loaded (photosLoaded); a failed/absent photo list
// (e.g. legacy mock) leaves the lineup minimum neutral rather than blocking.
let lineupMissing: ('calibration' | 'overlay')[] = [];
$: lineupMissing = photosLoaded
? missingMinimums(keptInOrder(photos as LineupItem[]) as LineupItem[])
: [];
$: lineupValid = lineupMissing.length === 0;
// Leftmost kept photo id = primary (Woo cover). '' when nothing is kept.
let primaryId = '';
$: primaryId = photosLoaded
? ((keptInOrder(photos as LineupItem[]) as StripPhoto[])[0]?.id ?? '')
: '';
// Persist the edited lineup (re-seq kept + drop removed), then hand to Publish.
async function goPublish() {
if (!lineupValid) return;
persistBusy = true;
persistError = '';
try {
const kept = keptInOrder(photos as LineupItem[]) as StripPhoto[];
const removed = photos.filter((p) => !p.kept).map((p) => p.id);
await persistLineup(
slabId,
kept.map((p) => ({
id: p.id,
role: p.role,
isCalibration: p.isCalibration,
original: p.original,
processed: p.processed,
})),
removed,
);
onPublish();
} catch (e) {
persistError = e instanceof ApiError ? e.message : 'Could not save the photos. Try again.';
} finally {
persistBusy = false;
}
}
function hydrate(s: Slab, t: Taxonomy | null) {
speciesId = s.species_id ?? '';
woodCatIds = [...(s.wood_category_ids ?? [])];
@@ -323,6 +464,7 @@
deriveOptions();
hydrate(s, t);
canGenerateContent = !!set.content_llm_enabled;
await loadPhotos();
await commitReview();
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
@@ -331,17 +473,9 @@
}
});
function goPublish() {
onPublish();
}
function goBack() {
onBack();
}
function publish() {
goPublish();
}
</script>
{#if loading}
@@ -365,6 +499,47 @@
<div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
</dl>
<!-- #90 Photo strip: keep/remove + drag-reorder; leftmost kept = primary. -->
{#if photosLoaded && photos.length}
<div class="photos" data-testid="photo-strip">
<p class="ph-hint">Tap a photo to keep or remove it · drag to reorder · leftmost photo = primary</p>
<div class="ph-grid">
{#each photos as p, i (p.id)}
<div
class="pcard"
class:kept={p.kept}
class:primary={p.id === primaryId}
data-testid="photo-card"
data-primary={p.id === primaryId ? 'true' : ''}
role="button"
tabindex="0"
aria-pressed={p.kept}
aria-label="{roleLabel(p.role)}{p.kept ? ', kept' : ', removed'}"
draggable="true"
on:click={() => toggleKeep(p.id)}
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleKeep(p.id); } }}
on:dragstart={(e) => onDragStart(e, i)}
on:dragover={onDragOver}
on:drop={(e) => onDrop(e, i)}
>
<img class="ph-thumb" src={p.url} alt={roleLabel(p.role)} draggable="false" />
{#if p.id === primaryId}
<span class="ph-pri">PRIMARY</span>
{/if}
<span class="ph-seq" aria-hidden="true">{i + 1}</span>
<span class="ph-keep" aria-hidden="true"></span>
</div>
{/each}
</div>
{#if lineupMissing.length}
<p class="ph-nudge" data-testid="lineup-nudge" role="alert">
Keep at least {lineupMissing.map(roleLabel).join(' and ')} — required to publish.
</p>
{/if}
{#if persistError}<p class="nudge" role="alert">{persistError}</p>{/if}
</div>
{/if}
{#if settings?.inference_enabled}
<div class="aibar">
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
@@ -537,13 +712,16 @@
{#if reviewBusy}
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
{:else if review}
<div class="status" class:ok={ready()} data-testid="ready-status">
{ready()
<div class="status" class:ok={ready() && lineupValid} data-testid="ready-status">
{ready() && lineupValid
? 'Ready to publish'
: `Not ready — ${review.missing.length} field${review.missing.length === 1 ? '' : 's'} remaining`}
: `Not ready — ${[
...review.missing,
...lineupMissing.map(roleLabel),
].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`}
</div>
{#if !ready()}
<p class="missing" data-testid="missing-list">Missing: {review.missing.join(', ')}</p>
{#if !(ready() && lineupValid)}
<p class="missing" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p>
{/if}
{/if}
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
@@ -554,10 +732,10 @@
type="button"
class="primary"
data-testid="continue-publish"
disabled={publishDisabled}
on:click={publish}
disabled={publishDisabled || !lineupValid || persistBusy}
on:click={goPublish}
>
Continue to Publish
{persistBusy ? 'Saving photos…' : 'Continue to Publish'}
</button>
</div>
</section>
@@ -594,4 +772,23 @@
.primary:disabled { background: #333; color: #777; }
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
.err { color: #e88; } .busy { color: #aaa; }
/* #90 Photo strip */
.photos { margin: 14px 0 4px; }
.ph-hint { color: #8a8a8a; font-size: .72rem; margin: 0 0 8px; }
.ph-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
@media (max-width: 420px) { .ph-grid { grid-template-columns: repeat(2, 1fr); } }
.pcard { position: relative; aspect-ratio: 1; border: 2px solid #2a2a2a; border-radius: 3px; overflow: hidden; cursor: grab; background: #141414; }
.pcard:active { cursor: grabbing; }
.pcard:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
.pcard.kept { border-color: #444; }
.pcard.primary { border-color: #00e5ff; }
.pcard:not(.kept) { opacity: .4; }
.ph-thumb { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.ph-pri { position: absolute; top: 6px; right: 6px; background: #00e5ff; color: #002; font-size: .56rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .03em; pointer-events: none; }
.ph-seq { position: absolute; bottom: 6px; left: 6px; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,.7); color: #fff; font-size: .66rem; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.ph-keep { position: absolute; top: 6px; left: 6px; width: 16px; height: 16px; border: 2px solid #fff; border-radius: 3px; background: rgba(0,0,0,.5); pointer-events: none; }
.pcard.kept .ph-keep { background: #00e5ff; border-color: #00e5ff; }
.pcard.kept .ph-keep::after { content: ''; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #002; border-width: 0 2px 2px 0; transform: rotate(45deg); }
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; }
</style>
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
keptInOrder,
moveItem,
missingMinimums,
lineupValid,
roleLabel,
type LineupItem,
} from './lineup';
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
id,
role,
isCalibration: role === 'calibration',
kept,
});
test('lineup: keptInOrder returns kept photos in their current order', () => {
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
});
test('lineup: leftmost kept is the primary', () => {
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
// After a drag the owner re-ordered; primary follows position, not role.
assert.equal(keptInOrder(items)[0].id, 'b1');
});
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
assert.equal(keptInOrder(items)[0].id, 'ov');
});
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
const a = ['x', 'y', 'z'];
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
// Out-of-range is a no-op, never throws.
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
// Returns a new array, does not mutate the input.
const before = a.slice();
moveItem(a, 0, 2);
assert.deepEqual(a, before);
});
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
});
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
assert.equal(lineupValid([p('b1', 'batch')]), false);
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
});
test('lineup: missingMinimums only counts KEPT photos', () => {
// Both roles present in the array, but the cal is not kept.
assert.deepEqual(
missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]),
['calibration'],
);
});
test('lineup: roleLabel is owner-facing', () => {
assert.match(roleLabel('calibration'), /clean/);
assert.match(roleLabel('overlay'), /overlay/);
});
+62
View File
@@ -0,0 +1,62 @@
// #90 Review photo strip (REVIEW-5 / PUBLISH-45). Pure logic split out so the
// keep/remove + reorder + minimum enforcement is unit-testable without a browser.
//
// Model (approved in the wireframe): the owner taps a photo to keep/remove it,
// drags to reorder, and the LEFTMOST kept photo is the primary (Woo cover). The
// submission-ready minimum is at least one clean (calibration) photo AND one
// dimension-overlay copy (REVIEW-5 "clean first pic plus its dimension-overlay
// copy"). Batch photos are optional.
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
export interface LineupItem {
id: string;
role: PhotoRole;
isCalibration: boolean;
/** True = the owner keeps this photo in the submission. */
kept: boolean;
}
/**
* The kept photos in their current (left→right) order. The first kept photo is
* the primary (Woo cover). Reordering is a plain array move; this helper just
* exposes the kept subset so the caller can re-seq on persist.
*/
export function keptInOrder(items: LineupItem[]): LineupItem[] {
return items.filter((it) => it.kept);
}
/** Move an item from one index to another (drag-reorder). Returns a new array. */
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
return arr;
}
const next = arr.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/**
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
* clean calibration photo and one dimension-overlay copy. Returns the missing
* role(s) so the UI can name them in a nudge.
*/
export function missingMinimums(kept: LineupItem[]): PhotoRole[] {
const missing: PhotoRole[] = [];
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
return missing;
}
/** True when the kept lineup is publishable from a photo standpoint. */
export function lineupValid(kept: LineupItem[]): boolean {
return missingMinimums(kept).length === 0;
}
/** A short owner-facing label for a role (used in the minimum nudge). */
export function roleLabel(role: PhotoRole): string {
if (role === 'calibration') return 'a clean photo';
if (role === 'overlay') return 'its dimension-overlay copy';
return 'a batch photo';
}
@@ -0,0 +1,266 @@
import { test, expect, type Page, type Route } from '@playwright/test';
import { seedMeasureDraft } from './helpers/draft';
/**
* M3 Review photo strip E2E (issue #90, REVIEW-5 / PUBLISH-45). Drives the
* capture→review flow (mocked API, no live backend) and exercises the photo
* strip: render from stored photos, keep/remove (whole-card tap), drag-reorder,
* leftmost-kept = primary, the clean+overlay minimum, and persistLineup on
* "Continue to Publish". All ids are synthetic fixtures (QUALITY-105).
*/
const SLAB_ID = '90e00000-0000-4000-8000-000000000090';
const SPECIES_ID = 11;
const WCAT_ID = 20;
const LIVEEDGE_ID = 21;
const TAXONOMY = {
categories: [
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
{ id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: 10 },
{ id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null },
{ id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID },
],
attributes: [
{ id: 30, name: 'Edge Type', slug: 'edge_type', terms: [{ id: 31, name: 'Live edge', slug: 'live-edge' }] },
],
tags: [],
taxonomy_anchor: 'fixture',
taxonomy_last_sync_at: '2026-01-01T00:00:00',
};
// The stored lineup: clean cal (seq 1), dimension-overlay (seq 2), batch (seq 3).
const PHOTO_ROWS = [
{ id: 'ph-cal', seq: 1, is_calibration: 1, role: 'calibration', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
{ id: 'ph-ov', seq: 2, is_calibration: 0, role: 'overlay', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
{ id: 'ph-b1', seq: 3, is_calibration: 0, role: 'batch', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
];
function baseSlab(): Record<string, unknown> {
return {
id: SLAB_ID, sku: '', status: 'draft', price: null, price_source: 'recommendation',
length_in: 48, thickness_in: 2, sqft: 6.25, bdft: 12.5, width_min_in: 10, width_max_in: 12, width_avg_in: 11,
species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null,
thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
title: null, short_description: null, description: null, content_source: 'manual',
metadata_locked: false, species_confidence: null, figure_confidence: null, grade_confidence: null,
inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
};
}
async function json(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
}
interface PersistRecord {
seq: number; role: string; is_calibration: string;
}
async function mockApi(page: Page) {
const slab = baseSlab();
const photoStore = new Map<string, typeof PHOTO_ROWS[number]>();
for (const r of PHOTO_ROWS) photoStore.set(r.id, { ...r });
const persistCalls: { photoId: string; seq: number; role: string }[] = [];
const deletes: string[] = [];
const merge = (payload: Record<string, unknown>) => {
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false;
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'metadata_locked', 'edge_type_term_id']) {
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
}
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
};
const reviewResult = (payload: Record<string, unknown>) => {
if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
const missing: string[] = [];
if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species');
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
if (!slab.edge_type_term_id) missing.push('edge type');
if (!slab.title) missing.push('title');
if (!slab.short_description) missing.push('short description');
if (slab.price == null) missing.push('price');
// Deterministic copy from the server on lock (REVIEW-15) — set before ready.
if (!slab.title) slab.title = 'Cedar · 48″ Live Edge';
if (!slab.short_description) slab.short_description = 'Hand-cut slab, ready to ship.';
return {
ready: missing.length === 0, missing,
title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null,
price: (slab.price as number) ?? null, price_suggestion: 120,
primary_wood_category_id: null, locked: slab.metadata_locked === true,
};
};
await page.route('**/api/v1/**', async (route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const method = req.method();
const payload = (() => { try { return req.postDataJSON() as Record<string, unknown>; } catch { return {}; } })();
if (path === '/api/v1/settings' && method === 'GET') {
return json(route, { woo_base_url: 'https://example.invalid', woo_wp_username: 'wp', woo_app_password: { configured: true }, inference_base_url: { configured: false }, inference_api_key: { configured: false }, inference_model: null, inference_enabled: false, content_llm_enabled: false, brand_voice: null, geo_context: null, woo_create_status: 'draft', taxonomy_last_sync_at: '2026-01-01T00:00:00', taxonomy_anchor: 'fixture' });
}
if (path === '/api/v1/admin/taxonomy' && method === 'GET') return json(route, TAXONOMY);
if (path === '/api/v1/slabs' && method === 'POST') {
slab.id = String(payload.id ?? SLAB_ID);
merge(payload);
return json(route, slab, 201);
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
if (merge(payload) === false) return json(route, { error: { code: 'revision_conflict', message: 'rev', detail: {} } }, 409);
return json(route, slab);
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') return json(route, slab);
// --- Photos (the strip) ---
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
return json(route, [...photoStore.values()].sort((a, b) => a.seq - b.seq));
}
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
// Multipart; pull the photo_id / seq / role form fields out of the body.
const raw = req.postData() ?? '';
const field = (name: string): string => {
const re = new RegExp(`name="${name}"\\r\\n\\r\\n([\\s\\S]*?)(?=$|--)`);
const m = raw.match(re);
return m ? m[1].trim() : '';
};
const photoId = field('photo_id');
const seq = Number(field('seq'));
const role = field('role') || 'batch';
// Capture-time uploads (photo_id not in the fixture store, e.g. "photo-1")
// are accepted but not added to the lineup — the strip is driven by the
// stored rows only. Review-strip re-uploads reference a known fixture id.
if (!photoStore.has(photoId)) {
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
}
persistCalls.push({ photoId, seq, role });
const existing = photoStore.get(photoId);
if (existing) {
existing.seq = seq;
existing.role = role as typeof existing.role;
}
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
}
const delMatch = path.match(new RegExp(`^/api/v1/slabs/${slab.id}/photos/([^/]+)$`));
if (delMatch && method === 'DELETE') {
const id = delMatch[1];
if (photoStore.delete(id)) { deletes.push(id); return route.fulfill({ status: 204, body: '' }); }
return json(route, { error: { code: 'not_found', message: 'Photo not found', detail: null } }, 404);
}
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') return json(route, reviewResult(payload));
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
return json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
}
return json(route, { error: { code: 'not_found', message: path } }, 404);
});
return { persistCalls, deletes, photoStore };
}
async function reachReview(page: Page) {
await page.goto('/?noload');
await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11 });
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 });
await page.getByTestId('continue-to-review').click();
await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 });
}
async function completeForReady(page: Page) {
await page.getByTestId('species').selectOption(String(SPECIES_ID));
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
await expect(page.getByTestId('lock-metadata')).toBeEnabled();
await page.getByTestId('lock-metadata').click();
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001');
await page.getByTestId('sku').press('Tab');
}
test.describe('M3 Review photo strip (#90)', () => {
test('renders stored photos; leftmost kept = primary; tap keep/remove; drag reorder; minimum; persist', async ({ page }) => {
const mock = await mockApi(page);
await reachReview(page);
// Strip renders the 3 stored photos in stored seq order.
const cards = page.getByTestId('photo-card');
await expect(cards).toHaveCount(3);
// Leftmost (cal, seq 1) is the primary.
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
await expect(page.getByText('PRIMARY', { exact: true })).toHaveCount(1);
// Remove the primary (cal) → primary auto-promotes to the next leftmost (ov).
await cards.nth(0).click();
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'false');
await expect(cards.nth(1)).toHaveAttribute('data-primary', 'true');
// Minimum nudge: cal + batch kept, but overlay still present → only cal missing.
await expect(page.getByTestId('lineup-nudge')).toBeVisible();
await expect(page.getByTestId('lineup-nudge')).toContainText('clean photo');
// Re-add the cal (the original re-check bug) → it's back to the left = primary.
await cards.nth(0).click();
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Drag the batch photo (index 2) to the front → it becomes the primary.
await cards.nth(2).scrollIntoViewIfNeeded();
await page.waitForTimeout(120);
const first = await cards.nth(0).boundingBox();
const third = await cards.nth(2).boundingBox();
if (first && third) {
const sx = third.x + third.width / 2, sy = third.y + third.height / 2;
const dx = first.x + first.width / 2, dy = first.y + first.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// Several small steps so the HTML5 DnD picks up the drag.
for (let i = 1; i <= 14; i++) {
await page.mouse.move(sx + (dx - sx) * (i / 14), sy + (dy - sy) * (i / 14));
}
await page.waitForTimeout(60);
await page.mouse.up();
}
// After reorder the batch is leftmost = primary; seq badges renumber.
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
// Fill fields to ready, then Continue → persistLineup runs (re-seq kept, drop none).
await completeForReady(page);
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
// Persisted: 3 re-uploads (re-seq 1..3), no deletes (nothing was removed in final state).
expect(mock.persistCalls.length).toBe(3);
const seqs = mock.persistCalls.map((c) => c.seq).sort((a, b) => a - b);
expect(seqs).toEqual([1, 2, 3]);
});
test('removing a photo persists a DELETE and blocks publish when the minimum is broken', async ({ page }) => {
const mock = await mockApi(page);
await reachReview(page);
const cards = page.getByTestId('photo-card');
// Remove the overlay (seq 2) → minimum broken (no overlay).
await cards.nth(1).click();
await expect(cards.nth(1)).toHaveAttribute('aria-pressed', 'false');
await expect(page.getByTestId('lineup-nudge')).toContainText('overlay');
// Complete the rest of the form; the slab fields are ready but the lineup is not.
await completeForReady(page);
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|remaining/);
// Continue is blocked by the lineup minimum even though the fields are set.
await expect(page.getByTestId('continue-publish')).toBeDisabled();
});
});