feat(review): #90 wood primary category + derived thickness band
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user