diff --git a/AGENTS.md b/AGENTS.md index eada9d2..c85ae8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,32 @@ - Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel. +## Build & test (verified commands) + +Frontend (`frontend/`, Svelte 4 + Vite + TS, no router): + +``` +cd frontend && npm install +npm run test # node --import tsx --test src/**/*.test.ts (unit) +npm run build # tsc --noEmit && vite build (type-check + build) +npm run test:e2e # playwright test (see Pitfalls: mocks the API) +``` + +Backend (`backend/`, FastAPI + stdlib sqlite, no ORM, deps pinned in `backend/requirements.txt`): + +``` +cd backend && python -m pytest tests/ -q +uvicorn backend.app:app --host 127.0.0.1 --port 18081 +``` + +No Makefile, no CI workflows, no Gitea CI — nothing runs in a pipeline; the only automation is the local commands above plus the human UAT walk (`deploy/uat_walk.sh`). + +## Conventions + +- The LIVE FastAPI routes in `backend/*.py` are the API source of truth; `docs/contracts/API.md` is aspirational and can drift (it describes a polling model the backend never shipped). Read the router, not the doc, before wiring a client. +- Frontend is one Svelte app, no router: `App.svelte` switches on a `writable` flow store. Screens live under `frontend/src//`. +- Commit subjects: `feat(scope): …` / `fix(scope): …` / `test(scope): …` / `chore(scope): …`, often citing a requirement ID or Gitea number. + ## Gates (must pass before claiming work complete) Run from repo root: @@ -25,6 +51,14 @@ python tools/check_coverage.py Exit 0 on both = pass. +## Pitfalls + +- E2E mocks the API: `tests/e2e/*.spec.ts` use Playwright `page.route('**/api/v1/**')` with fixture ids — there is **no live backend** under e2e. A green `test:e2e` is not proof the real backend works; that's the human `deploy/uat_walk.sh` (real Woo, never a mock — QUALITY-105). +- Ports on the 5060 UAT host (192.168.1.202): FastAPI `18083`, FE `18082`/`10881` are typically already up (compose on `deploy/docker-compose.yml`). Check before binding; don't assume `18081` is free. +- `SLAB_UAT_UNMASK_SECRETS=1` makes `GET /settings` return decrypted Woo/inference secrets — dev-test only, never set in prod (SETTINGS-75 masking is the default). +- Never hardcode live Woo category/term ids into app code (QUALITY-105 / METADATA-5); resolve groupers by slug (`species`, `wcat`) at runtime. +- `tsc` won't catch Svelte template errors; `npm run build` (which runs `tsc --noEmit`) is the real type gate for the frontend. + ## Pointers (read these for everything else) - Docs map and shipped status: `docs/README.md` diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index bc261cd..e8e4b79 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -6,12 +6,12 @@ import { defineConfig, devices } from '@playwright/test'; */ export default defineConfig({ testDir: './tests/e2e', - fullyParallel: false, + fullyParallel: true, forbidOnly: !!process.env.CI, retries: 0, - workers: 1, - timeout: 180_000, - expect: { timeout: 30_000 }, + workers: 2, + timeout: 30_000, + expect: { timeout: 4_000 }, reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]], use: { baseURL: 'http://127.0.0.1:4173', diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 692f8f8..4288d20 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -2,19 +2,24 @@ import { onMount, onDestroy } from 'svelte'; import { supported } from './browser'; import { createCaptureSession, type CaptureSession } from './capture/session'; - import type { Capture } from './capture/capture.types'; -import Resume from './capture/Resume.svelte'; -import BatchPhotos from './capture/BatchPhotos.svelte'; -import PickCalibration from './capture/PickCalibration.svelte'; -import MaskPanel from './capture/MaskPanel.svelte'; -import AxisPanel from './capture/AxisPanel.svelte'; -import MeasurePanel from './capture/MeasurePanel.svelte'; -import Settings from './create/settings/Settings.svelte'; -import SlabList from './create/slabs/SlabList.svelte'; -import PhotoAttach from './create/photos/PhotoAttach.svelte'; -import Review from './create/review/Review.svelte'; -import Publish from './create/publish/Publish.svelte'; -import GenerateText from './create/inference/GenerateText.svelte'; + import type { Capture, Measures } from './capture/capture.types'; + import { eighthsToIn } from './capture/capture.types'; + import { buildPhotosReadyLineup } from './capture/photosReadyLineup'; + import Resume from './capture/Resume.svelte'; + import BatchPhotos from './capture/BatchPhotos.svelte'; + import PickCalibration from './capture/PickCalibration.svelte'; + import MaskPanel from './capture/MaskPanel.svelte'; + import AxisPanel from './capture/AxisPanel.svelte'; + import MeasurePanel from './capture/MeasurePanel.svelte'; + import Settings from './create/settings/Settings.svelte'; + import SlabList from './create/slabs/SlabList.svelte'; + import Review from './create/review/Review.svelte'; + import Publish from './create/publish/Publish.svelte'; + import { + flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture, + } from './create/flow'; + import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs'; + import { clearDraft } from './draft/idb'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const session: CaptureSession = createCaptureSession(); @@ -24,27 +29,104 @@ import GenerateText from './create/inference/GenerateText.svelte'; let pngUrl: string | null = null; let showSettings = false; let showSlabs = false; - let showPhotos = false; - let showReview = false; - let showPublish = false; - let showGenerate = false; + + let curFlow: import('./create/flow').Flow = { phase: 'capture' }; + $: curFlow = $flow; + let saving = false; + let saveError = ''; const unsubCap = session.capture.subscribe((c) => { cap = c; pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null; }); - const unsubBusy = session.busy.subscribe((b) => { - busy = b; - }); + const unsubBusy = session.busy.subscribe((b) => (busy = b)); onMount(() => { - void session.resumeProbe(); + // e2e boot guard: `?noload` skips the resume probe so a test can seed the + // device draft into IndexedDB after the origin exists, then reload without + // ?noload to trigger the Resume prompt deterministically. + const boot = new URLSearchParams(window.location.search); + if (!boot.get('noload')) void session.resumeProbe(); }); onDestroy(() => { unsubCap(); unsubBusy(); session.dispose(); }); + + // APP-15: the device draft is cleared only on publish success / "new slab". + function startNewSlab(): void { + void clearDraft().then(() => { + session.startFresh(); + backToCapture(); + }); + } + + // C2: Photos-ready → build the calibrated slab, upload the lineup PNGs, then + // hand off to Review. The device draft is retained here (APP-10); it is + // cleared only on publish success (APP-15). + async function continueToReview(): Promise { + const m = session.currentMeasure(); + if (!m || !m.results) return; + const r: Measures = m.results; + const capture: CalibratedCapture = { + lengthIn: eighthsToIn(m.lengthEighths), + thicknessIn: eighthsToIn(m.thicknessEighths), + sqft: r.sqft, + bdft: r.bdft, + widthMinIn: eighthsToIn(r.widths.min), + widthMaxIn: eighthsToIn(r.widths.max), + widthAvgIn: eighthsToIn(r.widths.avg), + sourceBelowFloor: r.sourceBelowFloor, + lineup: buildPhotosReadyLineup({ + inventoryPng: r.inventoryPng, + overlayPng: r.overlayPng, + batchApply: m.batchApply, + sourceBelowFloor: r.sourceBelowFloor, + }), + }; + saving = true; + saveError = ''; + startSaving(); + try { + const id = crypto.randomUUID(); + await createSlab({ + id, + sku: '', + length_in: capture.lengthIn, + thickness_in: capture.thicknessIn, + sqft: capture.sqft, + bdft: capture.bdft, + width_min_in: capture.widthMinIn, + width_max_in: capture.widthMaxIn, + width_avg_in: capture.widthAvgIn, + price: null, + price_source: 'recommendation', + content_source: 'manual', + client_rev: 0, + }); + const pngItems = capture.lineup.filter( + (it) => it.kind === 'png' && it.role !== 'overlay', + ); + for (let i = 0; i < pngItems.length; i++) { + const it = pngItems[i]; + await uploadPhoto(id, { + photoId: crypto.randomUUID(), + seq: i + 1, + isCalibration: it.role === 'calibration', + original: it.blob, + processed: it.blob, + }); + } + enterReview(id, capture); + } catch (e) { + saveError = + e instanceof ApiError ? e.message : 'Could not start the slab. Check your connection and try again.'; + backToCapture(); + } finally { + saving = false; + } + } {#if gate} @@ -58,55 +140,103 @@ import GenerateText from './create/inference/GenerateText.svelte'; {:else}
- {#if cap.k === 'resume'} - session.continueDraft()} onFresh={() => void session.startFresh()} /> - {:else if cap.k === 'batch'} - void session.addToBatch(f)} - onRemove={session.removeFromBatch} - onContinue={session.continueBatch} + {#if curFlow.phase === 'review'} + { + const f = curFlow; + if (f.phase === 'review') enterPublish(f.slabId, f.capture); + }} + onBack={() => backToCapture()} /> - {:else if cap.k === 'pickCalibration'} - - {:else if cap.k === 'mask'} - - {:else if cap.k === 'axis'} - markDone(curFlow.slabId, wooId, draft)} + onBack={() => { + const f = curFlow; + if (f.phase === 'publish') backToReview(f.slabId, f.capture); + }} /> + {:else if curFlow.phase === 'done'} +
+

Published

+

+ {#if curFlow.draft} + Created as a UAT draft in the store. + {:else} + Created in the store. + {/if} +

+

Woo product #{curFlow.wooProductId} · photos and device draft cleared.

+
+ +
+
+ {:else if curFlow.phase === 'saving'} +
+

Saving slab…

+
{:else} - + + {#if cap.k === 'resume'} + session.continueDraft()} onFresh={() => void session.startFresh()} /> + {:else if cap.k === 'batch'} + void session.addToBatch(f)} + onRemove={session.removeFromBatch} + onContinue={session.continueBatch} + /> + {:else if cap.k === 'pickCalibration'} + + {:else if cap.k === 'mask'} + + {:else if cap.k === 'axis'} + + {:else} + void continueToReview()} + /> + {/if} + {#if saving} +
Saving slab…
+ {/if} + {#if saveError} + + {/if} {/if} + +
- - - - - - + +
{#if showSettings} @@ -114,18 +244,6 @@ import GenerateText from './create/inference/GenerateText.svelte'; {#if showSlabs} {/if} - {#if showPhotos} - - {/if} - {#if showReview} - - {/if} - {#if showPublish} - - {/if} - {#if showGenerate} - - {/if}
{/if} @@ -139,4 +257,13 @@ import GenerateText from './create/inference/GenerateText.svelte'; .hardstop { padding: 48px 24px; color: #eee; text-align: center; } .hardstop h1 { font-size: 1.6rem; } .detail { color: #888; font-size: 0.85rem; } + .panel { padding: 16px; color: #eee; } + .done .ok { color: #7fd37f; font-weight: 600; } + .row { display: flex; gap: 10px; margin-top: 16px; } + .primary { + flex: 1; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0; + background: #00e5ff; color: #002; font-weight: 700; + } + .busy { color: #aaa; } + .save-err { margin-top: 12px; color: #ffb4a2; font-weight: 600; } diff --git a/frontend/src/api/slabs.ts b/frontend/src/api/slabs.ts index 785fb08..5d3cfd0 100644 --- a/frontend/src/api/slabs.ts +++ b/frontend/src/api/slabs.ts @@ -1,45 +1,130 @@ -// Thin client for the S2 slabs endpoints. Svelte-agnostic. +// Thin client for the SlabUploader app API. Svelte-agnostic. +// +// Mirrors the LIVE backend routes (backend/*.py), which are the source of truth: +// slabs.py POST/GET/PUT/DELETE /api/v1/slabs[/{id}] +// photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed] +// review.py POST /api/v1/slabs/{id}/review +// publish.py POST /api/v1/slabs/{id}/publish +// inference.py POST /api/v1/slabs/{id}/inference/call1|call2 +// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference +// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync] +// pricing.py CRUD /api/v1/admin/pricing[/{id}] +// +// Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain +// owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner). export interface Slab { id: string; sku: string; status: string; - price: number; + price: number | null; + price_source: string | null; length_in: number; thickness_in: number; sqft: number; bdft: number; + width_min_in: number; + width_max_in: number; width_avg_in: number; + species_id: number | null; + wood_category_ids: number[]; + edge_type_term_id: number | null; + figure_term_ids: number[]; + grade_term_id: number | null; + thickness_term_id: number | null; + moisture_term_id: number | null; + fig_tag_ids: number[]; + feat_tag_ids: number[]; title: string | null; + short_description: string | null; + description: string | null; + content_source: string | null; + species_confidence: number | null; + figure_confidence: number | null; + grade_confidence: number | null; + inference_status: string | null; + calibration_photo_id: string | null; + woo_product_id: number | null; server_rev: number; created_at: string; updated_at: string; } +// Fields a client may send on create/update (subset of the row). +export type SlabInput = Partial< + Omit< + Slab, + | 'id' + | 'status' + | 'woo_product_id' + | 'server_rev' + | 'created_at' + | 'updated_at' + | 'inference_status' + | 'species_confidence' + | 'figure_confidence' + | 'grade_confidence' + | 'calibration_photo_id' + > +>; + +export interface SlabCreateInput extends SlabInput { + id: string; // client-generated UUIDv4 (APP-20) + client_rev: number; // 0 on create (APP-20) +} + +export interface ApiErrorBody { + code: string | null; + message: string; + detail?: Record | null; +} + export class ApiError extends Error { - constructor(public status: number, public code: string | null, message: string) { + constructor( + public status: number, + public code: string | null, + message: string, + public detail?: Record | null, + ) { super(message); this.name = 'ApiError'; } } async function parse(res: Response): Promise { - if (!res.ok) { - let code: string | null = null; - let message = res.statusText; - try { - const body = await res.json(); - if (body?.error) { - code = body.error.code ?? null; - message = body.error.message ?? message; - } - } catch { - /* non-JSON body */ - } - throw new ApiError(res.status, code, message); - } if (res.status === 204) return undefined as T; - return (await res.json()) as T; + let body: unknown = null; + try { + body = await res.json(); + } catch { + /* non-JSON body (e.g. 410 Gone) */ + } + if (!res.ok) { + const e = (body as { error?: ApiErrorBody } | null)?.error; + throw new ApiError( + res.status, + e?.code ?? null, + e?.message ?? res.statusText, + e?.detail ?? null, + ); + } + return body as T; +} + +function jsonBody(v: unknown): BodyInit { + return JSON.stringify(v); +} + +// --------------------------------------------------------------------------- +// Slabs +// --------------------------------------------------------------------------- + +export function createSlab(input: SlabCreateInput): Promise { + return fetch('/api/v1/slabs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: jsonBody(input), + }).then((r) => parse(r)); } export function listSlabs(): Promise { @@ -49,3 +134,319 @@ export function listSlabs(): Promise { export function getSlab(id: string): Promise { return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse(r)); } + +export function updateSlab(id: string, patch: SlabInput & { client_rev: number }): Promise { + return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: jsonBody(patch), + }).then((r) => parse(r)); +} + +export function deleteSlab(id: string): Promise { + return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) => + parse(r), + ); +} + +// --------------------------------------------------------------------------- +// Photos (lineup order = seq; calibration is is_calibration=1) +// --------------------------------------------------------------------------- + +export interface PhotoRow { + id: string; + seq: number; + is_calibration: number; + width_px: number | null; + height_px: number | null; + has_original: number; + has_processed: number; +} + +export interface UploadPhotoInput { + photoId: string; // client-generated UUIDv4 + seq?: number; + isCalibration: boolean; + original: Blob; + processed?: Blob | null; +} + +export interface UploadPhotoResult { + id: string; + slab_id: string; + seq: number; + is_calibration: number; + original_retained: boolean; + processed_retained: boolean; +} + +export function listPhotos(slabId: string): Promise { + return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) => + parse(r), + ); +} + +export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise { + const form = new FormData(); + form.append('photo_id', input.photoId); + if (input.seq != null) form.append('seq', String(input.seq)); + form.append('is_calibration', input.isCalibration ? '1' : '0'); + 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'); + return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`, { + method: 'POST', + body: form, + }).then((r) => parse(r)); +} + +export function photoUrl( + slabId: string, + photoId: string, + kind: 'original' | 'processed', +): string { + return `/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}/${kind}`; +} + +// --------------------------------------------------------------------------- +// Review (REVIEW-*) +// --------------------------------------------------------------------------- + +export interface ReviewResult { + ready: boolean; + missing: string[]; + title: string | null; + short_description: string | null; + price: number | null; + price_suggestion: number | null; + primary_wood_category_id: number | null; +} + +export interface ReviewInput { + primary_wood_category_id?: number; + price?: number; + sku?: string; + title?: string; + short_description?: string; + description?: string; + price_source?: string; + content_source?: string; +} + +export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise { + return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: jsonBody(payload), + }).then((r) => parse(r)); +} + +// --------------------------------------------------------------------------- +// Publish (PUBLISH-*) +// --------------------------------------------------------------------------- + +export interface PublishResult { + ok: true; + status: string; + woo_product_id: number; + draft: boolean; +} + +export function publishSlab(slabId: string): Promise { + return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { + method: 'POST', + }).then((r) => parse(r)); +} + +// --------------------------------------------------------------------------- +// Inference (METADATA-*) +// --------------------------------------------------------------------------- + +export interface Call1Suggestions { + species_id?: number | null; + species_confidence?: number | null; + wood_category_ids?: number[]; + edge_type_term_id?: number | null; + figure_term_ids?: number[]; + figure_confidence?: number | null; + grade_term_id?: number | null; + grade_confidence?: number | null; + character?: string | null; + inclusions?: string | null; + voids?: string | null; + checks?: string | null; +} + +export interface Call1Result { + ok: boolean; + suggestions: Call1Suggestions; +} + +export interface Call2Result { + ok: boolean; + description: string; +} + +export function inferTaxon(slabId: string): Promise { + return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, { + method: 'POST', + }).then((r) => parse(r)); +} + +export function inferContent(slabId: string): Promise { + return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { + method: 'POST', + }).then((r) => parse(r)); +} + +// --------------------------------------------------------------------------- +// Taxonomy (METADATA-5/15/30; only legal picker options) +// --------------------------------------------------------------------------- + +export interface TaxCategory { + id: number; + name: string; + slug: string; + parent_id: number | null; + woo_id: number | null; +} + +export interface TaxTerm { + id: number; + name: string; + slug: string; +} + +export interface TaxAttribute { + id: number; + name: string; + slug: string; + terms: TaxTerm[]; +} + +export interface TaxTag { + id: number; + name: string; + slug: string; +} + +export interface Taxonomy { + categories: TaxCategory[]; + attributes: TaxAttribute[]; + tags: TaxTag[]; + taxonomy_anchor: string | null; + taxonomy_last_sync_at: string | null; +} + +export function getTaxonomy(): Promise { + return fetch('/api/v1/admin/taxonomy').then((r) => parse(r)); +} + +export function syncTaxonomy(): Promise<{ + taxonomy_anchor: string; + taxonomy_last_sync_at: string; +}> { + return fetch('/api/v1/admin/taxonomy/sync', { method: 'POST' }).then((r) => parse(r)); +} + +// --------------------------------------------------------------------------- +// Pricing (SETTINGS-35) +// --------------------------------------------------------------------------- + +export interface PriceRule { + id: number; + species_id: number; + price_per_bdft: number; + created_at: string; + updated_at: string; +} + +export function listPricing(): Promise { + return fetch('/api/v1/admin/pricing').then((r) => parse(r)); +} + +export function createPricingRule(rule: { + species_id: number; + price_per_bdft: number; +}): Promise { + return fetch('/api/v1/admin/pricing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: jsonBody(rule), + }).then((r) => parse(r)); +} + +export function updatePricingRule( + id: number, + patch: { price_per_bdft: number; species_id?: number }, +): Promise { + return fetch(`/api/v1/admin/pricing/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: jsonBody(patch), + }).then((r) => parse(r)); +} + +export function deletePricingRule(id: number): Promise { + return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse(r)); +} + +// --------------------------------------------------------------------------- +// Settings (SETTINGS-*) +// --------------------------------------------------------------------------- + +export interface SettingsView { + woo_base_url: string; + woo_wp_username: string | null; + woo_app_password: { configured: boolean } | string; + inference_base_url: { configured: boolean } | string; + inference_api_key: { configured: boolean } | string; + inference_model: string | null; + inference_enabled: boolean; + content_llm_enabled: boolean; + brand_voice: string | null; + geo_context: string | null; + woo_create_status: string; + taxonomy_last_sync_at: string | null; + taxonomy_anchor: string | null; +} + +export function getSettings(): Promise { + return fetch('/api/v1/settings').then((r) => parse(r)); +} + +export function updateSettings( + patch: Partial>, +): Promise { + return fetch('/api/v1/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: jsonBody(patch), + }).then((r) => parse(r)); +} + +export interface TestWooResult { + ok: boolean; + detail: string; + taxonomy_anchor: string | null; + taxonomy_last_sync_at: string | null; +} + +export interface TestInferenceResult { + ok: boolean; + mode: 'stateful' | 'stateless' | 'fail'; + vision_capable: boolean; + detail: string; +} + +export function testWoo(): Promise { + return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) => + parse(r), + ); +} + +export function testInference(): Promise { + return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) => + parse(r), + ); +} diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index f236794..58b071e 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -16,6 +16,8 @@ export let calculate: () => Promise; export let back: () => void; export let pngUrl: string | null; + /** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */ + export let onContinue: (() => void) | undefined = undefined; let calcBusy = false; let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths)); @@ -177,6 +179,11 @@
+ {#if onContinue} + + {/if}
{/if} diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 59e73fa..3cf36b2 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -47,6 +47,8 @@ export interface CaptureSession { calculate(): Promise; back(): void; getInventoryUrl(): string | null; + /** The current `measure` branch, or null while not on it (C2 handoff). */ + currentMeasure(): Extract | null; dispose(): void; } @@ -632,6 +634,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { return pngUrl; }, + currentMeasure() { + return state.k === 'measure' ? state : null; + }, + dispose() { teardownWorker(); }, diff --git a/frontend/src/create/flow.ts b/frontend/src/create/flow.ts new file mode 100644 index 0000000..5765129 --- /dev/null +++ b/frontend/src/create/flow.ts @@ -0,0 +1,51 @@ +// Shared state for the one-column Create flow (UX.md §1): capture → review → +// publish. App.svelte owns it; MeasurePanel hands off the calibrated capture, +// Review hands to Publish, Publish clears the device draft on success. +// +// The flow always concerns a SINGLE slab — there is no list/picker in the +// owner path (CAPTURE-25/30). + +import { writable } from 'svelte/store'; +import type { PhotosReadyLineupItem } from '../capture/photosReadyLineup'; + +export interface CalibratedCapture { + lengthIn: number; + thicknessIn: number; + sqft: number; + bdft: number; + widthMinIn: number; + widthMaxIn: number; + widthAvgIn: number; + sourceBelowFloor: boolean; + lineup: PhotosReadyLineupItem[]; +} + +export type Flow = + | { phase: 'capture' } + | { phase: 'saving' } + | { phase: 'review'; slabId: string; capture: CalibratedCapture } + | { phase: 'publish'; slabId: string; capture: CalibratedCapture } + | { phase: 'done'; slabId: string; wooProductId: number; draft: boolean }; + +const initial: Flow = { phase: 'capture' }; + +export const flow = writable(initial); + +export function startSaving(): void { + flow.set({ phase: 'saving' }); +} +export function enterReview(slabId: string, capture: CalibratedCapture): void { + flow.set({ phase: 'review', slabId, capture }); +} +export function enterPublish(slabId: string, capture: CalibratedCapture): void { + flow.set({ phase: 'publish', slabId, capture }); +} +export function markDone(slabId: string, wooProductId: number, draft: boolean): void { + flow.set({ phase: 'done', slabId, wooProductId, draft }); +} +export function backToReview(slabId: string, capture: CalibratedCapture): void { + flow.set({ phase: 'review', slabId, capture }); +} +export function backToCapture(): void { + flow.set({ phase: 'capture' }); +} diff --git a/frontend/src/create/publish/Publish.svelte b/frontend/src/create/publish/Publish.svelte index 99d063f..e0ebde7 100644 --- a/frontend/src/create/publish/Publish.svelte +++ b/frontend/src/create/publish/Publish.svelte @@ -1,69 +1,137 @@ -
-

Publish to WooCommerce

-
- - -
- +{#if loading} +

Publish to WooCommerce

Loading…

+{:else if loadError} +

Publish to WooCommerce

+ +
+{:else} +
+

Publish to WooCommerce

+

SKU {slab?.sku || '—'} · {slab?.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab?.price != null ? `$${slab.price.toFixed(2)}` : '—'}

- {#if result} - {#if result.ok} -

- Published{result.draft ? ' (UAT draft)' : ''} → Woo product #{result.woo_product_id} -

- {:else} -

- {result.error?.message} - {#if result.error?.detail?.recoverable === false} - unrecoverable - {:else if result.error?.detail?.recoverable === true} - recoverable — retry - {/if} - {#if result.error?.detail?.partial_media} - orphan media — clean up manually - {/if} -

+ {#if st.phase === 'idle' || st.phase === 'working'} + + {/if} - {/if} -
+ + {#if st.phase === 'success'} +
Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}
+

Photos purged · device draft cleared.

+ + {:else if st.phase === 'failure' && st.fail} + {#if st.fail.duplicateSku} +
+
✕
+

SKU already exists

+

“{slab?.sku}” is already in the store.

+
+ + + {:else if st.fail.code === 'unpublishable'} +
+
✕
+

Not ready to publish

+

Some fields are missing. Go back and finish Review.

+
+ + {:else} +
+
{st.fail.partialMedia ? '⚠' : '↻'}
+

{st.fail.message}

+ {#if st.fail.recoverable}recoverable{/if} + {#if st.fail.partialMedia}orphan media — remove manually{/if} + {#if !st.fail.recoverable}check Settings{/if} +
+ + {/if} + {/if} + +{/if} diff --git a/frontend/src/create/review/MultiSelect.svelte b/frontend/src/create/review/MultiSelect.svelte new file mode 100644 index 0000000..29719db --- /dev/null +++ b/frontend/src/create/review/MultiSelect.svelte @@ -0,0 +1,119 @@ + + +
+ + + {#if open} +
+
+ + +
+
    + {#each filtered as o (o.id)} +
  • toggle(o.id)} + > + + + {o.name} + {#if o.ai}AI{/if} + +
  • + {:else} +
  • No matches
  • + {/each} +
+
+ {options.length} synced{filter.trim() ? ` · ${filtered.length} shown` : ''} + +
+
+ {/if} +
+ + diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index 1fc66ec..80262c3 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -1,86 +1,504 @@ -
-

Review to Ready

-
- - -
-
- - -
-
- - -
- - {#if error}

{error}

{/if} +{#if loading} +
+

Review to Ready

+

Loading slab…

+
+{:else if loadError} +
+

Review to Ready

+ + +
+{:else} +
+

Review to Ready

- {#if result} -

- {result.ready ? 'READY for publish' : `Not ready — missing: ${result.missing.join(', ')}`} -

-
Title: {result.title ?? '(none)'}
-
Short description: {result.short_description ?? '(none)'}
-
- Price: {result.price != null ? `$${result.price.toFixed(2)}` : '(empty — enter required)'} - {#if result.price_suggestion != null && result.price_suggestion !== result.price} - (suggested ${result.price_suggestion.toFixed(2)}) +
+
Length
{fmt1(capture?.lengthIn ?? slab?.length_in ?? 0)}″
+
Thickness
{fmt1(capture?.thicknessIn ?? slab?.thickness_in ?? 0)}″
+
Sqft / Bdft
{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}
+
+ + {#if settings?.inference_enabled} +
+ + {#if aiNote}

{aiNote}

{/if} +
+ {/if} + + +
+ + +
+ + + (woodCatIds = toggleMulti(woodCatIds, id))} + /> + + +
+ + +
+ + + (figureIds = toggleMulti(figureIds, id))} + /> + + +
+ + +
+ + +
+ + (figTagIds = toggleMulti(figTagIds, id))} + /> +
+ + +
+ + (featTagIds = toggleMulti(featTagIds, id))} + /> +
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + + {#if canGenerateContent} +
+ +
+ {/if} + {#if genError}{/if} +
+ + +
+ + +
+
+ + + {#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)} +

SKU must be 3–24 alphanumerics or hyphens.

{/if}
- {/if} -
+ + + {#if reviewBusy} +
Checking readiness…
+ {:else if review} +
+ {ready() + ? 'Ready to publish' + : `Not ready — ${review.missing.length} field${review.missing.length === 1 ? '' : 's'} remaining`} +
+ {#if !ready()} +

Missing: {review.missing.join(', ')}

+ {/if} + {/if} + {#if reviewError}{/if} + +
+ + +
+
+{/if} diff --git a/frontend/tests/e2e/helpers/draft.ts b/frontend/tests/e2e/helpers/draft.ts new file mode 100644 index 0000000..a266a61 --- /dev/null +++ b/frontend/tests/e2e/helpers/draft.ts @@ -0,0 +1,102 @@ +// e2e helper: seed an in-progress device draft (stage='measure', with results) so +// the Resume path restores straight to the Photos-ready Measure step — the +// deterministic driver for the create→review→publish flow (no live camera/canvas +// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval', +// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` + +// `continueDraft` + `continueToReview` consume it unmodified. +// +// `source.blob` MUST be a decodable image: restoreFromDraft re-derives the mask +// in a Worker that decodes the blob; a failed decode falls back to `mask` (no +// results → the Continue button never renders). A 1×1 white PNG decodes fine and +// yields slabPx:0 (an empty mask) → rederiveMask resolves → stage reaches measure. +import type { Page } from '@playwright/test'; + +const KEY = 'slabuploader-draft'; + +export interface SeedMeasure { + sku: string; + lengthIn: number; + thicknessIn: number; + sqft: number; + bdft: number; + widthIn: number; +} + +const toEighths = (inches: number) => Math.round(inches * 8); + +export async function seedMeasureDraft( + page: Page, + d: SeedMeasure, +): Promise { + const widthEighths = toEighths(d.widthIn); + // Build the decodable source PNG in the page via canvas (a fixed base64 1×1 + // gray+alpha PNG trips Chromium's software decoder in headless), then write + // the draft with that blob so restoreFromDraft's worker decode succeeds. + await page.evaluate( + (payload) => + new Promise((resolve, reject) => { + const c = document.createElement('canvas'); + c.width = 1; + c.height = 1; + const ctx = c.getContext('2d')!; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, 1, 1); + c.toBlob((png) => { + if (!png) { reject(new Error('canvas toBlob returned null')); return; } + const draft = { + version: 1, + stage: 'measure', + batch: [], + calibrationIndex: null, + source: { widthPx: 1, heightPx: 1, blob: png }, + maskParams: { sheet: 'green', sensitivity: 50 }, + mask: { + slabPx: 0, + bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, + slabBounds: { x: 0, y: 0, w: 0, h: 0 }, + }, + axisBbox: null, + lengthEighths: payload.lengthEighths, + thicknessEighths: payload.thicknessEighths, + results: { + lengthEighths: payload.lengthEighths, + thicknessEighths: payload.thicknessEighths, + scale: 8, + widths: { + min: payload.widthEighths, + max: payload.widthEighths, + avg: payload.widthEighths, + every6In: [], + }, + sqft: payload.sqft, + bdft: payload.bdft, + sourceBelowFloor: false, + }, + inventoryPng: png, + overlayPng: null, + batchApply: null, + }; + const req = indexedDB.open('keyval-store', 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains('keyval')) db.createObjectStore('keyval'); + }; + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction('keyval', 'readwrite'); + tx.objectStore('keyval').put(draft, 'slabuploader-draft'); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }; + req.onerror = () => reject(req.error); + }, 'image/png'); + }), + { + lengthEighths: toEighths(d.lengthIn), + thicknessEighths: toEighths(d.thicknessIn), + widthEighths, + sqft: d.sqft, + bdft: d.bdft, + }, + ); +} diff --git a/frontend/tests/e2e/m3-create.spec.ts b/frontend/tests/e2e/m3-create.spec.ts index 0f6302a..b7205b0 100644 --- a/frontend/tests/e2e/m3-create.spec.ts +++ b/frontend/tests/e2e/m3-create.spec.ts @@ -1,55 +1,443 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page, type Route } from '@playwright/test'; +import { seedMeasureDraft } from './helpers/draft'; /** - * M3 Create E2E (QUALITY-50): the Create flow screens render and the publish - * control is reachable. Runs against the vite preview (webServer in - * playwright.config). The backend is the local API; Woo is mocked at the - * host level for the integration path (see backend/tests/test_integration_woo_mock.py). - * This spec verifies the UI surface, not a live store (that is the UAT walk). + * M3 Review→Publish E2E (issue #77, QUALITY-50/105): drives the one-column + * create→review→publish flow through the UI against a mocked API + * (`page.route` on the whole /api/v1 namespace; no live backend under e2e). + * + * The capture→measure path needs real camera/canvas/Vision workers, so the + * deterministic driver is the Resume path: seed an in-progress measure draft + * (helpers/draft.ts) → Resume → Continue → mocked createSlab/uploadPhoto → + * Review → Publish. All ids are synthetic fixtures (QUALITY-105 / METADATA-5). */ -test.describe('M3 Create flow', () => { - test('renders all M3 dev screens', async ({ page }) => { - await page.goto('/'); - // The M3 dev toggles are always present in the toolbar. - const toggles = [ - 'Settings (dev)', - 'Slabs (dev)', - 'Photos (dev)', - 'Review (dev)', - 'Publish (dev)', - 'Generate (dev)', - ]; - for (const name of toggles) { - await expect(page.getByRole('button', { name })).toBeVisible(); +// --- Fixture taxonomy: fixture ids only, parented like the live Woo tree --- +const SLAB_ID = '5f3c9a2e-0000-4000-8000-0000000000aa'; +const SPECIES_GROUper_ID = 10; // "Species" grouper (parent_id null) +const SPECIES_ID = 11; // Cedar (parent = species grouper) +const WCAT_ID = 20; // "Wood category" grouper (parent null) +const LIVEEDGE_ID = 21; // Live edge (parent = wcat) +const BOWL_ID = 22; // Bowl blanks (parent = wcat) +const FIG_TAG_ID = 101; // fig-burl product tag +const FEAT_TAG_ID = 201; // feat-mineral-streaks product tag + +const TAXONOMY = { + categories: [ + { id: SPECIES_GROUper_ID, name: 'Species', slug: 'species', parent_id: null }, + { id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: SPECIES_GROUper_ID }, + { id: 12, name: 'Maple', slug: 'maple', parent_id: SPECIES_GROUper_ID }, + { id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null }, + { id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID }, + { id: BOWL_ID, name: 'Bowl blanks', slug: 'bowlblanks', parent_id: WCAT_ID }, + { id: 99, name: 'Chairs', slug: 'chairs', parent_id: null }, + ], + attributes: [ + { + id: 30, name: 'Edge Type', slug: 'edge_type', + terms: [ + { id: 31, name: 'Live edge', slug: 'live-edge' }, + { id: 32, name: 'Square edge', slug: 'square-edge' }, + ], + }, + { + id: 33, name: 'Figure', slug: 'figure', + terms: [ + { id: 34, name: 'Burl', slug: 'burl' }, + { id: 35, name: 'Birdseye', slug: 'birdseye' }, + ], + }, + { + id: 36, name: 'Grade', slug: 'grade', + terms: [{ id: 37, name: 'A grade', slug: 'a-grade' }], + }, + { + id: 38, name: 'Thickness', slug: 'thickness', + terms: [{ id: 39, name: '2-4 in', slug: '2-4' }], + }, + { + id: 40, name: 'Moisture', slug: 'moisture', + terms: [{ id: 41, name: 'Kiln dried', slug: 'kiln-dried' }], + }, + ], + tags: [ + { id: 100, name: 'fig', slug: 'fig' }, + { id: FIG_TAG_ID, name: 'fig-burl', slug: 'fig-burl' }, + { id: 102, name: 'fig-birdseye', slug: 'fig-birdseye' }, + { id: FEAT_TAG_ID, name: 'feat-mineral-streaks', slug: 'feat-mineral-streaks' }, + { id: 202, name: 'feat-knots', slug: 'feat-knots' }, + ], + taxonomy_anchor: 'fixture', + taxonomy_last_sync_at: '2026-01-01T00:00:00', +}; + +function settingsView(over: Record = {}) { + return { + woo_base_url: 'https://example.invalid', + woo_wp_username: 'wpuser', + 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', + ...over, + }; +} + +function baseSlab(): Record { + 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 ApiHooks { + settings?: Record; + onPublish?: (route: Route) => Promise; + onCall1?: (route: Route) => Promise; + onCall2?: (route: Route) => Promise; + /** Counts inference endpoints hit (default: record to an array via closure). */ + calls?: { call1: number; call2: number }; +} + +/** Mock the full API for the flow. Slab PUTs are folded into a live row; the + * review endpoint recomputes `ready` from the folded row and echoes the primary. */ +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 merge = (payload: Record) => { + 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', + 'title', 'short_description', 'description', 'content_source', 'price', 'price_source', + ]) { + if (k in payload) (slab as Record)[k] = payload[k]; } - }); + if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1; + }; - test('Review screen loads the ready-state control', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: 'Review (dev)' }).click(); - await expect(page.getByRole('heading', { name: 'Review to Ready' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Review', exact: true })).toBeVisible(); - }); + const reviewResult = (payload: Record) => { + 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; + 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.price == null) missing.push('price'); + 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: primary, + }; + }; - test('Publish screen loads the publish control', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: 'Publish (dev)' }).click(); - await expect(page.getByRole('heading', { name: 'Publish to WooCommerce' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Publish', exact: true })).toBeVisible(); - }); + 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; } catch { return {}; } + })(); - test('Generate control is visible only when enabled + reachable', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: 'Generate (dev)' }).click(); - await expect(page.getByRole('heading', { name: 'Generate text' })).toBeVisible(); - // With no inference configured, the control is disabled with a plain note. - const note = page.locator('.note'); - const state = page.locator('.state'); - await expect(state).toContainText(/endpoint: (reachable|unreachable)/); - const text = (await state.textContent()) ?? ''; - if (text.includes('unreachable')) { - await expect(note).toBeVisible(); + if (path === '/api/v1/settings' && method === 'GET') { + await json(route, settingsView(hooks.settings)); + return; } + if (path === '/api/v1/admin/taxonomy' && method === 'GET') { + await json(route, TAXONOMY); + return; + } + if (path === '/api/v1/slabs' && method === 'POST') { + // The client generates the slab id (crypto.randomUUID, APP-20); adopt it so + // the subsequent photo/review/publish paths (keyed by this id) resolve. + slab.id = String(payload.id ?? SLAB_ID); + merge(payload); // created with capture math; client fields empty + await json(route, slab, 201); + return; + } + if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') { + merge(payload); + await json(route, slab); + return; + } + if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') { + await json(route, slab); + return; + } + if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') { + // Photo uploads are multipart; `payload` is {} — the client's photo_id is + // in the form body, not JSON, so echo a stable synthetic id. + await json(route, { + id: 'photo-1', + slab_id: slab.id, + seq: 1, + is_calibration: 1, + original_retained: false, + processed_retained: true, + }, 201); + return; + } + if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') { + await json(route, reviewResult(payload)); + return; + } + if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') { + if (hooks.onPublish) { await hooks.onPublish(route); return; } + await json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true }); + return; + } + if (path === `/api/v1/slabs/${slab.id}/inference/call1` && method === 'POST') { + calls.call1 += 1; + if (hooks.onCall1) { await hooks.onCall1(route); return; } + await json(route, { ok: false, suggestions: {} }); + return; + } + if (path === `/api/v1/slabs/${slab.id}/inference/call2` && method === 'POST') { + calls.call2 += 1; + if (hooks.onCall2) { await hooks.onCall2(route); return; } + await json(route, { ok: false, description: '' }); + return; + } + await json(route, { error: { code: 'not_found', message: path } }, 404); + }); + return { calls, slab }; +} + +// Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and +// drive Resume → Continue to land in Review. +async function reachReview(page: Page) { + // Phase 1: boot with ?noload so the app mounts (real origin for IndexedDB) + // WITHOUT probing for a draft. Then seed the measure draft into IDB. + await page.goto('/?noload'); + await seedMeasureDraft(page, { + sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11, + }); + // Phase 2: reload WITHOUT ?noload so onMount probes IDB and shows Resume. + await page.goto('/'); + // Resume prompt → Continue (restores measure). The mask re-derivation runs in + // a worker; wait for the Photos-ready Continue control. + 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 }); + // Continue → mocked createSlab + uploadPhoto → Review screen. + await page.getByTestId('continue-to-review').click(); + await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 }); +} + +/** Fill every ready-gating field to make the slab publishable. */ +async function completeForReady(page: Page) { + await page.getByTestId('species').selectOption(String(SPECIES_ID)); + // Wood categories: open, select Live edge, Done. + 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 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). + await page.getByTestId('sku').press('Tab'); + await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 }); + } + +test.describe('M3 Review→Publish flow (#77)', () => { + test('golden path: capture→review→ready→publish→done', async ({ page }) => { + await mockApi(page); + await reachReview(page); + // Not ready until fields are set. + await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/); + await completeForReady(page); + // Continue to Publish is enabled and hands off. + await expect(page.getByTestId('continue-publish')).toBeEnabled(); + await page.getByTestId('continue-publish').click(); + await expect(page.getByTestId('publish-screen')).toBeVisible(); + // Publish → success → Done panel (draft in UAT store). + await page.getByTestId('publish-btn').click(); + await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/Woo product #4242/)).toBeVisible(); + 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 } }); + 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); + }); + + 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, + }, + }); + }, + 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 }); + }); + + 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]); + }); + + 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); }); });