merge work/merge-89-90 into main (Create-string #89/#90 + PR #97 fixes)
Reconciles two independent lines of work that both touched Review.svelte, MultiSelect.svelte, and the thickness band: - main's PR #97 (client-side thicknessBand.ts + MultiSelect names/commit + syncSelects DOM race fix + box-sizing CSS) - work/merge-89-90 (server-side thickness band in thickness_band.py, Create-string #89, metadata_locked dropped, photo strip, wood primary) Canonical resolution: the band is SERVER-side (hidden from UI, sent to Woo on publish), so the client-side thicknessBand.ts + read-only band display are removed from Review.svelte; PR #97's syncSelects() and box-sizing CSS are preserved (orthogonal fixes). MultiSelect takes selectedSummary from woodPrimary.ts (names in closed state) + the ms-count testid + onDestroy timer cleanup; duplicate close()/summary definitions dropped. Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip).
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --import tsx --test src/**/*.test.ts",
|
||||
"test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
|
||||
@@ -105,16 +105,22 @@
|
||||
content_source: 'manual',
|
||||
client_rev: 0,
|
||||
});
|
||||
const pngItems = capture.lineup.filter(
|
||||
(it) => it.kind === 'png' && it.role !== 'overlay',
|
||||
);
|
||||
// #90 / REVIEW-5: persist the full lineup with correct roles so the Review
|
||||
// strip matches in-memory blobs by role. The overlay is the dimension-drawn
|
||||
// copy — it must be persisted (its blob already has the dimensions) and is
|
||||
// the photo the owner sees with the measurement overlay. Excluding it (the
|
||||
// old `role !== 'overlay'` filter) dropped the dimension copy from Review.
|
||||
const pngItems = capture.lineup.filter((it) => it.kind === 'png');
|
||||
for (let i = 0; i < pngItems.length; i++) {
|
||||
const it = pngItems[i];
|
||||
await uploadPhoto(id, {
|
||||
photoId: uuid(),
|
||||
seq: i + 1,
|
||||
isCalibration: it.role === 'calibration',
|
||||
role: it.role,
|
||||
original: it.blob,
|
||||
// The calibration/batch PNGs have no separate processed variant; the
|
||||
// overlay blob already carries the drawn dimensions.
|
||||
processed: it.blob,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +157,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;
|
||||
@@ -167,6 +169,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;
|
||||
}
|
||||
@@ -176,6 +180,7 @@ export interface UploadPhotoResult {
|
||||
slab_id: string;
|
||||
seq: number;
|
||||
is_calibration: number;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
original_retained: boolean;
|
||||
processed_retained: boolean;
|
||||
}
|
||||
@@ -191,6 +196,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');
|
||||
@@ -200,6 +206,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,
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
// (METADATA-10: "parents group only and are never assigned"); only the
|
||||
// leaf rows are selectable. Filtering hides a parent group when none of
|
||||
// its leaves match.
|
||||
// #90 closed state shows selected NAMES (not only a count). Done dispatches
|
||||
// `commit` so the parent can persist + re-review.
|
||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||
import { selectedSummary } from './woodPrimary';
|
||||
|
||||
export interface Opt { id: number; name: string; ai: boolean }
|
||||
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
|
||||
@@ -38,15 +41,7 @@
|
||||
|
||||
// Effective selectable set (leaves when grouped, options when flat).
|
||||
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
|
||||
// #90: the closed state must show WHAT is selected (names), not just a count.
|
||||
// Truncate long lists so the button stays one line.
|
||||
$: selectedNames = allOpts.filter((o) => selected.includes(o.id)).map((o) => o.name);
|
||||
$: summary =
|
||||
selectedNames.length === 0
|
||||
? ''
|
||||
: selectedNames.length <= 2
|
||||
? selectedNames.join(', ')
|
||||
: `${selectedNames.slice(0, 2).join(', ')} +${selectedNames.length - 2}`;
|
||||
$: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
|
||||
$: q = filter.trim().toLowerCase();
|
||||
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
|
||||
// Grouped: keep a parent only if at least one of its leaves survives the filter.
|
||||
@@ -56,6 +51,7 @@
|
||||
.filter((g) => g.leaves.length > 0)
|
||||
: [];
|
||||
$: count = selected.length;
|
||||
$: summary = selectedSummary(selected, nameById, placeholder);
|
||||
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
|
||||
|
||||
function scheduleCommit() {
|
||||
@@ -86,9 +82,13 @@
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
|
||||
function destroy() { if (commitTimer) clearTimeout(commitTimer); }
|
||||
onDestroy(destroy);
|
||||
// Rows are focusable options (role=option): Enter/Space toggles, matching click.
|
||||
function onRowKey(e: KeyboardEvent, id: number) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
|
||||
<div class="ms" class:open role="group" aria-label={label} data-testid={testid} on:keydown={onKey}>
|
||||
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
|
||||
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>
|
||||
{open
|
||||
@@ -97,7 +97,7 @@
|
||||
? summary
|
||||
: placeholder}
|
||||
</span>
|
||||
<span class="ms-c">{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</span>
|
||||
<span class="ms-c" data-testid={testid ? `${testid}-count` : 'ms-count'}>{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</span>
|
||||
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
|
||||
@@ -121,9 +121,11 @@
|
||||
class="ms-row leaf"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
on:keydown={(e) => onRowKey(e, o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||
@@ -140,9 +142,11 @@
|
||||
class="ms-row"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
on:keydown={(e) => onRowKey(e, o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||
@@ -163,7 +167,7 @@
|
||||
|
||||
<style>
|
||||
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
|
||||
.ms-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
.ms-btn { box-sizing: border-box; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
|
||||
.ms-btn .ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eee; }
|
||||
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
|
||||
@@ -185,6 +189,7 @@
|
||||
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
|
||||
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
|
||||
.ms-row:focus-visible { outline: 2px solid #00e5ff; outline-offset: -2px; }
|
||||
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
|
||||
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, 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';
|
||||
@@ -35,7 +35,6 @@
|
||||
let edgeOpts: Opt[] = [];
|
||||
let figureOpts: Opt[] = [];
|
||||
let gradeOpts: Opt[] = [];
|
||||
let thicknessOpts: Opt[] = [];
|
||||
let moistureOpts: Opt[] = [];
|
||||
let figTagOpts: Opt[] = [];
|
||||
let featTagOpts: Opt[] = [];
|
||||
@@ -78,7 +77,8 @@
|
||||
edgeOpts = termsOf('edge type');
|
||||
figureOpts = termsOf('figure');
|
||||
gradeOpts = termsOf('grade');
|
||||
thicknessOpts = termsOf('thickness');
|
||||
// No thickness options: the band is derived server-side and never shown here
|
||||
// (METADATA-15 / QUALITY-95).
|
||||
moistureOpts = termsOf('moisture');
|
||||
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
|
||||
// filtered by the family the backend keeps. Never invent a prefix — if the
|
||||
@@ -99,23 +99,7 @@
|
||||
let gradeSel: string = '';
|
||||
let woodCatIds: number[] = [];
|
||||
let figureIds: number[] = [];
|
||||
// #88: thickness band is NOT an owner control — it is derived deterministically
|
||||
// from thickness_in (TV-9 / QUALITY-95) against the synced Woo thickness terms.
|
||||
// The Review screen shows it read-only; the owner changes thickness upstream, and
|
||||
// the band follows.
|
||||
$: thicknessId = deriveThicknessTermId(
|
||||
slab?.thickness_in ?? null,
|
||||
thicknessOpts.map((o) => ({ id: o.id, name: o.name })),
|
||||
);
|
||||
$: thicknessName = thicknessOpts.find((o) => o.id === thicknessId)?.name ?? '';
|
||||
// #88 display text: the derived band name, or an explicit "no band" note when a
|
||||
// thickness is set but nothing in the synced set encloses it.
|
||||
$: thicknessDisplay =
|
||||
thicknessId !== ''
|
||||
? thicknessName
|
||||
: (slab?.thickness_in ?? 0) > 0
|
||||
? `— no band for ${fmt1(slab!.thickness_in!)}″`
|
||||
: '—';
|
||||
let gradeId: number | '' = '';
|
||||
let moistureId: number | '' = '';
|
||||
let figTagIds: number[] = [];
|
||||
let featTagIds: number[] = [];
|
||||
@@ -137,6 +121,16 @@
|
||||
let review: ReviewOut | null = null;
|
||||
let reviewBusy = false;
|
||||
let reviewError = '';
|
||||
// #89: explicit "Create" actions. `createBusy` covers the deterministic
|
||||
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
|
||||
// category). `canCreateDesc` gates the AI long-description on title+short-desc
|
||||
// being populated AND the content LLM being enabled.
|
||||
let createBusy = false;
|
||||
let createError = '';
|
||||
$: canCreateTitle =
|
||||
speciesId !== '' && woodCatIds.length > 0;
|
||||
$: canCreateDesc =
|
||||
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
||||
// Monotonic token: each commit captures the current value and ignores its
|
||||
// result if a newer commit has since started. Without this, two concurrent
|
||||
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
||||
@@ -156,14 +150,177 @@
|
||||
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;
|
||||
/** True for the mandatory photos (clean calibration + dimension-overlay
|
||||
copy). Their keep checkbox is checked and disabled — the owner cannot
|
||||
remove them (REVIEW-5 requires both). */
|
||||
required: 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;
|
||||
};
|
||||
|
||||
// Object URLs minted from in-memory blobs; revoked on unmount/reload so the
|
||||
// <img> src stays valid. A raw Blob assigned to src renders broken — it must
|
||||
// be a blob: URL (bug: the first/Primary photo was the one carrying a blob).
|
||||
let photoUrls: string[] = [];
|
||||
|
||||
async function loadPhotos() {
|
||||
// Revoke any previous object URLs before rebuilding the strip.
|
||||
for (const u of photoUrls) URL.revokeObjectURL(u);
|
||||
photoUrls = [];
|
||||
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;
|
||||
// Prefer the in-memory blob (fresh capture), as an object URL. Otherwise
|
||||
// fall back to the stored photo endpoint (a plain string URL).
|
||||
const endpoint = photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed');
|
||||
const url = resolvePhotoUrl(blob, endpoint);
|
||||
if (blob && blob.size > 0) photoUrls.push(url);
|
||||
return {
|
||||
id: r.id,
|
||||
role,
|
||||
isCalibration: r.is_calibration === 1,
|
||||
// Mandatory: the clean calibration photo and the dimension-overlay
|
||||
// copy. REVIEW-5 requires both to ship, so their keep control is
|
||||
// checked + disabled (the owner can't remove them).
|
||||
required: role === 'calibration' || role === 'overlay',
|
||||
kept: true,
|
||||
original: blob ?? new Blob(),
|
||||
processed: blob ?? null,
|
||||
url,
|
||||
};
|
||||
});
|
||||
photosLoaded = true;
|
||||
lineupDirty = false;
|
||||
} catch {
|
||||
photos = [];
|
||||
photosLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const u of photoUrls) URL.revokeObjectURL(u);
|
||||
photoUrls = [];
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
if (p.id !== id) return p;
|
||||
// Mandatory photos (clean cal + dimension-overlay) can't be removed.
|
||||
if (p.required) return p;
|
||||
return { ...p, kept: !p.kept };
|
||||
});
|
||||
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) {
|
||||
speciesSel = s.species_id != null ? String(s.species_id) : '';
|
||||
woodCatIds = [...(s.wood_category_ids ?? [])];
|
||||
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
|
||||
figureIds = [...(s.figure_term_ids ?? [])];
|
||||
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
||||
// thicknessId is derived reactively from slab.thickness_in (#88) — not hydrated
|
||||
// from the stored term, so a stale stored band can't shadow the derived value.
|
||||
gradeId = s.grade_term_id ?? '';
|
||||
moistureId = s.moisture_term_id ?? '';
|
||||
figTagIds = [...(s.fig_tag_ids ?? [])];
|
||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||
@@ -174,7 +331,8 @@
|
||||
price = s.price != null ? String(s.price) : '';
|
||||
priceSource = s.price_source ?? 'recommendation';
|
||||
sku = s.sku ?? '';
|
||||
primaryCatId = woodCatIds[0] ?? '';
|
||||
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
|
||||
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
|
||||
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
||||
// its weight behind the value the owner kept).
|
||||
aiRecommendations = [];
|
||||
@@ -184,28 +342,63 @@
|
||||
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
||||
}
|
||||
|
||||
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
||||
// AI-marked option views (badge on rows after Call 1 / stored confidences).
|
||||
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
$: woodCatGroupsView = woodCatGroups.map((g) => ({
|
||||
parent: g.parent,
|
||||
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
|
||||
}));
|
||||
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
// Flat leaf name lookup for the primary radio set (REVIEW-15).
|
||||
$: woodLeafById = new Map(
|
||||
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
|
||||
);
|
||||
$: primaryOpts = woodCatIds
|
||||
.map((id) => woodLeafById.get(id))
|
||||
.filter((o): o is Opt => !!o)
|
||||
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
|
||||
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
|
||||
// measured thickness against the live synced Woo bands. It is deliberately absent
|
||||
// from this screen — no owner control, no read-only echo. The only thickness the
|
||||
// owner sees here is the measured value in the summary above.
|
||||
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
||||
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
||||
// #89: the deterministic title/short_description are written here — the server's
|
||||
// review returns the build from the current metadata, and if the field is empty
|
||||
// we fold the deterministic value into THIS same PUT (no extra request, no rev
|
||||
// race). Owner-edited text is never clobbered (only an empty field is filled).
|
||||
async function commitReview(): Promise<void> {
|
||||
if (!slab) return;
|
||||
const myToken = ++commitToken;
|
||||
reviewBusy = true;
|
||||
reviewError = '';
|
||||
try {
|
||||
// Run the deterministic review FIRST (reads the current metadata) to get the
|
||||
// title/short_description build before we persist.
|
||||
const probe = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
price: price === '' ? undefined : Number(price),
|
||||
sku: sku || undefined,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
|
||||
// so this PUT persists it (the Create buttons do the same, on demand).
|
||||
if (probe.title && title === '') title = probe.title;
|
||||
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
|
||||
|
||||
const patch = {
|
||||
species_id: speciesSel === '' ? null : Number(speciesSel),
|
||||
wood_category_ids: woodCatIds,
|
||||
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
|
||||
figure_term_ids: figureIds,
|
||||
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
|
||||
thickness_term_id: thicknessId === '' ? null : Number(thicknessId),
|
||||
grade_term_id: gradeId === '' ? null : Number(gradeId),
|
||||
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||||
fig_tag_ids: figTagIds,
|
||||
feat_tag_ids: featTagIds,
|
||||
title: title || null,
|
||||
short_description: shortDescription || null,
|
||||
description: description || null,
|
||||
content_source: contentSource || null,
|
||||
price: price === '' ? null : Number(price),
|
||||
price_source: priceSource,
|
||||
@@ -215,21 +408,79 @@
|
||||
const fresh = await updateSlab(slabId, patch);
|
||||
if (myToken !== commitToken) return; // superseded; don't clobber the latest
|
||||
slab = fresh;
|
||||
if (primaryCatId !== '') {
|
||||
const res = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: Number(primaryCatId),
|
||||
title: title || undefined,
|
||||
short_description: shortDescription || undefined,
|
||||
price: price === '' ? undefined : Number(price),
|
||||
sku: sku || undefined,
|
||||
// Re-run the review against the just-persisted row (authoritative ready/missing).
|
||||
review = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
price: price === '' ? undefined : Number(price),
|
||||
sku: sku || undefined,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
|
||||
} catch (e) {
|
||||
if (myToken !== commitToken) return;
|
||||
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
|
||||
review = null;
|
||||
} finally {
|
||||
if (myToken === commitToken) reviewBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// #89: explicit "Create title" / "Create short desc" — fire the deterministic
|
||||
// build from the current metadata and write the result into the fields,
|
||||
// overwriting whatever was there (empty, owner-typed, or previously generated).
|
||||
// That is the whole contract: the button fires the build. commitReview()
|
||||
// auto-fills only empty fields; this is the explicit "force overwrite" action.
|
||||
async function createTitle(): Promise<void> {
|
||||
if (!slab || createBusy) return;
|
||||
createBusy = true;
|
||||
createError = '';
|
||||
try {
|
||||
// 1. Run the deterministic review to get title / short_description.
|
||||
const res = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
});
|
||||
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
|
||||
// 2. Write the deterministic values into the bound fields — always overwrite.
|
||||
if (res.title) title = res.title;
|
||||
if (res.short_description) shortDescription = res.short_description;
|
||||
// 3. Persist the generated prose (single PUT, fresh client_rev).
|
||||
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
|
||||
const prosePatch = {
|
||||
title: title || null,
|
||||
short_description: shortDescription || null,
|
||||
client_rev: (slab as Slab).server_rev,
|
||||
};
|
||||
const fresh = await updateSlab(slabId, prosePatch);
|
||||
slab = fresh;
|
||||
review = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
review = res;
|
||||
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
|
||||
} else {
|
||||
review = await reviewSlab(slabId);
|
||||
if (myToken !== commitToken) return;
|
||||
}
|
||||
} catch (e) {
|
||||
createError = e instanceof ApiError ? e.message : 'Could not build the title. Try again.';
|
||||
} finally {
|
||||
createBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// #89: the description textarea commits on blur. commitReview() no longer sends
|
||||
// description (see #89), so this persists the owner-typed description explicitly.
|
||||
async function commitDescription(): Promise<void> {
|
||||
if (!slab) return;
|
||||
const myToken = ++commitToken;
|
||||
reviewBusy = true;
|
||||
reviewError = '';
|
||||
try {
|
||||
const fresh = await updateSlab(slabId, {
|
||||
description: description || null,
|
||||
content_source: contentSource || null,
|
||||
client_rev: slab.server_rev,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
slab = fresh;
|
||||
review = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
});
|
||||
} catch (e) {
|
||||
if (myToken !== commitToken) return;
|
||||
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
|
||||
@@ -250,7 +501,7 @@
|
||||
if (s.species_id) { speciesSel = String(s.species_id); recs.push(s.species_id); }
|
||||
if (s.wood_category_ids) {
|
||||
woodCatIds = [...s.wood_category_ids];
|
||||
if (!primaryCatId) primaryCatId = woodCatIds[0];
|
||||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||||
recs.push(...s.wood_category_ids);
|
||||
}
|
||||
if (s.edge_type_term_id) edgeSel = String(s.edge_type_term_id);
|
||||
@@ -274,14 +525,22 @@
|
||||
}
|
||||
|
||||
async function generateContent() {
|
||||
if (!slab) return;
|
||||
if (!slab || !canCreateDesc) return;
|
||||
genBusy = true;
|
||||
genError = '';
|
||||
try {
|
||||
const r = await inferContent(slabId);
|
||||
description = r.description;
|
||||
contentSource = 'llm';
|
||||
await commitReview();
|
||||
// commitReview() no longer sends description (see #89), so persist it explicitly.
|
||||
const myToken = ++commitToken;
|
||||
const fresh = await updateSlab(slabId, {
|
||||
description: description || null,
|
||||
content_source: contentSource || null,
|
||||
client_rev: slab.server_rev,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
slab = fresh;
|
||||
} catch (e) {
|
||||
genError = e instanceof ApiError ? e.message : 'Could not generate text.';
|
||||
} finally {
|
||||
@@ -319,6 +578,17 @@
|
||||
let speciesEl: HTMLSelectElement | undefined;
|
||||
let edgeEl: HTMLSelectElement | undefined;
|
||||
let gradeEl: HTMLSelectElement | undefined;
|
||||
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
|
||||
function toggleWoodCat(id: number) {
|
||||
woodCatIds = toggleMulti(woodCatIds, id);
|
||||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||||
}
|
||||
|
||||
function pickPrimary(id: number) {
|
||||
if (!woodCatIds.includes(id)) return;
|
||||
primaryCatId = id;
|
||||
void commitReview();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -329,6 +599,7 @@
|
||||
canGenerateContent = !!set.content_llm_enabled;
|
||||
// #90: sync the <select> DOM to the hydrated values (same race as AI prefill).
|
||||
syncSelects();
|
||||
await loadPhotos();
|
||||
await commitReview();
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
|
||||
@@ -337,17 +608,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
function goPublish() {
|
||||
onPublish();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
onBack();
|
||||
}
|
||||
|
||||
function publish() {
|
||||
goPublish();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -371,6 +634,55 @@
|
||||
<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}
|
||||
class:required={p.required}
|
||||
data-testid="photo-card"
|
||||
data-primary={p.id === primaryId ? 'true' : ''}
|
||||
data-required={p.required ? 'true' : ''}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-pressed={p.required ? true : p.kept}
|
||||
aria-disabled={p.required ? 'true' : 'false'}
|
||||
aria-label="{p.required ? 'Required: ' : ''}{roleLabel(p.role)}{p.required ? ' (cannot be removed)' : (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}
|
||||
{#if p.required}
|
||||
<span class="ph-req">REQUIRED</span>
|
||||
{/if}
|
||||
<span class="ph-seq" aria-hidden="true">{i + 1}</span>
|
||||
<!-- Keep checkbox: always checked for required photos; a disabled
|
||||
glyph (no check) when a batch photo is removed. -->
|
||||
<span class="ph-keep" class:required={p.required} 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}>
|
||||
@@ -391,16 +703,43 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Wood categories (multi, searchable list, AI-assist) -->
|
||||
<MultiSelect
|
||||
label="Wood categories"
|
||||
groups={woodCatGroups}
|
||||
selected={woodCatIds}
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))}
|
||||
onCommit={commitReview}
|
||||
/>
|
||||
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
||||
<div class="field" data-testid="wood-cat-field">
|
||||
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="Wood categories"
|
||||
groups={woodCatGroupsView}
|
||||
selected={woodCatIds}
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={toggleWoodCat}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
{#if primaryOpts.length}
|
||||
<div class="primary-set" data-testid="primary-wood-set" role="radiogroup" aria-label="Primary wood category">
|
||||
<div class="ps-lbl">Primary wood category <span class="ps-req">drives title</span></div>
|
||||
{#each primaryOpts as o (o.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="ps-opt"
|
||||
class:on={primaryCatId === o.id}
|
||||
role="radio"
|
||||
aria-checked={primaryCatId === o.id}
|
||||
data-testid={`primary-wood-${o.id}`}
|
||||
on:click={() => pickPrimary(o.id)}
|
||||
>
|
||||
<span class="ps-radio" aria-hidden="true"></span>
|
||||
<span class="ps-name">{o.name}</span>
|
||||
{#if o.ai}<span class="ps-tag">AI</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<p class="ps-note">
|
||||
Primary selects the title & short-description pattern (METADATA-85).
|
||||
Secondary categories feed the description. Default = first selected.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edge type (single, AI-assist) -->
|
||||
<div class="field">
|
||||
@@ -418,12 +757,12 @@
|
||||
<label class="lbl">Figure <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="Figure"
|
||||
options={figureOpts}
|
||||
options={figureOptsView}
|
||||
selected={figureIds}
|
||||
placeholder="Choose figure"
|
||||
testid="figure"
|
||||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -446,7 +785,7 @@
|
||||
placeholder="Choose fig-* tags"
|
||||
testid="fig-tag"
|
||||
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -455,21 +794,18 @@
|
||||
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="feat-* tags"
|
||||
options={featTagOpts}
|
||||
options={featTagOptsView}
|
||||
selected={featTagIds}
|
||||
placeholder="Choose feat-* tags"
|
||||
testid="feat-tag"
|
||||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- #88: thickness band is deterministic from thickness_in — display-only, no owner pick. -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="thickness">Thickness band <span class="sug">from thickness</span></label>
|
||||
<div id="thickness" class="ro" data-testid="thickness-band">{thicknessDisplay}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
||||
<label class="lbl" for="moisture">Moisture</label>
|
||||
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
@@ -477,26 +813,39 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Title / Description -->
|
||||
<!-- Title / Short description + explicit "Create" build buttons (#89) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="title">Title</label>
|
||||
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
||||
<div class="create-row">
|
||||
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
||||
<button type="button" class="create-btn" data-testid="create-title"
|
||||
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||||
{createBusy ? 'Building…' : 'Create title'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="short-desc">Short description</label>
|
||||
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
||||
<div class="create-row">
|
||||
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
||||
<button type="button" class="create-btn" data-testid="create-short-desc"
|
||||
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||||
{createBusy ? 'Building…' : 'Create short desc'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="desc">Description</label>
|
||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitReview}></textarea>
|
||||
{#if canGenerateContent}
|
||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
|
||||
{#if canCreateDesc}
|
||||
<div class="gen">
|
||||
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}>
|
||||
{genBusy ? 'Writing…' : 'Generate text'}
|
||||
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
|
||||
{genBusy ? 'Writing…' : 'Create desc'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||||
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Price / SKU (SKU last) -->
|
||||
@@ -518,13 +867,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}
|
||||
@@ -535,10 +887,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>
|
||||
@@ -555,15 +907,40 @@
|
||||
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
|
||||
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
||||
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
||||
.inp, .sel { width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||||
/* #88 read-only thickness band: same chrome as .sel but non-interactive. */
|
||||
.ro { width: 100%; padding: 9px; background: #141414; border: 1px solid #333; border-radius: 3px; color: #9ad0d8; font-size: .9rem; text-align: left; }
|
||||
/* box-sizing: the app has no global reset, so a width:100% control with padding
|
||||
and a border pokes out of its container by that padding+border (20px here) —
|
||||
which becomes real page overflow whenever the column sits at the viewport edge
|
||||
(measured: 375px mobile scrolled 20px). Border-box keeps the box inside. */
|
||||
.inp, .sel { box-sizing: border-box; width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||||
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
|
||||
.ta { resize: vertical; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||||
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||||
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
||||
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
|
||||
/* #89: explicit "Create" build buttons beside the title / short-desc fields. */
|
||||
.create-row { display: flex; gap: 8px; align-items: stretch; }
|
||||
.create-row .inp { flex: 1; }
|
||||
.create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
|
||||
background: #1d1d1d; color: #eee; font-size: .82rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
||||
.create-btn:hover { background: #242424; }
|
||||
.create-btn:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
|
||||
|
||||
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
|
||||
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
|
||||
.primary-set .ps-lbl { font-size: .74rem; color: #9a9a9a; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
|
||||
.primary-set .ps-req { font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #2a2a2a; color: #888; }
|
||||
.ps-opt { box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 9px; padding: 8px 6px; margin: 0; border: 0; border-radius: 3px; background: transparent; color: #ddd; font-size: .88rem; cursor: pointer; text-align: left; }
|
||||
.ps-opt:hover { background: #1c1c1c; }
|
||||
.ps-opt.on { background: #12262b; color: #fff; }
|
||||
.ps-opt:focus-visible { outline: 2px solid #00e5ff; outline-offset: 1px; }
|
||||
.ps-radio { width: 14px; height: 14px; border: 2px solid #555; border-radius: 50%; flex: none; position: relative; }
|
||||
.ps-opt.on .ps-radio { border-color: #00e5ff; }
|
||||
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
|
||||
.ps-name { flex: 1; }
|
||||
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
|
||||
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||||
.status.ok { background: #122a12; color: #7fd37f; }
|
||||
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||||
@@ -572,4 +949,45 @@
|
||||
.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;
|
||||
/* Gray checkerboard, matching the Capture lineup (MeasurePanel .lineup-item.png)
|
||||
so transparent inventory PNGs read the same on both screens (bug2). */
|
||||
background-color: #eee;
|
||||
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; }
|
||||
.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-req { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); background: #ffb020; color: #201500; font-size: .52rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .04em; 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); }
|
||||
/* Mandatory photos (clean cal + dimension-overlay): the keep checkbox is
|
||||
checked AND disabled. A bold padlock glyph in the box signals "required —
|
||||
cannot be removed", and the card carries a thin required outline so the
|
||||
owner can tell mandatory photos apart from optional batch photos. */
|
||||
.pcard.required { border-color: #00e5ff55; }
|
||||
.pcard.required .ph-keep { background: #00e5ff; border-color: #00e5ff; }
|
||||
/* Padlock: shackle (arc) + body, drawn large enough to read on a 16px box. */
|
||||
.pcard.required .ph-keep::after {
|
||||
content: ''; position: absolute; left: 3px; top: 2px; width: 8px; height: 8px;
|
||||
border: 2px solid #002; border-bottom: none; border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.pcard.required .ph-keep::before {
|
||||
content: ''; position: absolute; left: 2.5px; top: 6px; width: 8px; height: 6px;
|
||||
background: #002; border-radius: 1px;
|
||||
}
|
||||
.pcard.required { cursor: default; }
|
||||
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
keptInOrder,
|
||||
moveItem,
|
||||
missingMinimums,
|
||||
lineupValid,
|
||||
roleLabel,
|
||||
resolvePhotoUrl,
|
||||
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/);
|
||||
});
|
||||
|
||||
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => {
|
||||
// The first/Primary (calibration) photo carries a fresh capture blob. It must
|
||||
// become a valid object URL — never a raw Blob, never String(blob)="object Blob".
|
||||
const blob = new Blob(['png-bytes'], { type: 'image/png' });
|
||||
const fake = (b: Blob) => `blob:fake-${b.size}`;
|
||||
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
|
||||
// Empty/absent blob falls back to the stored endpoint.
|
||||
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
||||
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// #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(items: LineupItem[]): PhotoRole[] {
|
||||
const kept = items.filter((it) => it.kept);
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the <img> src for a strip photo. The in-memory capture blob (fresh
|
||||
* photo) must become a `blob:` object URL — assigning a raw Blob (or
|
||||
* `String(blob)` = "object Blob") to `src` renders a broken image. That was
|
||||
* bug #1: the first/Primary (calibration) photo is the one carrying a blob.
|
||||
* When there is no usable blob, fall back to the stored photo endpoint.
|
||||
*
|
||||
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
|
||||
* is unit-testable without a real DOM/Blob.
|
||||
*/
|
||||
export function resolvePhotoUrl(
|
||||
blob: Blob | null,
|
||||
endpoint: string,
|
||||
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
|
||||
): string {
|
||||
if (blob && blob.size > 0) return createObjectUrl(blob);
|
||||
return endpoint;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { nextPrimary, selectedSummary } from './woodPrimary';
|
||||
|
||||
test('nextPrimary: empty selection clears primary', () => {
|
||||
assert.equal(nextPrimary([], 21), '');
|
||||
assert.equal(nextPrimary([], ''), '');
|
||||
});
|
||||
|
||||
test('nextPrimary: default is first selected (REVIEW-15)', () => {
|
||||
assert.equal(nextPrimary([21, 22], ''), 21);
|
||||
assert.equal(nextPrimary([21, 22], null), 21);
|
||||
});
|
||||
|
||||
test('nextPrimary: keeps current when still selected', () => {
|
||||
assert.equal(nextPrimary([21, 22], 22), 22);
|
||||
assert.equal(nextPrimary([22], 22), 22);
|
||||
});
|
||||
|
||||
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
|
||||
assert.equal(nextPrimary([22, 23], 21), 22);
|
||||
assert.equal(nextPrimary([23], 21), 23);
|
||||
});
|
||||
|
||||
test('selectedSummary: placeholder when empty', () => {
|
||||
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
|
||||
});
|
||||
|
||||
test('selectedSummary: shows names, then +N overflow', () => {
|
||||
const m = new Map([
|
||||
[1, 'Live Edge'],
|
||||
[2, 'Book Match'],
|
||||
[3, 'Cookie'],
|
||||
]);
|
||||
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
|
||||
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
|
||||
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
|
||||
});
|
||||
|
||||
test('selectedSummary: falls back to count when names unknown', () => {
|
||||
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* REVIEW-15: owner declares one primary wood category among the selected set.
|
||||
* Default primary = first selected. Deselecting the current primary promotes
|
||||
* the first remaining selected id. Empty selection clears primary.
|
||||
*/
|
||||
export function nextPrimary(
|
||||
selected: number[],
|
||||
current: number | '' | null | undefined,
|
||||
): number | '' {
|
||||
if (!selected.length) return '';
|
||||
const cur = typeof current === 'number' ? current : '';
|
||||
if (cur !== '' && selected.includes(cur)) return cur;
|
||||
return selected[0];
|
||||
}
|
||||
|
||||
/** Closed MultiSelect summary: names when known, else a count. */
|
||||
export function selectedSummary(
|
||||
selected: number[],
|
||||
nameById: Map<number, string>,
|
||||
placeholder: string,
|
||||
maxNames = 2,
|
||||
): string {
|
||||
if (!selected.length) return placeholder;
|
||||
const names = selected
|
||||
.map((id) => nameById.get(id))
|
||||
.filter((n): n is string => !!n && n.length > 0);
|
||||
if (!names.length) {
|
||||
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
|
||||
}
|
||||
if (names.length <= maxNames) return names.join(', ');
|
||||
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
|
||||
}
|
||||
@@ -53,7 +53,16 @@ const TAXONOMY = {
|
||||
},
|
||||
{
|
||||
id: 38, name: 'Thickness', slug: 'thickness',
|
||||
terms: [{ id: 39, name: '2-4 in', slug: '2-4' }],
|
||||
// Real live band names: the Review screen only *displays* the derived band.
|
||||
terms: [
|
||||
{ id: 347, name: 'Under 1/2"', slug: 'xthin' },
|
||||
{ id: 348, name: '1/2" - 3/4"', slug: '2-3qtr' },
|
||||
{ id: 349, name: '3/4" - 1"', slug: '3-4qtr' },
|
||||
{ id: 350, name: '1"- 1 1/2"', slug: '4-6qtr' },
|
||||
{ id: 351, name: '1 1/2" - 2"', slug: '6-8qtr' },
|
||||
{ id: 352, name: '2" - 3"', slug: '8-12qtr' },
|
||||
{ id: 353, name: 'Over 3"', slug: '12qtr' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 40, name: 'Moisture', slug: 'moisture',
|
||||
@@ -109,7 +118,7 @@ function baseSlab(): Record<string, unknown> {
|
||||
edge_type_term_id: null,
|
||||
figure_term_ids: [],
|
||||
grade_term_id: null,
|
||||
thickness_term_id: null,
|
||||
thickness_term_id: 352,
|
||||
moisture_term_id: null,
|
||||
fig_tag_ids: [],
|
||||
feat_tag_ids: [],
|
||||
@@ -148,8 +157,13 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
const calls = hooks.calls ?? { call1: 0, call2: 0 };
|
||||
const slab = baseSlab();
|
||||
let primary = null as number | null;
|
||||
const reviewPrimaries: number[] = [];
|
||||
|
||||
const merge = (payload: Record<string, unknown>) => {
|
||||
// Respect optimistic concurrency: reject stale client_rev (like the real backend).
|
||||
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) {
|
||||
return false; // stale — do NOT merge
|
||||
}
|
||||
for (const k of [
|
||||
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
|
||||
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
|
||||
@@ -160,10 +174,21 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
|
||||
};
|
||||
|
||||
const reviewResult = (payload: Record<string, unknown>) => {
|
||||
const reviewResult = (payload: Record<string, unknown>) => {
|
||||
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
|
||||
if (typeof payload.price === 'number') slab.price = payload.price;
|
||||
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
|
||||
|
||||
// #89: the review ALWAYS returns the deterministic title/short_description built
|
||||
// from the current metadata (the Create button's source of truth). The server
|
||||
// does NOT persist title/short_description from the review payload.
|
||||
const hasSpecies = !!slab.species_id;
|
||||
const hasWoodCat = Array.isArray(slab.wood_category_ids) && slab.wood_category_ids.length > 0;
|
||||
const deterministicTitle =
|
||||
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab' : null;
|
||||
const deterministicShort =
|
||||
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab, 4ft 0in x 11in x 2in, 12.5 bdft' : null;
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!slab.sku) missing.push('sku');
|
||||
if (!slab.species_id) missing.push('species');
|
||||
@@ -173,8 +198,8 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
return {
|
||||
ready: missing.length === 0,
|
||||
missing,
|
||||
title: (slab.title as string) ?? null,
|
||||
short_description: (slab.short_description as string) ?? null,
|
||||
title: deterministicTitle,
|
||||
short_description: deterministicShort,
|
||||
price: (slab.price as number) ?? null,
|
||||
price_suggestion: 120,
|
||||
primary_wood_category_id: primary,
|
||||
@@ -207,7 +232,10 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
return;
|
||||
}
|
||||
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
|
||||
merge(payload);
|
||||
if (merge(payload) === false) {
|
||||
await json(route, { error: { code: 'revision_conflict', message: 'client_rev mismatch', detail: { current_server_rev: slab.server_rev } } }, 409);
|
||||
return;
|
||||
}
|
||||
await json(route, slab);
|
||||
return;
|
||||
}
|
||||
@@ -229,7 +257,11 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
return;
|
||||
}
|
||||
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') {
|
||||
await json(route, reviewResult(payload));
|
||||
const result = reviewResult(payload);
|
||||
if (typeof result.primary_wood_category_id === 'number') {
|
||||
reviewPrimaries.push(result.primary_wood_category_id);
|
||||
}
|
||||
await json(route, result);
|
||||
return;
|
||||
}
|
||||
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
|
||||
@@ -251,7 +283,7 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
}
|
||||
await json(route, { error: { code: 'not_found', message: path } }, 404);
|
||||
});
|
||||
return { calls, slab };
|
||||
return { calls, slab, reviewPrimaries };
|
||||
}
|
||||
|
||||
// Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and
|
||||
@@ -284,6 +316,12 @@ async function completeForReady(page: Page) {
|
||||
await page.getByTestId('wood-cat-done').click();
|
||||
await page.getByTestId('edge-type').selectOption('31');
|
||||
await page.getByTestId('price').fill('120');
|
||||
// #89: fire the deterministic build so the title/short_description populate.
|
||||
// Do this BEFORE the SKU blur so a subsequent plain review doesn't race.
|
||||
await expect(page.getByTestId('create-title')).toBeEnabled();
|
||||
await page.getByTestId('create-title').click();
|
||||
// Wait for the build to complete (title field populated = deterministic copy).
|
||||
await expect(page.getByTestId('title')).not.toHaveValue('');
|
||||
await page.getByTestId('sku').fill('SLAB-0001');
|
||||
// SKU commits on blur — press Tab to move focus off it (deterministic, single
|
||||
// commit; a body click can re-focus the SKU and re-fire blur).
|
||||
@@ -292,12 +330,16 @@ async function completeForReady(page: Page) {
|
||||
}
|
||||
|
||||
test.describe('M3 Review→Publish flow (#77)', () => {
|
||||
test('golden path: capture→review→ready→publish→done', async ({ page }) => {
|
||||
await mockApi(page);
|
||||
test('golden path: capture→review→create→ready→publish→done (#77, #89)', async ({ page }) => {
|
||||
const { slab } = await mockApi(page);
|
||||
await reachReview(page);
|
||||
// Not ready until fields are set.
|
||||
// Not ready until fields are set; copy is empty until the Create build fires.
|
||||
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
|
||||
await expect(page.getByTestId('title')).toHaveValue('');
|
||||
await completeForReady(page);
|
||||
// #89: the Create build populated the deterministic copy on the slab row.
|
||||
expect(slab.title).toBeTruthy();
|
||||
expect(slab.short_description).toBeTruthy();
|
||||
// Continue to Publish is enabled and hands off.
|
||||
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||
await page.getByTestId('continue-publish').click();
|
||||
@@ -309,135 +351,136 @@ test.describe('M3 Review→Publish flow (#77)', () => {
|
||||
await expect(page.getByTestId('new-slab')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inference off: no inference calls are made', async ({ page }) => {
|
||||
const { calls } = await mockApi(page, { settings: { inference_enabled: false } });
|
||||
test('wood categories: closed summary shows names; primary radio among selected (#90, REVIEW-15)', async ({ page }) => {
|
||||
const { reviewPrimaries } = await mockApi(page);
|
||||
await reachReview(page);
|
||||
await completeForReady(page);
|
||||
// No "Suggest with AI" control when inference is disabled, and zero hits.
|
||||
await expect(page.getByTestId('run-inference')).toHaveCount(0);
|
||||
await expect(page.getByTestId('generate-text')).toHaveCount(0);
|
||||
expect(calls.call1).toBe(0);
|
||||
expect(calls.call2).toBe(0);
|
||||
|
||||
// Open wood MultiSelect, pick Live edge + Bowl blanks, Done.
|
||||
await page.getByTestId('wood-cat-btn').click();
|
||||
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
|
||||
await page.getByTestId('wood-cat-done').click();
|
||||
|
||||
// Closed summary shows selected names (not only a count).
|
||||
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Live edge/i);
|
||||
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Bowl blanks/i);
|
||||
|
||||
// Primary set appears with both selected; default primary = first selected.
|
||||
const primarySet = page.getByTestId('primary-wood-set');
|
||||
await expect(primarySet).toBeVisible();
|
||||
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
// Owner switches primary to Bowl blanks; review payload carries it.
|
||||
await page.getByTestId(`primary-wood-${BOWL_ID}`).click();
|
||||
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'false');
|
||||
await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID);
|
||||
});
|
||||
|
||||
test('inference on: Suggest with AI prefills + AI badges; Generate text writes', async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
settings: { inference_enabled: true, content_llm_enabled: true },
|
||||
onCall1: async (route) => {
|
||||
await json(route, {
|
||||
ok: true,
|
||||
suggestions: {
|
||||
species_id: SPECIES_ID,
|
||||
species_confidence: 0.9,
|
||||
wood_category_ids: [LIVEEDGE_ID],
|
||||
edge_type_term_id: 31,
|
||||
figure_term_ids: [34, 35],
|
||||
grade_term_id: 37,
|
||||
},
|
||||
test('thickness band is server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
|
||||
await mockApi(page);
|
||||
await reachReview(page);
|
||||
|
||||
// METADATA-15 / QUALITY-95: the band is derived server-side from the measured
|
||||
// thickness against the live synced Woo bands. It is deliberately ABSENT from
|
||||
// this screen — no owner control, no read-only echo. The only thickness the
|
||||
// owner sees is the measured value in the summary. (Commit 204bc5e dropped the
|
||||
// read-only echo; this test asserts the omission.)
|
||||
await expect(page.getByTestId('thickness-band')).toHaveCount(0);
|
||||
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* #90 served-surface render check: the changed Review surface (wood-category
|
||||
* MultiSelect + primary-radio set + read-only thickness band) is rendered at
|
||||
* desktop/tablet/mobile widths, measured, and captured to
|
||||
* tests/e2e/artifacts/ so layout and interaction can be inspected as evidence.
|
||||
*/
|
||||
test('Review surface renders at 1440/768/375 with no overflow (#90)', async ({ page }) => {
|
||||
const widths = [
|
||||
{ w: 1440, h: 900, name: 'desktop' },
|
||||
{ w: 768, h: 1024, name: 'tablet' },
|
||||
{ w: 375, h: 812, name: 'mobile' },
|
||||
];
|
||||
const report: string[] = [];
|
||||
|
||||
for (const vp of widths) {
|
||||
await page.setViewportSize({ width: vp.w, height: vp.h });
|
||||
await mockApi(page);
|
||||
await reachReview(page);
|
||||
// Put the surface in its real state: two wood categories selected, primary set.
|
||||
await page.getByTestId('wood-cat-btn').click();
|
||||
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
|
||||
await page.getByTestId('wood-cat-done').click();
|
||||
await expect(page.getByTestId('primary-wood-set')).toBeVisible();
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
|
||||
const box = (sel: string) => {
|
||||
const el = q(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
w: Math.round(r.width), h: Math.round(r.height),
|
||||
fontSize: cs.fontSize,
|
||||
overflowX: el.scrollWidth > el.clientWidth + 1,
|
||||
text: (el.textContent || '').trim().slice(0, 80),
|
||||
};
|
||||
};
|
||||
const primaryRow = q('[data-testid^="primary-wood-"]');
|
||||
const primaryRowBox = primaryRow ? primaryRow.getBoundingClientRect() : null;
|
||||
// Anything whose box pokes outside the viewport, with enough identity to fix it.
|
||||
const offenders: { tag: string; cls: string; id: string; left: number; right: number }[] = [];
|
||||
document.querySelectorAll('body *').forEach((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) return;
|
||||
if (r.right > de.clientWidth + 1 || r.left < -1) {
|
||||
offenders.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: String(el.className || '').slice(0, 48),
|
||||
id: el.id,
|
||||
left: Math.round(r.left),
|
||||
right: Math.round(r.right),
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
onCall2: async (route) => {
|
||||
await json(route, { ok: true, description: 'AI-generated slab story.' });
|
||||
},
|
||||
});
|
||||
await reachReview(page);
|
||||
await page.getByTestId('run-inference').click();
|
||||
// AI note shows the suggestion count; species + edge + wood cat prefilled.
|
||||
await expect(page.getByTestId('ai-note')).toContainText(/AI suggested/i, { timeout: 15000 });
|
||||
await expect(page.getByTestId('species')).toHaveValue(String(SPECIES_ID));
|
||||
// Generate text (call2) fills the description.
|
||||
await page.getByTestId('generate-text').click();
|
||||
await expect(page.getByTestId('desc')).toHaveValue('AI-generated slab story.', { timeout: 15000 });
|
||||
});
|
||||
return {
|
||||
docScrollW: de.scrollWidth,
|
||||
docClientW: de.clientWidth,
|
||||
pageHasHScroll: de.scrollWidth > de.clientWidth + 1,
|
||||
offenders: offenders.slice(0, 8),
|
||||
summary: box('[data-testid="wood-cat-summary"]'),
|
||||
count: box('[data-testid="wood-cat-count"]'),
|
||||
band: box('[data-testid="thickness-band"]'),
|
||||
primarySet: box('[data-testid="primary-wood-set"]'),
|
||||
primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null,
|
||||
createBtn: box('[data-testid="create-title"]'),
|
||||
continueBtn: box('[data-testid="continue-publish"]'),
|
||||
};
|
||||
});
|
||||
|
||||
test('fig-*/feat-* tag pickers render (optional) and persist to the slab', async ({ page }) => {
|
||||
const { slab } = await mockApi(page);
|
||||
await reachReview(page);
|
||||
// Both pickers render from the synced tag families (METADATA-30 / METADATA-40).
|
||||
await expect(page.getByTestId('fig-tag-btn')).toBeVisible();
|
||||
await expect(page.getByTestId('feat-tag-btn')).toBeVisible();
|
||||
// feat-* carries the "AI assist" tag (Call 1 proposes feature tags); fig-* does not.
|
||||
// Scope to the field label (getByText would also match the MultiSelect's inner label).
|
||||
await expect(page.locator('label', { hasText: 'feat-* tags' })).toContainText('AI assist');
|
||||
await expect(page.locator('label', { hasText: 'fig-* tags' })).not.toContainText('AI assist');
|
||||
// Select one fig tag and one feat tag.
|
||||
await page.getByTestId('fig-tag-btn').click();
|
||||
await page.getByTestId(`fig-tag-opt-${FIG_TAG_ID}`).click();
|
||||
await page.getByTestId('fig-tag-done').click();
|
||||
await expect(page.getByTestId('fig-tag-btn')).toContainText('1 selected');
|
||||
await page.getByTestId('feat-tag-btn').click();
|
||||
await page.getByTestId(`feat-tag-opt-${FEAT_TAG_ID}`).click();
|
||||
await page.getByTestId('feat-tag-done').click();
|
||||
await expect(page.getByTestId('feat-tag-btn')).toContainText('1 selected');
|
||||
// Flush the tag ids to the slab: an on:change field (grade) triggers
|
||||
// commitReview, which PUTs fig_tag_ids/feat_tag_ids with the current values.
|
||||
await page.getByTestId('grade').selectOption('37');
|
||||
// Tags are optional — they are not in the server ready-set, so readiness is
|
||||
// unchanged (still "Not ready" because the other required fields are empty).
|
||||
await expect(page.getByTestId('ready-status')).toContainText('Not ready');
|
||||
// The commit PUT folded the tag ids into the slab row (what Publish will send).
|
||||
expect(slab.fig_tag_ids).toEqual([FIG_TAG_ID]);
|
||||
expect(slab.feat_tag_ids).toEqual([FEAT_TAG_ID]);
|
||||
});
|
||||
const shot = `tests/e2e/artifacts/review-90-${vp.name}-${vp.w}.png`;
|
||||
await page.screenshot({ path: shot, fullPage: true });
|
||||
report.push(`${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
|
||||
// Print before asserting so the measurement survives a failing gate.
|
||||
console.log(`RENDER ${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
|
||||
|
||||
test('publish failure: duplicate SKU offers edit + open existing', async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
onPublish: async (route) => {
|
||||
await json(route, {
|
||||
error: { code: 'duplicate_sku', message: 'SKU already exists', detail: {} },
|
||||
}, 409);
|
||||
},
|
||||
});
|
||||
await reachReview(page);
|
||||
await completeForReady(page);
|
||||
await page.getByTestId('continue-publish').click();
|
||||
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||
await page.getByTestId('publish-btn').click();
|
||||
await expect(page.getByTestId('dup-sku')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByTestId('edit-sku')).toBeVisible();
|
||||
await expect(page.getByTestId('open-existing')).toBeVisible();
|
||||
});
|
||||
|
||||
test('publish failure: recoverable Woo error offers retry', async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
onPublish: async (route) => {
|
||||
await json(route, {
|
||||
error: {
|
||||
code: 'woo_unreachable',
|
||||
message: 'Store is unreachable',
|
||||
detail: { recoverable: true, partial_media: false },
|
||||
},
|
||||
}, 502);
|
||||
},
|
||||
});
|
||||
await reachReview(page);
|
||||
await completeForReady(page);
|
||||
await page.getByTestId('continue-publish').click();
|
||||
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||
await page.getByTestId('publish-btn').click();
|
||||
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByTestId('store-failure')).toContainText('Store is unreachable');
|
||||
await expect(page.getByTestId('retry-publish')).toBeVisible();
|
||||
});
|
||||
|
||||
test('publish failure: partial media is flagged for manual cleanup', async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
onPublish: async (route) => {
|
||||
await json(route, {
|
||||
error: {
|
||||
code: 'woo_partial_media',
|
||||
message: 'Product created, photos failed',
|
||||
detail: { recoverable: true, partial_media: true },
|
||||
},
|
||||
}, 502);
|
||||
},
|
||||
});
|
||||
await reachReview(page);
|
||||
await completeForReady(page);
|
||||
await page.getByTestId('continue-publish').click();
|
||||
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||
await page.getByTestId('publish-btn').click();
|
||||
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByTestId('store-failure')).toContainText(/orphan media/i);
|
||||
// Layout gates: nothing spills sideways, the closed summary names both picks
|
||||
// (CSS ellipsis may shorten the paint, but the text must be there), and the
|
||||
// primary rows stay a comfortable tap target on mobile.
|
||||
expect(metrics.pageHasHScroll, `${vp.name} horizontal overflow: ${metrics.docScrollW} > ${metrics.docClientW}`).toBe(false);
|
||||
expect(metrics.summary?.text).toMatch(/Live edge/i);
|
||||
expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
|
||||
expect(metrics.count?.text).toContain('2 selected');
|
||||
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
|
||||
// Band is deliberately absent from Review (METADATA-15) — no element to measure.
|
||||
expect(metrics.band).toBeNull();
|
||||
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
|
||||
}
|
||||
console.log('RENDER REPORT\n' + report.join('\n'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
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',
|
||||
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', '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,
|
||||
};
|
||||
};
|
||||
|
||||
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('create-title')).toBeEnabled();
|
||||
await page.getByTestId('create-title').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);
|
||||
|
||||
// Bug #1 regression: the primary photo's <img src> must be a valid URL
|
||||
// (blob: for an in-memory capture blob), never empty or "object Blob".
|
||||
const primaryImg = cards.nth(0).locator('img');
|
||||
await expect(primaryImg).not.toHaveAttribute('src', /object Blob|^$/);
|
||||
const src = await primaryImg.getAttribute('src');
|
||||
expect(src && src.length > 4).toBeTruthy();
|
||||
|
||||
// Bug #2 regression: the card carries the gray checkerboard background
|
||||
// (matching the Capture lineup), not a flat color.
|
||||
const bgImage = await cards.nth(0).evaluate((el) => getComputedStyle(el).backgroundImage);
|
||||
expect(bgImage).toContain('45deg');
|
||||
|
||||
// Mandatory photos: the clean calibration (seq 1) and the dimension-overlay
|
||||
// copy (seq 2) are required — their keep checkbox is checked AND disabled,
|
||||
// and a REQUIRED tag marks them.
|
||||
await expect(cards.nth(0)).toHaveAttribute('data-required', 'true');
|
||||
await expect(cards.nth(1)).toHaveAttribute('data-required', 'true');
|
||||
await expect(cards.nth(2)).toHaveAttribute('data-required', '');
|
||||
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(cards.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(cards.nth(0)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(cards.nth(1)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(cards.nth(2)).toHaveAttribute('aria-disabled', 'false');
|
||||
// Two REQUIRED tags (cal + overlay), none on the batch photo.
|
||||
await expect(page.getByText('REQUIRED', { exact: true })).toHaveCount(2);
|
||||
|
||||
// Tapping a mandatory photo is a no-op: it stays checked + primary (can't remove).
|
||||
// The card is aria-disabled, so force the click to exercise the handler directly.
|
||||
await cards.nth(0).click({ force: true });
|
||||
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
||||
// No minimum nudge — both required photos are kept by construction.
|
||||
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
||||
|
||||
// Remove the OPTIONAL batch photo (index 2) → it un-keeps; required stay kept.
|
||||
await cards.nth(2).click();
|
||||
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
|
||||
// Primary is still the leftmost required (cal, index 0).
|
||||
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
||||
// Re-add the batch photo.
|
||||
await cards.nth(2).click();
|
||||
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', '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('required photos guarantee the minimum; removing an OPTIONAL batch persists a DELETE', async ({ page }) => {
|
||||
const mock = await mockApi(page);
|
||||
await reachReview(page);
|
||||
const cards = page.getByTestId('photo-card');
|
||||
|
||||
// The clean calibration + dimension-overlay are mandatory and always kept,
|
||||
// so the submission minimum can never be broken — publish is never lineup-blocked.
|
||||
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
||||
|
||||
// Removing an OPTIONAL batch photo is allowed and persists a DELETE.
|
||||
await cards.nth(2).click();
|
||||
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
|
||||
// Still no nudge: both required photos remain kept.
|
||||
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
||||
|
||||
// Complete the rest of the form; the lineup is valid (required photos kept).
|
||||
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();
|
||||
|
||||
// The removed batch photo persisted as a DELETE; the required photos re-uploaded.
|
||||
expect(mock.deletes).toEqual(['ph-b1']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user