-
Publish to WooCommerce
-
-
-
-
-
+{#if loading}
+
Publish to WooCommerce
Loading…
+{:else if loadError}
+
Publish to WooCommerce
{loadError}
+
+
+{: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.
+
+
{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}
+
+
-
Review to Ready
-
-
-
-
-
-
-
-
-
-
-
-
-
- {#if error}
{error}
{/if}
+{#if loading}
+
+ Review to Ready
+ Loading slab…
+
+{:else if loadError}
+
+ Review to Ready
+ {loadError}
+
+
+{: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}
{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}{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);
});
});