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:
@@ -55,7 +55,6 @@ _SCHEMA_SENTINELS = [
|
||||
("settings", "value"),
|
||||
("slabs", "deleted_at"),
|
||||
("slabs", "server_rev"),
|
||||
("slabs", "metadata_locked"),
|
||||
("slab_photos", "original_blob"),
|
||||
("sync_log", "payload_hash"),
|
||||
]
|
||||
|
||||
@@ -35,9 +35,6 @@ CREATE TABLE IF NOT EXISTS slabs (
|
||||
species_confidence REAL,
|
||||
figure_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),
|
||||
calibration_photo_id TEXT,
|
||||
client_rev INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
+15
-68
@@ -155,65 +155,15 @@ 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)
|
||||
locked = bool(slab.get("metadata_locked"))
|
||||
|
||||
# --- Explicit lock (#89): owner confirms the taxonomy set; server then
|
||||
# builds AND PERSISTS the deterministic title + short_description. Until
|
||||
# this action runs, the prose fields stay unset (no "ready" copy from an
|
||||
# unlocked AI draft). Owner prose (title/short_description) always wins.
|
||||
if payload.get("action") == "lock":
|
||||
if not slab.get("species_id"):
|
||||
return JSONResponse(status_code=422, content=_error(
|
||||
"not_ready", "Select a species before locking the metadata"))
|
||||
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.
|
||||
# --- Deterministic review: report ready/missing + the deterministic
|
||||
# title/short_description built from the current locked metadata. The client's
|
||||
# "Create title" / "Create short desc" buttons call this endpoint and write the
|
||||
# returned strings into the fields (item 1: no lock action, no metadata_locked).
|
||||
# #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_id = payload.get("primary_wood_category_id")
|
||||
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 = 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] = {}
|
||||
for k in ("price", "sku", "title", "short_description"):
|
||||
for k in ("price", "sku"):
|
||||
if k in payload and payload[k] is not None:
|
||||
overrides[k] = payload[k]
|
||||
if overrides:
|
||||
@@ -238,20 +188,17 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
||||
conn.commit()
|
||||
slab.update(overrides)
|
||||
|
||||
if not locked:
|
||||
title = slab.get("title") or None
|
||||
short_desc = slab.get("short_description") or None
|
||||
else:
|
||||
# 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)
|
||||
# Deterministic title / short_description, re-derived from the current
|
||||
# metadata on every review (the Create button's source of truth).
|
||||
title = build_title(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("title") or None
|
||||
short_desc = build_short_description(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("short_description") or None
|
||||
|
||||
price = _resolve_price(conn, slab, payload)
|
||||
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
|
||||
return {
|
||||
"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": locked,
|
||||
"primary_wood_category_id": primary_id,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+4
-5
@@ -65,9 +65,10 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
# #89: server-managed — only the explicit review lock action may set it. A plain
|
||||
# PUT must not flip the lock (the deterministic copy is built inside that action).
|
||||
CLIENT_FORBIDDEN_FIELDS = {"metadata_locked"}
|
||||
# #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]:
|
||||
@@ -78,8 +79,6 @@ def _row_to_slab(row) -> Dict[str, Any]:
|
||||
d[f] = json.loads(d[f])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# #89: expose the lock flag to the client (0/None → False, 1 → True).
|
||||
d["metadata_locked"] = bool(d.get("metadata_locked"))
|
||||
return d
|
||||
|
||||
|
||||
|
||||
@@ -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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("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: title/short_description are built AND persisted only after an explicit lock.
|
||||
r = env[0].post("/api/v1/slabs/s1/review", json={"action": "lock"})
|
||||
# #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()
|
||||
assert body["locked"] is True
|
||||
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 "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):
|
||||
@@ -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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("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: build happens on lock.
|
||||
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
|
||||
# #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)"
|
||||
|
||||
|
||||
# --- #89: explicit lock gate for deterministic title/short_description ---
|
||||
|
||||
def test_review_before_lock_reports_no_title(env):
|
||||
"""#89 AC: until lock, title/short_description are not set (no 'ready' copy
|
||||
from an unlocked AI draft). A plain review reports the stored (empty) values."""
|
||||
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["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["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"] == body["title"]
|
||||
assert got["short_description"] == body["short_description"]
|
||||
assert got["metadata_locked"] is True
|
||||
assert got["title"] is None
|
||||
assert got["short_description"] is None
|
||||
|
||||
|
||||
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
|
||||
# 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("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={"action": "lock"})
|
||||
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.",
|
||||
})
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["title"] == "My Handcrafted Walnut Slab"
|
||||
assert body["short_description"] == "Owner-authored short description."
|
||||
got = client.get("/api/v1/slabs/s1").json()
|
||||
assert got["title"] == "My Handcrafted Walnut Slab"
|
||||
assert body["title"] is None
|
||||
assert "title" in body["missing"]
|
||||
|
||||
|
||||
def test_review_persists_owner_overrides(env):
|
||||
"""#80: regular review (no lock) persists price/sku/title/short_description
|
||||
overrides from the payload to the DB, so the client doesn't need a separate
|
||||
PUT for each override."""
|
||||
"""#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 + title + short_description.
|
||||
# Review with owner overrides for price + sku.
|
||||
r = client.post("/api/v1/slabs/s1/review", json={
|
||||
"price": 250.0,
|
||||
"sku": "SLAB-99",
|
||||
"title": "Owner Title",
|
||||
"short_description": "Owner Short Desc",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
@@ -246,7 +192,5 @@ def test_review_persists_owner_overrides(env):
|
||||
got = client.get("/api/v1/slabs/s1").json()
|
||||
assert got["price"] == 250.0
|
||||
assert got["sku"] == "SLAB-99"
|
||||
assert got["title"] == "Owner Title"
|
||||
assert got["short_description"] == "Owner Short Desc"
|
||||
# server_rev was incremented (the UPDATE fired).
|
||||
assert got["server_rev"] == 2
|
||||
|
||||
@@ -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".
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -39,8 +39,6 @@ export interface Slab {
|
||||
short_description: string | null;
|
||||
description: 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;
|
||||
figure_confidence: number | null;
|
||||
grade_confidence: number | null;
|
||||
@@ -271,8 +269,6 @@ export interface ReviewResult {
|
||||
price: number | null;
|
||||
price_suggestion: number | null;
|
||||
primary_wood_category_id: number | null;
|
||||
// #89: whether the metadata is locked (deterministic copy built/persisted).
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewInput {
|
||||
@@ -284,8 +280,6 @@ export interface ReviewInput {
|
||||
description?: string;
|
||||
price_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> {
|
||||
@@ -296,13 +290,6 @@ export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<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-*)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, lockSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
listPhotos, persistLineup, photoUrl, ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
|
||||
} from '../../api/slabs';
|
||||
@@ -109,14 +109,20 @@
|
||||
ready: boolean; missing: string[]; title: string | null;
|
||||
short_description: string | null; price: number | null;
|
||||
price_suggestion: number | null; primary_wood_category_id: number | null;
|
||||
locked: boolean;
|
||||
}
|
||||
let review: ReviewOut | null = null;
|
||||
let reviewBusy = false;
|
||||
let reviewError = '';
|
||||
// #89: explicit lock of the confirmed taxonomy set.
|
||||
let lockBusy = false;
|
||||
let lockError = '';
|
||||
// #89: explicit "Create" actions. `createBusy` covers the deterministic
|
||||
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
|
||||
// category). `canCreateDesc` gates the AI long-description on title+short-desc
|
||||
// being populated AND the content LLM being enabled.
|
||||
let createBusy = false;
|
||||
let createError = '';
|
||||
$: canCreateTitle =
|
||||
speciesId !== '' && woodCatIds.length > 0;
|
||||
$: canCreateDesc =
|
||||
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
||||
// Monotonic token: each commit captures the current value and ignores its
|
||||
// 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
|
||||
@@ -351,12 +357,29 @@
|
||||
// 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),
|
||||
wood_category_ids: woodCatIds,
|
||||
@@ -368,7 +391,6 @@
|
||||
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,
|
||||
@@ -378,21 +400,14 @@
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
// 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.';
|
||||
@@ -402,46 +417,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
// #89: explicit lock — persist the current (already-committed) taxonomy, then
|
||||
// have the server build + store the deterministic title/short_description from
|
||||
// that locked set. Owner prose in the title/short-desc fields wins (METADATA-50).
|
||||
// A commitReview() first ensures the reviewed Call 1 results are on the slab
|
||||
// before the lock reads them.
|
||||
async function lockMetadata(): Promise<void> {
|
||||
if (!slab || lockBusy) return;
|
||||
lockBusy = true;
|
||||
lockError = '';
|
||||
// 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;
|
||||
// #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 {
|
||||
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),
|
||||
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;
|
||||
// 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;
|
||||
slab = await getSlab(slabId);
|
||||
if (myToken !== commitToken) return;
|
||||
// 3. Persist the generated prose (single PUT, fresh client_rev).
|
||||
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
|
||||
const prosePatch = {
|
||||
title: title || null,
|
||||
short_description: shortDescription || null,
|
||||
client_rev: (slab as Slab).server_rev,
|
||||
};
|
||||
const fresh = await updateSlab(slabId, prosePatch);
|
||||
slab = fresh;
|
||||
review = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (myToken !== commitToken) return;
|
||||
lockError = e instanceof ApiError ? e.message : 'Could not lock the metadata. Try again.';
|
||||
createError = e instanceof ApiError ? e.message : 'Could not build the title. Try again.';
|
||||
} finally {
|
||||
if (myToken === commitToken) lockBusy = false;
|
||||
createBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// #89: the lock is available once the pattern inputs exist (species + a wood
|
||||
// category). Prefer the live editor model so the button enables before the
|
||||
// next commit finishes writing the slab row.
|
||||
let canLock = false;
|
||||
$: canLock = speciesId !== '' && woodCatIds.length > 0 && primaryCatId !== '';
|
||||
// #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.';
|
||||
review = null;
|
||||
} finally {
|
||||
if (myToken === commitToken) reviewBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runInference() {
|
||||
if (!slab) return;
|
||||
@@ -475,14 +514,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 {
|
||||
@@ -736,53 +783,39 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- #89: explicit lock — the unmistakable "Call 1 → review → LOCK → copy" step.
|
||||
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 & short description built and saved.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="lock-hint">
|
||||
{canLock
|
||||
? 'Review the fields above, then lock the metadata to build the title & short description.'
|
||||
: 'Select a species and a wood category, then lock to build the title & 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 -->
|
||||
<!-- 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) -->
|
||||
@@ -856,11 +889,13 @@
|
||||
.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. */
|
||||
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a3a2a; border-radius: 3px; background: #141a14; }
|
||||
.lockbox .lock-hint { color: #9fb89f; font-size: .78rem; margin: 0 0 8px; }
|
||||
.lock-btn { box-sizing: border-box; width: 100%; padding: 11px; border: 1px solid #2a3a2a; border-radius: 3px; background: #1e3a28; color: #9fd3a8; font-weight: 700; font-size: .9rem; }
|
||||
.lock-btn:disabled { background: #2a2a2a; color: #777; border-color: #2a2a2a; }
|
||||
.locked-note { color: #7fd37f; font-size: .82rem; font-weight: 600; margin: 0; }
|
||||
/* #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; }
|
||||
|
||||
@@ -126,7 +126,6 @@ function baseSlab(): Record<string, unknown> {
|
||||
short_description: null,
|
||||
description: null,
|
||||
content_source: 'manual',
|
||||
metadata_locked: false,
|
||||
species_confidence: null,
|
||||
figure_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',
|
||||
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
|
||||
'title', 'short_description', 'description', 'content_source', 'price', 'price_source',
|
||||
'metadata_locked',
|
||||
]) {
|
||||
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;
|
||||
};
|
||||
|
||||
// #89: deterministic copy is built only on an explicit lock. Before lock the
|
||||
// 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>) => {
|
||||
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;
|
||||
|
||||
// Explicit lock (#89): persist the deterministic copy + lock flag.
|
||||
if (payload.action === 'lock') {
|
||||
const title = (typeof payload.title === 'string' && payload.title)
|
||||
? (payload.title as string)
|
||||
: buildCopy();
|
||||
(slab as Record<string, unknown>).title = title;
|
||||
(slab as Record<string, unknown>).short_description = `${title}, 4ft 0in x 11in x 2in, 12.5 bdft`;
|
||||
(slab as Record<string, unknown>).metadata_locked = true;
|
||||
}
|
||||
// #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 locked = (slab.metadata_locked as boolean) === true;
|
||||
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');
|
||||
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,
|
||||
locked,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -332,12 +316,11 @@ 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: lock the confirmed metadata so the server builds + persists the
|
||||
// deterministic title/short_description. Do this BEFORE the SKU blur so a
|
||||
// subsequent plain review can't clobber the locked-note banner.
|
||||
await expect(page.getByTestId('lock-metadata')).toBeEnabled();
|
||||
await page.getByTestId('lock-metadata').click();
|
||||
// Wait for the lock to complete (title field populated = lock persisted copy).
|
||||
// #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
|
||||
@@ -347,15 +330,14 @@ async function completeForReady(page: Page) {
|
||||
}
|
||||
|
||||
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);
|
||||
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('title')).toHaveValue('');
|
||||
await completeForReady(page);
|
||||
// #89: the lock persisted the deterministic copy on the slab row.
|
||||
expect(slab.metadata_locked).toBe(true);
|
||||
// #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.
|
||||
@@ -396,17 +378,17 @@ test.describe('M3 Review→Publish flow (#77)', () => {
|
||||
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 reachReview(page);
|
||||
|
||||
// The band is derived server-side, so Review displays it as data.
|
||||
const band = page.getByTestId('thickness-band');
|
||||
await expect(band).toBeVisible();
|
||||
await expect(band).toContainText('2" - 3"'); // 2 in rounds into the live band
|
||||
// There is no owner control for it anywhere on Review — not a select, not an input.
|
||||
// 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);
|
||||
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"]'),
|
||||
primarySet: box('[data-testid="primary-wood-set"]'),
|
||||
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"]'),
|
||||
};
|
||||
});
|
||||
@@ -495,7 +477,8 @@ test.describe('M3 Review→Publish flow (#77)', () => {
|
||||
expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
|
||||
expect(metrics.count?.text).toContain('2 selected');
|
||||
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
|
||||
expect(metrics.band?.text).toContain('2" - 3"');
|
||||
// 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'));
|
||||
|
||||
@@ -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,
|
||||
thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
|
||||
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,
|
||||
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>) => {
|
||||
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 (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,
|
||||
short_description: (slab.short_description as string) ?? null,
|
||||
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('edge-type').selectOption('31');
|
||||
await page.getByTestId('price').fill('120');
|
||||
await expect(page.getByTestId('lock-metadata')).toBeEnabled();
|
||||
await page.getByTestId('lock-metadata').click();
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user