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
+11
View File
@@ -13,6 +13,7 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response
from .db import get_connection
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
router = APIRouter(prefix="/api/v1/slabs")
@@ -133,6 +134,12 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
conn = get_connection()
if payload["sku"] is not None and _sku_taken(conn, payload["sku"]):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
# Resolve it from the measured thickness against the live synced Woo bands and
# overwrite whatever the client sent (the UI has no band control).
band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn))
if band is not None:
payload = {**payload, "thickness_term_id": band}
now = _now_iso()
data = _encode(payload)
allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
@@ -203,6 +210,10 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
live = [val] # single-id field: just show what was submitted
return _err("stale_field", f"{field} not in current taxonomy",
{"field": field, "submitted": val, "current_options": live}, status=422)
eff_thickness = payload.get("thickness_in", current["thickness_in"])
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
if band is not None:
payload = {**payload, "thickness_term_id": band}
data = _encode(payload)
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
if not cols:
+66
View File
@@ -33,6 +33,23 @@ def _seed_taxonomy(conn):
# tags
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
# live Woo attribute, because the derivation parses them.
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
for tid, name, slug in [
(347, 'Under 1/2"', 'xthin'),
(348, '1/2" - 3/4"', '2-3qtr'),
(349, '3/4" - 1"', '3-4qtr'),
(350, '1"- 1 1/2"', '4-6qtr'),
(351, '1 1/2" - 2"', '6-8qtr'),
(352, '2" - 3"', '8-12qtr'),
(353, 'Over 3"', '12qtr'),
]:
conn.execute(
"INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
"VALUES (?, 2, ?, ?, ?)",
(tid, name, slug, tid),
)
conn.commit()
@pytest.fixture
@@ -103,6 +120,55 @@ def test_create_and_fetch(client, db):
assert body[key] == payload[key]
def test_create_derives_thickness_band(client, db):
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
the UI has no band control, so the server's derivation is authoritative.
"""
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"thickness_term_id": 1,
}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
def test_update_rebands_when_thickness_changes(client, db):
"""A thickness edit moves the slab to the band the new measurement rounds into."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
def test_update_ignores_client_supplied_band(client, db):
"""A stale client band cannot pin the slab to the wrong band."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
def test_duplicate_sku(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
+94
View File
@@ -0,0 +1,94 @@
"""Thickness band resolution (METADATA-15 / QUALITY-95).
The owner never picks the Woo thickness band in the UI — the server derives it
from the measured thickness_in against the live synced bands. These tests pin the
derivation on the real live band names (from the UAT taxonomy) and on the
quarter-inch slug fallback.
"""
import pytest
from backend.thickness_band import (
parse_inch_token,
resolve_thickness_band_id,
term_bounds,
)
# The live UAT Thickness attribute (pa_thk) as synced: name, slug.
LIVE_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"},
]
@pytest.mark.parametrize(
"text,expected",
[
("1 1/2", 1.5),
("3/4", 0.75),
("2", 2.0),
("1.75", 1.75),
("2\"", 2.0),
("1 1/2\"", 1.5),
],
)
def test_parse_inch_token(text, expected):
assert parse_inch_token(text) == pytest.approx(expected)
def test_parse_inch_token_rejects_junk():
assert parse_inch_token("") is None
assert parse_inch_token(" ") is None
def test_term_bounds_from_live_names():
assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5)
assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75)
assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5)
assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf"))
def test_term_bounds_falls_back_to_qtr_slug():
assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5)
assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf"))
assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5)
@pytest.mark.parametrize(
"thickness,expected_id",
[
(0.25, 347), # Under 1/2"
(0.5, 348), # boundary rounds UP into 1/2"-3/4", not down
(0.625, 348),
(0.75, 349), # boundary rounds UP
(0.875, 349),
(1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band
(1.25, 350),
(1.5, 351),
(1.75, 351), # the UAT sample thickness
(2.0, 352),
(2.75, 352),
(3.0, 353),
(4.0, 353),
],
)
def test_resolve_rounds_up_on_boundary(thickness, expected_id):
assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id
def test_resolve_handles_missing_inputs():
assert resolve_thickness_band_id(None, LIVE_TERMS) is None
assert resolve_thickness_band_id(-1, LIVE_TERMS) is None
assert resolve_thickness_band_id(1.0, []) is None
# A term with no parseable name/slug is skipped, never guessed at.
assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None
def test_resolve_is_id_order_independent():
"""The result must not depend on the sync order of the terms."""
assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351
+177
View File
@@ -0,0 +1,177 @@
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
synced band term. We map thickness_in into the first half-open band [low, high)
that contains it, derived only from the live synced term names/slugs — never a
hardcoded Woo term id.
Round-up semantics: bands are half-open on the upper bound, so a value that sits
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
value (1"–1 1/2"), not the band that ends there (3/4"–1").
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Optional
# Mixed number / fraction / decimal inch token, optional trailing quote.
_INCH_RE = re.compile(
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
re.IGNORECASE,
)
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
def parse_inch_token(text: str) -> Optional[float]:
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
t = (text or "").strip().rstrip("\"'").strip()
if not t:
return None
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
if not m:
return None
if m.group("a") is not None:
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
if m.group("n2") is not None:
return float(m.group("n2")) / float(m.group("d2"))
if m.group("dec") is not None:
return float(m.group("dec"))
return None
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
"""Return half-open [low, high) inches from a Woo thickness term name."""
raw = (name or "").strip()
low_s = raw.lower()
if not raw:
return None
# Under X / less than X / < X
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
if m:
hi = parse_inch_token(m.group(1))
if hi is not None:
return (0.0, hi)
# Over X / above X / > X / X+
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
if m:
lo = parse_inch_token(m.group(1))
if lo is not None:
return (lo, float("inf"))
m = re.match(r"^(.+?)\+\s*$", raw)
if m:
lo = parse_inch_token(m.group(1))
if lo is not None:
return (lo, float("inf"))
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
if len(parts) == 2:
lo = parse_inch_token(parts[0])
hi = parse_inch_token(parts[1])
if lo is not None and hi is not None and hi > lo:
return (lo, hi)
# Single value band treated as [v, v+epsilon) — rare
only = parse_inch_token(raw)
if only is not None:
return (only, only + 1e-9)
return None
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
s = (slug or "").strip().lower()
if not s:
return None
if s in {"xthin", "under", "thin"}:
return (0.0, 0.5)
m = _QTR_RANGE_RE.match(s)
if m:
lo = int(m.group(1)) * 0.25
hi = int(m.group(2)) * 0.25
if hi > lo:
return (lo, hi)
m = _QTR_MIN_RE.match(s)
if m:
lo = int(m.group(1)) * 0.25
return (lo, float("inf"))
return None
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
"""Bounds for one synced thickness term; name first, slug fallback."""
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
str(term.get("slug") or "")
)
def resolve_thickness_band_id(
thickness_in: float | int | None,
terms: Iterable[dict[str, Any]],
) -> Optional[int]:
"""Pick the synced thickness term id for thickness_in, or None if unknown.
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
"""
if thickness_in is None:
return None
try:
t = float(thickness_in)
except (TypeError, ValueError):
return None
if t < 0:
return None
scored: list[tuple[float, float, int]] = [] # (low, high, id)
for term in terms:
b = term_bounds(term)
if b is None:
continue
tid = term.get("id")
if tid is None:
continue
lo, hi = b
scored.append((lo, hi, int(tid)))
if not scored:
return None
# Prefer the tightest band that contains t under half-open [low, high).
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
if matches:
matches.sort() # tightest span first
return matches[0][3]
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
scored.sort(key=lambda x: x[0])
for lo, hi, tid in scored:
if t < hi:
return tid
return scored[-1][2]
def load_thickness_terms(conn) -> list[dict[str, Any]]:
"""Live Thickness attribute terms from the synced Woo cache."""
row = conn.execute(
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
("thickness", "thickness"),
).fetchone()
if row is None:
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
row = conn.execute(
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
).fetchone()
if row is None:
return []
attr_id = row[0]
rows = conn.execute(
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
(attr_id,),
).fetchall()
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]
+1 -1
View File
@@ -199,7 +199,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
| QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
| QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — |
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | — |
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | backend/tests/test_thickness_band.py |
| QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
| QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
| QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
+3 -1
View File
@@ -121,7 +121,9 @@ hard stop. Switch to a supported browser.
3. **Wood categories.** Pick at least one from the synced list.
4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
exactly one grade.
5. **Thickness band.** Pick the Woo band that the measured thickness rounds up into.
5. **Thickness band.** Derived, not picked: the server bands the measured thickness up into
the synced Woo band and Review shows it read-only. Verify the shown band is the one the
captured thickness rounds up into (`QUALITY-95`).
6. **Moisture.** Keep the default (kiln-dried) or change it.
7. **fig-* tags.** Optional; pick any from the synced list.
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
+4 -1
View File
@@ -12,7 +12,7 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
| CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
| METADATA-10, METADATA-15, METADATA-30, QUALITY-95 | required taxonomy fields and cardinality; the thickness band is derived from the measurement, not picked |
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
| PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
@@ -59,6 +59,9 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
optional; price; title, short_description, and description.
- **Primary category.** The owner declares one primary wood category and any secondary ones; the
default is the first selected.
- **Thickness band is derived, not picked.** The measured thickness bands up into the synced Woo
band server-side (`METADATA-15`, `QUALITY-95`); Review shows the assigned band read-only and
offers no owner control for it.
- **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is
no single submit-time gap list.
- **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable.
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --import tsx --test src/**/*.test.ts",
"test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
+21 -5
View File
@@ -9,7 +9,11 @@
// (METADATA-10: "parents group only and are never assigned"); only the
// leaf rows are selectable. Filtering hides a parent group when none of
// its leaves match.
//
// #90 closed state shows selected NAMES (not only a count). Done dispatches
// `commit` so the parent can persist + re-review.
import { createEventDispatcher } from 'svelte';
import { selectedSummary } from './woodPrimary';
export interface Opt { id: number; name: string; ai: boolean }
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
@@ -33,6 +37,7 @@
// Effective selectable set (leaves when grouped, options when flat).
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
$: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
$: q = filter.trim().toLowerCase();
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
// Grouped: keep a parent only if at least one of its leaves survives the filter.
@@ -42,6 +47,7 @@
.filter((g) => g.leaves.length > 0)
: [];
$: count = selected.length;
$: summary = selectedSummary(selected, nameById, placeholder);
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
function toggle(id: number) {
@@ -55,13 +61,17 @@
for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id);
}
function close() { open = false; filter = ''; dispatch('commit'); }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) { open = false; filter = ''; dispatch('commit'); } }
// Rows are focusable options (role=option): Enter/Space toggles, matching click.
function onRowKey(e: KeyboardEvent, id: number) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
}
</script>
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
<div class="ms" class:open role="group" aria-label={label} data-testid={testid} on:keydown={onKey}>
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
<span class="ms-label">{open ? 'Choose ' + label.toLowerCase() : placeholder}</span>
<span class="ms-c">{count === 1 ? '1 selected' : `${count} selected`}</span>
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>{open ? 'Choose ' + label.toLowerCase() : summary}</span>
<span class="ms-c" data-testid={testid ? `${testid}-count` : 'ms-count'}>{count === 0 ? '' : count === 1 ? '1 selected' : `${count} selected`}</span>
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
</button>
@@ -85,9 +95,11 @@
class="ms-row leaf"
class:on={selected.includes(o.id)}
role="option"
tabindex="0"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
on:keydown={(e) => onRowKey(e, o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
@@ -104,9 +116,11 @@
class="ms-row"
class:on={selected.includes(o.id)}
role="option"
tabindex="0"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
on:keydown={(e) => onRowKey(e, o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
@@ -127,8 +141,9 @@
<style>
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
.ms-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
.ms-btn { box-sizing: border-box; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
.ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
.ms-arrow { color: #777; }
.ms-panel { border-top: 1px solid #333; background: #161616; }
@@ -148,6 +163,7 @@
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
.ms-row:focus-visible { outline: 2px solid #00e5ff; outline-offset: -2px; }
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
+116 -29
View File
@@ -11,6 +11,7 @@
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
type LineupItem,
} from './lineup';
import { nextPrimary } from './woodPrimary';
// Props handed off by App (one-column flow, UX.md §1).
export let slabId: string;
@@ -91,7 +92,6 @@
let edgeTypeId: number | '' = '';
let figureIds: number[] = [];
let gradeId: number | '' = '';
let thicknessId: number | '' = '';
let moistureId: number | '' = '';
let figTagIds: number[] = [];
let featTagIds: number[] = [];
@@ -307,7 +307,6 @@
edgeTypeId = s.edge_type_term_id ?? '';
figureIds = [...(s.figure_term_ids ?? [])];
gradeId = s.grade_term_id ?? '';
thicknessId = s.thickness_term_id ?? '';
moistureId = s.moisture_term_id ?? '';
figTagIds = [...(s.fig_tag_ids ?? [])];
featTagIds = [...(s.feat_tag_ids ?? [])];
@@ -318,7 +317,8 @@
price = s.price != null ? String(s.price) : '';
priceSource = s.price_source ?? 'recommendation';
sku = s.sku ?? '';
primaryCatId = woodCatIds[0] ?? '';
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
// its weight behind the value the owner kept).
aiRecommendations = [];
@@ -328,8 +328,30 @@
aiRecommendations.push(...(s.figure_term_ids ?? []));
}
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// AI-marked option views (badge on rows after Call 1 / stored confidences).
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: woodCatGroupsView = woodCatGroups.map((g) => ({
parent: g.parent,
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
}));
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
// Flat leaf name lookup for the primary radio set (REVIEW-15).
$: woodLeafById = new Map(
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
);
$: primaryOpts = woodCatIds
.map((id) => woodLeafById.get(id))
.filter((o): o is Opt => !!o)
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
// measured thickness against the live synced Woo bands. There is no owner
// control for it — this is the read-only display of the band the server assigned.
$: thicknessBandName =
thicknessOpts.find((o) => o.id === (slab?.thickness_term_id ?? -1))?.name ?? '';
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
async function commitReview(): Promise<void> {
if (!slab) return;
@@ -343,7 +365,6 @@
edge_type_term_id: edgeTypeId === '' ? null : Number(edgeTypeId),
figure_term_ids: figureIds,
grade_term_id: gradeId === '' ? null : Number(gradeId),
thickness_term_id: thicknessId === '' ? null : Number(thicknessId),
moisture_term_id: moistureId === '' ? null : Number(moistureId),
fig_tag_ids: figTagIds,
feat_tag_ids: featTagIds,
@@ -419,9 +440,10 @@
}
// #89: the lock is available once the pattern inputs exist (species + a wood
// category). The button is the unmistakable "Call 1 → review → lock → copy" step.
// category). Prefer the live editor model so the button enables before the
// next commit finishes writing the slab row.
let canLock = false;
$: canLock = !!slab?.species_id && (slab?.wood_category_ids?.length ?? 0) > 0;
$: canLock = speciesId !== '' && woodCatIds.length > 0 && primaryCatId !== '';
async function runInference() {
if (!slab) return;
@@ -434,7 +456,7 @@
if (s.species_id) { speciesId = s.species_id; recs.push(s.species_id); }
if (s.wood_category_ids) {
woodCatIds = [...s.wood_category_ids];
if (!primaryCatId) primaryCatId = woodCatIds[0];
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
recs.push(...s.wood_category_ids);
}
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
@@ -485,6 +507,18 @@
return arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id];
}
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
function toggleWoodCat(id: number) {
woodCatIds = toggleMulti(woodCatIds, id);
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
}
function pickPrimary(id: number) {
if (!woodCatIds.includes(id)) return;
primaryCatId = id;
void commitReview();
}
onMount(async () => {
try {
const [s, t, set] = await Promise.all([getSlab(slabId), getTaxonomy(), getSettings()]);
@@ -590,21 +624,49 @@
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
<select id="species" class="sel" data-testid="species" bind:value={speciesId} on:change={commitReview}>
<option value="">— select —</option>
{#each speciesOpts as o (o.id)}
{#each speciesOptsView as o (o.id)}
<option value={o.id} data-ai={o.ai ? 'true' : ''}>{o.name}{o.ai ? ' (AI)' : ''}</option>
{/each}
</select>
</div>
<!-- Wood categories (multi, searchable list, AI-assist) -->
<MultiSelect
label="Wood categories"
groups={woodCatGroups}
selected={woodCatIds}
placeholder="Choose wood categories"
testid="wood-cat"
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))}
/>
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
<div class="field" data-testid="wood-cat-field">
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
<MultiSelect
label="Wood categories"
groups={woodCatGroupsView}
selected={woodCatIds}
placeholder="Choose wood categories"
testid="wood-cat"
onToggle={toggleWoodCat}
on:commit={commitReview}
/>
{#if primaryOpts.length}
<div class="primary-set" data-testid="primary-wood-set" role="radiogroup" aria-label="Primary wood category">
<div class="ps-lbl">Primary wood category <span class="ps-req">drives title</span></div>
{#each primaryOpts as o (o.id)}
<button
type="button"
class="ps-opt"
class:on={primaryCatId === o.id}
role="radio"
aria-checked={primaryCatId === o.id}
data-testid={`primary-wood-${o.id}`}
on:click={() => pickPrimary(o.id)}
>
<span class="ps-radio" aria-hidden="true"></span>
<span class="ps-name">{o.name}</span>
{#if o.ai}<span class="ps-tag">AI</span>{/if}
</button>
{/each}
<p class="ps-note">
Primary selects the title &amp; short-description pattern (METADATA-85).
Secondary categories feed the description. Default = first selected.
</p>
</div>
{/if}
</div>
<!-- Edge type (single, AI-assist) -->
<div class="field">
@@ -622,11 +684,12 @@
<label class="lbl">Figure <span class="ai">AI assist</span></label>
<MultiSelect
label="Figure"
options={figureOpts}
options={figureOptsView}
selected={figureIds}
placeholder="Choose figure"
testid="figure"
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
on:commit={commitReview}
/>
</div>
@@ -649,6 +712,7 @@
placeholder="Choose fig-* tags"
testid="fig-tag"
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
on:commit={commitReview}
/>
</div>
@@ -657,20 +721,20 @@
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
<MultiSelect
label="feat-* tags"
options={featTagOpts}
options={featTagOptsView}
selected={featTagIds}
placeholder="Choose feat-* tags"
testid="feat-tag"
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
on:commit={commitReview}
/>
</div>
<div class="field">
<label class="lbl" for="thickness">Thickness band</label>
<select id="thickness" class="sel" data-testid="thickness-band" bind:value={thicknessId} on:change={commitReview}>
<option value="">— select —</option>
{#each thicknessOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
</select>
<label class="lbl">Thickness band <span class="sug">set from the measurement</span></label>
<p class="ro" data-testid="thickness-band">
{#if thicknessBandName}{thicknessBandName}{:else}— not yet resolved{/if}
</p>
</div>
<div class="field">
<label class="lbl" for="moisture">Moisture</label>
@@ -788,18 +852,41 @@
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
.inp, .sel { width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
/* box-sizing: the app has no global reset, so a width:100% control with padding
and a border pokes out of its container by that padding+border (20px here) —
which becomes real page overflow whenever the column sits at the viewport edge
(measured: 375px mobile scrolled 20px). Border-box keeps the box inside. */
.inp, .sel { box-sizing: border-box; width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
.ta { resize: vertical; }
.field { margin-bottom: 12px; }
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
/* Read-only derived value (thickness band): reads as data, not as a control. */
.ro { margin: 0; padding: 9px; background: #161616; border: 1px solid #2f2f2f; border-radius: 3px;
color: #cfcfcf; font-size: .9rem; font-variant-numeric: tabular-nums; }
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a4a2a; border-radius: 3px; background: #12210f; }
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a3a2a; border-radius: 3px; background: #141a14; }
.lockbox .lock-hint { color: #9fb89f; font-size: .78rem; margin: 0 0 8px; }
.lock-btn { width: 100%; padding: 11px; border: 0; border-radius: 3px; background: #37d05f; color: #002; font-weight: 700; font-size: .9rem; }
.lock-btn:disabled { background: #2a2a2a; color: #777; }
.lock-btn { box-sizing: border-box; width: 100%; padding: 11px; border: 1px solid #2a3a2a; border-radius: 3px; background: #1e3a28; color: #9fd3a8; font-weight: 700; font-size: .9rem; }
.lock-btn:disabled { background: #2a2a2a; color: #777; border-color: #2a2a2a; }
.locked-note { color: #7fd37f; font-size: .82rem; font-weight: 600; margin: 0; }
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
.primary-set .ps-lbl { font-size: .74rem; color: #9a9a9a; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
.primary-set .ps-req { font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #2a2a2a; color: #888; }
.ps-opt { box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 9px; padding: 8px 6px; margin: 0; border: 0; border-radius: 3px; background: transparent; color: #ddd; font-size: .88rem; cursor: pointer; text-align: left; }
.ps-opt:hover { background: #1c1c1c; }
.ps-opt.on { background: #12262b; color: #fff; }
.ps-opt:focus-visible { outline: 2px solid #00e5ff; outline-offset: 1px; }
.ps-radio { width: 14px; height: 14px; border: 2px solid #555; border-radius: 50%; flex: none; position: relative; }
.ps-opt.on .ps-radio { border-color: #00e5ff; }
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
.ps-name { flex: 1; }
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
.status.ok { background: #122a12; color: #7fd37f; }
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { nextPrimary, selectedSummary } from './woodPrimary';
test('nextPrimary: empty selection clears primary', () => {
assert.equal(nextPrimary([], 21), '');
assert.equal(nextPrimary([], ''), '');
});
test('nextPrimary: default is first selected (REVIEW-15)', () => {
assert.equal(nextPrimary([21, 22], ''), 21);
assert.equal(nextPrimary([21, 22], null), 21);
});
test('nextPrimary: keeps current when still selected', () => {
assert.equal(nextPrimary([21, 22], 22), 22);
assert.equal(nextPrimary([22], 22), 22);
});
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
assert.equal(nextPrimary([22, 23], 21), 22);
assert.equal(nextPrimary([23], 21), 23);
});
test('selectedSummary: placeholder when empty', () => {
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
});
test('selectedSummary: shows names, then +N overflow', () => {
const m = new Map([
[1, 'Live Edge'],
[2, 'Book Match'],
[3, 'Cookie'],
]);
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
});
test('selectedSummary: falls back to count when names unknown', () => {
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
}
);
+32
View File
@@ -0,0 +1,32 @@
/**
* REVIEW-15: owner declares one primary wood category among the selected set.
* Default primary = first selected. Deselecting the current primary promotes
* the first remaining selected id. Empty selection clears primary.
*/
export function nextPrimary(
selected: number[],
current: number | '' | null | undefined,
): number | '' {
if (!selected.length) return '';
const cur = typeof current === 'number' ? current : '';
if (cur !== '' && selected.includes(cur)) return cur;
return selected[0];
}
/** Closed MultiSelect summary: names when known, else a count. */
export function selectedSummary(
selected: number[],
nameById: Map<number, string>,
placeholder: string,
maxNames = 2,
): string {
if (!selected.length) return placeholder;
const names = selected
.map((id) => nameById.get(id))
.filter((n): n is string => !!n && n.length > 0);
if (!names.length) {
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
}
if (names.length <= maxNames) return names.join(', ');
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
}
+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'));
});
});