15 Commits

Author SHA1 Message Date
Ty 8ee3da9dcf merge work/merge-89-90 into main (Create-string #89/#90 + PR #97 fixes)
Reconciles two independent lines of work that both touched Review.svelte,
MultiSelect.svelte, and the thickness band:

- main's PR #97 (client-side thicknessBand.ts + MultiSelect names/commit +
  syncSelects DOM race fix + box-sizing CSS)
- work/merge-89-90 (server-side thickness band in thickness_band.py,
  Create-string #89, metadata_locked dropped, photo strip, wood primary)

Canonical resolution: the band is SERVER-side (hidden from UI, sent to Woo
on publish), so the client-side thicknessBand.ts + read-only band display
are removed from Review.svelte; PR #97's syncSelects() and box-sizing CSS
are preserved (orthogonal fixes). MultiSelect takes selectedSummary from
woodPrimary.ts (names in closed state) + the ms-count testid + onDestroy
timer cleanup; duplicate close()/summary definitions dropped.

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip).
2026-09-21 11:59:36 -07:00
Ty 1a50fad0a5 merge issue-89-metadata-lock → main (Create-string model, #89/#90)
Drop the metadata_locked column + "Save and lock" UI end-to-end. The
review endpoint now builds the deterministic title/short_description on
every call; the owner's Create title / Create short desc buttons fire
the build and write the result (force-overwrite contract). commitReview
auto-fills only empty fields (no owner-clobber) and folds the
deterministic copy into its single PUT (kills the client_rev 409
cascade).

Taken wholesale from the branch: photo strip, wood primary/secondary,
server-side thickness band (hidden from UI, passed to Woo on publish),
and fixes #80/#82/#83/#84.

- backend: remove metadata_locked from schema.sql, db sentinels,
  slabs.py client fields/exposure, review.py lock action; review
  always computes title/short_desc
- frontend: slabs.ts + Review.svelte drop the lock model, add
  Create-string workflow + wood primary + photo strip
- e2e: m3-create + review-photo-strip rewritten for the Create-string
  contract; thickness-band test asserts absence (per #90)
- deploy: recreate LAN binds (override-m3.yml was absent); verify
  metadata_locked gone, review builds title, PUT persists

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip). Live UAT verified at 192.168.1.202:10881.
2026-09-21 11:54:48 -07:00
Ty e5acef4c9b chore(deploy): record final #90 UAT state (band omitted) 2026-09-21 02:50:06 -07:00
Ty 204bc5e960 fix(review): #90 thickness band omitted entirely (not read-only) 2026-09-21 02:49:47 -07:00
Ty 191b507934 chore(deploy): record #90 UAT deploy marker 2026-09-21 02:43:26 -07:00
Ty 0329052d37 feat(review): #90 wood primary category + derived thickness band 2026-09-21 02:41:20 -07:00
Ty e1b928ccbd fix(review): #90 clearer mandatory-photo indicator (padlock + REQUIRED tag)
The mandatory clean-cal and dimension-overlay photos now show a padlock glyph
in their keep box and an amber REQUIRED tag at the top of the card, so the
owner can see at a glance which photos cannot be removed (vs. optional batch
photos, which keep a plain checkmark).
2026-09-21 02:18:01 -07:00
Ty 76fcb36a2b fix(review): #90 clean cal + dimension-overlay are mandatory (checked + disabled)
REVIEW-5 requires both the clean calibration photo and its dimension-overlay
copy to ship. Their keep checkbox is now checked AND disabled (aria-disabled,
a lock glyph, cursor:default, tap is a no-op) so the owner can't remove them.
Only batch photos are removable. The submission minimum can no longer be
broken by the owner, so publish is never lineup-blocked by a missing required
photo.
2026-09-21 02:15:00 -07:00
Ty 5a1800df91 fix(review): #90 persist the dimension-overlay copy with its role
The Capture→Review upload filtered out the overlay (role !== 'overlay') and
sent no role field, so the dimension-overlay copy was never stored and every
photo defaulted to role=batch. Review then fell back to a stored /processed
URL that had no dimensions drawn — the owner saw the overlay thumbnail
without its measurements. Now upload the full lineup with the correct role,
keeping the overlay's dimension-drawn blob as both original and processed.
2026-09-21 01:59:27 -07:00
Ty cd326d1ec9 test(review): #90 assert Primary photo src is valid + checkerboard on strip
Locks in the two live-bug fixes: the Primary (leftmost kept) photo's <img src>
must be a real URL (blob: for an in-memory capture blob) and never empty/
"object Blob"; and each strip card must carry the gray checkerboard
background matching the Capture lineup.
2026-09-21 01:54:21 -07:00
Ty 0612ce66a6 fix(review): #90 photo strip — blob: URL for Primary photo, checkerboard thumbs
Bug #1: the first/Primary (calibration) photo rendered broken because its
in-memory capture Blob was assigned to <img src> directly (String(Blob)=
"object Blob"). resolvePhotoUrl() now mints a blob: object URL (revoked on
unload) and falls back to the stored photo endpoint when there is no blob.
Bug #2: strip thumbnails used a flat dark bg; now carry the gray checkerboard
from the Capture lineup (MeasurePanel) so transparent inventory PNGs read the
same on both screens.
Also harden missingMinimums to filter kept internally (pre-existing test 7
failed on the clean base because it passed un-kept items).
2026-09-21 01:48:43 -07:00
Ty 7b7adbdb34 fix(deploy): nginx client_max_body_size 25m for photo uploads
Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
multipart to /api/v1/slabs/{id}/photos. nginx's default 1MB
client_max_body_size 413s on real multi-MB photos, blocking the
Capture→Review transition. Allow 25m.
2026-09-21 01:41:31 -07:00
Ty d9af8420bc feat(review): #90 photo strip — keep/remove, drag-reorder, leftmost=primary, persist
REVIEW-5 / PUBLISH-45: owner edits the stored photo lineup in Review — tap a
card to keep/remove, drag to reorder, leftmost kept photo = primary (Woo
cover). The kept set must contain a clean calibration + its dimension-overlay
copy; publish is blocked until it does.

- backend: role column on slab_photos (schema + idempotent _migrate ALTER),
  role in photo CRUD + new DELETE endpoint, Woo image order by role-rank
  (clean→overlay→batch) per PUBLISH-45
- frontend: Review photo strip (render from listPhotos, keep/remove,
  HTML5 drag-reorder, dynamic PRIMARY badge from .primary class),
  persistLineup() re-seq kept + drop removed before entering Publish
- tests: lineup.ts unit, e2e review-photo-strip.spec, backend role + order

Refs: #90, REVIEW-5, PUBLISH-45
2026-09-21 01:34:22 -07:00
Ty fe82133ca4 fix(backend): #84 real woo_reachable, #83 reject unknown fields, #82 live taxonomy in stale_field, #80 persist review overrides
#84: /api/health woo_reachable is now a real check (GET /wp-json/ with a
3s timeout) instead of a hardcoded False stub. Version bumped to 0.5.0-m3-s1.

#83: POST /slabs rejects unknown (non-ALLOWED_FIELDS) keys with a 400
'unknown fields' error, instead of silently dropping them. The INSERT
binding count is now provably correct (no more assert needed).

#82: stale_field 422 error now returns the LIVE taxonomy ids
(SELECT id FROM table) as current_options, plus the field name and
submitted value, instead of echoing the submitted value back.

#80: regular review (no lock) persists owner overrides (price/sku/title/
short_description) from the payload to the DB via an UPDATE, so the
review endpoint is self-contained. server_rev is incremented on persist.

Tests: +3 (test_create_rejects_unknown_fields,
test_update_stale_field_returns_live_options,
test_review_persists_owner_overrides). 87/87 passing.
2026-09-20 23:38:29 -07:00
Ty 693719e85f fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review
The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.

- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
  stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
  ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
  inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
  ready→publish→done); mock respects optimistic concurrency (client_rev) so
  stale PUTs 409 like the real backend
