feat(review): #90 wood primary category + derived thickness band

This commit is contained in:
Ty
2026-09-21 02:41:20 -07:00
parent e1b928ccbd
commit 0329052d37
13 changed files with 719 additions and 42 deletions
+150 -4
View File
@@ -53,7 +53,16 @@ const TAXONOMY = {
},
{
id: 38, name: 'Thickness', slug: 'thickness',
terms: [{ id: 39, name: '2-4 in', slug: '2-4' }],
// Real live band names: the Review screen only *displays* the derived band.
terms: [
{ id: 347, name: 'Under 1/2"', slug: 'xthin' },
{ id: 348, name: '1/2" - 3/4"', slug: '2-3qtr' },
{ id: 349, name: '3/4" - 1"', slug: '3-4qtr' },
{ id: 350, name: '1"- 1 1/2"', slug: '4-6qtr' },
{ id: 351, name: '1 1/2" - 2"', slug: '6-8qtr' },
{ id: 352, name: '2" - 3"', slug: '8-12qtr' },
{ id: 353, name: 'Over 3"', slug: '12qtr' },
],
},
{
id: 40, name: 'Moisture', slug: 'moisture',
@@ -109,7 +118,7 @@ function baseSlab(): Record<string, unknown> {
edge_type_term_id: null,
figure_term_ids: [],
grade_term_id: null,
thickness_term_id: null,
thickness_term_id: 352,
moisture_term_id: null,
fig_tag_ids: [],
feat_tag_ids: [],
@@ -149,6 +158,7 @@ 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 reviewPrimaries: number[] = [];
const merge = (payload: Record<string, unknown>) => {
// Respect optimistic concurrency: reject stale client_rev (like the real backend).
@@ -263,7 +273,11 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
return;
}
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') {
await json(route, reviewResult(payload));
const result = reviewResult(payload);
if (typeof result.primary_wood_category_id === 'number') {
reviewPrimaries.push(result.primary_wood_category_id);
}
await json(route, result);
return;
}
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
@@ -285,7 +299,7 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
}
await json(route, { error: { code: 'not_found', message: path } }, 404);
});
return { calls, slab };
return { calls, slab, reviewPrimaries };
}
// Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and
@@ -354,4 +368,136 @@ test.describe('M3 Review→Publish flow (#77)', () => {
await expect(page.getByText(/Woo product #4242/)).toBeVisible();
await expect(page.getByTestId('new-slab')).toBeVisible();
});
test('wood categories: closed summary shows names; primary radio among selected (#90, REVIEW-15)', async ({ page }) => {
const { reviewPrimaries } = await mockApi(page);
await reachReview(page);
// Open wood MultiSelect, pick Live edge + Bowl blanks, Done.
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
await page.getByTestId('wood-cat-done').click();
// Closed summary shows selected names (not only a count).
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Live edge/i);
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Bowl blanks/i);
// Primary set appears with both selected; default primary = first selected.
const primarySet = page.getByTestId('primary-wood-set');
await expect(primarySet).toBeVisible();
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'true');
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'false');
// Owner switches primary to Bowl blanks; review payload carries it.
await page.getByTestId(`primary-wood-${BOWL_ID}`).click();
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'true');
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'false');
await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID);
});
test('thickness band is read-only and shows the derived band (#90, METADATA-15)', async ({ page }) => {
await mockApi(page);
await reachReview(page);
// The band is derived server-side, so Review displays it as data.
const band = page.getByTestId('thickness-band');
await expect(band).toBeVisible();
await expect(band).toContainText('2" - 3"'); // 2 in rounds into the live band
// There is no owner control for it anywhere on Review — not a select, not an input.
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
expect(await band.evaluate((el) => el.tagName)).toBe('P');
});
/**
* #90 served-surface render check: the changed Review surface (wood-category
* MultiSelect + primary-radio set + read-only thickness band) is rendered at
* desktop/tablet/mobile widths, measured, and captured to
* tests/e2e/artifacts/ so layout and interaction can be inspected as evidence.
*/
test('Review surface renders at 1440/768/375 with no overflow (#90)', async ({ page }) => {
const widths = [
{ w: 1440, h: 900, name: 'desktop' },
{ w: 768, h: 1024, name: 'tablet' },
{ w: 375, h: 812, name: 'mobile' },
];
const report: string[] = [];
for (const vp of widths) {
await page.setViewportSize({ width: vp.w, height: vp.h });
await mockApi(page);
await reachReview(page);
// Put the surface in its real state: two wood categories selected, primary set.
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
await page.getByTestId('wood-cat-done').click();
await expect(page.getByTestId('primary-wood-set')).toBeVisible();
const metrics = await page.evaluate(() => {
const de = document.documentElement;
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
const box = (sel: string) => {
const el = q(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
w: Math.round(r.width), h: Math.round(r.height),
fontSize: cs.fontSize,
overflowX: el.scrollWidth > el.clientWidth + 1,
text: (el.textContent || '').trim().slice(0, 80),
};
};
const primaryRow = q('[data-testid^="primary-wood-"]');
const primaryRowBox = primaryRow ? primaryRow.getBoundingClientRect() : null;
// Anything whose box pokes outside the viewport, with enough identity to fix it.
const offenders: { tag: string; cls: string; id: string; left: number; right: number }[] = [];
document.querySelectorAll('body *').forEach((el) => {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return;
if (r.right > de.clientWidth + 1 || r.left < -1) {
offenders.push({
tag: el.tagName.toLowerCase(),
cls: String(el.className || '').slice(0, 48),
id: el.id,
left: Math.round(r.left),
right: Math.round(r.right),
});
}
});
return {
docScrollW: de.scrollWidth,
docClientW: de.clientWidth,
pageHasHScroll: de.scrollWidth > de.clientWidth + 1,
offenders: offenders.slice(0, 8),
summary: box('[data-testid="wood-cat-summary"]'),
count: box('[data-testid="wood-cat-count"]'),
band: box('[data-testid="thickness-band"]'),
primarySet: box('[data-testid="primary-wood-set"]'),
primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null,
lockBtn: box('[data-testid="lock-metadata"]'),
continueBtn: box('[data-testid="continue-publish"]'),
};
});
const shot = `tests/e2e/artifacts/review-90-${vp.name}-${vp.w}.png`;
await page.screenshot({ path: shot, fullPage: true });
report.push(`${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
// Print before asserting so the measurement survives a failing gate.
console.log(`RENDER ${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
// Layout gates: nothing spills sideways, the closed summary names both picks
// (CSS ellipsis may shorten the paint, but the text must be there), and the
// primary rows stay a comfortable tap target on mobile.
expect(metrics.pageHasHScroll, `${vp.name} horizontal overflow: ${metrics.docScrollW} > ${metrics.docClientW}`).toBe(false);
expect(metrics.summary?.text).toMatch(/Live edge/i);
expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
expect(metrics.count?.text).toContain('2 selected');
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
expect(metrics.band?.text).toContain('2" - 3"');
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
}
console.log('RENDER REPORT\n' + report.join('\n'));
});
});