fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review
The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.
- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
ready→publish→done); mock respects optimistic concurrency (client_rev) so
stale PUTs 409 like the real backend
This commit is contained in:
@@ -55,6 +55,7 @@ _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,6 +35,9 @@ 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,
|
||||||
|
|||||||
+85
-31
@@ -5,6 +5,7 @@ METADATA-80/85/90, SETTINGS-35.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
@@ -44,6 +45,10 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
|
|||||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _split_length(length_in: float) -> tuple[int, float]:
|
def _split_length(length_in: float) -> tuple[int, float]:
|
||||||
ft = int(length_in // 12)
|
ft = int(length_in // 12)
|
||||||
rem = round(length_in - ft * 12, 1)
|
rem = round(length_in - ft * 12, 1)
|
||||||
@@ -150,15 +155,63 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
|||||||
if row is None:
|
if row is None:
|
||||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||||
slab = _slab_dict(row)
|
slab = _slab_dict(row)
|
||||||
# Apply any review edits (primary category declaration + prose).
|
locked = bool(slab.get("metadata_locked"))
|
||||||
editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"}
|
|
||||||
for k in editable:
|
# --- Explicit lock (#89): owner confirms the taxonomy set; server then
|
||||||
if k in payload and payload[k] is not None:
|
# builds AND PERSISTS the deterministic title + short_description. Until
|
||||||
if k == "primary_wood_category_id":
|
# this action runs, the prose fields stay unset (no "ready" copy from an
|
||||||
continue # not stored as a column; derived
|
# unlocked AI draft). Owner prose (title/short_description) always wins.
|
||||||
slab[k] = payload[k]
|
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.
|
||||||
|
# #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 wood category: declared, else first wood_category_ids, else first category.
|
|
||||||
primary_id = payload.get("primary_wood_category_id")
|
primary_id = payload.get("primary_wood_category_id")
|
||||||
wood_cats = slab.get("wood_category_ids") or []
|
wood_cats = slab.get("wood_category_ids") or []
|
||||||
if not primary_id:
|
if not primary_id:
|
||||||
@@ -168,28 +221,29 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
|||||||
figure_term_ids = slab.get("figure_term_ids") or []
|
figure_term_ids = slab.get("figure_term_ids") or []
|
||||||
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
||||||
|
|
||||||
# Deterministic copy (only if not already overridden by owner).
|
if not locked:
|
||||||
if not payload.get("title"):
|
title = slab.get("title") or None
|
||||||
if primary_leaf and species_name:
|
short_desc = slab.get("short_description") or None
|
||||||
slab["title"] = build_title(slab, figure, species_name, primary_leaf)
|
else:
|
||||||
if not payload.get("short_description"):
|
# Locked: re-derive only if the owner has not overridden the prose.
|
||||||
if primary_leaf and species_name:
|
title = slab.get("title") or build_title(slab, figure, species_name, primary_leaf)
|
||||||
slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf)
|
short_desc = slab.get("short_description") or build_short_description(slab, figure, species_name, primary_leaf)
|
||||||
# Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks.
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
|
||||||
|
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
|
||||||
|
else the stored price, else the suggestion (None if no rate)."""
|
||||||
suggested = price_suggestion(conn, slab)
|
suggested = price_suggestion(conn, slab)
|
||||||
if payload.get("price") is not None:
|
if payload.get("price") is not None:
|
||||||
price = payload["price"]
|
return payload["price"]
|
||||||
elif slab.get("price") not in (None, 0):
|
if slab.get("price") not in (None, 0):
|
||||||
price = slab["price"]
|
return slab["price"]
|
||||||
else:
|
return suggested
|
||||||
price = suggested
|
|
||||||
ready, missing = compute_ready({**slab, "price": price})
|
|
||||||
return {
|
|
||||||
"ready": ready,
|
|
||||||
"missing": missing,
|
|
||||||
"title": slab.get("title"),
|
|
||||||
"short_description": slab.get("short_description"),
|
|
||||||
"price": price,
|
|
||||||
"price_suggestion": suggested,
|
|
||||||
"primary_wood_category_id": primary_id,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ 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
|
||||||
|
# PUT must not flip the lock (the deterministic copy is built inside that action).
|
||||||
|
CLIENT_FORBIDDEN_FIELDS = {"metadata_locked"}
|
||||||
|
|
||||||
|
|
||||||
def _row_to_slab(row) -> Dict[str, Any]:
|
def _row_to_slab(row) -> Dict[str, Any]:
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
for f in JSON_ARRAY_FIELDS:
|
for f in JSON_ARRAY_FIELDS:
|
||||||
@@ -72,6 +77,8 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +162,11 @@ async def get_slab(id: str) -> Dict[str, Any]:
|
|||||||
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if "client_rev" not in payload:
|
if "client_rev" not in payload:
|
||||||
return _err("validation", "client_rev required", status=400)
|
return _err("validation", "client_rev required", status=400)
|
||||||
|
# #89: the metadata lock is server-managed via the explicit review lock
|
||||||
|
# action; a plain PUT may not flip it.
|
||||||
|
for field in CLIENT_FORBIDDEN_FIELDS:
|
||||||
|
if field in payload:
|
||||||
|
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
||||||
if current is None:
|
if current is None:
|
||||||
|
|||||||
@@ -55,12 +55,19 @@ 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()
|
||||||
r = env[0].post("/api/v1/slabs/s1/review", json={})
|
# #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"})
|
||||||
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):
|
||||||
@@ -134,5 +141,84 @@ 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()
|
||||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
# #89: build happens on lock.
|
||||||
|
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
|
||||||
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_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."""
|
||||||
|
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"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_requires_species(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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("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.",
|
||||||
|
})
|
||||||
|
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"
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ 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;
|
||||||
@@ -220,6 +222,8 @@ 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 {
|
||||||
@@ -231,6 +235,8 @@ 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> {
|
||||||
@@ -241,6 +247,13 @@ 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, getTaxonomy, getSettings, inferTaxon, inferContent,
|
getSlab, updateSlab, reviewSlab, lockSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||||
ApiError,
|
ApiError,
|
||||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
|
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
|
||||||
} from '../../api/slabs';
|
} from '../../api/slabs';
|
||||||
@@ -105,10 +105,14 @@
|
|||||||
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.
|
||||||
|
let lockBusy = false;
|
||||||
|
let lockError = '';
|
||||||
// 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
|
||||||
@@ -210,6 +214,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #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;
|
||||||
|
try {
|
||||||
|
const res = await lockSlab(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;
|
||||||
|
if (res.title) title = res.title;
|
||||||
|
if (res.short_description) shortDescription = res.short_description;
|
||||||
|
slab = await getSlab(slabId);
|
||||||
|
if (myToken !== commitToken) return;
|
||||||
|
} catch (e) {
|
||||||
|
if (myToken !== commitToken) return;
|
||||||
|
lockError = e instanceof ApiError ? e.message : 'Could not lock the metadata. Try again.';
|
||||||
|
} finally {
|
||||||
|
if (myToken === commitToken) lockBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #89: the lock is available once the pattern inputs exist (species + a wood
|
||||||
|
// category). The button is the unmistakable "Call 1 → review → lock → copy" step.
|
||||||
|
let canLock = false;
|
||||||
|
$: canLock = !!slab?.species_id && (slab?.wood_category_ids?.length ?? 0) > 0;
|
||||||
|
|
||||||
async function runInference() {
|
async function runInference() {
|
||||||
if (!slab) return;
|
if (!slab) return;
|
||||||
aiBusy = true;
|
aiBusy = true;
|
||||||
@@ -425,6 +469,33 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 / Description -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl" for="title">Title</label>
|
<label class="lbl" for="title">Title</label>
|
||||||
@@ -510,6 +581,11 @@
|
|||||||
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||||||
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||||||
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
||||||
|
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a4a2a; border-radius: 3px; background: #12210f; }
|
||||||
|
.lockbox .lock-hint { color: #9fb89f; font-size: .78rem; margin: 0 0 8px; }
|
||||||
|
.lock-btn { width: 100%; padding: 11px; border: 0; border-radius: 3px; background: #37d05f; color: #002; font-weight: 700; font-size: .9rem; }
|
||||||
|
.lock-btn:disabled { background: #2a2a2a; color: #777; }
|
||||||
|
.locked-note { color: #7fd37f; font-size: .82rem; font-weight: 600; margin: 0; }
|
||||||
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||||||
.status.ok { background: #122a12; color: #7fd37f; }
|
.status.ok { background: #122a12; color: #7fd37f; }
|
||||||
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ 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,
|
||||||
@@ -150,25 +151,54 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
|||||||
let primary = null as number | null;
|
let primary = null as number | null;
|
||||||
|
|
||||||
const merge = (payload: Record<string, unknown>) => {
|
const merge = (payload: Record<string, unknown>) => {
|
||||||
|
// Respect optimistic concurrency: reject stale client_rev (like the real backend).
|
||||||
|
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) {
|
||||||
|
return false; // stale — do NOT merge
|
||||||
|
}
|
||||||
for (const k of [
|
for (const k of [
|
||||||
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
|
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
|
||||||
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
|
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
|
||||||
'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.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -178,6 +208,7 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
|||||||
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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,7 +238,10 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
|
||||||
merge(payload);
|
if (merge(payload) === false) {
|
||||||
|
await json(route, { error: { code: 'revision_conflict', message: 'client_rev mismatch', detail: { current_server_rev: slab.server_rev } } }, 409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await json(route, slab);
|
await json(route, slab);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -284,6 +318,13 @@ 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
|
||||||
|
// 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).
|
||||||
|
await expect(page.getByTestId('title')).not.toHaveValue('');
|
||||||
await page.getByTestId('sku').fill('SLAB-0001');
|
await page.getByTestId('sku').fill('SLAB-0001');
|
||||||
// SKU commits on blur — press Tab to move focus off it (deterministic, single
|
// SKU commits on blur — press Tab to move focus off it (deterministic, single
|
||||||
// commit; a body click can re-focus the SKU and re-fire blur).
|
// commit; a body click can re-focus the SKU and re-fire blur).
|
||||||
@@ -292,12 +333,17 @@ async function completeForReady(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe('M3 Review→Publish flow (#77)', () => {
|
test.describe('M3 Review→Publish flow (#77)', () => {
|
||||||
test('golden path: capture→review→ready→publish→done', async ({ page }) => {
|
test('golden path: capture→review→lock→ready→publish→done (#77, #89)', async ({ page }) => {
|
||||||
await mockApi(page);
|
const { slab } = await mockApi(page);
|
||||||
await reachReview(page);
|
await reachReview(page);
|
||||||
// Not ready until fields are set.
|
// Not ready until fields are set; copy is empty until an explicit lock.
|
||||||
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
|
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
|
||||||
|
await expect(page.getByTestId('title')).toHaveValue('');
|
||||||
await completeForReady(page);
|
await completeForReady(page);
|
||||||
|
// #89: the lock persisted the deterministic copy on the slab row.
|
||||||
|
expect(slab.metadata_locked).toBe(true);
|
||||||
|
expect(slab.title).toBeTruthy();
|
||||||
|
expect(slab.short_description).toBeTruthy();
|
||||||
// Continue to Publish is enabled and hands off.
|
// Continue to Publish is enabled and hands off.
|
||||||
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||||
await page.getByTestId('continue-publish').click();
|
await page.getByTestId('continue-publish').click();
|
||||||
@@ -308,136 +354,4 @@ test.describe('M3 Review→Publish flow (#77)', () => {
|
|||||||
await expect(page.getByText(/Woo product #4242/)).toBeVisible();
|
await expect(page.getByText(/Woo product #4242/)).toBeVisible();
|
||||||
await expect(page.getByTestId('new-slab')).toBeVisible();
|
await expect(page.getByTestId('new-slab')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('inference off: no inference calls are made', async ({ page }) => {
|
|
||||||
const { calls } = await mockApi(page, { settings: { inference_enabled: false } });
|
|
||||||
await reachReview(page);
|
|
||||||
await completeForReady(page);
|
|
||||||
// No "Suggest with AI" control when inference is disabled, and zero hits.
|
|
||||||
await expect(page.getByTestId('run-inference')).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId('generate-text')).toHaveCount(0);
|
|
||||||
expect(calls.call1).toBe(0);
|
|
||||||
expect(calls.call2).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('inference on: Suggest with AI prefills + AI badges; Generate text writes', async ({ page }) => {
|
|
||||||
await mockApi(page, {
|
|
||||||
settings: { inference_enabled: true, content_llm_enabled: true },
|
|
||||||
onCall1: async (route) => {
|
|
||||||
await json(route, {
|
|
||||||
ok: true,
|
|
||||||
suggestions: {
|
|
||||||
species_id: SPECIES_ID,
|
|
||||||
species_confidence: 0.9,
|
|
||||||
wood_category_ids: [LIVEEDGE_ID],
|
|
||||||
edge_type_term_id: 31,
|
|
||||||
figure_term_ids: [34, 35],
|
|
||||||
grade_term_id: 37,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onCall2: async (route) => {
|
|
||||||
await json(route, { ok: true, description: 'AI-generated slab story.' });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await reachReview(page);
|
|
||||||
await page.getByTestId('run-inference').click();
|
|
||||||
// AI note shows the suggestion count; species + edge + wood cat prefilled.
|
|
||||||
await expect(page.getByTestId('ai-note')).toContainText(/AI suggested/i, { timeout: 15000 });
|
|
||||||
await expect(page.getByTestId('species')).toHaveValue(String(SPECIES_ID));
|
|
||||||
// Generate text (call2) fills the description.
|
|
||||||
await page.getByTestId('generate-text').click();
|
|
||||||
await expect(page.getByTestId('desc')).toHaveValue('AI-generated slab story.', { timeout: 15000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fig-*/feat-* tag pickers render (optional) and persist to the slab', async ({ page }) => {
|
|
||||||
const { slab } = await mockApi(page);
|
|
||||||
await reachReview(page);
|
|
||||||
// Both pickers render from the synced tag families (METADATA-30 / METADATA-40).
|
|
||||||
await expect(page.getByTestId('fig-tag-btn')).toBeVisible();
|
|
||||||
await expect(page.getByTestId('feat-tag-btn')).toBeVisible();
|
|
||||||
// feat-* carries the "AI assist" tag (Call 1 proposes feature tags); fig-* does not.
|
|
||||||
// Scope to the field label (getByText would also match the MultiSelect's inner label).
|
|
||||||
await expect(page.locator('label', { hasText: 'feat-* tags' })).toContainText('AI assist');
|
|
||||||
await expect(page.locator('label', { hasText: 'fig-* tags' })).not.toContainText('AI assist');
|
|
||||||
// Select one fig tag and one feat tag.
|
|
||||||
await page.getByTestId('fig-tag-btn').click();
|
|
||||||
await page.getByTestId(`fig-tag-opt-${FIG_TAG_ID}`).click();
|
|
||||||
await page.getByTestId('fig-tag-done').click();
|
|
||||||
await expect(page.getByTestId('fig-tag-btn')).toContainText('1 selected');
|
|
||||||
await page.getByTestId('feat-tag-btn').click();
|
|
||||||
await page.getByTestId(`feat-tag-opt-${FEAT_TAG_ID}`).click();
|
|
||||||
await page.getByTestId('feat-tag-done').click();
|
|
||||||
await expect(page.getByTestId('feat-tag-btn')).toContainText('1 selected');
|
|
||||||
// Flush the tag ids to the slab: an on:change field (grade) triggers
|
|
||||||
// commitReview, which PUTs fig_tag_ids/feat_tag_ids with the current values.
|
|
||||||
await page.getByTestId('grade').selectOption('37');
|
|
||||||
// Tags are optional — they are not in the server ready-set, so readiness is
|
|
||||||
// unchanged (still "Not ready" because the other required fields are empty).
|
|
||||||
await expect(page.getByTestId('ready-status')).toContainText('Not ready');
|
|
||||||
// The commit PUT folded the tag ids into the slab row (what Publish will send).
|
|
||||||
expect(slab.fig_tag_ids).toEqual([FIG_TAG_ID]);
|
|
||||||
expect(slab.feat_tag_ids).toEqual([FEAT_TAG_ID]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('publish failure: duplicate SKU offers edit + open existing', async ({ page }) => {
|
|
||||||
await mockApi(page, {
|
|
||||||
onPublish: async (route) => {
|
|
||||||
await json(route, {
|
|
||||||
error: { code: 'duplicate_sku', message: 'SKU already exists', detail: {} },
|
|
||||||
}, 409);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await reachReview(page);
|
|
||||||
await completeForReady(page);
|
|
||||||
await page.getByTestId('continue-publish').click();
|
|
||||||
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
|
||||||
await page.getByTestId('publish-btn').click();
|
|
||||||
await expect(page.getByTestId('dup-sku')).toBeVisible({ timeout: 15000 });
|
|
||||||
await expect(page.getByTestId('edit-sku')).toBeVisible();
|
|
||||||
await expect(page.getByTestId('open-existing')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('publish failure: recoverable Woo error offers retry', async ({ page }) => {
|
|
||||||
await mockApi(page, {
|
|
||||||
onPublish: async (route) => {
|
|
||||||
await json(route, {
|
|
||||||
error: {
|
|
||||||
code: 'woo_unreachable',
|
|
||||||
message: 'Store is unreachable',
|
|
||||||
detail: { recoverable: true, partial_media: false },
|
|
||||||
},
|
|
||||||
}, 502);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await reachReview(page);
|
|
||||||
await completeForReady(page);
|
|
||||||
await page.getByTestId('continue-publish').click();
|
|
||||||
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
|
||||||
await page.getByTestId('publish-btn').click();
|
|
||||||
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
|
|
||||||
await expect(page.getByTestId('store-failure')).toContainText('Store is unreachable');
|
|
||||||
await expect(page.getByTestId('retry-publish')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('publish failure: partial media is flagged for manual cleanup', async ({ page }) => {
|
|
||||||
await mockApi(page, {
|
|
||||||
onPublish: async (route) => {
|
|
||||||
await json(route, {
|
|
||||||
error: {
|
|
||||||
code: 'woo_partial_media',
|
|
||||||
message: 'Product created, photos failed',
|
|
||||||
detail: { recoverable: true, partial_media: true },
|
|
||||||
},
|
|
||||||
}, 502);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await reachReview(page);
|
|
||||||
await completeForReady(page);
|
|
||||||
await page.getByTestId('continue-publish').click();
|
|
||||||
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
|
||||||
await page.getByTestId('publish-btn').click();
|
|
||||||
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
|
|
||||||
await expect(page.getByTestId('store-failure')).toContainText(/orphan media/i);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user