2026-09-20 23:36:31 -07:00
30 changed files with 2124 additions and 295 deletions
+20 -3
View File
@@ -25,7 +25,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable from .db import get_connection, init_db, probe_images_writable, probe_writable
from . import settings as settings_mod from . import settings as settings_mod
__version__ = "0.5.0-m3-s0" __version__ = "0.5.0-m3-s1"
# --- Structured JSON logging (stdlib only) --- # --- Structured JSON logging (stdlib only) ---
request_id_var: ContextVar[str] = ContextVar("request_id", default="") request_id_var: ContextVar[str] = ContextVar("request_id", default="")
@@ -155,16 +155,33 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict[str, Any]: async def health() -> dict[str, Any]:
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).""" """Health per APP-30/45: non-200 when db or image volume not writable (probe file).
#84: woo_reachable is a real check (HEAD /wp-json/ with a short timeout),
not a hardcoded stub.
"""
db_ok = probe_writable() db_ok = probe_writable()
img_ok = probe_images_writable() img_ok = probe_images_writable()
if not (db_ok and img_ok): if not (db_ok and img_ok):
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")}) logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
raise HTTPException(status_code=503, detail="storage not writable") raise HTTPException(status_code=503, detail="storage not writable")
woo_reachable = False
try:
woo_base = os.environ.get("WOO_BASE_URL", "")
if woo_base:
import urllib.request
req = urllib.request.Request(
woo_base.rstrip("/") + "/wp-json/",
method="GET",
headers={"User-Agent": "SlabUploader/health"},
)
with urllib.request.urlopen(req, timeout=3) as resp:
woo_reachable = resp.status < 500
except Exception:
woo_reachable = False
return { return {
"status": "ok", "status": "ok",
"version": __version__, "version": __version__,
"woo_reachable": False, # S0 stub; S4 will implement real check "woo_reachable": woo_reachable,
} }
+19
View File
@@ -79,6 +79,21 @@ _M3_TABLES = [
] ]
def _migrate(conn: sqlite3.Connection) -> None:
"""Idempotent column additions for volumes created before a column existed.
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
so new columns must be added here. Each guard is a no-op once the column is
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
"""
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
if photo_cols and "role" not in photo_cols:
conn.execute(
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
"CHECK(role IN ('calibration','overlay','batch'))"
)
def init_db(db_path: str | None = None) -> sqlite3.Connection: def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn = get_connection(db_path) conn = get_connection(db_path)
schema_path = _get_schema_path() schema_path = _get_schema_path()
@@ -98,6 +113,10 @@ def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn.executescript(schema) conn.executescript(schema)
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)") conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
conn.commit() conn.commit()
# Safe to run every boot: no-op once the columns exist.
with _lock:
_migrate(conn)
conn.commit()
return conn return conn
+3
View File
@@ -54,6 +54,9 @@ CREATE TABLE IF NOT EXISTS slab_photos (
slab_id TEXT NOT NULL, slab_id TEXT NOT NULL,
seq INTEGER NOT NULL, seq INTEGER NOT NULL,
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)), is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
-- #90: lineup kind (clean cal / dimension-overlay copy / batch sibling).
-- Drives the Review photo strip labels and the PUBLISH-45 Woo image order.
role TEXT NOT NULL DEFAULT 'batch' CHECK(role IN ('calibration','overlay','batch')),
original_blob BLOB, original_blob BLOB,
processed_blob BLOB, processed_blob BLOB,
width_px INTEGER, width_px INTEGER,
+25 -6
View File
@@ -26,7 +26,7 @@ def _get_slab(conn, slab_id: str):
def _get_photo(conn, slab_id: str, photo_id: str): def _get_photo(conn, slab_id: str, photo_id: str):
row = conn.execute( row = conn.execute(
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq " "SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq, role "
"FROM slab_photos WHERE id=? AND slab_id=?", "FROM slab_photos WHERE id=? AND slab_id=?",
(photo_id, slab_id), (photo_id, slab_id),
).fetchone() ).fetchone()
@@ -44,6 +44,7 @@ async def upload_photo(
photo_id: str = Form(...), photo_id: str = Form(...),
seq: int | None = Form(None), seq: int | None = Form(None),
is_calibration: int = Form(0), is_calibration: int = Form(0),
role: str = Form("batch"),
original: UploadFile = File(...), original: UploadFile = File(...),
processed: UploadFile | None = File(None), processed: UploadFile | None = File(None),
) -> dict: ) -> dict:
@@ -52,6 +53,8 @@ async def upload_photo(
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
if is_calibration not in (0, 1): if is_calibration not in (0, 1):
return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1")) return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1"))
if role not in ("calibration", "overlay", "batch"):
return JSONResponse(status_code=400, content=_error("validation", "role must be calibration, overlay, or batch"))
orig_bytes = await original.read() orig_bytes = await original.read()
proc_bytes = await processed.read() if processed is not None else None proc_bytes = await processed.read() if processed is not None else None
if not orig_bytes: if not orig_bytes:
@@ -60,14 +63,14 @@ async def upload_photo(
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)) conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
seq_val = seq if seq is not None else _next_seq(conn, slab_id) seq_val = seq if seq is not None else _next_seq(conn, slab_id)
conn.execute( conn.execute(
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, original_blob, processed_blob, created_at) " "INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
"VALUES (?,?,?,?,?,?,?)", "VALUES (?,?,?,?,?,?,?,?)",
(photo_id, slab_id, seq_val, is_calibration, orig_bytes, proc_bytes, _now()), (photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()),
) )
conn.commit() conn.commit()
row = _get_photo(conn, slab_id, photo_id) row = _get_photo(conn, slab_id, photo_id)
return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"], return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"],
"original_retained": True, "processed_retained": row["processed_blob"] is not None} "role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None}
def _now() -> str: def _now() -> str:
@@ -108,9 +111,25 @@ async def list_photos(slab_id: str) -> list[dict]:
if _get_slab(conn, slab_id) is None: if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
rows = conn.execute( rows = conn.execute(
"SELECT id, seq, is_calibration, width_px, height_px, " "SELECT id, seq, is_calibration, role, width_px, height_px, "
"(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed " "(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed "
"FROM slab_photos WHERE slab_id=? ORDER BY seq", "FROM slab_photos WHERE slab_id=? ORDER BY seq",
(slab_id,), (slab_id,),
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
@router.delete("/{slab_id}/photos/{photo_id}", status_code=204)
async def delete_photo(slab_id: str, photo_id: str):
"""#90: drop a photo the owner removed in Review (keep/remove edit). The
remaining photos are re-numbered by the client via re-upload."""
conn = get_connection()
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
cur = conn.execute(
"DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)
)
conn.commit()
if cur.rowcount == 0:
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
return Response(status_code=204)
+8 -1
View File
@@ -150,9 +150,16 @@ async def publish(slab_id: str) -> dict:
code, msg, rec = _classify(e) code, msg, rec = _classify(e)
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec})) return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
# 4/5. Upload processed PNGs, then POST product. # 4/5. Upload processed PNGs, then POST product.
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
# its dimension-overlay copy, then the remaining batch photos. The Review
# photo strip stores the owner's keep order (leftmost = primary), so the
# overlay (which can sit after a moved batch photo) is re-ordered here by
# role rather than by stored seq. Ties fall back to stored seq.
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn) status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
photos = conn.execute( photos = conn.execute(
"SELECT id, processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq", "SELECT id, processed_blob, role FROM slab_photos "
"WHERE slab_id=? AND processed_blob IS NOT NULL "
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
(slab_id,), (slab_id,),
).fetchall() ).fetchall()
uploaded_ids: list[int] = [] uploaded_ids: list[int] = []
+48 -30
View File
@@ -5,6 +5,7 @@ METADATA-80/85/90, SETTINGS-35.
import json import json
import re import re
from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
@@ -44,6 +45,10 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}} return {"error": {"code": code, "message": message, "detail": detail}}
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _split_length(length_in: float) -> tuple[int, float]: def _split_length(length_in: float) -> tuple[int, float]:
ft = int(length_in // 12) ft = int(length_in // 12)
rem = round(length_in - ft * 12, 1) rem = round(length_in - ft * 12, 1)
@@ -150,15 +155,16 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
if row is None: if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
slab = _slab_dict(row) slab = _slab_dict(row)
# Apply any review edits (primary category declaration + prose).
editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"} # --- Deterministic review: report ready/missing + the deterministic
for k in editable: # title/short_description built from the current locked metadata. The client's
if k in payload and payload[k] is not None: # "Create title" / "Create short desc" buttons call this endpoint and write the
if k == "primary_wood_category_id": # returned strings into the fields (item 1: no lock action, no metadata_locked).
continue # not stored as a column; derived # #80: persist owner overrides (price/sku) from the payload so the review
slab[k] = payload[k] # endpoint is self-contained. title/short_description are NOT overridden here —
# they are produced by the deterministic build below, not persisted from the
# payload (so the review POST can't clobber the owner's Create workflow).
cats, terms = _taxonomy_lookup(conn) cats, terms = _taxonomy_lookup(conn)
# Primary wood category: declared, else first wood_category_ids, else first category.
primary_id = payload.get("primary_wood_category_id") primary_id = payload.get("primary_wood_category_id")
wood_cats = slab.get("wood_category_ids") or [] wood_cats = slab.get("wood_category_ids") or []
if not primary_id: if not primary_id:
@@ -168,28 +174,40 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
figure_term_ids = slab.get("figure_term_ids") or [] figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
# Deterministic copy (only if not already overridden by owner). # #80: persist owner overrides from the payload (price/sku only).
if not payload.get("title"): overrides: dict[str, Any] = {}
if primary_leaf and species_name: for k in ("price", "sku"):
slab["title"] = build_title(slab, figure, species_name, primary_leaf) if k in payload and payload[k] is not None:
if not payload.get("short_description"): overrides[k] = payload[k]
if primary_leaf and species_name: if overrides:
slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf) set_clause = ", ".join(f"{k} = ?" for k in overrides)
# Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks. conn.execute(
suggested = price_suggestion(conn, slab) f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
if payload.get("price") is not None: [*overrides.values(), _now(), slab_id],
price = payload["price"] )
elif slab.get("price") not in (None, 0): conn.commit()
price = slab["price"] slab.update(overrides)
else:
price = suggested # Deterministic title / short_description, re-derived from the current
ready, missing = compute_ready({**slab, "price": price}) # metadata on every review (the Create button's source of truth).
title = build_title(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("title") or None
short_desc = build_short_description(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("short_description") or None
price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
return { return {
"ready": ready, "ready": ready, "missing": missing, "title": title, "short_description": short_desc,
"missing": missing, "price": price, "price_suggestion": price_suggestion(conn, slab),
"title": slab.get("title"),
"short_description": slab.get("short_description"),
"price": price,
"price_suggestion": suggested,
"primary_wood_category_id": primary_id, "primary_wood_category_id": primary_id,
} }
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
else the stored price, else the suggestion (None if no rate)."""
suggested = price_suggestion(conn, slab)
if payload.get("price") is not None:
return payload["price"]
if slab.get("price") not in (None, 0):
return slab["price"]
return suggested
+38 -2
View File
@@ -13,6 +13,7 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from .db import get_connection from .db import get_connection
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
router = APIRouter(prefix="/api/v1/slabs") router = APIRouter(prefix="/api/v1/slabs")
@@ -64,6 +65,12 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
return out return out
# #89: server-managed fields that a plain client PUT must not set directly.
# (metadata_locked was dropped — the deterministic title/short-desc are built by
# the review endpoint and written back by the client's Create buttons.)
CLIENT_FORBIDDEN_FIELDS = set()
def _row_to_slab(row) -> Dict[str, Any]: def _row_to_slab(row) -> Dict[str, Any]:
d = dict(row) d = dict(row)
for f in JSON_ARRAY_FIELDS: for f in JSON_ARRAY_FIELDS:
@@ -110,6 +117,13 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload] missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
if missing: if missing:
return _err("validation", "missing fields", {"fields": missing}, status=400) return _err("validation", "missing fields", {"fields": missing}, status=400)
# #83: reject unknown keys (client sent a field that is not client-settable).
# The DB INSERT only binds ALLOWED_FIELDS, so extra keys are silently dropped
# — but a 400 tells the client its payload is malformed, not that the slab
# was created with the extra keys silently ignored.
unknown = [k for k in payload if k not in ALLOWED_FIELDS and k != "client_rev"]
if unknown:
return _err("validation", "unknown fields", {"fields": unknown}, status=400)
# Business rule (middle tier, not the DB): an empty/blank SKU at create means # Business rule (middle tier, not the DB): an empty/blank SKU at create means
# "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only # "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only
# governs present SKUs, and NULL is the honest "absent" representation. # governs present SKUs, and NULL is the honest "absent" representation.
@@ -119,6 +133,12 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
conn = get_connection() conn = get_connection()
if payload["sku"] is not None and _sku_taken(conn, payload["sku"]): if payload["sku"] is not None and _sku_taken(conn, payload["sku"]):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409) 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() now = _now_iso()
data = _encode(payload) data = _encode(payload)
allowed_cols = [f for f in ALLOWED_FIELDS if f in data] allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
@@ -128,7 +148,6 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
# One value per col, in cols order: data values for the allowed fields, then the 6 server values. # One value per col, in cols order: data values for the allowed fields, then the 6 server values.
vals = [data[f] for f in allowed_cols] + server_vals vals = [data[f] for f in allowed_cols] + server_vals
ph = ",".join(["?"] * len(cols)) ph = ",".join(["?"] * len(cols))
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals) conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit() conn.commit()
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone() row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
@@ -155,6 +174,11 @@ async def get_slab(id: str) -> Dict[str, Any]:
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]: async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if "client_rev" not in payload: if "client_rev" not in payload:
return _err("validation", "client_rev required", status=400) return _err("validation", "client_rev required", status=400)
# #89: the metadata lock is server-managed via the explicit review lock
# action; a plain PUT may not flip it.
for field in CLIENT_FORBIDDEN_FIELDS:
if field in payload:
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
conn = get_connection() conn = get_connection()
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone() current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if current is None: if current is None:
@@ -175,8 +199,20 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
except (ValueError, TypeError): except (ValueError, TypeError):
val = [] val = []
if not _check_taxonomy(conn, table, val): if not _check_taxonomy(conn, table, val):
# #82: return the LIVE taxonomy options (valid ids in the table),
# not the submitted value. The client needs to know what's valid.
if isinstance(val, list):
live = [r[0] for r in conn.execute(
f"SELECT id FROM {table} ORDER BY id"
).fetchall()]
else:
live = [val] # single-id field: just show what was submitted
return _err("stale_field", f"{field} not in current taxonomy", return _err("stale_field", f"{field} not in current taxonomy",
{"current_options": val}, status=422) {"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) data = _encode(payload)
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data] cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
if not cols: if not cols:
+41 -4
View File
@@ -34,13 +34,16 @@ PNG_ORIG = b"\x89PNG-orig-bytes"
PNG_PROC = b"\x89PNG-proc-bytes" PNG_PROC = b"\x89PNG-proc-bytes"
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0): def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0, role="batch", seq=None):
files = {"original": ("orig.png", original, "image/png")} files = {"original": ("orig.png", original, "image/png")}
if processed is not None: if processed is not None:
files["processed"] = ("proc.png", processed, "image/png") files["processed"] = ("proc.png", processed, "image/png")
data = {"photo_id": photo_id, "is_calibration": str(is_calibration), "role": role}
if seq is not None:
data["seq"] = str(seq)
return client.post( return client.post(
f"/api/v1/slabs/{slab_id}/photos", f"/api/v1/slabs/{slab_id}/photos",
data={"photo_id": photo_id, "is_calibration": str(is_calibration)}, data=data,
files=files, files=files,
) )
@@ -90,9 +93,43 @@ def test_reupload_replaces(photos_env):
def test_list_photos(photos_env): def test_list_photos(photos_env):
client, _ = photos_env client, _ = photos_env
_multipart(client, "s1", "pa", is_calibration=1) _multipart(client, "s1", "pa", is_calibration=1, role="calibration")
_multipart(client, "s1", "pb") _multipart(client, "s1", "pb", role="overlay")
r = client.get("/api/v1/slabs/s1/photos") r = client.get("/api/v1/slabs/s1/photos")
assert r.status_code == 200 assert r.status_code == 200
ids = [row["id"] for row in r.json()] ids = [row["id"] for row in r.json()]
assert "pa" in ids and "pb" in ids assert "pa" in ids and "pb" in ids
def test_upload_stores_and_returns_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r1", role="overlay")
assert r.status_code == 201
assert r.json()["role"] == "overlay"
r = client.get("/api/v1/slabs/s1/photos")
row = {row_["id"]: row_ for row_ in r.json()}["r1"]
assert row["role"] == "overlay"
def test_upload_rejects_bad_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r2", role="not-a-role")
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
def test_delete_photo(photos_env):
client, _ = photos_env
_multipart(client, "s1", "d1")
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 200
r = client.delete("/api/v1/slabs/s1/photos/d1")
assert r.status_code == 204
# Gone from the list and blob no longer served.
ids = [row["id"] for row in client.get("/api/v1/slabs/s1/photos").json()]
assert "d1" not in ids
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 404
def test_delete_photo_not_found(photos_env):
client, _ = photos_env
assert client.delete("/api/v1/slabs/s1/photos/ghost").status_code == 404
+118
View File
@@ -45,6 +45,21 @@ def env(monkeypatch):
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)") conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)") conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)") conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
# Thickness bands, names as synced (the derivation parses them) — QUALITY-95.
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)")
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 INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)",
(tid, name, slug, tid),
)
# Moisture keeps its own attribute so the five locked attributes stay distinct.
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)")
# creds # creds
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)") conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),)) conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
@@ -119,6 +134,109 @@ def test_publish_success(env, monkeypatch):
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
def test_publish_sends_derived_thickness_band(env, monkeypatch):
"""#90 / QUALITY-95: the band is off the Review screen but must still reach Woo.
The owner has no thickness-band control, so nothing client-side carries it: the
server derives it from the measurement on create/update, and publish must send the
live synced term name on the product payload. This pins that chain end to end.
"""
client, conn = env
create = client.post("/api/v1/slabs", json={
"id": "s1", "sku": "SLAB-BAND", "length_in": 114.0, "thickness_in": 1.75,
"sqft": 30.0, "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
})
assert create.status_code == 201, create.text
# 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client.
assert create.json()["thickness_term_id"] == 351
put = client.put("/api/v1/slabs/s1", json={
"client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2],
"edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326,
"price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S",
"description": "D", "content_source": "manual",
})
assert put.status_code == 200, put.text
conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
conn.commit()
products = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
products.append(json.loads(data))
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(products) == 1, "expected exactly one product create"
attrs = {a["id"]: a for a in products[0].get("attributes", [])}
assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}"
assert attrs[9]["options"] == ['1 1/2" - 2"']
def test_publish_woo_image_order_by_role(env, monkeypatch):
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
# role, not by stored seq (the Review strip stores the owner's keep order).
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
# overlay(3). The publish query must reorder to cal, overlay, batch.
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
conn.commit()
media_calls = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
media_calls.append(data)
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(media_calls) == 3
# The photo id is embedded in the multipart filename (slab.py publish
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
# batch (PUBLISH-45), not stored seq.
def photo_id(body: bytes) -> str:
import re
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
return m.group(1).decode() if m else ""
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
def test_uat_draft(env, monkeypatch): def test_uat_draft(env, monkeypatch):
client, conn = env client, conn = env
s = _full_slab(sku="SLAB-UAT-1") s = _full_slab(sku="SLAB-UAT-1")
+58
View File
@@ -55,6 +55,8 @@ def test_title_live_edge_no_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t")) ("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit() conn.commit()
# #89: the review always returns the deterministic title/short_description
# built from the current metadata (the Create button's source of truth).
r = env[0].post("/api/v1/slabs/s1/review", json={}) r = env[0].post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
body = r.json() body = r.json()
@@ -134,5 +136,61 @@ def test_knife_blank_carries_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t")) ("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit() conn.commit()
# #89: the deterministic build runs on every review.
r = client.post("/api/v1/slabs/s1/review", json={}) r = client.post("/api/v1/slabs/s1/review", json={})
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)" assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
def test_review_always_reports_deterministic_title(env):
"""#89: the review always returns the deterministic title/short_description
built from the current metadata. The client's Create button writes these into
the fields; the server does not persist them from the review payload."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
# The review does NOT persist title/short_description from the payload.
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] is None
assert got["short_description"] is None
def test_review_no_species_reports_stored_title(env):
"""With no species, there is no deterministic pattern; the review reports the
stored title (None here) and lists title as missing."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] is None
assert "title" in body["missing"]
def test_review_persists_owner_overrides(env):
"""#80: regular review persists price/sku overrides from the payload to the DB,
so the client doesn't need a separate PUT for each. (title/short_description
are NOT persisted here — they come from the deterministic build, not the payload.)"""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# Review with owner overrides for price + sku.
r = client.post("/api/v1/slabs/s1/review", json={
"price": 250.0,
"sku": "SLAB-99",
})
assert r.status_code == 200
body = r.json()
assert body["price"] == 250.0
# The overrides are persisted: GET reflects them.
got = client.get("/api/v1/slabs/s1").json()
assert got["price"] == 250.0
assert got["sku"] == "SLAB-99"
# server_rev was incremented (the UPDATE fired).
assert got["server_rev"] == 2
+95
View File
@@ -33,6 +33,23 @@ def _seed_taxonomy(conn):
# tags # 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 (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)") 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() conn.commit()
@pytest.fixture @pytest.fixture
@@ -103,6 +120,55 @@ def test_create_and_fetch(client, db):
assert body[key] == payload[key] 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): def test_duplicate_sku(client, db):
slab_id = str(uuid.uuid4()) 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} 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}
@@ -260,3 +326,32 @@ def test_delete_published_conflict(client, db):
conn.commit() conn.commit()
resp = client.delete(f"/api/v1/slabs/{slab_id}") resp = client.delete(f"/api/v1/slabs/{slab_id}")
assert resp.status_code == 409 assert resp.status_code == 409
def test_create_rejects_unknown_fields(client, db):
# #83: POST /slabs with extra (non-ALLOWED_FIELDS) keys → 400, not a silent drop.
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, "bogus_field": 42}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 400
body = resp.json()
assert body["error"]["code"] == "validation"
assert "bogus_field" in body["error"]["detail"]["fields"]
def test_update_stale_field_returns_live_options(client, db):
# #82: stale_field error returns the LIVE taxonomy ids, not the submitted value.
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"}
client.post("/api/v1/slabs", json=payload)
# Submit a wood_category_id that doesn't exist in the taxonomy (id=999).
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "wood_category_ids": json.dumps([999])})
assert resp.status_code == 422
body = resp.json()
assert body["error"]["code"] == "stale_field"
detail = body["error"]["detail"]
assert detail["submitted"] == [999]
# current_options must be the LIVE ids from woo_categories, not [999].
assert 999 not in detail["current_options"]
# The seeded taxonomy has ids 1 and 2 in woo_categories.
assert set(detail["current_options"]) == {1, 2}
+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]
+53
View File
@@ -24,3 +24,56 @@ Check to see if you are running on ubudual5060 (192.168.1.202) before attempting
Future deploys use a **separate** container name and a free port in `10880`–`10889` Future deploys use a **separate** container name and a free port in `10880`–`10889`
(other than `10880`), bound `0.0.0.0`. (other than `10880`), bound `0.0.0.0`.
Do not put a host name in the product spec. Do not put a host name in the product spec.
## Deploy records
### 2026-09-21 02:41 PDT — #90 wood primary category + derived thickness band
- Source ref: `0329052` on `issue-89-metadata-lock` (pushed to Gitea; pulled into `~/slab-deploy`).
- Images (built on `ubudual5060`): `deploy-frontend:latest` `1c169c29f131`,
`deploy-fastapi:latest` `32671659639b`.
- Compose: `deploy/docker-compose.yml` + host-local (untracked) `deploy/override-m3.yml`.
- URL: `http://192.168.1.202:10881` (LAN) · `http://100.64.0.9:10881` (Tailscale).
- `matches HEAD`: **yes**.
- Sample data preserved: **yes** — 24 slabs before and after `up -d` (no `-v`, no volume removal).
- Verified on the live stack: served asset `assets/index-tlFib3RM.js` (sha256 `3b665746ec81f28e…`)
contains the new markers and no `#thickness` select; `POST /api/v1/slabs` at `thickness_in 1.0`
stored band `350` (`1"- 1 1/2"`); `PUT` at `2.75` re-banded to `352`; a client-sent stale band id
was ignored; `POST /review` no longer lists thickness as missing; the Review screen in a real
browser (LAN URL) shows the wood-category MultiSelect closed summary with names + count, the
`Primary wood category` radio set switching on click, and the band as read-only text.
- Not exercised: the Woo publish path. Rows created **before** this deploy keep `thickness_term_id`
NULL until their next save (the band is derived on write, so any Review commit fills it in).
### 2026-09-21 03:12 PDT — follow-up: thickness band omitted entirely from Review
- Commit: `204bc5e` (fix #90: band removed from UI, not merely read-only; publish test added).
- Rebuilt `deploy-frontend:latest` and recreated the container on 10881.
- Live verification: served HTML and bundle contain zero occurrences of "Thickness band" or "set from the measurement".
- Sample data unchanged (still 25 slabs post-recreate).
- All gates (unit, build, requirements, coverage, m3-create e2e) pass.
### 2026-09-21 11:52 PDT — merge `issue-89-metadata-lock` → main (Create-string model)
- Source: branch `work/merge-89-90` (base `origin/issue-89-metadata-lock` + Create-string re-applied).
`matches HEAD`: **no** — staged from the uncommitted working tree (local sync; Gitea push not
attempted this round). 9 files changed: Review.svelte, slabs.ts, review.py, slabs.py, schema.sql,
db/__init__.py, test_review.py, m3-create.spec.ts, review-photo-strip.spec.ts.
- Model change: `metadata_locked` DB column + "Save and lock" UI **dropped end-to-end**. The
deterministic title/short_description are now built by the review endpoint on every call; the
owner's **Create title / Create short desc** buttons fire the build and write the result
(overwrite contract). `commitReview` auto-fills only empty fields (no owner-clobber); `createTitle`
is the explicit force-overwrite. Photo strip, wood primary/secondary, and server-side thickness
band (item: not shown in UI, passed to Woo on publish) all taken from the branch.
- LAN binds: `deploy/override-m3.yml` was absent from disk; the live stack's 10881/10882 LAN binds
were re-created by editing `deploy/docker-compose.yml` ports in-place (loopback 18082/18083 from
`.env` + `0.0.0.0:10881`/`0.0.0.0:10882`), then `down --remove-orphans` + `up -d` (no `-v`).
- Bundle: `assets/index-YPAqx609.js` (new; contains `create-title`). API `/api/health`
`woo_reachable:true`.
- Sample data preserved: **yes** — 34 slabs before/after.
- Live verification (LAN URL, real DB): `slabs` table has **no** `metadata_locked` column;
`slab_photos.role` present. `POST /review` on slab `ec4088da…` → `title:'Curl Black Walnut Slab'`,
`ready:True`. `PUT` title persisted (rev 1→2) then restored (original sample value intact, rev 3).
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
not exercised this round.
+6 -2
View File
@@ -16,7 +16,9 @@ services:
build: build:
context: .. context: ..
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"] ports:
- "127.0.0.1:${FRONTEND_PORT:-18080}:80"
- "0.0.0.0:10881:80"
depends_on: [fastapi] depends_on: [fastapi]
restart: unless-stopped restart: unless-stopped
@@ -34,7 +36,9 @@ services:
volumes: volumes:
- slab_db:/data - slab_db:/data
- slab_images:/images - slab_images:/images
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"] ports:
- "127.0.0.1:${FASTAPI_PORT:-18081}:8000"
- "0.0.0.0:10882:8000"
mem_limit: 2g mem_limit: 2g
restart: unless-stopped restart: unless-stopped
+5
View File
@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
# multipart to /api/v1/slabs/{id}/photos. nginx's default client_max_body_size
# is 1MB, which 413s on real multi-MB photos. Allow large photo bodies.
client_max_body_size 25m;
# Docker embedded DNS for service name resolution (fastapi) # Docker embedded DNS for service name resolution (fastapi)
resolver 127.0.0.11 valid=10s; resolver 127.0.0.11 valid=10s;
+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-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-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-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-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-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 | — | | 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 | — |
+4 -4
View File
@@ -121,10 +121,10 @@ hard stop. Switch to a supported browser.
3. **Wood categories.** Pick at least one from the synced list. 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, 4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
exactly one grade. exactly one grade.
5. **Thickness band.** Not a control — it is derived automatically from the measured 5. **Thickness band.** Not on the screen and not picked: the server bands the measured thickness
thickness (the thickness-in rounds up into the synced Woo band, `TV-9` / `QUALITY-95`). up into the synced Woo band, stores it, and publish sends it to the store. Verify after publish
It displays read-only on the Review screen; fix it upstream by correcting the thickness that the Woo product's Thickness attribute is the band the captured thickness rounds up into
on capture/measure if the band looks wrong. (`QUALITY-95`).
6. **Moisture.** Keep the default (kiln-dried) or change it. 6. **Moisture.** Keep the default (kiln-dried) or change it.
7. **fig-* tags.** Optional; pick any from the synced list. 7. **fig-* tags.** Optional; pick any from the synced list.
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags. 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-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-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 | | 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 | | 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 | | 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 | | REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
@@ -62,6 +62,9 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
(`REVIEW-5`) via that derived value. (`REVIEW-5`) via that derived value.
- **Primary category.** The owner declares one primary wood category and any secondary ones; the - **Primary category.** The owner declares one primary wood category and any secondary ones; the
default is the first selected. default is the first selected.
- **Thickness band is derived and not shown.** The measured thickness bands up into the synced Woo
band server-side (`METADATA-15`, `QUALITY-95`); Review carries no thickness-band control and no
read-only echo of it. The derived band is stored on the slab and sent to the store at publish.
- **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is - **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is
no single submit-time gap list. no single submit-time gap list.
- **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable. - **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable.
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "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": "playwright test",
"test:e2e:headed": "playwright test --headed" "test:e2e:headed": "playwright test --headed"
}, },
+9 -3
View File
@@ -105,16 +105,22 @@
content_source: 'manual', content_source: 'manual',
client_rev: 0, client_rev: 0,
}); });
const pngItems = capture.lineup.filter( // #90 / REVIEW-5: persist the full lineup with correct roles so the Review
(it) => it.kind === 'png' && it.role !== 'overlay', // strip matches in-memory blobs by role. The overlay is the dimension-drawn
); // copy — it must be persisted (its blob already has the dimensions) and is
// the photo the owner sees with the measurement overlay. Excluding it (the
// old `role !== 'overlay'` filter) dropped the dimension copy from Review.
const pngItems = capture.lineup.filter((it) => it.kind === 'png');
for (let i = 0; i < pngItems.length; i++) { for (let i = 0; i < pngItems.length; i++) {
const it = pngItems[i]; const it = pngItems[i];
await uploadPhoto(id, { await uploadPhoto(id, {
photoId: uuid(), photoId: uuid(),
seq: i + 1, seq: i + 1,
isCalibration: it.role === 'calibration', isCalibration: it.role === 'calibration',
role: it.role,
original: it.blob, original: it.blob,
// The calibration/batch PNGs have no separate processed variant; the
// overlay blob already carries the drawn dimensions.
processed: it.blob, processed: it.blob,
}); });
} }
+49
View File
@@ -157,6 +157,8 @@ export interface PhotoRow {
id: string; id: string;
seq: number; seq: number;
is_calibration: number; is_calibration: number;
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
role: 'calibration' | 'overlay' | 'batch';
width_px: number | null; width_px: number | null;
height_px: number | null; height_px: number | null;
has_original: number; has_original: number;
@@ -167,6 +169,8 @@ export interface UploadPhotoInput {
photoId: string; // client-generated UUIDv4 photoId: string; // client-generated UUIDv4
seq?: number; seq?: number;
isCalibration: boolean; isCalibration: boolean;
/** #90: lineup kind; defaults to batch. */
role?: 'calibration' | 'overlay' | 'batch';
original: Blob; original: Blob;
processed?: Blob | null; processed?: Blob | null;
} }
@@ -176,6 +180,7 @@ export interface UploadPhotoResult {
slab_id: string; slab_id: string;
seq: number; seq: number;
is_calibration: number; is_calibration: number;
role: 'calibration' | 'overlay' | 'batch';
original_retained: boolean; original_retained: boolean;
processed_retained: boolean; processed_retained: boolean;
} }
@@ -191,6 +196,7 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
form.append('photo_id', input.photoId); form.append('photo_id', input.photoId);
if (input.seq != null) form.append('seq', String(input.seq)); if (input.seq != null) form.append('seq', String(input.seq));
form.append('is_calibration', input.isCalibration ? '1' : '0'); form.append('is_calibration', input.isCalibration ? '1' : '0');
form.append('role', input.role ?? 'batch');
const origName = input.original instanceof File ? input.original.name : 'original.png'; const origName = input.original instanceof File ? input.original.name : 'original.png';
form.append('original', input.original, origName); form.append('original', input.original, origName);
if (input.processed) form.append('processed', input.processed, 'processed.png'); if (input.processed) form.append('processed', input.processed, 'processed.png');
@@ -200,6 +206,49 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
}).then((r) => parse<UploadPhotoResult>(r)); }).then((r) => parse<UploadPhotoResult>(r));
} }
// #90: drop a photo the owner removed in Review (keep/remove edit).
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
method: 'DELETE',
}).then((r) => parse<void>(r));
}
/**
* #90: persist an edited Review photo lineup. `kept` is the owner's final
* order (leftmost = primary); each entry re-uploads with a fresh seq so the
* stored `seq` reflects the keep order. `removed` photos are dropped. Every
* kept photo's blob is re-sent (original + processed) because the server
* stores only the BLOB — there is no separate re-seq endpoint.
*/
export interface LineupPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
original: Blob;
processed: Blob | null;
}
export async function persistLineup(
slabId: string,
kept: LineupPhoto[],
removed: string[],
): Promise<void> {
for (const id of removed) {
await deletePhoto(slabId, id);
}
for (let i = 0; i < kept.length; i++) {
const p = kept[i];
await uploadPhoto(slabId, {
photoId: p.id,
seq: i + 1,
isCalibration: p.isCalibration,
role: p.role,
original: p.original,
processed: p.processed,
});
}
}
export function photoUrl( export function photoUrl(
slabId: string, slabId: string,
photoId: string, photoId: string,
+17 -12
View File
@@ -9,7 +9,10 @@
// (METADATA-10: "parents group only and are never assigned"); only the // (METADATA-10: "parents group only and are never assigned"); only the
// leaf rows are selectable. Filtering hides a parent group when none of // leaf rows are selectable. Filtering hides a parent group when none of
// its leaves match. // 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, onDestroy } from 'svelte'; import { createEventDispatcher, onDestroy } from 'svelte';
import { selectedSummary } from './woodPrimary';
export interface Opt { id: number; name: string; ai: boolean } export interface Opt { id: number; name: string; ai: boolean }
export interface Group { parent: { id: number; name: string }; leaves: Opt[] } export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
@@ -38,15 +41,7 @@
// Effective selectable set (leaves when grouped, options when flat). // Effective selectable set (leaves when grouped, options when flat).
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[]; $: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
// #90: the closed state must show WHAT is selected (names), not just a count. $: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
// Truncate long lists so the button stays one line.
$: selectedNames = allOpts.filter((o) => selected.includes(o.id)).map((o) => o.name);
$: summary =
selectedNames.length === 0
? ''
: selectedNames.length <= 2
? selectedNames.join(', ')
: `${selectedNames.slice(0, 2).join(', ')} +${selectedNames.length - 2}`;
$: q = filter.trim().toLowerCase(); $: q = filter.trim().toLowerCase();
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts; $: 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. // Grouped: keep a parent only if at least one of its leaves survives the filter.
@@ -56,6 +51,7 @@
.filter((g) => g.leaves.length > 0) .filter((g) => g.leaves.length > 0)
: []; : [];
$: count = selected.length; $: count = selected.length;
$: summary = selectedSummary(selected, nameById, placeholder);
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id)); $: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
function scheduleCommit() { function scheduleCommit() {
@@ -86,9 +82,13 @@
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; } function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
function destroy() { if (commitTimer) clearTimeout(commitTimer); } function destroy() { if (commitTimer) clearTimeout(commitTimer); }
onDestroy(destroy); onDestroy(destroy);
// 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> </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}> <button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}> <span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>
{open {open
@@ -97,7 +97,7 @@
? summary ? summary
: placeholder} : placeholder}
</span> </span>
<span class="ms-c">{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</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> <span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
</button> </button>
@@ -121,9 +121,11 @@
class="ms-row leaf" class="ms-row leaf"
class:on={selected.includes(o.id)} class:on={selected.includes(o.id)}
role="option" role="option"
tabindex="0"
aria-selected={selected.includes(o.id)} aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`} data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(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="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> <span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
@@ -140,9 +142,11 @@
class="ms-row" class="ms-row"
class:on={selected.includes(o.id)} class:on={selected.includes(o.id)}
role="option" role="option"
tabindex="0"
aria-selected={selected.includes(o.id)} aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`} data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(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="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> <span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
@@ -163,7 +167,7 @@
<style> <style>
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; } .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; } padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
.ms-btn .ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eee; } .ms-btn .ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eee; }
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; } .ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
@@ -185,6 +189,7 @@
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; } .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; } .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.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-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 { 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; } .ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
+496 -78
View File
@@ -2,8 +2,8 @@
import { onMount, onDestroy, tick } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { import {
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent, getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
ApiError, listPhotos, persistLineup, photoUrl, ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
} from '../../api/slabs'; } from '../../api/slabs';
import type { CalibratedCapture } from '../flow'; import type { CalibratedCapture } from '../flow';
import MultiSelect from './MultiSelect.svelte'; import MultiSelect from './MultiSelect.svelte';
@@ -35,7 +35,6 @@
let edgeOpts: Opt[] = []; let edgeOpts: Opt[] = [];
let figureOpts: Opt[] = []; let figureOpts: Opt[] = [];
let gradeOpts: Opt[] = []; let gradeOpts: Opt[] = [];
let thicknessOpts: Opt[] = [];
let moistureOpts: Opt[] = []; let moistureOpts: Opt[] = [];
let figTagOpts: Opt[] = []; let figTagOpts: Opt[] = [];
let featTagOpts: Opt[] = []; let featTagOpts: Opt[] = [];
@@ -78,7 +77,8 @@
edgeOpts = termsOf('edge type'); edgeOpts = termsOf('edge type');
figureOpts = termsOf('figure'); figureOpts = termsOf('figure');
gradeOpts = termsOf('grade'); gradeOpts = termsOf('grade');
thicknessOpts = termsOf('thickness'); // No thickness options: the band is derived server-side and never shown here
// (METADATA-15 / QUALITY-95).
moistureOpts = termsOf('moisture'); moistureOpts = termsOf('moisture');
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set, // fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
// filtered by the family the backend keeps. Never invent a prefix — if the // filtered by the family the backend keeps. Never invent a prefix — if the
@@ -99,23 +99,7 @@
let gradeSel: string = ''; let gradeSel: string = '';
let woodCatIds: number[] = []; let woodCatIds: number[] = [];
let figureIds: number[] = []; let figureIds: number[] = [];
// #88: thickness band is NOT an owner control — it is derived deterministically let gradeId: number | '' = '';
// from thickness_in (TV-9 / QUALITY-95) against the synced Woo thickness terms.
// The Review screen shows it read-only; the owner changes thickness upstream, and
// the band follows.
$: thicknessId = deriveThicknessTermId(
slab?.thickness_in ?? null,
thicknessOpts.map((o) => ({ id: o.id, name: o.name })),
);
$: thicknessName = thicknessOpts.find((o) => o.id === thicknessId)?.name ?? '';
// #88 display text: the derived band name, or an explicit "no band" note when a
// thickness is set but nothing in the synced set encloses it.
$: thicknessDisplay =
thicknessId !== ''
? thicknessName
: (slab?.thickness_in ?? 0) > 0
? `— no band for ${fmt1(slab!.thickness_in!)}″`
: '—';
let moistureId: number | '' = ''; let moistureId: number | '' = '';
let figTagIds: number[] = []; let figTagIds: number[] = [];
let featTagIds: number[] = []; let featTagIds: number[] = [];
@@ -137,6 +121,16 @@
let review: ReviewOut | null = null; let review: ReviewOut | null = null;
let reviewBusy = false; let reviewBusy = false;
let reviewError = ''; let reviewError = '';
// #89: explicit "Create" actions. `createBusy` covers the deterministic
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
// category). `canCreateDesc` gates the AI long-description on title+short-desc
// being populated AND the content LLM being enabled.
let createBusy = false;
let createError = '';
$: canCreateTitle =
speciesId !== '' && woodCatIds.length > 0;
$: canCreateDesc =
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
// Monotonic token: each commit captures the current value and ignores its // Monotonic token: each commit captures the current value and ignores its
// result if a newer commit has since started. Without this, two concurrent // result if a newer commit has since started. Without this, two concurrent
// commits (e.g. a select change racing the SKU blur) resolve out of order and // commits (e.g. a select change racing the SKU blur) resolve out of order and
@@ -156,14 +150,177 @@
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; }; const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
const fmt2 = (v: number) => v.toFixed(2); const fmt2 = (v: number) => v.toFixed(2);
// --- #90 Photo strip (REVIEW-5 / PUBLISH-45) ---
// The owner edits which photos stay/go and their order. Leftmost kept photo =
// primary (Woo cover). Kept in-memory; persisted via persistLineup() on
// "Continue to Publish". Sources are the STORED photos (listPhotos) so the
// strip survives re-entry into Review; in-memory lineup blobs (from capture)
// are preferred when present.
interface StripPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
kept: boolean;
/** True for the mandatory photos (clean calibration + dimension-overlay
copy). Their keep checkbox is checked and disabled — the owner cannot
remove them (REVIEW-5 requires both). */
required: boolean;
original: Blob;
processed: Blob | null;
url: string;
}
let photos: StripPhoto[] = [];
let photosLoaded = false;
let lineupDirty = false;
let persistBusy = false;
let persistError = '';
// In-memory blobs from the capture lineup, keyed by lineup order. The stored
// photo id matches the capture's upload order only on the first entry; after
// an edit the STORED row (listPhotos) is authoritative and we re-fetch blobs.
const inMemByRole = (): Map<'calibration' | 'overlay' | 'batch', Blob[]> => {
const m = new Map<'calibration' | 'overlay' | 'batch', Blob[]>();
for (const it of capture?.lineup ?? []) {
if (it.kind !== 'png') continue;
const arr = m.get(it.role) ?? [];
arr.push(it.blob);
m.set(it.role, arr);
}
return m;
};
// Object URLs minted from in-memory blobs; revoked on unmount/reload so the
// <img> src stays valid. A raw Blob assigned to src renders broken — it must
// be a blob: URL (bug: the first/Primary photo was the one carrying a blob).
let photoUrls: string[] = [];
async function loadPhotos() {
// Revoke any previous object URLs before rebuilding the strip.
for (const u of photoUrls) URL.revokeObjectURL(u);
photoUrls = [];
try {
const rows: PhotoRow[] = await listPhotos(slabId);
const byRole = inMemByRole();
const seqByRole: Record<string, number> = { calibration: 0, overlay: 0, batch: 0 };
photos = rows.map((r) => {
const role = r.role ?? 'batch';
const n = seqByRole[role]++;
const blob = (byRole.get(role)?.[n]) ?? null;
// Prefer the in-memory blob (fresh capture), as an object URL. Otherwise
// fall back to the stored photo endpoint (a plain string URL).
const endpoint = photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed');
const url = resolvePhotoUrl(blob, endpoint);
if (blob && blob.size > 0) photoUrls.push(url);
return {
id: r.id,
role,
isCalibration: r.is_calibration === 1,
// Mandatory: the clean calibration photo and the dimension-overlay
// copy. REVIEW-5 requires both to ship, so their keep control is
// checked + disabled (the owner can't remove them).
required: role === 'calibration' || role === 'overlay',
kept: true,
original: blob ?? new Blob(),
processed: blob ?? null,
url,
};
});
photosLoaded = true;
lineupDirty = false;
} catch {
photos = [];
photosLoaded = false;
}
}
onDestroy(() => {
for (const u of photoUrls) URL.revokeObjectURL(u);
photoUrls = [];
});
// Whole-card tap = keep/remove toggle. A real drag (>6px) reorders and does NOT
// toggle keep/remove — the threshold lives in the pointer handlers below.
function toggleKeep(id: string) {
photos = photos.map((p) => {
if (p.id !== id) return p;
// Mandatory photos (clean cal + dimension-overlay) can't be removed.
if (p.required) return p;
return { ...p, kept: !p.kept };
});
lineupDirty = true;
}
function reorder(from: number, to: number) {
photos = moveItem(photos, from, to);
lineupDirty = true;
}
let dragFrom = -1;
function onDragStart(e: DragEvent, index: number) {
dragFrom = index;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}
}
function onDragOver(e: DragEvent) {
if (e.preventDefault) e.preventDefault();
}
function onDrop(e: DragEvent, index: number) {
if (e.preventDefault) e.preventDefault();
if (dragFrom >= 0 && dragFrom !== index) reorder(dragFrom, index);
dragFrom = -1;
}
// REVIEW-5: the kept set must contain a clean photo and its overlay copy. Only
// enforced once photos have loaded (photosLoaded); a failed/absent photo list
// (e.g. legacy mock) leaves the lineup minimum neutral rather than blocking.
let lineupMissing: ('calibration' | 'overlay')[] = [];
$: lineupMissing = photosLoaded
? missingMinimums(keptInOrder(photos as LineupItem[]) as LineupItem[])
: [];
$: lineupValid = lineupMissing.length === 0;
// Leftmost kept photo id = primary (Woo cover). '' when nothing is kept.
let primaryId = '';
$: primaryId = photosLoaded
? ((keptInOrder(photos as LineupItem[]) as StripPhoto[])[0]?.id ?? '')
: '';
// Persist the edited lineup (re-seq kept + drop removed), then hand to Publish.
async function goPublish() {
if (!lineupValid) return;
persistBusy = true;
persistError = '';
try {
const kept = keptInOrder(photos as LineupItem[]) as StripPhoto[];
const removed = photos.filter((p) => !p.kept).map((p) => p.id);
await persistLineup(
slabId,
kept.map((p) => ({
id: p.id,
role: p.role,
isCalibration: p.isCalibration,
original: p.original,
processed: p.processed,
})),
removed,
);
onPublish();
} catch (e) {
persistError = e instanceof ApiError ? e.message : 'Could not save the photos. Try again.';
} finally {
persistBusy = false;
}
}
function hydrate(s: Slab, t: Taxonomy | null) { function hydrate(s: Slab, t: Taxonomy | null) {
speciesSel = s.species_id != null ? String(s.species_id) : ''; speciesSel = s.species_id != null ? String(s.species_id) : '';
woodCatIds = [...(s.wood_category_ids ?? [])]; woodCatIds = [...(s.wood_category_ids ?? [])];
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : ''; edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
figureIds = [...(s.figure_term_ids ?? [])]; figureIds = [...(s.figure_term_ids ?? [])];
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : ''; gradeId = s.grade_term_id ?? '';
// thicknessId is derived reactively from slab.thickness_in (#88) — not hydrated
// from the stored term, so a stale stored band can't shadow the derived value.
moistureId = s.moisture_term_id ?? ''; moistureId = s.moisture_term_id ?? '';
figTagIds = [...(s.fig_tag_ids ?? [])]; figTagIds = [...(s.fig_tag_ids ?? [])];
featTagIds = [...(s.feat_tag_ids ?? [])]; featTagIds = [...(s.feat_tag_ids ?? [])];
@@ -174,7 +331,8 @@
price = s.price != null ? String(s.price) : ''; price = s.price != null ? String(s.price) : '';
priceSource = s.price_source ?? 'recommendation'; priceSource = s.price_source ?? 'recommendation';
sku = s.sku ?? ''; 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 // Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
// its weight behind the value the owner kept). // its weight behind the value the owner kept).
aiRecommendations = []; aiRecommendations = [];
@@ -184,28 +342,63 @@
aiRecommendations.push(...(s.figure_term_ids ?? [])); 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. It is deliberately absent
// from this screen — no owner control, no read-only echo. The only thickness the
// owner sees here is the measured value in the summary above.
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready. // Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
// #89: the deterministic title/short_description are written here — the server's
// review returns the build from the current metadata, and if the field is empty
// we fold the deterministic value into THIS same PUT (no extra request, no rev
// race). Owner-edited text is never clobbered (only an empty field is filled).
async function commitReview(): Promise<void> { async function commitReview(): Promise<void> {
if (!slab) return; if (!slab) return;
const myToken = ++commitToken; const myToken = ++commitToken;
reviewBusy = true; reviewBusy = true;
reviewError = ''; reviewError = '';
try { try {
// Run the deterministic review FIRST (reads the current metadata) to get the
// title/short_description build before we persist.
const probe = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
// so this PUT persists it (the Create buttons do the same, on demand).
if (probe.title && title === '') title = probe.title;
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
const patch = { const patch = {
species_id: speciesSel === '' ? null : Number(speciesSel), species_id: speciesSel === '' ? null : Number(speciesSel),
wood_category_ids: woodCatIds, wood_category_ids: woodCatIds,
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel), edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
figure_term_ids: figureIds, figure_term_ids: figureIds,
grade_term_id: gradeSel === '' ? null : Number(gradeSel), grade_term_id: gradeId === '' ? null : Number(gradeId),
thickness_term_id: thicknessId === '' ? null : Number(thicknessId),
moisture_term_id: moistureId === '' ? null : Number(moistureId), moisture_term_id: moistureId === '' ? null : Number(moistureId),
fig_tag_ids: figTagIds, fig_tag_ids: figTagIds,
feat_tag_ids: featTagIds, feat_tag_ids: featTagIds,
title: title || null, title: title || null,
short_description: shortDescription || null, short_description: shortDescription || null,
description: description || null,
content_source: contentSource || null, content_source: contentSource || null,
price: price === '' ? null : Number(price), price: price === '' ? null : Number(price),
price_source: priceSource, price_source: priceSource,
@@ -215,21 +408,79 @@
const fresh = await updateSlab(slabId, patch); const fresh = await updateSlab(slabId, patch);
if (myToken !== commitToken) return; // superseded; don't clobber the latest if (myToken !== commitToken) return; // superseded; don't clobber the latest
slab = fresh; slab = fresh;
if (primaryCatId !== '') { // Re-run the review against the just-persisted row (authoritative ready/missing).
const res = await reviewSlab(slabId, { review = await reviewSlab(slabId, {
primary_wood_category_id: Number(primaryCatId), primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
title: title || undefined,
short_description: shortDescription || undefined,
price: price === '' ? undefined : Number(price), price: price === '' ? undefined : Number(price),
sku: sku || undefined, sku: sku || undefined,
}); });
if (myToken !== commitToken) return; if (myToken !== commitToken) return;
review = res; if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id; } catch (e) {
} else {
review = await reviewSlab(slabId);
if (myToken !== commitToken) return; if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
review = null;
} finally {
if (myToken === commitToken) reviewBusy = false;
} }
}
// #89: explicit "Create title" / "Create short desc" — fire the deterministic
// build from the current metadata and write the result into the fields,
// overwriting whatever was there (empty, owner-typed, or previously generated).
// That is the whole contract: the button fires the build. commitReview()
// auto-fills only empty fields; this is the explicit "force overwrite" action.
async function createTitle(): Promise<void> {
if (!slab || createBusy) return;
createBusy = true;
createError = '';
try {
// 1. Run the deterministic review to get title / short_description.
const res = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
// 2. Write the deterministic values into the bound fields — always overwrite.
if (res.title) title = res.title;
if (res.short_description) shortDescription = res.short_description;
// 3. Persist the generated prose (single PUT, fresh client_rev).
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
const prosePatch = {
title: title || null,
short_description: shortDescription || null,
client_rev: (slab as Slab).server_rev,
};
const fresh = await updateSlab(slabId, prosePatch);
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
}
} catch (e) {
createError = e instanceof ApiError ? e.message : 'Could not build the title. Try again.';
} finally {
createBusy = false;
}
}
// #89: the description textarea commits on blur. commitReview() no longer sends
// description (see #89), so this persists the owner-typed description explicitly.
async function commitDescription(): Promise<void> {
if (!slab) return;
const myToken = ++commitToken;
reviewBusy = true;
reviewError = '';
try {
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
} catch (e) { } catch (e) {
if (myToken !== commitToken) return; if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.'; reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
@@ -250,7 +501,7 @@
if (s.species_id) { speciesSel = String(s.species_id); recs.push(s.species_id); } if (s.species_id) { speciesSel = String(s.species_id); recs.push(s.species_id); }
if (s.wood_category_ids) { if (s.wood_category_ids) {
woodCatIds = [...s.wood_category_ids]; woodCatIds = [...s.wood_category_ids];
if (!primaryCatId) primaryCatId = woodCatIds[0]; primaryCatId = nextPrimary(woodCatIds, primaryCatId);
recs.push(...s.wood_category_ids); recs.push(...s.wood_category_ids);
} }
if (s.edge_type_term_id) edgeSel = String(s.edge_type_term_id); if (s.edge_type_term_id) edgeSel = String(s.edge_type_term_id);
@@ -274,14 +525,22 @@
} }
async function generateContent() { async function generateContent() {
if (!slab) return; if (!slab || !canCreateDesc) return;
genBusy = true; genBusy = true;
genError = ''; genError = '';
try { try {
const r = await inferContent(slabId); const r = await inferContent(slabId);
description = r.description; description = r.description;
contentSource = 'llm'; contentSource = 'llm';
await commitReview(); // commitReview() no longer sends description (see #89), so persist it explicitly.
const myToken = ++commitToken;
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
} catch (e) { } catch (e) {
genError = e instanceof ApiError ? e.message : 'Could not generate text.'; genError = e instanceof ApiError ? e.message : 'Could not generate text.';
} finally { } finally {
@@ -319,6 +578,17 @@
let speciesEl: HTMLSelectElement | undefined; let speciesEl: HTMLSelectElement | undefined;
let edgeEl: HTMLSelectElement | undefined; let edgeEl: HTMLSelectElement | undefined;
let gradeEl: HTMLSelectElement | undefined; let gradeEl: HTMLSelectElement | undefined;
/** 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 () => { onMount(async () => {
try { try {
@@ -329,6 +599,7 @@
canGenerateContent = !!set.content_llm_enabled; canGenerateContent = !!set.content_llm_enabled;
// #90: sync the <select> DOM to the hydrated values (same race as AI prefill). // #90: sync the <select> DOM to the hydrated values (same race as AI prefill).
syncSelects(); syncSelects();
await loadPhotos();
await commitReview(); await commitReview();
} catch (e) { } catch (e) {
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.'; loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
@@ -337,17 +608,9 @@
} }
}); });
function goPublish() {
onPublish();
}
function goBack() { function goBack() {
onBack(); onBack();
} }
function publish() {
goPublish();
}
</script> </script>
{#if loading} {#if loading}
@@ -371,6 +634,55 @@
<div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div> <div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
</dl> </dl>
<!-- #90 Photo strip: keep/remove + drag-reorder; leftmost kept = primary. -->
{#if photosLoaded && photos.length}
<div class="photos" data-testid="photo-strip">
<p class="ph-hint">Tap a photo to keep or remove it · drag to reorder · leftmost photo = primary</p>
<div class="ph-grid">
{#each photos as p, i (p.id)}
<div
class="pcard"
class:kept={p.kept}
class:primary={p.id === primaryId}
class:required={p.required}
data-testid="photo-card"
data-primary={p.id === primaryId ? 'true' : ''}
data-required={p.required ? 'true' : ''}
role="button"
tabindex="0"
aria-pressed={p.required ? true : p.kept}
aria-disabled={p.required ? 'true' : 'false'}
aria-label="{p.required ? 'Required: ' : ''}{roleLabel(p.role)}{p.required ? ' (cannot be removed)' : (p.kept ? ', kept' : ', removed')}"
draggable="true"
on:click={() => toggleKeep(p.id)}
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleKeep(p.id); } }}
on:dragstart={(e) => onDragStart(e, i)}
on:dragover={onDragOver}
on:drop={(e) => onDrop(e, i)}
>
<img class="ph-thumb" src={p.url} alt={roleLabel(p.role)} draggable="false" />
{#if p.id === primaryId}
<span class="ph-pri">PRIMARY</span>
{/if}
{#if p.required}
<span class="ph-req">REQUIRED</span>
{/if}
<span class="ph-seq" aria-hidden="true">{i + 1}</span>
<!-- Keep checkbox: always checked for required photos; a disabled
glyph (no check) when a batch photo is removed. -->
<span class="ph-keep" class:required={p.required} aria-hidden="true"></span>
</div>
{/each}
</div>
{#if lineupMissing.length}
<p class="ph-nudge" data-testid="lineup-nudge" role="alert">
Keep at least {lineupMissing.map(roleLabel).join(' and ')} — required to publish.
</p>
{/if}
{#if persistError}<p class="nudge" role="alert">{persistError}</p>{/if}
</div>
{/if}
{#if settings?.inference_enabled} {#if settings?.inference_enabled}
<div class="aibar"> <div class="aibar">
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}> <button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
@@ -391,16 +703,43 @@
</select> </select>
</div> </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 <MultiSelect
label="Wood categories" label="Wood categories"
groups={woodCatGroups} groups={woodCatGroupsView}
selected={woodCatIds} selected={woodCatIds}
placeholder="Choose wood categories" placeholder="Choose wood categories"
testid="wood-cat" testid="wood-cat"
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))} onToggle={toggleWoodCat}
onCommit={commitReview} 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) --> <!-- Edge type (single, AI-assist) -->
<div class="field"> <div class="field">
@@ -418,12 +757,12 @@
<label class="lbl">Figure <span class="ai">AI assist</span></label> <label class="lbl">Figure <span class="ai">AI assist</span></label>
<MultiSelect <MultiSelect
label="Figure" label="Figure"
options={figureOpts} options={figureOptsView}
selected={figureIds} selected={figureIds}
placeholder="Choose figure" placeholder="Choose figure"
testid="figure" testid="figure"
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))} onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
onCommit={commitReview} on:commit={commitReview}
/> />
</div> </div>
@@ -446,7 +785,7 @@
placeholder="Choose fig-* tags" placeholder="Choose fig-* tags"
testid="fig-tag" testid="fig-tag"
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))} onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
onCommit={commitReview} on:commit={commitReview}
/> />
</div> </div>
@@ -455,21 +794,18 @@
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label> <label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
<MultiSelect <MultiSelect
label="feat-* tags" label="feat-* tags"
options={featTagOpts} options={featTagOptsView}
selected={featTagIds} selected={featTagIds}
placeholder="Choose feat-* tags" placeholder="Choose feat-* tags"
testid="feat-tag" testid="feat-tag"
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))} onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
onCommit={commitReview} on:commit={commitReview}
/> />
</div> </div>
<!-- #88: thickness band is deterministic from thickness_in — display-only, no owner pick. --> <!-- #88: thickness band is deterministic from thickness_in — display-only, no owner pick. -->
<div class="field"> <div class="field">
<label class="lbl" for="thickness">Thickness band <span class="sug">from thickness</span></label>
<div id="thickness" class="ro" data-testid="thickness-band">{thicknessDisplay}</div>
</div>
<div class="field">
<label class="lbl" for="moisture">Moisture</label> <label class="lbl" for="moisture">Moisture</label>
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}> <select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
<option value="">— select —</option> <option value="">— select —</option>
@@ -477,26 +813,39 @@
</select> </select>
</div> </div>
<!-- Title / Description --> <!-- Title / Short description + explicit "Create" build buttons (#89) -->
<div class="field"> <div class="field">
<label class="lbl" for="title">Title</label> <label class="lbl" for="title">Title</label>
<div class="create-row">
<input id="title" class="inp" data-testid="title" bind:value={title} /> <input id="title" class="inp" data-testid="title" bind:value={title} />
<button type="button" class="create-btn" data-testid="create-title"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create title'}
</button>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="lbl" for="short-desc">Short description</label> <label class="lbl" for="short-desc">Short description</label>
<div class="create-row">
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} /> <input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
<button type="button" class="create-btn" data-testid="create-short-desc"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create short desc'}
</button>
</div>
</div> </div>
<div class="field"> <div class="field">
<label class="lbl" for="desc">Description</label> <label class="lbl" for="desc">Description</label>
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitReview}></textarea> <textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
{#if canGenerateContent} {#if canCreateDesc}
<div class="gen"> <div class="gen">
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}> <button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
{genBusy ? 'Writing…' : 'Generate text'} {genBusy ? 'Writing…' : 'Create desc'}
</button> </button>
</div> </div>
{/if} {/if}
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if} {#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
</div> </div>
<!-- Price / SKU (SKU last) --> <!-- Price / SKU (SKU last) -->
@@ -518,13 +867,16 @@
{#if reviewBusy} {#if reviewBusy}
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div> <div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
{:else if review} {:else if review}
<div class="status" class:ok={ready()} data-testid="ready-status"> <div class="status" class:ok={ready() && lineupValid} data-testid="ready-status">
{ready() {ready() && lineupValid
? 'Ready to publish' ? 'Ready to publish'
: `Not ready — ${review.missing.length} field${review.missing.length === 1 ? '' : 's'} remaining`} : `Not ready — ${[
...review.missing,
...lineupMissing.map(roleLabel),
].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`}
</div> </div>
{#if !ready()} {#if !(ready() && lineupValid)}
<p class="missing" data-testid="missing-list">Missing: {review.missing.join(', ')}</p> <p class="missing" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p>
{/if} {/if}
{/if} {/if}
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if} {#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
@@ -535,10 +887,10 @@
type="button" type="button"
class="primary" class="primary"
data-testid="continue-publish" data-testid="continue-publish"
disabled={publishDisabled} disabled={publishDisabled || !lineupValid || persistBusy}
on:click={publish} on:click={goPublish}
> >
Continue to Publish {persistBusy ? 'Saving photos…' : 'Continue to Publish'}
</button> </button>
</div> </div>
</section> </section>
@@ -555,15 +907,40 @@
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; } .ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; } .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; } .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
/* #88 read-only thickness band: same chrome as .sel but non-interactive. */ and a border pokes out of its container by that padding+border (20px here) —
.ro { width: 100%; padding: 9px; background: #141414; border: 1px solid #333; border-radius: 3px; color: #9ad0d8; font-size: .9rem; text-align: left; } 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; } .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; } .ta { resize: vertical; }
.field { margin-bottom: 12px; } .field { margin-bottom: 12px; }
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; } .nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; } .sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; } .gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
/* #89: explicit "Create" build buttons beside the title / short-desc fields. */
.create-row { display: flex; gap: 8px; align-items: stretch; }
.create-row .inp { flex: 1; }
.create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
background: #1d1d1d; color: #eee; font-size: .82rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
.create-btn:hover { background: #242424; }
.create-btn:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
/* 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 { 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; } .status.ok { background: #122a12; color: #7fd37f; }
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; } .missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
@@ -572,4 +949,45 @@
.primary:disabled { background: #333; color: #777; } .primary:disabled { background: #333; color: #777; }
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; } .ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
.err { color: #e88; } .busy { color: #aaa; } .err { color: #e88; } .busy { color: #aaa; }
/* #90 Photo strip */
.photos { margin: 14px 0 4px; }
.ph-hint { color: #8a8a8a; font-size: .72rem; margin: 0 0 8px; }
.ph-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
@media (max-width: 420px) { .ph-grid { grid-template-columns: repeat(2, 1fr); } }
.pcard { position: relative; aspect-ratio: 1; border: 2px solid #2a2a2a; border-radius: 3px; overflow: hidden; cursor: grab;
/* Gray checkerboard, matching the Capture lineup (MeasurePanel .lineup-item.png)
so transparent inventory PNGs read the same on both screens (bug2). */
background-color: #eee;
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; }
.pcard:active { cursor: grabbing; }
.pcard:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
.pcard.kept { border-color: #444; }
.pcard.primary { border-color: #00e5ff; }
.pcard:not(.kept) { opacity: .4; }
.ph-thumb { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.ph-pri { position: absolute; top: 6px; right: 6px; background: #00e5ff; color: #002; font-size: .56rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .03em; pointer-events: none; }
.ph-req { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); background: #ffb020; color: #201500; font-size: .52rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .04em; pointer-events: none; }
.ph-seq { position: absolute; bottom: 6px; left: 6px; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,.7); color: #fff; font-size: .66rem; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.ph-keep { position: absolute; top: 6px; left: 6px; width: 16px; height: 16px; border: 2px solid #fff; border-radius: 3px; background: rgba(0,0,0,.5); pointer-events: none; }
.pcard.kept .ph-keep { background: #00e5ff; border-color: #00e5ff; }
.pcard.kept .ph-keep::after { content: ''; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #002; border-width: 0 2px 2px 0; transform: rotate(45deg); }
/* Mandatory photos (clean cal + dimension-overlay): the keep checkbox is
checked AND disabled. A bold padlock glyph in the box signals "required —
cannot be removed", and the card carries a thin required outline so the
owner can tell mandatory photos apart from optional batch photos. */
.pcard.required { border-color: #00e5ff55; }
.pcard.required .ph-keep { background: #00e5ff; border-color: #00e5ff; }
/* Padlock: shackle (arc) + body, drawn large enough to read on a 16px box. */
.pcard.required .ph-keep::after {
content: ''; position: absolute; left: 3px; top: 2px; width: 8px; height: 8px;
border: 2px solid #002; border-bottom: none; border-radius: 4px 4px 0 0;
}
.pcard.required .ph-keep::before {
content: ''; position: absolute; left: 2.5px; top: 6px; width: 8px; height: 6px;
background: #002; border-radius: 1px;
}
.pcard.required { cursor: default; }
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; }
</style> </style>
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
keptInOrder,
moveItem,
missingMinimums,
lineupValid,
roleLabel,
resolvePhotoUrl,
type LineupItem,
} from './lineup';
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
id,
role,
isCalibration: role === 'calibration',
kept,
});
test('lineup: keptInOrder returns kept photos in their current order', () => {
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
});
test('lineup: leftmost kept is the primary', () => {
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
// After a drag the owner re-ordered; primary follows position, not role.
assert.equal(keptInOrder(items)[0].id, 'b1');
});
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
assert.equal(keptInOrder(items)[0].id, 'ov');
});
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
const a = ['x', 'y', 'z'];
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
// Out-of-range is a no-op, never throws.
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
// Returns a new array, does not mutate the input.
const before = a.slice();
moveItem(a, 0, 2);
assert.deepEqual(a, before);
});
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
});
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
assert.equal(lineupValid([p('b1', 'batch')]), false);
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
});
test('lineup: missingMinimums only counts KEPT photos', () => {
// Both roles present in the array, but the cal is not kept.
assert.deepEqual(
missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]),
['calibration'],
);
});
test('lineup: roleLabel is owner-facing', () => {
assert.match(roleLabel('calibration'), /clean/);
assert.match(roleLabel('overlay'), /overlay/);
});
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => {
// The first/Primary (calibration) photo carries a fresh capture blob. It must
// become a valid object URL — never a raw Blob, never String(blob)="object Blob".
const blob = new Blob(['png-bytes'], { type: 'image/png' });
const fake = (b: Blob) => `blob:fake-${b.size}`;
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
// Empty/absent blob falls back to the stored endpoint.
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
});
+82
View File
@@ -0,0 +1,82 @@
// #90 Review photo strip (REVIEW-5 / PUBLISH-45). Pure logic split out so the
// keep/remove + reorder + minimum enforcement is unit-testable without a browser.
//
// Model (approved in the wireframe): the owner taps a photo to keep/remove it,
// drags to reorder, and the LEFTMOST kept photo is the primary (Woo cover). The
// submission-ready minimum is at least one clean (calibration) photo AND one
// dimension-overlay copy (REVIEW-5 "clean first pic plus its dimension-overlay
// copy"). Batch photos are optional.
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
export interface LineupItem {
id: string;
role: PhotoRole;
isCalibration: boolean;
/** True = the owner keeps this photo in the submission. */
kept: boolean;
}
/**
* The kept photos in their current (left→right) order. The first kept photo is
* the primary (Woo cover). Reordering is a plain array move; this helper just
* exposes the kept subset so the caller can re-seq on persist.
*/
export function keptInOrder(items: LineupItem[]): LineupItem[] {
return items.filter((it) => it.kept);
}
/** Move an item from one index to another (drag-reorder). Returns a new array. */
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
return arr;
}
const next = arr.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/**
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
* clean calibration photo and one dimension-overlay copy. Returns the missing
* role(s) so the UI can name them in a nudge.
*/
export function missingMinimums(items: LineupItem[]): PhotoRole[] {
const kept = items.filter((it) => it.kept);
const missing: PhotoRole[] = [];
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
return missing;
}
/** True when the kept lineup is publishable from a photo standpoint. */
export function lineupValid(kept: LineupItem[]): boolean {
return missingMinimums(kept).length === 0;
}
/** A short owner-facing label for a role (used in the minimum nudge). */
export function roleLabel(role: PhotoRole): string {
if (role === 'calibration') return 'a clean photo';
if (role === 'overlay') return 'its dimension-overlay copy';
return 'a batch photo';
}
/**
* Resolve the <img> src for a strip photo. The in-memory capture blob (fresh
* photo) must become a `blob:` object URL — assigning a raw Blob (or
* `String(blob)` = "object Blob") to `src` renders a broken image. That was
* bug #1: the first/Primary (calibration) photo is the one carrying a blob.
* When there is no usable blob, fall back to the stored photo endpoint.
*
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
* is unit-testable without a real DOM/Blob.
*/
export function resolvePhotoUrl(
blob: Blob | null,
endpoint: string,
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
): string {
if (blob && blob.size > 0) return createObjectUrl(blob);
return endpoint;
}
@@ -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}`;
}
+173 -130
View File
@@ -53,7 +53,16 @@ const TAXONOMY = {
}, },
{ {
id: 38, name: 'Thickness', slug: 'thickness', 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', id: 40, name: 'Moisture', slug: 'moisture',
@@ -109,7 +118,7 @@ function baseSlab(): Record<string, unknown> {
edge_type_term_id: null, edge_type_term_id: null,
figure_term_ids: [], figure_term_ids: [],
grade_term_id: null, grade_term_id: null,
thickness_term_id: null, thickness_term_id: 352,
moisture_term_id: null, moisture_term_id: null,
fig_tag_ids: [], fig_tag_ids: [],
feat_tag_ids: [], feat_tag_ids: [],
@@ -148,8 +157,13 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
const calls = hooks.calls ?? { call1: 0, call2: 0 }; const calls = hooks.calls ?? { call1: 0, call2: 0 };
const slab = baseSlab(); const slab = baseSlab();
let primary = null as number | null; let primary = null as number | null;
const reviewPrimaries: number[] = [];
const merge = (payload: Record<string, unknown>) => { const merge = (payload: Record<string, unknown>) => {
// Respect optimistic concurrency: reject stale client_rev (like the real backend).
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) {
return false; // stale — do NOT merge
}
for (const k of [ for (const k of [
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids', 'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids', 'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
@@ -164,6 +178,17 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id; if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
if (typeof payload.price === 'number') slab.price = payload.price; if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku; if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
// #89: the review ALWAYS returns the deterministic title/short_description built
// from the current metadata (the Create button's source of truth). The server
// does NOT persist title/short_description from the review payload.
const hasSpecies = !!slab.species_id;
const hasWoodCat = Array.isArray(slab.wood_category_ids) && slab.wood_category_ids.length > 0;
const deterministicTitle =
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab' : null;
const deterministicShort =
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab, 4ft 0in x 11in x 2in, 12.5 bdft' : null;
const missing: string[] = []; const missing: string[] = [];
if (!slab.sku) missing.push('sku'); if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species'); if (!slab.species_id) missing.push('species');
@@ -173,8 +198,8 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
return { return {
ready: missing.length === 0, ready: missing.length === 0,
missing, missing,
title: (slab.title as string) ?? null, title: deterministicTitle,
short_description: (slab.short_description as string) ?? null, short_description: deterministicShort,
price: (slab.price as number) ?? null, price: (slab.price as number) ?? null,
price_suggestion: 120, price_suggestion: 120,
primary_wood_category_id: primary, primary_wood_category_id: primary,
@@ -207,7 +232,10 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
return; return;
} }
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') { if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
merge(payload); if (merge(payload) === false) {
await json(route, { error: { code: 'revision_conflict', message: 'client_rev mismatch', detail: { current_server_rev: slab.server_rev } } }, 409);
return;
}
await json(route, slab); await json(route, slab);
return; return;
} }
@@ -229,7 +257,11 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
return; return;
} }
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') { 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; return;
} }
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') { if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
@@ -251,7 +283,7 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
} }
await json(route, { error: { code: 'not_found', message: path } }, 404); 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 // Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and
@@ -284,6 +316,12 @@ async function completeForReady(page: Page) {
await page.getByTestId('wood-cat-done').click(); await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31'); await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120'); await page.getByTestId('price').fill('120');
// #89: fire the deterministic build so the title/short_description populate.
// Do this BEFORE the SKU blur so a subsequent plain review doesn't race.
await expect(page.getByTestId('create-title')).toBeEnabled();
await page.getByTestId('create-title').click();
// Wait for the build to complete (title field populated = deterministic copy).
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001'); await page.getByTestId('sku').fill('SLAB-0001');
// SKU commits on blur — press Tab to move focus off it (deterministic, single // SKU commits on blur — press Tab to move focus off it (deterministic, single
// commit; a body click can re-focus the SKU and re-fire blur). // commit; a body click can re-focus the SKU and re-fire blur).
@@ -292,12 +330,16 @@ async function completeForReady(page: Page) {
} }
test.describe('M3 Review→Publish flow (#77)', () => { test.describe('M3 Review→Publish flow (#77)', () => {
test('golden path: capture→review→ready→publish→done', async ({ page }) => { test('golden path: capture→review→create→ready→publish→done (#77, #89)', async ({ page }) => {
await mockApi(page); const { slab } = await mockApi(page);
await reachReview(page); await reachReview(page);
// Not ready until fields are set. // Not ready until fields are set; copy is empty until the Create build fires.
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/); await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
await expect(page.getByTestId('title')).toHaveValue('');
await completeForReady(page); await completeForReady(page);
// #89: the Create build populated the deterministic copy on the slab row.
expect(slab.title).toBeTruthy();
expect(slab.short_description).toBeTruthy();
// Continue to Publish is enabled and hands off. // Continue to Publish is enabled and hands off.
await expect(page.getByTestId('continue-publish')).toBeEnabled(); await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click(); await page.getByTestId('continue-publish').click();
@@ -309,135 +351,136 @@ test.describe('M3 Review→Publish flow (#77)', () => {
await expect(page.getByTestId('new-slab')).toBeVisible(); await expect(page.getByTestId('new-slab')).toBeVisible();
}); });
test('inference off: no inference calls are made', async ({ page }) => { test('wood categories: closed summary shows names; primary radio among selected (#90, REVIEW-15)', async ({ page }) => {
const { calls } = await mockApi(page, { settings: { inference_enabled: false } }); const { reviewPrimaries } = await mockApi(page);
await reachReview(page); await reachReview(page);
await completeForReady(page);
// No "Suggest with AI" control when inference is disabled, and zero hits. // Open wood MultiSelect, pick Live edge + Bowl blanks, Done.
await expect(page.getByTestId('run-inference')).toHaveCount(0); await page.getByTestId('wood-cat-btn').click();
await expect(page.getByTestId('generate-text')).toHaveCount(0); await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
expect(calls.call1).toBe(0); await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
expect(calls.call2).toBe(0); 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('inference on: Suggest with AI prefills + AI badges; Generate text writes', async ({ page }) => { test('thickness band is server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
await mockApi(page, { await mockApi(page);
settings: { inference_enabled: true, content_llm_enabled: true },
onCall1: async (route) => {
await json(route, {
ok: true,
suggestions: {
species_id: SPECIES_ID,
species_confidence: 0.9,
wood_category_ids: [LIVEEDGE_ID],
edge_type_term_id: 31,
figure_term_ids: [34, 35],
grade_term_id: 37,
},
});
},
onCall2: async (route) => {
await json(route, { ok: true, description: 'AI-generated slab story.' });
},
});
await reachReview(page); await reachReview(page);
await page.getByTestId('run-inference').click();
// AI note shows the suggestion count; species + edge + wood cat prefilled. // METADATA-15 / QUALITY-95: the band is derived server-side from the measured
await expect(page.getByTestId('ai-note')).toContainText(/AI suggested/i, { timeout: 15000 }); // thickness against the live synced Woo bands. It is deliberately ABSENT from
await expect(page.getByTestId('species')).toHaveValue(String(SPECIES_ID)); // this screen — no owner control, no read-only echo. The only thickness the
// Generate text (call2) fills the description. // owner sees is the measured value in the summary. (Commit 204bc5e dropped the
await page.getByTestId('generate-text').click(); // read-only echo; this test asserts the omission.)
await expect(page.getByTestId('desc')).toHaveValue('AI-generated slab story.', { timeout: 15000 }); await expect(page.getByTestId('thickness-band')).toHaveCount(0);
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
}); });
test('fig-*/feat-* tag pickers render (optional) and persist to the slab', async ({ page }) => { /**
const { slab } = await mockApi(page); * #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); await reachReview(page);
// Both pickers render from the synced tag families (METADATA-30 / METADATA-40). // Put the surface in its real state: two wood categories selected, primary set.
await expect(page.getByTestId('fig-tag-btn')).toBeVisible(); await page.getByTestId('wood-cat-btn').click();
await expect(page.getByTestId('feat-tag-btn')).toBeVisible(); await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
// feat-* carries the "AI assist" tag (Call 1 proposes feature tags); fig-* does not. await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
// Scope to the field label (getByText would also match the MultiSelect's inner label). await page.getByTestId('wood-cat-done').click();
await expect(page.locator('label', { hasText: 'feat-* tags' })).toContainText('AI assist'); await expect(page.getByTestId('primary-wood-set')).toBeVisible();
await expect(page.locator('label', { hasText: 'fig-* tags' })).not.toContainText('AI assist');
// Select one fig tag and one feat tag. const metrics = await page.evaluate(() => {
await page.getByTestId('fig-tag-btn').click(); const de = document.documentElement;
await page.getByTestId(`fig-tag-opt-${FIG_TAG_ID}`).click(); const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
await page.getByTestId('fig-tag-done').click(); const box = (sel: string) => {
await expect(page.getByTestId('fig-tag-btn')).toContainText('1 selected'); const el = q(sel);
await page.getByTestId('feat-tag-btn').click(); if (!el) return null;
await page.getByTestId(`feat-tag-opt-${FEAT_TAG_ID}`).click(); const r = el.getBoundingClientRect();
await page.getByTestId('feat-tag-done').click(); const cs = getComputedStyle(el);
await expect(page.getByTestId('feat-tag-btn')).toContainText('1 selected'); return {
// Flush the tag ids to the slab: an on:change field (grade) triggers w: Math.round(r.width), h: Math.round(r.height),
// commitReview, which PUTs fig_tag_ids/feat_tag_ids with the current values. fontSize: cs.fontSize,
await page.getByTestId('grade').selectOption('37'); overflowX: el.scrollWidth > el.clientWidth + 1,
// Tags are optional — they are not in the server ready-set, so readiness is text: (el.textContent || '').trim().slice(0, 80),
// unchanged (still "Not ready" because the other required fields are empty). };
await expect(page.getByTestId('ready-status')).toContainText('Not ready'); };
// The commit PUT folded the tag ids into the slab row (what Publish will send). const primaryRow = q('[data-testid^="primary-wood-"]');
expect(slab.fig_tag_ids).toEqual([FIG_TAG_ID]); const primaryRowBox = primaryRow ? primaryRow.getBoundingClientRect() : null;
expect(slab.feat_tag_ids).toEqual([FEAT_TAG_ID]); // 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,
createBtn: box('[data-testid="create-title"]'),
continueBtn: box('[data-testid="continue-publish"]'),
};
}); });
test('publish failure: duplicate SKU offers edit + open existing', async ({ page }) => { const shot = `tests/e2e/artifacts/review-90-${vp.name}-${vp.w}.png`;
await mockApi(page, { await page.screenshot({ path: shot, fullPage: true });
onPublish: async (route) => { report.push(`${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
await json(route, { // Print before asserting so the measurement survives a failing gate.
error: { code: 'duplicate_sku', message: 'SKU already exists', detail: {} }, console.log(`RENDER ${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
}, 409);
},
});
await reachReview(page);
await completeForReady(page);
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
await page.getByTestId('publish-btn').click();
await expect(page.getByTestId('dup-sku')).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('edit-sku')).toBeVisible();
await expect(page.getByTestId('open-existing')).toBeVisible();
});
test('publish failure: recoverable Woo error offers retry', async ({ page }) => { // Layout gates: nothing spills sideways, the closed summary names both picks
await mockApi(page, { // (CSS ellipsis may shorten the paint, but the text must be there), and the
onPublish: async (route) => { // primary rows stay a comfortable tap target on mobile.
await json(route, { expect(metrics.pageHasHScroll, `${vp.name} horizontal overflow: ${metrics.docScrollW} > ${metrics.docClientW}`).toBe(false);
error: { expect(metrics.summary?.text).toMatch(/Live edge/i);
code: 'woo_unreachable', expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
message: 'Store is unreachable', expect(metrics.count?.text).toContain('2 selected');
detail: { recoverable: true, partial_media: false }, expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
}, // Band is deliberately absent from Review (METADATA-15) — no element to measure.
}, 502); expect(metrics.band).toBeNull();
}, expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
}); }
await reachReview(page); console.log('RENDER REPORT\n' + report.join('\n'));
await completeForReady(page);
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
await page.getByTestId('publish-btn').click();
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('store-failure')).toContainText('Store is unreachable');
await expect(page.getByTestId('retry-publish')).toBeVisible();
});
test('publish failure: partial media is flagged for manual cleanup', async ({ page }) => {
await mockApi(page, {
onPublish: async (route) => {
await json(route, {
error: {
code: 'woo_partial_media',
message: 'Product created, photos failed',
detail: { recoverable: true, partial_media: true },
},
}, 502);
},
});
await reachReview(page);
await completeForReady(page);
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
await page.getByTestId('publish-btn').click();
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('store-failure')).toContainText(/orphan media/i);
}); });
}); });
@@ -0,0 +1,305 @@
import { test, expect, type Page, type Route } from '@playwright/test';
import { seedMeasureDraft } from './helpers/draft';
/**
* M3 Review photo strip E2E (issue #90, REVIEW-5 / PUBLISH-45). Drives the
* capture→review flow (mocked API, no live backend) and exercises the photo
* strip: render from stored photos, keep/remove (whole-card tap), drag-reorder,
* leftmost-kept = primary, the clean+overlay minimum, and persistLineup on
* "Continue to Publish". All ids are synthetic fixtures (QUALITY-105).
*/
const SLAB_ID = '90e00000-0000-4000-8000-000000000090';
const SPECIES_ID = 11;
const WCAT_ID = 20;
const LIVEEDGE_ID = 21;
const TAXONOMY = {
categories: [
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
{ id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: 10 },
{ id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null },
{ id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID },
],
attributes: [
{ id: 30, name: 'Edge Type', slug: 'edge_type', terms: [{ id: 31, name: 'Live edge', slug: 'live-edge' }] },
],
tags: [],
taxonomy_anchor: 'fixture',
taxonomy_last_sync_at: '2026-01-01T00:00:00',
};
// The stored lineup: clean cal (seq 1), dimension-overlay (seq 2), batch (seq 3).
const PHOTO_ROWS = [
{ id: 'ph-cal', seq: 1, is_calibration: 1, role: 'calibration', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
{ id: 'ph-ov', seq: 2, is_calibration: 0, role: 'overlay', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
{ id: 'ph-b1', seq: 3, is_calibration: 0, role: 'batch', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
];
function baseSlab(): Record<string, unknown> {
return {
id: SLAB_ID, sku: '', status: 'draft', price: null, price_source: 'recommendation',
length_in: 48, thickness_in: 2, sqft: 6.25, bdft: 12.5, width_min_in: 10, width_max_in: 12, width_avg_in: 11,
species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null,
thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
title: null, short_description: null, description: null, content_source: 'manual',
species_confidence: null, figure_confidence: null, grade_confidence: null,
inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
};
}
async function json(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
}
interface PersistRecord {
seq: number; role: string; is_calibration: string;
}
async function mockApi(page: Page) {
const slab = baseSlab();
const photoStore = new Map<string, typeof PHOTO_ROWS[number]>();
for (const r of PHOTO_ROWS) photoStore.set(r.id, { ...r });
const persistCalls: { photoId: string; seq: number; role: string }[] = [];
const deletes: string[] = [];
const merge = (payload: Record<string, unknown>) => {
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false;
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'edge_type_term_id']) {
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
}
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
};
const reviewResult = (payload: Record<string, unknown>) => {
if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
const missing: string[] = [];
if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species');
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
if (!slab.edge_type_term_id) missing.push('edge type');
if (!slab.title) missing.push('title');
if (!slab.short_description) missing.push('short description');
if (slab.price == null) missing.push('price');
// Deterministic copy from the server on lock (REVIEW-15) — set before ready.
if (!slab.title) slab.title = 'Cedar · 48″ Live Edge';
if (!slab.short_description) slab.short_description = 'Hand-cut slab, ready to ship.';
return {
ready: missing.length === 0, missing,
title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null,
price: (slab.price as number) ?? null, price_suggestion: 120,
primary_wood_category_id: null,
};
};
await page.route('**/api/v1/**', async (route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const method = req.method();
const payload = (() => { try { return req.postDataJSON() as Record<string, unknown>; } catch { return {}; } })();
if (path === '/api/v1/settings' && method === 'GET') {
return json(route, { woo_base_url: 'https://example.invalid', woo_wp_username: 'wp', woo_app_password: { configured: true }, inference_base_url: { configured: false }, inference_api_key: { configured: false }, inference_model: null, inference_enabled: false, content_llm_enabled: false, brand_voice: null, geo_context: null, woo_create_status: 'draft', taxonomy_last_sync_at: '2026-01-01T00:00:00', taxonomy_anchor: 'fixture' });
}
if (path === '/api/v1/admin/taxonomy' && method === 'GET') return json(route, TAXONOMY);
if (path === '/api/v1/slabs' && method === 'POST') {
slab.id = String(payload.id ?? SLAB_ID);
merge(payload);
return json(route, slab, 201);
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
if (merge(payload) === false) return json(route, { error: { code: 'revision_conflict', message: 'rev', detail: {} } }, 409);
return json(route, slab);
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') return json(route, slab);
// --- Photos (the strip) ---
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
return json(route, [...photoStore.values()].sort((a, b) => a.seq - b.seq));
}
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
// Multipart; pull the photo_id / seq / role form fields out of the body.
const raw = req.postData() ?? '';
const field = (name: string): string => {
const re = new RegExp(`name="${name}"\\r\\n\\r\\n([\\s\\S]*?)(?=$|--)`);
const m = raw.match(re);
return m ? m[1].trim() : '';
};
const photoId = field('photo_id');
const seq = Number(field('seq'));
const role = field('role') || 'batch';
// Capture-time uploads (photo_id not in the fixture store, e.g. "photo-1")
// are accepted but not added to the lineup — the strip is driven by the
// stored rows only. Review-strip re-uploads reference a known fixture id.
if (!photoStore.has(photoId)) {
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
}
persistCalls.push({ photoId, seq, role });
const existing = photoStore.get(photoId);
if (existing) {
existing.seq = seq;
existing.role = role as typeof existing.role;
}
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
}
const delMatch = path.match(new RegExp(`^/api/v1/slabs/${slab.id}/photos/([^/]+)$`));
if (delMatch && method === 'DELETE') {
const id = delMatch[1];
if (photoStore.delete(id)) { deletes.push(id); return route.fulfill({ status: 204, body: '' }); }
return json(route, { error: { code: 'not_found', message: 'Photo not found', detail: null } }, 404);
}
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') return json(route, reviewResult(payload));
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
return json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
}
return json(route, { error: { code: 'not_found', message: path } }, 404);
});
return { persistCalls, deletes, photoStore };
}
async function reachReview(page: Page) {
await page.goto('/?noload');
await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11 });
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 });
await page.getByTestId('continue-to-review').click();
await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 });
}
async function completeForReady(page: Page) {
await page.getByTestId('species').selectOption(String(SPECIES_ID));
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
await expect(page.getByTestId('create-title')).toBeEnabled();
await page.getByTestId('create-title').click();
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001');
await page.getByTestId('sku').press('Tab');
}
test.describe('M3 Review photo strip (#90)', () => {
test('renders stored photos; leftmost kept = primary; tap keep/remove; drag reorder; minimum; persist', async ({ page }) => {
const mock = await mockApi(page);
await reachReview(page);
// Strip renders the 3 stored photos in stored seq order.
const cards = page.getByTestId('photo-card');
await expect(cards).toHaveCount(3);
// Leftmost (cal, seq 1) is the primary.
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
await expect(page.getByText('PRIMARY', { exact: true })).toHaveCount(1);
// Bug #1 regression: the primary photo's <img src> must be a valid URL
// (blob: for an in-memory capture blob), never empty or "object Blob".
const primaryImg = cards.nth(0).locator('img');
await expect(primaryImg).not.toHaveAttribute('src', /object Blob|^$/);
const src = await primaryImg.getAttribute('src');
expect(src && src.length > 4).toBeTruthy();
// Bug #2 regression: the card carries the gray checkerboard background
// (matching the Capture lineup), not a flat color.
const bgImage = await cards.nth(0).evaluate((el) => getComputedStyle(el).backgroundImage);
expect(bgImage).toContain('45deg');
// Mandatory photos: the clean calibration (seq 1) and the dimension-overlay
// copy (seq 2) are required — their keep checkbox is checked AND disabled,
// and a REQUIRED tag marks them.
await expect(cards.nth(0)).toHaveAttribute('data-required', 'true');
await expect(cards.nth(1)).toHaveAttribute('data-required', 'true');
await expect(cards.nth(2)).toHaveAttribute('data-required', '');
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
await expect(cards.nth(1)).toHaveAttribute('aria-pressed', 'true');
await expect(cards.nth(0)).toHaveAttribute('aria-disabled', 'true');
await expect(cards.nth(1)).toHaveAttribute('aria-disabled', 'true');
await expect(cards.nth(2)).toHaveAttribute('aria-disabled', 'false');
// Two REQUIRED tags (cal + overlay), none on the batch photo.
await expect(page.getByText('REQUIRED', { exact: true })).toHaveCount(2);
// Tapping a mandatory photo is a no-op: it stays checked + primary (can't remove).
// The card is aria-disabled, so force the click to exercise the handler directly.
await cards.nth(0).click({ force: true });
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
// No minimum nudge — both required photos are kept by construction.
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Remove the OPTIONAL batch photo (index 2) → it un-keeps; required stay kept.
await cards.nth(2).click();
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
// Primary is still the leftmost required (cal, index 0).
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
// Re-add the batch photo.
await cards.nth(2).click();
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Drag the batch photo (index 2) to the front → it becomes the primary.
await cards.nth(2).scrollIntoViewIfNeeded();
await page.waitForTimeout(120);
const first = await cards.nth(0).boundingBox();
const third = await cards.nth(2).boundingBox();
if (first && third) {
const sx = third.x + third.width / 2, sy = third.y + third.height / 2;
const dx = first.x + first.width / 2, dy = first.y + first.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// Several small steps so the HTML5 DnD picks up the drag.
for (let i = 1; i <= 14; i++) {
await page.mouse.move(sx + (dx - sx) * (i / 14), sy + (dy - sy) * (i / 14));
}
await page.waitForTimeout(60);
await page.mouse.up();
}
// After reorder the batch is leftmost = primary; seq badges renumber.
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
// Fill fields to ready, then Continue → persistLineup runs (re-seq kept, drop none).
await completeForReady(page);
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
// Persisted: 3 re-uploads (re-seq 1..3), no deletes (nothing was removed in final state).
expect(mock.persistCalls.length).toBe(3);
const seqs = mock.persistCalls.map((c) => c.seq).sort((a, b) => a - b);
expect(seqs).toEqual([1, 2, 3]);
});
test('required photos guarantee the minimum; removing an OPTIONAL batch persists a DELETE', async ({ page }) => {
const mock = await mockApi(page);
await reachReview(page);
const cards = page.getByTestId('photo-card');
// The clean calibration + dimension-overlay are mandatory and always kept,
// so the submission minimum can never be broken — publish is never lineup-blocked.
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Removing an OPTIONAL batch photo is allowed and persists a DELETE.
await cards.nth(2).click();
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
// Still no nudge: both required photos remain kept.
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Complete the rest of the form; the lineup is valid (required photos kept).
await completeForReady(page);
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
// The removed batch photo persisted as a DELETE; the required photos re-uploaded.
expect(mock.deletes).toEqual(['ph-b1']);
});
});