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"),
|
("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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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-*)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
|
||||||
short_description: shortDescription || undefined,
|
|
||||||
price: price === '' ? undefined : Number(price),
|
price: price === '' ? undefined : Number(price),
|
||||||
sku: sku || undefined,
|
sku: sku || undefined,
|
||||||
});
|
});
|
||||||
if (myToken !== commitToken) return;
|
if (myToken !== commitToken) return;
|
||||||
review = res;
|
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
|
||||||
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
|
|
||||||
} 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 & 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 -->
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl" for="title">Title</label>
|
<label class="lbl" for="title">Title</label>
|
||||||
|
<div class="create-row">
|
||||||
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
||||||
|
<button type="button" class="create-btn" data-testid="create-title"
|
||||||
|
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||||||
|
{createBusy ? 'Building…' : 'Create title'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl" for="short-desc">Short description</label>
|
<label class="lbl" for="short-desc">Short description</label>
|
||||||
|
<div class="create-row">
|
||||||
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
||||||
|
<button type="button" class="create-btn" data-testid="create-short-desc"
|
||||||
|
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||||||
|
{createBusy ? 'Building…' : 'Create short desc'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl" for="desc">Description</label>
|
<label class="lbl" for="desc">Description</label>
|
||||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitReview}></textarea>
|
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
|
||||||
{#if canGenerateContent}
|
{#if canCreateDesc}
|
||||||
<div class="gen">
|
<div class="gen">
|
||||||
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}>
|
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
|
||||||
{genBusy ? 'Writing…' : 'Generate text'}
|
{genBusy ? 'Writing…' : 'Create desc'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||||||
|
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Price / SKU (SKU last) -->
|
<!-- Price / SKU (SKU last) -->
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
|
||||||
// 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.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');
|
||||||
|
|||||||
Reference in New Issue
Block a user