610b0883fe
Species is chosen on the merged Measure screen; the Review select was a duplicate. Keep species_id on the slab from createSlab (Capture→draft→Woo) and gate Create title from the hydrated value without offering edit.
310 lines
16 KiB
TypeScript
310 lines
16 KiB
TypeScript
import { test, expect, type Page, type Route } from '@playwright/test';
|
|
import { seedMeasureDraft } from './helpers/draft';
|
|
|
|
/**
|
|
* M3 Review photo strip E2E (issue #90, REVIEW-5 / PUBLISH-45). Drives the
|
|
* capture→review flow (mocked API, no live backend) and exercises the photo
|
|
* strip: render from stored photos, keep/remove (whole-card tap), drag-reorder,
|
|
* leftmost-kept = primary, the clean+overlay minimum, and persistLineup on
|
|
* "Continue to Publish". All ids are synthetic fixtures (QUALITY-105).
|
|
*/
|
|
|
|
const SLAB_ID = '90e00000-0000-4000-8000-000000000090';
|
|
const SPECIES_ID = 11;
|
|
const WCAT_ID = 20;
|
|
const LIVEEDGE_ID = 21;
|
|
|
|
const TAXONOMY = {
|
|
categories: [
|
|
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
|
|
{ id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: 10 },
|
|
{ id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null },
|
|
{ id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID },
|
|
],
|
|
attributes: [
|
|
{ id: 30, name: 'Edge Type', slug: 'edge_type', terms: [{ id: 31, name: 'Live edge', slug: 'live-edge' }] },
|
|
],
|
|
tags: [],
|
|
taxonomy_anchor: 'fixture',
|
|
taxonomy_last_sync_at: '2026-01-01T00:00:00',
|
|
};
|
|
|
|
// The stored lineup: clean cal (seq 1), dimension-overlay (seq 2), batch (seq 3).
|
|
const PHOTO_ROWS = [
|
|
{ id: 'ph-cal', seq: 1, is_calibration: 1, role: 'calibration', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
|
|
{ id: 'ph-ov', seq: 2, is_calibration: 0, role: 'overlay', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
|
|
{ id: 'ph-b1', seq: 3, is_calibration: 0, role: 'batch', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
|
|
];
|
|
|
|
function baseSlab(): Record<string, unknown> {
|
|
return {
|
|
id: SLAB_ID, sku: '', status: 'draft', price: null, price_source: 'recommendation',
|
|
length_in: 48, thickness_in: 2, sqft: 6.25, bdft: 12.5, width_min_in: 10, width_max_in: 12, width_avg_in: 11,
|
|
species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null,
|
|
thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
|
|
title: null, short_description: null, description: null, content_source: 'manual',
|
|
species_confidence: null, figure_confidence: null, grade_confidence: null,
|
|
inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0,
|
|
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
|
};
|
|
}
|
|
|
|
async function json(route: Route, body: unknown, status = 200) {
|
|
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
|
|
}
|
|
|
|
interface PersistRecord {
|
|
seq: number; role: string; is_calibration: string;
|
|
}
|
|
|
|
async function mockApi(page: Page) {
|
|
const slab = baseSlab();
|
|
const photoStore = new Map<string, typeof PHOTO_ROWS[number]>();
|
|
for (const r of PHOTO_ROWS) photoStore.set(r.id, { ...r });
|
|
const persistCalls: { photoId: string; seq: number; role: string }[] = [];
|
|
const deletes: string[] = [];
|
|
|
|
const merge = (payload: Record<string, unknown>) => {
|
|
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false;
|
|
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'edge_type_term_id']) {
|
|
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
|
|
}
|
|
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
|
|
};
|
|
|
|
const reviewResult = (payload: Record<string, unknown>) => {
|
|
if (typeof payload.price === 'number') slab.price = payload.price;
|
|
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
|
|
const missing: string[] = [];
|
|
if (!slab.sku) missing.push('sku');
|
|
if (!slab.species_id) missing.push('species');
|
|
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
|
|
if (!slab.edge_type_term_id) missing.push('edge type');
|
|
if (!slab.title) missing.push('title');
|
|
if (!slab.short_description) missing.push('short description');
|
|
if (slab.price == null) missing.push('price');
|
|
// Deterministic copy from the server on lock (REVIEW-15) — set before ready.
|
|
if (!slab.title) slab.title = 'Cedar · 48″ Live Edge';
|
|
if (!slab.short_description) slab.short_description = 'Hand-cut slab, ready to ship.';
|
|
return {
|
|
ready: missing.length === 0, missing,
|
|
title: (slab.title as string) ?? null,
|
|
short_description: (slab.short_description as string) ?? null,
|
|
price: (slab.price as number) ?? null, price_suggestion: 120,
|
|
primary_wood_category_id: null,
|
|
};
|
|
};
|
|
|
|
await page.route('**/api/v1/**', async (route) => {
|
|
const req = route.request();
|
|
const url = new URL(req.url());
|
|
const path = url.pathname;
|
|
const method = req.method();
|
|
const payload = (() => { try { return req.postDataJSON() as Record<string, unknown>; } catch { return {}; } })();
|
|
|
|
if (path === '/api/v1/settings' && method === 'GET') {
|
|
return json(route, { woo_base_url: 'https://example.invalid', woo_wp_username: 'wp', woo_app_password: { configured: true }, inference_base_url: { configured: false }, inference_api_key: { configured: false }, inference_model: null, inference_enabled: false, content_llm_enabled: false, brand_voice: null, geo_context: null, woo_create_status: 'draft', taxonomy_last_sync_at: '2026-01-01T00:00:00', taxonomy_anchor: 'fixture' });
|
|
}
|
|
if (path === '/api/v1/admin/taxonomy' && method === 'GET') return json(route, TAXONOMY);
|
|
if (path === '/api/v1/slabs' && method === 'POST') {
|
|
slab.id = String(payload.id ?? SLAB_ID);
|
|
merge(payload);
|
|
return json(route, slab, 201);
|
|
}
|
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
|
|
if (merge(payload) === false) return json(route, { error: { code: 'revision_conflict', message: 'rev', detail: {} } }, 409);
|
|
return json(route, slab);
|
|
}
|
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') return json(route, slab);
|
|
|
|
// --- Photos (the strip) ---
|
|
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
|
|
return json(route, [...photoStore.values()].sort((a, b) => a.seq - b.seq));
|
|
}
|
|
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
|
|
// Multipart; pull the photo_id / seq / role form fields out of the body.
|
|
const raw = req.postData() ?? '';
|
|
const field = (name: string): string => {
|
|
const re = new RegExp(`name="${name}"\\r\\n\\r\\n([\\s\\S]*?)(?=$|--)`);
|
|
const m = raw.match(re);
|
|
return m ? m[1].trim() : '';
|
|
};
|
|
const photoId = field('photo_id');
|
|
const seq = Number(field('seq'));
|
|
const role = field('role') || 'batch';
|
|
// Capture-time uploads (photo_id not in the fixture store, e.g. "photo-1")
|
|
// are accepted but not added to the lineup — the strip is driven by the
|
|
// stored rows only. Review-strip re-uploads reference a known fixture id.
|
|
if (!photoStore.has(photoId)) {
|
|
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
|
|
}
|
|
persistCalls.push({ photoId, seq, role });
|
|
const existing = photoStore.get(photoId);
|
|
if (existing) {
|
|
existing.seq = seq;
|
|
existing.role = role as typeof existing.role;
|
|
}
|
|
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
|
|
}
|
|
const delMatch = path.match(new RegExp(`^/api/v1/slabs/${slab.id}/photos/([^/]+)$`));
|
|
if (delMatch && method === 'DELETE') {
|
|
const id = delMatch[1];
|
|
if (photoStore.delete(id)) { deletes.push(id); return route.fulfill({ status: 204, body: '' }); }
|
|
return json(route, { error: { code: 'not_found', message: 'Photo not found', detail: null } }, 404);
|
|
}
|
|
|
|
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') return json(route, reviewResult(payload));
|
|
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
|
|
return json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
|
|
}
|
|
return json(route, { error: { code: 'not_found', message: path } }, 404);
|
|
});
|
|
|
|
return { persistCalls, deletes, photoStore };
|
|
}
|
|
|
|
async function reachReview(page: Page) {
|
|
await page.goto('/?noload');
|
|
await seedMeasureDraft(page, {
|
|
sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11,
|
|
speciesId: SPECIES_ID,
|
|
});
|
|
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) {
|
|
// Species comes from Measure/createSlab (seeded on the draft); no Review picker.
|
|
await expect(page.getByTestId('species')).toHaveCount(0);
|
|
await page.getByTestId('wood-cat-btn').click();
|
|
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
|
await page.getByTestId('wood-cat-done').click();
|
|
await page.getByTestId('edge-type').selectOption('31');
|
|
await page.getByTestId('price').fill('120');
|
|
await expect(page.getByTestId('create-title')).toBeEnabled();
|
|
await page.getByTestId('create-title').click();
|
|
await expect(page.getByTestId('title')).not.toHaveValue('');
|
|
await page.getByTestId('sku').fill('SLAB-0001');
|
|
await page.getByTestId('sku').press('Tab');
|
|
}
|
|
|
|
test.describe('M3 Review photo strip (#90)', () => {
|
|
test('renders stored photos; leftmost kept = primary; tap keep/remove; drag reorder; minimum; persist', async ({ page }) => {
|
|
const mock = await mockApi(page);
|
|
await reachReview(page);
|
|
|
|
// Strip renders the 3 stored photos in stored seq order.
|
|
const cards = page.getByTestId('photo-card');
|
|
await expect(cards).toHaveCount(3);
|
|
// Leftmost (cal, seq 1) is the primary.
|
|
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
|
await expect(page.getByText('PRIMARY', { exact: true })).toHaveCount(1);
|
|
|
|
// Bug #1 regression: the primary photo's <img src> must be a valid URL
|
|
// (blob: for an in-memory capture blob), never empty or "object Blob".
|
|
const primaryImg = cards.nth(0).locator('img');
|
|
await expect(primaryImg).not.toHaveAttribute('src', /object Blob|^$/);
|
|
const src = await primaryImg.getAttribute('src');
|
|
expect(src && src.length > 4).toBeTruthy();
|
|
|
|
// Bug #2 regression: the card carries the gray checkerboard background
|
|
// (matching the Capture lineup), not a flat color.
|
|
const bgImage = await cards.nth(0).evaluate((el) => getComputedStyle(el).backgroundImage);
|
|
expect(bgImage).toContain('45deg');
|
|
|
|
// Mandatory photos: the clean calibration (seq 1) and the dimension-overlay
|
|
// copy (seq 2) are required — their keep checkbox is checked AND disabled,
|
|
// and a REQUIRED tag marks them.
|
|
await expect(cards.nth(0)).toHaveAttribute('data-required', 'true');
|
|
await expect(cards.nth(1)).toHaveAttribute('data-required', 'true');
|
|
await expect(cards.nth(2)).toHaveAttribute('data-required', '');
|
|
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(cards.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(cards.nth(0)).toHaveAttribute('aria-disabled', 'true');
|
|
await expect(cards.nth(1)).toHaveAttribute('aria-disabled', 'true');
|
|
await expect(cards.nth(2)).toHaveAttribute('aria-disabled', 'false');
|
|
// Two REQUIRED tags (cal + overlay), none on the batch photo.
|
|
await expect(page.getByText('REQUIRED', { exact: true })).toHaveCount(2);
|
|
|
|
// Tapping a mandatory photo is a no-op: it stays checked + primary (can't remove).
|
|
// The card is aria-disabled, so force the click to exercise the handler directly.
|
|
await cards.nth(0).click({ force: true });
|
|
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
|
// No minimum nudge — both required photos are kept by construction.
|
|
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
|
|
|
// Remove the OPTIONAL batch photo (index 2) → it un-keeps; required stay kept.
|
|
await cards.nth(2).click();
|
|
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
|
|
// Primary is still the leftmost required (cal, index 0).
|
|
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
|
// Re-add the batch photo.
|
|
await cards.nth(2).click();
|
|
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
|
|
|
// Drag the batch photo (index 2) to the front → it becomes the primary.
|
|
await cards.nth(2).scrollIntoViewIfNeeded();
|
|
await page.waitForTimeout(120);
|
|
const first = await cards.nth(0).boundingBox();
|
|
const third = await cards.nth(2).boundingBox();
|
|
if (first && third) {
|
|
const sx = third.x + third.width / 2, sy = third.y + third.height / 2;
|
|
const dx = first.x + first.width / 2, dy = first.y + first.height / 2;
|
|
await page.mouse.move(sx, sy);
|
|
await page.mouse.down();
|
|
// Several small steps so the HTML5 DnD picks up the drag.
|
|
for (let i = 1; i <= 14; i++) {
|
|
await page.mouse.move(sx + (dx - sx) * (i / 14), sy + (dy - sy) * (i / 14));
|
|
}
|
|
await page.waitForTimeout(60);
|
|
await page.mouse.up();
|
|
}
|
|
// After reorder the batch is leftmost = primary; seq badges renumber.
|
|
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
|
|
|
// Fill fields to ready, then Continue → persistLineup runs (re-seq kept, drop none).
|
|
await completeForReady(page);
|
|
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
|
|
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
|
await page.getByTestId('continue-publish').click();
|
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
|
|
|
// Persisted: 3 re-uploads (re-seq 1..3), no deletes (nothing was removed in final state).
|
|
expect(mock.persistCalls.length).toBe(3);
|
|
const seqs = mock.persistCalls.map((c) => c.seq).sort((a, b) => a - b);
|
|
expect(seqs).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
test('required photos guarantee the minimum; removing an OPTIONAL batch persists a DELETE', async ({ page }) => {
|
|
const mock = await mockApi(page);
|
|
await reachReview(page);
|
|
const cards = page.getByTestId('photo-card');
|
|
|
|
// The clean calibration + dimension-overlay are mandatory and always kept,
|
|
// so the submission minimum can never be broken — publish is never lineup-blocked.
|
|
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
|
|
|
// Removing an OPTIONAL batch photo is allowed and persists a DELETE.
|
|
await cards.nth(2).click();
|
|
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
|
|
// Still no nudge: both required photos remain kept.
|
|
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
|
|
|
// Complete the rest of the form; the lineup is valid (required photos kept).
|
|
await completeForReady(page);
|
|
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
|
|
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
|
await page.getByTestId('continue-publish').click();
|
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
|
|
|
// The removed batch photo persisted as a DELETE; the required photos re-uploaded.
|
|
expect(mock.deletes).toEqual(['ph-b1']);
|
|
});
|
|
});
|