merge issue-89-metadata-lock → main (Create-string model, #89/#90)

Drop the metadata_locked column + "Save and lock" UI end-to-end. The
review endpoint now builds the deterministic title/short_description on
every call; the owner's Create title / Create short desc buttons fire
the build and write the result (force-overwrite contract). commitReview
auto-fills only empty fields (no owner-clobber) and folds the
deterministic copy into its single PUT (kills the client_rev 409
cascade).

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

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

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip). Live UAT verified at 192.168.1.202:10881.
This commit is contained in:
Ty
2026-09-21 11:54:48 -07:00
parent e5acef4c9b
commit 1a50fad0a5
11 changed files with 234 additions and 314 deletions
-1
View File
@@ -55,7 +55,6 @@ _SCHEMA_SENTINELS = [
("settings", "value"), ("settings", "value"),
("slabs", "deleted_at"), ("slabs", "deleted_at"),
("slabs", "server_rev"), ("slabs", "server_rev"),
("slabs", "metadata_locked"),
("slab_photos", "original_blob"), ("slab_photos", "original_blob"),
("sync_log", "payload_hash"), ("sync_log", "payload_hash"),
] ]
-3
View File
@@ -35,9 +35,6 @@ CREATE TABLE IF NOT EXISTS slabs (
species_confidence REAL, species_confidence REAL,
figure_confidence REAL, figure_confidence REAL,
grade_confidence REAL, grade_confidence REAL,
-- #89: explicit owner lock of the confirmed taxonomy set. Deterministic
-- title/short_description are built and persisted only once this is 1.
metadata_locked INTEGER CHECK(metadata_locked IN (0,1) OR metadata_locked IS NULL),
inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL), inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL),
calibration_photo_id TEXT, calibration_photo_id TEXT,
client_rev INTEGER NOT NULL DEFAULT 1, client_rev INTEGER NOT NULL DEFAULT 1,
+15 -68
View File
@@ -155,65 +155,15 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
if row is None: if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
slab = _slab_dict(row) slab = _slab_dict(row)
locked = bool(slab.get("metadata_locked"))
# --- Explicit lock (#89): owner confirms the taxonomy set; server then # --- Deterministic review: report ready/missing + the deterministic
# builds AND PERSISTS the deterministic title + short_description. Until # title/short_description built from the current locked metadata. The client's
# this action runs, the prose fields stay unset (no "ready" copy from an # "Create title" / "Create short desc" buttons call this endpoint and write the
# unlocked AI draft). Owner prose (title/short_description) always wins. # returned strings into the fields (item 1: no lock action, no metadata_locked).
if payload.get("action") == "lock": # #80: persist owner overrides (price/sku) from the payload so the review
if not slab.get("species_id"): # endpoint is self-contained. title/short_description are NOT overridden here —
return JSONResponse(status_code=422, content=_error( # they are produced by the deterministic build below, not persisted from the
"not_ready", "Select a species before locking the metadata")) # payload (so the review POST can't clobber the owner's Create workflow).
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
if not primary_id:
return JSONResponse(status_code=422, content=_error(
"not_ready", "Select a wood category before locking the metadata"))
cats, terms = _taxonomy_lookup(conn)
primary_leaf = cats.get(primary_id, {})
if not primary_leaf:
return JSONResponse(status_code=422, content=_error(
"not_ready", "Wood category is no longer in the current taxonomy"))
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
owner_title = (payload.get("title") or slab.get("title") or "").strip()
owner_short = (payload.get("short_description") or slab.get("short_description") or "").strip()
title = owner_title or build_title(slab, figure, species_name, primary_leaf)
short_desc = owner_short or build_short_description(slab, figure, species_name, primary_leaf)
now = _now()
cur = conn.execute(
"UPDATE slabs SET title=?, short_description=?, metadata_locked=1, server_rev=server_rev+1, updated_at=? WHERE id=?",
(title, short_desc, now, slab_id),
)
conn.commit()
if cur.rowcount == 0:
return JSONResponse(status_code=409, content=_error("not_found", "Slab not found"))
slab["title"] = title
slab["short_description"] = short_desc
slab["metadata_locked"] = 1
price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price})
return {
"ready": ready, "missing": missing, "title": title,
"short_description": short_desc, "price": price,
"price_suggestion": price_suggestion(conn, slab),
"primary_wood_category_id": primary_id, "locked": True,
}
# --- Regular review (no lock): report ready/missing + suggested prose.
# #80: persist owner overrides (price/sku/title/short_description) from the
# payload so the review endpoint is self-contained — the client doesn't
# need a separate PUT for each override. #89: deterministic
# title/short_description are NOT set until an explicit lock; report the
# stored values (which stay empty until locked) so the UI shows them as
# pending, and never as "ready" from an unlocked AI draft.
cats, terms = _taxonomy_lookup(conn) cats, terms = _taxonomy_lookup(conn)
primary_id = payload.get("primary_wood_category_id") primary_id = payload.get("primary_wood_category_id")
wood_cats = slab.get("wood_category_ids") or [] wood_cats = slab.get("wood_category_ids") or []
@@ -224,9 +174,9 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
figure_term_ids = slab.get("figure_term_ids") or [] figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
# #80: persist owner overrides from the payload. # #80: persist owner overrides from the payload (price/sku only).
overrides: dict[str, Any] = {} overrides: dict[str, Any] = {}
for k in ("price", "sku", "title", "short_description"): for k in ("price", "sku"):
if k in payload and payload[k] is not None: if k in payload and payload[k] is not None:
overrides[k] = payload[k] overrides[k] = payload[k]
if overrides: if overrides:
@@ -238,20 +188,17 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
conn.commit() conn.commit()
slab.update(overrides) slab.update(overrides)
if not locked: # Deterministic title / short_description, re-derived from the current
title = slab.get("title") or None # metadata on every review (the Create button's source of truth).
short_desc = slab.get("short_description") or None title = build_title(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("title") or None
else: short_desc = build_short_description(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("short_description") or None
# Locked: re-derive only if the owner has not overridden the prose.
title = slab.get("title") or build_title(slab, figure, species_name, primary_leaf)
short_desc = slab.get("short_description") or build_short_description(slab, figure, species_name, primary_leaf)
price = _resolve_price(conn, slab, payload) price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc}) ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
return { return {
"ready": ready, "missing": missing, "title": title, "short_description": short_desc, "ready": ready, "missing": missing, "title": title, "short_description": short_desc,
"price": price, "price_suggestion": price_suggestion(conn, slab), "price": price, "price_suggestion": price_suggestion(conn, slab),
"primary_wood_category_id": primary_id, "locked": locked, "primary_wood_category_id": primary_id,
} }
+4 -5
View File
@@ -65,9 +65,10 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
return out return out
# #89: server-managed — only the explicit review lock action may set it. A plain # #89: server-managed fields that a plain client PUT must not set directly.
# PUT must not flip the lock (the deterministic copy is built inside that action). # (metadata_locked was dropped — the deterministic title/short-desc are built by
CLIENT_FORBIDDEN_FIELDS = {"metadata_locked"} # the review endpoint and written back by the client's Create buttons.)
CLIENT_FORBIDDEN_FIELDS = set()
def _row_to_slab(row) -> Dict[str, Any]: def _row_to_slab(row) -> Dict[str, Any]:
@@ -78,8 +79,6 @@ def _row_to_slab(row) -> Dict[str, Any]:
d[f] = json.loads(d[f]) d[f] = json.loads(d[f])
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# #89: expose the lock flag to the client (0/None → False, 1 → True).
d["metadata_locked"] = bool(d.get("metadata_locked"))
return d return d
+23 -79
View File
@@ -55,19 +55,14 @@ def test_title_live_edge_no_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t")) ("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit() conn.commit()
# #89: title/short_description are built AND persisted only after an explicit lock. # #89: the review always returns the deterministic title/short_description
r = env[0].post("/api/v1/slabs/s1/review", json={"action": "lock"}) # 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 assert r.status_code == 200, r.text
body = r.json() body = r.json()
assert body["locked"] is True
assert body["title"] == "Figured Black Walnut Live Edge Slab" assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in") assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in")
assert "18.4 bdft" in body["short_description"] assert "18.4 bdft" in body["short_description"]
# GET shows the stored (persisted) copy — it matches the deterministic pattern.
got = env[0].get("/api/v1/slabs/s1").json()
assert got["title"] == "Figured Black Walnut Live Edge Slab"
assert got["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
assert got["metadata_locked"] is True
def test_ready_requires_full_set(env): def test_ready_requires_full_set(env):
@@ -141,103 +136,54 @@ def test_knife_blank_carries_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t")) ("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit() conn.commit()
# #89: build happens on lock. # #89: the deterministic build runs on every review.
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"}) r = client.post("/api/v1/slabs/s1/review", json={})
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)" assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
# --- #89: explicit lock gate for deterministic title/short_description --- def test_review_always_reports_deterministic_title(env):
"""#89: the review always returns the deterministic title/short_description
def test_review_before_lock_reports_no_title(env): built from the current metadata. The client's Create button writes these into
"""#89 AC: until lock, title/short_description are not set (no 'ready' copy the fields; the server does not persist them from the review payload."""
from an unlocked AI draft). A plain review reports the stored (empty) values."""
client, conn = env 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t")) ("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit() conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={}) r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json() body = r.json()
assert body["locked"] is False
assert body["title"] is None
assert body["short_description"] is None
# title/short_description are in the missing set (not ready until locked).
assert "title" in body["missing"] and "short_description" in body["missing"]
# Nothing was persisted: GET still shows empty copy.
assert client.get("/api/v1/slabs/s1").json()["title"] is None
def test_lock_persists_title_and_get_matches(env):
"""#89 AC: after lock with species + primary wood category, title/short_desc
are populated deterministically, stored, and GET reflects them."""
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={"action": "lock"})
assert r.status_code == 200
body = r.json()
assert body["locked"] is True
assert body["title"] == "Figured Black Walnut Live Edge Slab" 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() got = client.get("/api/v1/slabs/s1").json()
assert got["title"] == body["title"] assert got["title"] is None
assert got["short_description"] == body["short_description"] assert got["short_description"] is None
assert got["metadata_locked"] is True
def test_lock_requires_species(env): 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 client, conn = env
# No species_id -> lock is rejected with a plain message.
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", 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")) ("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() conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"}) r = client.post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 422
assert r.json()["error"]["code"] == "not_ready"
def test_lock_requires_wood_category(env):
client, conn = env
# species present but no wood category -> rejected.
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,1,json.dumps([]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.status_code == 422
assert r.json()["error"]["code"] == "not_ready"
def test_lock_owner_prose_wins(env):
# METADATA-50: typed owner title/short_description win over the pattern.
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={
"action": "lock",
"title": "My Handcrafted Walnut Slab",
"short_description": "Owner-authored short description.",
})
body = r.json() body = r.json()
assert body["title"] == "My Handcrafted Walnut Slab" assert body["title"] is None
assert body["short_description"] == "Owner-authored short description." assert "title" in body["missing"]
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] == "My Handcrafted Walnut Slab"
def test_review_persists_owner_overrides(env): def test_review_persists_owner_overrides(env):
"""#80: regular review (no lock) persists price/sku/title/short_description """#80: regular review persists price/sku overrides from the payload to the DB,
overrides from the payload to the DB, so the client doesn't need a separate so the client doesn't need a separate PUT for each. (title/short_description
PUT for each override.""" are NOT persisted here — they come from the deterministic build, not the payload.)"""
client, conn = env 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t")) ("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit() conn.commit()
# Review with owner overrides for price + sku + title + short_description. # Review with owner overrides for price + sku.
r = client.post("/api/v1/slabs/s1/review", json={ r = client.post("/api/v1/slabs/s1/review", json={
"price": 250.0, "price": 250.0,
"sku": "SLAB-99", "sku": "SLAB-99",
"title": "Owner Title",
"short_description": "Owner Short Desc",
}) })
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
@@ -246,7 +192,5 @@ def test_review_persists_owner_overrides(env):
got = client.get("/api/v1/slabs/s1").json() got = client.get("/api/v1/slabs/s1").json()
assert got["price"] == 250.0 assert got["price"] == 250.0
assert got["sku"] == "SLAB-99" assert got["sku"] == "SLAB-99"
assert got["title"] == "Owner Title"
assert got["short_description"] == "Owner Short Desc"
# server_rev was incremented (the UPDATE fired). # server_rev was incremented (the UPDATE fired).
assert got["server_rev"] == 2 assert got["server_rev"] == 2
+25
View File
@@ -51,3 +51,28 @@ Do not put a host name in the product spec.
- Live verification: served HTML and bundle contain zero occurrences of "Thickness band" or "set from the measurement". - 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). - Sample data unchanged (still 25 slabs post-recreate).
- All gates (unit, build, requirements, coverage, m3-create e2e) pass. - All gates (unit, build, requirements, coverage, m3-create e2e) pass.
### 2026-09-21 11:52 PDT — merge `issue-89-metadata-lock` → main (Create-string model)
- Source: branch `work/merge-89-90` (base `origin/issue-89-metadata-lock` + Create-string re-applied).
`matches HEAD`: **no** — staged from the uncommitted working tree (local sync; Gitea push not
attempted this round). 9 files changed: Review.svelte, slabs.ts, review.py, slabs.py, schema.sql,
db/__init__.py, test_review.py, m3-create.spec.ts, review-photo-strip.spec.ts.
- Model change: `metadata_locked` DB column + "Save and lock" UI **dropped end-to-end**. The
deterministic title/short_description are now built by the review endpoint on every call; the
owner's **Create title / Create short desc** buttons fire the build and write the result
(overwrite contract). `commitReview` auto-fills only empty fields (no owner-clobber); `createTitle`
is the explicit force-overwrite. Photo strip, wood primary/secondary, and server-side thickness
band (item: not shown in UI, passed to Woo on publish) all taken from the branch.
- LAN binds: `deploy/override-m3.yml` was absent from disk; the live stack's 10881/10882 LAN binds
were re-created by editing `deploy/docker-compose.yml` ports in-place (loopback 18082/18083 from
`.env` + `0.0.0.0:10881`/`0.0.0.0:10882`), then `down --remove-orphans` + `up -d` (no `-v`).
- Bundle: `assets/index-YPAqx609.js` (new; contains `create-title`). API `/api/health`
`woo_reachable:true`.
- Sample data preserved: **yes** — 34 slabs before/after.
- Live verification (LAN URL, real DB): `slabs` table has **no** `metadata_locked` column;
`slab_photos.role` present. `POST /review` on slab `ec4088da…` → `title:'Curl Black Walnut Slab'`,
`ready:True`. `PUT` title persisted (rev 1→2) then restored (original sample value intact, rev 3).
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
not exercised this round.
+6 -2
View File
@@ -16,7 +16,9 @@ services:
build: build:
context: .. context: ..
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"] ports:
- "127.0.0.1:${FRONTEND_PORT:-18080}:80"
- "0.0.0.0:10881:80"
depends_on: [fastapi] depends_on: [fastapi]
restart: unless-stopped restart: unless-stopped
@@ -34,7 +36,9 @@ services:
volumes: volumes:
- slab_db:/data - slab_db:/data
- slab_images:/images - slab_images:/images
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"] ports:
- "127.0.0.1:${FASTAPI_PORT:-18081}:8000"
- "0.0.0.0:10882:8000"
mem_limit: 2g mem_limit: 2g
restart: unless-stopped restart: unless-stopped
-13
View File
@@ -39,8 +39,6 @@ export interface Slab {
short_description: string | null; short_description: string | null;
description: string | null; description: string | null;
content_source: string | null; content_source: string | null;
// #89: set true once the owner has locked the taxonomy (deterministic copy built).
metadata_locked: boolean;
species_confidence: number | null; species_confidence: number | null;
figure_confidence: number | null; figure_confidence: number | null;
grade_confidence: number | null; grade_confidence: number | null;
@@ -271,8 +269,6 @@ export interface ReviewResult {
price: number | null; price: number | null;
price_suggestion: number | null; price_suggestion: number | null;
primary_wood_category_id: number | null; primary_wood_category_id: number | null;
// #89: whether the metadata is locked (deterministic copy built/persisted).
locked: boolean;
} }
export interface ReviewInput { export interface ReviewInput {
@@ -284,8 +280,6 @@ export interface ReviewInput {
description?: string; description?: string;
price_source?: string; price_source?: string;
content_source?: string; content_source?: string;
// #89: 'lock' triggers the explicit metadata-lock + deterministic copy build.
action?: 'lock';
} }
export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> { export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
@@ -296,13 +290,6 @@ export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<R
}).then((r) => parse<ReviewResult>(r)); }).then((r) => parse<ReviewResult>(r));
} }
// #89: explicit lock of the confirmed taxonomy. The server builds and persists
// the deterministic title/short_description from the locked set (owner prose
// already on the slab wins). Distinct from reviewSlab() which only reports.
export function lockSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
return reviewSlab(slabId, { ...payload, action: 'lock' });
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Publish (PUBLISH-*) // Publish (PUBLISH-*)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+126 -91
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { import {
getSlab, updateSlab, reviewSlab, lockSlab, getTaxonomy, getSettings, inferTaxon, inferContent, getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
listPhotos, persistLineup, photoUrl, ApiError, listPhotos, persistLineup, photoUrl, ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow, type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
} from '../../api/slabs'; } from '../../api/slabs';
@@ -109,14 +109,20 @@
ready: boolean; missing: string[]; title: string | null; ready: boolean; missing: string[]; title: string | null;
short_description: string | null; price: number | null; short_description: string | null; price: number | null;
price_suggestion: number | null; primary_wood_category_id: number | null; price_suggestion: number | null; primary_wood_category_id: number | null;
locked: boolean;
} }
let review: ReviewOut | null = null; let review: ReviewOut | null = null;
let reviewBusy = false; let reviewBusy = false;
let reviewError = ''; let reviewError = '';
// #89: explicit lock of the confirmed taxonomy set. // #89: explicit "Create" actions. `createBusy` covers the deterministic
let lockBusy = false; // title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
let lockError = ''; // category). `canCreateDesc` gates the AI long-description on title+short-desc
// being populated AND the content LLM being enabled.
let createBusy = false;
let createError = '';
$: canCreateTitle =
speciesId !== '' && woodCatIds.length > 0;
$: canCreateDesc =
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
// Monotonic token: each commit captures the current value and ignores its // Monotonic token: each commit captures the current value and ignores its
// result if a newer commit has since started. Without this, two concurrent // result if a newer commit has since started. Without this, two concurrent
// commits (e.g. a select change racing the SKU blur) resolve out of order and // commits (e.g. a select change racing the SKU blur) resolve out of order and
@@ -351,12 +357,29 @@
// owner sees here is the measured value in the summary above. // owner sees here is the measured value in the summary above.
const aiIn = (ids: number[]) => (id: number) => ids.includes(id); const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready. // Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
// #89: the deterministic title/short_description are written here — the server's
// review returns the build from the current metadata, and if the field is empty
// we fold the deterministic value into THIS same PUT (no extra request, no rev
// race). Owner-edited text is never clobbered (only an empty field is filled).
async function commitReview(): Promise<void> { async function commitReview(): Promise<void> {
if (!slab) return; if (!slab) return;
const myToken = ++commitToken; const myToken = ++commitToken;
reviewBusy = true; reviewBusy = true;
reviewError = ''; reviewError = '';
try { try {
// Run the deterministic review FIRST (reads the current metadata) to get the
// title/short_description build before we persist.
const probe = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
// so this PUT persists it (the Create buttons do the same, on demand).
if (probe.title && title === '') title = probe.title;
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
const patch = { const patch = {
species_id: speciesId === '' ? null : Number(speciesId), species_id: speciesId === '' ? null : Number(speciesId),
wood_category_ids: woodCatIds, wood_category_ids: woodCatIds,
@@ -368,7 +391,6 @@
feat_tag_ids: featTagIds, feat_tag_ids: featTagIds,
title: title || null, title: title || null,
short_description: shortDescription || null, short_description: shortDescription || null,
description: description || null,
content_source: contentSource || null, content_source: contentSource || null,
price: price === '' ? null : Number(price), price: price === '' ? null : Number(price),
price_source: priceSource, price_source: priceSource,
@@ -378,21 +400,14 @@
const fresh = await updateSlab(slabId, patch); const fresh = await updateSlab(slabId, patch);
if (myToken !== commitToken) return; // superseded; don't clobber the latest if (myToken !== commitToken) return; // superseded; don't clobber the latest
slab = fresh; slab = fresh;
if (primaryCatId !== '') { // Re-run the review against the just-persisted row (authoritative ready/missing).
const res = await reviewSlab(slabId, { review = await reviewSlab(slabId, {
primary_wood_category_id: Number(primaryCatId), primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
title: title || undefined, price: price === '' ? undefined : Number(price),
short_description: shortDescription || undefined, sku: sku || undefined,
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;
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) { } catch (e) {
if (myToken !== commitToken) return; if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.'; reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
@@ -402,46 +417,70 @@
} }
} }
// #89: explicit lock — persist the current (already-committed) taxonomy, then // #89: explicit "Create title" / "Create short desc" — fire the deterministic
// have the server build + store the deterministic title/short_description from // build from the current metadata and write the result into the fields,
// that locked set. Owner prose in the title/short-desc fields wins (METADATA-50). // overwriting whatever was there (empty, owner-typed, or previously generated).
// A commitReview() first ensures the reviewed Call 1 results are on the slab // That is the whole contract: the button fires the build. commitReview()
// before the lock reads them. // auto-fills only empty fields; this is the explicit "force overwrite" action.
async function lockMetadata(): Promise<void> { async function createTitle(): Promise<void> {
if (!slab || lockBusy) return; if (!slab || createBusy) return;
lockBusy = true; createBusy = true;
lockError = ''; createError = '';
// Persist the current taxonomy first (inner commit captures commitToken=N),
// THEN bump our token to N+1 so a later concurrent commit is ignored,
// while the inner commit's PUT is NOT invalidated (it saw token==N).
await commitReview();
const myToken = ++commitToken;
try { try {
const res = await lockSlab(slabId, { // 1. Run the deterministic review to get title / short_description.
const res = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId), primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
title: title || undefined,
short_description: shortDescription || undefined,
}); });
if (myToken !== commitToken) return;
review = res;
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id; 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.title) title = res.title;
if (res.short_description) shortDescription = res.short_description; if (res.short_description) shortDescription = res.short_description;
slab = await getSlab(slabId); // 3. Persist the generated prose (single PUT, fresh client_rev).
if (myToken !== commitToken) return; if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
const prosePatch = {
title: title || null,
short_description: shortDescription || null,
client_rev: (slab as Slab).server_rev,
};
const fresh = await updateSlab(slabId, prosePatch);
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
}
} catch (e) { } catch (e) {
if (myToken !== commitToken) return; createError = e instanceof ApiError ? e.message : 'Could not build the title. Try again.';
lockError = e instanceof ApiError ? e.message : 'Could not lock the metadata. Try again.';
} finally { } finally {
if (myToken === commitToken) lockBusy = false; createBusy = false;
} }
} }
// #89: the lock is available once the pattern inputs exist (species + a wood // #89: the description textarea commits on blur. commitReview() no longer sends
// category). Prefer the live editor model so the button enables before the // description (see #89), so this persists the owner-typed description explicitly.
// next commit finishes writing the slab row. async function commitDescription(): Promise<void> {
let canLock = false; if (!slab) return;
$: canLock = speciesId !== '' && woodCatIds.length > 0 && primaryCatId !== ''; 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.';
review = null;
} finally {
if (myToken === commitToken) reviewBusy = false;
}
}
async function runInference() { async function runInference() {
if (!slab) return; if (!slab) return;
@@ -475,14 +514,22 @@
} }
async function generateContent() { async function generateContent() {
if (!slab) return; if (!slab || !canCreateDesc) return;
genBusy = true; genBusy = true;
genError = ''; genError = '';
try { try {
const r = await inferContent(slabId); const r = await inferContent(slabId);
description = r.description; description = r.description;
contentSource = 'llm'; contentSource = 'llm';
await commitReview(); // commitReview() no longer sends description (see #89), so persist it explicitly.
const myToken = ++commitToken;
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
} catch (e) { } catch (e) {
genError = e instanceof ApiError ? e.message : 'Could not generate text.'; genError = e instanceof ApiError ? e.message : 'Could not generate text.';
} finally { } finally {
@@ -736,53 +783,39 @@
</select> </select>
</div> </div>
<!-- #89: explicit lock — the unmistakable "Call 1 → review → LOCK → copy" step. <!-- Title / Short description + explicit "Create" build buttons (#89) -->
Until this runs, title/short_description stay empty (no "ready" copy from
an unlocked AI draft). Owner prose already typed into the fields below wins. -->
<div class="lockbox" data-testid="lock-section">
{#if review?.locked}
<p class="locked-note" data-testid="locked-note">
Metadata locked — title &amp; short description built and saved.
</p>
{:else}
<p class="lock-hint">
{canLock
? 'Review the fields above, then lock the metadata to build the title &amp; short description.'
: 'Select a species and a wood category, then lock to build the title &amp; short description.'}
</p>
<button
type="button"
class="lock-btn"
data-testid="lock-metadata"
disabled={!canLock || lockBusy}
on:click={lockMetadata}
>
{lockBusy ? 'Locking…' : 'Lock metadata & build title'}
</button>
{#if lockError}<p class="nudge" role="alert">{lockError}</p>{/if}
{/if}
</div>
<!-- Title / Description -->
<div class="field"> <div class="field">
<label class="lbl" for="title">Title</label> <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>
<div class="field"> <div class="field">
<label class="lbl" for="short-desc">Short description</label> <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>
<div class="field"> <div class="field">
<label class="lbl" for="desc">Description</label> <label class="lbl" for="desc">Description</label>
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitReview}></textarea> <textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
{#if canGenerateContent} {#if canCreateDesc}
<div class="gen"> <div class="gen">
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}> <button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
{genBusy ? 'Writing…' : 'Generate text'} {genBusy ? 'Writing…' : 'Create desc'}
</button> </button>
</div> </div>
{/if} {/if}
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if} {#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
</div> </div>
<!-- Price / SKU (SKU last) --> <!-- Price / SKU (SKU last) -->
@@ -856,11 +889,13 @@
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; } .sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; } .gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */ /* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a3a2a; border-radius: 3px; background: #141a14; } /* #89: explicit "Create" build buttons beside the title / short-desc fields. */
.lockbox .lock-hint { color: #9fb89f; font-size: .78rem; margin: 0 0 8px; } .create-row { display: flex; gap: 8px; align-items: stretch; }
.lock-btn { box-sizing: border-box; width: 100%; padding: 11px; border: 1px solid #2a3a2a; border-radius: 3px; background: #1e3a28; color: #9fd3a8; font-weight: 700; font-size: .9rem; } .create-row .inp { flex: 1; }
.lock-btn:disabled { background: #2a2a2a; color: #777; border-color: #2a2a2a; } .create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
.locked-note { color: #7fd37f; font-size: .82rem; font-weight: 600; margin: 0; } 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 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 { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
+30 -47
View File
@@ -126,7 +126,6 @@ function baseSlab(): Record<string, unknown> {
short_description: null, short_description: null,
description: null, description: null,
content_source: 'manual', content_source: 'manual',
metadata_locked: false,
species_confidence: null, species_confidence: null,
figure_confidence: null, figure_confidence: null,
grade_confidence: null, grade_confidence: null,
@@ -169,56 +168,41 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids', 'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids', 'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
'title', 'short_description', 'description', 'content_source', 'price', 'price_source', 'title', 'short_description', 'description', 'content_source', 'price', 'price_source',
'metadata_locked',
]) { ]) {
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k]; 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; if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
}; };
// #89: deterministic copy is built only on an explicit lock. Before lock the const reviewResult = (payload: Record<string, unknown>) => {
// review endpoint reports the stored (empty) title/short_description.
const buildCopy = () => {
const speciesName = TAXONOMY.categories.find((c) => c.id === slab.species_id)?.name ?? 'Slab';
const fig = Array.isArray(slab.figure_term_ids) && slab.figure_term_ids.length
? 'Figured'
: '';
return `${fig} ${speciesName} Live Edge Slab`;
};
const reviewResult = (payload: Record<string, unknown>) => {
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id; if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
if (typeof payload.price === 'number') slab.price = payload.price; if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku; if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
// Explicit lock (#89): persist the deterministic copy + lock flag. // #89: the review ALWAYS returns the deterministic title/short_description built
if (payload.action === 'lock') { // from the current metadata (the Create button's source of truth). The server
const title = (typeof payload.title === 'string' && payload.title) // does NOT persist title/short_description from the review payload.
? (payload.title as string) const hasSpecies = !!slab.species_id;
: buildCopy(); const hasWoodCat = Array.isArray(slab.wood_category_ids) && slab.wood_category_ids.length > 0;
(slab as Record<string, unknown>).title = title; const deterministicTitle =
(slab as Record<string, unknown>).short_description = `${title}, 4ft 0in x 11in x 2in, 12.5 bdft`; hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab' : null;
(slab as Record<string, unknown>).metadata_locked = true; const deterministicShort =
} hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab, 4ft 0in x 11in x 2in, 12.5 bdft' : null;
const locked = (slab.metadata_locked as boolean) === true;
const missing: string[] = []; const missing: string[] = [];
if (!slab.sku) missing.push('sku'); if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species'); if (!slab.species_id) missing.push('species');
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories'); 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.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'); if (slab.price == null) missing.push('price');
return { return {
ready: missing.length === 0, ready: missing.length === 0,
missing, missing,
title: (slab.title as string) ?? null, title: deterministicTitle,
short_description: (slab.short_description as string) ?? null, short_description: deterministicShort,
price: (slab.price as number) ?? null, price: (slab.price as number) ?? null,
price_suggestion: 120, price_suggestion: 120,
primary_wood_category_id: primary, primary_wood_category_id: primary,
locked,
}; };
}; };
@@ -332,12 +316,11 @@ async function completeForReady(page: Page) {
await page.getByTestId('wood-cat-done').click(); await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31'); await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120'); await page.getByTestId('price').fill('120');
// #89: lock the confirmed metadata so the server builds + persists the // #89: fire the deterministic build so the title/short_description populate.
// deterministic title/short_description. Do this BEFORE the SKU blur so a // Do this BEFORE the SKU blur so a subsequent plain review doesn't race.
// subsequent plain review can't clobber the locked-note banner. await expect(page.getByTestId('create-title')).toBeEnabled();
await expect(page.getByTestId('lock-metadata')).toBeEnabled(); await page.getByTestId('create-title').click();
await page.getByTestId('lock-metadata').click(); // Wait for the build to complete (title field populated = deterministic copy).
// Wait for the lock to complete (title field populated = lock persisted copy).
await expect(page.getByTestId('title')).not.toHaveValue(''); await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001'); await page.getByTestId('sku').fill('SLAB-0001');
// SKU commits on blur — press Tab to move focus off it (deterministic, single // SKU commits on blur — press Tab to move focus off it (deterministic, single
@@ -347,15 +330,14 @@ async function completeForReady(page: Page) {
} }
test.describe('M3 Review→Publish flow (#77)', () => { test.describe('M3 Review→Publish flow (#77)', () => {
test('golden path: capture→review→lock→ready→publish→done (#77, #89)', async ({ page }) => { test('golden path: capture→review→create→ready→publish→done (#77, #89)', async ({ page }) => {
const { slab } = await mockApi(page); const { slab } = await mockApi(page);
await reachReview(page); await reachReview(page);
// Not ready until fields are set; copy is empty until an explicit lock. // Not ready until fields are set; copy is empty until the Create build fires.
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/); await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
await expect(page.getByTestId('title')).toHaveValue(''); await expect(page.getByTestId('title')).toHaveValue('');
await completeForReady(page); await completeForReady(page);
// #89: the lock persisted the deterministic copy on the slab row. // #89: the Create build populated the deterministic copy on the slab row.
expect(slab.metadata_locked).toBe(true);
expect(slab.title).toBeTruthy(); expect(slab.title).toBeTruthy();
expect(slab.short_description).toBeTruthy(); expect(slab.short_description).toBeTruthy();
// Continue to Publish is enabled and hands off. // Continue to Publish is enabled and hands off.
@@ -396,17 +378,17 @@ test.describe('M3 Review→Publish flow (#77)', () => {
await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID); await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID);
}); });
test('thickness band is read-only and shows the derived band (#90, METADATA-15)', async ({ page }) => { test('thickness band is server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
await mockApi(page); await mockApi(page);
await reachReview(page); await reachReview(page);
// The band is derived server-side, so Review displays it as data. // METADATA-15 / QUALITY-95: the band is derived server-side from the measured
const band = page.getByTestId('thickness-band'); // thickness against the live synced Woo bands. It is deliberately ABSENT from
await expect(band).toBeVisible(); // this screen — no owner control, no read-only echo. The only thickness the
await expect(band).toContainText('2" - 3"'); // 2 in rounds into the live band // owner sees is the measured value in the summary. (Commit 204bc5e dropped the
// There is no owner control for it anywhere on Review — not a select, not an input. // 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); await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
expect(await band.evaluate((el) => el.tagName)).toBe('P');
}); });
/** /**
@@ -476,7 +458,7 @@ test.describe('M3 Review→Publish flow (#77)', () => {
band: box('[data-testid="thickness-band"]'), band: box('[data-testid="thickness-band"]'),
primarySet: box('[data-testid="primary-wood-set"]'), primarySet: box('[data-testid="primary-wood-set"]'),
primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null, primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null,
lockBtn: box('[data-testid="lock-metadata"]'), createBtn: box('[data-testid="create-title"]'),
continueBtn: box('[data-testid="continue-publish"]'), continueBtn: box('[data-testid="continue-publish"]'),
}; };
}); });
@@ -495,7 +477,8 @@ test.describe('M3 Review→Publish flow (#77)', () => {
expect(metrics.summary?.text).toMatch(/Bowl blanks/i); expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
expect(metrics.count?.text).toContain('2 selected'); expect(metrics.count?.text).toContain('2 selected');
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w); expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
expect(metrics.band?.text).toContain('2" - 3"'); // Band is deliberately absent from Review (METADATA-15) — no element to measure.
expect(metrics.band).toBeNull();
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32); expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
} }
console.log('RENDER REPORT\n' + report.join('\n')); console.log('RENDER REPORT\n' + report.join('\n'));
@@ -43,7 +43,7 @@ function baseSlab(): Record<string, unknown> {
species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null, 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: [], thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
title: null, short_description: null, description: null, content_source: 'manual', title: null, short_description: null, description: null, content_source: 'manual',
metadata_locked: false, species_confidence: null, figure_confidence: null, grade_confidence: null, species_confidence: null, figure_confidence: null, grade_confidence: null,
inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0, 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', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
}; };
@@ -66,7 +66,7 @@ async function mockApi(page: Page) {
const merge = (payload: Record<string, unknown>) => { const merge = (payload: Record<string, unknown>) => {
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false; 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', 'metadata_locked', 'edge_type_term_id']) { 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 (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
} }
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1; if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
@@ -91,7 +91,7 @@ async function mockApi(page: Page) {
title: (slab.title as string) ?? null, title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null, short_description: (slab.short_description as string) ?? null,
price: (slab.price as number) ?? null, price_suggestion: 120, price: (slab.price as number) ?? null, price_suggestion: 120,
primary_wood_category_id: null, locked: slab.metadata_locked === true, primary_wood_category_id: null,
}; };
}; };
@@ -181,8 +181,8 @@ async function completeForReady(page: Page) {
await page.getByTestId('wood-cat-done').click(); await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31'); await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120'); await page.getByTestId('price').fill('120');
await expect(page.getByTestId('lock-metadata')).toBeEnabled(); await expect(page.getByTestId('create-title')).toBeEnabled();
await page.getByTestId('lock-metadata').click(); await page.getByTestId('create-title').click();
await expect(page.getByTestId('title')).not.toHaveValue(''); await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001'); await page.getByTestId('sku').fill('SLAB-0001');
await page.getByTestId('sku').press('Tab'); await page.getByTestId('sku').press('Tab');