feat(review): #90 wood primary category + derived thickness band
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) -->
|
||||
<!-- 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={woodCatGroups}
|
||||
groups={woodCatGroupsView}
|
||||
selected={woodCatIds}
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))}
|
||||
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 & 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');
|
||||
}
|
||||
);
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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