Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63b8879720 | |||
| 0c00d25cc7 | |||
| 96a697a50a | |||
| 67aa3d6b0d | |||
| 8ee3da9dcf | |||
| 1a50fad0a5 | |||
| e61303e2d8 | |||
| bdd03afce4 | |||
| e5acef4c9b | |||
| 204bc5e960 | |||
| 191b507934 | |||
| 0329052d37 | |||
| e1b928ccbd | |||
| 76fcb36a2b | |||
| 5a1800df91 | |||
| cd326d1ec9 | |||
| 502314b338 | |||
| 0612ce66a6 | |||
| 7b7adbdb34 | |||
| d9af8420bc | |||
| fe82133ca4 | |||
| 693719e85f |
+24
-3
@@ -25,7 +25,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
||||
from . import settings as settings_mod
|
||||
|
||||
__version__ = "0.5.0-m3-s0"
|
||||
__version__ = "0.5.0-m3"
|
||||
|
||||
# --- Structured JSON logging (stdlib only) ---
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
||||
@@ -155,7 +155,11 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
|
||||
@app.get("/api/health")
|
||||
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 authenticated probe (signal only — store outage
|
||||
does not fail health).
|
||||
"""
|
||||
db_ok = probe_writable()
|
||||
img_ok = probe_images_writable()
|
||||
if not (db_ok and img_ok):
|
||||
@@ -164,10 +168,27 @@ async def health() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": __version__,
|
||||
"woo_reachable": False, # S0 stub; S4 will implement real check
|
||||
"woo_reachable": _probe_woo_reachable(),
|
||||
}
|
||||
|
||||
|
||||
def _probe_woo_reachable() -> bool:
|
||||
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
|
||||
|
||||
Storage health never depends on this result. Missing creds → False.
|
||||
"""
|
||||
try:
|
||||
base, username, enc_password = _woo_credentials()
|
||||
if not base or not username or not enc_password:
|
||||
return False
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
# One lightweight authenticated call (categories page 1).
|
||||
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
|
||||
return code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/v1/meta")
|
||||
async def meta() -> dict[str, Any]:
|
||||
"""Per API.md §3."""
|
||||
|
||||
@@ -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:
|
||||
conn = get_connection(db_path)
|
||||
schema_path = _get_schema_path()
|
||||
@@ -98,6 +113,10 @@ def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
||||
conn.executescript(schema)
|
||||
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
|
||||
conn.commit()
|
||||
# Safe to run every boot: no-op once the columns exist.
|
||||
with _lock:
|
||||
_migrate(conn)
|
||||
conn.commit()
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ CREATE TABLE IF NOT EXISTS slab_photos (
|
||||
slab_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
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,
|
||||
processed_blob BLOB,
|
||||
width_px INTEGER,
|
||||
|
||||
+25
-6
@@ -26,7 +26,7 @@ def _get_slab(conn, slab_id: str):
|
||||
|
||||
def _get_photo(conn, slab_id: str, photo_id: str):
|
||||
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=?",
|
||||
(photo_id, slab_id),
|
||||
).fetchone()
|
||||
@@ -44,6 +44,7 @@ async def upload_photo(
|
||||
photo_id: str = Form(...),
|
||||
seq: int | None = Form(None),
|
||||
is_calibration: int = Form(0),
|
||||
role: str = Form("batch"),
|
||||
original: UploadFile = File(...),
|
||||
processed: UploadFile | None = File(None),
|
||||
) -> dict:
|
||||
@@ -52,6 +53,8 @@ async def upload_photo(
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
if is_calibration not in (0, 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()
|
||||
proc_bytes = await processed.read() if processed is not None else None
|
||||
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))
|
||||
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, original_blob, processed_blob, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(photo_id, slab_id, seq_val, is_calibration, orig_bytes, proc_bytes, _now()),
|
||||
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
row = _get_photo(conn, slab_id, photo_id)
|
||||
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:
|
||||
@@ -108,9 +111,25 @@ async def list_photos(slab_id: str) -> list[dict]:
|
||||
if _get_slab(conn, slab_id) is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
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 "
|
||||
"FROM slab_photos WHERE slab_id=? ORDER BY seq",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
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)
|
||||
|
||||
+50
-30
@@ -16,7 +16,14 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .db import get_connection
|
||||
from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
|
||||
from .woo import (
|
||||
WooAuthError,
|
||||
WooClient,
|
||||
WooConnectionError,
|
||||
WooCredentialsMissing,
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
)
|
||||
from .review import compute_ready, _slab_dict
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
@@ -72,21 +79,24 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
||||
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
|
||||
if tags:
|
||||
payload["tags"] = [{"id": t} for t in tags]
|
||||
# Five locked attributes by synced term name.
|
||||
# Five locked attributes: resolve the attribute row from the synced cache by
|
||||
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
|
||||
# then fall back to case-insensitive name. Term *options* are always the
|
||||
# names of the selected synced terms.
|
||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
|
||||
built_attrs = []
|
||||
for field, attr_name, pos in (
|
||||
("edge_type_term_id", "Edge Type", 0),
|
||||
("figure_term_ids", "Figure", 1),
|
||||
("grade_term_id", "Grade", 2),
|
||||
("thickness_term_id", "Thickness", 3),
|
||||
("moisture_term_id", "Moisture", 4),
|
||||
for field, attr_slug, attr_name, pos in (
|
||||
("edge_type_term_id", "edge-type", "Edge Type", 0),
|
||||
("figure_term_ids", "pa_figure", "Figure", 1),
|
||||
("grade_term_id", "pa_grade", "Grade", 2),
|
||||
("thickness_term_id", "pa_thickness", "Thickness", 3),
|
||||
("moisture_term_id", "pa_moisture", "Moisture", 4),
|
||||
):
|
||||
raw = slab.get(field)
|
||||
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
|
||||
options = [terms[t]["name"] for t in term_ids if t in terms]
|
||||
attr_id = _attr_id_by_name(attrs, attr_name)
|
||||
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
|
||||
if attr_id and options:
|
||||
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
|
||||
if built_attrs:
|
||||
@@ -94,19 +104,35 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
||||
return payload
|
||||
|
||||
|
||||
def _attr_id_by_name(attrs: dict, name: str) -> int | None:
|
||||
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
|
||||
"""Match a locked attribute against the synced Woo set by slug, then name."""
|
||||
slug_l = slug.lower().lstrip()
|
||||
# Accept both "figure" and "pa_figure" style slugs from Woo.
|
||||
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
|
||||
for a in attrs.values():
|
||||
if a.get("name", "").lower() == name.lower():
|
||||
s = (a.get("slug") or "").lower()
|
||||
if s in slug_alts:
|
||||
return a["id"]
|
||||
name_l = name.lower()
|
||||
for a in attrs.values():
|
||||
if (a.get("name") or "").lower() == name_l:
|
||||
return a["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _classify(e: Exception) -> tuple[str, str, bool]:
|
||||
"""Return (code, plain message, recoverable)."""
|
||||
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
|
||||
if isinstance(e, WooCredentialsMissing):
|
||||
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
||||
if isinstance(e, WooAuthError):
|
||||
return "woo_auth", "The store rejected the credentials. Check the app password.", False
|
||||
if isinstance(e, WooForbiddenError):
|
||||
# WAF / security plugin block — retrying the same request will not help.
|
||||
return (
|
||||
"woo_forbidden",
|
||||
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
|
||||
False,
|
||||
)
|
||||
if isinstance(e, WooConnectionError):
|
||||
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
|
||||
return "woo_failed", "The store create failed. You can retry.", True
|
||||
@@ -150,9 +176,16 @@ async def publish(slab_id: str) -> dict:
|
||||
code, msg, rec = _classify(e)
|
||||
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
|
||||
# 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)
|
||||
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,),
|
||||
).fetchall()
|
||||
uploaded_ids: list[int] = []
|
||||
@@ -202,26 +235,13 @@ def _create_status(conn) -> str:
|
||||
|
||||
|
||||
def _sku_exists(client: WooClient, sku: str) -> bool:
|
||||
code, _h, body = client._request(
|
||||
f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
|
||||
"GET", None,
|
||||
{"Authorization": f"Basic {client._auth}", "Accept": "application/json"},
|
||||
)
|
||||
if code != 200:
|
||||
raise WooError(f"sku lookup returned {code}")
|
||||
return len(json.loads(body)) > 0
|
||||
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||
return len(client.get_products_by_sku(sku)) > 0
|
||||
|
||||
|
||||
def _create_product(client: WooClient, payload: dict) -> int:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
code, _h, resp = client._request(
|
||||
f"{client.base_url}/wp-json/wc/v3/products",
|
||||
"POST", body,
|
||||
{"Authorization": f"Basic {client._auth}", "Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooError(f"product create returned {code}")
|
||||
return int(json.loads(resp)["id"])
|
||||
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||
return client.create_product(payload)
|
||||
|
||||
|
||||
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
||||
|
||||
+82
-37
@@ -5,6 +5,7 @@ METADATA-80/85/90, SETTINGS-35.
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
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}}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _split_length(length_in: float) -> tuple[int, float]:
|
||||
ft = int(length_in // 12)
|
||||
rem = round(length_in - ft * 12, 1)
|
||||
@@ -66,14 +71,16 @@ def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
|
||||
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
|
||||
|
||||
|
||||
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
|
||||
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
|
||||
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
||||
)
|
||||
figure = figure.strip() if figure else ""
|
||||
live_edge = ""
|
||||
if primary_leaf["slug"] in ("dimensional-wood", "bowl-blanks"):
|
||||
live_edge = "Live Edge " if slab.get("edge_type_term_id") else ""
|
||||
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
|
||||
# "Live Edge" is a product-form flag driven by the *synced* edge term
|
||||
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
|
||||
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
|
||||
title = pattern.format(
|
||||
figure=figure + " " if figure else "",
|
||||
species=species_name,
|
||||
@@ -84,8 +91,17 @@ def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf:
|
||||
return re.sub(r"\s+", " ", title).strip()
|
||||
|
||||
|
||||
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
|
||||
title = build_title(slab, figure, species_name, primary_leaf)
|
||||
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
|
||||
"""True when the synced edge term name/slug indicates live edge."""
|
||||
if not edge_term_id:
|
||||
return False
|
||||
term = terms.get(edge_term_id) or {}
|
||||
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
|
||||
return "live" in blob
|
||||
|
||||
|
||||
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
title = build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
ft, rem = _split_length(slab["length_in"])
|
||||
length_str = f"{ft}ft {_fmt1(rem)}in"
|
||||
bdft = round(slab["bdft"], 1)
|
||||
@@ -142,6 +158,13 @@ def price_suggestion(conn, slab: dict) -> float | None:
|
||||
|
||||
@router.post("/{slab_id}/review")
|
||||
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
|
||||
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
|
||||
|
||||
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
|
||||
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
|
||||
win (METADATA-50). Assembled values are written back to the slab so the lock
|
||||
sticks for publish.
|
||||
"""
|
||||
payload = payload or {}
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
@@ -150,46 +173,68 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
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"}
|
||||
for k in editable:
|
||||
if k in payload and payload[k] is not None:
|
||||
if k == "primary_wood_category_id":
|
||||
continue # not stored as a column; derived
|
||||
slab[k] = payload[k]
|
||||
|
||||
# --- Deterministic review: report ready/missing + the deterministic
|
||||
# title/short_description built from the current metadata. The client's
|
||||
# "Create title" / "Create short desc" buttons call this endpoint and write the
|
||||
# returned strings into the fields (#89: no separate lock action).
|
||||
# #80: persist owner overrides (price/sku) from the payload so the review
|
||||
# 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)
|
||||
# Primary wood category: declared, else first wood_category_ids, else first category.
|
||||
primary_id = payload.get("primary_wood_category_id")
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
if not primary_id:
|
||||
primary_id = wood_cats[0] if wood_cats else None
|
||||
primary_leaf = cats.get(primary_id, {})
|
||||
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
|
||||
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
|
||||
species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") else ""
|
||||
figure_term_ids = slab.get("figure_term_ids") or []
|
||||
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
||||
|
||||
# Deterministic copy (only if not already overridden by owner).
|
||||
if not payload.get("title"):
|
||||
if primary_leaf and species_name:
|
||||
slab["title"] = build_title(slab, figure, species_name, primary_leaf)
|
||||
if not payload.get("short_description"):
|
||||
if primary_leaf and species_name:
|
||||
slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf)
|
||||
# Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks.
|
||||
suggested = price_suggestion(conn, slab)
|
||||
if payload.get("price") is not None:
|
||||
price = payload["price"]
|
||||
elif slab.get("price") not in (None, 0):
|
||||
price = slab["price"]
|
||||
else:
|
||||
price = suggested
|
||||
ready, missing = compute_ready({**slab, "price": price})
|
||||
# #80: persist owner overrides from the payload (price/sku only).
|
||||
overrides: dict[str, Any] = {}
|
||||
for k in ("price", "sku"):
|
||||
if k in payload and payload[k] is not None:
|
||||
overrides[k] = payload[k]
|
||||
if overrides:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in overrides)
|
||||
conn.execute(
|
||||
f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
|
||||
[*overrides.values(), _now(), slab_id],
|
||||
)
|
||||
conn.commit()
|
||||
slab.update(overrides)
|
||||
|
||||
# Deterministic title / short_description, re-derived from the current
|
||||
# metadata on every review (the Create button's source of truth).
|
||||
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
|
||||
title = (
|
||||
build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
if (primary_leaf and species_name)
|
||||
else slab.get("title") or None
|
||||
)
|
||||
short_desc = (
|
||||
build_short_description(slab, figure, species_name, primary_leaf, terms)
|
||||
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 {
|
||||
"ready": ready,
|
||||
"missing": missing,
|
||||
"title": slab.get("title"),
|
||||
"short_description": slab.get("short_description"),
|
||||
"price": price,
|
||||
"price_suggestion": suggested,
|
||||
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
|
||||
"price": price, "price_suggestion": price_suggestion(conn, slab),
|
||||
"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
|
||||
|
||||
+116
-16
@@ -13,6 +13,7 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from .db import get_connection
|
||||
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
|
||||
@@ -42,6 +43,20 @@ TAXONOMY_TABLES = {
|
||||
"fig_tag_ids": "woo_tags",
|
||||
"feat_tag_ids": "woo_tags",
|
||||
}
|
||||
# Owner-facing labels for stale_field messages (QUALITY-120 — never raw column names).
|
||||
FIELD_LABELS = {
|
||||
"species_id": "Species",
|
||||
"wood_category_ids": "Wood category",
|
||||
"edge_type_term_id": "Edge type",
|
||||
"figure_term_ids": "Figure",
|
||||
"grade_term_id": "Grade",
|
||||
"thickness_term_id": "Thickness",
|
||||
"moisture_term_id": "Moisture",
|
||||
"fig_tag_ids": "Figure tags",
|
||||
"feat_tag_ids": "Feature tags",
|
||||
}
|
||||
PRICE_SOURCES = {"recommendation", "override", "manual"}
|
||||
INFERENCE_STATUSES = {"idle", "inferring", "done", "failed", "skipped"}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
@@ -64,6 +79,12 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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]:
|
||||
d = dict(row)
|
||||
for f in JSON_ARRAY_FIELDS:
|
||||
@@ -95,6 +116,58 @@ def _check_taxonomy(conn, table: str, val: Any) -> bool:
|
||||
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
|
||||
|
||||
|
||||
def _current_options(conn, table: str) -> list[dict[str, Any]]:
|
||||
"""Live taxonomy options for a stale_field response (PUBLISH-20 / #82)."""
|
||||
rows = conn.execute(f"SELECT id, name FROM {table} ORDER BY name, id").fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
|
||||
|
||||
def _normalize_tax_val(val: Any) -> Any:
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return val
|
||||
|
||||
|
||||
def _validate_taxonomy_fields(conn, payload: Dict[str, Any]) -> JSONResponse | None:
|
||||
"""Return a 422 stale_field response, or None if all present taxonomy ids are live."""
|
||||
for field, table in TAXONOMY_TABLES.items():
|
||||
if field not in payload or payload[field] is None:
|
||||
continue
|
||||
val = _normalize_tax_val(payload[field])
|
||||
if not _check_taxonomy(conn, table, val):
|
||||
label = FIELD_LABELS.get(field, field)
|
||||
return _err(
|
||||
"stale_field",
|
||||
f"{label} is no longer in the store taxonomy",
|
||||
{"field": field, "current_options": _current_options(conn, table)},
|
||||
status=422,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_enums(payload: Dict[str, Any]) -> JSONResponse | None:
|
||||
if "price_source" in payload and payload["price_source"] is not None:
|
||||
if payload["price_source"] not in PRICE_SOURCES:
|
||||
return _err(
|
||||
"validation",
|
||||
"price_source is not a known value",
|
||||
{"field": "price_source", "allowed": sorted(PRICE_SOURCES)},
|
||||
status=400,
|
||||
)
|
||||
if "inference_status" in payload and payload["inference_status"] is not None:
|
||||
if payload["inference_status"] not in INFERENCE_STATUSES:
|
||||
return _err(
|
||||
"validation",
|
||||
"inference_status is not a known value",
|
||||
{"field": "inference_status", "allowed": sorted(INFERENCE_STATUSES)},
|
||||
status=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Capture-only fields required to create a slab. The Review screen fills the
|
||||
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
|
||||
# editable until create"), so create must accept a capture-math payload.
|
||||
@@ -110,15 +183,34 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
|
||||
if missing:
|
||||
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
|
||||
# "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.
|
||||
sku = payload["sku"]
|
||||
sku = payload.get("sku")
|
||||
if isinstance(sku, str) and sku.strip() == "":
|
||||
payload = {**payload, "sku": None}
|
||||
enum_err = _validate_enums(payload)
|
||||
if enum_err is not None:
|
||||
return enum_err
|
||||
conn = get_connection()
|
||||
if payload["sku"] is not None and _sku_taken(conn, payload["sku"]):
|
||||
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||
if tax_err is not None:
|
||||
return tax_err
|
||||
if payload.get("sku") is not None and _sku_taken(conn, payload["sku"]):
|
||||
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
|
||||
# Resolve it from the measured thickness against the live synced Woo bands and
|
||||
# overwrite whatever the client sent (the UI has no band control).
|
||||
band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn))
|
||||
if band is not None:
|
||||
payload = {**payload, "thickness_term_id": band}
|
||||
now = _now_iso()
|
||||
data = _encode(payload)
|
||||
allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
|
||||
@@ -129,8 +221,12 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
vals = [data[f] for f in allowed_cols] + server_vals
|
||||
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.commit()
|
||||
try:
|
||||
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# QUALITY-120: never surface a driver stack to the owner.
|
||||
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
|
||||
return _row_to_slab(row)
|
||||
|
||||
@@ -155,6 +251,13 @@ async def get_slab(id: str) -> Dict[str, Any]:
|
||||
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if "client_rev" not in payload:
|
||||
return _err("validation", "client_rev required", status=400)
|
||||
# #89: server-managed fields a plain PUT must not set.
|
||||
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)
|
||||
enum_err = _validate_enums(payload)
|
||||
if enum_err is not None:
|
||||
return enum_err
|
||||
conn = get_connection()
|
||||
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
||||
if current is None:
|
||||
@@ -164,19 +267,16 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return _err("revision_conflict", "client_rev mismatch",
|
||||
{"current_server_rev": current["server_rev"]}, status=409)
|
||||
if "sku" in payload and payload["sku"] != current["sku"]:
|
||||
if _sku_taken(conn, payload["sku"], exclude_id=id):
|
||||
if payload["sku"] is not None and _sku_taken(conn, payload["sku"], exclude_id=id):
|
||||
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||
for field, table in TAXONOMY_TABLES.items():
|
||||
if field in payload and payload[field] is not None:
|
||||
val = payload[field]
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
val = json.loads(val)
|
||||
except (ValueError, TypeError):
|
||||
val = []
|
||||
if not _check_taxonomy(conn, table, val):
|
||||
return _err("stale_field", f"{field} not in current taxonomy",
|
||||
{"current_options": val}, status=422)
|
||||
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||
if tax_err is not None:
|
||||
return tax_err
|
||||
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
|
||||
eff_thickness = payload.get("thickness_in", current["thickness_in"])
|
||||
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
|
||||
if band is not None:
|
||||
payload = {**payload, "thickness_term_id": band}
|
||||
data = _encode(payload)
|
||||
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
|
||||
if not cols:
|
||||
|
||||
@@ -34,13 +34,16 @@ PNG_ORIG = b"\x89PNG-orig-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")}
|
||||
if processed is not None:
|
||||
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(
|
||||
f"/api/v1/slabs/{slab_id}/photos",
|
||||
data={"photo_id": photo_id, "is_calibration": str(is_calibration)},
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
@@ -90,9 +93,43 @@ def test_reupload_replaces(photos_env):
|
||||
|
||||
def test_list_photos(photos_env):
|
||||
client, _ = photos_env
|
||||
_multipart(client, "s1", "pa", is_calibration=1)
|
||||
_multipart(client, "s1", "pb")
|
||||
_multipart(client, "s1", "pa", is_calibration=1, role="calibration")
|
||||
_multipart(client, "s1", "pb", role="overlay")
|
||||
r = client.get("/api/v1/slabs/s1/photos")
|
||||
assert r.status_code == 200
|
||||
ids = [row["id"] for row in r.json()]
|
||||
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
|
||||
|
||||
@@ -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_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)")
|
||||
# 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
|
||||
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"),))
|
||||
@@ -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
|
||||
|
||||
|
||||
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):
|
||||
client, conn = env
|
||||
s = _full_slab(sku="SLAB-UAT-1")
|
||||
@@ -182,3 +300,63 @@ def test_partial_media_failure_502(env, monkeypatch):
|
||||
# sync_log written, status failed
|
||||
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
|
||||
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
|
||||
|
||||
|
||||
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
|
||||
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
seen = []
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, b"[]"
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
from backend.woo import WooClient, _USER_AGENT
|
||||
|
||||
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
|
||||
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
|
||||
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
|
||||
assert sku_hops and create_hops
|
||||
for hop in sku_hops + create_hops:
|
||||
assert hop["headers"].get("User-Agent") == _USER_AGENT
|
||||
|
||||
|
||||
def test_publish_403_is_unrecoverable(env, monkeypatch):
|
||||
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
from backend.woo import WooClient, WooForbiddenError
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
raise WooForbiddenError("request forbidden (403)")
|
||||
|
||||
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 == 502
|
||||
body = r.json()
|
||||
assert body["error"]["code"] == "woo_forbidden"
|
||||
assert body["error"]["detail"]["recoverable"] is False
|
||||
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
|
||||
|
||||
@@ -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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("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()
|
||||
# #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={})
|
||||
assert r.status_code == 200, r.text
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("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()
|
||||
# #89: the deterministic build runs on every review.
|
||||
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)"
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -33,6 +33,23 @@ def _seed_taxonomy(conn):
|
||||
# tags
|
||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
|
||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
|
||||
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
|
||||
# live Woo attribute, because the derivation parses them.
|
||||
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
|
||||
for tid, name, slug in [
|
||||
(347, 'Under 1/2"', 'xthin'),
|
||||
(348, '1/2" - 3/4"', '2-3qtr'),
|
||||
(349, '3/4" - 1"', '3-4qtr'),
|
||||
(350, '1"- 1 1/2"', '4-6qtr'),
|
||||
(351, '1 1/2" - 2"', '6-8qtr'),
|
||||
(352, '2" - 3"', '8-12qtr'),
|
||||
(353, 'Over 3"', '12qtr'),
|
||||
]:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
|
||||
"VALUES (?, 2, ?, ?, ?)",
|
||||
(tid, name, slug, tid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@pytest.fixture
|
||||
@@ -103,6 +120,55 @@ def test_create_and_fetch(client, db):
|
||||
assert body[key] == payload[key]
|
||||
|
||||
|
||||
def test_create_derives_thickness_band(client, db):
|
||||
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
|
||||
|
||||
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
|
||||
the UI has no band control, so the server's derivation is authoritative.
|
||||
"""
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
"thickness_term_id": 1,
|
||||
}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
|
||||
|
||||
|
||||
def test_update_rebands_when_thickness_changes(client, db):
|
||||
"""A thickness edit moves the slab to the band the new measurement rounds into."""
|
||||
slab_id = str(uuid.uuid4())
|
||||
create = client.post("/api/v1/slabs", json={
|
||||
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
})
|
||||
assert create.status_code == 201
|
||||
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
|
||||
put = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
|
||||
})
|
||||
assert put.status_code == 200
|
||||
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
|
||||
|
||||
|
||||
def test_update_ignores_client_supplied_band(client, db):
|
||||
"""A stale client band cannot pin the slab to the wrong band."""
|
||||
slab_id = str(uuid.uuid4())
|
||||
create = client.post("/api/v1/slabs", json={
|
||||
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
})
|
||||
assert create.status_code == 201
|
||||
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
|
||||
put = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
|
||||
})
|
||||
assert put.status_code == 200
|
||||
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
|
||||
|
||||
|
||||
def test_duplicate_sku(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
@@ -260,3 +326,59 @@ def test_delete_published_conflict(client, db):
|
||||
conn.commit()
|
||||
resp = client.delete(f"/api/v1/slabs/{slab_id}")
|
||||
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 returns LIVE taxonomy options (id+name), not the submitted id.
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
}
|
||||
assert client.post("/api/v1/slabs", json=payload).status_code == 201
|
||||
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "edge_type_term_id": 999})
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["error"]["code"] == "stale_field"
|
||||
opts = body["error"]["detail"]["current_options"]
|
||||
assert isinstance(opts, list) and opts
|
||||
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
|
||||
assert 999 not in [o["id"] for o in opts]
|
||||
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_create_extra_key_and_bad_taxonomy_are_4xx(client, db):
|
||||
"""#83: server-managed/extra keys and bad taxonomy/enums are 4xx, not 500."""
|
||||
base = {
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-XTRA", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
"status": "published", # server-managed → unknown field 400
|
||||
}
|
||||
r = client.post("/api/v1/slabs", json=base)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "status" in r.json()["error"]["detail"]["fields"]
|
||||
# Bad taxonomy on create
|
||||
r2 = client.post("/api/v1/slabs", json={
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-BADTAX", "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": 999,
|
||||
})
|
||||
assert r2.status_code == 422
|
||||
assert r2.json()["error"]["code"] == "stale_field"
|
||||
# Bad enum
|
||||
r3 = client.post("/api/v1/slabs", json={
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-BADENUM", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "price_source": "not-a-source",
|
||||
})
|
||||
assert r3.status_code == 400
|
||||
assert r3.json()["error"]["code"] == "validation"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Thickness band resolution (METADATA-15 / QUALITY-95).
|
||||
|
||||
The owner never picks the Woo thickness band in the UI — the server derives it
|
||||
from the measured thickness_in against the live synced bands. These tests pin the
|
||||
derivation on the real live band names (from the UAT taxonomy) and on the
|
||||
quarter-inch slug fallback.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.thickness_band import (
|
||||
parse_inch_token,
|
||||
resolve_thickness_band_id,
|
||||
term_bounds,
|
||||
)
|
||||
|
||||
# The live UAT Thickness attribute (pa_thk) as synced: name, slug.
|
||||
LIVE_TERMS = [
|
||||
{"id": 347, "name": "Under 1/2\"", "slug": "xthin"},
|
||||
{"id": 348, "name": "1/2\" - 3/4\"", "slug": "2-3qtr"},
|
||||
{"id": 349, "name": "3/4\" - 1\"", "slug": "3-4qtr"},
|
||||
{"id": 350, "name": "1\"- 1 1/2\"", "slug": "4-6qtr"},
|
||||
{"id": 351, "name": "1 1/2\" - 2\"", "slug": "6-8qtr"},
|
||||
{"id": 352, "name": "2\" - 3\"", "slug": "8-12qtr"},
|
||||
{"id": 353, "name": "Over 3\"", "slug": "12qtr"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("1 1/2", 1.5),
|
||||
("3/4", 0.75),
|
||||
("2", 2.0),
|
||||
("1.75", 1.75),
|
||||
("2\"", 2.0),
|
||||
("1 1/2\"", 1.5),
|
||||
],
|
||||
)
|
||||
def test_parse_inch_token(text, expected):
|
||||
assert parse_inch_token(text) == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_parse_inch_token_rejects_junk():
|
||||
assert parse_inch_token("") is None
|
||||
assert parse_inch_token(" ") is None
|
||||
|
||||
|
||||
def test_term_bounds_from_live_names():
|
||||
assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5)
|
||||
assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75)
|
||||
assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5)
|
||||
assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf"))
|
||||
|
||||
|
||||
def test_term_bounds_falls_back_to_qtr_slug():
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5)
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf"))
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thickness,expected_id",
|
||||
[
|
||||
(0.25, 347), # Under 1/2"
|
||||
(0.5, 348), # boundary rounds UP into 1/2"-3/4", not down
|
||||
(0.625, 348),
|
||||
(0.75, 349), # boundary rounds UP
|
||||
(0.875, 349),
|
||||
(1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band
|
||||
(1.25, 350),
|
||||
(1.5, 351),
|
||||
(1.75, 351), # the UAT sample thickness
|
||||
(2.0, 352),
|
||||
(2.75, 352),
|
||||
(3.0, 353),
|
||||
(4.0, 353),
|
||||
],
|
||||
)
|
||||
def test_resolve_rounds_up_on_boundary(thickness, expected_id):
|
||||
assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id
|
||||
|
||||
|
||||
def test_resolve_handles_missing_inputs():
|
||||
assert resolve_thickness_band_id(None, LIVE_TERMS) is None
|
||||
assert resolve_thickness_band_id(-1, LIVE_TERMS) is None
|
||||
assert resolve_thickness_band_id(1.0, []) is None
|
||||
# A term with no parseable name/slug is skipped, never guessed at.
|
||||
assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None
|
||||
|
||||
|
||||
def test_resolve_is_id_order_independent():
|
||||
"""The result must not depend on the sync order of the terms."""
|
||||
assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
|
||||
|
||||
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
|
||||
synced band term. We map thickness_in into the first half-open band [low, high)
|
||||
that contains it, derived only from the live synced term names/slugs — never a
|
||||
hardcoded Woo term id.
|
||||
|
||||
Round-up semantics: bands are half-open on the upper bound, so a value that sits
|
||||
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
|
||||
value (1"–1 1/2"), not the band that ends there (3/4"–1").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
# Mixed number / fraction / decimal inch token, optional trailing quote.
|
||||
_INCH_RE = re.compile(
|
||||
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
|
||||
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
|
||||
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
|
||||
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_inch_token(text: str) -> Optional[float]:
|
||||
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
|
||||
t = (text or "").strip().rstrip("\"'").strip()
|
||||
if not t:
|
||||
return None
|
||||
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
|
||||
if not m:
|
||||
return None
|
||||
if m.group("a") is not None:
|
||||
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
|
||||
if m.group("n2") is not None:
|
||||
return float(m.group("n2")) / float(m.group("d2"))
|
||||
if m.group("dec") is not None:
|
||||
return float(m.group("dec"))
|
||||
return None
|
||||
|
||||
|
||||
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
|
||||
"""Return half-open [low, high) inches from a Woo thickness term name."""
|
||||
raw = (name or "").strip()
|
||||
low_s = raw.lower()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# Under X / less than X / < X
|
||||
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
|
||||
if m:
|
||||
hi = parse_inch_token(m.group(1))
|
||||
if hi is not None:
|
||||
return (0.0, hi)
|
||||
|
||||
# Over X / above X / > X / X+
|
||||
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
|
||||
if m:
|
||||
lo = parse_inch_token(m.group(1))
|
||||
if lo is not None:
|
||||
return (lo, float("inf"))
|
||||
m = re.match(r"^(.+?)\+\s*$", raw)
|
||||
if m:
|
||||
lo = parse_inch_token(m.group(1))
|
||||
if lo is not None:
|
||||
return (lo, float("inf"))
|
||||
|
||||
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
|
||||
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
lo = parse_inch_token(parts[0])
|
||||
hi = parse_inch_token(parts[1])
|
||||
if lo is not None and hi is not None and hi > lo:
|
||||
return (lo, hi)
|
||||
|
||||
# Single value band treated as [v, v+epsilon) — rare
|
||||
only = parse_inch_token(raw)
|
||||
if only is not None:
|
||||
return (only, only + 1e-9)
|
||||
return None
|
||||
|
||||
|
||||
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
|
||||
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
|
||||
s = (slug or "").strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if s in {"xthin", "under", "thin"}:
|
||||
return (0.0, 0.5)
|
||||
m = _QTR_RANGE_RE.match(s)
|
||||
if m:
|
||||
lo = int(m.group(1)) * 0.25
|
||||
hi = int(m.group(2)) * 0.25
|
||||
if hi > lo:
|
||||
return (lo, hi)
|
||||
m = _QTR_MIN_RE.match(s)
|
||||
if m:
|
||||
lo = int(m.group(1)) * 0.25
|
||||
return (lo, float("inf"))
|
||||
return None
|
||||
|
||||
|
||||
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
|
||||
"""Bounds for one synced thickness term; name first, slug fallback."""
|
||||
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
|
||||
str(term.get("slug") or "")
|
||||
)
|
||||
|
||||
|
||||
def resolve_thickness_band_id(
|
||||
thickness_in: float | int | None,
|
||||
terms: Iterable[dict[str, Any]],
|
||||
) -> Optional[int]:
|
||||
"""Pick the synced thickness term id for thickness_in, or None if unknown.
|
||||
|
||||
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
|
||||
"""
|
||||
if thickness_in is None:
|
||||
return None
|
||||
try:
|
||||
t = float(thickness_in)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if t < 0:
|
||||
return None
|
||||
|
||||
scored: list[tuple[float, float, int]] = [] # (low, high, id)
|
||||
for term in terms:
|
||||
b = term_bounds(term)
|
||||
if b is None:
|
||||
continue
|
||||
tid = term.get("id")
|
||||
if tid is None:
|
||||
continue
|
||||
lo, hi = b
|
||||
scored.append((lo, hi, int(tid)))
|
||||
|
||||
if not scored:
|
||||
return None
|
||||
|
||||
# Prefer the tightest band that contains t under half-open [low, high).
|
||||
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
|
||||
if matches:
|
||||
matches.sort() # tightest span first
|
||||
return matches[0][3]
|
||||
|
||||
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
|
||||
scored.sort(key=lambda x: x[0])
|
||||
for lo, hi, tid in scored:
|
||||
if t < hi:
|
||||
return tid
|
||||
return scored[-1][2]
|
||||
|
||||
|
||||
def load_thickness_terms(conn) -> list[dict[str, Any]]:
|
||||
"""Live Thickness attribute terms from the synced Woo cache."""
|
||||
row = conn.execute(
|
||||
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
|
||||
("thickness", "thickness"),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return []
|
||||
attr_id = row[0]
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
|
||||
(attr_id,),
|
||||
).fetchall()
|
||||
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]
|
||||
+38
-13
@@ -98,14 +98,7 @@ class WooClient:
|
||||
|
||||
def _get(self, path: str) -> tuple[int, dict, bytes]:
|
||||
url = f"{self.base_url}/wp-json/wc/v3{path}"
|
||||
return self._request(
|
||||
url, "GET", None,
|
||||
{
|
||||
"Authorization": f"Basic {self._auth}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _USER_AGENT,
|
||||
},
|
||||
)
|
||||
return self._request(url, "GET", None, self._auth_headers())
|
||||
|
||||
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
|
||||
page = 1
|
||||
@@ -132,6 +125,42 @@ class WooClient:
|
||||
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
|
||||
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
|
||||
|
||||
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""Shared headers for every outbound Woo/WP request (User-Agent + auth)."""
|
||||
h = {
|
||||
"Authorization": f"Basic {self._auth}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _USER_AGENT,
|
||||
}
|
||||
if extra:
|
||||
h.update(extra)
|
||||
return h
|
||||
|
||||
def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]:
|
||||
"""GET products filtered by SKU. Raises typed Woo* errors on non-200."""
|
||||
code, _h, body = self._request(
|
||||
f"{self.base_url}/wp-json/wc/v3/products?sku={sku}",
|
||||
"GET",
|
||||
None,
|
||||
self._auth_headers(),
|
||||
)
|
||||
if code != 200:
|
||||
raise WooServerError(f"sku lookup returned {code}")
|
||||
return list(json.loads(body))
|
||||
|
||||
def create_product(self, payload: dict[str, Any]) -> int:
|
||||
"""POST /products. Raises typed Woo* errors on failure; returns product id."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
code, _h, resp = self._request(
|
||||
f"{self.base_url}/wp-json/wc/v3/products",
|
||||
"POST",
|
||||
body,
|
||||
self._auth_headers({"Content-Type": "application/json"}),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooServerError(f"product create returned {code}")
|
||||
return int(json.loads(resp)["id"])
|
||||
|
||||
def upload_media(self, png_bytes: bytes, filename: str) -> int:
|
||||
"""POST a PNG to WordPress core media; return the attachment id."""
|
||||
boundary = "----slabuploader-media-boundary"
|
||||
@@ -143,11 +172,7 @@ class WooClient:
|
||||
url = f"{self.base_url}/wp-json/wp/v2/media"
|
||||
code, _headers, resp = self._request(
|
||||
url, "POST", body,
|
||||
{
|
||||
"Authorization": f"Basic {self._auth}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"User-Agent": _USER_AGENT,
|
||||
},
|
||||
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooServerError(f"media upload returned {code}")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Port block `10880`–`10889` on ubudual5060 is allocated to this project.
|
||||
`:10880` is Ty's local Node/SvelteKit and must never be bound by project deploys.
|
||||
The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can reach it.
|
||||
Check to see if you are running on ubudual5060 (192.168.1.202) before attempting SSH.
|
||||
|
||||
## Dev-test environment (M2 current, M3 incoming)
|
||||
|
||||
@@ -23,3 +24,96 @@ The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can
|
||||
Future deploys use a **separate** container name and a free port in `10880`–`10889`
|
||||
(other than `10880`), bound `0.0.0.0`.
|
||||
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.
|
||||
|
||||
### 2026-09-21 12:10 PDT — redeploy: `main` after merge `8ee3da9` (reconciled PR #97 + branch)
|
||||
|
||||
- Source: `main` @ `8ee3da9` (merge of `work/merge-89-90` into main, which itself reconciled
|
||||
main's PR #97 client-side `thicknessBand.ts` + `syncSelects`/`box-sizing` against the branch's
|
||||
server-side `thickness_band.py` + Create-string #89). `matches HEAD`: **yes**.
|
||||
- Resolution outcome in the deployed bundle: band is **server-side** — `Review.svelte` no longer
|
||||
imports `thicknessBand` and the read-only band display is gone; PR #97's `syncSelects()` (3× in
|
||||
source) + `box-sizing` CSS preserved; `metadata_locked` still dropped; Create-string + photo
|
||||
strip + wood primary live.
|
||||
- Rebuilt both images from `main`; recreated the stack (`down --remove-orphans` + `up -d`, no `-v`).
|
||||
LAN binds 10881/10882 intact.
|
||||
- Bundle: `assets/index-BScrgd8o.js` (new; differs from pre-merge `YPAqx609`; contains
|
||||
`create-title`, **zero** `thicknessBand` references). API `/api/health` `woo_reachable:true`.
|
||||
- Sample data preserved: **yes** — 35 slabs (one added upstream since last deploy).
|
||||
- Live verification (LAN URL, real DB): `POST /review` on slab `ec4088da…` →
|
||||
`title:'Curl Black Walnut Slab'`, `ready:True`.
|
||||
- Gates at merge time: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip).
|
||||
- Branches `work/merge-89-90` and `issue-89-metadata-lock` deleted (local + remote); remote has
|
||||
only `main`.
|
||||
|
||||
### 2026-09-21 12:34 PDT — Review mount: restore dropped lineup/woodPrimary imports
|
||||
|
||||
- Source: uncommitted working tree on `main` (parent `67aa3d6`). `matches HEAD`: **no**.
|
||||
Diff: `frontend/src/create/review/Review.svelte` +5 lines restoring imports of
|
||||
`keptInOrder`/`moveItem`/`missingMinimums`/`roleLabel`/`resolvePhotoUrl`/`LineupItem`
|
||||
from `lineup.ts` and `nextPrimary` from `woodPrimary.ts`. The morning merge kept the
|
||||
call sites and dropped the imports → `ReferenceError` on Review mount → "Could not load the slab."
|
||||
- Rebuilt `deploy-frontend` only (`compose build frontend` + `up -d frontend`, no `-v`).
|
||||
Image `56b35f6cdcdf`. FastAPI container left running.
|
||||
- URL: http://192.168.1.202:10881 (LAN) · loopback 127.0.0.1:18082
|
||||
- Bundle: `assets/index-BVCyM130.js` (replaced `index-ck41xD02.js`). Served JS has **0**
|
||||
`nextPrimary`/`keptInOrder` (minified locals) vs 3 unresolved `nextPrimary` in the broken bundle.
|
||||
- Sample data preserved: **yes** — 47 slabs before rebuild; 48 after live Capture→Review probe
|
||||
(created `ef1f4101-b1d2-459d-b9a0-842e7650ea3c`, sku NULL, status calibrated, 48×2 in; not published).
|
||||
- Live verification (loopback, real API): Continue to Review → `review-screen` visible,
|
||||
heading "Review to Ready", no loadError, no pageerror. POST /slabs 201, photos 201×2,
|
||||
GET slab/taxonomy/settings 200, review 200.
|
||||
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
|
||||
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
|
||||
|
||||
@@ -16,7 +16,9 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
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]
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -34,7 +36,9 @@ services:
|
||||
volumes:
|
||||
- slab_db:/data
|
||||
- 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
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ server {
|
||||
root /usr/share/nginx/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)
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
|
||||
| QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
|
||||
| QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — |
|
||||
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | — |
|
||||
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | backend/tests/test_thickness_band.py |
|
||||
| QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
|
||||
| QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
|
||||
| QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
|
||||
|
||||
@@ -121,7 +121,10 @@ hard stop. Switch to a supported browser.
|
||||
3. **Wood categories.** Pick at least one from the synced list.
|
||||
4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
|
||||
exactly one grade.
|
||||
5. **Thickness band.** Pick the Woo band that the measured thickness rounds up into.
|
||||
5. **Thickness band.** Not on the screen and not picked: the server bands the measured thickness
|
||||
up into the synced Woo band, stores it, and publish sends it to the store. Verify after publish
|
||||
that the Woo product's Thickness attribute is the band the captured thickness rounds up into
|
||||
(`QUALITY-95`).
|
||||
6. **Moisture.** Keep the default (kiln-dried) or change it.
|
||||
7. **fig-* tags.** Optional; pick any from the synced list.
|
||||
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a
|
||||
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
|
||||
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
|
||||
| CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
|
||||
| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
|
||||
| METADATA-10, METADATA-15, METADATA-30, QUALITY-95 | required taxonomy fields and cardinality; the thickness band is derived from the measurement, not picked |
|
||||
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
|
||||
| PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
|
||||
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
|
||||
@@ -55,10 +55,16 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
|
||||
with an "AI" badge; below it the field starts empty. The badge is presentation only and clears
|
||||
on edit.
|
||||
- **Required set.** Exactly one species and at least one wood-category leaf; one Edge Type, at
|
||||
least one Figure, exactly one Grade, one Thickness band, one Moisture; fig-* and feat-* tags
|
||||
optional; price; title, short_description, and description.
|
||||
least one Figure, exactly one Grade, one Moisture; fig-* and feat-* tags optional; price;
|
||||
title, short_description, and description. The **Thickness band is not an owner control** — it
|
||||
is derived deterministically from `thickness_in` (the value rounds up into the synced Woo band,
|
||||
`TV-9` / `QUALITY-95`), displayed read-only, and satisfies the readiness requirement
|
||||
(`REVIEW-5`) via that derived value.
|
||||
- **Primary category.** The owner declares one primary wood category and any secondary ones; the
|
||||
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
|
||||
no single submit-time gap list.
|
||||
- **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable.
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
||||
rectangle (calibration length + crop)".
|
||||
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
||||
|
||||
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
||||
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
||||
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
||||
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
||||
in from the top and the bottom — and never changes the aspect ratio.
|
||||
|
||||
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
||||
production workflow:
|
||||
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
||||
- the requirements-coverage board below the stage
|
||||
- the "was / now" pair of mini diagrams
|
||||
- the PROPOSAL / OPEN markers and the implementation-notes card
|
||||
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
||||
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
||||
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
||||
- the window.__wf debug hook
|
||||
What stays: the fixed middle line + its two end ticks, the slab transform set
|
||||
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
||||
semantics, and the single shared crop_fill.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
||||
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
||||
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
||||
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
||||
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||||
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
||||
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
||||
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
||||
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
||||
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
||||
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
||||
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
||||
.panel{padding:10px 4px;color:var(--fg)}
|
||||
h1{font-size:1.3rem;margin:0 0 10px}
|
||||
h3{font-size:.95rem;margin:0 0 8px}
|
||||
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
||||
.row{display:flex;gap:10px;margin-top:16px}
|
||||
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
||||
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
||||
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
||||
/* ---- the stage: fixed line + movable slab ---- */
|
||||
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||||
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
||||
.tw.dragging svg{cursor:grabbing}
|
||||
.tickg{cursor:ns-resize}
|
||||
.rotg{cursor:ew-resize}
|
||||
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
||||
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
||||
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
||||
input[type=range]{flex:1}
|
||||
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
||||
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
||||
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
||||
.nums span:first-child{color:var(--mut)}
|
||||
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
||||
.nums .dim{color:#777}
|
||||
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
||||
.status.ok{background:#12210f;color:var(--ok)}
|
||||
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
||||
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
||||
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
||||
.plan code{color:#ddd;font-size:.72rem}
|
||||
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
||||
/* replacement two-up */
|
||||
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||||
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
||||
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
||||
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
||||
.swap .gone{color:#ff9d8a}
|
||||
/* scope lineup */
|
||||
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
||||
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
||||
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
||||
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
||||
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
||||
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
||||
/* fields + settings */
|
||||
.fields{display:flex;gap:16px;margin-top:16px}
|
||||
.field{flex:1}
|
||||
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
||||
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
||||
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
||||
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
||||
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
||||
.settings .msg.ok{color:var(--ok)}
|
||||
/* requirement coverage board (outside the phone) */
|
||||
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
||||
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
||||
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
||||
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
||||
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
||||
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
||||
.cov .where{color:#ddd}
|
||||
/* narrow screens: stack the table instead of forcing its 400px min width */
|
||||
@media (max-width:760px){
|
||||
.cov thead{display:none}
|
||||
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
||||
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
||||
.cov tr:last-child{border-bottom:0}
|
||||
.cov td{border:0;padding:1px 0}
|
||||
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
||||
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
||||
}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
||||
<div class="flowbar" id="flowbar"></div>
|
||||
<div class="vp">
|
||||
<span>Viewport</span>
|
||||
<span id="vpval">412</span>
|
||||
<button data-w="390">390</button>
|
||||
<button data-w="412" class="on">412</button>
|
||||
<button data-w="720">720</button>
|
||||
<button data-w="1440">1440</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage"><div id="phone"></div></div>
|
||||
<div class="cov"><div class="cov-inner">
|
||||
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
||||
<table>
|
||||
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
||||
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
||||
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
||||
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
||||
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
||||
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
||||
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
<script>
|
||||
// ===========================================================================
|
||||
// issue #99 — the tape line is a FIXED ENTITY.
|
||||
//
|
||||
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
||||
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
||||
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
||||
// its long axis lies on the line, tick to tick.
|
||||
// * The ticks belong to the line - they slide along it, never with the photo.
|
||||
// * L = the distance between the two ticks (the owner's tape reading).
|
||||
// ===========================================================================
|
||||
const S = {
|
||||
screen: "tape",
|
||||
// slab transform (the only movable entity)
|
||||
tilt: 14, // degrees
|
||||
zoom: 1,
|
||||
panX: 60, panY: 0,
|
||||
// the line's ticks: y positions along the fixed line. They rest at the crop
|
||||
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom - and the operator drags them onto their tape marks from there.
|
||||
tickA: 32,
|
||||
tickB: 608,
|
||||
focus: "A",
|
||||
fill: 90, // settings.crop_fill - global, default 90
|
||||
// declared values (entered on Measure, shown on both steps)
|
||||
L: "88",
|
||||
thickness: "2.125",
|
||||
savedMsg: "",
|
||||
};
|
||||
|
||||
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
||||
const VB = { w: 480, h: 640 };
|
||||
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
||||
// AXIS - the centreline running down the middle of the board, which is where
|
||||
// the tape is laid. (Not a physical side edge.)
|
||||
const SLAB = {
|
||||
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
||||
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
||||
};
|
||||
const TICK_LEN = 26; // rifle-scope end tick, total
|
||||
const TICK_GAP = 40; // min separation between ticks
|
||||
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
||||
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
||||
const HIT_R = 34;
|
||||
const ALIGN_TOL = 3; // stage units
|
||||
|
||||
const rad = (a) => a * Math.PI / 180;
|
||||
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
||||
// crop frame, which is where the crop centres the slab's long axis.
|
||||
const lineX = () => VB.w / 2;
|
||||
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
||||
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
||||
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
||||
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
||||
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
||||
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
||||
|
||||
// slab transform: zoom + tilt about the stage centre, then pan
|
||||
function mapPoint(p) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
||||
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
||||
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
||||
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
||||
}
|
||||
function slabTransform() {
|
||||
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
||||
}
|
||||
// where the slab's long axis crosses a given stage y
|
||||
function axisXAt(y) {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
||||
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
||||
}
|
||||
const axisAngle = () => {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
||||
};
|
||||
// the axis is vertical when its angle points straight down (or up) the screen
|
||||
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
||||
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
||||
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
||||
|
||||
// ---------------------------------------------------------------- the stage
|
||||
function slabSvg() {
|
||||
return `
|
||||
<g id="slab" transform="${slabTransform()}">
|
||||
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
||||
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
||||
<g clip-path="url(#slabClip)" opacity=".8">
|
||||
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
</g>
|
||||
</g>`;
|
||||
}
|
||||
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
||||
function fixtureSvg() {
|
||||
const x = lineX();
|
||||
const tick = (y, id) => {
|
||||
const on = S.focus === id;
|
||||
return `
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
||||
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
||||
};
|
||||
return `
|
||||
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
||||
}
|
||||
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
||||
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
||||
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
||||
function cropFrameSvg() {
|
||||
const m = cropInset();
|
||||
return `
|
||||
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
||||
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
||||
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
||||
}
|
||||
function rotateHandleSvg() {
|
||||
return `
|
||||
<g class="rotg" data-rot="1">
|
||||
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
||||
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
||||
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
||||
</g>`;
|
||||
}
|
||||
function stageSvg() {
|
||||
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
||||
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
||||
${cropFrameSvg()}
|
||||
${slabSvg()}
|
||||
${fixtureSvg()}
|
||||
${rotateHandleSvg()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ screens
|
||||
const screens = {
|
||||
batch() {
|
||||
return `<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
||||
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
||||
</section>`;
|
||||
},
|
||||
mask() {
|
||||
return `<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
||||
<div class="nums">
|
||||
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
||||
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-batch">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
tape() {
|
||||
const off = axisOffset(), ok = aligned();
|
||||
return `<section class="panel">
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<div class="tw" id="tw">${stageSvg()}</div>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
||||
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
||||
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
||||
<span class="val" id="tiltVal">${S.tilt}°</span>
|
||||
</div>
|
||||
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
||||
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
||||
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
||||
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
||||
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
||||
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
||||
? "The board's long axis is on the line ✓"
|
||||
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
||||
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
||||
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
||||
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
||||
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
||||
</div>
|
||||
|
||||
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||||
|
||||
<div class="swap">
|
||||
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
||||
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-mask">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<h3>Two things on this screen are my proposal, not the issue</h3>
|
||||
<ul>
|
||||
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
||||
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
measure() {
|
||||
const mini = (tilt, marks) => `
|
||||
<svg viewBox="0 0 480 640">
|
||||
<rect width="480" height="640" fill="#16281c"/>
|
||||
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
||||
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
||||
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
||||
</g>
|
||||
</svg>`;
|
||||
return `<section class="panel">
|
||||
<h1>Photos ready</h1>
|
||||
<div class="fields">
|
||||
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
||||
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
||||
</div>
|
||||
<div class="nums">
|
||||
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
||||
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
||||
</div>
|
||||
<div class="plan">
|
||||
<h3>Rotation scope — req. 6</h3>
|
||||
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
||||
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
||||
</div>
|
||||
<ol class="lineup">
|
||||
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-tape">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
settings() {
|
||||
return `<section class="panel settings">
|
||||
<h1>Settings</h1>
|
||||
<div class="section">
|
||||
<h3>Crop — global</h3>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
||||
<div class="croprow">
|
||||
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
||||
<button data-act="save-fill">Save Changes</button>
|
||||
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Implementation notes</h3>
|
||||
<ul class="plan" style="border:0;padding:0;background:none">
|
||||
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
||||
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
||||
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
||||
</section>`;
|
||||
},
|
||||
};
|
||||
|
||||
// the two mini overlays for the was/now pair
|
||||
function oldOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
||||
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
||||
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
||||
</svg>`;
|
||||
}
|
||||
function newOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- plumbing
|
||||
const phone = document.getElementById('phone');
|
||||
const flowbar = document.getElementById('flowbar');
|
||||
const STEPS = [
|
||||
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
||||
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
||||
];
|
||||
function render() {
|
||||
phone.innerHTML = screens[S.screen]();
|
||||
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
||||
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
||||
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
||||
}
|
||||
flowbar.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-goto]');
|
||||
if (!b) return;
|
||||
S.screen = b.dataset.goto;
|
||||
render();
|
||||
});
|
||||
document.querySelector('.vp').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-w]');
|
||||
if (!b) return;
|
||||
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
||||
document.getElementById('vpval').textContent = b.dataset.w;
|
||||
});
|
||||
|
||||
// Replace only the stage + the live readouts, so an in-progress drag survives
|
||||
// (a full panel re-render would destroy the element under the pointer).
|
||||
function refreshStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
host.innerHTML = stageSvg();
|
||||
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
||||
set('tiltVal', S.tilt + '\u00B0');
|
||||
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
||||
set('fillVal', S.fill + '%');
|
||||
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
||||
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
||||
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
||||
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
||||
const st = document.getElementById('alignstatus');
|
||||
if (st) {
|
||||
const ok = aligned(), off = axisOffset();
|
||||
st.className = 'status' + (ok ? ' ok' : '');
|
||||
st.innerHTML = ok
|
||||
? "The board's long axis is on the line \u2713"
|
||||
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
||||
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
||||
}
|
||||
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
||||
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
||||
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
||||
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
||||
axisAngle: axisAngle(), aligned: aligned() };
|
||||
}
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const norm360 = (a) => ((a % 360) + 360) % 360;
|
||||
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
||||
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
||||
// twin, stepping about the stage centre so the slab stays where it is.
|
||||
function stepZoom(dir) {
|
||||
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
||||
refreshStage();
|
||||
}
|
||||
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
||||
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
||||
function nudgeTick(dir, mult) {
|
||||
const step = (mult === undefined ? 1 : mult);
|
||||
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
||||
render();
|
||||
}
|
||||
|
||||
function bindStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
const toVB = (e) => {
|
||||
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
||||
};
|
||||
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
||||
function zoomAt(p, factor) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2;
|
||||
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
||||
if (z1 === z0) return;
|
||||
const qx = cx + (p.x - cx - S.panX) / z0;
|
||||
const qy = cy + (p.y - cy - S.panY) / z0;
|
||||
S.zoom = z1;
|
||||
S.panX = p.x - cx - z1 * (qx - cx);
|
||||
S.panY = p.y - cy - z1 * (qy - cy);
|
||||
refreshStage();
|
||||
}
|
||||
host.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
||||
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
||||
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
||||
const dy = clamp(e.deltaY, -200, 200);
|
||||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
||||
}, { passive: false });
|
||||
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
||||
// preventDefault (see the tap tracking in end()), some engines still emit it
|
||||
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
||||
|
||||
let drag = null, tb = null, moved = false, downP = null;
|
||||
const off = { x: 0, y: 0 };
|
||||
host.addEventListener('pointerdown', (e) => {
|
||||
const tick = e.target.closest('[data-tick]');
|
||||
const rot = e.target.closest('[data-rot]');
|
||||
const p = toVB(e);
|
||||
moved = false; downP = p;
|
||||
if (tick) {
|
||||
S.focus = tick.dataset.tick;
|
||||
drag = { kind: "tick", id: S.focus };
|
||||
} else if (rot) {
|
||||
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
||||
} else {
|
||||
drag = { kind: "slab" };
|
||||
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
||||
}
|
||||
host.setPointerCapture(e.pointerId);
|
||||
host.classList.add('dragging');
|
||||
refreshStage();
|
||||
e.preventDefault();
|
||||
});
|
||||
host.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const p = toVB(e);
|
||||
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
||||
if (drag.kind === "tick") {
|
||||
// ticks slide ALONG the fixed line — x is ignored on purpose
|
||||
const y = clamp(p.y, 20, VB.h - 20);
|
||||
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
||||
} else if (drag.kind === "rot") {
|
||||
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
||||
} else {
|
||||
S.panX = clamp(p.x - off.x, -400, 400);
|
||||
S.panY = clamp(p.y - off.y, -400, 400);
|
||||
}
|
||||
refreshStage();
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag) return;
|
||||
const wasSlab = drag.kind === "slab";
|
||||
const startP = toVB(e);
|
||||
drag = null;
|
||||
host.classList.remove('dragging');
|
||||
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
||||
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
||||
// dblclick both stop arriving), so track the taps ourselves.
|
||||
if (wasSlab && !moved) {
|
||||
const now = performance.now();
|
||||
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
||||
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
||||
tb = { t: now, x: startP.x, y: startP.y };
|
||||
}
|
||||
refreshStage();
|
||||
};
|
||||
host.addEventListener('pointerup', end);
|
||||
host.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (S.screen !== "tape") return;
|
||||
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
||||
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!e.key.startsWith("Arrow")) return;
|
||||
const fine = e.shiftKey ? 0.1 : 1;
|
||||
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
||||
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
||||
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
||||
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
phone.addEventListener('click', (e) => {
|
||||
const t = e.target.closest('[data-act]');
|
||||
if (!t) return;
|
||||
const a = t.dataset.act;
|
||||
if (a === "tilt") tiltBy(+t.dataset.d);
|
||||
else if (a === "zoom") stepZoom(+t.dataset.d);
|
||||
else if (a === "fit") fitSlab();
|
||||
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
||||
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
||||
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
||||
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
||||
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
||||
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
||||
});
|
||||
phone.addEventListener('input', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
||||
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
||||
else if (t.dataset.act === "fill-range") {
|
||||
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
||||
// rectangle's aspect never changes and the line's x never moves.
|
||||
S.fill = +t.value;
|
||||
const ct = cropTicks();
|
||||
S.tickA = ct.a; S.tickB = ct.b;
|
||||
refreshStage();
|
||||
}
|
||||
});
|
||||
phone.addEventListener('change', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
||||
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --import tsx --test src/**/*.test.ts",
|
||||
"test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
|
||||
@@ -105,16 +105,22 @@
|
||||
content_source: 'manual',
|
||||
client_rev: 0,
|
||||
});
|
||||
const pngItems = capture.lineup.filter(
|
||||
(it) => it.kind === 'png' && it.role !== 'overlay',
|
||||
);
|
||||
// #90 / REVIEW-5: persist the full lineup with correct roles so the Review
|
||||
// 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++) {
|
||||
const it = pngItems[i];
|
||||
await uploadPhoto(id, {
|
||||
photoId: uuid(),
|
||||
seq: i + 1,
|
||||
isCalibration: it.role === 'calibration',
|
||||
role: it.role,
|
||||
original: it.blob,
|
||||
// The calibration/batch PNGs have no separate processed variant; the
|
||||
// overlay blob already carries the drawn dimensions.
|
||||
processed: it.blob,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +157,8 @@ export interface PhotoRow {
|
||||
id: string;
|
||||
seq: number;
|
||||
is_calibration: number;
|
||||
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
width_px: number | null;
|
||||
height_px: number | null;
|
||||
has_original: number;
|
||||
@@ -167,6 +169,8 @@ export interface UploadPhotoInput {
|
||||
photoId: string; // client-generated UUIDv4
|
||||
seq?: number;
|
||||
isCalibration: boolean;
|
||||
/** #90: lineup kind; defaults to batch. */
|
||||
role?: 'calibration' | 'overlay' | 'batch';
|
||||
original: Blob;
|
||||
processed?: Blob | null;
|
||||
}
|
||||
@@ -176,6 +180,7 @@ export interface UploadPhotoResult {
|
||||
slab_id: string;
|
||||
seq: number;
|
||||
is_calibration: number;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
original_retained: boolean;
|
||||
processed_retained: boolean;
|
||||
}
|
||||
@@ -191,6 +196,7 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
|
||||
form.append('photo_id', input.photoId);
|
||||
if (input.seq != null) form.append('seq', String(input.seq));
|
||||
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';
|
||||
form.append('original', input.original, origName);
|
||||
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));
|
||||
}
|
||||
|
||||
// #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(
|
||||
slabId: string,
|
||||
photoId: string,
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
// (METADATA-10: "parents group only and are never assigned"); only the
|
||||
// leaf rows are selectable. Filtering hides a parent group when none of
|
||||
// its leaves match.
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
// #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 { selectedSummary } from './woodPrimary';
|
||||
|
||||
export interface Opt { id: number; name: string; ai: boolean }
|
||||
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
|
||||
@@ -23,16 +26,22 @@
|
||||
export let placeholder = 'Choose…';
|
||||
export let testid = '';
|
||||
export let onToggle: (id: number) => void;
|
||||
// #90: commit (persist + re-review) after a toggle settles. The parent wires this
|
||||
// so multi-select changes flush to the slab immediately — no need to touch another
|
||||
// field first. Debounced so a quick multi-tap doesn't fire a commit per click.
|
||||
export let onCommit: (() => void) | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let open = false;
|
||||
let filter = '';
|
||||
let focused: number | null = null;
|
||||
let commitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const grouped = groups.length > 0;
|
||||
|
||||
// Effective selectable set (leaves when grouped, options when flat).
|
||||
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
|
||||
$: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
|
||||
$: q = filter.trim().toLowerCase();
|
||||
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
|
||||
// Grouped: keep a parent only if at least one of its leaves survives the filter.
|
||||
@@ -42,26 +51,53 @@
|
||||
.filter((g) => g.leaves.length > 0)
|
||||
: [];
|
||||
$: count = selected.length;
|
||||
$: summary = selectedSummary(selected, nameById, placeholder);
|
||||
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
|
||||
|
||||
function scheduleCommit() {
|
||||
if (typeof onCommit !== 'function') return;
|
||||
if (commitTimer) clearTimeout(commitTimer);
|
||||
commitTimer = setTimeout(() => { commitTimer = undefined; onCommit(); }, 250);
|
||||
}
|
||||
function toggle(id: number) {
|
||||
onToggle(id);
|
||||
focused = id;
|
||||
scheduleCommit(); // #90: persist + re-review without waiting for another field
|
||||
}
|
||||
function selectAll() {
|
||||
for (const o of filteredFlat) if (!selected.includes(o.id)) onToggle(o.id);
|
||||
scheduleCommit();
|
||||
}
|
||||
function clearAll() {
|
||||
for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id);
|
||||
scheduleCommit();
|
||||
}
|
||||
function close() {
|
||||
open = false;
|
||||
filter = '';
|
||||
if (commitTimer) { clearTimeout(commitTimer); commitTimer = undefined; }
|
||||
if (typeof onCommit === 'function') onCommit(); // flush immediately on Done
|
||||
dispatch('commit');
|
||||
}
|
||||
function close() { open = false; filter = ''; dispatch('commit'); }
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
|
||||
function destroy() { if (commitTimer) clearTimeout(commitTimer); }
|
||||
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>
|
||||
|
||||
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
|
||||
<div class="ms" class:open role="group" aria-label={label} data-testid={testid} on:keydown={onKey}>
|
||||
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
|
||||
<span class="ms-label">{open ? 'Choose ' + label.toLowerCase() : placeholder}</span>
|
||||
<span class="ms-c">{count === 1 ? '1 selected' : `${count} selected`}</span>
|
||||
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>
|
||||
{open
|
||||
? 'Choose ' + label.toLowerCase()
|
||||
: summary !== ''
|
||||
? summary
|
||||
: placeholder}
|
||||
</span>
|
||||
<span class="ms-c" data-testid={testid ? `${testid}-count` : 'ms-count'}>{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</span>
|
||||
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
|
||||
@@ -85,9 +121,11 @@
|
||||
class="ms-row leaf"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
on:keydown={(e) => onRowKey(e, o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||
@@ -104,9 +142,11 @@
|
||||
class="ms-row"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
on:keydown={(e) => onRowKey(e, o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||
@@ -127,8 +167,9 @@
|
||||
|
||||
<style>
|
||||
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
|
||||
.ms-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
.ms-btn { box-sizing: border-box; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
|
||||
.ms-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-arrow { color: #777; }
|
||||
.ms-panel { border-top: 1px solid #333; background: #161616; }
|
||||
@@ -148,6 +189,7 @@
|
||||
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
|
||||
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
|
||||
.ms-row:focus-visible { outline: 2px solid #00e5ff; outline-offset: -2px; }
|
||||
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
|
||||
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
|
||||
listPhotos, persistLineup, photoUrl, ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
|
||||
} from '../../api/slabs';
|
||||
import type { CalibratedCapture } from '../flow';
|
||||
import MultiSelect from './MultiSelect.svelte';
|
||||
import { deriveThicknessTermId } from './thicknessBand';
|
||||
import {
|
||||
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
import { nextPrimary } from './woodPrimary';
|
||||
|
||||
// Props handed off by App (one-column flow, UX.md §1).
|
||||
export let slabId: string;
|
||||
@@ -21,7 +27,7 @@
|
||||
let loading = true;
|
||||
|
||||
// Picker option types (id + name + whether the AI recommended it).
|
||||
interface Opt { id: number; name: string; ai: boolean }
|
||||
interface Opt { id: number; name: string; ai: boolean; lowIn?: number }
|
||||
const byId = <T extends { id: number }>(arr: T[]) => {
|
||||
const m = new Map<number, T>();
|
||||
for (const x of arr) m.set(x.id, x);
|
||||
@@ -34,7 +40,6 @@
|
||||
let edgeOpts: Opt[] = [];
|
||||
let figureOpts: Opt[] = [];
|
||||
let gradeOpts: Opt[] = [];
|
||||
let thicknessOpts: Opt[] = [];
|
||||
let moistureOpts: Opt[] = [];
|
||||
let figTagOpts: Opt[] = [];
|
||||
let featTagOpts: Opt[] = [];
|
||||
@@ -54,7 +59,13 @@
|
||||
const attr = tax!.attributes.find(
|
||||
(a) => a.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||
return (attr?.terms ?? []).map((t) => {
|
||||
// Carry the band's numeric lower bound for thickness (TV-9 round-up); other
|
||||
// attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined.
|
||||
const m = t.name.match(/\d+(?:[./]\d+)?/);
|
||||
const lowIn = m ? Number(m[0]) : undefined;
|
||||
return { id: t.id, name: t.name, ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
|
||||
});
|
||||
};
|
||||
speciesOpts = childrenOf(speciesGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
|
||||
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
|
||||
@@ -71,7 +82,8 @@
|
||||
edgeOpts = termsOf('edge type');
|
||||
figureOpts = termsOf('figure');
|
||||
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');
|
||||
// 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
|
||||
@@ -82,12 +94,16 @@
|
||||
}
|
||||
|
||||
// --- Editable model (mirrors the live slab) ---
|
||||
let speciesId: number | '' = '';
|
||||
// #90: single-selects bind a STRING var to the native <select>, because
|
||||
// <option value={id}> coerces the option value to a string. Binding a
|
||||
// `number | ''` var directly to those string values is the classic
|
||||
// number/string mismatch that leaves the selected option blank after AI
|
||||
// prefill or reload. `Number(...)` at commit normalizes back.
|
||||
let speciesSel: string = '';
|
||||
let edgeSel: string = '';
|
||||
let gradeSel: string = '';
|
||||
let woodCatIds: number[] = [];
|
||||
let edgeTypeId: number | '' = '';
|
||||
let figureIds: number[] = [];
|
||||
let gradeId: number | '' = '';
|
||||
let thicknessId: number | '' = '';
|
||||
let moistureId: number | '' = '';
|
||||
let figTagIds: number[] = [];
|
||||
let featTagIds: number[] = [];
|
||||
@@ -109,6 +125,16 @@
|
||||
let review: ReviewOut | null = null;
|
||||
let reviewBusy = false;
|
||||
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 =
|
||||
speciesSel !== '' && woodCatIds.length > 0;
|
||||
$: canCreateDesc =
|
||||
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
||||
// Monotonic token: each commit captures the current value and ignores its
|
||||
// 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
|
||||
@@ -128,13 +154,177 @@
|
||||
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);
|
||||
|
||||
// --- #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) {
|
||||
speciesId = s.species_id ?? '';
|
||||
speciesSel = s.species_id != null ? String(s.species_id) : '';
|
||||
woodCatIds = [...(s.wood_category_ids ?? [])];
|
||||
edgeTypeId = s.edge_type_term_id ?? '';
|
||||
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
|
||||
figureIds = [...(s.figure_term_ids ?? [])];
|
||||
gradeId = s.grade_term_id ?? '';
|
||||
thicknessId = s.thickness_term_id ?? '';
|
||||
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
||||
moistureId = s.moisture_term_id ?? '';
|
||||
figTagIds = [...(s.fig_tag_ids ?? [])];
|
||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||
@@ -145,7 +335,8 @@
|
||||
price = s.price != null ? String(s.price) : '';
|
||||
priceSource = s.price_source ?? 'recommendation';
|
||||
sku = s.sku ?? '';
|
||||
primaryCatId = woodCatIds[0] ?? '';
|
||||
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
|
||||
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
|
||||
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
||||
// its weight behind the value the owner kept).
|
||||
aiRecommendations = [];
|
||||
@@ -155,28 +346,63 @@
|
||||
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.
|
||||
// #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> {
|
||||
if (!slab) return;
|
||||
const myToken = ++commitToken;
|
||||
reviewBusy = true;
|
||||
reviewError = '';
|
||||
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 = {
|
||||
species_id: speciesId === '' ? null : Number(speciesId),
|
||||
species_id: speciesSel === '' ? null : Number(speciesSel),
|
||||
wood_category_ids: woodCatIds,
|
||||
edge_type_term_id: edgeTypeId === '' ? null : Number(edgeTypeId),
|
||||
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
|
||||
figure_term_ids: figureIds,
|
||||
grade_term_id: gradeId === '' ? null : Number(gradeId),
|
||||
thickness_term_id: thicknessId === '' ? null : Number(thicknessId),
|
||||
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
|
||||
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||||
fig_tag_ids: figTagIds,
|
||||
feat_tag_ids: featTagIds,
|
||||
title: title || null,
|
||||
short_description: shortDescription || null,
|
||||
description: description || null,
|
||||
content_source: contentSource || null,
|
||||
price: price === '' ? null : Number(price),
|
||||
price_source: priceSource,
|
||||
@@ -186,21 +412,79 @@
|
||||
const fresh = await updateSlab(slabId, patch);
|
||||
if (myToken !== commitToken) return; // superseded; don't clobber the latest
|
||||
slab = fresh;
|
||||
if (primaryCatId !== '') {
|
||||
const res = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: Number(primaryCatId),
|
||||
title: title || undefined,
|
||||
short_description: shortDescription || undefined,
|
||||
price: price === '' ? undefined : Number(price),
|
||||
sku: sku || undefined,
|
||||
// Re-run the review against the just-persisted row (authoritative ready/missing).
|
||||
review = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
price: price === '' ? undefined : Number(price),
|
||||
sku: sku || undefined,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
|
||||
} catch (e) {
|
||||
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),
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
review = res;
|
||||
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
|
||||
} else {
|
||||
review = await reviewSlab(slabId);
|
||||
if (myToken !== commitToken) return;
|
||||
}
|
||||
} 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) {
|
||||
if (myToken !== commitToken) return;
|
||||
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
|
||||
@@ -218,13 +502,13 @@
|
||||
const r = await inferTaxon(slabId);
|
||||
const s = r.suggestions;
|
||||
const recs: number[] = [];
|
||||
if (s.species_id) { speciesId = 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) {
|
||||
woodCatIds = [...s.wood_category_ids];
|
||||
if (!primaryCatId) primaryCatId = woodCatIds[0];
|
||||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||||
recs.push(...s.wood_category_ids);
|
||||
}
|
||||
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
|
||||
if (s.edge_type_term_id) edgeSel = String(s.edge_type_term_id);
|
||||
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
|
||||
if (s.feature_tag_ids?.length) {
|
||||
featTagIds = [...s.feature_tag_ids];
|
||||
@@ -234,6 +518,9 @@
|
||||
aiRecommendations = recs;
|
||||
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
|
||||
await commitReview();
|
||||
// #90: force the <select> DOM to reflect the prefilled values after Svelte's
|
||||
// own select_option(reset) has run.
|
||||
syncSelects();
|
||||
} catch (e) {
|
||||
aiNote = e instanceof ApiError ? e.message : 'Inference unavailable.';
|
||||
} finally {
|
||||
@@ -242,14 +529,22 @@
|
||||
}
|
||||
|
||||
async function generateContent() {
|
||||
if (!slab) return;
|
||||
if (!slab || !canCreateDesc) return;
|
||||
genBusy = true;
|
||||
genError = '';
|
||||
try {
|
||||
const r = await inferContent(slabId);
|
||||
description = r.description;
|
||||
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) {
|
||||
genError = e instanceof ApiError ? e.message : 'Could not generate text.';
|
||||
} finally {
|
||||
@@ -272,6 +567,33 @@
|
||||
return arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id];
|
||||
}
|
||||
|
||||
// #90: keep the native <select> DOM option in sync when the bound value is set
|
||||
// programmatically (AI prefill, reload hydrate). Svelte 4's update() re-runs
|
||||
// select_option(select, val) on every val change, which resets el.value to the
|
||||
// bound value — so a $: sync in the same flush gets overwritten. We defer with
|
||||
// tick() so the write lands AFTER Svelte's DOM update, guaranteeing the visible
|
||||
// option matches the bound value. Idempotent: only writes when they differ.
|
||||
async function syncSelects() {
|
||||
await tick();
|
||||
if (speciesEl && speciesEl.value !== speciesSel) speciesEl.value = speciesSel;
|
||||
if (edgeEl && edgeEl.value !== edgeSel) edgeEl.value = edgeSel;
|
||||
if (gradeEl && gradeEl.value !== gradeSel) gradeEl.value = gradeSel;
|
||||
}
|
||||
let speciesEl: HTMLSelectElement | undefined;
|
||||
let edgeEl: 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 () => {
|
||||
try {
|
||||
const [s, t, set] = await Promise.all([getSlab(slabId), getTaxonomy(), getSettings()]);
|
||||
@@ -279,6 +601,9 @@
|
||||
deriveOptions();
|
||||
hydrate(s, t);
|
||||
canGenerateContent = !!set.content_llm_enabled;
|
||||
// #90: sync the <select> DOM to the hydrated values (same race as AI prefill).
|
||||
syncSelects();
|
||||
await loadPhotos();
|
||||
await commitReview();
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
|
||||
@@ -287,17 +612,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
function goPublish() {
|
||||
onPublish();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
onBack();
|
||||
}
|
||||
|
||||
function publish() {
|
||||
goPublish();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -321,6 +638,55 @@
|
||||
<div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
|
||||
</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}
|
||||
<div class="aibar">
|
||||
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
|
||||
@@ -333,28 +699,56 @@
|
||||
<!-- Species (single, AI-assist) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
|
||||
<select id="species" class="sel" data-testid="species" bind:value={speciesId} on:change={commitReview}>
|
||||
<select id="species" class="sel" data-testid="species" bind:this={speciesEl} bind:value={speciesSel} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each speciesOpts as o (o.id)}
|
||||
<option value={o.id} data-ai={o.ai ? 'true' : ''}>{o.name}{o.ai ? ' (AI)' : ''}</option>
|
||||
<option value={o.id} data-ai={aiRecommendations.includes(o.id) ? 'true' : ''}>{o.name}{aiRecommendations.includes(o.id) ? ' (AI)' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Wood categories (multi, searchable list, AI-assist) -->
|
||||
<MultiSelect
|
||||
label="Wood categories"
|
||||
groups={woodCatGroups}
|
||||
selected={woodCatIds}
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))}
|
||||
/>
|
||||
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
||||
<div class="field" data-testid="wood-cat-field">
|
||||
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="Wood categories"
|
||||
groups={woodCatGroupsView}
|
||||
selected={woodCatIds}
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={toggleWoodCat}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
{#if primaryOpts.length}
|
||||
<div class="primary-set" data-testid="primary-wood-set" role="radiogroup" aria-label="Primary wood category">
|
||||
<div class="ps-lbl">Primary wood category <span class="ps-req">drives title</span></div>
|
||||
{#each primaryOpts as o (o.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="ps-opt"
|
||||
class:on={primaryCatId === o.id}
|
||||
role="radio"
|
||||
aria-checked={primaryCatId === o.id}
|
||||
data-testid={`primary-wood-${o.id}`}
|
||||
on:click={() => pickPrimary(o.id)}
|
||||
>
|
||||
<span class="ps-radio" aria-hidden="true"></span>
|
||||
<span class="ps-name">{o.name}</span>
|
||||
{#if o.ai}<span class="ps-tag">AI</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<p class="ps-note">
|
||||
Primary selects the title & short-description pattern (METADATA-85).
|
||||
Secondary categories feed the description. Default = first selected.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edge type (single, AI-assist) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
|
||||
<select id="edge" class="sel" data-testid="edge-type" bind:value={edgeTypeId} on:change={commitReview}>
|
||||
<select id="edge" class="sel" data-testid="edge-type" bind:this={edgeEl} bind:value={edgeSel} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each edgeOpts as o (o.id)}
|
||||
<option value={o.id}>{o.name}</option>
|
||||
@@ -367,18 +761,19 @@
|
||||
<label class="lbl">Figure <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="Figure"
|
||||
options={figureOpts}
|
||||
options={figureOptsView}
|
||||
selected={figureIds}
|
||||
placeholder="Choose figure"
|
||||
testid="figure"
|
||||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grade (single, owner judgment — NOT AI-assisted per METADATA-40) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="grade">Grade</label>
|
||||
<select id="grade" class="sel" data-testid="grade" bind:value={gradeId} on:change={commitReview}>
|
||||
<select id="grade" class="sel" data-testid="grade" bind:this={gradeEl} bind:value={gradeSel} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each gradeOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||
</select>
|
||||
@@ -394,6 +789,7 @@
|
||||
placeholder="Choose fig-* tags"
|
||||
testid="fig-tag"
|
||||
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -402,22 +798,18 @@
|
||||
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="feat-* tags"
|
||||
options={featTagOpts}
|
||||
options={featTagOptsView}
|
||||
selected={featTagIds}
|
||||
placeholder="Choose feat-* tags"
|
||||
testid="feat-tag"
|
||||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- #88: thickness band is deterministic from thickness_in — display-only, no owner pick. -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="thickness">Thickness band</label>
|
||||
<select id="thickness" class="sel" data-testid="thickness-band" bind:value={thicknessId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each thicknessOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
||||
<label class="lbl" for="moisture">Moisture</label>
|
||||
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
@@ -425,26 +817,39 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Title / Description -->
|
||||
<!-- Title / Short description + explicit "Create" build buttons (#89) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="title">Title</label>
|
||||
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
||||
<div class="create-row">
|
||||
<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 class="field">
|
||||
<label class="lbl" for="short-desc">Short description</label>
|
||||
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
||||
<div class="create-row">
|
||||
<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 class="field">
|
||||
<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>
|
||||
{#if canGenerateContent}
|
||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
|
||||
{#if canCreateDesc}
|
||||
<div class="gen">
|
||||
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}>
|
||||
{genBusy ? 'Writing…' : 'Generate text'}
|
||||
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
|
||||
{genBusy ? 'Writing…' : 'Create desc'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||||
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Price / SKU (SKU last) -->
|
||||
@@ -466,13 +871,16 @@
|
||||
{#if reviewBusy}
|
||||
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
|
||||
{:else if review}
|
||||
<div class="status" class:ok={ready()} data-testid="ready-status">
|
||||
{ready()
|
||||
<div class="status" class:ok={ready() && lineupValid} data-testid="ready-status">
|
||||
{ready() && lineupValid
|
||||
? '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>
|
||||
{#if !ready()}
|
||||
<p class="missing" data-testid="missing-list">Missing: {review.missing.join(', ')}</p>
|
||||
{#if !(ready() && lineupValid)}
|
||||
<p class="missing" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
|
||||
@@ -483,10 +891,10 @@
|
||||
type="button"
|
||||
class="primary"
|
||||
data-testid="continue-publish"
|
||||
disabled={publishDisabled}
|
||||
on:click={publish}
|
||||
disabled={publishDisabled || !lineupValid || persistBusy}
|
||||
on:click={goPublish}
|
||||
>
|
||||
Continue to Publish
|
||||
{persistBusy ? 'Saving photos…' : 'Continue to Publish'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -503,13 +911,40 @@
|
||||
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
|
||||
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
||||
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
||||
.inp, .sel { width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||||
/* box-sizing: the app has no global reset, so a width:100% control with padding
|
||||
and a border pokes out of its container by that padding+border (20px here) —
|
||||
which becomes real page overflow whenever the column sits at the viewport edge
|
||||
(measured: 375px mobile scrolled 20px). Border-box keeps the box inside. */
|
||||
.inp, .sel { box-sizing: border-box; width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||||
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
|
||||
.ta { resize: vertical; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||||
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||||
.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.ok { background: #122a12; color: #7fd37f; }
|
||||
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||||
@@ -518,4 +953,45 @@
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { deriveThicknessTermId, type ThicknessBand } from './thicknessBand';
|
||||
|
||||
// Synced-Woo-shaped bands (id + name). The e2e fixture uses a single "2-4 in" band.
|
||||
const BANDS: ThicknessBand[] = [
|
||||
{ id: 39, name: '2-4 in' },
|
||||
];
|
||||
|
||||
test('TV-9 / QUALITY-95: 1 in rounds up into the enclosing synced band', () => {
|
||||
const bands: ThicknessBand[] = [
|
||||
{ id: 10, name: '1-2 in' },
|
||||
{ id: 39, name: '2-4 in' },
|
||||
];
|
||||
assert.equal(deriveThicknessTermId(1, bands), 10);
|
||||
assert.equal(deriveThicknessTermId(1.5, bands), 10);
|
||||
// 2.0 is exactly the upper bound of the 1-2 band → belongs to it.
|
||||
assert.equal(deriveThicknessTermId(2.0, bands), 10);
|
||||
// 2.01 falls into 2-4.
|
||||
assert.equal(deriveThicknessTermId(2.25, bands), 39);
|
||||
assert.equal(deriveThicknessTermId(4, bands), 39);
|
||||
});
|
||||
|
||||
test('e2e fixture: thickness_in 2 → the single "2-4 in" band (id 39)', () => {
|
||||
assert.equal(deriveThicknessTermId(2, BANDS), 39);
|
||||
});
|
||||
|
||||
test('above every band → empty (no invented band; upstream thickness fix resolves it)', () => {
|
||||
assert.equal(deriveThicknessTermId(5, BANDS), '');
|
||||
});
|
||||
|
||||
test('no thickness / non-finite → empty', () => {
|
||||
assert.equal(deriveThicknessTermId(null, BANDS), '');
|
||||
assert.equal(deriveThicknessTermId(undefined, BANDS), '');
|
||||
assert.equal(deriveThicknessTermId(0, BANDS), '');
|
||||
assert.equal(deriveThicknessTermId(NaN, BANDS), '');
|
||||
assert.equal(deriveThicknessTermId(2, []), '');
|
||||
});
|
||||
|
||||
test('band names are parsed from the synced names, never hardcoded (order-independent)', () => {
|
||||
// Reverse the array: the derivation must still pick the right band by bound.
|
||||
const bands: ThicknessBand[] = [
|
||||
{ id: 39, name: '2-4 in' },
|
||||
{ id: 10, name: '1-2 in' },
|
||||
];
|
||||
assert.equal(deriveThicknessTermId(1.5, bands), 10);
|
||||
assert.equal(deriveThicknessTermId(3, bands), 39);
|
||||
});
|
||||
|
||||
test('malformed band names are skipped without throwing', () => {
|
||||
const bands: ThicknessBand[] = [
|
||||
{ id: 1, name: 'N/A' },
|
||||
{ id: 2, name: '4-2 in' }, // inverted, unusable
|
||||
{ id: 39, name: '2-4 in' },
|
||||
];
|
||||
assert.equal(deriveThicknessTermId(3, bands), 39);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Deterministic thickness-band derivation (TV-9 / QUALITY-95, METADATA-15/25).
|
||||
*
|
||||
* The Woo "Thickness" attribute is a set of named bands (e.g. "2-4 in"). The
|
||||
* owner enters a numeric thickness in 1/8-inch steps; the band is derived by
|
||||
* rounding that thickness UP into the band whose numeric upper bound first
|
||||
* encloses it. The Review UI never exposes a band picker — the band always
|
||||
* follows from thickness_in (issue #88).
|
||||
*
|
||||
* Pure function so it is unit-testable without a browser/backend.
|
||||
*/
|
||||
|
||||
export interface ThicknessBand {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Parse the leading numeric bounds ("2-4 in" → {min:2,max:4}). Returns null if the name
|
||||
* carries no usable numeric upper bound (defensive — a band we can't place is skipped). */
|
||||
function parseBounds(name: string): { min: number; max: number } | null {
|
||||
// Match the first two numbers in the name, in order (handles "2-4 in", "1/8", "12in+").
|
||||
const nums = name.match(/\d+(?:[./]\d+)?/g);
|
||||
if (!nums) return null;
|
||||
const asNum = (s: string) => Number(s);
|
||||
const min = asNum(nums[0]);
|
||||
const max = nums.length >= 2 ? asNum(nums[1]) : min;
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
|
||||
if (max < min) return null; // malformed "4-2"; not a usable band
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round `thicknessIn` (inches, 1/8" steps) up into the synced Woo thickness band.
|
||||
* Returns the term id, or '' when no band encloses the value (no thickness known,
|
||||
* empty synced set, or a value above every band — all leave the field empty so the
|
||||
* owner's upstream fix, not a wrong band, resolves it).
|
||||
*/
|
||||
export function deriveThicknessTermId(
|
||||
thicknessIn: number | null | undefined,
|
||||
bands: ThicknessBand[],
|
||||
): number | '' {
|
||||
if (typeof thicknessIn !== 'number' || !Number.isFinite(thicknessIn) || thicknessIn <= 0) {
|
||||
return '';
|
||||
}
|
||||
// Sort bands by upper bound so "first enclosing max" is well-defined regardless of
|
||||
// the order Woo returned them in.
|
||||
const ranked = bands
|
||||
.map((b) => ({ b, bounds: parseBounds(b.name) }))
|
||||
.filter((x): x is { b: ThicknessBand; bounds: { min: number; max: number } } => x.bounds !== null)
|
||||
.sort((a, z) => a.bounds.max - z.bounds.max);
|
||||
for (const { b, bounds } of ranked) {
|
||||
// A value exactly on the band's max belongs to that band; above it, fall through.
|
||||
if (thicknessIn <= bounds.max) {
|
||||
return b.id;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { nextPrimary, selectedSummary } from './woodPrimary';
|
||||
|
||||
test('nextPrimary: empty selection clears primary', () => {
|
||||
assert.equal(nextPrimary([], 21), '');
|
||||
assert.equal(nextPrimary([], ''), '');
|
||||
});
|
||||
|
||||
test('nextPrimary: default is first selected (REVIEW-15)', () => {
|
||||
assert.equal(nextPrimary([21, 22], ''), 21);
|
||||
assert.equal(nextPrimary([21, 22], null), 21);
|
||||
});
|
||||
|
||||
test('nextPrimary: keeps current when still selected', () => {
|
||||
assert.equal(nextPrimary([21, 22], 22), 22);
|
||||
assert.equal(nextPrimary([22], 22), 22);
|
||||
});
|
||||
|
||||
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
|
||||
assert.equal(nextPrimary([22, 23], 21), 22);
|
||||
assert.equal(nextPrimary([23], 21), 23);
|
||||
});
|
||||
|
||||
test('selectedSummary: placeholder when empty', () => {
|
||||
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
|
||||
});
|
||||
|
||||
test('selectedSummary: shows names, then +N overflow', () => {
|
||||
const m = new Map([
|
||||
[1, 'Live Edge'],
|
||||
[2, 'Book Match'],
|
||||
[3, 'Cookie'],
|
||||
]);
|
||||
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
|
||||
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
|
||||
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
|
||||
});
|
||||
|
||||
test('selectedSummary: falls back to count when names unknown', () => {
|
||||
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* REVIEW-15: owner declares one primary wood category among the selected set.
|
||||
* Default primary = first selected. Deselecting the current primary promotes
|
||||
* the first remaining selected id. Empty selection clears primary.
|
||||
*/
|
||||
export function nextPrimary(
|
||||
selected: number[],
|
||||
current: number | '' | null | undefined,
|
||||
): number | '' {
|
||||
if (!selected.length) return '';
|
||||
const cur = typeof current === 'number' ? current : '';
|
||||
if (cur !== '' && selected.includes(cur)) return cur;
|
||||
return selected[0];
|
||||
}
|
||||
|
||||
/** Closed MultiSelect summary: names when known, else a count. */
|
||||
export function selectedSummary(
|
||||
selected: number[],
|
||||
nameById: Map<number, string>,
|
||||
placeholder: string,
|
||||
maxNames = 2,
|
||||
): string {
|
||||
if (!selected.length) return placeholder;
|
||||
const names = selected
|
||||
.map((id) => nameById.get(id))
|
||||
.filter((n): n is string => !!n && n.length > 0);
|
||||
if (!names.length) {
|
||||
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
|
||||
}
|
||||
if (names.length <= maxNames) return names.join(', ');
|
||||
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
|
||||
}
|
||||
@@ -53,7 +53,16 @@ const TAXONOMY = {
|
||||
},
|
||||
{
|
||||
id: 38, name: 'Thickness', slug: 'thickness',
|
||||
terms: [{ id: 39, name: '2-4 in', slug: '2-4' }],
|
||||
// Real live band names: the Review screen only *displays* the derived band.
|
||||
terms: [
|
||||
{ id: 347, name: 'Under 1/2"', slug: 'xthin' },
|
||||
{ id: 348, name: '1/2" - 3/4"', slug: '2-3qtr' },
|
||||
{ id: 349, name: '3/4" - 1"', slug: '3-4qtr' },
|
||||
{ id: 350, name: '1"- 1 1/2"', slug: '4-6qtr' },
|
||||
{ id: 351, name: '1 1/2" - 2"', slug: '6-8qtr' },
|
||||
{ id: 352, name: '2" - 3"', slug: '8-12qtr' },
|
||||
{ id: 353, name: 'Over 3"', slug: '12qtr' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 40, name: 'Moisture', slug: 'moisture',
|
||||
@@ -109,7 +118,7 @@ function baseSlab(): Record<string, unknown> {
|
||||
edge_type_term_id: null,
|
||||
figure_term_ids: [],
|
||||
grade_term_id: null,
|
||||
thickness_term_id: null,
|
||||
thickness_term_id: 352,
|
||||
moisture_term_id: null,
|
||||
fig_tag_ids: [],
|
||||
feat_tag_ids: [],
|
||||
@@ -148,8 +157,13 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
const calls = hooks.calls ?? { call1: 0, call2: 0 };
|
||||
const slab = baseSlab();
|
||||
let primary = null as number | null;
|
||||
const reviewPrimaries: number[] = [];
|
||||
|
||||
const merge = (payload: Record<string, unknown>) => {
|
||||
// Respect optimistic concurrency: reject stale client_rev (like the real backend).
|
||||
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) {
|
||||
return false; // stale — do NOT merge
|
||||
}
|
||||
for (const k of [
|
||||
'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',
|
||||
@@ -160,10 +174,21 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
|
||||
};
|
||||
|
||||
const reviewResult = (payload: Record<string, unknown>) => {
|
||||
const reviewResult = (payload: Record<string, unknown>) => {
|
||||
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.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[] = [];
|
||||
if (!slab.sku) missing.push('sku');
|
||||
if (!slab.species_id) missing.push('species');
|
||||
@@ -173,8 +198,8 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
return {
|
||||
ready: missing.length === 0,
|
||||
missing,
|
||||
title: (slab.title as string) ?? null,
|
||||
short_description: (slab.short_description as string) ?? null,
|
||||
title: deterministicTitle,
|
||||
short_description: deterministicShort,
|
||||
price: (slab.price as number) ?? null,
|
||||
price_suggestion: 120,
|
||||
primary_wood_category_id: primary,
|
||||
@@ -207,7 +232,10 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -229,7 +257,11 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
return;
|
||||
}
|
||||
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') {
|
||||
await json(route, reviewResult(payload));
|
||||
const result = reviewResult(payload);
|
||||
if (typeof result.primary_wood_category_id === 'number') {
|
||||
reviewPrimaries.push(result.primary_wood_category_id);
|
||||
}
|
||||
await json(route, result);
|
||||
return;
|
||||
}
|
||||
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
|
||||
@@ -251,7 +283,7 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
}
|
||||
await json(route, { error: { code: 'not_found', message: path } }, 404);
|
||||
});
|
||||
return { calls, slab };
|
||||
return { calls, slab, reviewPrimaries };
|
||||
}
|
||||
|
||||
// Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and
|
||||
@@ -284,6 +316,12 @@ async function completeForReady(page: Page) {
|
||||
await page.getByTestId('wood-cat-done').click();
|
||||
await page.getByTestId('edge-type').selectOption('31');
|
||||
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');
|
||||
// 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).
|
||||
@@ -292,12 +330,16 @@ async function completeForReady(page: Page) {
|
||||
}
|
||||
|
||||
test.describe('M3 Review→Publish flow (#77)', () => {
|
||||
test('golden path: capture→review→ready→publish→done', async ({ page }) => {
|
||||
await mockApi(page);
|
||||
test('golden path: capture→review→create→ready→publish→done (#77, #89)', async ({ page }) => {
|
||||
const { slab } = await mockApi(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('title')).toHaveValue('');
|
||||
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.
|
||||
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||
await page.getByTestId('continue-publish').click();
|
||||
@@ -309,135 +351,136 @@ test.describe('M3 Review→Publish flow (#77)', () => {
|
||||
await expect(page.getByTestId('new-slab')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inference off: no inference calls are made', async ({ page }) => {
|
||||
const { calls } = await mockApi(page, { settings: { inference_enabled: false } });
|
||||
test('wood categories: closed summary shows names; primary radio among selected (#90, REVIEW-15)', async ({ page }) => {
|
||||
const { reviewPrimaries } = await mockApi(page);
|
||||
await reachReview(page);
|
||||
await completeForReady(page);
|
||||
// No "Suggest with AI" control when inference is disabled, and zero hits.
|
||||
await expect(page.getByTestId('run-inference')).toHaveCount(0);
|
||||
await expect(page.getByTestId('generate-text')).toHaveCount(0);
|
||||
expect(calls.call1).toBe(0);
|
||||
expect(calls.call2).toBe(0);
|
||||
|
||||
// Open wood MultiSelect, pick Live edge + Bowl blanks, Done.
|
||||
await page.getByTestId('wood-cat-btn').click();
|
||||
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
|
||||
await page.getByTestId('wood-cat-done').click();
|
||||
|
||||
// Closed summary shows selected names (not only a count).
|
||||
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Live edge/i);
|
||||
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Bowl blanks/i);
|
||||
|
||||
// Primary set appears with both selected; default primary = first selected.
|
||||
const primarySet = page.getByTestId('primary-wood-set');
|
||||
await expect(primarySet).toBeVisible();
|
||||
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
// Owner switches primary to Bowl blanks; review payload carries it.
|
||||
await page.getByTestId(`primary-wood-${BOWL_ID}`).click();
|
||||
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'false');
|
||||
await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID);
|
||||
});
|
||||
|
||||
test('inference on: Suggest with AI prefills + AI badges; Generate text writes', async ({ 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,
|
||||
},
|
||||
test('thickness band is server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
|
||||
await mockApi(page);
|
||||
await reachReview(page);
|
||||
|
||||
// METADATA-15 / QUALITY-95: the 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 is the measured value in the summary. (Commit 204bc5e dropped the
|
||||
// read-only echo; this test asserts the omission.)
|
||||
await expect(page.getByTestId('thickness-band')).toHaveCount(0);
|
||||
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* #90 served-surface render check: the changed Review surface (wood-category
|
||||
* MultiSelect + primary-radio set + read-only thickness band) is rendered at
|
||||
* desktop/tablet/mobile widths, measured, and captured to
|
||||
* tests/e2e/artifacts/ so layout and interaction can be inspected as evidence.
|
||||
*/
|
||||
test('Review surface renders at 1440/768/375 with no overflow (#90)', async ({ page }) => {
|
||||
const widths = [
|
||||
{ w: 1440, h: 900, name: 'desktop' },
|
||||
{ w: 768, h: 1024, name: 'tablet' },
|
||||
{ w: 375, h: 812, name: 'mobile' },
|
||||
];
|
||||
const report: string[] = [];
|
||||
|
||||
for (const vp of widths) {
|
||||
await page.setViewportSize({ width: vp.w, height: vp.h });
|
||||
await mockApi(page);
|
||||
await reachReview(page);
|
||||
// Put the surface in its real state: two wood categories selected, primary set.
|
||||
await page.getByTestId('wood-cat-btn').click();
|
||||
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
|
||||
await page.getByTestId('wood-cat-done').click();
|
||||
await expect(page.getByTestId('primary-wood-set')).toBeVisible();
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
|
||||
const box = (sel: string) => {
|
||||
const el = q(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
w: Math.round(r.width), h: Math.round(r.height),
|
||||
fontSize: cs.fontSize,
|
||||
overflowX: el.scrollWidth > el.clientWidth + 1,
|
||||
text: (el.textContent || '').trim().slice(0, 80),
|
||||
};
|
||||
};
|
||||
const primaryRow = q('[data-testid^="primary-wood-"]');
|
||||
const primaryRowBox = primaryRow ? primaryRow.getBoundingClientRect() : null;
|
||||
// Anything whose box pokes outside the viewport, with enough identity to fix it.
|
||||
const offenders: { tag: string; cls: string; id: string; left: number; right: number }[] = [];
|
||||
document.querySelectorAll('body *').forEach((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) return;
|
||||
if (r.right > de.clientWidth + 1 || r.left < -1) {
|
||||
offenders.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: String(el.className || '').slice(0, 48),
|
||||
id: el.id,
|
||||
left: Math.round(r.left),
|
||||
right: Math.round(r.right),
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
onCall2: async (route) => {
|
||||
await json(route, { ok: true, description: 'AI-generated slab story.' });
|
||||
},
|
||||
});
|
||||
await reachReview(page);
|
||||
await page.getByTestId('run-inference').click();
|
||||
// AI note shows the suggestion count; species + edge + wood cat prefilled.
|
||||
await expect(page.getByTestId('ai-note')).toContainText(/AI suggested/i, { timeout: 15000 });
|
||||
await expect(page.getByTestId('species')).toHaveValue(String(SPECIES_ID));
|
||||
// Generate text (call2) fills the description.
|
||||
await page.getByTestId('generate-text').click();
|
||||
await expect(page.getByTestId('desc')).toHaveValue('AI-generated slab story.', { timeout: 15000 });
|
||||
});
|
||||
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('fig-*/feat-* tag pickers render (optional) and persist to the slab', async ({ page }) => {
|
||||
const { slab } = await mockApi(page);
|
||||
await reachReview(page);
|
||||
// Both pickers render from the synced tag families (METADATA-30 / METADATA-40).
|
||||
await expect(page.getByTestId('fig-tag-btn')).toBeVisible();
|
||||
await expect(page.getByTestId('feat-tag-btn')).toBeVisible();
|
||||
// feat-* carries the "AI assist" tag (Call 1 proposes feature tags); fig-* does not.
|
||||
// Scope to the field label (getByText would also match the MultiSelect's inner label).
|
||||
await expect(page.locator('label', { hasText: 'feat-* tags' })).toContainText('AI assist');
|
||||
await expect(page.locator('label', { hasText: 'fig-* tags' })).not.toContainText('AI assist');
|
||||
// Select one fig tag and one feat tag.
|
||||
await page.getByTestId('fig-tag-btn').click();
|
||||
await page.getByTestId(`fig-tag-opt-${FIG_TAG_ID}`).click();
|
||||
await page.getByTestId('fig-tag-done').click();
|
||||
await expect(page.getByTestId('fig-tag-btn')).toContainText('1 selected');
|
||||
await page.getByTestId('feat-tag-btn').click();
|
||||
await page.getByTestId(`feat-tag-opt-${FEAT_TAG_ID}`).click();
|
||||
await page.getByTestId('feat-tag-done').click();
|
||||
await expect(page.getByTestId('feat-tag-btn')).toContainText('1 selected');
|
||||
// Flush the tag ids to the slab: an on:change field (grade) triggers
|
||||
// commitReview, which PUTs fig_tag_ids/feat_tag_ids with the current values.
|
||||
await page.getByTestId('grade').selectOption('37');
|
||||
// Tags are optional — they are not in the server ready-set, so readiness is
|
||||
// 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).
|
||||
expect(slab.fig_tag_ids).toEqual([FIG_TAG_ID]);
|
||||
expect(slab.feat_tag_ids).toEqual([FEAT_TAG_ID]);
|
||||
});
|
||||
const shot = `tests/e2e/artifacts/review-90-${vp.name}-${vp.w}.png`;
|
||||
await page.screenshot({ path: shot, fullPage: true });
|
||||
report.push(`${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
|
||||
// Print before asserting so the measurement survives a failing gate.
|
||||
console.log(`RENDER ${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
|
||||
|
||||
test('publish failure: duplicate SKU offers edit + open existing', async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
onPublish: async (route) => {
|
||||
await json(route, {
|
||||
error: { code: 'duplicate_sku', message: 'SKU already exists', detail: {} },
|
||||
}, 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 }) => {
|
||||
await mockApi(page, {
|
||||
onPublish: async (route) => {
|
||||
await json(route, {
|
||||
error: {
|
||||
code: 'woo_unreachable',
|
||||
message: 'Store is unreachable',
|
||||
detail: { recoverable: true, partial_media: false },
|
||||
},
|
||||
}, 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('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);
|
||||
// Layout gates: nothing spills sideways, the closed summary names both picks
|
||||
// (CSS ellipsis may shorten the paint, but the text must be there), and the
|
||||
// primary rows stay a comfortable tap target on mobile.
|
||||
expect(metrics.pageHasHScroll, `${vp.name} horizontal overflow: ${metrics.docScrollW} > ${metrics.docClientW}`).toBe(false);
|
||||
expect(metrics.summary?.text).toMatch(/Live edge/i);
|
||||
expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
|
||||
expect(metrics.count?.text).toContain('2 selected');
|
||||
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
|
||||
// Band is deliberately absent from Review (METADATA-15) — no element to measure.
|
||||
expect(metrics.band).toBeNull();
|
||||
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
|
||||
}
|
||||
console.log('RENDER REPORT\n' + report.join('\n'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user