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 { return { id: SLAB_ID, sku: '', status: 'draft', price: null, price_source: 'recommendation', length_in: 48, thickness_in: 2, sqft: 6.25, bdft: 12.5, width_min_in: 10, width_max_in: 12, width_avg_in: 11, species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null, thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [], title: null, short_description: null, description: null, content_source: 'manual', metadata_locked: false, species_confidence: null, figure_confidence: null, grade_confidence: null, inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; } async function json(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); } interface PersistRecord { seq: number; role: string; is_calibration: string; } async function mockApi(page: Page) { const slab = baseSlab(); const photoStore = new Map(); 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) => { if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false; for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'metadata_locked', 'edge_type_term_id']) { if (k in payload) (slab as Record)[k] = payload[k]; } if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1; }; const reviewResult = (payload: Record) => { if (typeof payload.price === 'number') slab.price = payload.price; if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku; const missing: string[] = []; if (!slab.sku) missing.push('sku'); if (!slab.species_id) missing.push('species'); if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories'); if (!slab.edge_type_term_id) missing.push('edge type'); if (!slab.title) missing.push('title'); if (!slab.short_description) missing.push('short description'); if (slab.price == null) missing.push('price'); // Deterministic copy from the server on lock (REVIEW-15) — set before ready. if (!slab.title) slab.title = 'Cedar · 48″ Live Edge'; if (!slab.short_description) slab.short_description = 'Hand-cut slab, ready to ship.'; return { ready: missing.length === 0, missing, title: (slab.title as string) ?? null, short_description: (slab.short_description as string) ?? null, price: (slab.price as number) ?? null, price_suggestion: 120, primary_wood_category_id: null, locked: slab.metadata_locked === true, }; }; await page.route('**/api/v1/**', async (route) => { const req = route.request(); const url = new URL(req.url()); const path = url.pathname; const method = req.method(); const payload = (() => { try { return req.postDataJSON() as Record; } catch { return {}; } })(); if (path === '/api/v1/settings' && method === 'GET') { return json(route, { woo_base_url: 'https://example.invalid', woo_wp_username: 'wp', woo_app_password: { configured: true }, inference_base_url: { configured: false }, inference_api_key: { configured: false }, inference_model: null, inference_enabled: false, content_llm_enabled: false, brand_voice: null, geo_context: null, woo_create_status: 'draft', taxonomy_last_sync_at: '2026-01-01T00:00:00', taxonomy_anchor: 'fixture' }); } if (path === '/api/v1/admin/taxonomy' && method === 'GET') return json(route, TAXONOMY); if (path === '/api/v1/slabs' && method === 'POST') { slab.id = String(payload.id ?? SLAB_ID); merge(payload); return json(route, slab, 201); } if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') { if (merge(payload) === false) return json(route, { error: { code: 'revision_conflict', message: 'rev', detail: {} } }, 409); return json(route, slab); } if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') return json(route, slab); // --- Photos (the strip) --- if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') { return json(route, [...photoStore.values()].sort((a, b) => a.seq - b.seq)); } if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') { // Multipart; pull the photo_id / seq / role form fields out of the body. const raw = req.postData() ?? ''; const field = (name: string): string => { const re = new RegExp(`name="${name}"\\r\\n\\r\\n([\\s\\S]*?)(?=$|--)`); const m = raw.match(re); return m ? m[1].trim() : ''; }; const photoId = field('photo_id'); const seq = Number(field('seq')); const role = field('role') || 'batch'; // Capture-time uploads (photo_id not in the fixture store, e.g. "photo-1") // are accepted but not added to the lineup — the strip is driven by the // stored rows only. Review-strip re-uploads reference a known fixture id. if (!photoStore.has(photoId)) { return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201); } persistCalls.push({ photoId, seq, role }); const existing = photoStore.get(photoId); if (existing) { existing.seq = seq; existing.role = role as typeof existing.role; } return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201); } const delMatch = path.match(new RegExp(`^/api/v1/slabs/${slab.id}/photos/([^/]+)$`)); if (delMatch && method === 'DELETE') { const id = delMatch[1]; if (photoStore.delete(id)) { deletes.push(id); return route.fulfill({ status: 204, body: '' }); } return json(route, { error: { code: 'not_found', message: 'Photo not found', detail: null } }, 404); } if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') return json(route, reviewResult(payload)); if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') { return json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true }); } return json(route, { error: { code: 'not_found', message: path } }, 404); }); return { persistCalls, deletes, photoStore }; } async function reachReview(page: Page) { await page.goto('/?noload'); await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11 }); await page.goto('/'); await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 }); await page.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 }); await page.getByTestId('continue-to-review').click(); await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 }); } async function completeForReady(page: Page) { await page.getByTestId('species').selectOption(String(SPECIES_ID)); await page.getByTestId('wood-cat-btn').click(); await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click(); await page.getByTestId('wood-cat-done').click(); await page.getByTestId('edge-type').selectOption('31'); await page.getByTestId('price').fill('120'); await expect(page.getByTestId('lock-metadata')).toBeEnabled(); await page.getByTestId('lock-metadata').click(); await expect(page.getByTestId('title')).not.toHaveValue(''); await page.getByTestId('sku').fill('SLAB-0001'); await page.getByTestId('sku').press('Tab'); } test.describe('M3 Review photo strip (#90)', () => { test('renders stored photos; leftmost kept = primary; tap keep/remove; drag reorder; minimum; persist', async ({ page }) => { const mock = await mockApi(page); await reachReview(page); // Strip renders the 3 stored photos in stored seq order. const cards = page.getByTestId('photo-card'); await expect(cards).toHaveCount(3); // Leftmost (cal, seq 1) is the primary. await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true'); await expect(page.getByText('PRIMARY', { exact: true })).toHaveCount(1); // Bug #1 regression: the primary photo's 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']); }); });