Files
SlabUploader/frontend/tests/e2e/review-photo-strip.spec.ts
T
Ty e1b928ccbd fix(review): #90 clearer mandatory-photo indicator (padlock + REQUIRED tag)
The mandatory clean-cal and dimension-overlay photos now show a padlock glyph
in their keep box and an amber REQUIRED tag at the top of the card, so the
owner can see at a glance which photos cannot be removed (vs. optional batch
photos, which keep a plain checkmark).
2026-09-21 02:18:01 -07:00

306 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',
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<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', 'metadata_locked', '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, 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<string, unknown>; } catch { return {}; } })();
if (path === '/api/v1/settings' && method === 'GET') {
return json(route, { woo_base_url: 'https://example.invalid', woo_wp_username: 'wp', woo_app_password: { configured: true }, inference_base_url: { configured: false }, inference_api_key: { configured: false }, inference_model: null, inference_enabled: false, content_llm_enabled: false, brand_voice: null, geo_context: null, woo_create_status: 'draft', taxonomy_last_sync_at: '2026-01-01T00:00:00', taxonomy_anchor: 'fixture' });
}
if (path === '/api/v1/admin/taxonomy' && method === 'GET') return json(route, TAXONOMY);
if (path === '/api/v1/slabs' && method === 'POST') {
slab.id = String(payload.id ?? SLAB_ID);
merge(payload);
return json(route, slab, 201);
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
if (merge(payload) === false) return json(route, { error: { code: 'revision_conflict', message: 'rev', detail: {} } }, 409);
return json(route, slab);
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') return json(route, slab);
// --- Photos (the strip) ---
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
return json(route, [...photoStore.values()].sort((a, b) => a.seq - b.seq));
}
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
// Multipart; pull the photo_id / seq / role form fields out of the body.
const raw = req.postData() ?? '';
const field = (name: string): string => {
const re = new RegExp(`name="${name}"\\r\\n\\r\\n([\\s\\S]*?)(?=$|--)`);
const m = raw.match(re);
return m ? m[1].trim() : '';
};
const photoId = field('photo_id');
const seq = Number(field('seq'));
const role = field('role') || 'batch';
// Capture-time uploads (photo_id not in the fixture store, e.g. "photo-1")
// are accepted but not added to the lineup — the strip is driven by the
// stored rows only. Review-strip re-uploads reference a known fixture id.
if (!photoStore.has(photoId)) {
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
}
persistCalls.push({ photoId, seq, role });
const existing = photoStore.get(photoId);
if (existing) {
existing.seq = seq;
existing.role = role as typeof existing.role;
}
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
}
const delMatch = path.match(new RegExp(`^/api/v1/slabs/${slab.id}/photos/([^/]+)$`));
if (delMatch && method === 'DELETE') {
const id = delMatch[1];
if (photoStore.delete(id)) { deletes.push(id); return route.fulfill({ status: 204, body: '' }); }
return json(route, { error: { code: 'not_found', message: 'Photo not found', detail: null } }, 404);
}
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') return json(route, reviewResult(payload));
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
return json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
}
return json(route, { error: { code: 'not_found', message: path } }, 404);
});
return { persistCalls, deletes, photoStore };
}
async function reachReview(page: Page) {
await page.goto('/?noload');
await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11 });
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 });
await page.getByTestId('continue-to-review').click();
await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 });
}
async function completeForReady(page: Page) {
await page.getByTestId('species').selectOption(String(SPECIES_ID));
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
await expect(page.getByTestId('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 <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']);
});
});