Compare commits
28 Commits
2e2be48154
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c00d25cc7 | |||
| 96a697a50a | |||
| 67aa3d6b0d | |||
| 8ee3da9dcf | |||
| 1a50fad0a5 | |||
| e61303e2d8 | |||
| bdd03afce4 | |||
| e5acef4c9b | |||
| 204bc5e960 | |||
| 191b507934 | |||
| 0329052d37 | |||
| e1b928ccbd | |||
| 76fcb36a2b | |||
| 5a1800df91 | |||
| cd326d1ec9 | |||
| 502314b338 | |||
| 0612ce66a6 | |||
| 7b7adbdb34 | |||
| d9af8420bc | |||
| fe82133ca4 | |||
| 693719e85f | |||
| 41401a7266 | |||
| 7287a55707 | |||
| 81be17d5cd | |||
| cec03c5bd3 | |||
| e36979617b | |||
| c8d6d5eda0 | |||
| 1133201f30 |
@@ -14,6 +14,32 @@
|
|||||||
|
|
||||||
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
|
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
|
||||||
|
|
||||||
|
## Build & test (verified commands)
|
||||||
|
|
||||||
|
Frontend (`frontend/`, Svelte 4 + Vite + TS, no router):
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend && npm install
|
||||||
|
npm run test # node --import tsx --test src/**/*.test.ts (unit)
|
||||||
|
npm run build # tsc --noEmit && vite build (type-check + build)
|
||||||
|
npm run test:e2e # playwright test (see Pitfalls: mocks the API)
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend (`backend/`, FastAPI + stdlib sqlite, no ORM, deps pinned in `backend/requirements.txt`):
|
||||||
|
|
||||||
|
```
|
||||||
|
cd backend && python -m pytest tests/ -q
|
||||||
|
uvicorn backend.app:app --host 127.0.0.1 --port 18081
|
||||||
|
```
|
||||||
|
|
||||||
|
No Makefile, no CI workflows, no Gitea CI — nothing runs in a pipeline; the only automation is the local commands above plus the human UAT walk (`deploy/uat_walk.sh`).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- The LIVE FastAPI routes in `backend/*.py` are the API source of truth; `docs/contracts/API.md` is aspirational and can drift (it describes a polling model the backend never shipped). Read the router, not the doc, before wiring a client.
|
||||||
|
- Frontend is one Svelte app, no router: `App.svelte` switches on a `writable` flow store. Screens live under `frontend/src/<area>/`.
|
||||||
|
- Commit subjects: `feat(scope): …` / `fix(scope): …` / `test(scope): …` / `chore(scope): …`, often citing a requirement ID or Gitea number.
|
||||||
|
|
||||||
## Gates (must pass before claiming work complete)
|
## Gates (must pass before claiming work complete)
|
||||||
|
|
||||||
Run from repo root:
|
Run from repo root:
|
||||||
@@ -25,6 +51,14 @@ python tools/check_coverage.py
|
|||||||
|
|
||||||
Exit 0 on both = pass.
|
Exit 0 on both = pass.
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
- E2E mocks the API: `tests/e2e/*.spec.ts` use Playwright `page.route('**/api/v1/**')` with fixture ids — there is **no live backend** under e2e. A green `test:e2e` is not proof the real backend works; that's the human `deploy/uat_walk.sh` (real Woo, never a mock — QUALITY-105).
|
||||||
|
- Ports on the 5060 UAT host (192.168.1.202): FastAPI `18083`, FE `18082`/`10881` are typically already up (compose on `deploy/docker-compose.yml`). Check before binding; don't assume `18081` is free.
|
||||||
|
- `SLAB_UAT_UNMASK_SECRETS=1` makes `GET /settings` return decrypted Woo/inference secrets — dev-test only, never set in prod (SETTINGS-75 masking is the default).
|
||||||
|
- Never hardcode live Woo category/term ids into app code (QUALITY-105 / METADATA-5); resolve groupers by slug (`species`, `wcat`) at runtime.
|
||||||
|
- `tsc` won't catch Svelte template errors; `npm run build` (which runs `tsc --noEmit`) is the real type gate for the frontend.
|
||||||
|
|
||||||
## Pointers (read these for everything else)
|
## Pointers (read these for everything else)
|
||||||
|
|
||||||
- Docs map and shipped status: `docs/README.md`
|
- Docs map and shipped status: `docs/README.md`
|
||||||
|
|||||||
+24
-3
@@ -25,7 +25,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
||||||
from . import settings as settings_mod
|
from . import settings as settings_mod
|
||||||
|
|
||||||
__version__ = "0.5.0-m3-s0"
|
__version__ = "0.5.0-m3"
|
||||||
|
|
||||||
# --- Structured JSON logging (stdlib only) ---
|
# --- Structured JSON logging (stdlib only) ---
|
||||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
||||||
@@ -155,7 +155,11 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health() -> dict[str, Any]:
|
async def health() -> dict[str, Any]:
|
||||||
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
|
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
|
||||||
|
|
||||||
|
#84: woo_reachable is a real authenticated probe (signal only — store outage
|
||||||
|
does not fail health).
|
||||||
|
"""
|
||||||
db_ok = probe_writable()
|
db_ok = probe_writable()
|
||||||
img_ok = probe_images_writable()
|
img_ok = probe_images_writable()
|
||||||
if not (db_ok and img_ok):
|
if not (db_ok and img_ok):
|
||||||
@@ -164,10 +168,27 @@ async def health() -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"woo_reachable": False, # S0 stub; S4 will implement real check
|
"woo_reachable": _probe_woo_reachable(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_woo_reachable() -> bool:
|
||||||
|
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
|
||||||
|
|
||||||
|
Storage health never depends on this result. Missing creds → False.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
base, username, enc_password = _woo_credentials()
|
||||||
|
if not base or not username or not enc_password:
|
||||||
|
return False
|
||||||
|
client = WooClient.from_settings(base, username, enc_password)
|
||||||
|
# One lightweight authenticated call (categories page 1).
|
||||||
|
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
|
||||||
|
return code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/meta")
|
@app.get("/api/v1/meta")
|
||||||
async def meta() -> dict[str, Any]:
|
async def meta() -> dict[str, Any]:
|
||||||
"""Per API.md §3."""
|
"""Per API.md §3."""
|
||||||
|
|||||||
@@ -79,6 +79,21 @@ _M3_TABLES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Idempotent column additions for volumes created before a column existed.
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
|
||||||
|
so new columns must be added here. Each guard is a no-op once the column is
|
||||||
|
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
|
||||||
|
"""
|
||||||
|
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
|
||||||
|
if photo_cols and "role" not in photo_cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
|
||||||
|
"CHECK(role IN ('calibration','overlay','batch'))"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
||||||
conn = get_connection(db_path)
|
conn = get_connection(db_path)
|
||||||
schema_path = _get_schema_path()
|
schema_path = _get_schema_path()
|
||||||
@@ -98,6 +113,10 @@ def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
|||||||
conn.executescript(schema)
|
conn.executescript(schema)
|
||||||
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
|
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
# Safe to run every boot: no-op once the columns exist.
|
||||||
|
with _lock:
|
||||||
|
_migrate(conn)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ CREATE TABLE IF NOT EXISTS slab_photos (
|
|||||||
slab_id TEXT NOT NULL,
|
slab_id TEXT NOT NULL,
|
||||||
seq INTEGER NOT NULL,
|
seq INTEGER NOT NULL,
|
||||||
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
|
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
|
||||||
|
-- #90: lineup kind (clean cal / dimension-overlay copy / batch sibling).
|
||||||
|
-- Drives the Review photo strip labels and the PUBLISH-45 Woo image order.
|
||||||
|
role TEXT NOT NULL DEFAULT 'batch' CHECK(role IN ('calibration','overlay','batch')),
|
||||||
original_blob BLOB,
|
original_blob BLOB,
|
||||||
processed_blob BLOB,
|
processed_blob BLOB,
|
||||||
width_px INTEGER,
|
width_px INTEGER,
|
||||||
|
|||||||
+337
-55
@@ -5,6 +5,7 @@ Cites METADATA-35..115, SETTINGS-30/45/90.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -13,19 +14,94 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from .db import get_connection
|
from .db import get_connection
|
||||||
from .crypto import decrypt
|
from .crypto import decrypt
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
# METADATA-35: Call 1 (and Call 2) send clean processed photos at 1024 px long edge or less.
|
||||||
|
_INFER_MAX_LONG_EDGE = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def fit_long_edge_png(png_bytes: bytes, max_long_edge: int = _INFER_MAX_LONG_EDGE) -> bytes:
|
||||||
|
"""Downscale a PNG so max(width, height) <= max_long_edge. Never upscale. Keep alpha.
|
||||||
|
|
||||||
|
Used only for the inference request payload; stored processed_blob / Woo media stay full size.
|
||||||
|
Unreadable or non-image bytes are returned unchanged so a bad blob cannot crash Call 1.
|
||||||
|
"""
|
||||||
|
if not png_bytes or max_long_edge < 1:
|
||||||
|
return png_bytes
|
||||||
|
try:
|
||||||
|
im = Image.open(io.BytesIO(png_bytes))
|
||||||
|
im.load()
|
||||||
|
except Exception:
|
||||||
|
return png_bytes
|
||||||
|
w, h = im.size
|
||||||
|
long_edge = max(w, h)
|
||||||
|
if long_edge <= max_long_edge:
|
||||||
|
# Already within budget; re-encode only if we need a stable PNG (caller always base64s).
|
||||||
|
return png_bytes
|
||||||
|
scale = max_long_edge / float(long_edge)
|
||||||
|
nw = max(1, int(round(w * scale)))
|
||||||
|
nh = max(1, int(round(h * scale)))
|
||||||
|
# Preserve alpha: convert palette/etc. to RGBA when needed, LANCZOS downscale.
|
||||||
|
if im.mode not in ("RGB", "RGBA", "L", "LA"):
|
||||||
|
im = im.convert("RGBA")
|
||||||
|
out = im.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
out.save(buf, format="PNG", optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _image_part_from_processed(png_bytes: bytes) -> dict:
|
||||||
|
"""Build one OpenAI-style image_url part from a processed PNG (METADATA-35 sized)."""
|
||||||
|
fitted = fit_long_edge_png(png_bytes)
|
||||||
|
b64 = base64.b64encode(fitted).decode()
|
||||||
|
return {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||||
|
|
||||||
|
# Locked Call 1 seed (#86 / METADATA-40): expert helper + closed Woo lists only.
|
||||||
CALL1_SEED = (
|
CALL1_SEED = (
|
||||||
"Classify the slab's species, wood categories, edge type, figure, and grade from the "
|
"You are a wood identification expert for retail lumber slabs. Judge only from the photos "
|
||||||
"supplied photos, choosing only from the supplied synced taxonomy lists, and return the "
|
"and the LISTS below. Use list ids only — never invent a name or id.\n"
|
||||||
"result fields as JSON: species_id, wood_category_ids, edge_type_term_id, figure_term_ids, "
|
"\n"
|
||||||
"grade_term_id, character, inclusions, voids, checks. Report a confidence (0-1) for each "
|
"Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, "
|
||||||
"classified field. Map free-text observations to the supplied feature-tag list only. "
|
"(5) feature tags. Decide wood categories before edge type — edge rules depend on product form.\n"
|
||||||
"Invent no category or term."
|
"\n"
|
||||||
|
"For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). "
|
||||||
|
"Prefer null or [] over a weak guess.\n"
|
||||||
|
"\n"
|
||||||
|
"Species — which tree species is this slab. Match grain, color, and figure to the species "
|
||||||
|
"list. If more than one entry is plausible, return only the single highest-confidence match "
|
||||||
|
"(one id). Null only when none are credible.\n"
|
||||||
|
"\n"
|
||||||
|
"Wood categories — product form / how it will be sold (live edge, dimensional, cookies & "
|
||||||
|
"burl slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; "
|
||||||
|
"often one, sometimes more. [] if none fit. Finish this field before edge type.\n"
|
||||||
|
"\n"
|
||||||
|
"Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.\n"
|
||||||
|
" Meanings (match the closest list name):\n"
|
||||||
|
" • Square / square edge — both long edges milled straight (2 milled long edges).\n"
|
||||||
|
" • Live one side / Live Edge 1 side — one long edge milled, the other natural bark or "
|
||||||
|
"waney (1 milled long edge).\n"
|
||||||
|
" • Live both / Live edge 2 sides — neither long edge milled; both natural "
|
||||||
|
"(0 milled long edges).\n"
|
||||||
|
" • Wild edge — use when the three mill-count cases do not fit, including cookies and burl "
|
||||||
|
"cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.\n"
|
||||||
|
"\n"
|
||||||
|
"Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids "
|
||||||
|
"from figure_terms that clearly apply.\n"
|
||||||
|
"\n"
|
||||||
|
"Feature tags — defects and character marks the store tags for shoppers (knots, checks, "
|
||||||
|
"inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].\n"
|
||||||
|
"\n"
|
||||||
|
"Return one JSON object with exactly these keys:\n"
|
||||||
|
'{"species_id": <id|null>, "species_confidence": <0-1>, '
|
||||||
|
'"wood_category_ids": [<id>,...], "wood_category_confidence": <0-1>, '
|
||||||
|
'"edge_type_term_id": <id|null>, "edge_confidence": <0-1>, '
|
||||||
|
'"figure_term_ids": [<id>,...], "figure_confidence": <0-1>, '
|
||||||
|
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
|
||||||
)
|
)
|
||||||
CALL2_SEED = (
|
CALL2_SEED = (
|
||||||
"Write the long product description for the slab from the supplied photos and listing "
|
"Write the long product description for the slab from the supplied photos and listing "
|
||||||
@@ -52,19 +128,53 @@ def _hash(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _seed_prompts(conn) -> None:
|
def _seed_prompts(conn) -> None:
|
||||||
|
"""Ensure each call has a prompt row and the shipped seed revision.
|
||||||
|
|
||||||
|
If the shipped seed hash is new and the active revision is still seed-authored
|
||||||
|
(or missing), activate the new seed so deploys pick up CALL1_SEED / CALL2_SEED
|
||||||
|
updates without clobbering an owner-activated API revision.
|
||||||
|
"""
|
||||||
for name, seed in _SEEDS.items():
|
for name, seed in _SEEDS.items():
|
||||||
if conn.execute("SELECT 1 FROM prompts WHERE name=?", (name,)).fetchone():
|
prow = conn.execute("SELECT id, active_revision_id FROM prompts WHERE name=?", (name,)).fetchone()
|
||||||
continue
|
if prow is None:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
|
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
|
||||||
(name, None, _now(), _now()),
|
(name, None, _now(), _now()),
|
||||||
)
|
)
|
||||||
pid = cur.lastrowid
|
pid = cur.lastrowid
|
||||||
cur2 = conn.execute(
|
active_id = None
|
||||||
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)",
|
else:
|
||||||
(pid, seed, _hash(seed), _now(), "seed"),
|
pid = prow[0]
|
||||||
)
|
active_id = prow[1]
|
||||||
conn.execute("UPDATE prompts SET active_revision_id=? WHERE id=?", (cur2.lastrowid, pid))
|
h = _hash(seed)
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM prompt_revisions WHERE prompt_id=? AND content_hash=?",
|
||||||
|
(pid, h),
|
||||||
|
).fetchone()
|
||||||
|
if existing is None:
|
||||||
|
cur2 = conn.execute(
|
||||||
|
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) "
|
||||||
|
"VALUES (?,?,?,?,?)",
|
||||||
|
(pid, seed, h, _now(), "seed"),
|
||||||
|
)
|
||||||
|
seed_rev_id = cur2.lastrowid
|
||||||
|
else:
|
||||||
|
seed_rev_id = existing[0]
|
||||||
|
# Activate shipped seed when nothing active, or active is still a seed revision.
|
||||||
|
activate = False
|
||||||
|
if active_id is None:
|
||||||
|
activate = True
|
||||||
|
else:
|
||||||
|
act = conn.execute(
|
||||||
|
"SELECT created_by FROM prompt_revisions WHERE id=?", (active_id,)
|
||||||
|
).fetchone()
|
||||||
|
if act is None or act[0] == "seed":
|
||||||
|
activate = True
|
||||||
|
if activate and active_id != seed_rev_id:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?",
|
||||||
|
(seed_rev_id, _now(), pid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _active_prompt(conn, call: str) -> tuple[int, str] | None:
|
def _active_prompt(conn, call: str) -> tuple[int, str] | None:
|
||||||
@@ -195,21 +305,26 @@ async def call1(slab_id: str) -> dict:
|
|||||||
if not base or not key:
|
if not base or not key:
|
||||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||||
client = InferenceClient(base, key, model)
|
client = InferenceClient(base, key, model)
|
||||||
|
_seed_prompts(conn)
|
||||||
|
conn.commit()
|
||||||
act = _active_prompt(conn, "call1")
|
act = _active_prompt(conn, "call1")
|
||||||
prompt_text = act[1] if act else CALL1_SEED
|
prompt_text = act[1] if act else CALL1_SEED
|
||||||
# Build vision message with processed photos (1024px) + taxonomy lists.
|
# Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy.
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||||
|
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||||
(slab_id,),
|
(slab_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
image_parts = []
|
image_parts = []
|
||||||
|
photo_meta: list[dict] = []
|
||||||
for p in photos:
|
for p in photos:
|
||||||
image_parts.append({
|
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||||
"type": "image_url",
|
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||||
"image_url": {"url": f"data:image/png;base64,{base64.b64encode(p[0]).decode()}"},
|
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||||
})
|
image_parts.append(_image_part_from_processed(blob))
|
||||||
|
photo_meta.append(_photo_meta_row(pid, seq, blob))
|
||||||
tax = _taxonomy_lists(conn)
|
tax = _taxonomy_lists(conn)
|
||||||
text = (f"{prompt_text}\n\nSYNCED TAXONOMY (only legal choices):\n{json.dumps(tax)}\n\n"
|
text = (f"{prompt_text}\n\nLISTS:\n{json.dumps(tax, ensure_ascii=False)}\n\n"
|
||||||
f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}")
|
f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}")
|
||||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -221,26 +336,75 @@ async def call1(slab_id: str) -> dict:
|
|||||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||||
latency = int((time.time() - t0) * 1000)
|
latency = int((time.time() - t0) * 1000)
|
||||||
parsed = _parse_json(raw)
|
parsed = _parse_json(raw)
|
||||||
_log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency)
|
_log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency, photo_meta)
|
||||||
# Apply confidence gate (METADATA-45).
|
# Apply confidence gate (METADATA-45).
|
||||||
suggestions = _gate(parsed)
|
suggestions = _gate(parsed)
|
||||||
return {"ok": True, "suggestions": suggestions}
|
return {"ok": True, "suggestions": suggestions}
|
||||||
|
|
||||||
|
|
||||||
|
def _feat_display_name(name: str) -> str:
|
||||||
|
"""Strip leading feat- / feat_ for the model (#86); Woo id stays authoritative."""
|
||||||
|
n = (name or "").strip()
|
||||||
|
low = n.lower()
|
||||||
|
for prefix in ("feat-", "feat_"):
|
||||||
|
if low.startswith(prefix):
|
||||||
|
return n[len(prefix):].strip() or n
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def _taxonomy_lists(conn) -> dict:
|
def _taxonomy_lists(conn) -> dict:
|
||||||
out = {"species_leaves": [], "edge_terms": [], "figure_terms": [], "grade_terms": [],
|
"""Closed Call 1 lists only (#86 / METADATA-40).
|
||||||
"thickness_terms": [], "moisture_terms": [], "feat_tags": []}
|
|
||||||
for r in conn.execute("SELECT id,name FROM woo_categories WHERE parent_id IS NULL").fetchall():
|
Keys match the seed prompt. No grade/thickness/moisture (owner fields).
|
||||||
out["species_leaves"].append({"id": r[0], "name": r[1]})
|
Feature tag names are shown without the feat- prefix; ids are Woo/local tag ids.
|
||||||
for attr, key in (("Edge Type", "edge_terms"), ("Figure", "figure_terms"), ("Grade", "grade_terms"),
|
Groupers resolved by slug only (QUALITY-105).
|
||||||
("Thickness", "thickness_terms"), ("Moisture", "moisture_terms")):
|
"""
|
||||||
|
out: dict[str, list] = {
|
||||||
|
"species": [],
|
||||||
|
"wood_categories": [],
|
||||||
|
"edge_terms": [],
|
||||||
|
"figure_terms": [],
|
||||||
|
"feature_tags": [],
|
||||||
|
}
|
||||||
|
# species = leaves under the 'species' grouper
|
||||||
|
sp = conn.execute("SELECT id FROM woo_categories WHERE slug='species'").fetchone()
|
||||||
|
if sp:
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id,name FROM woo_categories WHERE parent_id=?", (sp[0],)
|
||||||
|
).fetchall():
|
||||||
|
out["species"].append({"id": r[0], "name": r[1]})
|
||||||
|
# wood categories = leaves under the 'wcat' grouper (leaves may sit under sub-groupers).
|
||||||
|
wc = conn.execute("SELECT id FROM woo_categories WHERE slug='wcat'").fetchone()
|
||||||
|
if wc:
|
||||||
|
kids = [r[0] for r in conn.execute(
|
||||||
|
"SELECT id FROM woo_categories WHERE parent_id=?", (wc[0],)
|
||||||
|
).fetchall()]
|
||||||
|
leaf_ids = []
|
||||||
|
for cid in kids:
|
||||||
|
has_kids = conn.execute(
|
||||||
|
"SELECT 1 FROM woo_categories WHERE parent_id=? LIMIT 1", (cid,)
|
||||||
|
).fetchone()
|
||||||
|
if has_kids:
|
||||||
|
leaf_ids.extend(r[0] for r in conn.execute(
|
||||||
|
"SELECT id FROM woo_categories WHERE parent_id=?", (cid,)
|
||||||
|
).fetchall())
|
||||||
|
else:
|
||||||
|
leaf_ids.append(cid)
|
||||||
|
if leaf_ids:
|
||||||
|
q = ",".join("?" * len(leaf_ids))
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id,name FROM woo_categories WHERE id IN (%s)" % q, leaf_ids
|
||||||
|
).fetchall():
|
||||||
|
out["wood_categories"].append({"id": r[0], "name": r[1]})
|
||||||
|
for attr, key in (("Edge type", "edge_terms"), ("Figure", "figure_terms")):
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id WHERE a.name=?",
|
"SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id "
|
||||||
|
"WHERE lower(a.name)=lower(?)",
|
||||||
(attr,),
|
(attr,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
out[key] = [{"id": r[0], "name": r[1]} for r in rows]
|
out[key] = [{"id": r[0], "name": r[1]} for r in rows]
|
||||||
for r in conn.execute("SELECT id,name FROM woo_tags WHERE slug LIKE 'feat-%'").fetchall():
|
for r in conn.execute("SELECT id,name FROM woo_tags WHERE slug LIKE 'feat-%'").fetchall():
|
||||||
out["feat_tags"].append({"id": r[0], "name": r[1]})
|
out["feature_tags"].append({"id": r[0], "name": _feat_display_name(r[1])})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -257,27 +421,46 @@ def _parse_json(raw: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _gate(parsed: dict) -> dict:
|
def _gate(parsed: dict) -> dict:
|
||||||
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45)."""
|
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45).
|
||||||
def keep(field: str, conf_key: str) -> Any:
|
|
||||||
conf = parsed.get(conf_key)
|
Confidence is read from the flat '<field>_confidence' keys; as a safety net it also
|
||||||
|
understands the nested 'confidence: {<field>: n}' shape some models emit, so a shape
|
||||||
|
wobble cannot silently nuke every suggestion. Grade is never suggested (METADATA-40).
|
||||||
|
Free-text observation bags are dropped — feature tags are ids only (#86).
|
||||||
|
"""
|
||||||
|
conf_key = {
|
||||||
|
"species_id": "species_confidence",
|
||||||
|
"wood_category_ids": "wood_category_confidence",
|
||||||
|
"edge_type_term_id": "edge_confidence",
|
||||||
|
"figure_term_ids": "figure_confidence",
|
||||||
|
"feature_tag_ids": "feature_tag_confidence",
|
||||||
|
}
|
||||||
|
conf_obj = parsed.get("confidence") if isinstance(parsed.get("confidence"), dict) else {}
|
||||||
|
|
||||||
|
def _conf(field: str) -> Any:
|
||||||
|
k = conf_key.get(field, f"{field}_confidence")
|
||||||
|
if k in conf_obj:
|
||||||
|
return conf_obj[k]
|
||||||
|
return parsed.get(k)
|
||||||
|
|
||||||
|
def keep(field: str) -> Any:
|
||||||
|
conf = _conf(field)
|
||||||
if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed:
|
if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed:
|
||||||
return parsed[field]
|
return parsed[field]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
out: dict[str, Any] = {}
|
out: dict[str, Any] = {}
|
||||||
if v := keep("species_id", "species_confidence"):
|
if v := keep("species_id"):
|
||||||
out["species_id"] = v
|
out["species_id"] = v
|
||||||
if v := keep("wood_category_ids", "wood_category_confidence"):
|
if v := keep("wood_category_ids"):
|
||||||
out["wood_category_ids"] = v
|
out["wood_category_ids"] = v
|
||||||
if v := keep("edge_type_term_id", "edge_confidence"):
|
if v := keep("edge_type_term_id"):
|
||||||
out["edge_type_term_id"] = v
|
out["edge_type_term_id"] = v
|
||||||
if v := keep("figure_term_ids", "figure_confidence"):
|
if v := keep("figure_term_ids"):
|
||||||
out["figure_term_ids"] = v
|
out["figure_term_ids"] = v
|
||||||
if v := keep("grade_term_id", "grade_confidence"):
|
if v := keep("feature_tag_ids"):
|
||||||
out["grade_term_id"] = v
|
out["feature_tag_ids"] = v
|
||||||
for k in ("character", "inclusions", "voids", "checks"):
|
# grade / free-text bags intentionally omitted (METADATA-40, #86).
|
||||||
if parsed.get(k):
|
|
||||||
out[k] = parsed[k]
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -296,13 +479,18 @@ async def call2(slab_id: str) -> dict:
|
|||||||
act = _active_prompt(conn, "call2")
|
act = _active_prompt(conn, "call2")
|
||||||
prompt_text = act[1] if act else CALL2_SEED
|
prompt_text = act[1] if act else CALL2_SEED
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||||
|
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||||
(slab_id,),
|
(slab_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
image_parts = [
|
image_parts = []
|
||||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64.b64encode(p[0]).decode()}"}}
|
photo_meta: list[dict] = []
|
||||||
for p in photos
|
for p in photos:
|
||||||
]
|
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||||
|
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||||
|
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||||
|
image_parts.append(_image_part_from_processed(blob))
|
||||||
|
photo_meta.append(_photo_meta_row(pid, seq, blob))
|
||||||
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
||||||
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
||||||
text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
||||||
@@ -318,7 +506,7 @@ async def call2(slab_id: str) -> dict:
|
|||||||
except InferenceError as e:
|
except InferenceError as e:
|
||||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||||
latency = int((time.time() - t0) * 1000)
|
latency = int((time.time() - t0) * 1000)
|
||||||
_log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency)
|
_log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency, photo_meta)
|
||||||
# Store as description, mark content_source=llm.
|
# Store as description, mark content_source=llm.
|
||||||
conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||||
(raw.strip(), _now(), slab_id))
|
(raw.strip(), _now(), slab_id))
|
||||||
@@ -336,12 +524,106 @@ def server_description(slab: dict) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _photo_meta_row(photo_id: str, seq: int, blob: bytes) -> dict[str, Any]:
|
||||||
|
"""Metadata for one photo slot in the inference log (#87)."""
|
||||||
|
edge = None
|
||||||
|
try:
|
||||||
|
im = Image.open(io.BytesIO(blob))
|
||||||
|
im.load()
|
||||||
|
edge = max(im.size)
|
||||||
|
except Exception:
|
||||||
|
edge = None
|
||||||
|
return {
|
||||||
|
"photo_id": photo_id,
|
||||||
|
"seq": seq,
|
||||||
|
"bytes": len(blob) if blob is not None else 0,
|
||||||
|
"infer_long_edge_px": edge,
|
||||||
|
"label": f"photo seq={seq} id={photo_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_request_for_log(messages: list, photo_meta: list[dict] | None = None) -> list:
|
||||||
|
"""Replace image data-URLs with photo refs for inference_log (#87 / METADATA-66).
|
||||||
|
|
||||||
|
Wire messages still carry base64 for the model; only the logged copy is redacted.
|
||||||
|
Text parts are kept in full. Image parts become type=image_ref using photo_meta by
|
||||||
|
image-part index (same order as built from slab_photos).
|
||||||
|
"""
|
||||||
|
meta = list(photo_meta or [])
|
||||||
|
out: list = []
|
||||||
|
img_i = 0
|
||||||
|
for msg in messages:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
out.append(msg)
|
||||||
|
continue
|
||||||
|
content = msg.get("content")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
out.append(msg)
|
||||||
|
continue
|
||||||
|
new_parts: list = []
|
||||||
|
for part in content:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
new_parts.append(part)
|
||||||
|
continue
|
||||||
|
ptype = part.get("type")
|
||||||
|
if ptype == "text":
|
||||||
|
new_parts.append({"type": "text", "text": part.get("text", "")})
|
||||||
|
continue
|
||||||
|
if ptype == "image_url":
|
||||||
|
url = ""
|
||||||
|
iu = part.get("image_url")
|
||||||
|
if isinstance(iu, dict):
|
||||||
|
url = str(iu.get("url") or "")
|
||||||
|
if url.startswith("data:image") or "base64," in url:
|
||||||
|
ref = {
|
||||||
|
"type": "image_ref",
|
||||||
|
"photo_id": None,
|
||||||
|
"seq": None,
|
||||||
|
"bytes": None,
|
||||||
|
"infer_long_edge_px": None,
|
||||||
|
"label": f"photo slot={img_i}",
|
||||||
|
}
|
||||||
|
if img_i < len(meta):
|
||||||
|
m = meta[img_i]
|
||||||
|
ref.update({
|
||||||
|
"photo_id": m.get("photo_id"),
|
||||||
|
"seq": m.get("seq"),
|
||||||
|
"bytes": m.get("bytes"),
|
||||||
|
"infer_long_edge_px": m.get("infer_long_edge_px"),
|
||||||
|
"label": m.get("label") or ref["label"],
|
||||||
|
})
|
||||||
|
new_parts.append(ref)
|
||||||
|
img_i += 1
|
||||||
|
else:
|
||||||
|
# Non-data URL (rare): keep URL string only, never invent bytes
|
||||||
|
new_parts.append({
|
||||||
|
"type": "image_ref",
|
||||||
|
"url": url[:200],
|
||||||
|
"label": f"external image slot={img_i}",
|
||||||
|
})
|
||||||
|
img_i += 1
|
||||||
|
continue
|
||||||
|
new_parts.append(part)
|
||||||
|
out.append({**msg, "content": new_parts})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_LOG_PAYLOAD_CAP = 200_000
|
||||||
|
|
||||||
|
|
||||||
def _log_call(conn, slab_id: str, call: int, rev_id: int | None, model: str,
|
def _log_call(conn, slab_id: str, call: int, rev_id: int | None, model: str,
|
||||||
request: list, response: str, latency_ms: int) -> None:
|
request: list, response: str, latency_ms: int,
|
||||||
|
photo_meta: list[dict] | None = None) -> None:
|
||||||
|
"""Append inference_log with redacted request (no image bytes) — #87 / METADATA-66."""
|
||||||
|
redacted = _redact_request_for_log(request, photo_meta)
|
||||||
|
payload = json.dumps(redacted, ensure_ascii=False)
|
||||||
|
if len(payload) > _LOG_PAYLOAD_CAP:
|
||||||
|
# Safety net only; after redaction normal calls are far under the cap.
|
||||||
|
payload = payload[: _LOG_PAYLOAD_CAP - 20] + "\n/* truncated */"
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) "
|
"INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) "
|
||||||
"VALUES (?,?,?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?,?)",
|
||||||
(slab_id, call, rev_id, model, json.dumps(request)[:200000], response, latency_ms, _now()),
|
(slab_id, call, rev_id, model, payload, response, latency_ms, _now()),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -361,4 +643,4 @@ async def test_inference() -> dict:
|
|||||||
except InferenceError as e:
|
except InferenceError as e:
|
||||||
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)}
|
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)}
|
||||||
detail = f"reachable in {int((time.time()-t0)*1000)}ms"
|
detail = f"reachable in {int((time.time()-t0)*1000)}ms"
|
||||||
return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail}
|
return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail}
|
||||||
+25
-6
@@ -26,7 +26,7 @@ def _get_slab(conn, slab_id: str):
|
|||||||
|
|
||||||
def _get_photo(conn, slab_id: str, photo_id: str):
|
def _get_photo(conn, slab_id: str, photo_id: str):
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq "
|
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq, role "
|
||||||
"FROM slab_photos WHERE id=? AND slab_id=?",
|
"FROM slab_photos WHERE id=? AND slab_id=?",
|
||||||
(photo_id, slab_id),
|
(photo_id, slab_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -44,6 +44,7 @@ async def upload_photo(
|
|||||||
photo_id: str = Form(...),
|
photo_id: str = Form(...),
|
||||||
seq: int | None = Form(None),
|
seq: int | None = Form(None),
|
||||||
is_calibration: int = Form(0),
|
is_calibration: int = Form(0),
|
||||||
|
role: str = Form("batch"),
|
||||||
original: UploadFile = File(...),
|
original: UploadFile = File(...),
|
||||||
processed: UploadFile | None = File(None),
|
processed: UploadFile | None = File(None),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -52,6 +53,8 @@ async def upload_photo(
|
|||||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||||
if is_calibration not in (0, 1):
|
if is_calibration not in (0, 1):
|
||||||
return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1"))
|
return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1"))
|
||||||
|
if role not in ("calibration", "overlay", "batch"):
|
||||||
|
return JSONResponse(status_code=400, content=_error("validation", "role must be calibration, overlay, or batch"))
|
||||||
orig_bytes = await original.read()
|
orig_bytes = await original.read()
|
||||||
proc_bytes = await processed.read() if processed is not None else None
|
proc_bytes = await processed.read() if processed is not None else None
|
||||||
if not orig_bytes:
|
if not orig_bytes:
|
||||||
@@ -60,14 +63,14 @@ async def upload_photo(
|
|||||||
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
|
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
|
||||||
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
|
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, original_blob, processed_blob, created_at) "
|
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
|
||||||
"VALUES (?,?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?,?)",
|
||||||
(photo_id, slab_id, seq_val, is_calibration, orig_bytes, proc_bytes, _now()),
|
(photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
row = _get_photo(conn, slab_id, photo_id)
|
row = _get_photo(conn, slab_id, photo_id)
|
||||||
return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"],
|
return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"],
|
||||||
"original_retained": True, "processed_retained": row["processed_blob"] is not None}
|
"role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None}
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
@@ -108,9 +111,25 @@ async def list_photos(slab_id: str) -> list[dict]:
|
|||||||
if _get_slab(conn, slab_id) is None:
|
if _get_slab(conn, slab_id) 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"))
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, seq, is_calibration, width_px, height_px, "
|
"SELECT id, seq, is_calibration, role, width_px, height_px, "
|
||||||
"(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed "
|
"(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed "
|
||||||
"FROM slab_photos WHERE slab_id=? ORDER BY seq",
|
"FROM slab_photos WHERE slab_id=? ORDER BY seq",
|
||||||
(slab_id,),
|
(slab_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{slab_id}/photos/{photo_id}", status_code=204)
|
||||||
|
async def delete_photo(slab_id: str, photo_id: str):
|
||||||
|
"""#90: drop a photo the owner removed in Review (keep/remove edit). The
|
||||||
|
remaining photos are re-numbered by the client via re-upload."""
|
||||||
|
conn = get_connection()
|
||||||
|
if _get_slab(conn, slab_id) is None:
|
||||||
|
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
|
||||||
|
return Response(status_code=204)
|
||||||
|
|||||||
+50
-30
@@ -16,7 +16,14 @@ from fastapi import APIRouter
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from .db import get_connection
|
from .db import get_connection
|
||||||
from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
|
from .woo import (
|
||||||
|
WooAuthError,
|
||||||
|
WooClient,
|
||||||
|
WooConnectionError,
|
||||||
|
WooCredentialsMissing,
|
||||||
|
WooError,
|
||||||
|
WooForbiddenError,
|
||||||
|
)
|
||||||
from .review import compute_ready, _slab_dict
|
from .review import compute_ready, _slab_dict
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/slabs")
|
router = APIRouter(prefix="/api/v1/slabs")
|
||||||
@@ -72,21 +79,24 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
|||||||
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
|
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
|
||||||
if tags:
|
if tags:
|
||||||
payload["tags"] = [{"id": t} for t in tags]
|
payload["tags"] = [{"id": t} for t in tags]
|
||||||
# Five locked attributes by synced term name.
|
# Five locked attributes: resolve the attribute row from the synced cache by
|
||||||
|
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
|
||||||
|
# then fall back to case-insensitive name. Term *options* are always the
|
||||||
|
# names of the selected synced terms.
|
||||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||||
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
|
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
|
||||||
built_attrs = []
|
built_attrs = []
|
||||||
for field, attr_name, pos in (
|
for field, attr_slug, attr_name, pos in (
|
||||||
("edge_type_term_id", "Edge Type", 0),
|
("edge_type_term_id", "edge-type", "Edge Type", 0),
|
||||||
("figure_term_ids", "Figure", 1),
|
("figure_term_ids", "pa_figure", "Figure", 1),
|
||||||
("grade_term_id", "Grade", 2),
|
("grade_term_id", "pa_grade", "Grade", 2),
|
||||||
("thickness_term_id", "Thickness", 3),
|
("thickness_term_id", "pa_thickness", "Thickness", 3),
|
||||||
("moisture_term_id", "Moisture", 4),
|
("moisture_term_id", "pa_moisture", "Moisture", 4),
|
||||||
):
|
):
|
||||||
raw = slab.get(field)
|
raw = slab.get(field)
|
||||||
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
|
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
|
||||||
options = [terms[t]["name"] for t in term_ids if t in terms]
|
options = [terms[t]["name"] for t in term_ids if t in terms]
|
||||||
attr_id = _attr_id_by_name(attrs, attr_name)
|
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
|
||||||
if attr_id and options:
|
if attr_id and options:
|
||||||
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
|
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
|
||||||
if built_attrs:
|
if built_attrs:
|
||||||
@@ -94,19 +104,35 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _attr_id_by_name(attrs: dict, name: str) -> int | None:
|
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
|
||||||
|
"""Match a locked attribute against the synced Woo set by slug, then name."""
|
||||||
|
slug_l = slug.lower().lstrip()
|
||||||
|
# Accept both "figure" and "pa_figure" style slugs from Woo.
|
||||||
|
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
|
||||||
for a in attrs.values():
|
for a in attrs.values():
|
||||||
if a.get("name", "").lower() == name.lower():
|
s = (a.get("slug") or "").lower()
|
||||||
|
if s in slug_alts:
|
||||||
|
return a["id"]
|
||||||
|
name_l = name.lower()
|
||||||
|
for a in attrs.values():
|
||||||
|
if (a.get("name") or "").lower() == name_l:
|
||||||
return a["id"]
|
return a["id"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _classify(e: Exception) -> tuple[str, str, bool]:
|
def _classify(e: Exception) -> tuple[str, str, bool]:
|
||||||
"""Return (code, plain message, recoverable)."""
|
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
|
||||||
if isinstance(e, WooCredentialsMissing):
|
if isinstance(e, WooCredentialsMissing):
|
||||||
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
||||||
if isinstance(e, WooAuthError):
|
if isinstance(e, WooAuthError):
|
||||||
return "woo_auth", "The store rejected the credentials. Check the app password.", False
|
return "woo_auth", "The store rejected the credentials. Check the app password.", False
|
||||||
|
if isinstance(e, WooForbiddenError):
|
||||||
|
# WAF / security plugin block — retrying the same request will not help.
|
||||||
|
return (
|
||||||
|
"woo_forbidden",
|
||||||
|
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
|
||||||
|
False,
|
||||||
|
)
|
||||||
if isinstance(e, WooConnectionError):
|
if isinstance(e, WooConnectionError):
|
||||||
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
|
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
|
||||||
return "woo_failed", "The store create failed. You can retry.", True
|
return "woo_failed", "The store create failed. You can retry.", True
|
||||||
@@ -150,9 +176,16 @@ async def publish(slab_id: str) -> dict:
|
|||||||
code, msg, rec = _classify(e)
|
code, msg, rec = _classify(e)
|
||||||
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
|
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
|
||||||
# 4/5. Upload processed PNGs, then POST product.
|
# 4/5. Upload processed PNGs, then POST product.
|
||||||
|
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
|
||||||
|
# its dimension-overlay copy, then the remaining batch photos. The Review
|
||||||
|
# photo strip stores the owner's keep order (leftmost = primary), so the
|
||||||
|
# overlay (which can sit after a moved batch photo) is re-ordered here by
|
||||||
|
# role rather than by stored seq. Ties fall back to stored seq.
|
||||||
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
|
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"SELECT id, processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq",
|
"SELECT id, processed_blob, role FROM slab_photos "
|
||||||
|
"WHERE slab_id=? AND processed_blob IS NOT NULL "
|
||||||
|
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
|
||||||
(slab_id,),
|
(slab_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
uploaded_ids: list[int] = []
|
uploaded_ids: list[int] = []
|
||||||
@@ -202,26 +235,13 @@ def _create_status(conn) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _sku_exists(client: WooClient, sku: str) -> bool:
|
def _sku_exists(client: WooClient, sku: str) -> bool:
|
||||||
code, _h, body = client._request(
|
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||||
f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
|
return len(client.get_products_by_sku(sku)) > 0
|
||||||
"GET", None,
|
|
||||||
{"Authorization": f"Basic {client._auth}", "Accept": "application/json"},
|
|
||||||
)
|
|
||||||
if code != 200:
|
|
||||||
raise WooError(f"sku lookup returned {code}")
|
|
||||||
return len(json.loads(body)) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def _create_product(client: WooClient, payload: dict) -> int:
|
def _create_product(client: WooClient, payload: dict) -> int:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||||
code, _h, resp = client._request(
|
return client.create_product(payload)
|
||||||
f"{client.base_url}/wp-json/wc/v3/products",
|
|
||||||
"POST", body,
|
|
||||||
{"Authorization": f"Basic {client._auth}", "Content-Type": "application/json", "Accept": "application/json"},
|
|
||||||
)
|
|
||||||
if code not in (200, 201):
|
|
||||||
raise WooError(f"product create returned {code}")
|
|
||||||
return int(json.loads(resp)["id"])
|
|
||||||
|
|
||||||
|
|
||||||
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ python-multipart==0.0.9
|
|||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
cryptography==43.0.1
|
cryptography==43.0.1
|
||||||
|
Pillow==11.3.0
|
||||||
# stdlib only for sqlite3, logging, json, contextvars, threading
|
# stdlib only for sqlite3, logging, json, contextvars, threading
|
||||||
|
|||||||
+94
-37
@@ -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)
|
||||||
@@ -66,14 +71,16 @@ def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
|
|||||||
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
|
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
|
||||||
|
|
||||||
|
|
||||||
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
|
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||||
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
|
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
|
||||||
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
||||||
)
|
)
|
||||||
figure = figure.strip() if figure else ""
|
figure = figure.strip() if figure else ""
|
||||||
live_edge = ""
|
live_edge = ""
|
||||||
if primary_leaf["slug"] in ("dimensional-wood", "bowl-blanks"):
|
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
|
||||||
live_edge = "Live Edge " if slab.get("edge_type_term_id") else ""
|
# "Live Edge" is a product-form flag driven by the *synced* edge term
|
||||||
|
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
|
||||||
|
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
|
||||||
title = pattern.format(
|
title = pattern.format(
|
||||||
figure=figure + " " if figure else "",
|
figure=figure + " " if figure else "",
|
||||||
species=species_name,
|
species=species_name,
|
||||||
@@ -84,8 +91,17 @@ def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf:
|
|||||||
return re.sub(r"\s+", " ", title).strip()
|
return re.sub(r"\s+", " ", title).strip()
|
||||||
|
|
||||||
|
|
||||||
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
|
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
|
||||||
title = build_title(slab, figure, species_name, primary_leaf)
|
"""True when the synced edge term name/slug indicates live edge."""
|
||||||
|
if not edge_term_id:
|
||||||
|
return False
|
||||||
|
term = terms.get(edge_term_id) or {}
|
||||||
|
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
|
||||||
|
return "live" in blob
|
||||||
|
|
||||||
|
|
||||||
|
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||||
|
title = build_title(slab, figure, species_name, primary_leaf, terms)
|
||||||
ft, rem = _split_length(slab["length_in"])
|
ft, rem = _split_length(slab["length_in"])
|
||||||
length_str = f"{ft}ft {_fmt1(rem)}in"
|
length_str = f"{ft}ft {_fmt1(rem)}in"
|
||||||
bdft = round(slab["bdft"], 1)
|
bdft = round(slab["bdft"], 1)
|
||||||
@@ -114,8 +130,20 @@ def missing_required(slab: dict) -> list[str]:
|
|||||||
return [f for f in _REQUIRED if slab.get(f) in (None, "", [])]
|
return [f for f in _REQUIRED if slab.get(f) in (None, "", [])]
|
||||||
|
|
||||||
|
|
||||||
|
# REVIEW-45: SKU is 3 to 24 chars, alphanumeric and hyphen only. The middle tier
|
||||||
|
# (not the DB CHECK) is the business-logic arbiter here; the DB constraint is a backstop.
|
||||||
|
_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$")
|
||||||
|
|
||||||
|
|
||||||
|
def sku_valid(sku: Any) -> bool:
|
||||||
|
return isinstance(sku, str) and bool(_SKU_RE.match(sku))
|
||||||
|
|
||||||
|
|
||||||
def compute_ready(slab: dict) -> tuple[bool, list[str]]:
|
def compute_ready(slab: dict) -> tuple[bool, list[str]]:
|
||||||
missing = missing_required(slab)
|
missing = missing_required(slab)
|
||||||
|
# A present-but-malformed SKU is not ready (business rule, enforced in the middle tier).
|
||||||
|
if "sku" not in missing and not sku_valid(slab.get("sku")):
|
||||||
|
missing.append("sku")
|
||||||
return (len(missing) == 0), missing
|
return (len(missing) == 0), missing
|
||||||
|
|
||||||
|
|
||||||
@@ -130,6 +158,13 @@ def price_suggestion(conn, slab: dict) -> float | None:
|
|||||||
|
|
||||||
@router.post("/{slab_id}/review")
|
@router.post("/{slab_id}/review")
|
||||||
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
|
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
|
||||||
|
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
|
||||||
|
|
||||||
|
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
|
||||||
|
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
|
||||||
|
win (METADATA-50). Assembled values are written back to the slab so the lock
|
||||||
|
sticks for publish.
|
||||||
|
"""
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -138,46 +173,68 @@ 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).
|
|
||||||
editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"}
|
# --- Deterministic review: report ready/missing + the deterministic
|
||||||
for k in editable:
|
# title/short_description built from the current metadata. The client's
|
||||||
if k in payload and payload[k] is not None:
|
# "Create title" / "Create short desc" buttons call this endpoint and write the
|
||||||
if k == "primary_wood_category_id":
|
# returned strings into the fields (#89: no separate lock action).
|
||||||
continue # not stored as a column; derived
|
# #80: persist owner overrides (price/sku) from the payload so the review
|
||||||
slab[k] = payload[k]
|
# endpoint is self-contained. title/short_description are NOT overridden here —
|
||||||
|
# they are produced by the deterministic build below, not persisted from the
|
||||||
|
# payload (so the review POST can't clobber the owner's Create workflow).
|
||||||
cats, terms = _taxonomy_lookup(conn)
|
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:
|
||||||
primary_id = wood_cats[0] if wood_cats else None
|
primary_id = wood_cats[0] if wood_cats else None
|
||||||
primary_leaf = cats.get(primary_id, {})
|
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
|
||||||
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
|
species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") else ""
|
||||||
figure_term_ids = slab.get("figure_term_ids") or []
|
figure_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).
|
# #80: persist owner overrides from the payload (price/sku only).
|
||||||
if not payload.get("title"):
|
overrides: dict[str, Any] = {}
|
||||||
if primary_leaf and species_name:
|
for k in ("price", "sku"):
|
||||||
slab["title"] = build_title(slab, figure, species_name, primary_leaf)
|
if k in payload and payload[k] is not None:
|
||||||
if not payload.get("short_description"):
|
overrides[k] = payload[k]
|
||||||
if primary_leaf and species_name:
|
if overrides:
|
||||||
slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf)
|
set_clause = ", ".join(f"{k} = ?" for k in overrides)
|
||||||
# Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks.
|
conn.execute(
|
||||||
suggested = price_suggestion(conn, slab)
|
f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
|
||||||
if payload.get("price") is not None:
|
[*overrides.values(), _now(), slab_id],
|
||||||
price = payload["price"]
|
)
|
||||||
elif slab.get("price") not in (None, 0):
|
conn.commit()
|
||||||
price = slab["price"]
|
slab.update(overrides)
|
||||||
else:
|
|
||||||
price = suggested
|
# Deterministic title / short_description, re-derived from the current
|
||||||
ready, missing = compute_ready({**slab, "price": price})
|
# metadata on every review (the Create button's source of truth).
|
||||||
|
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
|
||||||
|
title = (
|
||||||
|
build_title(slab, figure, species_name, primary_leaf, terms)
|
||||||
|
if (primary_leaf and species_name)
|
||||||
|
else slab.get("title") or None
|
||||||
|
)
|
||||||
|
short_desc = (
|
||||||
|
build_short_description(slab, figure, species_name, primary_leaf, terms)
|
||||||
|
if (primary_leaf and species_name)
|
||||||
|
else slab.get("short_description") or None
|
||||||
|
)
|
||||||
|
|
||||||
|
price = _resolve_price(conn, slab, payload)
|
||||||
|
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
|
||||||
return {
|
return {
|
||||||
"ready": ready,
|
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
|
||||||
"missing": missing,
|
"price": price, "price_suggestion": price_suggestion(conn, slab),
|
||||||
"title": slab.get("title"),
|
|
||||||
"short_description": slab.get("short_description"),
|
|
||||||
"price": price,
|
|
||||||
"price_suggestion": suggested,
|
|
||||||
"primary_wood_category_id": primary_id,
|
"primary_wood_category_id": primary_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
|
||||||
|
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
|
||||||
|
else the stored price, else the suggestion (None if no rate)."""
|
||||||
|
suggested = price_suggestion(conn, slab)
|
||||||
|
if payload.get("price") is not None:
|
||||||
|
return payload["price"]
|
||||||
|
if slab.get("price") not in (None, 0):
|
||||||
|
return slab["price"]
|
||||||
|
return suggested
|
||||||
|
|||||||
+139
-18
@@ -13,6 +13,7 @@ from fastapi import APIRouter
|
|||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
|
||||||
from .db import get_connection
|
from .db import get_connection
|
||||||
|
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/slabs")
|
router = APIRouter(prefix="/api/v1/slabs")
|
||||||
|
|
||||||
@@ -42,6 +43,20 @@ TAXONOMY_TABLES = {
|
|||||||
"fig_tag_ids": "woo_tags",
|
"fig_tag_ids": "woo_tags",
|
||||||
"feat_tag_ids": "woo_tags",
|
"feat_tag_ids": "woo_tags",
|
||||||
}
|
}
|
||||||
|
# Owner-facing labels for stale_field messages (QUALITY-120 — never raw column names).
|
||||||
|
FIELD_LABELS = {
|
||||||
|
"species_id": "Species",
|
||||||
|
"wood_category_ids": "Wood category",
|
||||||
|
"edge_type_term_id": "Edge type",
|
||||||
|
"figure_term_ids": "Figure",
|
||||||
|
"grade_term_id": "Grade",
|
||||||
|
"thickness_term_id": "Thickness",
|
||||||
|
"moisture_term_id": "Moisture",
|
||||||
|
"fig_tag_ids": "Figure tags",
|
||||||
|
"feat_tag_ids": "Feature tags",
|
||||||
|
}
|
||||||
|
PRICE_SOURCES = {"recommendation", "override", "manual"}
|
||||||
|
INFERENCE_STATUSES = {"idle", "inferring", "done", "failed", "skipped"}
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
@@ -64,6 +79,12 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# #89: server-managed fields that a plain client PUT must not set directly.
|
||||||
|
# (metadata_locked was dropped — the deterministic title/short-desc are built by
|
||||||
|
# the review endpoint and written back by the client's Create buttons.)
|
||||||
|
CLIENT_FORBIDDEN_FIELDS = set()
|
||||||
|
|
||||||
|
|
||||||
def _row_to_slab(row) -> Dict[str, Any]:
|
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:
|
||||||
@@ -95,21 +116,117 @@ def _check_taxonomy(conn, table: str, val: Any) -> bool:
|
|||||||
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
|
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _current_options(conn, table: str) -> list[dict[str, Any]]:
|
||||||
|
"""Live taxonomy options for a stale_field response (PUBLISH-20 / #82)."""
|
||||||
|
rows = conn.execute(f"SELECT id, name FROM {table} ORDER BY name, id").fetchall()
|
||||||
|
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tax_val(val: Any) -> Any:
|
||||||
|
if isinstance(val, str):
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_taxonomy_fields(conn, payload: Dict[str, Any]) -> JSONResponse | None:
|
||||||
|
"""Return a 422 stale_field response, or None if all present taxonomy ids are live."""
|
||||||
|
for field, table in TAXONOMY_TABLES.items():
|
||||||
|
if field not in payload or payload[field] is None:
|
||||||
|
continue
|
||||||
|
val = _normalize_tax_val(payload[field])
|
||||||
|
if not _check_taxonomy(conn, table, val):
|
||||||
|
label = FIELD_LABELS.get(field, field)
|
||||||
|
return _err(
|
||||||
|
"stale_field",
|
||||||
|
f"{label} is no longer in the store taxonomy",
|
||||||
|
{"field": field, "current_options": _current_options(conn, table)},
|
||||||
|
status=422,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_enums(payload: Dict[str, Any]) -> JSONResponse | None:
|
||||||
|
if "price_source" in payload and payload["price_source"] is not None:
|
||||||
|
if payload["price_source"] not in PRICE_SOURCES:
|
||||||
|
return _err(
|
||||||
|
"validation",
|
||||||
|
"price_source is not a known value",
|
||||||
|
{"field": "price_source", "allowed": sorted(PRICE_SOURCES)},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if "inference_status" in payload and payload["inference_status"] is not None:
|
||||||
|
if payload["inference_status"] not in INFERENCE_STATUSES:
|
||||||
|
return _err(
|
||||||
|
"validation",
|
||||||
|
"inference_status is not a known value",
|
||||||
|
{"field": "inference_status", "allowed": sorted(INFERENCE_STATUSES)},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Capture-only fields required to create a slab. The Review screen fills the
|
||||||
|
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
|
||||||
|
# editable until create"), so create must accept a capture-math payload.
|
||||||
|
# client_rev is server-managed (set to 1 at create) — never required from the client.
|
||||||
|
REQUIRED_CREATE_FIELDS = [
|
||||||
|
"id", "sku", "length_in", "thickness_in", "sqft", "bdft",
|
||||||
|
"width_min_in", "width_max_in", "width_avg_in",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
|
async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
missing = [f for f in ALLOWED_FIELDS if f not in payload]
|
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
|
||||||
if missing:
|
if missing:
|
||||||
return _err("validation", "missing fields", {"fields": missing}, status=400)
|
return _err("validation", "missing fields", {"fields": missing}, status=400)
|
||||||
|
# #83: reject unknown keys (client sent a field that is not client-settable).
|
||||||
|
# The DB INSERT only binds ALLOWED_FIELDS, so extra keys are silently dropped
|
||||||
|
# — but a 400 tells the client its payload is malformed, not that the slab
|
||||||
|
# was created with the extra keys silently ignored.
|
||||||
|
unknown = [k for k in payload if k not in ALLOWED_FIELDS and k != "client_rev"]
|
||||||
|
if unknown:
|
||||||
|
return _err("validation", "unknown fields", {"fields": unknown}, status=400)
|
||||||
|
# Business rule (middle tier, not the DB): an empty/blank SKU at create means
|
||||||
|
# "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only
|
||||||
|
# governs present SKUs, and NULL is the honest "absent" representation.
|
||||||
|
sku = payload.get("sku")
|
||||||
|
if isinstance(sku, str) and sku.strip() == "":
|
||||||
|
payload = {**payload, "sku": None}
|
||||||
|
enum_err = _validate_enums(payload)
|
||||||
|
if enum_err is not None:
|
||||||
|
return enum_err
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
if _sku_taken(conn, payload["sku"]):
|
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||||
|
if tax_err is not None:
|
||||||
|
return tax_err
|
||||||
|
if payload.get("sku") is not None and _sku_taken(conn, payload["sku"]):
|
||||||
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||||
|
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
|
||||||
|
# Resolve it from the measured thickness against the live synced Woo bands and
|
||||||
|
# overwrite whatever the client sent (the UI has no band control).
|
||||||
|
band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn))
|
||||||
|
if band is not None:
|
||||||
|
payload = {**payload, "thickness_term_id": band}
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
data = _encode(payload)
|
data = _encode(payload)
|
||||||
cols = [f for f in ALLOWED_FIELDS if f in data] + ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
|
allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
|
||||||
vals = [data[f] for f in cols if f in data] + ["calibrated", 1, 1, None, now, now]
|
server_cols = ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
|
||||||
|
server_vals = ["calibrated", 1, 1, None, now, now]
|
||||||
|
cols = allowed_cols + server_cols
|
||||||
|
# One value per col, in cols order: data values for the allowed fields, then the 6 server values.
|
||||||
|
vals = [data[f] for f in allowed_cols] + server_vals
|
||||||
ph = ",".join(["?"] * len(cols))
|
ph = ",".join(["?"] * len(cols))
|
||||||
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
|
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
|
||||||
conn.commit()
|
try:
|
||||||
|
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
# QUALITY-120: never surface a driver stack to the owner.
|
||||||
|
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
|
||||||
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
|
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
|
||||||
return _row_to_slab(row)
|
return _row_to_slab(row)
|
||||||
|
|
||||||
@@ -134,6 +251,13 @@ async def get_slab(id: str) -> Dict[str, Any]:
|
|||||||
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
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: server-managed fields a plain PUT must not set.
|
||||||
|
for field in CLIENT_FORBIDDEN_FIELDS:
|
||||||
|
if field in payload:
|
||||||
|
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
|
||||||
|
enum_err = _validate_enums(payload)
|
||||||
|
if enum_err is not None:
|
||||||
|
return enum_err
|
||||||
conn = get_connection()
|
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:
|
||||||
@@ -143,19 +267,16 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return _err("revision_conflict", "client_rev mismatch",
|
return _err("revision_conflict", "client_rev mismatch",
|
||||||
{"current_server_rev": current["server_rev"]}, status=409)
|
{"current_server_rev": current["server_rev"]}, status=409)
|
||||||
if "sku" in payload and payload["sku"] != current["sku"]:
|
if "sku" in payload and payload["sku"] != current["sku"]:
|
||||||
if _sku_taken(conn, payload["sku"], exclude_id=id):
|
if payload["sku"] is not None and _sku_taken(conn, payload["sku"], exclude_id=id):
|
||||||
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||||
for field, table in TAXONOMY_TABLES.items():
|
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||||
if field in payload and payload[field] is not None:
|
if tax_err is not None:
|
||||||
val = payload[field]
|
return tax_err
|
||||||
if isinstance(val, str):
|
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
|
||||||
try:
|
eff_thickness = payload.get("thickness_in", current["thickness_in"])
|
||||||
val = json.loads(val)
|
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
|
||||||
except (ValueError, TypeError):
|
if band is not None:
|
||||||
val = []
|
payload = {**payload, "thickness_term_id": band}
|
||||||
if not _check_taxonomy(conn, table, val):
|
|
||||||
return _err("stale_field", f"{field} not in current taxonomy",
|
|
||||||
{"current_options": val}, status=422)
|
|
||||||
data = _encode(payload)
|
data = _encode(payload)
|
||||||
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
|
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
|
||||||
if not cols:
|
if not cols:
|
||||||
|
|||||||
@@ -58,6 +58,101 @@ def test_prompt_seed_and_revision(env):
|
|||||||
assert client.get("/api/v1/prompts/call1").json()["active_content"] is not None
|
assert client.get("/api/v1/prompts/call1").json()["active_content"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_call1_seed_is_locked_expert_prompt(env):
|
||||||
|
"""#86: shipped Call 1 seed is the locked expert prompt (no free-text bags, no grade)."""
|
||||||
|
client, _conn = env
|
||||||
|
from backend.inference import CALL1_SEED
|
||||||
|
|
||||||
|
body = client.get("/api/v1/prompts/call1").json()
|
||||||
|
assert body["active_content"] == CALL1_SEED
|
||||||
|
assert "wood identification expert" in CALL1_SEED
|
||||||
|
assert "feature_tag_ids" in CALL1_SEED
|
||||||
|
assert "Decide wood categories before edge type" in CALL1_SEED
|
||||||
|
assert "highest-confidence" in CALL1_SEED
|
||||||
|
assert "Wild edge" in CALL1_SEED
|
||||||
|
assert "cookies and burl" in CALL1_SEED.lower() or "cookies and burl" in CALL1_SEED
|
||||||
|
assert "grade_term_id" not in CALL1_SEED
|
||||||
|
assert '"character"' not in CALL1_SEED
|
||||||
|
assert '"inclusions"' not in CALL1_SEED
|
||||||
|
assert "character\":" not in CALL1_SEED.replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_taxonomy_lists_call1_closed_shape():
|
||||||
|
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
|
||||||
|
import tempfile, os, shutil
|
||||||
|
tmp = tempfile.mkdtemp(prefix="tax_")
|
||||||
|
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
|
||||||
|
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||||
|
os.environ.setdefault("WOO_BASE_URL", "https://x.com")
|
||||||
|
os.environ.setdefault(
|
||||||
|
"SLAB_AES_KEY",
|
||||||
|
base64.b64encode(b"0123456789abcdef0123456789abcdef").decode(),
|
||||||
|
)
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
conn = db_mod.init_db()
|
||||||
|
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (10,'Species','species',NULL,10)")
|
||||||
|
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Walnut','walnut',10,11)")
|
||||||
|
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (20,'Wcat','wcat',NULL,20)")
|
||||||
|
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (21,'Live edge wood','liveedge',20,21)")
|
||||||
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (6,'Edge type','pa_etype',6)")
|
||||||
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Figure','pa_fig',8)")
|
||||||
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Grade','pa_gr',9)")
|
||||||
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Thickness','pa_thk',10)")
|
||||||
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (7,'Moisture','pa_mst',7)")
|
||||||
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (321,6,'Square edge','sqe',321)")
|
||||||
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (328,8,'Plain','pl',328)")
|
||||||
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (342,9,'Fancy','fnc',342)")
|
||||||
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (350,10,'1\"- 1 1/2\"','4-6qtr',350)")
|
||||||
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,7,'Kiln dried','kd',326)")
|
||||||
|
conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)")
|
||||||
|
conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (2,'fig-curl','fig-curl',2)")
|
||||||
|
conn.commit()
|
||||||
|
from backend.inference import _taxonomy_lists
|
||||||
|
|
||||||
|
tax = _taxonomy_lists(conn)
|
||||||
|
assert set(tax.keys()) == {"species", "wood_categories", "edge_terms", "figure_terms", "feature_tags"}
|
||||||
|
assert tax["species"] == [{"id": 11, "name": "Walnut"}]
|
||||||
|
assert tax["wood_categories"] == [{"id": 21, "name": "Live edge wood"}]
|
||||||
|
assert tax["edge_terms"] == [{"id": 321, "name": "Square edge"}]
|
||||||
|
assert tax["figure_terms"] == [{"id": 328, "name": "Plain"}]
|
||||||
|
# feat- prefix stripped for the model; fig- tags are not feature_tags
|
||||||
|
assert tax["feature_tags"] == [{"id": 1, "name": "knot"}]
|
||||||
|
assert "grade_terms" not in tax
|
||||||
|
assert "thickness_terms" not in tax
|
||||||
|
assert "moisture_terms" not in tax
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_feature_tag_ids_not_free_text():
|
||||||
|
"""#86: gate keeps feature_tag_ids at threshold; drops free-text bags and grade."""
|
||||||
|
from backend.inference import _gate
|
||||||
|
|
||||||
|
parsed = {
|
||||||
|
"species_id": 11, "species_confidence": 0.9,
|
||||||
|
"wood_category_ids": [21], "wood_category_confidence": 0.8,
|
||||||
|
"edge_type_term_id": 321, "edge_confidence": 0.85,
|
||||||
|
"figure_term_ids": [328, 335], "figure_confidence": 0.75,
|
||||||
|
"feature_tag_ids": [1], "feature_tag_confidence": 0.8,
|
||||||
|
"grade_term_id": 342, "grade_confidence": 0.99,
|
||||||
|
"character": ["rustic"], "inclusions": ["bark"], "voids": [], "checks": ["end"],
|
||||||
|
}
|
||||||
|
out = _gate(parsed)
|
||||||
|
assert out["species_id"] == 11
|
||||||
|
assert out["wood_category_ids"] == [21]
|
||||||
|
assert out["edge_type_term_id"] == 321
|
||||||
|
assert out["figure_term_ids"] == [328, 335]
|
||||||
|
assert out["feature_tag_ids"] == [1]
|
||||||
|
assert "grade_term_id" not in out
|
||||||
|
assert "character" not in out
|
||||||
|
assert "inclusions" not in out
|
||||||
|
assert "voids" not in out
|
||||||
|
assert "checks" not in out
|
||||||
|
# below threshold feature tags dropped
|
||||||
|
low = {**parsed, "feature_tag_confidence": 0.2}
|
||||||
|
assert "feature_tag_ids" not in _gate(low)
|
||||||
|
|
||||||
|
|
||||||
def test_call1_confidence_gate(env, monkeypatch):
|
def test_call1_confidence_gate(env, monkeypatch):
|
||||||
client, conn = env
|
client, conn = env
|
||||||
# enable inference
|
# enable inference
|
||||||
@@ -69,11 +164,18 @@ def test_call1_confidence_gate(env, monkeypatch):
|
|||||||
from backend.inference import InferenceClient
|
from backend.inference import InferenceClient
|
||||||
|
|
||||||
def fake_chat(self, messages):
|
def fake_chat(self, messages):
|
||||||
|
# Capture list header for #86 shape
|
||||||
|
text = messages[0]["content"][-1]["text"]
|
||||||
|
assert "LISTS:" in text
|
||||||
|
assert "grade_terms" not in text
|
||||||
|
assert "species_leaves" not in text
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"species_id": 1, "species_confidence": 0.9,
|
"species_id": 1, "species_confidence": 0.9,
|
||||||
"wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold
|
"wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold
|
||||||
"grade_term_id": 7, "grade_confidence": 0.3, # below
|
"grade_term_id": 7, "grade_confidence": 0.99, # never suggested
|
||||||
"figure_term_ids": [2], "figure_confidence": 0.8, # at/above
|
"figure_term_ids": [2], "figure_confidence": 0.8, # at/above
|
||||||
|
"feature_tag_ids": [9], "feature_tag_confidence": 0.9,
|
||||||
|
"character": ["x"], "inclusions": ["y"],
|
||||||
})
|
})
|
||||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||||
@@ -81,8 +183,10 @@ def test_call1_confidence_gate(env, monkeypatch):
|
|||||||
s = r.json()["suggestions"]
|
s = r.json()["suggestions"]
|
||||||
assert s.get("species_id") == 1
|
assert s.get("species_id") == 1
|
||||||
assert s.get("figure_term_ids") == [2]
|
assert s.get("figure_term_ids") == [2]
|
||||||
|
assert s.get("feature_tag_ids") == [9]
|
||||||
assert "wood_category_ids" not in s # gated out
|
assert "wood_category_ids" not in s # gated out
|
||||||
assert "grade_term_id" not in s # gated out
|
assert "grade_term_id" not in s # never
|
||||||
|
assert "character" not in s
|
||||||
# inference_log written with call=1
|
# inference_log written with call=1
|
||||||
assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1
|
assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1
|
||||||
|
|
||||||
@@ -126,3 +230,188 @@ def test_test_inference_unconfigured(env):
|
|||||||
r = client.post("/api/v1/admin/test-inference")
|
r = client.post("/api/v1/admin/test-inference")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["ok"] is False and r.json()["mode"] == "fail"
|
assert r.json()["ok"] is False and r.json()["mode"] == "fail"
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba_png(w: int, h: int) -> bytes:
|
||||||
|
"""RGBA PNG: opaque body + transparent top-left block (capture-like alpha)."""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
im = Image.new("RGBA", (w, h), (10, 20, 30, 255))
|
||||||
|
for y in range(min(80, h)):
|
||||||
|
for x in range(min(80, w)):
|
||||||
|
im.putpixel((x, y), (0, 0, 0, 0))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
im.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_long_edge_png_downscales_over_1024():
|
||||||
|
"""METADATA-35: Call 1 images long edge <= 1024; never upscale; keep alpha."""
|
||||||
|
from backend.inference import fit_long_edge_png
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
src = _rgba_png(800, 1067) # inventory 3:4, long edge 1067
|
||||||
|
out = fit_long_edge_png(src, max_long_edge=1024)
|
||||||
|
im = Image.open(io.BytesIO(out)).convert("RGBA")
|
||||||
|
assert max(im.size) == 1024
|
||||||
|
assert min(im.size) < 1024
|
||||||
|
w, h = im.size
|
||||||
|
assert abs((w / h) - (800 / 1067)) < 0.02
|
||||||
|
# Transparent block still transparent near origin after downscale
|
||||||
|
assert im.getpixel((0, 0))[3] == 0
|
||||||
|
assert im.getpixel((5, 5))[3] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_long_edge_png_leaves_small_unchanged_dims():
|
||||||
|
from backend.inference import fit_long_edge_png
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
src = _rgba_png(400, 300)
|
||||||
|
out = fit_long_edge_png(src, max_long_edge=1024)
|
||||||
|
im = Image.open(io.BytesIO(out))
|
||||||
|
assert im.size == (400, 300)
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_request_for_log_strips_data_urls_keeps_text():
|
||||||
|
"""#87: log sanitizer replaces data-URL images with photo refs; keeps text."""
|
||||||
|
from backend.inference import _redact_request_for_log
|
||||||
|
|
||||||
|
b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64 * 20}"}},
|
||||||
|
{"type": "text", "text": "LISTS:\n{\"species\":[]}\n\nSKU=X length_in=1 thickness_in=2"},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
meta = [
|
||||||
|
{"photo_id": "p1", "seq": 1, "bytes": 100, "infer_long_edge_px": 64},
|
||||||
|
{"photo_id": "p2", "seq": 2, "bytes": 200, "infer_long_edge_px": 128},
|
||||||
|
]
|
||||||
|
out = _redact_request_for_log(messages, meta)
|
||||||
|
blob = json.dumps(out)
|
||||||
|
assert "data:image" not in blob
|
||||||
|
assert "base64" not in blob.lower()
|
||||||
|
assert "LISTS:" in blob
|
||||||
|
assert "SKU=X" in blob
|
||||||
|
parts = out[0]["content"]
|
||||||
|
assert parts[0]["type"] == "image_ref"
|
||||||
|
assert parts[0]["photo_id"] == "p1"
|
||||||
|
assert parts[0]["seq"] == 1
|
||||||
|
assert parts[1]["photo_id"] == "p2"
|
||||||
|
assert parts[2]["type"] == "text"
|
||||||
|
assert "LISTS:" in parts[2]["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_call1_log_has_text_and_image_refs_no_base64(env, monkeypatch):
|
||||||
|
"""#87 METADATA-66: inference_log stores text+refs, not image bytes."""
|
||||||
|
client, conn = env
|
||||||
|
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
|
||||||
|
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
|
||||||
|
)
|
||||||
|
png = _rgba_png(400, 300)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('photo-abc','s1',1,?, 't')",
|
||||||
|
(png,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
from backend.inference import InferenceClient
|
||||||
|
|
||||||
|
def fake_chat(self, messages):
|
||||||
|
# Wire path still has data-URL for the model
|
||||||
|
content = messages[0]["content"]
|
||||||
|
assert any(
|
||||||
|
isinstance(p, dict) and p.get("type") == "image_url"
|
||||||
|
and str(p.get("image_url", {}).get("url", "")).startswith("data:image")
|
||||||
|
for p in content
|
||||||
|
)
|
||||||
|
return json.dumps({"species_id": 1, "species_confidence": 0.9})
|
||||||
|
|
||||||
|
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||||
|
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT request_payload, length(request_payload) AS n FROM inference_log ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
payload = row["request_payload"]
|
||||||
|
assert row["n"] < 50000
|
||||||
|
assert "data:image" not in payload
|
||||||
|
assert "LISTS:" in payload
|
||||||
|
assert "image_ref" in payload
|
||||||
|
assert "photo-abc" in payload
|
||||||
|
assert '"seq": 1' in payload or '"seq":1' in payload
|
||||||
|
logged = json.loads(payload)
|
||||||
|
refs = [p for p in logged[0]["content"] if p.get("type") == "image_ref"]
|
||||||
|
assert len(refs) == 1
|
||||||
|
assert refs[0]["photo_id"] == "photo-abc"
|
||||||
|
assert refs[0]["bytes"] == len(png)
|
||||||
|
|
||||||
|
|
||||||
|
def test_call2_log_redacts_images(env, monkeypatch):
|
||||||
|
"""#87: Call 2 log also redacts image data-URLs."""
|
||||||
|
client, conn = env
|
||||||
|
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) "
|
||||||
|
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut','ready',1,1,'t','t')"
|
||||||
|
)
|
||||||
|
png = _rgba_png(200, 200)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p2','s1',1,?, 't')",
|
||||||
|
(png,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
from backend.inference import InferenceClient
|
||||||
|
|
||||||
|
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>ok</p>")
|
||||||
|
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = conn.execute(
|
||||||
|
"SELECT request_payload FROM inference_log WHERE call=2 ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()["request_payload"]
|
||||||
|
assert "data:image" not in payload
|
||||||
|
assert "image_ref" in payload
|
||||||
|
assert "p2" in payload
|
||||||
|
assert "DIMENSION FACT BLOCK" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_call1_sends_long_edge_at_most_1024(env, monkeypatch):
|
||||||
|
"""METADATA-35 end-to-end: messages to the model use <=1024 long edge."""
|
||||||
|
client, conn = env
|
||||||
|
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
|
||||||
|
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
|
||||||
|
)
|
||||||
|
big = _rgba_png(800, 1067)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
|
||||||
|
(big,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
from backend.inference import InferenceClient
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_chat(self, messages):
|
||||||
|
captured["messages"] = messages
|
||||||
|
return json.dumps({"species_id": 1, "species_confidence": 0.9})
|
||||||
|
|
||||||
|
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||||
|
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
content = captured["messages"][0]["content"]
|
||||||
|
imgs = [p for p in content if isinstance(p, dict) and p.get("type") == "image_url"]
|
||||||
|
assert len(imgs) == 1
|
||||||
|
url = imgs[0]["image_url"]["url"]
|
||||||
|
assert url.startswith("data:image/png;base64,")
|
||||||
|
raw = base64.b64decode(url.split(",", 1)[1])
|
||||||
|
im = Image.open(io.BytesIO(raw))
|
||||||
|
assert max(im.size) <= 1024
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ PNG_ORIG = b"\x89PNG-orig-bytes"
|
|||||||
PNG_PROC = b"\x89PNG-proc-bytes"
|
PNG_PROC = b"\x89PNG-proc-bytes"
|
||||||
|
|
||||||
|
|
||||||
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0):
|
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0, role="batch", seq=None):
|
||||||
files = {"original": ("orig.png", original, "image/png")}
|
files = {"original": ("orig.png", original, "image/png")}
|
||||||
if processed is not None:
|
if processed is not None:
|
||||||
files["processed"] = ("proc.png", processed, "image/png")
|
files["processed"] = ("proc.png", processed, "image/png")
|
||||||
|
data = {"photo_id": photo_id, "is_calibration": str(is_calibration), "role": role}
|
||||||
|
if seq is not None:
|
||||||
|
data["seq"] = str(seq)
|
||||||
return client.post(
|
return client.post(
|
||||||
f"/api/v1/slabs/{slab_id}/photos",
|
f"/api/v1/slabs/{slab_id}/photos",
|
||||||
data={"photo_id": photo_id, "is_calibration": str(is_calibration)},
|
data=data,
|
||||||
files=files,
|
files=files,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,9 +93,43 @@ def test_reupload_replaces(photos_env):
|
|||||||
|
|
||||||
def test_list_photos(photos_env):
|
def test_list_photos(photos_env):
|
||||||
client, _ = photos_env
|
client, _ = photos_env
|
||||||
_multipart(client, "s1", "pa", is_calibration=1)
|
_multipart(client, "s1", "pa", is_calibration=1, role="calibration")
|
||||||
_multipart(client, "s1", "pb")
|
_multipart(client, "s1", "pb", role="overlay")
|
||||||
r = client.get("/api/v1/slabs/s1/photos")
|
r = client.get("/api/v1/slabs/s1/photos")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
ids = [row["id"] for row in r.json()]
|
ids = [row["id"] for row in r.json()]
|
||||||
assert "pa" in ids and "pb" in ids
|
assert "pa" in ids and "pb" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_stores_and_returns_role(photos_env):
|
||||||
|
client, _ = photos_env
|
||||||
|
r = _multipart(client, "s1", "r1", role="overlay")
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["role"] == "overlay"
|
||||||
|
r = client.get("/api/v1/slabs/s1/photos")
|
||||||
|
row = {row_["id"]: row_ for row_ in r.json()}["r1"]
|
||||||
|
assert row["role"] == "overlay"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_bad_role(photos_env):
|
||||||
|
client, _ = photos_env
|
||||||
|
r = _multipart(client, "s1", "r2", role="not-a-role")
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["error"]["code"] == "validation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_photo(photos_env):
|
||||||
|
client, _ = photos_env
|
||||||
|
_multipart(client, "s1", "d1")
|
||||||
|
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 200
|
||||||
|
r = client.delete("/api/v1/slabs/s1/photos/d1")
|
||||||
|
assert r.status_code == 204
|
||||||
|
# Gone from the list and blob no longer served.
|
||||||
|
ids = [row["id"] for row in client.get("/api/v1/slabs/s1/photos").json()]
|
||||||
|
assert "d1" not in ids
|
||||||
|
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_photo_not_found(photos_env):
|
||||||
|
client, _ = photos_env
|
||||||
|
assert client.delete("/api/v1/slabs/s1/photos/ghost").status_code == 404
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ def env(monkeypatch):
|
|||||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
|
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
|
||||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
|
||||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
|
||||||
|
# Thickness bands, names as synced (the derivation parses them) — QUALITY-95.
|
||||||
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)")
|
||||||
|
for tid, name, slug in [
|
||||||
|
(347, 'Under 1/2"', 'xthin'), (348, '1/2" - 3/4"', '2-3qtr'),
|
||||||
|
(349, '3/4" - 1"', '3-4qtr'), (350, '1"- 1 1/2"', '4-6qtr'),
|
||||||
|
(351, '1 1/2" - 2"', '6-8qtr'), (352, '2" - 3"', '8-12qtr'),
|
||||||
|
(353, 'Over 3"', '12qtr'),
|
||||||
|
]:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)",
|
||||||
|
(tid, name, slug, tid),
|
||||||
|
)
|
||||||
|
# Moisture keeps its own attribute so the five locked attributes stay distinct.
|
||||||
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)")
|
||||||
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)")
|
||||||
# creds
|
# creds
|
||||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
|
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
|
||||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
|
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
|
||||||
@@ -119,6 +134,109 @@ def test_publish_success(env, monkeypatch):
|
|||||||
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
|
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_sends_derived_thickness_band(env, monkeypatch):
|
||||||
|
"""#90 / QUALITY-95: the band is off the Review screen but must still reach Woo.
|
||||||
|
|
||||||
|
The owner has no thickness-band control, so nothing client-side carries it: the
|
||||||
|
server derives it from the measurement on create/update, and publish must send the
|
||||||
|
live synced term name on the product payload. This pins that chain end to end.
|
||||||
|
"""
|
||||||
|
client, conn = env
|
||||||
|
create = client.post("/api/v1/slabs", json={
|
||||||
|
"id": "s1", "sku": "SLAB-BAND", "length_in": 114.0, "thickness_in": 1.75,
|
||||||
|
"sqft": 30.0, "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
|
||||||
|
})
|
||||||
|
assert create.status_code == 201, create.text
|
||||||
|
# 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client.
|
||||||
|
assert create.json()["thickness_term_id"] == 351
|
||||||
|
|
||||||
|
put = client.put("/api/v1/slabs/s1", json={
|
||||||
|
"client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2],
|
||||||
|
"edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326,
|
||||||
|
"price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S",
|
||||||
|
"description": "D", "content_source": "manual",
|
||||||
|
})
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'")
|
||||||
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
products = []
|
||||||
|
|
||||||
|
def fake_request(url, method, data, headers):
|
||||||
|
if "/products?sku=" in url:
|
||||||
|
return 200, {}, b"[]"
|
||||||
|
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
||||||
|
return 201, {}, json.dumps({"id": 42}).encode()
|
||||||
|
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||||
|
products.append(json.loads(data))
|
||||||
|
return 201, {}, json.dumps({"id": 999}).encode()
|
||||||
|
return 200, {}, b"[]"
|
||||||
|
|
||||||
|
from backend.woo import WooClient
|
||||||
|
|
||||||
|
orig_init = WooClient.__init__
|
||||||
|
|
||||||
|
def patched_init(self, *a, **k):
|
||||||
|
orig_init(self, *a, **k)
|
||||||
|
self._request = fake_request
|
||||||
|
|
||||||
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||||
|
r = client.post("/api/v1/slabs/s1/publish")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert len(products) == 1, "expected exactly one product create"
|
||||||
|
attrs = {a["id"]: a for a in products[0].get("attributes", [])}
|
||||||
|
assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}"
|
||||||
|
assert attrs[9]["options"] == ['1 1/2" - 2"']
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_woo_image_order_by_role(env, monkeypatch):
|
||||||
|
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
|
||||||
|
# role, not by stored seq (the Review strip stores the owner's keep order).
|
||||||
|
client, conn = env
|
||||||
|
s = _full_slab()
|
||||||
|
_insert_slab(conn, s)
|
||||||
|
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
|
||||||
|
# overlay(3). The publish query must reorder to cal, overlay, batch.
|
||||||
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
|
||||||
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
|
||||||
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
|
||||||
|
conn.commit()
|
||||||
|
media_calls = []
|
||||||
|
|
||||||
|
def fake_request(url, method, data, headers):
|
||||||
|
if "/products?sku=" in url:
|
||||||
|
return 200, {}, b"[]"
|
||||||
|
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
||||||
|
media_calls.append(data)
|
||||||
|
return 201, {}, json.dumps({"id": 42}).encode()
|
||||||
|
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||||
|
return 201, {}, json.dumps({"id": 999}).encode()
|
||||||
|
return 200, {}, b"[]"
|
||||||
|
|
||||||
|
from backend.woo import WooClient
|
||||||
|
|
||||||
|
orig_init = WooClient.__init__
|
||||||
|
|
||||||
|
def patched_init(self, *a, **k):
|
||||||
|
orig_init(self, *a, **k)
|
||||||
|
self._request = fake_request
|
||||||
|
|
||||||
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||||
|
r = client.post("/api/v1/slabs/s1/publish")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert len(media_calls) == 3
|
||||||
|
# The photo id is embedded in the multipart filename (slab.py publish
|
||||||
|
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
|
||||||
|
# batch (PUBLISH-45), not stored seq.
|
||||||
|
def photo_id(body: bytes) -> str:
|
||||||
|
import re
|
||||||
|
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
|
||||||
|
return m.group(1).decode() if m else ""
|
||||||
|
|
||||||
|
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
|
||||||
|
|
||||||
|
|
||||||
def test_uat_draft(env, monkeypatch):
|
def test_uat_draft(env, monkeypatch):
|
||||||
client, conn = env
|
client, conn = env
|
||||||
s = _full_slab(sku="SLAB-UAT-1")
|
s = _full_slab(sku="SLAB-UAT-1")
|
||||||
@@ -182,3 +300,63 @@ def test_partial_media_failure_502(env, monkeypatch):
|
|||||||
# sync_log written, status failed
|
# sync_log written, status failed
|
||||||
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
|
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
|
||||||
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
|
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
|
||||||
|
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
|
||||||
|
client, conn = env
|
||||||
|
s = _full_slab()
|
||||||
|
_insert_slab(conn, s)
|
||||||
|
conn.commit()
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_request(url, method, data, headers):
|
||||||
|
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
|
||||||
|
if "/products?sku=" in url:
|
||||||
|
return 200, {}, b"[]"
|
||||||
|
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||||
|
return 201, {}, json.dumps({"id": 42}).encode()
|
||||||
|
return 200, {}, b"[]"
|
||||||
|
|
||||||
|
from backend.woo import WooClient, _USER_AGENT
|
||||||
|
|
||||||
|
orig_init = WooClient.__init__
|
||||||
|
|
||||||
|
def patched_init(self, *a, **k):
|
||||||
|
orig_init(self, *a, **k)
|
||||||
|
self._request = fake_request
|
||||||
|
|
||||||
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||||
|
r = client.post("/api/v1/slabs/s1/publish")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
|
||||||
|
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
|
||||||
|
assert sku_hops and create_hops
|
||||||
|
for hop in sku_hops + create_hops:
|
||||||
|
assert hop["headers"].get("User-Agent") == _USER_AGENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_403_is_unrecoverable(env, monkeypatch):
|
||||||
|
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
|
||||||
|
client, conn = env
|
||||||
|
s = _full_slab()
|
||||||
|
_insert_slab(conn, s)
|
||||||
|
conn.commit()
|
||||||
|
from backend.woo import WooClient, WooForbiddenError
|
||||||
|
|
||||||
|
def fake_request(url, method, data, headers):
|
||||||
|
raise WooForbiddenError("request forbidden (403)")
|
||||||
|
|
||||||
|
orig_init = WooClient.__init__
|
||||||
|
|
||||||
|
def patched_init(self, *a, **k):
|
||||||
|
orig_init(self, *a, **k)
|
||||||
|
self._request = fake_request
|
||||||
|
|
||||||
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||||
|
r = client.post("/api/v1/slabs/s1/publish")
|
||||||
|
assert r.status_code == 502
|
||||||
|
body = r.json()
|
||||||
|
assert body["error"]["code"] == "woo_forbidden"
|
||||||
|
assert body["error"]["detail"]["recoverable"] is False
|
||||||
|
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ def test_title_live_edge_no_dims(env):
|
|||||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
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: the review always returns the deterministic title/short_description
|
||||||
|
# built from the current metadata (the Create button's source of truth).
|
||||||
r = env[0].post("/api/v1/slabs/s1/review", json={})
|
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()
|
||||||
@@ -134,5 +136,61 @@ def test_knife_blank_carries_dims(env):
|
|||||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
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: the deterministic build runs on every review.
|
||||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
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)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_always_reports_deterministic_title(env):
|
||||||
|
"""#89: the review always returns the deterministic title/short_description
|
||||||
|
built from the current metadata. The client's Create button writes these into
|
||||||
|
the fields; the server does not persist them from the review payload."""
|
||||||
|
client, conn = env
|
||||||
|
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||||
|
conn.commit()
|
||||||
|
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||||
|
body = r.json()
|
||||||
|
assert body["title"] == "Figured Black Walnut Live Edge Slab"
|
||||||
|
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
|
||||||
|
# The review does NOT persist title/short_description from the payload.
|
||||||
|
got = client.get("/api/v1/slabs/s1").json()
|
||||||
|
assert got["title"] is None
|
||||||
|
assert got["short_description"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_no_species_reports_stored_title(env):
|
||||||
|
"""With no species, there is no deterministic pattern; the review reports the
|
||||||
|
stored title (None here) and lists title as missing."""
|
||||||
|
client, conn = env
|
||||||
|
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
|
||||||
|
conn.commit()
|
||||||
|
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||||
|
body = r.json()
|
||||||
|
assert body["title"] is None
|
||||||
|
assert "title" in body["missing"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_persists_owner_overrides(env):
|
||||||
|
"""#80: regular review persists price/sku overrides from the payload to the DB,
|
||||||
|
so the client doesn't need a separate PUT for each. (title/short_description
|
||||||
|
are NOT persisted here — they come from the deterministic build, not the payload.)"""
|
||||||
|
client, conn = env
|
||||||
|
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||||
|
conn.commit()
|
||||||
|
# Review with owner overrides for price + sku.
|
||||||
|
r = client.post("/api/v1/slabs/s1/review", json={
|
||||||
|
"price": 250.0,
|
||||||
|
"sku": "SLAB-99",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["price"] == 250.0
|
||||||
|
# The overrides are persisted: GET reflects them.
|
||||||
|
got = client.get("/api/v1/slabs/s1").json()
|
||||||
|
assert got["price"] == 250.0
|
||||||
|
assert got["sku"] == "SLAB-99"
|
||||||
|
# server_rev was incremented (the UPDATE fired).
|
||||||
|
assert got["server_rev"] == 2
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ def _seed_taxonomy(conn):
|
|||||||
# tags
|
# tags
|
||||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
|
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
|
||||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
|
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
|
||||||
|
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
|
||||||
|
# live Woo attribute, because the derivation parses them.
|
||||||
|
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
|
||||||
|
for tid, name, slug in [
|
||||||
|
(347, 'Under 1/2"', 'xthin'),
|
||||||
|
(348, '1/2" - 3/4"', '2-3qtr'),
|
||||||
|
(349, '3/4" - 1"', '3-4qtr'),
|
||||||
|
(350, '1"- 1 1/2"', '4-6qtr'),
|
||||||
|
(351, '1 1/2" - 2"', '6-8qtr'),
|
||||||
|
(352, '2" - 3"', '8-12qtr'),
|
||||||
|
(353, 'Over 3"', '12qtr'),
|
||||||
|
]:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
|
||||||
|
"VALUES (?, 2, ?, ?, ?)",
|
||||||
|
(tid, name, slug, tid),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -103,6 +120,55 @@ def test_create_and_fetch(client, db):
|
|||||||
assert body[key] == payload[key]
|
assert body[key] == payload[key]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_derives_thickness_band(client, db):
|
||||||
|
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
|
||||||
|
|
||||||
|
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
|
||||||
|
the UI has no band control, so the server's derivation is authoritative.
|
||||||
|
"""
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
payload = {
|
||||||
|
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
|
||||||
|
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||||
|
"thickness_term_id": 1,
|
||||||
|
}
|
||||||
|
resp = client.post("/api/v1/slabs", json=payload)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rebands_when_thickness_changes(client, db):
|
||||||
|
"""A thickness edit moves the slab to the band the new measurement rounds into."""
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
create = client.post("/api/v1/slabs", json={
|
||||||
|
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
|
||||||
|
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||||
|
})
|
||||||
|
assert create.status_code == 201
|
||||||
|
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
|
||||||
|
put = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||||
|
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
|
||||||
|
})
|
||||||
|
assert put.status_code == 200
|
||||||
|
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_ignores_client_supplied_band(client, db):
|
||||||
|
"""A stale client band cannot pin the slab to the wrong band."""
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
create = client.post("/api/v1/slabs", json={
|
||||||
|
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
|
||||||
|
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||||
|
})
|
||||||
|
assert create.status_code == 201
|
||||||
|
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
|
||||||
|
put = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||||
|
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
|
||||||
|
})
|
||||||
|
assert put.status_code == 200
|
||||||
|
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_sku(client, db):
|
def test_duplicate_sku(client, db):
|
||||||
slab_id = str(uuid.uuid4())
|
slab_id = str(uuid.uuid4())
|
||||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||||
@@ -180,6 +246,76 @@ def test_soft_delete(client, db):
|
|||||||
assert resp_get.status_code == 404
|
assert resp_get.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_empty_sku_stored_as_null(client, db):
|
||||||
|
# Business rule (middle tier): empty SKU at create = "not set yet" -> stored as NULL,
|
||||||
|
# not rejected by the DB CHECK. This is the UAT "Continue to Review" path.
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
payload = {"id": slab_id, "sku": "", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||||
|
resp = client.post("/api/v1/slabs", json=payload)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["sku"] is None
|
||||||
|
# A second empty-SKU slab is allowed (NULLs are not unique-conflicts).
|
||||||
|
resp2 = client.post("/api/v1/slabs", json={**payload, "id": str(uuid.uuid4())})
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_null_sku_allowed(client, db):
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
payload = {"id": slab_id, "sku": None, "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||||
|
resp = client.post("/api/v1/slabs", json=payload)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["sku"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_reads_flat_confidence_keys(client, db):
|
||||||
|
# Regression: the gate's confidence keys are 'species_confidence' / 'edge_confidence'
|
||||||
|
# etc., NOT f'{field}_confidence' (field 'species_id' would give the wrong key). A
|
||||||
|
# high-confidence response must surface its picks; low-confidence ones must not.
|
||||||
|
from backend.inference import _gate, _parse_json
|
||||||
|
good = '```json\n{"species_id": 284, "species_confidence": 0.85, "wood_category_ids": [251], "wood_category_confidence": 1.0, "edge_type_term_id": 323, "edge_confidence": 0.95, "figure_term_ids": [332], "figure_confidence": 0.9, "feature_tag_ids": [1], "feature_tag_confidence": 0.8, "character": [], "inclusions": ["bark"], "voids": [], "checks": []}\n```'
|
||||||
|
out = _gate(_parse_json(good))
|
||||||
|
assert out["species_id"] == 284
|
||||||
|
assert out["wood_category_ids"] == [251]
|
||||||
|
assert out["edge_type_term_id"] == 323
|
||||||
|
assert out["figure_term_ids"] == [332]
|
||||||
|
assert out["feature_tag_ids"] == [1]
|
||||||
|
# grade must never be gated in (METADATA-40)
|
||||||
|
assert "grade_term_id" not in out
|
||||||
|
# free-text bags dropped (#86)
|
||||||
|
assert "character" not in out
|
||||||
|
assert "inclusions" not in out
|
||||||
|
# low-confidence fields are dropped (METADATA-45)
|
||||||
|
low = '```json\n{"species_id": 284, "species_confidence": 0.4, "wood_category_ids": [251], "wood_category_confidence": 0.8, "edge_type_term_id": 323, "edge_confidence": 0.2, "figure_term_ids": [332], "figure_confidence": 0.9}\n```'
|
||||||
|
out2 = _gate(_parse_json(low))
|
||||||
|
assert out2["wood_category_ids"] == [251]
|
||||||
|
assert out2["figure_term_ids"] == [332]
|
||||||
|
assert "species_id" not in out2
|
||||||
|
assert "edge_type_term_id" not in out2
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_gate_rejects_malformed_sku(client, db):
|
||||||
|
# REVIEW-45 enforced in the middle tier: a present-but-out-of-range/char-set SKU is not ready.
|
||||||
|
from backend.review import compute_ready, sku_valid
|
||||||
|
base = {"length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual"}
|
||||||
|
# valid
|
||||||
|
assert sku_valid("AB-1") is True
|
||||||
|
ready, missing = compute_ready({**base, "sku": "AB-1"})
|
||||||
|
assert ready is True and "sku" not in missing
|
||||||
|
# too short
|
||||||
|
assert sku_valid("AB") is False
|
||||||
|
ready, missing = compute_ready({**base, "sku": "AB"})
|
||||||
|
assert ready is False and "sku" in missing
|
||||||
|
# bad char
|
||||||
|
ready, missing = compute_ready({**base, "sku": "AB!"})
|
||||||
|
assert ready is False and "sku" in missing
|
||||||
|
# too long
|
||||||
|
ready, missing = compute_ready({**base, "sku": "A" * 25})
|
||||||
|
assert ready is False and "sku" in missing
|
||||||
|
# missing (None)
|
||||||
|
ready, missing = compute_ready({**base, "sku": None})
|
||||||
|
assert ready is False and "sku" in missing
|
||||||
|
|
||||||
|
|
||||||
def test_delete_published_conflict(client, db):
|
def test_delete_published_conflict(client, db):
|
||||||
slab_id = str(uuid.uuid4())
|
slab_id = str(uuid.uuid4())
|
||||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||||
@@ -190,3 +326,59 @@ def test_delete_published_conflict(client, db):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
resp = client.delete(f"/api/v1/slabs/{slab_id}")
|
resp = client.delete(f"/api/v1/slabs/{slab_id}")
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_unknown_fields(client, db):
|
||||||
|
# #83: POST /slabs with extra (non-ALLOWED_FIELDS) keys → 400, not a silent drop.
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5, "bogus_field": 42}
|
||||||
|
resp = client.post("/api/v1/slabs", json=payload)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
body = resp.json()
|
||||||
|
assert body["error"]["code"] == "validation"
|
||||||
|
assert "bogus_field" in body["error"]["detail"]["fields"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_stale_field_returns_live_options(client, db):
|
||||||
|
# #82: stale_field returns LIVE taxonomy options (id+name), not the submitted id.
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
payload = {
|
||||||
|
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||||
|
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||||
|
}
|
||||||
|
assert client.post("/api/v1/slabs", json=payload).status_code == 201
|
||||||
|
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "edge_type_term_id": 999})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
body = resp.json()
|
||||||
|
assert body["error"]["code"] == "stale_field"
|
||||||
|
opts = body["error"]["detail"]["current_options"]
|
||||||
|
assert isinstance(opts, list) and opts
|
||||||
|
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
|
||||||
|
assert 999 not in [o["id"] for o in opts]
|
||||||
|
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_extra_key_and_bad_taxonomy_are_4xx(client, db):
|
||||||
|
"""#83: server-managed/extra keys and bad taxonomy/enums are 4xx, not 500."""
|
||||||
|
base = {
|
||||||
|
"id": str(uuid.uuid4()), "sku": "SKU-XTRA", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||||
|
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||||
|
"status": "published", # server-managed → unknown field 400
|
||||||
|
}
|
||||||
|
r = client.post("/api/v1/slabs", json=base)
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "status" in r.json()["error"]["detail"]["fields"]
|
||||||
|
# Bad taxonomy on create
|
||||||
|
r2 = client.post("/api/v1/slabs", json={
|
||||||
|
"id": str(uuid.uuid4()), "sku": "SKU-BADTAX", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||||
|
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "species_id": 999,
|
||||||
|
})
|
||||||
|
assert r2.status_code == 422
|
||||||
|
assert r2.json()["error"]["code"] == "stale_field"
|
||||||
|
# Bad enum
|
||||||
|
r3 = client.post("/api/v1/slabs", json={
|
||||||
|
"id": str(uuid.uuid4()), "sku": "SKU-BADENUM", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||||
|
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "price_source": "not-a-source",
|
||||||
|
})
|
||||||
|
assert r3.status_code == 400
|
||||||
|
assert r3.json()["error"]["code"] == "validation"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Thickness band resolution (METADATA-15 / QUALITY-95).
|
||||||
|
|
||||||
|
The owner never picks the Woo thickness band in the UI — the server derives it
|
||||||
|
from the measured thickness_in against the live synced bands. These tests pin the
|
||||||
|
derivation on the real live band names (from the UAT taxonomy) and on the
|
||||||
|
quarter-inch slug fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.thickness_band import (
|
||||||
|
parse_inch_token,
|
||||||
|
resolve_thickness_band_id,
|
||||||
|
term_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The live UAT Thickness attribute (pa_thk) as synced: name, slug.
|
||||||
|
LIVE_TERMS = [
|
||||||
|
{"id": 347, "name": "Under 1/2\"", "slug": "xthin"},
|
||||||
|
{"id": 348, "name": "1/2\" - 3/4\"", "slug": "2-3qtr"},
|
||||||
|
{"id": 349, "name": "3/4\" - 1\"", "slug": "3-4qtr"},
|
||||||
|
{"id": 350, "name": "1\"- 1 1/2\"", "slug": "4-6qtr"},
|
||||||
|
{"id": 351, "name": "1 1/2\" - 2\"", "slug": "6-8qtr"},
|
||||||
|
{"id": 352, "name": "2\" - 3\"", "slug": "8-12qtr"},
|
||||||
|
{"id": 353, "name": "Over 3\"", "slug": "12qtr"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expected",
|
||||||
|
[
|
||||||
|
("1 1/2", 1.5),
|
||||||
|
("3/4", 0.75),
|
||||||
|
("2", 2.0),
|
||||||
|
("1.75", 1.75),
|
||||||
|
("2\"", 2.0),
|
||||||
|
("1 1/2\"", 1.5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_inch_token(text, expected):
|
||||||
|
assert parse_inch_token(text) == pytest.approx(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inch_token_rejects_junk():
|
||||||
|
assert parse_inch_token("") is None
|
||||||
|
assert parse_inch_token(" ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_term_bounds_from_live_names():
|
||||||
|
assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5)
|
||||||
|
assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75)
|
||||||
|
assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5)
|
||||||
|
assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_term_bounds_falls_back_to_qtr_slug():
|
||||||
|
assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5)
|
||||||
|
assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf"))
|
||||||
|
assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"thickness,expected_id",
|
||||||
|
[
|
||||||
|
(0.25, 347), # Under 1/2"
|
||||||
|
(0.5, 348), # boundary rounds UP into 1/2"-3/4", not down
|
||||||
|
(0.625, 348),
|
||||||
|
(0.75, 349), # boundary rounds UP
|
||||||
|
(0.875, 349),
|
||||||
|
(1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band
|
||||||
|
(1.25, 350),
|
||||||
|
(1.5, 351),
|
||||||
|
(1.75, 351), # the UAT sample thickness
|
||||||
|
(2.0, 352),
|
||||||
|
(2.75, 352),
|
||||||
|
(3.0, 353),
|
||||||
|
(4.0, 353),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_rounds_up_on_boundary(thickness, expected_id):
|
||||||
|
assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_handles_missing_inputs():
|
||||||
|
assert resolve_thickness_band_id(None, LIVE_TERMS) is None
|
||||||
|
assert resolve_thickness_band_id(-1, LIVE_TERMS) is None
|
||||||
|
assert resolve_thickness_band_id(1.0, []) is None
|
||||||
|
# A term with no parseable name/slug is skipped, never guessed at.
|
||||||
|
assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_is_id_order_independent():
|
||||||
|
"""The result must not depend on the sync order of the terms."""
|
||||||
|
assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
|
||||||
|
|
||||||
|
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
|
||||||
|
synced band term. We map thickness_in into the first half-open band [low, high)
|
||||||
|
that contains it, derived only from the live synced term names/slugs — never a
|
||||||
|
hardcoded Woo term id.
|
||||||
|
|
||||||
|
Round-up semantics: bands are half-open on the upper bound, so a value that sits
|
||||||
|
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
|
||||||
|
value (1"–1 1/2"), not the band that ends there (3/4"–1").
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
# Mixed number / fraction / decimal inch token, optional trailing quote.
|
||||||
|
_INCH_RE = re.compile(
|
||||||
|
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
|
||||||
|
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
|
||||||
|
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
|
||||||
|
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_inch_token(text: str) -> Optional[float]:
|
||||||
|
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
|
||||||
|
t = (text or "").strip().rstrip("\"'").strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
if m.group("a") is not None:
|
||||||
|
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
|
||||||
|
if m.group("n2") is not None:
|
||||||
|
return float(m.group("n2")) / float(m.group("d2"))
|
||||||
|
if m.group("dec") is not None:
|
||||||
|
return float(m.group("dec"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
|
||||||
|
"""Return half-open [low, high) inches from a Woo thickness term name."""
|
||||||
|
raw = (name or "").strip()
|
||||||
|
low_s = raw.lower()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Under X / less than X / < X
|
||||||
|
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
hi = parse_inch_token(m.group(1))
|
||||||
|
if hi is not None:
|
||||||
|
return (0.0, hi)
|
||||||
|
|
||||||
|
# Over X / above X / > X / X+
|
||||||
|
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
lo = parse_inch_token(m.group(1))
|
||||||
|
if lo is not None:
|
||||||
|
return (lo, float("inf"))
|
||||||
|
m = re.match(r"^(.+?)\+\s*$", raw)
|
||||||
|
if m:
|
||||||
|
lo = parse_inch_token(m.group(1))
|
||||||
|
if lo is not None:
|
||||||
|
return (lo, float("inf"))
|
||||||
|
|
||||||
|
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
|
||||||
|
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
lo = parse_inch_token(parts[0])
|
||||||
|
hi = parse_inch_token(parts[1])
|
||||||
|
if lo is not None and hi is not None and hi > lo:
|
||||||
|
return (lo, hi)
|
||||||
|
|
||||||
|
# Single value band treated as [v, v+epsilon) — rare
|
||||||
|
only = parse_inch_token(raw)
|
||||||
|
if only is not None:
|
||||||
|
return (only, only + 1e-9)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
|
||||||
|
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
|
||||||
|
s = (slug or "").strip().lower()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s in {"xthin", "under", "thin"}:
|
||||||
|
return (0.0, 0.5)
|
||||||
|
m = _QTR_RANGE_RE.match(s)
|
||||||
|
if m:
|
||||||
|
lo = int(m.group(1)) * 0.25
|
||||||
|
hi = int(m.group(2)) * 0.25
|
||||||
|
if hi > lo:
|
||||||
|
return (lo, hi)
|
||||||
|
m = _QTR_MIN_RE.match(s)
|
||||||
|
if m:
|
||||||
|
lo = int(m.group(1)) * 0.25
|
||||||
|
return (lo, float("inf"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
|
||||||
|
"""Bounds for one synced thickness term; name first, slug fallback."""
|
||||||
|
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
|
||||||
|
str(term.get("slug") or "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_thickness_band_id(
|
||||||
|
thickness_in: float | int | None,
|
||||||
|
terms: Iterable[dict[str, Any]],
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Pick the synced thickness term id for thickness_in, or None if unknown.
|
||||||
|
|
||||||
|
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
|
||||||
|
"""
|
||||||
|
if thickness_in is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
t = float(thickness_in)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if t < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scored: list[tuple[float, float, int]] = [] # (low, high, id)
|
||||||
|
for term in terms:
|
||||||
|
b = term_bounds(term)
|
||||||
|
if b is None:
|
||||||
|
continue
|
||||||
|
tid = term.get("id")
|
||||||
|
if tid is None:
|
||||||
|
continue
|
||||||
|
lo, hi = b
|
||||||
|
scored.append((lo, hi, int(tid)))
|
||||||
|
|
||||||
|
if not scored:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prefer the tightest band that contains t under half-open [low, high).
|
||||||
|
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
|
||||||
|
if matches:
|
||||||
|
matches.sort() # tightest span first
|
||||||
|
return matches[0][3]
|
||||||
|
|
||||||
|
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
|
||||||
|
scored.sort(key=lambda x: x[0])
|
||||||
|
for lo, hi, tid in scored:
|
||||||
|
if t < hi:
|
||||||
|
return tid
|
||||||
|
return scored[-1][2]
|
||||||
|
|
||||||
|
|
||||||
|
def load_thickness_terms(conn) -> list[dict[str, Any]]:
|
||||||
|
"""Live Thickness attribute terms from the synced Woo cache."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
|
||||||
|
("thickness", "thickness"),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return []
|
||||||
|
attr_id = row[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
|
||||||
|
(attr_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]
|
||||||
+38
-13
@@ -98,14 +98,7 @@ class WooClient:
|
|||||||
|
|
||||||
def _get(self, path: str) -> tuple[int, dict, bytes]:
|
def _get(self, path: str) -> tuple[int, dict, bytes]:
|
||||||
url = f"{self.base_url}/wp-json/wc/v3{path}"
|
url = f"{self.base_url}/wp-json/wc/v3{path}"
|
||||||
return self._request(
|
return self._request(url, "GET", None, self._auth_headers())
|
||||||
url, "GET", None,
|
|
||||||
{
|
|
||||||
"Authorization": f"Basic {self._auth}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"User-Agent": _USER_AGENT,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
|
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
|
||||||
page = 1
|
page = 1
|
||||||
@@ -132,6 +125,42 @@ class WooClient:
|
|||||||
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
|
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
|
||||||
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
|
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
|
||||||
|
|
||||||
|
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||||
|
"""Shared headers for every outbound Woo/WP request (User-Agent + auth)."""
|
||||||
|
h = {
|
||||||
|
"Authorization": f"Basic {self._auth}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": _USER_AGENT,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
h.update(extra)
|
||||||
|
return h
|
||||||
|
|
||||||
|
def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]:
|
||||||
|
"""GET products filtered by SKU. Raises typed Woo* errors on non-200."""
|
||||||
|
code, _h, body = self._request(
|
||||||
|
f"{self.base_url}/wp-json/wc/v3/products?sku={sku}",
|
||||||
|
"GET",
|
||||||
|
None,
|
||||||
|
self._auth_headers(),
|
||||||
|
)
|
||||||
|
if code != 200:
|
||||||
|
raise WooServerError(f"sku lookup returned {code}")
|
||||||
|
return list(json.loads(body))
|
||||||
|
|
||||||
|
def create_product(self, payload: dict[str, Any]) -> int:
|
||||||
|
"""POST /products. Raises typed Woo* errors on failure; returns product id."""
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
code, _h, resp = self._request(
|
||||||
|
f"{self.base_url}/wp-json/wc/v3/products",
|
||||||
|
"POST",
|
||||||
|
body,
|
||||||
|
self._auth_headers({"Content-Type": "application/json"}),
|
||||||
|
)
|
||||||
|
if code not in (200, 201):
|
||||||
|
raise WooServerError(f"product create returned {code}")
|
||||||
|
return int(json.loads(resp)["id"])
|
||||||
|
|
||||||
def upload_media(self, png_bytes: bytes, filename: str) -> int:
|
def upload_media(self, png_bytes: bytes, filename: str) -> int:
|
||||||
"""POST a PNG to WordPress core media; return the attachment id."""
|
"""POST a PNG to WordPress core media; return the attachment id."""
|
||||||
boundary = "----slabuploader-media-boundary"
|
boundary = "----slabuploader-media-boundary"
|
||||||
@@ -143,11 +172,7 @@ class WooClient:
|
|||||||
url = f"{self.base_url}/wp-json/wp/v2/media"
|
url = f"{self.base_url}/wp-json/wp/v2/media"
|
||||||
code, _headers, resp = self._request(
|
code, _headers, resp = self._request(
|
||||||
url, "POST", body,
|
url, "POST", body,
|
||||||
{
|
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
|
||||||
"Authorization": f"Basic {self._auth}",
|
|
||||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
||||||
"User-Agent": _USER_AGENT,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if code not in (200, 201):
|
if code not in (200, 201):
|
||||||
raise WooServerError(f"media upload returned {code}")
|
raise WooServerError(f"media upload returned {code}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Port block `10880`–`10889` on ubudual5060 is allocated to this project.
|
Port block `10880`–`10889` on ubudual5060 is allocated to this project.
|
||||||
`:10880` is Ty's local Node/SvelteKit and must never be bound by project deploys.
|
`:10880` is Ty's local Node/SvelteKit and must never be bound by project deploys.
|
||||||
The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can reach it.
|
The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can reach it.
|
||||||
|
Check to see if you are running on ubudual5060 (192.168.1.202) before attempting SSH.
|
||||||
|
|
||||||
## Dev-test environment (M2 current, M3 incoming)
|
## Dev-test environment (M2 current, M3 incoming)
|
||||||
|
|
||||||
@@ -23,3 +24,96 @@ The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can
|
|||||||
Future deploys use a **separate** container name and a free port in `10880`–`10889`
|
Future deploys use a **separate** container name and a free port in `10880`–`10889`
|
||||||
(other than `10880`), bound `0.0.0.0`.
|
(other than `10880`), bound `0.0.0.0`.
|
||||||
Do not put a host name in the product spec.
|
Do not put a host name in the product spec.
|
||||||
|
|
||||||
|
## Deploy records
|
||||||
|
|
||||||
|
### 2026-09-21 02:41 PDT — #90 wood primary category + derived thickness band
|
||||||
|
|
||||||
|
- Source ref: `0329052` on `issue-89-metadata-lock` (pushed to Gitea; pulled into `~/slab-deploy`).
|
||||||
|
- Images (built on `ubudual5060`): `deploy-frontend:latest` `1c169c29f131`,
|
||||||
|
`deploy-fastapi:latest` `32671659639b`.
|
||||||
|
- Compose: `deploy/docker-compose.yml` + host-local (untracked) `deploy/override-m3.yml`.
|
||||||
|
- URL: `http://192.168.1.202:10881` (LAN) · `http://100.64.0.9:10881` (Tailscale).
|
||||||
|
- `matches HEAD`: **yes**.
|
||||||
|
- Sample data preserved: **yes** — 24 slabs before and after `up -d` (no `-v`, no volume removal).
|
||||||
|
- Verified on the live stack: served asset `assets/index-tlFib3RM.js` (sha256 `3b665746ec81f28e…`)
|
||||||
|
contains the new markers and no `#thickness` select; `POST /api/v1/slabs` at `thickness_in 1.0`
|
||||||
|
stored band `350` (`1"- 1 1/2"`); `PUT` at `2.75` re-banded to `352`; a client-sent stale band id
|
||||||
|
was ignored; `POST /review` no longer lists thickness as missing; the Review screen in a real
|
||||||
|
browser (LAN URL) shows the wood-category MultiSelect closed summary with names + count, the
|
||||||
|
`Primary wood category` radio set switching on click, and the band as read-only text.
|
||||||
|
- Not exercised: the Woo publish path. Rows created **before** this deploy keep `thickness_term_id`
|
||||||
|
NULL until their next save (the band is derived on write, so any Review commit fills it in).
|
||||||
|
|
||||||
|
### 2026-09-21 03:12 PDT — follow-up: thickness band omitted entirely from Review
|
||||||
|
|
||||||
|
- Commit: `204bc5e` (fix #90: band removed from UI, not merely read-only; publish test added).
|
||||||
|
- Rebuilt `deploy-frontend:latest` and recreated the container on 10881.
|
||||||
|
- Live verification: served HTML and bundle contain zero occurrences of "Thickness band" or "set from the measurement".
|
||||||
|
- Sample data unchanged (still 25 slabs post-recreate).
|
||||||
|
- All gates (unit, build, requirements, coverage, m3-create e2e) pass.
|
||||||
|
|
||||||
|
### 2026-09-21 11:52 PDT — merge `issue-89-metadata-lock` → main (Create-string model)
|
||||||
|
|
||||||
|
- Source: branch `work/merge-89-90` (base `origin/issue-89-metadata-lock` + Create-string re-applied).
|
||||||
|
`matches HEAD`: **no** — staged from the uncommitted working tree (local sync; Gitea push not
|
||||||
|
attempted this round). 9 files changed: Review.svelte, slabs.ts, review.py, slabs.py, schema.sql,
|
||||||
|
db/__init__.py, test_review.py, m3-create.spec.ts, review-photo-strip.spec.ts.
|
||||||
|
- Model change: `metadata_locked` DB column + "Save and lock" UI **dropped end-to-end**. The
|
||||||
|
deterministic title/short_description are now built by the review endpoint on every call; the
|
||||||
|
owner's **Create title / Create short desc** buttons fire the build and write the result
|
||||||
|
(overwrite contract). `commitReview` auto-fills only empty fields (no owner-clobber); `createTitle`
|
||||||
|
is the explicit force-overwrite. Photo strip, wood primary/secondary, and server-side thickness
|
||||||
|
band (item: not shown in UI, passed to Woo on publish) all taken from the branch.
|
||||||
|
- LAN binds: `deploy/override-m3.yml` was absent from disk; the live stack's 10881/10882 LAN binds
|
||||||
|
were re-created by editing `deploy/docker-compose.yml` ports in-place (loopback 18082/18083 from
|
||||||
|
`.env` + `0.0.0.0:10881`/`0.0.0.0:10882`), then `down --remove-orphans` + `up -d` (no `-v`).
|
||||||
|
- Bundle: `assets/index-YPAqx609.js` (new; contains `create-title`). API `/api/health`
|
||||||
|
`woo_reachable:true`.
|
||||||
|
- Sample data preserved: **yes** — 34 slabs before/after.
|
||||||
|
- Live verification (LAN URL, real DB): `slabs` table has **no** `metadata_locked` column;
|
||||||
|
`slab_photos.role` present. `POST /review` on slab `ec4088da…` → `title:'Curl Black Walnut Slab'`,
|
||||||
|
`ready:True`. `PUT` title persisted (rev 1→2) then restored (original sample value intact, rev 3).
|
||||||
|
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
|
||||||
|
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
|
||||||
|
not exercised this round.
|
||||||
|
|
||||||
|
### 2026-09-21 12:10 PDT — redeploy: `main` after merge `8ee3da9` (reconciled PR #97 + branch)
|
||||||
|
|
||||||
|
- Source: `main` @ `8ee3da9` (merge of `work/merge-89-90` into main, which itself reconciled
|
||||||
|
main's PR #97 client-side `thicknessBand.ts` + `syncSelects`/`box-sizing` against the branch's
|
||||||
|
server-side `thickness_band.py` + Create-string #89). `matches HEAD`: **yes**.
|
||||||
|
- Resolution outcome in the deployed bundle: band is **server-side** — `Review.svelte` no longer
|
||||||
|
imports `thicknessBand` and the read-only band display is gone; PR #97's `syncSelects()` (3× in
|
||||||
|
source) + `box-sizing` CSS preserved; `metadata_locked` still dropped; Create-string + photo
|
||||||
|
strip + wood primary live.
|
||||||
|
- Rebuilt both images from `main`; recreated the stack (`down --remove-orphans` + `up -d`, no `-v`).
|
||||||
|
LAN binds 10881/10882 intact.
|
||||||
|
- Bundle: `assets/index-BScrgd8o.js` (new; differs from pre-merge `YPAqx609`; contains
|
||||||
|
`create-title`, **zero** `thicknessBand` references). API `/api/health` `woo_reachable:true`.
|
||||||
|
- Sample data preserved: **yes** — 35 slabs (one added upstream since last deploy).
|
||||||
|
- Live verification (LAN URL, real DB): `POST /review` on slab `ec4088da…` →
|
||||||
|
`title:'Curl Black Walnut Slab'`, `ready:True`.
|
||||||
|
- Gates at merge time: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip).
|
||||||
|
- Branches `work/merge-89-90` and `issue-89-metadata-lock` deleted (local + remote); remote has
|
||||||
|
only `main`.
|
||||||
|
|
||||||
|
### 2026-09-21 12:34 PDT — Review mount: restore dropped lineup/woodPrimary imports
|
||||||
|
|
||||||
|
- Source: uncommitted working tree on `main` (parent `67aa3d6`). `matches HEAD`: **no**.
|
||||||
|
Diff: `frontend/src/create/review/Review.svelte` +5 lines restoring imports of
|
||||||
|
`keptInOrder`/`moveItem`/`missingMinimums`/`roleLabel`/`resolvePhotoUrl`/`LineupItem`
|
||||||
|
from `lineup.ts` and `nextPrimary` from `woodPrimary.ts`. The morning merge kept the
|
||||||
|
call sites and dropped the imports → `ReferenceError` on Review mount → "Could not load the slab."
|
||||||
|
- Rebuilt `deploy-frontend` only (`compose build frontend` + `up -d frontend`, no `-v`).
|
||||||
|
Image `56b35f6cdcdf`. FastAPI container left running.
|
||||||
|
- URL: http://192.168.1.202:10881 (LAN) · loopback 127.0.0.1:18082
|
||||||
|
- Bundle: `assets/index-BVCyM130.js` (replaced `index-ck41xD02.js`). Served JS has **0**
|
||||||
|
`nextPrimary`/`keptInOrder` (minified locals) vs 3 unresolved `nextPrimary` in the broken bundle.
|
||||||
|
- Sample data preserved: **yes** — 47 slabs before rebuild; 48 after live Capture→Review probe
|
||||||
|
(created `ef1f4101-b1d2-459d-b9a0-842e7650ea3c`, sku NULL, status calibrated, 48×2 in; not published).
|
||||||
|
- Live verification (loopback, real API): Continue to Review → `review-screen` visible,
|
||||||
|
heading "Review to Ready", no loadError, no pageerror. POST /slabs 201, photos 201×2,
|
||||||
|
GET slab/taxonomy/settings 200, review 200.
|
||||||
|
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
|
||||||
|
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ services:
|
|||||||
build:
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
|
||||||
|
# multipart to /api/v1/slabs/{id}/photos. nginx's default client_max_body_size
|
||||||
|
# is 1MB, which 413s on real multi-MB photos. Allow large photo bodies.
|
||||||
|
client_max_body_size 25m;
|
||||||
|
|
||||||
# Docker embedded DNS for service name resolution (fastapi)
|
# Docker embedded DNS for service name resolution (fastapi)
|
||||||
resolver 127.0.0.11 valid=10s;
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
|||||||
| METADATA-20 | Missing attributes or terms are skipped, never created. | M3 | — |
|
| METADATA-20 | Missing attributes or terms are skipped, never created. | M3 | — |
|
||||||
| METADATA-25 | Species, character, length, and free-text thickness are not product attributes. | M3 | — |
|
| METADATA-25 | Species, character, length, and free-text thickness are not product attributes. | M3 | — |
|
||||||
| METADATA-30 | Product tags come from the synced Woo set and are all optional; no hardcoded prefixes, and the bare fig tag is kept. | M3 | — |
|
| METADATA-30 | Product tags come from the synced Woo set and are all optional; no hardcoded prefixes, and the bare fig tag is kept. | M3 | — |
|
||||||
| METADATA-100 | Call 1 free-text observations (character, inclusions, voids, checks) are mapped to existing synced feat-* tags only; WooCommerce is the authority for feat-* definitions. | M3 | — |
|
| METADATA-100 | Call 1 returns feature suggestions as feature_tag_ids chosen only from the synced feat-* tag set (display names without the feat- prefix); WooCommerce is the authority for those tags. No free-text observation bags. | M3 | backend/tests/test_inference.py |
|
||||||
| METADATA-35 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | — |
|
| METADATA-35 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | backend/tests/test_inference.py |
|
||||||
| METADATA-40 | Call 1 returns species, wood categories, edge, figure, grade, and feature-tag suggestions constrained to the synced lists, each with confidence. | M3 | — |
|
| METADATA-40 | Call 1 returns species, wood categories, edge, figure (multi), and feature-tag id suggestions constrained to the synced LISTS, each with confidence. Grade is not suggested by Call 1 (owner judgment). Wood category is decided before edge; cookies/burl prefer Wild edge when present. | M3 | backend/tests/test_inference.py |
|
||||||
| METADATA-45 | Call 1 confidence at or above the threshold (default 0.7) pre-fills review fields; below it the field stays empty for the owner to fill by hand or re-run Call 1; suggestions are always editable. | M3 | — |
|
| METADATA-45 | Call 1 confidence at or above the threshold (default 0.7) pre-fills review fields; below it the field stays empty for the owner to fill by hand or re-run Call 1; suggestions are always editable. | M3 | — |
|
||||||
| METADATA-50 | Typed values always win over inferred values. | M3 | — |
|
| METADATA-50 | Typed values always win over inferred values. | M3 | — |
|
||||||
| METADATA-85 | The primary wood category selects the Call 2 prompt and the title and short-description patterns. | M3 | — |
|
| METADATA-85 | The primary wood category selects the Call 2 prompt and the title and short-description patterns. | M3 | — |
|
||||||
@@ -75,7 +75,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
|||||||
| METADATA-65 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — |
|
| METADATA-65 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — |
|
||||||
| METADATA-95 | The dimension-overlay image is never sent to the model. | M3 | — |
|
| METADATA-95 | The dimension-overlay image is never sent to the model. | M3 | — |
|
||||||
| METADATA-70 | With inference off, the server produces the long description. | M3 | — |
|
| METADATA-70 | With inference off, the server produces the long description. | M3 | — |
|
||||||
| METADATA-66 | Every content generation, from a deterministic pattern or from the model, is logged with its inputs, the model request and response when a model was used, and the final stored content. | M3 | — |
|
| METADATA-66 | Every content generation, from a deterministic pattern or from the model, is logged with its inputs, the model request and response when a model was used, and the final stored content. The logged request keeps full text and photo refs only — never base64 image payloads. | M3 | backend/tests/test_inference.py |
|
||||||
| METADATA-75 | Inference never blocks create and never auto-publishes. | M3 | — |
|
| METADATA-75 | Inference never blocks create and never auto-publishes. | M3 | — |
|
||||||
| METADATA-105 | Inference prompts are stored in the database with all revisions; each call uses the active revision, the revision history is retained, and the revision used is recorded with the call. | M3 | — |
|
| METADATA-105 | Inference prompts are stored in the database with all revisions; each call uses the active revision, the revision history is retained, and the revision used is recorded with the call. | M3 | — |
|
||||||
| METADATA-106 | The prompt revision interface is a REST API: append a revision, activate a revision, and read the active prompt with its revision list. There is no prompt editing UI. | M3 | — |
|
| METADATA-106 | The prompt revision interface is a REST API: append a revision, activate a revision, and read the active prompt with its revision list. There is no prompt editing UI. | M3 | — |
|
||||||
@@ -199,7 +199,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
|||||||
| QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
|
| QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
|
||||||
| QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
|
| QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
|
||||||
| QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — |
|
| QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — |
|
||||||
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | — |
|
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | backend/tests/test_thickness_band.py |
|
||||||
| QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
|
| QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
|
||||||
| QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
|
| QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
|
||||||
| QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
|
| QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ hard stop. Switch to a supported browser.
|
|||||||
3. **Wood categories.** Pick at least one from the synced list.
|
3. **Wood categories.** Pick at least one from the synced list.
|
||||||
4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
|
4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
|
||||||
exactly one grade.
|
exactly one grade.
|
||||||
5. **Thickness band.** Pick the Woo band that the measured thickness rounds up into.
|
5. **Thickness band.** Not on the screen and not picked: the server bands the measured thickness
|
||||||
|
up into the synced Woo band, stores it, and publish sends it to the store. Verify after publish
|
||||||
|
that the Woo product's Thickness attribute is the band the captured thickness rounds up into
|
||||||
|
(`QUALITY-95`).
|
||||||
6. **Moisture.** Keep the default (kiln-dried) or change it.
|
6. **Moisture.** Keep the default (kiln-dried) or change it.
|
||||||
7. **fig-* tags.** Optional; pick any from the synced list.
|
7. **fig-* tags.** Optional; pick any from the synced list.
|
||||||
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
|
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
|
||||||
|
|||||||
@@ -144,9 +144,9 @@ block, and brand/geo context; no provider thread id and no raw Call 1 result. Re
|
|||||||
and never touches dimensions. Cites METADATA-55, METADATA-65.
|
and never touches dimensions. Cites METADATA-55, METADATA-65.
|
||||||
|
|
||||||
Call 1 result fields: `species_id`, `species_confidence`, `wood_category_ids`,
|
Call 1 result fields: `species_id`, `species_confidence`, `wood_category_ids`,
|
||||||
`edge_type_term_id`, `figure_term_ids`, `figure_confidence`, `grade_term_id`, `grade_confidence`,
|
`wood_category_confidence`, `edge_type_term_id`, `edge_confidence`, `figure_term_ids`,
|
||||||
`character`, `inclusions`, `voids`, `checks`. Free-text observations map to synced `feat-*` tags
|
`figure_confidence`, `feature_tag_ids`, `feature_tag_confidence`. Feature tags are ids from the
|
||||||
only.
|
synced `feat-*` set only (no free-text bags). Grade is never suggested.
|
||||||
|
|
||||||
Call 2 result field: `description`. The name and short description are assembled by the server from
|
Call 2 result field: `description`. The name and short description are assembled by the server from
|
||||||
the primary wood category's patterns, not returned by the call.
|
the primary wood category's patterns, not returned by the call.
|
||||||
|
|||||||
@@ -160,6 +160,14 @@ Append-only; never deleted on publish. Columns: `id`, `slab_id`, `woo_product_id
|
|||||||
Append-only audit. Columns: `id`, `slab_id`, `call` (1 | 2), `prompt_revision_id` FK, `model`,
|
Append-only audit. Columns: `id`, `slab_id`, `call` (1 | 2), `prompt_revision_id` FK, `model`,
|
||||||
`request_payload`, `response_payload`, `latency_ms`, `created_at`.
|
`request_payload`, `response_payload`, `latency_ms`, `created_at`.
|
||||||
|
|
||||||
|
`request_payload` is auditable JSON of the model request **without media bytes**
|
||||||
|
(`METADATA-66`). Text parts (active prompt, LISTS / fact block, SKU/dims) are stored in full.
|
||||||
|
Each vision attachment is an `image_ref` (`photo_id`, `seq`, `bytes`, optional
|
||||||
|
`infer_long_edge_px`, `label`) pointing at `slab_photos` — never a
|
||||||
|
`data:image/...;base64,...` URL. The wire request to the inference API still includes images;
|
||||||
|
only the log is redacted. A byte cap remains as a safety net after redaction; normal Call 1/2
|
||||||
|
rows are far under it.
|
||||||
|
|
||||||
## 10. Status transitions
|
## 10. Status transitions
|
||||||
|
|
||||||
Cites APP-5.
|
Cites APP-5.
|
||||||
|
|||||||
+54
-15
@@ -46,20 +46,23 @@ true; also on demand or retry. With inference off it is skipped and the fields a
|
|||||||
|
|
||||||
**Inputs.**
|
**Inputs.**
|
||||||
- The clean processed photos at 1024 px long edge or less.
|
- The clean processed photos at 1024 px long edge or less.
|
||||||
- The synced taxonomy: species leaves, Edge Type, Figure, Grade, Thickness, Moisture terms, each
|
- Closed LISTS from the synced Woo cache only: `species`, `wood_categories`, `edge_terms`,
|
||||||
with its Woo id. These are the only legal choices.
|
`figure_terms`, `feature_tags` (each with id + display name). Grade, thickness, and moisture are
|
||||||
|
not sent to Call 1.
|
||||||
- User metadata: SKU, length, thickness.
|
- User metadata: SKU, length, thickness.
|
||||||
|
|
||||||
**Constraints.**
|
**Constraints.**
|
||||||
- The model may only choose values from the supplied synced lists; it never invents categories or
|
- The model may only choose values from the supplied LISTS; it never invents categories or terms.
|
||||||
terms.
|
- Feature tags are returned as `feature_tag_ids` from the supplied list only. Display names omit a
|
||||||
- Free-text observations (character, inclusions, voids, checks) are mapped to existing synced
|
leading `feat-` prefix; Woo remains authoritative for the tag set. No free-text observation bags.
|
||||||
`feat-*` tags only; Woo is authoritative for those tags.
|
- Grade is never suggested (owner judgment).
|
||||||
|
- Decide wood categories before edge type. Cookies and burl cuts prefer Wild edge when that term
|
||||||
|
exists in the list.
|
||||||
|
|
||||||
**Result fields.** `species_id` + `species_confidence`; `wood_category_ids`;
|
**Result fields.** `species_id` + `species_confidence`; `wood_category_ids` +
|
||||||
`edge_type_term_id`; `figure_term_ids` + `figure_confidence`; `grade_term_id` +
|
`wood_category_confidence`; `edge_type_term_id` + `edge_confidence`; `figure_term_ids` +
|
||||||
`grade_confidence`; `character`, `inclusions`, `voids`, `checks`. Grade, edge, thickness, and
|
`figure_confidence` (multi allowed); `feature_tag_ids` + `feature_tag_confidence`. Edge is singular;
|
||||||
moisture are singular.
|
figure and feature tags are multi.
|
||||||
|
|
||||||
**Pre-fill.** At or above the threshold (default 0.7) the field is pre-filled and remains
|
**Pre-fill.** At or above the threshold (default 0.7) the field is pre-filled and remains
|
||||||
editable; below it the field stays empty and is not shown as a low-confidence guess. Typed values
|
editable; below it the field stays empty and is not shown as a low-confidence guess. Typed values
|
||||||
@@ -110,11 +113,47 @@ application.
|
|||||||
The seed is the starting point for tuning, not a final text. Both calls are revised ad hoc through
|
The seed is the starting point for tuning, not a final text. Both calls are revised ad hoc through
|
||||||
the §2 interface when UAT surfaces a miss.
|
the §2 interface when UAT surfaces a miss.
|
||||||
|
|
||||||
**`call1` seed.** Classify the slab's species, wood categories, edge type, figure, and grade from
|
**`call1` seed.** Shipped text in `backend/inference.py` (`CALL1_SEED`), mirrored here for review.
|
||||||
the supplied photos, choosing only from the supplied synced taxonomy lists, and return the result
|
|
||||||
fields in §3 as JSON. Report a confidence for each classified field. Map free-text observations
|
You are a wood identification expert for retail lumber slabs. Judge only from the photos and the
|
||||||
(character, inclusions, voids, checks) to the supplied feature-tag list only. Invent no category or
|
LISTS below. Use list ids only — never invent a name or id.
|
||||||
term.
|
|
||||||
|
Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, (5) feature tags.
|
||||||
|
Decide wood categories before edge type — edge rules depend on product form.
|
||||||
|
|
||||||
|
For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). Prefer
|
||||||
|
null or [] over a weak guess.
|
||||||
|
|
||||||
|
Species — which tree species is this slab. Match grain, color, and figure to the species list. If
|
||||||
|
more than one entry is plausible, return only the single highest-confidence match (one id). Null
|
||||||
|
only when none are credible.
|
||||||
|
|
||||||
|
Wood categories — product form / how it will be sold (live edge, dimensional, cookies & burl
|
||||||
|
slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; often one,
|
||||||
|
sometimes more. [] if none fit. Finish this field before edge type.
|
||||||
|
|
||||||
|
Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.
|
||||||
|
Meanings (match the closest list name):
|
||||||
|
• Square / square edge — both long edges milled straight (2 milled long edges).
|
||||||
|
• Live one side / Live Edge 1 side — one long edge milled, the other natural bark or waney
|
||||||
|
(1 milled long edge).
|
||||||
|
• Live both / Live edge 2 sides — neither long edge milled; both natural (0 milled long edges).
|
||||||
|
• Wild edge — use when the three mill-count cases do not fit, including cookies and burl
|
||||||
|
cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.
|
||||||
|
|
||||||
|
Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids from
|
||||||
|
figure_terms that clearly apply.
|
||||||
|
|
||||||
|
Feature tags — defects and character marks the store tags for shoppers (knots, checks,
|
||||||
|
inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].
|
||||||
|
|
||||||
|
Return one JSON object with exactly these keys:
|
||||||
|
{"species_id": <id|null>, "species_confidence": <0-1>, "wood_category_ids": [<id>,...],
|
||||||
|
"wood_category_confidence": <0-1>, "edge_type_term_id": <id|null>, "edge_confidence": <0-1>,
|
||||||
|
"figure_term_ids": [<id>,...], "figure_confidence": <0-1>, "feature_tag_ids": [<id>,...],
|
||||||
|
"feature_tag_confidence": <0-1>}
|
||||||
|
|
||||||
|
Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickness and the photos.
|
||||||
|
|
||||||
**`call2` seed.** Write the long product description for the slab from the supplied photos and
|
**`call2` seed.** Write the long product description for the slab from the supplied photos and
|
||||||
listing facts. The name and short description are already built, so do not repeat them. Use the
|
listing facts. The name and short description are already built, so do not repeat them. Use the
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a
|
|||||||
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
|
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
|
||||||
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
|
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
|
||||||
| CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
|
| CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
|
||||||
| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
|
| METADATA-10, METADATA-15, METADATA-30, QUALITY-95 | required taxonomy fields and cardinality; the thickness band is derived from the measurement, not picked |
|
||||||
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
|
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
|
||||||
| PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
|
| PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
|
||||||
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
|
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
|
||||||
@@ -55,10 +55,16 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
|
|||||||
with an "AI" badge; below it the field starts empty. The badge is presentation only and clears
|
with an "AI" badge; below it the field starts empty. The badge is presentation only and clears
|
||||||
on edit.
|
on edit.
|
||||||
- **Required set.** Exactly one species and at least one wood-category leaf; one Edge Type, at
|
- **Required set.** Exactly one species and at least one wood-category leaf; one Edge Type, at
|
||||||
least one Figure, exactly one Grade, one Thickness band, one Moisture; fig-* and feat-* tags
|
least one Figure, exactly one Grade, one Moisture; fig-* and feat-* tags optional; price;
|
||||||
optional; price; title, short_description, and description.
|
title, short_description, and description. The **Thickness band is not an owner control** — it
|
||||||
|
is derived deterministically from `thickness_in` (the value rounds up into the synced Woo band,
|
||||||
|
`TV-9` / `QUALITY-95`), displayed read-only, and satisfies the readiness requirement
|
||||||
|
(`REVIEW-5`) via that derived value.
|
||||||
- **Primary category.** The owner declares one primary wood category and any secondary ones; the
|
- **Primary category.** The owner declares one primary wood category and any secondary ones; the
|
||||||
default is the first selected.
|
default is the first selected.
|
||||||
|
- **Thickness band is derived and not shown.** The measured thickness bands up into the synced Woo
|
||||||
|
band server-side (`METADATA-15`, `QUALITY-95`); Review carries no thickness-band control and no
|
||||||
|
read-only echo of it. The derived band is stored on the slab and sent to the store at publish.
|
||||||
- **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is
|
- **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is
|
||||||
no single submit-time gap list.
|
no single submit-time gap list.
|
||||||
- **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable.
|
- **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "node --import tsx --test src/**/*.test.ts",
|
"test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:headed": "playwright test --headed"
|
"test:e2e:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
fullyParallel: false,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
workers: 1,
|
workers: 2,
|
||||||
timeout: 180_000,
|
timeout: 30_000,
|
||||||
expect: { timeout: 30_000 },
|
expect: { timeout: 4_000 },
|
||||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:4173',
|
baseURL: 'http://127.0.0.1:4173',
|
||||||
|
|||||||
+211
-78
@@ -1,20 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { supported } from './browser';
|
import { supported, uuid } from './browser';
|
||||||
import { createCaptureSession, type CaptureSession } from './capture/session';
|
import { createCaptureSession, type CaptureSession } from './capture/session';
|
||||||
import type { Capture } from './capture/capture.types';
|
import type { Capture, Measures } from './capture/capture.types';
|
||||||
import Resume from './capture/Resume.svelte';
|
import { eighthsToIn } from './capture/capture.types';
|
||||||
import BatchPhotos from './capture/BatchPhotos.svelte';
|
import { buildPhotosReadyLineup } from './capture/photosReadyLineup';
|
||||||
import PickCalibration from './capture/PickCalibration.svelte';
|
import Resume from './capture/Resume.svelte';
|
||||||
import MaskPanel from './capture/MaskPanel.svelte';
|
import BatchPhotos from './capture/BatchPhotos.svelte';
|
||||||
import AxisPanel from './capture/AxisPanel.svelte';
|
import PickCalibration from './capture/PickCalibration.svelte';
|
||||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
import MaskPanel from './capture/MaskPanel.svelte';
|
||||||
import Settings from './create/settings/Settings.svelte';
|
import AxisPanel from './capture/AxisPanel.svelte';
|
||||||
import SlabList from './create/slabs/SlabList.svelte';
|
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||||
import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
import Settings from './create/settings/Settings.svelte';
|
||||||
import Review from './create/review/Review.svelte';
|
import SlabList from './create/slabs/SlabList.svelte';
|
||||||
import Publish from './create/publish/Publish.svelte';
|
import Review from './create/review/Review.svelte';
|
||||||
import GenerateText from './create/inference/GenerateText.svelte';
|
import Publish from './create/publish/Publish.svelte';
|
||||||
|
import {
|
||||||
|
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
||||||
|
} from './create/flow';
|
||||||
|
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
|
||||||
|
import { clearDraft } from './draft/idb';
|
||||||
|
|
||||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||||
const session: CaptureSession = createCaptureSession();
|
const session: CaptureSession = createCaptureSession();
|
||||||
@@ -24,27 +29,110 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
let pngUrl: string | null = null;
|
let pngUrl: string | null = null;
|
||||||
let showSettings = false;
|
let showSettings = false;
|
||||||
let showSlabs = false;
|
let showSlabs = false;
|
||||||
let showPhotos = false;
|
|
||||||
let showReview = false;
|
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
||||||
let showPublish = false;
|
$: curFlow = $flow;
|
||||||
let showGenerate = false;
|
let saving = false;
|
||||||
|
let saveError = '';
|
||||||
|
|
||||||
const unsubCap = session.capture.subscribe((c) => {
|
const unsubCap = session.capture.subscribe((c) => {
|
||||||
cap = c;
|
cap = c;
|
||||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
||||||
});
|
});
|
||||||
const unsubBusy = session.busy.subscribe((b) => {
|
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||||
busy = b;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void session.resumeProbe();
|
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
|
||||||
|
// device draft into IndexedDB after the origin exists, then reload without
|
||||||
|
// ?noload to trigger the Resume prompt deterministically.
|
||||||
|
const boot = new URLSearchParams(window.location.search);
|
||||||
|
if (!boot.get('noload')) void session.resumeProbe();
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubCap();
|
unsubCap();
|
||||||
unsubBusy();
|
unsubBusy();
|
||||||
session.dispose();
|
session.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// APP-15: the device draft is cleared only on publish success / "new slab".
|
||||||
|
function startNewSlab(): void {
|
||||||
|
void clearDraft().then(() => {
|
||||||
|
session.startFresh();
|
||||||
|
backToCapture();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// C2: Photos-ready → build the calibrated slab, upload the lineup PNGs, then
|
||||||
|
// hand off to Review. The device draft is retained here (APP-10); it is
|
||||||
|
// cleared only on publish success (APP-15).
|
||||||
|
async function continueToReview(): Promise<void> {
|
||||||
|
const m = session.currentMeasure();
|
||||||
|
if (!m || !m.results) return;
|
||||||
|
const r: Measures = m.results;
|
||||||
|
const capture: CalibratedCapture = {
|
||||||
|
lengthIn: eighthsToIn(m.lengthEighths),
|
||||||
|
thicknessIn: eighthsToIn(m.thicknessEighths),
|
||||||
|
sqft: r.sqft,
|
||||||
|
bdft: r.bdft,
|
||||||
|
widthMinIn: eighthsToIn(r.widths.min),
|
||||||
|
widthMaxIn: eighthsToIn(r.widths.max),
|
||||||
|
widthAvgIn: eighthsToIn(r.widths.avg),
|
||||||
|
sourceBelowFloor: r.sourceBelowFloor,
|
||||||
|
lineup: buildPhotosReadyLineup({
|
||||||
|
inventoryPng: r.inventoryPng,
|
||||||
|
overlayPng: r.overlayPng,
|
||||||
|
batchApply: m.batchApply,
|
||||||
|
sourceBelowFloor: r.sourceBelowFloor,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
saving = true;
|
||||||
|
saveError = '';
|
||||||
|
startSaving();
|
||||||
|
try {
|
||||||
|
const id = uuid();
|
||||||
|
await createSlab({
|
||||||
|
id,
|
||||||
|
sku: '',
|
||||||
|
length_in: capture.lengthIn,
|
||||||
|
thickness_in: capture.thicknessIn,
|
||||||
|
sqft: capture.sqft,
|
||||||
|
bdft: capture.bdft,
|
||||||
|
width_min_in: capture.widthMinIn,
|
||||||
|
width_max_in: capture.widthMaxIn,
|
||||||
|
width_avg_in: capture.widthAvgIn,
|
||||||
|
price: null,
|
||||||
|
price_source: 'recommendation',
|
||||||
|
content_source: 'manual',
|
||||||
|
client_rev: 0,
|
||||||
|
});
|
||||||
|
// #90 / REVIEW-5: persist the full lineup with correct roles so the Review
|
||||||
|
// strip matches in-memory blobs by role. The overlay is the dimension-drawn
|
||||||
|
// copy — it must be persisted (its blob already has the dimensions) and is
|
||||||
|
// the photo the owner sees with the measurement overlay. Excluding it (the
|
||||||
|
// old `role !== 'overlay'` filter) dropped the dimension copy from Review.
|
||||||
|
const pngItems = capture.lineup.filter((it) => it.kind === 'png');
|
||||||
|
for (let i = 0; i < pngItems.length; i++) {
|
||||||
|
const it = pngItems[i];
|
||||||
|
await uploadPhoto(id, {
|
||||||
|
photoId: uuid(),
|
||||||
|
seq: i + 1,
|
||||||
|
isCalibration: it.role === 'calibration',
|
||||||
|
role: it.role,
|
||||||
|
original: it.blob,
|
||||||
|
// The calibration/batch PNGs have no separate processed variant; the
|
||||||
|
// overlay blob already carries the drawn dimensions.
|
||||||
|
processed: it.blob,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
enterReview(id, capture);
|
||||||
|
} catch (e) {
|
||||||
|
saveError =
|
||||||
|
e instanceof ApiError ? e.message : 'Could not start the slab. Check your connection and try again.';
|
||||||
|
backToCapture();
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if gate}
|
{#if gate}
|
||||||
@@ -58,55 +146,103 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<main class="app">
|
<main class="app">
|
||||||
{#if cap.k === 'resume'}
|
{#if curFlow.phase === 'review'}
|
||||||
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
|
<Review
|
||||||
{:else if cap.k === 'batch'}
|
slabId={curFlow.slabId}
|
||||||
<BatchPhotos
|
capture={curFlow.capture}
|
||||||
photos={cap.photos}
|
onPublish={() => {
|
||||||
onAdd={(f) => void session.addToBatch(f)}
|
const f = curFlow;
|
||||||
onRemove={session.removeFromBatch}
|
if (f.phase === 'review') enterPublish(f.slabId, f.capture);
|
||||||
onContinue={session.continueBatch}
|
}}
|
||||||
|
onBack={() => backToCapture()}
|
||||||
/>
|
/>
|
||||||
{:else if cap.k === 'pickCalibration'}
|
{:else if curFlow.phase === 'publish'}
|
||||||
<PickCalibration
|
<Publish
|
||||||
photos={cap.photos}
|
slabId={curFlow.slabId}
|
||||||
onPick={session.pickCalibration}
|
onPublished={(wooId, draft) => markDone(curFlow.slabId, wooId, draft)}
|
||||||
onBack={session.back}
|
onBack={() => {
|
||||||
/>
|
const f = curFlow;
|
||||||
{:else if cap.k === 'mask'}
|
if (f.phase === 'publish') backToReview(f.slabId, f.capture);
|
||||||
<MaskPanel
|
}}
|
||||||
state={cap}
|
|
||||||
{busy}
|
|
||||||
setSensitivity={session.setSensitivity}
|
|
||||||
setSheet={session.setSheet}
|
|
||||||
approveMask={session.approveMask}
|
|
||||||
retakes={session.retakes}
|
|
||||||
back={session.back}
|
|
||||||
/>
|
|
||||||
{:else if cap.k === 'axis'}
|
|
||||||
<AxisPanel
|
|
||||||
state={cap}
|
|
||||||
setAxisAngle={session.setAxisAngle}
|
|
||||||
approveAxis={session.approveAxis}
|
|
||||||
back={session.back}
|
|
||||||
/>
|
/>
|
||||||
|
{:else if curFlow.phase === 'done'}
|
||||||
|
<section class="panel done" aria-label="Published">
|
||||||
|
<h1>Published</h1>
|
||||||
|
<p class="ok">
|
||||||
|
{#if curFlow.draft}
|
||||||
|
Created as a UAT draft in the store.
|
||||||
|
{:else}
|
||||||
|
Created in the store.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p>Woo product #{curFlow.wooProductId} · photos and device draft cleared.</p>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="primary" data-testid="new-slab" on:click={startNewSlab}>
|
||||||
|
Start a new slab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if curFlow.phase === 'saving'}
|
||||||
|
<section class="panel" aria-busy="true">
|
||||||
|
<h1>Saving slab…</h1>
|
||||||
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<MeasurePanel
|
<!-- capture column (single slab, CAPTURE-25/30) -->
|
||||||
state={cap}
|
{#if cap.k === 'resume'}
|
||||||
setLength={session.setLength}
|
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
|
||||||
setThickness={session.setThickness}
|
{:else if cap.k === 'batch'}
|
||||||
calculate={session.calculate}
|
<BatchPhotos
|
||||||
back={session.back}
|
photos={cap.photos}
|
||||||
{pngUrl}
|
onAdd={(f) => void session.addToBatch(f)}
|
||||||
/>
|
onRemove={session.removeFromBatch}
|
||||||
|
onContinue={session.continueBatch}
|
||||||
|
/>
|
||||||
|
{:else if cap.k === 'pickCalibration'}
|
||||||
|
<PickCalibration
|
||||||
|
photos={cap.photos}
|
||||||
|
onPick={session.pickCalibration}
|
||||||
|
onBack={session.back}
|
||||||
|
/>
|
||||||
|
{:else if cap.k === 'mask'}
|
||||||
|
<MaskPanel
|
||||||
|
state={cap}
|
||||||
|
{busy}
|
||||||
|
setSensitivity={session.setSensitivity}
|
||||||
|
setSheet={session.setSheet}
|
||||||
|
approveMask={session.approveMask}
|
||||||
|
retakes={session.retakes}
|
||||||
|
back={session.back}
|
||||||
|
/>
|
||||||
|
{:else if cap.k === 'axis'}
|
||||||
|
<AxisPanel
|
||||||
|
state={cap}
|
||||||
|
setAxisAngle={session.setAxisAngle}
|
||||||
|
approveAxis={session.approveAxis}
|
||||||
|
back={session.back}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<MeasurePanel
|
||||||
|
state={cap}
|
||||||
|
setLength={session.setLength}
|
||||||
|
setThickness={session.setThickness}
|
||||||
|
calculate={session.calculate}
|
||||||
|
back={session.back}
|
||||||
|
{pngUrl}
|
||||||
|
onContinue={saving ? undefined : () => void continueToReview()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if saving}
|
||||||
|
<div class="row"><span class="busy" aria-live="polite">Saving slab…</span></div>
|
||||||
|
{/if}
|
||||||
|
{#if saveError}
|
||||||
|
<div class="save-err" role="alert" data-testid="save-error">{saveError}</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Separate pages, never in the flow (UX.md §1). -->
|
||||||
<div style="margin-top:1rem;display:flex;gap:8px">
|
<div style="margin-top:1rem;display:flex;gap:8px">
|
||||||
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
|
<button on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
|
||||||
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
|
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
|
||||||
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
|
|
||||||
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
|
|
||||||
<button on:click={() => (showPublish = !showPublish)}>Publish (dev)</button>
|
|
||||||
<button on:click={() => (showGenerate = !showGenerate)}>Generate (dev)</button>
|
|
||||||
</div>
|
</div>
|
||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
<Settings />
|
<Settings />
|
||||||
@@ -114,18 +250,6 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
{#if showSlabs}
|
{#if showSlabs}
|
||||||
<SlabList />
|
<SlabList />
|
||||||
{/if}
|
{/if}
|
||||||
{#if showPhotos}
|
|
||||||
<PhotoAttach />
|
|
||||||
{/if}
|
|
||||||
{#if showReview}
|
|
||||||
<Review />
|
|
||||||
{/if}
|
|
||||||
{#if showPublish}
|
|
||||||
<Publish />
|
|
||||||
{/if}
|
|
||||||
{#if showGenerate}
|
|
||||||
<GenerateText />
|
|
||||||
{/if}
|
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -139,4 +263,13 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||||
.hardstop h1 { font-size: 1.6rem; }
|
.hardstop h1 { font-size: 1.6rem; }
|
||||||
.detail { color: #888; font-size: 0.85rem; }
|
.detail { color: #888; font-size: 0.85rem; }
|
||||||
|
.panel { padding: 16px; color: #eee; }
|
||||||
|
.done .ok { color: #7fd37f; font-weight: 600; }
|
||||||
|
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.primary {
|
||||||
|
flex: 1; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||||
|
background: #00e5ff; color: #002; font-weight: 700;
|
||||||
|
}
|
||||||
|
.busy { color: #aaa; }
|
||||||
|
.save-err { margin-top: 12px; color: #ffb4a2; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+464
-18
@@ -1,45 +1,130 @@
|
|||||||
// Thin client for the S2 slabs endpoints. Svelte-agnostic.
|
// Thin client for the SlabUploader app API. Svelte-agnostic.
|
||||||
|
//
|
||||||
|
// Mirrors the LIVE backend routes (backend/*.py), which are the source of truth:
|
||||||
|
// slabs.py POST/GET/PUT/DELETE /api/v1/slabs[/{id}]
|
||||||
|
// photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed]
|
||||||
|
// review.py POST /api/v1/slabs/{id}/review
|
||||||
|
// publish.py POST /api/v1/slabs/{id}/publish
|
||||||
|
// inference.py POST /api/v1/slabs/{id}/inference/call1|call2
|
||||||
|
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
|
||||||
|
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
||||||
|
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
||||||
|
//
|
||||||
|
// Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain
|
||||||
|
// owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner).
|
||||||
|
|
||||||
export interface Slab {
|
export interface Slab {
|
||||||
id: string;
|
id: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
status: string;
|
status: string;
|
||||||
price: number;
|
price: number | null;
|
||||||
|
price_source: string | null;
|
||||||
length_in: number;
|
length_in: number;
|
||||||
thickness_in: number;
|
thickness_in: number;
|
||||||
sqft: number;
|
sqft: number;
|
||||||
bdft: number;
|
bdft: number;
|
||||||
|
width_min_in: number;
|
||||||
|
width_max_in: number;
|
||||||
width_avg_in: number;
|
width_avg_in: number;
|
||||||
|
species_id: number | null;
|
||||||
|
wood_category_ids: number[];
|
||||||
|
edge_type_term_id: number | null;
|
||||||
|
figure_term_ids: number[];
|
||||||
|
grade_term_id: number | null;
|
||||||
|
thickness_term_id: number | null;
|
||||||
|
moisture_term_id: number | null;
|
||||||
|
fig_tag_ids: number[];
|
||||||
|
feat_tag_ids: number[];
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
short_description: string | null;
|
||||||
|
description: string | null;
|
||||||
|
content_source: string | null;
|
||||||
|
species_confidence: number | null;
|
||||||
|
figure_confidence: number | null;
|
||||||
|
grade_confidence: number | null;
|
||||||
|
inference_status: string | null;
|
||||||
|
calibration_photo_id: string | null;
|
||||||
|
woo_product_id: number | null;
|
||||||
server_rev: number;
|
server_rev: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fields a client may send on create/update (subset of the row).
|
||||||
|
export type SlabInput = Partial<
|
||||||
|
Omit<
|
||||||
|
Slab,
|
||||||
|
| 'id'
|
||||||
|
| 'status'
|
||||||
|
| 'woo_product_id'
|
||||||
|
| 'server_rev'
|
||||||
|
| 'created_at'
|
||||||
|
| 'updated_at'
|
||||||
|
| 'inference_status'
|
||||||
|
| 'species_confidence'
|
||||||
|
| 'figure_confidence'
|
||||||
|
| 'grade_confidence'
|
||||||
|
| 'calibration_photo_id'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface SlabCreateInput extends SlabInput {
|
||||||
|
id: string; // client-generated UUIDv4 (APP-20)
|
||||||
|
client_rev: number; // 0 on create (APP-20)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
code: string | null;
|
||||||
|
message: string;
|
||||||
|
detail?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(public status: number, public code: string | null, message: string) {
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public code: string | null,
|
||||||
|
message: string,
|
||||||
|
public detail?: Record<string, unknown> | null,
|
||||||
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parse<T>(res: Response): Promise<T> {
|
async function parse<T>(res: Response): Promise<T> {
|
||||||
if (!res.ok) {
|
|
||||||
let code: string | null = null;
|
|
||||||
let message = res.statusText;
|
|
||||||
try {
|
|
||||||
const body = await res.json();
|
|
||||||
if (body?.error) {
|
|
||||||
code = body.error.code ?? null;
|
|
||||||
message = body.error.message ?? message;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* non-JSON body */
|
|
||||||
}
|
|
||||||
throw new ApiError(res.status, code, message);
|
|
||||||
}
|
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return (await res.json()) as T;
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* non-JSON body (e.g. 410 Gone) */
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = (body as { error?: ApiErrorBody } | null)?.error;
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
e?.code ?? null,
|
||||||
|
e?.message ?? res.statusText,
|
||||||
|
e?.detail ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonBody(v: unknown): BodyInit {
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slabs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function createSlab(input: SlabCreateInput): Promise<Slab> {
|
||||||
|
return fetch('/api/v1/slabs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(input),
|
||||||
|
}).then((r) => parse<Slab>(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSlabs(): Promise<Slab[]> {
|
export function listSlabs(): Promise<Slab[]> {
|
||||||
@@ -49,3 +134,364 @@ export function listSlabs(): Promise<Slab[]> {
|
|||||||
export function getSlab(id: string): Promise<Slab> {
|
export function getSlab(id: string): Promise<Slab> {
|
||||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(patch),
|
||||||
|
}).then((r) => parse<Slab>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSlab(id: string): Promise<void> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) =>
|
||||||
|
parse<void>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Photos (lineup order = seq; calibration is is_calibration=1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PhotoRow {
|
||||||
|
id: string;
|
||||||
|
seq: number;
|
||||||
|
is_calibration: number;
|
||||||
|
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
|
||||||
|
role: 'calibration' | 'overlay' | 'batch';
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
has_original: number;
|
||||||
|
has_processed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadPhotoInput {
|
||||||
|
photoId: string; // client-generated UUIDv4
|
||||||
|
seq?: number;
|
||||||
|
isCalibration: boolean;
|
||||||
|
/** #90: lineup kind; defaults to batch. */
|
||||||
|
role?: 'calibration' | 'overlay' | 'batch';
|
||||||
|
original: Blob;
|
||||||
|
processed?: Blob | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadPhotoResult {
|
||||||
|
id: string;
|
||||||
|
slab_id: string;
|
||||||
|
seq: number;
|
||||||
|
is_calibration: number;
|
||||||
|
role: 'calibration' | 'overlay' | 'batch';
|
||||||
|
original_retained: boolean;
|
||||||
|
processed_retained: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPhotos(slabId: string): Promise<PhotoRow[]> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) =>
|
||||||
|
parse<PhotoRow[]>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<UploadPhotoResult> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('photo_id', input.photoId);
|
||||||
|
if (input.seq != null) form.append('seq', String(input.seq));
|
||||||
|
form.append('is_calibration', input.isCalibration ? '1' : '0');
|
||||||
|
form.append('role', input.role ?? 'batch');
|
||||||
|
const origName = input.original instanceof File ? input.original.name : 'original.png';
|
||||||
|
form.append('original', input.original, origName);
|
||||||
|
if (input.processed) form.append('processed', input.processed, 'processed.png');
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
}).then((r) => parse<UploadPhotoResult>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #90: drop a photo the owner removed in Review (keep/remove edit).
|
||||||
|
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}).then((r) => parse<void>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #90: persist an edited Review photo lineup. `kept` is the owner's final
|
||||||
|
* order (leftmost = primary); each entry re-uploads with a fresh seq so the
|
||||||
|
* stored `seq` reflects the keep order. `removed` photos are dropped. Every
|
||||||
|
* kept photo's blob is re-sent (original + processed) because the server
|
||||||
|
* stores only the BLOB — there is no separate re-seq endpoint.
|
||||||
|
*/
|
||||||
|
export interface LineupPhoto {
|
||||||
|
id: string;
|
||||||
|
role: 'calibration' | 'overlay' | 'batch';
|
||||||
|
isCalibration: boolean;
|
||||||
|
original: Blob;
|
||||||
|
processed: Blob | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistLineup(
|
||||||
|
slabId: string,
|
||||||
|
kept: LineupPhoto[],
|
||||||
|
removed: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const id of removed) {
|
||||||
|
await deletePhoto(slabId, id);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < kept.length; i++) {
|
||||||
|
const p = kept[i];
|
||||||
|
await uploadPhoto(slabId, {
|
||||||
|
photoId: p.id,
|
||||||
|
seq: i + 1,
|
||||||
|
isCalibration: p.isCalibration,
|
||||||
|
role: p.role,
|
||||||
|
original: p.original,
|
||||||
|
processed: p.processed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function photoUrl(
|
||||||
|
slabId: string,
|
||||||
|
photoId: string,
|
||||||
|
kind: 'original' | 'processed',
|
||||||
|
): string {
|
||||||
|
return `/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}/${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Review (REVIEW-*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ReviewResult {
|
||||||
|
ready: boolean;
|
||||||
|
missing: string[];
|
||||||
|
title: string | null;
|
||||||
|
short_description: string | null;
|
||||||
|
price: number | null;
|
||||||
|
price_suggestion: number | null;
|
||||||
|
primary_wood_category_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewInput {
|
||||||
|
primary_wood_category_id?: number;
|
||||||
|
price?: number;
|
||||||
|
sku?: string;
|
||||||
|
title?: string;
|
||||||
|
short_description?: string;
|
||||||
|
description?: string;
|
||||||
|
price_source?: string;
|
||||||
|
content_source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(payload),
|
||||||
|
}).then((r) => parse<ReviewResult>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Publish (PUBLISH-*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PublishResult {
|
||||||
|
ok: true;
|
||||||
|
status: string;
|
||||||
|
woo_product_id: number;
|
||||||
|
draft: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishSlab(slabId: string): Promise<PublishResult> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((r) => parse<PublishResult>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inference (METADATA-*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Call1Suggestions {
|
||||||
|
species_id?: number | null;
|
||||||
|
species_confidence?: number | null;
|
||||||
|
wood_category_ids?: number[];
|
||||||
|
edge_type_term_id?: number | null;
|
||||||
|
figure_term_ids?: number[];
|
||||||
|
figure_confidence?: number | null;
|
||||||
|
feature_tag_ids?: number[];
|
||||||
|
feature_tag_confidence?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Call1Result {
|
||||||
|
ok: boolean;
|
||||||
|
suggestions: Call1Suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Call2Result {
|
||||||
|
ok: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferTaxon(slabId: string): Promise<Call1Result> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((r) => parse<Call1Result>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferContent(slabId: string): Promise<Call2Result> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((r) => parse<Call2Result>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Taxonomy (METADATA-5/15/30; only legal picker options)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TaxCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
parent_id: number | null;
|
||||||
|
woo_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxTerm {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxAttribute {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
terms: TaxTerm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Taxonomy {
|
||||||
|
categories: TaxCategory[];
|
||||||
|
attributes: TaxAttribute[];
|
||||||
|
tags: TaxTag[];
|
||||||
|
taxonomy_anchor: string | null;
|
||||||
|
taxonomy_last_sync_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaxonomy(): Promise<Taxonomy> {
|
||||||
|
return fetch('/api/v1/admin/taxonomy').then((r) => parse<Taxonomy>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTaxonomy(): Promise<{
|
||||||
|
taxonomy_anchor: string;
|
||||||
|
taxonomy_last_sync_at: string;
|
||||||
|
}> {
|
||||||
|
return fetch('/api/v1/admin/taxonomy/sync', { method: 'POST' }).then((r) => parse(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pricing (SETTINGS-35)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PriceRule {
|
||||||
|
id: number;
|
||||||
|
species_id: number;
|
||||||
|
price_per_bdft: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPricing(): Promise<PriceRule[]> {
|
||||||
|
return fetch('/api/v1/admin/pricing').then((r) => parse<PriceRule[]>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPricingRule(rule: {
|
||||||
|
species_id: number;
|
||||||
|
price_per_bdft: number;
|
||||||
|
}): Promise<PriceRule> {
|
||||||
|
return fetch('/api/v1/admin/pricing', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(rule),
|
||||||
|
}).then((r) => parse<PriceRule>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePricingRule(
|
||||||
|
id: number,
|
||||||
|
patch: { price_per_bdft: number; species_id?: number },
|
||||||
|
): Promise<PriceRule> {
|
||||||
|
return fetch(`/api/v1/admin/pricing/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(patch),
|
||||||
|
}).then((r) => parse<PriceRule>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePricingRule(id: number): Promise<void> {
|
||||||
|
return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse<void>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings (SETTINGS-*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SettingsView {
|
||||||
|
woo_base_url: string;
|
||||||
|
woo_wp_username: string | null;
|
||||||
|
woo_app_password: { configured: boolean } | string;
|
||||||
|
inference_base_url: { configured: boolean } | string;
|
||||||
|
inference_api_key: { configured: boolean } | string;
|
||||||
|
inference_model: string | null;
|
||||||
|
inference_enabled: boolean;
|
||||||
|
content_llm_enabled: boolean;
|
||||||
|
brand_voice: string | null;
|
||||||
|
geo_context: string | null;
|
||||||
|
woo_create_status: string;
|
||||||
|
taxonomy_last_sync_at: string | null;
|
||||||
|
taxonomy_anchor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettings(): Promise<SettingsView> {
|
||||||
|
return fetch('/api/v1/settings').then((r) => parse<SettingsView>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(
|
||||||
|
patch: Partial<Record<keyof SettingsView, unknown>>,
|
||||||
|
): Promise<SettingsView> {
|
||||||
|
return fetch('/api/v1/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(patch),
|
||||||
|
}).then((r) => parse<SettingsView>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestWooResult {
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
taxonomy_anchor: string | null;
|
||||||
|
taxonomy_last_sync_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestInferenceResult {
|
||||||
|
ok: boolean;
|
||||||
|
mode: 'stateful' | 'stateless' | 'fail';
|
||||||
|
vision_capable: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testWoo(): Promise<TestWooResult> {
|
||||||
|
return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) =>
|
||||||
|
parse<TestWooResult>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testInference(): Promise<TestInferenceResult> {
|
||||||
|
return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) =>
|
||||||
|
parse<TestInferenceResult>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,3 +24,25 @@ export function supported(): string | null {
|
|||||||
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
||||||
return 'unsupported browser';
|
return 'unsupported browser';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4 UUID. crypto.randomUUID() is only guaranteed on secure contexts
|
||||||
|
// (HTTPS/localhost); the UAT host is plain HTTP on a LAN IP, where it is
|
||||||
|
// undefined. Fall back to crypto.getRandomValues (available on HTTP) so the
|
||||||
|
// client-generated ids (APP-20) work in both contexts.
|
||||||
|
export function uuid(): string {
|
||||||
|
const c = globalThis.crypto;
|
||||||
|
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
||||||
|
if (c && typeof c.getRandomValues === 'function') {
|
||||||
|
const b = new Uint8Array(16);
|
||||||
|
c.getRandomValues(b);
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
|
||||||
|
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
export let calculate: () => Promise<void>;
|
export let calculate: () => Promise<void>;
|
||||||
export let back: () => void;
|
export let back: () => void;
|
||||||
export let pngUrl: string | null;
|
export let pngUrl: string | null;
|
||||||
|
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
|
||||||
|
export let onContinue: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
let calcBusy = false;
|
let calcBusy = false;
|
||||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||||
@@ -177,6 +179,11 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
|
{#if onContinue}
|
||||||
|
<button type="button" class="primary" data-testid="continue-to-review" on:click={onContinue}>
|
||||||
|
Continue to Review
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface CaptureSession {
|
|||||||
calculate(): Promise<void>;
|
calculate(): Promise<void>;
|
||||||
back(): void;
|
back(): void;
|
||||||
getInventoryUrl(): string | null;
|
getInventoryUrl(): string | null;
|
||||||
|
/** The current `measure` branch, or null while not on it (C2 handoff). */
|
||||||
|
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +634,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
return pngUrl;
|
return pngUrl;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
currentMeasure() {
|
||||||
|
return state.k === 'measure' ? state : null;
|
||||||
|
},
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
teardownWorker();
|
teardownWorker();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Shared state for the one-column Create flow (UX.md §1): capture → review →
|
||||||
|
// publish. App.svelte owns it; MeasurePanel hands off the calibrated capture,
|
||||||
|
// Review hands to Publish, Publish clears the device draft on success.
|
||||||
|
//
|
||||||
|
// The flow always concerns a SINGLE slab — there is no list/picker in the
|
||||||
|
// owner path (CAPTURE-25/30).
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { PhotosReadyLineupItem } from '../capture/photosReadyLineup';
|
||||||
|
|
||||||
|
export interface CalibratedCapture {
|
||||||
|
lengthIn: number;
|
||||||
|
thicknessIn: number;
|
||||||
|
sqft: number;
|
||||||
|
bdft: number;
|
||||||
|
widthMinIn: number;
|
||||||
|
widthMaxIn: number;
|
||||||
|
widthAvgIn: number;
|
||||||
|
sourceBelowFloor: boolean;
|
||||||
|
lineup: PhotosReadyLineupItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Flow =
|
||||||
|
| { phase: 'capture' }
|
||||||
|
| { phase: 'saving' }
|
||||||
|
| { phase: 'review'; slabId: string; capture: CalibratedCapture }
|
||||||
|
| { phase: 'publish'; slabId: string; capture: CalibratedCapture }
|
||||||
|
| { phase: 'done'; slabId: string; wooProductId: number; draft: boolean };
|
||||||
|
|
||||||
|
const initial: Flow = { phase: 'capture' };
|
||||||
|
|
||||||
|
export const flow = writable<Flow>(initial);
|
||||||
|
|
||||||
|
export function startSaving(): void {
|
||||||
|
flow.set({ phase: 'saving' });
|
||||||
|
}
|
||||||
|
export function enterReview(slabId: string, capture: CalibratedCapture): void {
|
||||||
|
flow.set({ phase: 'review', slabId, capture });
|
||||||
|
}
|
||||||
|
export function enterPublish(slabId: string, capture: CalibratedCapture): void {
|
||||||
|
flow.set({ phase: 'publish', slabId, capture });
|
||||||
|
}
|
||||||
|
export function markDone(slabId: string, wooProductId: number, draft: boolean): void {
|
||||||
|
flow.set({ phase: 'done', slabId, wooProductId, draft });
|
||||||
|
}
|
||||||
|
export function backToReview(slabId: string, capture: CalibratedCapture): void {
|
||||||
|
flow.set({ phase: 'review', slabId, capture });
|
||||||
|
}
|
||||||
|
export function backToCapture(): void {
|
||||||
|
flow.set({ phase: 'capture' });
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { uuid } from '../../browser';
|
||||||
|
|
||||||
let slabId = '';
|
let slabId = '';
|
||||||
let photos: {
|
let photos: {
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
message = '';
|
message = '';
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('photo_id', photoId || crypto.randomUUID());
|
form.append('photo_id', photoId || uuid());
|
||||||
form.append('is_calibration', isCalibration ? '1' : '0');
|
form.append('is_calibration', isCalibration ? '1' : '0');
|
||||||
form.append('original', originalFile, originalFile.name);
|
form.append('original', originalFile, originalFile.name);
|
||||||
if (processedFile) form.append('processed', processedFile, processedFile.name);
|
if (processedFile) form.append('processed', processedFile, processedFile.name);
|
||||||
|
|||||||
@@ -1,69 +1,137 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let slabId = '';
|
import { onMount, createEventDispatcher } from 'svelte';
|
||||||
let result: {
|
import { getSlab, publishSlab, ApiError, type Slab } from '../../api/slabs';
|
||||||
ok?: boolean;
|
|
||||||
status?: string;
|
|
||||||
woo_product_id?: number;
|
|
||||||
draft?: boolean;
|
|
||||||
error?: { code: string; message: string; detail?: Record<string, unknown> };
|
|
||||||
} | null = null;
|
|
||||||
let busy = false;
|
|
||||||
|
|
||||||
async function publish() {
|
// One-column flow: Review hands to Publish by id (UX.md §1).
|
||||||
if (!slabId) {
|
export let slabId: string;
|
||||||
result = { error: { code: 'input', message: 'Enter a slab id.' } };
|
export let onPublished: (wooProductId: number, draft: boolean) => void;
|
||||||
return;
|
export let onBack: () => void;
|
||||||
}
|
|
||||||
busy = true;
|
const dispatch = createEventDispatcher();
|
||||||
result = null;
|
let slab: Slab | null = null;
|
||||||
try {
|
let loading = true;
|
||||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { method: 'POST' });
|
let loadError = '';
|
||||||
const body = await res.json();
|
|
||||||
if (res.ok) result = body;
|
interface PublishState {
|
||||||
else result = { ok: false, error: body.error };
|
phase: 'idle' | 'working' | 'success' | 'failure';
|
||||||
} catch {
|
ok?: { wooProductId: number; draft: boolean };
|
||||||
result = { ok: false, error: { code: 'network', message: 'network error' } };
|
fail?: {
|
||||||
}
|
code: string | null;
|
||||||
busy = false;
|
message: string;
|
||||||
|
recoverable: boolean;
|
||||||
|
partialMedia: boolean;
|
||||||
|
duplicateSku: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
let st: PublishState = { phase: 'idle' };
|
||||||
|
|
||||||
|
// Map backend codes to the recovery branches (PUBLISH-30/31, QUALITY-120).
|
||||||
|
function classify(code: string | null, message: string, detail: Record<string, unknown> | null): PublishState['fail'] {
|
||||||
|
const d = detail ?? {};
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
recoverable: d.recoverable === true,
|
||||||
|
partialMedia: d.partial_media === true,
|
||||||
|
duplicateSku: code === 'duplicate_sku',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPublish() {
|
||||||
|
st = { phase: 'working' };
|
||||||
|
publishSlab(slabId)
|
||||||
|
.then((r) => {
|
||||||
|
st = { phase: 'success', ok: { wooProductId: r.woo_product_id, draft: r.draft } };
|
||||||
|
// Mark the flow done (App switches to the Done panel) after a tick so the
|
||||||
|
// success state renders first.
|
||||||
|
onPublished(r.woo_product_id, r.draft);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) };
|
||||||
|
} else {
|
||||||
|
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSku() { dispatch('back'); }
|
||||||
|
function openExisting() { dispatch('open-existing'); }
|
||||||
|
function backToReview() { onBack(); }
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try { slab = await getSlab(slabId); }
|
||||||
|
catch (e) { loadError = e instanceof ApiError ? e.message : 'Could not load the slab.'; }
|
||||||
|
finally { loading = false; }
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="publish">
|
{#if loading}
|
||||||
<h2>Publish to WooCommerce</h2>
|
<section class="pub" aria-busy="true" data-testid="publish-loading"><h1>Publish to WooCommerce</h1><p class="busy">Loading…</p></section>
|
||||||
<div>
|
{:else if loadError}
|
||||||
<label>Slab ID</label>
|
<section class="pub"><h1>Publish to WooCommerce</h1><p class="err" role="alert">{loadError}</p>
|
||||||
<input bind:value={slabId} placeholder="uuid" />
|
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||||
</div>
|
</section>
|
||||||
<button on:click={publish} disabled={busy}>{busy ? 'Publishing…' : 'Publish'}</button>
|
{:else}
|
||||||
|
<section class="pub" data-testid="publish-screen">
|
||||||
|
<h1>Publish to WooCommerce</h1>
|
||||||
|
<p class="meta">SKU {slab?.sku || '—'} · {slab?.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab?.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
|
||||||
|
|
||||||
{#if result}
|
{#if st.phase === 'idle' || st.phase === 'working'}
|
||||||
{#if result.ok}
|
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish} disabled={st.phase === 'working'}>
|
||||||
<p class="ok">
|
{st.phase === 'working' ? 'Publishing…' : 'Publish'}
|
||||||
Published{result.draft ? ' (UAT draft)' : ''} → Woo product #{result.woo_product_id}
|
</button>
|
||||||
</p>
|
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview} disabled={st.phase === 'working'}>Back to Review</button>
|
||||||
{:else}
|
|
||||||
<p class="err">
|
|
||||||
{result.error?.message}
|
|
||||||
{#if result.error?.detail?.recoverable === false}
|
|
||||||
<span class="tag">unrecoverable</span>
|
|
||||||
{:else if result.error?.detail?.recoverable === true}
|
|
||||||
<span class="tag">recoverable — retry</span>
|
|
||||||
{/if}
|
|
||||||
{#if result.error?.detail?.partial_media}
|
|
||||||
<span class="tag warn">orphan media — clean up manually</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
{#if st.phase === 'success'}
|
||||||
|
<div class="status ok" data-testid="publish-ok">Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}</div>
|
||||||
|
<p class="meta">Photos purged · device draft cleared.</p>
|
||||||
|
<button type="button" class="primary" data-testid="new-slab" on:click={() => {}}>Start a new slab</button>
|
||||||
|
{:else if st.phase === 'failure' && st.fail}
|
||||||
|
{#if st.fail.duplicateSku}
|
||||||
|
<div class="step-x" data-testid="dup-sku">
|
||||||
|
<div class="x n">✕</div>
|
||||||
|
<h2>SKU already exists</h2>
|
||||||
|
<p class="meta">“{slab?.sku}” is already in the store.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
|
||||||
|
<button type="button" class="ghost" data-testid="open-existing" on:click={openExisting}>Open existing product</button>
|
||||||
|
{:else if st.fail.code === 'unpublishable'}
|
||||||
|
<div class="step-x" data-testid="not-ready">
|
||||||
|
<div class="x n">✕</div>
|
||||||
|
<h2>Not ready to publish</h2>
|
||||||
|
<p class="meta">Some fields are missing. Go back and finish Review.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||||
|
{:else}
|
||||||
|
<div class="step-x" data-testid="store-failure">
|
||||||
|
<div class="x n" class:rec={st.fail.recoverable}>{st.fail.partialMedia ? '⚠' : '↻'}</div>
|
||||||
|
<h2>{st.fail.message}</h2>
|
||||||
|
{#if st.fail.recoverable}<span class="tag">recoverable</span>{/if}
|
||||||
|
{#if st.fail.partialMedia}<span class="tag warn">orphan media — remove manually</span>{/if}
|
||||||
|
{#if !st.fail.recoverable}<span class="tag bad">check Settings</span>{/if}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary" data-testid="retry-publish" on:click={doPublish}>Retry</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.publish { max-width: 520px; color: #eee; }
|
.pub { max-width: 520px; margin: 0 auto; color: #eee; }
|
||||||
label { display: block; margin: 8px 0 4px; }
|
h1 { font-size: 1.3rem; margin: 0 0 6px; } h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||||||
input { width: 100%; padding: 6px; }
|
.meta { color: #888; font-size: .82rem; margin: 4px 0 10px; }
|
||||||
button { margin-top: 12px; }
|
.primary { width: 100%; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; margin-top: 12px; }
|
||||||
.ok { margin-top: 12px; color: #7fd37f; font-weight: 600; }
|
.primary:disabled { background: #333; color: #777; }
|
||||||
.err { margin-top: 12px; color: #e88; }
|
.ghost { width: 100%; padding: 13px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; margin-top: 10px; }
|
||||||
.tag { margin-left: 8px; font-size: 12px; padding: 1px 6px; border-radius: 4px; background: #444; }
|
.status { font-weight: 700; padding: 11px 13px; border-radius: 3px; font-size: .92rem; }
|
||||||
.tag.warn { background: #6a4; color: #000; }
|
.status.ok { background: #122a12; color: #7fd37f; }
|
||||||
|
.step-x { background: #2a1512; border-radius: 3px; padding: 14px; margin: 14px 0; }
|
||||||
|
.x { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.x .n { width: 22px; height: 22px; border-radius: 50%; background: #444; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .8rem; }
|
||||||
|
.x .n.rec { background: #00e5ff; color: #002; }
|
||||||
|
.tag { margin-left: 8px; font-size: .64rem; padding: 1px 6px; border-radius: 2px; background: #444; color: #ccc; }
|
||||||
|
.tag.warn { background: #6a4; color: #000; } .tag.bad { background: #633; color: #ffb4a2; }
|
||||||
|
.err { color: #e88; } .busy { color: #aaa; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Searchable multi-select list (the approved paradigm replacing tap-bubbles).
|
||||||
|
// Squared/slight-radius, type-to-filter, select-all, live count badge, per-row
|
||||||
|
// AI bubble on AI-recommended options. Works for 6 items or 60.
|
||||||
|
//
|
||||||
|
// Two modes:
|
||||||
|
// - flat: pass `options` (single-level list).
|
||||||
|
// - grouped: pass `groups` (parent/child). Parents are non-selectable header rows
|
||||||
|
// (METADATA-10: "parents group only and are never assigned"); only the
|
||||||
|
// leaf rows are selectable. Filtering hides a parent group when none of
|
||||||
|
// its leaves match.
|
||||||
|
// #90 closed state shows selected NAMES (not only a count). Done dispatches
|
||||||
|
// `commit` so the parent can persist + re-review.
|
||||||
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
|
import { selectedSummary } from './woodPrimary';
|
||||||
|
|
||||||
|
export interface Opt { id: number; name: string; ai: boolean }
|
||||||
|
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
|
||||||
|
|
||||||
|
export let label: string;
|
||||||
|
export let options: Opt[] = [];
|
||||||
|
// Optional grouped shape. When provided, `options` is ignored and the panel renders
|
||||||
|
// parent headers + selectable leaf rows.
|
||||||
|
export let groups: Group[] = [];
|
||||||
|
export let selected: number[];
|
||||||
|
export let placeholder = 'Choose…';
|
||||||
|
export let testid = '';
|
||||||
|
export let onToggle: (id: number) => void;
|
||||||
|
// #90: commit (persist + re-review) after a toggle settles. The parent wires this
|
||||||
|
// so multi-select changes flush to the slab immediately — no need to touch another
|
||||||
|
// field first. Debounced so a quick multi-tap doesn't fire a commit per click.
|
||||||
|
export let onCommit: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let open = false;
|
||||||
|
let filter = '';
|
||||||
|
let focused: number | null = null;
|
||||||
|
let commitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const grouped = groups.length > 0;
|
||||||
|
|
||||||
|
// Effective selectable set (leaves when grouped, options when flat).
|
||||||
|
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
|
||||||
|
$: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
|
||||||
|
$: q = filter.trim().toLowerCase();
|
||||||
|
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
|
||||||
|
// Grouped: keep a parent only if at least one of its leaves survives the filter.
|
||||||
|
$: filteredGroups = grouped
|
||||||
|
? groups
|
||||||
|
.map((g) => ({ parent: g.parent, leaves: q ? g.leaves.filter((o) => o.name.toLowerCase().includes(q)) : g.leaves }))
|
||||||
|
.filter((g) => g.leaves.length > 0)
|
||||||
|
: [];
|
||||||
|
$: count = selected.length;
|
||||||
|
$: summary = selectedSummary(selected, nameById, placeholder);
|
||||||
|
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
|
||||||
|
|
||||||
|
function scheduleCommit() {
|
||||||
|
if (typeof onCommit !== 'function') return;
|
||||||
|
if (commitTimer) clearTimeout(commitTimer);
|
||||||
|
commitTimer = setTimeout(() => { commitTimer = undefined; onCommit(); }, 250);
|
||||||
|
}
|
||||||
|
function toggle(id: number) {
|
||||||
|
onToggle(id);
|
||||||
|
focused = id;
|
||||||
|
scheduleCommit(); // #90: persist + re-review without waiting for another field
|
||||||
|
}
|
||||||
|
function selectAll() {
|
||||||
|
for (const o of filteredFlat) if (!selected.includes(o.id)) onToggle(o.id);
|
||||||
|
scheduleCommit();
|
||||||
|
}
|
||||||
|
function clearAll() {
|
||||||
|
for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id);
|
||||||
|
scheduleCommit();
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
filter = '';
|
||||||
|
if (commitTimer) { clearTimeout(commitTimer); commitTimer = undefined; }
|
||||||
|
if (typeof onCommit === 'function') onCommit(); // flush immediately on Done
|
||||||
|
dispatch('commit');
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
|
||||||
|
function destroy() { if (commitTimer) clearTimeout(commitTimer); }
|
||||||
|
onDestroy(destroy);
|
||||||
|
// Rows are focusable options (role=option): Enter/Space toggles, matching click.
|
||||||
|
function onRowKey(e: KeyboardEvent, id: number) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ms" class:open role="group" aria-label={label} data-testid={testid} on:keydown={onKey}>
|
||||||
|
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
|
||||||
|
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>
|
||||||
|
{open
|
||||||
|
? 'Choose ' + label.toLowerCase()
|
||||||
|
: summary !== ''
|
||||||
|
? summary
|
||||||
|
: placeholder}
|
||||||
|
</span>
|
||||||
|
<span class="ms-c" data-testid={testid ? `${testid}-count` : 'ms-count'}>{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</span>
|
||||||
|
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="ms-panel" role="listbox" aria-multiselectable="true">
|
||||||
|
<div class="ms-tools">
|
||||||
|
<input class="ms-search" type="text" placeholder="Filter…" bind:value={filter}
|
||||||
|
data-testid={testid ? `${testid}-filter` : 'ms-filter'} aria-label={`Filter ${label}`} />
|
||||||
|
<button type="button" class="ms-all" data-testid={testid ? `${testid}-all` : 'ms-all'}
|
||||||
|
on:click={allFilteredSelected ? clearAll : selectAll}>
|
||||||
|
{allFilteredSelected ? 'None' : 'All'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if grouped}
|
||||||
|
<ul class="ms-list">
|
||||||
|
{#each filteredGroups as g (g.parent.id)}
|
||||||
|
<li class="ms-group" aria-hidden="true">{g.parent.name}</li>
|
||||||
|
{#each g.leaves as o (o.id)}
|
||||||
|
<li
|
||||||
|
class="ms-row leaf"
|
||||||
|
class:on={selected.includes(o.id)}
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={selected.includes(o.id)}
|
||||||
|
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||||
|
on:click={() => toggle(o.id)}
|
||||||
|
on:keydown={(e) => onRowKey(e, o.id)}
|
||||||
|
>
|
||||||
|
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||||
|
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<li class="ms-empty">No matches</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<ul class="ms-list">
|
||||||
|
{#each filteredFlat as o (o.id)}
|
||||||
|
<li
|
||||||
|
class="ms-row"
|
||||||
|
class:on={selected.includes(o.id)}
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={selected.includes(o.id)}
|
||||||
|
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||||
|
on:click={() => toggle(o.id)}
|
||||||
|
on:keydown={(e) => onRowKey(e, o.id)}
|
||||||
|
>
|
||||||
|
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||||
|
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="ms-empty">No matches</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ms-foot">
|
||||||
|
<span>{allOpts.length} synced{filter.trim() ? ` · ${filteredFlat.length} shown` : ''}</span>
|
||||||
|
<button type="button" on:click={close} data-testid={testid ? `${testid}-done` : 'ms-done'}>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
|
||||||
|
.ms-btn { box-sizing: border-box; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||||
|
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
|
||||||
|
.ms-btn .ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eee; }
|
||||||
|
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
|
||||||
|
.ms-arrow { color: #777; }
|
||||||
|
.ms-panel { border-top: 1px solid #333; background: #161616; }
|
||||||
|
.ms-tools { display: flex; gap: 8px; padding: 8px; align-items: center; }
|
||||||
|
.ms-search { flex: 1; padding: 7px 9px; background: #0f0f0f; border: 1px solid #3a3a3a; border-radius: 3px; color: #eee; font-size: .85rem; }
|
||||||
|
.ms-all { font-size: .74rem; color: #00e5ff; background: none; border: 0; cursor: pointer; white-space: nowrap; padding: 4px; }
|
||||||
|
.ms-list { list-style: none; margin: 0; padding: 0; max-height: 200px; overflow-y: auto; }
|
||||||
|
.ms-row { display: flex; align-items: center; gap: 9px; padding: 9px 12px; font-size: .9rem; border-top: 1px solid #222; cursor: pointer; }
|
||||||
|
.ms-row:hover { background: #1f1f1f; }
|
||||||
|
.ms-row.on { background: #12262b; }
|
||||||
|
.ms-row.on .nm { color: #fff; }
|
||||||
|
.ms-row .nm { flex: 1; display: flex; align-items: center; gap: 6px; }
|
||||||
|
/* grouped: indented leaves under a non-selectable parent header */
|
||||||
|
.ms-group { padding: 8px 12px 3px; font-size: .72rem; font-weight: 700; letter-spacing: .4px;
|
||||||
|
text-transform: uppercase; color: #7f8a8a; background: #101010; border-top: 1px solid #222; }
|
||||||
|
.ms-row.leaf { padding-left: 26px; }
|
||||||
|
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||||
|
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
|
||||||
|
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
|
||||||
|
.ms-row:focus-visible { outline: 2px solid #00e5ff; outline-offset: -2px; }
|
||||||
|
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
|
||||||
|
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import {
|
||||||
|
keptInOrder,
|
||||||
|
moveItem,
|
||||||
|
missingMinimums,
|
||||||
|
lineupValid,
|
||||||
|
roleLabel,
|
||||||
|
resolvePhotoUrl,
|
||||||
|
type LineupItem,
|
||||||
|
} from './lineup';
|
||||||
|
|
||||||
|
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
isCalibration: role === 'calibration',
|
||||||
|
kept,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: keptInOrder returns kept photos in their current order', () => {
|
||||||
|
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
|
||||||
|
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: leftmost kept is the primary', () => {
|
||||||
|
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
|
||||||
|
// After a drag the owner re-ordered; primary follows position, not role.
|
||||||
|
assert.equal(keptInOrder(items)[0].id, 'b1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
|
||||||
|
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
|
||||||
|
assert.equal(keptInOrder(items)[0].id, 'ov');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
|
||||||
|
const a = ['x', 'y', 'z'];
|
||||||
|
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
|
||||||
|
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
|
||||||
|
// Out-of-range is a no-op, never throws.
|
||||||
|
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
|
||||||
|
// Returns a new array, does not mutate the input.
|
||||||
|
const before = a.slice();
|
||||||
|
moveItem(a, 0, 2);
|
||||||
|
assert.deepEqual(a, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
|
||||||
|
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
|
||||||
|
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
|
||||||
|
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
|
||||||
|
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
|
||||||
|
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
|
||||||
|
assert.equal(lineupValid([p('b1', 'batch')]), false);
|
||||||
|
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: missingMinimums only counts KEPT photos', () => {
|
||||||
|
// Both roles present in the array, but the cal is not kept.
|
||||||
|
assert.deepEqual(
|
||||||
|
missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]),
|
||||||
|
['calibration'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: roleLabel is owner-facing', () => {
|
||||||
|
assert.match(roleLabel('calibration'), /clean/);
|
||||||
|
assert.match(roleLabel('overlay'), /overlay/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => {
|
||||||
|
// The first/Primary (calibration) photo carries a fresh capture blob. It must
|
||||||
|
// become a valid object URL — never a raw Blob, never String(blob)="object Blob".
|
||||||
|
const blob = new Blob(['png-bytes'], { type: 'image/png' });
|
||||||
|
const fake = (b: Blob) => `blob:fake-${b.size}`;
|
||||||
|
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
|
||||||
|
// Empty/absent blob falls back to the stored endpoint.
|
||||||
|
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
||||||
|
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// #90 Review photo strip (REVIEW-5 / PUBLISH-45). Pure logic split out so the
|
||||||
|
// keep/remove + reorder + minimum enforcement is unit-testable without a browser.
|
||||||
|
//
|
||||||
|
// Model (approved in the wireframe): the owner taps a photo to keep/remove it,
|
||||||
|
// drags to reorder, and the LEFTMOST kept photo is the primary (Woo cover). The
|
||||||
|
// submission-ready minimum is at least one clean (calibration) photo AND one
|
||||||
|
// dimension-overlay copy (REVIEW-5 "clean first pic plus its dimension-overlay
|
||||||
|
// copy"). Batch photos are optional.
|
||||||
|
|
||||||
|
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
|
||||||
|
|
||||||
|
export interface LineupItem {
|
||||||
|
id: string;
|
||||||
|
role: PhotoRole;
|
||||||
|
isCalibration: boolean;
|
||||||
|
/** True = the owner keeps this photo in the submission. */
|
||||||
|
kept: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kept photos in their current (left→right) order. The first kept photo is
|
||||||
|
* the primary (Woo cover). Reordering is a plain array move; this helper just
|
||||||
|
* exposes the kept subset so the caller can re-seq on persist.
|
||||||
|
*/
|
||||||
|
export function keptInOrder(items: LineupItem[]): LineupItem[] {
|
||||||
|
return items.filter((it) => it.kept);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move an item from one index to another (drag-reorder). Returns a new array. */
|
||||||
|
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
|
||||||
|
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
const next = arr.slice();
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
|
||||||
|
* clean calibration photo and one dimension-overlay copy. Returns the missing
|
||||||
|
* role(s) so the UI can name them in a nudge.
|
||||||
|
*/
|
||||||
|
export function missingMinimums(items: LineupItem[]): PhotoRole[] {
|
||||||
|
const kept = items.filter((it) => it.kept);
|
||||||
|
const missing: PhotoRole[] = [];
|
||||||
|
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
|
||||||
|
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the kept lineup is publishable from a photo standpoint. */
|
||||||
|
export function lineupValid(kept: LineupItem[]): boolean {
|
||||||
|
return missingMinimums(kept).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short owner-facing label for a role (used in the minimum nudge). */
|
||||||
|
export function roleLabel(role: PhotoRole): string {
|
||||||
|
if (role === 'calibration') return 'a clean photo';
|
||||||
|
if (role === 'overlay') return 'its dimension-overlay copy';
|
||||||
|
return 'a batch photo';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the <img> src for a strip photo. The in-memory capture blob (fresh
|
||||||
|
* photo) must become a `blob:` object URL — assigning a raw Blob (or
|
||||||
|
* `String(blob)` = "object Blob") to `src` renders a broken image. That was
|
||||||
|
* bug #1: the first/Primary (calibration) photo is the one carrying a blob.
|
||||||
|
* When there is no usable blob, fall back to the stored photo endpoint.
|
||||||
|
*
|
||||||
|
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
|
||||||
|
* is unit-testable without a real DOM/Blob.
|
||||||
|
*/
|
||||||
|
export function resolvePhotoUrl(
|
||||||
|
blob: Blob | null,
|
||||||
|
endpoint: string,
|
||||||
|
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
|
||||||
|
): string {
|
||||||
|
if (blob && blob.size > 0) return createObjectUrl(blob);
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { deriveThicknessTermId, type ThicknessBand } from './thicknessBand';
|
||||||
|
|
||||||
|
// Synced-Woo-shaped bands (id + name). The e2e fixture uses a single "2-4 in" band.
|
||||||
|
const BANDS: ThicknessBand[] = [
|
||||||
|
{ id: 39, name: '2-4 in' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('TV-9 / QUALITY-95: 1 in rounds up into the enclosing synced band', () => {
|
||||||
|
const bands: ThicknessBand[] = [
|
||||||
|
{ id: 10, name: '1-2 in' },
|
||||||
|
{ id: 39, name: '2-4 in' },
|
||||||
|
];
|
||||||
|
assert.equal(deriveThicknessTermId(1, bands), 10);
|
||||||
|
assert.equal(deriveThicknessTermId(1.5, bands), 10);
|
||||||
|
// 2.0 is exactly the upper bound of the 1-2 band → belongs to it.
|
||||||
|
assert.equal(deriveThicknessTermId(2.0, bands), 10);
|
||||||
|
// 2.01 falls into 2-4.
|
||||||
|
assert.equal(deriveThicknessTermId(2.25, bands), 39);
|
||||||
|
assert.equal(deriveThicknessTermId(4, bands), 39);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('e2e fixture: thickness_in 2 → the single "2-4 in" band (id 39)', () => {
|
||||||
|
assert.equal(deriveThicknessTermId(2, BANDS), 39);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('above every band → empty (no invented band; upstream thickness fix resolves it)', () => {
|
||||||
|
assert.equal(deriveThicknessTermId(5, BANDS), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no thickness / non-finite → empty', () => {
|
||||||
|
assert.equal(deriveThicknessTermId(null, BANDS), '');
|
||||||
|
assert.equal(deriveThicknessTermId(undefined, BANDS), '');
|
||||||
|
assert.equal(deriveThicknessTermId(0, BANDS), '');
|
||||||
|
assert.equal(deriveThicknessTermId(NaN, BANDS), '');
|
||||||
|
assert.equal(deriveThicknessTermId(2, []), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('band names are parsed from the synced names, never hardcoded (order-independent)', () => {
|
||||||
|
// Reverse the array: the derivation must still pick the right band by bound.
|
||||||
|
const bands: ThicknessBand[] = [
|
||||||
|
{ id: 39, name: '2-4 in' },
|
||||||
|
{ id: 10, name: '1-2 in' },
|
||||||
|
];
|
||||||
|
assert.equal(deriveThicknessTermId(1.5, bands), 10);
|
||||||
|
assert.equal(deriveThicknessTermId(3, bands), 39);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed band names are skipped without throwing', () => {
|
||||||
|
const bands: ThicknessBand[] = [
|
||||||
|
{ id: 1, name: 'N/A' },
|
||||||
|
{ id: 2, name: '4-2 in' }, // inverted, unusable
|
||||||
|
{ id: 39, name: '2-4 in' },
|
||||||
|
];
|
||||||
|
assert.equal(deriveThicknessTermId(3, bands), 39);
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic thickness-band derivation (TV-9 / QUALITY-95, METADATA-15/25).
|
||||||
|
*
|
||||||
|
* The Woo "Thickness" attribute is a set of named bands (e.g. "2-4 in"). The
|
||||||
|
* owner enters a numeric thickness in 1/8-inch steps; the band is derived by
|
||||||
|
* rounding that thickness UP into the band whose numeric upper bound first
|
||||||
|
* encloses it. The Review UI never exposes a band picker — the band always
|
||||||
|
* follows from thickness_in (issue #88).
|
||||||
|
*
|
||||||
|
* Pure function so it is unit-testable without a browser/backend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ThicknessBand {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the leading numeric bounds ("2-4 in" → {min:2,max:4}). Returns null if the name
|
||||||
|
* carries no usable numeric upper bound (defensive — a band we can't place is skipped). */
|
||||||
|
function parseBounds(name: string): { min: number; max: number } | null {
|
||||||
|
// Match the first two numbers in the name, in order (handles "2-4 in", "1/8", "12in+").
|
||||||
|
const nums = name.match(/\d+(?:[./]\d+)?/g);
|
||||||
|
if (!nums) return null;
|
||||||
|
const asNum = (s: string) => Number(s);
|
||||||
|
const min = asNum(nums[0]);
|
||||||
|
const max = nums.length >= 2 ? asNum(nums[1]) : min;
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
|
||||||
|
if (max < min) return null; // malformed "4-2"; not a usable band
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round `thicknessIn` (inches, 1/8" steps) up into the synced Woo thickness band.
|
||||||
|
* Returns the term id, or '' when no band encloses the value (no thickness known,
|
||||||
|
* empty synced set, or a value above every band — all leave the field empty so the
|
||||||
|
* owner's upstream fix, not a wrong band, resolves it).
|
||||||
|
*/
|
||||||
|
export function deriveThicknessTermId(
|
||||||
|
thicknessIn: number | null | undefined,
|
||||||
|
bands: ThicknessBand[],
|
||||||
|
): number | '' {
|
||||||
|
if (typeof thicknessIn !== 'number' || !Number.isFinite(thicknessIn) || thicknessIn <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// Sort bands by upper bound so "first enclosing max" is well-defined regardless of
|
||||||
|
// the order Woo returned them in.
|
||||||
|
const ranked = bands
|
||||||
|
.map((b) => ({ b, bounds: parseBounds(b.name) }))
|
||||||
|
.filter((x): x is { b: ThicknessBand; bounds: { min: number; max: number } } => x.bounds !== null)
|
||||||
|
.sort((a, z) => a.bounds.max - z.bounds.max);
|
||||||
|
for (const { b, bounds } of ranked) {
|
||||||
|
// A value exactly on the band's max belongs to that band; above it, fall through.
|
||||||
|
if (thicknessIn <= bounds.max) {
|
||||||
|
return b.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { nextPrimary, selectedSummary } from './woodPrimary';
|
||||||
|
|
||||||
|
test('nextPrimary: empty selection clears primary', () => {
|
||||||
|
assert.equal(nextPrimary([], 21), '');
|
||||||
|
assert.equal(nextPrimary([], ''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextPrimary: default is first selected (REVIEW-15)', () => {
|
||||||
|
assert.equal(nextPrimary([21, 22], ''), 21);
|
||||||
|
assert.equal(nextPrimary([21, 22], null), 21);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextPrimary: keeps current when still selected', () => {
|
||||||
|
assert.equal(nextPrimary([21, 22], 22), 22);
|
||||||
|
assert.equal(nextPrimary([22], 22), 22);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
|
||||||
|
assert.equal(nextPrimary([22, 23], 21), 22);
|
||||||
|
assert.equal(nextPrimary([23], 21), 23);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectedSummary: placeholder when empty', () => {
|
||||||
|
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectedSummary: shows names, then +N overflow', () => {
|
||||||
|
const m = new Map([
|
||||||
|
[1, 'Live Edge'],
|
||||||
|
[2, 'Book Match'],
|
||||||
|
[3, 'Cookie'],
|
||||||
|
]);
|
||||||
|
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
|
||||||
|
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
|
||||||
|
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectedSummary: falls back to count when names unknown', () => {
|
||||||
|
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* REVIEW-15: owner declares one primary wood category among the selected set.
|
||||||
|
* Default primary = first selected. Deselecting the current primary promotes
|
||||||
|
* the first remaining selected id. Empty selection clears primary.
|
||||||
|
*/
|
||||||
|
export function nextPrimary(
|
||||||
|
selected: number[],
|
||||||
|
current: number | '' | null | undefined,
|
||||||
|
): number | '' {
|
||||||
|
if (!selected.length) return '';
|
||||||
|
const cur = typeof current === 'number' ? current : '';
|
||||||
|
if (cur !== '' && selected.includes(cur)) return cur;
|
||||||
|
return selected[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closed MultiSelect summary: names when known, else a count. */
|
||||||
|
export function selectedSummary(
|
||||||
|
selected: number[],
|
||||||
|
nameById: Map<number, string>,
|
||||||
|
placeholder: string,
|
||||||
|
maxNames = 2,
|
||||||
|
): string {
|
||||||
|
if (!selected.length) return placeholder;
|
||||||
|
const names = selected
|
||||||
|
.map((id) => nameById.get(id))
|
||||||
|
.filter((n): n is string => !!n && n.length > 0);
|
||||||
|
if (!names.length) {
|
||||||
|
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
|
||||||
|
}
|
||||||
|
if (names.length <= maxNames) return names.join(', ');
|
||||||
|
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
|
||||||
|
// the Resume path restores straight to the Photos-ready Measure step — the
|
||||||
|
// deterministic driver for the create→review→publish flow (no live camera/canvas
|
||||||
|
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
|
||||||
|
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
|
||||||
|
// `continueDraft` + `continueToReview` consume it unmodified.
|
||||||
|
//
|
||||||
|
// `source.blob` MUST be a decodable image: restoreFromDraft re-derives the mask
|
||||||
|
// in a Worker that decodes the blob; a failed decode falls back to `mask` (no
|
||||||
|
// results → the Continue button never renders). A 1×1 white PNG decodes fine and
|
||||||
|
// yields slabPx:0 (an empty mask) → rederiveMask resolves → stage reaches measure.
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const KEY = 'slabuploader-draft';
|
||||||
|
|
||||||
|
export interface SeedMeasure {
|
||||||
|
sku: string;
|
||||||
|
lengthIn: number;
|
||||||
|
thicknessIn: number;
|
||||||
|
sqft: number;
|
||||||
|
bdft: number;
|
||||||
|
widthIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEighths = (inches: number) => Math.round(inches * 8);
|
||||||
|
|
||||||
|
export async function seedMeasureDraft(
|
||||||
|
page: Page,
|
||||||
|
d: SeedMeasure,
|
||||||
|
): Promise<void> {
|
||||||
|
const widthEighths = toEighths(d.widthIn);
|
||||||
|
// Build the decodable source PNG in the page via canvas (a fixed base64 1×1
|
||||||
|
// gray+alpha PNG trips Chromium's software decoder in headless), then write
|
||||||
|
// the draft with that blob so restoreFromDraft's worker decode succeeds.
|
||||||
|
await page.evaluate(
|
||||||
|
(payload) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 1;
|
||||||
|
c.height = 1;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fillRect(0, 0, 1, 1);
|
||||||
|
c.toBlob((png) => {
|
||||||
|
if (!png) { reject(new Error('canvas toBlob returned null')); return; }
|
||||||
|
const draft = {
|
||||||
|
version: 1,
|
||||||
|
stage: 'measure',
|
||||||
|
batch: [],
|
||||||
|
calibrationIndex: null,
|
||||||
|
source: { widthPx: 1, heightPx: 1, blob: png },
|
||||||
|
maskParams: { sheet: 'green', sensitivity: 50 },
|
||||||
|
mask: {
|
||||||
|
slabPx: 0,
|
||||||
|
bbox: { corners: [], longSidePx: 0, angleDeg: 0 },
|
||||||
|
slabBounds: { x: 0, y: 0, w: 0, h: 0 },
|
||||||
|
},
|
||||||
|
axisBbox: null,
|
||||||
|
lengthEighths: payload.lengthEighths,
|
||||||
|
thicknessEighths: payload.thicknessEighths,
|
||||||
|
results: {
|
||||||
|
lengthEighths: payload.lengthEighths,
|
||||||
|
thicknessEighths: payload.thicknessEighths,
|
||||||
|
scale: 8,
|
||||||
|
widths: {
|
||||||
|
min: payload.widthEighths,
|
||||||
|
max: payload.widthEighths,
|
||||||
|
avg: payload.widthEighths,
|
||||||
|
every6In: [],
|
||||||
|
},
|
||||||
|
sqft: payload.sqft,
|
||||||
|
bdft: payload.bdft,
|
||||||
|
sourceBelowFloor: false,
|
||||||
|
},
|
||||||
|
inventoryPng: png,
|
||||||
|
overlayPng: null,
|
||||||
|
batchApply: null,
|
||||||
|
};
|
||||||
|
const req = indexedDB.open('keyval-store', 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains('keyval')) db.createObjectStore('keyval');
|
||||||
|
};
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const db = req.result;
|
||||||
|
const tx = db.transaction('keyval', 'readwrite');
|
||||||
|
tx.objectStore('keyval').put(draft, 'slabuploader-draft');
|
||||||
|
tx.oncomplete = () => { db.close(); resolve(); };
|
||||||
|
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
}, 'image/png');
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
lengthEighths: toEighths(d.lengthIn),
|
||||||
|
thicknessEighths: toEighths(d.thicknessIn),
|
||||||
|
widthEighths,
|
||||||
|
sqft: d.sqft,
|
||||||
|
bdft: d.bdft,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +1,486 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect, type Page, type Route } from '@playwright/test';
|
||||||
|
import { seedMeasureDraft } from './helpers/draft';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M3 Create E2E (QUALITY-50): the Create flow screens render and the publish
|
* M3 Review→Publish E2E (issue #77, QUALITY-50/105): drives the one-column
|
||||||
* control is reachable. Runs against the vite preview (webServer in
|
* create→review→publish flow through the UI against a mocked API
|
||||||
* playwright.config). The backend is the local API; Woo is mocked at the
|
* (`page.route` on the whole /api/v1 namespace; no live backend under e2e).
|
||||||
* host level for the integration path (see backend/tests/test_integration_woo_mock.py).
|
*
|
||||||
* This spec verifies the UI surface, not a live store (that is the UAT walk).
|
* The capture→measure path needs real camera/canvas/Vision workers, so the
|
||||||
|
* deterministic driver is the Resume path: seed an in-progress measure draft
|
||||||
|
* (helpers/draft.ts) → Resume → Continue → mocked createSlab/uploadPhoto →
|
||||||
|
* Review → Publish. All ids are synthetic fixtures (QUALITY-105 / METADATA-5).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
test.describe('M3 Create flow', () => {
|
// --- Fixture taxonomy: fixture ids only, parented like the live Woo tree ---
|
||||||
test('renders all M3 dev screens', async ({ page }) => {
|
const SLAB_ID = '5f3c9a2e-0000-4000-8000-0000000000aa';
|
||||||
await page.goto('/');
|
const SPECIES_GROUper_ID = 10; // "Species" grouper (parent_id null)
|
||||||
// The M3 dev toggles are always present in the toolbar.
|
const SPECIES_ID = 11; // Cedar (parent = species grouper)
|
||||||
const toggles = [
|
const WCAT_ID = 20; // "Wood category" grouper (parent null)
|
||||||
'Settings (dev)',
|
const LIVEEDGE_ID = 21; // Live edge (parent = wcat)
|
||||||
'Slabs (dev)',
|
const BOWL_ID = 22; // Bowl blanks (parent = wcat)
|
||||||
'Photos (dev)',
|
const FIG_TAG_ID = 101; // fig-burl product tag
|
||||||
'Review (dev)',
|
const FEAT_TAG_ID = 201; // feat-mineral-streaks product tag
|
||||||
'Publish (dev)',
|
|
||||||
'Generate (dev)',
|
const TAXONOMY = {
|
||||||
|
categories: [
|
||||||
|
{ id: SPECIES_GROUper_ID, name: 'Species', slug: 'species', parent_id: null },
|
||||||
|
{ id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: SPECIES_GROUper_ID },
|
||||||
|
{ id: 12, name: 'Maple', slug: 'maple', parent_id: SPECIES_GROUper_ID },
|
||||||
|
{ id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null },
|
||||||
|
{ id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID },
|
||||||
|
{ id: BOWL_ID, name: 'Bowl blanks', slug: 'bowlblanks', parent_id: WCAT_ID },
|
||||||
|
{ id: 99, name: 'Chairs', slug: 'chairs', parent_id: null },
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{
|
||||||
|
id: 30, name: 'Edge Type', slug: 'edge_type',
|
||||||
|
terms: [
|
||||||
|
{ id: 31, name: 'Live edge', slug: 'live-edge' },
|
||||||
|
{ id: 32, name: 'Square edge', slug: 'square-edge' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 33, name: 'Figure', slug: 'figure',
|
||||||
|
terms: [
|
||||||
|
{ id: 34, name: 'Burl', slug: 'burl' },
|
||||||
|
{ id: 35, name: 'Birdseye', slug: 'birdseye' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 36, name: 'Grade', slug: 'grade',
|
||||||
|
terms: [{ id: 37, name: 'A grade', slug: 'a-grade' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 38, name: 'Thickness', slug: 'thickness',
|
||||||
|
// Real live band names: the Review screen only *displays* the derived band.
|
||||||
|
terms: [
|
||||||
|
{ id: 347, name: 'Under 1/2"', slug: 'xthin' },
|
||||||
|
{ id: 348, name: '1/2" - 3/4"', slug: '2-3qtr' },
|
||||||
|
{ id: 349, name: '3/4" - 1"', slug: '3-4qtr' },
|
||||||
|
{ id: 350, name: '1"- 1 1/2"', slug: '4-6qtr' },
|
||||||
|
{ id: 351, name: '1 1/2" - 2"', slug: '6-8qtr' },
|
||||||
|
{ id: 352, name: '2" - 3"', slug: '8-12qtr' },
|
||||||
|
{ id: 353, name: 'Over 3"', slug: '12qtr' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 40, name: 'Moisture', slug: 'moisture',
|
||||||
|
terms: [{ id: 41, name: 'Kiln dried', slug: 'kiln-dried' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ id: 100, name: 'fig', slug: 'fig' },
|
||||||
|
{ id: FIG_TAG_ID, name: 'fig-burl', slug: 'fig-burl' },
|
||||||
|
{ id: 102, name: 'fig-birdseye', slug: 'fig-birdseye' },
|
||||||
|
{ id: FEAT_TAG_ID, name: 'feat-mineral-streaks', slug: 'feat-mineral-streaks' },
|
||||||
|
{ id: 202, name: 'feat-knots', slug: 'feat-knots' },
|
||||||
|
],
|
||||||
|
taxonomy_anchor: 'fixture',
|
||||||
|
taxonomy_last_sync_at: '2026-01-01T00:00:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
function settingsView(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
woo_base_url: 'https://example.invalid',
|
||||||
|
woo_wp_username: 'wpuser',
|
||||||
|
woo_app_password: { configured: true },
|
||||||
|
inference_base_url: { configured: false },
|
||||||
|
inference_api_key: { configured: false },
|
||||||
|
inference_model: null,
|
||||||
|
inference_enabled: false,
|
||||||
|
content_llm_enabled: false,
|
||||||
|
brand_voice: null,
|
||||||
|
geo_context: null,
|
||||||
|
woo_create_status: 'draft',
|
||||||
|
taxonomy_last_sync_at: '2026-01-01T00:00:00',
|
||||||
|
taxonomy_anchor: 'fixture',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseSlab(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: SLAB_ID,
|
||||||
|
sku: '',
|
||||||
|
status: 'draft',
|
||||||
|
price: null,
|
||||||
|
price_source: 'recommendation',
|
||||||
|
length_in: 48,
|
||||||
|
thickness_in: 2,
|
||||||
|
sqft: 6.25,
|
||||||
|
bdft: 12.5,
|
||||||
|
width_min_in: 10,
|
||||||
|
width_max_in: 12,
|
||||||
|
width_avg_in: 11,
|
||||||
|
species_id: null,
|
||||||
|
wood_category_ids: [],
|
||||||
|
edge_type_term_id: null,
|
||||||
|
figure_term_ids: [],
|
||||||
|
grade_term_id: null,
|
||||||
|
thickness_term_id: 352,
|
||||||
|
moisture_term_id: null,
|
||||||
|
fig_tag_ids: [],
|
||||||
|
feat_tag_ids: [],
|
||||||
|
title: null,
|
||||||
|
short_description: null,
|
||||||
|
description: null,
|
||||||
|
content_source: 'manual',
|
||||||
|
species_confidence: null,
|
||||||
|
figure_confidence: null,
|
||||||
|
grade_confidence: null,
|
||||||
|
inference_status: null,
|
||||||
|
calibration_photo_id: null,
|
||||||
|
woo_product_id: null,
|
||||||
|
server_rev: 0,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiHooks {
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
onPublish?: (route: Route) => Promise<void>;
|
||||||
|
onCall1?: (route: Route) => Promise<void>;
|
||||||
|
onCall2?: (route: Route) => Promise<void>;
|
||||||
|
/** Counts inference endpoints hit (default: record to an array via closure). */
|
||||||
|
calls?: { call1: number; call2: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock the full API for the flow. Slab PUTs are folded into a live row; the
|
||||||
|
* review endpoint recomputes `ready` from the folded row and echoes the primary. */
|
||||||
|
async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||||
|
const calls = hooks.calls ?? { call1: 0, call2: 0 };
|
||||||
|
const slab = baseSlab();
|
||||||
|
let primary = null as number | null;
|
||||||
|
const reviewPrimaries: number[] = [];
|
||||||
|
|
||||||
|
const merge = (payload: Record<string, unknown>) => {
|
||||||
|
// Respect optimistic concurrency: reject stale client_rev (like the real backend).
|
||||||
|
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) {
|
||||||
|
return false; // stale — do NOT merge
|
||||||
|
}
|
||||||
|
for (const k of [
|
||||||
|
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
|
||||||
|
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
|
||||||
|
'title', 'short_description', 'description', 'content_source', 'price', 'price_source',
|
||||||
|
]) {
|
||||||
|
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
|
||||||
|
}
|
||||||
|
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewResult = (payload: Record<string, unknown>) => {
|
||||||
|
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
|
||||||
|
if (typeof payload.price === 'number') slab.price = payload.price;
|
||||||
|
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
|
||||||
|
|
||||||
|
// #89: the review ALWAYS returns the deterministic title/short_description built
|
||||||
|
// from the current metadata (the Create button's source of truth). The server
|
||||||
|
// does NOT persist title/short_description from the review payload.
|
||||||
|
const hasSpecies = !!slab.species_id;
|
||||||
|
const hasWoodCat = Array.isArray(slab.wood_category_ids) && slab.wood_category_ids.length > 0;
|
||||||
|
const deterministicTitle =
|
||||||
|
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab' : null;
|
||||||
|
const deterministicShort =
|
||||||
|
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab, 4ft 0in x 11in x 2in, 12.5 bdft' : null;
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!slab.sku) missing.push('sku');
|
||||||
|
if (!slab.species_id) missing.push('species');
|
||||||
|
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.price == null) missing.push('price');
|
||||||
|
return {
|
||||||
|
ready: missing.length === 0,
|
||||||
|
missing,
|
||||||
|
title: deterministicTitle,
|
||||||
|
short_description: deterministicShort,
|
||||||
|
price: (slab.price as number) ?? null,
|
||||||
|
price_suggestion: 120,
|
||||||
|
primary_wood_category_id: primary,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.route('**/api/v1/**', async (route) => {
|
||||||
|
const req = route.request();
|
||||||
|
const url = new URL(req.url());
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = req.method();
|
||||||
|
const payload = (() => {
|
||||||
|
try { return req.postDataJSON() as Record<string, unknown>; } catch { return {}; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (path === '/api/v1/settings' && method === 'GET') {
|
||||||
|
await json(route, settingsView(hooks.settings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/admin/taxonomy' && method === 'GET') {
|
||||||
|
await json(route, TAXONOMY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/slabs' && method === 'POST') {
|
||||||
|
// The client generates the slab id (crypto.randomUUID, APP-20); adopt it so
|
||||||
|
// the subsequent photo/review/publish paths (keyed by this id) resolve.
|
||||||
|
slab.id = String(payload.id ?? SLAB_ID);
|
||||||
|
merge(payload); // created with capture math; client fields empty
|
||||||
|
await json(route, slab, 201);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
|
||||||
|
if (merge(payload) === false) {
|
||||||
|
await json(route, { error: { code: 'revision_conflict', message: 'client_rev mismatch', detail: { current_server_rev: slab.server_rev } } }, 409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, slab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') {
|
||||||
|
await json(route, slab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
|
||||||
|
// Photo uploads are multipart; `payload` is {} — the client's photo_id is
|
||||||
|
// in the form body, not JSON, so echo a stable synthetic id.
|
||||||
|
await json(route, {
|
||||||
|
id: 'photo-1',
|
||||||
|
slab_id: slab.id,
|
||||||
|
seq: 1,
|
||||||
|
is_calibration: 1,
|
||||||
|
original_retained: false,
|
||||||
|
processed_retained: true,
|
||||||
|
}, 201);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') {
|
||||||
|
const result = reviewResult(payload);
|
||||||
|
if (typeof result.primary_wood_category_id === 'number') {
|
||||||
|
reviewPrimaries.push(result.primary_wood_category_id);
|
||||||
|
}
|
||||||
|
await json(route, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
|
||||||
|
if (hooks.onPublish) { await hooks.onPublish(route); return; }
|
||||||
|
await json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/inference/call1` && method === 'POST') {
|
||||||
|
calls.call1 += 1;
|
||||||
|
if (hooks.onCall1) { await hooks.onCall1(route); return; }
|
||||||
|
await json(route, { ok: false, suggestions: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/inference/call2` && method === 'POST') {
|
||||||
|
calls.call2 += 1;
|
||||||
|
if (hooks.onCall2) { await hooks.onCall2(route); return; }
|
||||||
|
await json(route, { ok: false, description: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, { error: { code: 'not_found', message: path } }, 404);
|
||||||
|
});
|
||||||
|
return { calls, slab, reviewPrimaries };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and
|
||||||
|
// drive Resume → Continue to land in Review.
|
||||||
|
async function reachReview(page: Page) {
|
||||||
|
// Phase 1: boot with ?noload so the app mounts (real origin for IndexedDB)
|
||||||
|
// WITHOUT probing for a draft. Then seed the measure draft into IDB.
|
||||||
|
await page.goto('/?noload');
|
||||||
|
await seedMeasureDraft(page, {
|
||||||
|
sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11,
|
||||||
|
});
|
||||||
|
// Phase 2: reload WITHOUT ?noload so onMount probes IDB and shows Resume.
|
||||||
|
await page.goto('/');
|
||||||
|
// Resume prompt → Continue (restores measure). The mask re-derivation runs in
|
||||||
|
// a worker; wait for the Photos-ready Continue control.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 });
|
||||||
|
// Continue → mocked createSlab + uploadPhoto → Review screen.
|
||||||
|
await page.getByTestId('continue-to-review').click();
|
||||||
|
await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill every ready-gating field to make the slab publishable. */
|
||||||
|
async function completeForReady(page: Page) {
|
||||||
|
await page.getByTestId('species').selectOption(String(SPECIES_ID));
|
||||||
|
// Wood categories: open, select Live edge, Done.
|
||||||
|
await page.getByTestId('wood-cat-btn').click();
|
||||||
|
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||||
|
await page.getByTestId('wood-cat-done').click();
|
||||||
|
await page.getByTestId('edge-type').selectOption('31');
|
||||||
|
await page.getByTestId('price').fill('120');
|
||||||
|
// #89: fire the deterministic build so the title/short_description populate.
|
||||||
|
// Do this BEFORE the SKU blur so a subsequent plain review doesn't race.
|
||||||
|
await expect(page.getByTestId('create-title')).toBeEnabled();
|
||||||
|
await page.getByTestId('create-title').click();
|
||||||
|
// Wait for the build to complete (title field populated = deterministic copy).
|
||||||
|
await expect(page.getByTestId('title')).not.toHaveValue('');
|
||||||
|
await page.getByTestId('sku').fill('SLAB-0001');
|
||||||
|
// SKU commits on blur — press Tab to move focus off it (deterministic, single
|
||||||
|
// commit; a body click can re-focus the SKU and re-fire blur).
|
||||||
|
await page.getByTestId('sku').press('Tab');
|
||||||
|
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('M3 Review→Publish flow (#77)', () => {
|
||||||
|
test('golden path: capture→review→create→ready→publish→done (#77, #89)', async ({ page }) => {
|
||||||
|
const { slab } = await mockApi(page);
|
||||||
|
await reachReview(page);
|
||||||
|
// Not ready until fields are set; copy is empty until the Create build fires.
|
||||||
|
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
|
||||||
|
await expect(page.getByTestId('title')).toHaveValue('');
|
||||||
|
await completeForReady(page);
|
||||||
|
// #89: the Create build populated the deterministic copy on the slab row.
|
||||||
|
expect(slab.title).toBeTruthy();
|
||||||
|
expect(slab.short_description).toBeTruthy();
|
||||||
|
// Continue to Publish is enabled and hands off.
|
||||||
|
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||||
|
await page.getByTestId('continue-publish').click();
|
||||||
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||||
|
// Publish → success → Done panel (draft in UAT store).
|
||||||
|
await page.getByTestId('publish-btn').click();
|
||||||
|
await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByText(/Woo product #4242/)).toBeVisible();
|
||||||
|
await expect(page.getByTestId('new-slab')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wood categories: closed summary shows names; primary radio among selected (#90, REVIEW-15)', async ({ page }) => {
|
||||||
|
const { reviewPrimaries } = await mockApi(page);
|
||||||
|
await reachReview(page);
|
||||||
|
|
||||||
|
// Open wood MultiSelect, pick Live edge + Bowl blanks, Done.
|
||||||
|
await page.getByTestId('wood-cat-btn').click();
|
||||||
|
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||||
|
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
|
||||||
|
await page.getByTestId('wood-cat-done').click();
|
||||||
|
|
||||||
|
// Closed summary shows selected names (not only a count).
|
||||||
|
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Live edge/i);
|
||||||
|
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Bowl blanks/i);
|
||||||
|
|
||||||
|
// Primary set appears with both selected; default primary = first selected.
|
||||||
|
const primarySet = page.getByTestId('primary-wood-set');
|
||||||
|
await expect(primarySet).toBeVisible();
|
||||||
|
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'false');
|
||||||
|
|
||||||
|
// Owner switches primary to Bowl blanks; review payload carries it.
|
||||||
|
await page.getByTestId(`primary-wood-${BOWL_ID}`).click();
|
||||||
|
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'false');
|
||||||
|
await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thickness band is server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
|
||||||
|
await mockApi(page);
|
||||||
|
await reachReview(page);
|
||||||
|
|
||||||
|
// METADATA-15 / QUALITY-95: the band is derived server-side from the measured
|
||||||
|
// thickness against the live synced Woo bands. It is deliberately ABSENT from
|
||||||
|
// this screen — no owner control, no read-only echo. The only thickness the
|
||||||
|
// owner sees is the measured value in the summary. (Commit 204bc5e dropped the
|
||||||
|
// read-only echo; this test asserts the omission.)
|
||||||
|
await expect(page.getByTestId('thickness-band')).toHaveCount(0);
|
||||||
|
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #90 served-surface render check: the changed Review surface (wood-category
|
||||||
|
* MultiSelect + primary-radio set + read-only thickness band) is rendered at
|
||||||
|
* desktop/tablet/mobile widths, measured, and captured to
|
||||||
|
* tests/e2e/artifacts/ so layout and interaction can be inspected as evidence.
|
||||||
|
*/
|
||||||
|
test('Review surface renders at 1440/768/375 with no overflow (#90)', async ({ page }) => {
|
||||||
|
const widths = [
|
||||||
|
{ w: 1440, h: 900, name: 'desktop' },
|
||||||
|
{ w: 768, h: 1024, name: 'tablet' },
|
||||||
|
{ w: 375, h: 812, name: 'mobile' },
|
||||||
];
|
];
|
||||||
for (const name of toggles) {
|
const report: string[] = [];
|
||||||
await expect(page.getByRole('button', { name })).toBeVisible();
|
|
||||||
}
|
for (const vp of widths) {
|
||||||
});
|
await page.setViewportSize({ width: vp.w, height: vp.h });
|
||||||
|
await mockApi(page);
|
||||||
test('Review screen loads the ready-state control', async ({ page }) => {
|
await reachReview(page);
|
||||||
await page.goto('/');
|
// Put the surface in its real state: two wood categories selected, primary set.
|
||||||
await page.getByRole('button', { name: 'Review (dev)' }).click();
|
await page.getByTestId('wood-cat-btn').click();
|
||||||
await expect(page.getByRole('heading', { name: 'Review to Ready' })).toBeVisible();
|
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||||
await expect(page.getByRole('button', { name: 'Review', exact: true })).toBeVisible();
|
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
|
||||||
});
|
await page.getByTestId('wood-cat-done').click();
|
||||||
|
await expect(page.getByTestId('primary-wood-set')).toBeVisible();
|
||||||
test('Publish screen loads the publish control', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
const metrics = await page.evaluate(() => {
|
||||||
await page.getByRole('button', { name: 'Publish (dev)' }).click();
|
const de = document.documentElement;
|
||||||
await expect(page.getByRole('heading', { name: 'Publish to WooCommerce' })).toBeVisible();
|
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
|
||||||
await expect(page.getByRole('button', { name: 'Publish', exact: true })).toBeVisible();
|
const box = (sel: string) => {
|
||||||
});
|
const el = q(sel);
|
||||||
|
if (!el) return null;
|
||||||
test('Generate control is visible only when enabled + reachable', async ({ page }) => {
|
const r = el.getBoundingClientRect();
|
||||||
await page.goto('/');
|
const cs = getComputedStyle(el);
|
||||||
await page.getByRole('button', { name: 'Generate (dev)' }).click();
|
return {
|
||||||
await expect(page.getByRole('heading', { name: 'Generate text' })).toBeVisible();
|
w: Math.round(r.width), h: Math.round(r.height),
|
||||||
// With no inference configured, the control is disabled with a plain note.
|
fontSize: cs.fontSize,
|
||||||
const note = page.locator('.note');
|
overflowX: el.scrollWidth > el.clientWidth + 1,
|
||||||
const state = page.locator('.state');
|
text: (el.textContent || '').trim().slice(0, 80),
|
||||||
await expect(state).toContainText(/endpoint: (reachable|unreachable)/);
|
};
|
||||||
const text = (await state.textContent()) ?? '';
|
};
|
||||||
if (text.includes('unreachable')) {
|
const primaryRow = q('[data-testid^="primary-wood-"]');
|
||||||
await expect(note).toBeVisible();
|
const primaryRowBox = primaryRow ? primaryRow.getBoundingClientRect() : null;
|
||||||
|
// Anything whose box pokes outside the viewport, with enough identity to fix it.
|
||||||
|
const offenders: { tag: string; cls: string; id: string; left: number; right: number }[] = [];
|
||||||
|
document.querySelectorAll('body *').forEach((el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width === 0 && r.height === 0) return;
|
||||||
|
if (r.right > de.clientWidth + 1 || r.left < -1) {
|
||||||
|
offenders.push({
|
||||||
|
tag: el.tagName.toLowerCase(),
|
||||||
|
cls: String(el.className || '').slice(0, 48),
|
||||||
|
id: el.id,
|
||||||
|
left: Math.round(r.left),
|
||||||
|
right: Math.round(r.right),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
docScrollW: de.scrollWidth,
|
||||||
|
docClientW: de.clientWidth,
|
||||||
|
pageHasHScroll: de.scrollWidth > de.clientWidth + 1,
|
||||||
|
offenders: offenders.slice(0, 8),
|
||||||
|
summary: box('[data-testid="wood-cat-summary"]'),
|
||||||
|
count: box('[data-testid="wood-cat-count"]'),
|
||||||
|
band: box('[data-testid="thickness-band"]'),
|
||||||
|
primarySet: box('[data-testid="primary-wood-set"]'),
|
||||||
|
primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null,
|
||||||
|
createBtn: box('[data-testid="create-title"]'),
|
||||||
|
continueBtn: box('[data-testid="continue-publish"]'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const shot = `tests/e2e/artifacts/review-90-${vp.name}-${vp.w}.png`;
|
||||||
|
await page.screenshot({ path: shot, fullPage: true });
|
||||||
|
report.push(`${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
|
||||||
|
// Print before asserting so the measurement survives a failing gate.
|
||||||
|
console.log(`RENDER ${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
|
||||||
|
|
||||||
|
// Layout gates: nothing spills sideways, the closed summary names both picks
|
||||||
|
// (CSS ellipsis may shorten the paint, but the text must be there), and the
|
||||||
|
// primary rows stay a comfortable tap target on mobile.
|
||||||
|
expect(metrics.pageHasHScroll, `${vp.name} horizontal overflow: ${metrics.docScrollW} > ${metrics.docClientW}`).toBe(false);
|
||||||
|
expect(metrics.summary?.text).toMatch(/Live edge/i);
|
||||||
|
expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
|
||||||
|
expect(metrics.count?.text).toContain('2 selected');
|
||||||
|
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
|
||||||
|
// Band is deliberately absent from Review (METADATA-15) — no element to measure.
|
||||||
|
expect(metrics.band).toBeNull();
|
||||||
|
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
|
||||||
}
|
}
|
||||||
|
console.log('RENDER REPORT\n' + report.join('\n'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { test, expect, type Page, type Route } from '@playwright/test';
|
||||||
|
import { seedMeasureDraft } from './helpers/draft';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3 Review photo strip E2E (issue #90, REVIEW-5 / PUBLISH-45). Drives the
|
||||||
|
* capture→review flow (mocked API, no live backend) and exercises the photo
|
||||||
|
* strip: render from stored photos, keep/remove (whole-card tap), drag-reorder,
|
||||||
|
* leftmost-kept = primary, the clean+overlay minimum, and persistLineup on
|
||||||
|
* "Continue to Publish". All ids are synthetic fixtures (QUALITY-105).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SLAB_ID = '90e00000-0000-4000-8000-000000000090';
|
||||||
|
const SPECIES_ID = 11;
|
||||||
|
const WCAT_ID = 20;
|
||||||
|
const LIVEEDGE_ID = 21;
|
||||||
|
|
||||||
|
const TAXONOMY = {
|
||||||
|
categories: [
|
||||||
|
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
|
||||||
|
{ id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: 10 },
|
||||||
|
{ id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null },
|
||||||
|
{ id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID },
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{ id: 30, name: 'Edge Type', slug: 'edge_type', terms: [{ id: 31, name: 'Live edge', slug: 'live-edge' }] },
|
||||||
|
],
|
||||||
|
tags: [],
|
||||||
|
taxonomy_anchor: 'fixture',
|
||||||
|
taxonomy_last_sync_at: '2026-01-01T00:00:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The stored lineup: clean cal (seq 1), dimension-overlay (seq 2), batch (seq 3).
|
||||||
|
const PHOTO_ROWS = [
|
||||||
|
{ id: 'ph-cal', seq: 1, is_calibration: 1, role: 'calibration', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
|
||||||
|
{ id: 'ph-ov', seq: 2, is_calibration: 0, role: 'overlay', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
|
||||||
|
{ id: 'ph-b1', seq: 3, is_calibration: 0, role: 'batch', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function baseSlab(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: SLAB_ID, sku: '', status: 'draft', price: null, price_source: 'recommendation',
|
||||||
|
length_in: 48, thickness_in: 2, sqft: 6.25, bdft: 12.5, width_min_in: 10, width_max_in: 12, width_avg_in: 11,
|
||||||
|
species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null,
|
||||||
|
thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
|
||||||
|
title: null, short_description: null, description: null, content_source: 'manual',
|
||||||
|
species_confidence: null, figure_confidence: null, grade_confidence: null,
|
||||||
|
inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0,
|
||||||
|
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistRecord {
|
||||||
|
seq: number; role: string; is_calibration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockApi(page: Page) {
|
||||||
|
const slab = baseSlab();
|
||||||
|
const photoStore = new Map<string, typeof PHOTO_ROWS[number]>();
|
||||||
|
for (const r of PHOTO_ROWS) photoStore.set(r.id, { ...r });
|
||||||
|
const persistCalls: { photoId: string; seq: number; role: string }[] = [];
|
||||||
|
const deletes: string[] = [];
|
||||||
|
|
||||||
|
const merge = (payload: Record<string, unknown>) => {
|
||||||
|
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false;
|
||||||
|
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'edge_type_term_id']) {
|
||||||
|
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
|
||||||
|
}
|
||||||
|
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewResult = (payload: Record<string, unknown>) => {
|
||||||
|
if (typeof payload.price === 'number') slab.price = payload.price;
|
||||||
|
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!slab.sku) missing.push('sku');
|
||||||
|
if (!slab.species_id) missing.push('species');
|
||||||
|
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
|
||||||
|
if (!slab.edge_type_term_id) missing.push('edge type');
|
||||||
|
if (!slab.title) missing.push('title');
|
||||||
|
if (!slab.short_description) missing.push('short description');
|
||||||
|
if (slab.price == null) missing.push('price');
|
||||||
|
// Deterministic copy from the server on lock (REVIEW-15) — set before ready.
|
||||||
|
if (!slab.title) slab.title = 'Cedar · 48″ Live Edge';
|
||||||
|
if (!slab.short_description) slab.short_description = 'Hand-cut slab, ready to ship.';
|
||||||
|
return {
|
||||||
|
ready: missing.length === 0, missing,
|
||||||
|
title: (slab.title as string) ?? null,
|
||||||
|
short_description: (slab.short_description as string) ?? null,
|
||||||
|
price: (slab.price as number) ?? null, price_suggestion: 120,
|
||||||
|
primary_wood_category_id: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.route('**/api/v1/**', async (route) => {
|
||||||
|
const req = route.request();
|
||||||
|
const url = new URL(req.url());
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = req.method();
|
||||||
|
const payload = (() => { try { return req.postDataJSON() as Record<string, unknown>; } catch { return {}; } })();
|
||||||
|
|
||||||
|
if (path === '/api/v1/settings' && method === 'GET') {
|
||||||
|
return json(route, { woo_base_url: 'https://example.invalid', woo_wp_username: 'wp', woo_app_password: { configured: true }, inference_base_url: { configured: false }, inference_api_key: { configured: false }, inference_model: null, inference_enabled: false, content_llm_enabled: false, brand_voice: null, geo_context: null, woo_create_status: 'draft', taxonomy_last_sync_at: '2026-01-01T00:00:00', taxonomy_anchor: 'fixture' });
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/admin/taxonomy' && method === 'GET') return json(route, TAXONOMY);
|
||||||
|
if (path === '/api/v1/slabs' && method === 'POST') {
|
||||||
|
slab.id = String(payload.id ?? SLAB_ID);
|
||||||
|
merge(payload);
|
||||||
|
return json(route, slab, 201);
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
|
||||||
|
if (merge(payload) === false) return json(route, { error: { code: 'revision_conflict', message: 'rev', detail: {} } }, 409);
|
||||||
|
return json(route, slab);
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') return json(route, slab);
|
||||||
|
|
||||||
|
// --- Photos (the strip) ---
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
|
||||||
|
return json(route, [...photoStore.values()].sort((a, b) => a.seq - b.seq));
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
|
||||||
|
// Multipart; pull the photo_id / seq / role form fields out of the body.
|
||||||
|
const raw = req.postData() ?? '';
|
||||||
|
const field = (name: string): string => {
|
||||||
|
const re = new RegExp(`name="${name}"\\r\\n\\r\\n([\\s\\S]*?)(?=$|--)`);
|
||||||
|
const m = raw.match(re);
|
||||||
|
return m ? m[1].trim() : '';
|
||||||
|
};
|
||||||
|
const photoId = field('photo_id');
|
||||||
|
const seq = Number(field('seq'));
|
||||||
|
const role = field('role') || 'batch';
|
||||||
|
// Capture-time uploads (photo_id not in the fixture store, e.g. "photo-1")
|
||||||
|
// are accepted but not added to the lineup — the strip is driven by the
|
||||||
|
// stored rows only. Review-strip re-uploads reference a known fixture id.
|
||||||
|
if (!photoStore.has(photoId)) {
|
||||||
|
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
|
||||||
|
}
|
||||||
|
persistCalls.push({ photoId, seq, role });
|
||||||
|
const existing = photoStore.get(photoId);
|
||||||
|
if (existing) {
|
||||||
|
existing.seq = seq;
|
||||||
|
existing.role = role as typeof existing.role;
|
||||||
|
}
|
||||||
|
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
|
||||||
|
}
|
||||||
|
const delMatch = path.match(new RegExp(`^/api/v1/slabs/${slab.id}/photos/([^/]+)$`));
|
||||||
|
if (delMatch && method === 'DELETE') {
|
||||||
|
const id = delMatch[1];
|
||||||
|
if (photoStore.delete(id)) { deletes.push(id); return route.fulfill({ status: 204, body: '' }); }
|
||||||
|
return json(route, { error: { code: 'not_found', message: 'Photo not found', detail: null } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') return json(route, reviewResult(payload));
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
|
||||||
|
return json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
|
||||||
|
}
|
||||||
|
return json(route, { error: { code: 'not_found', message: path } }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { persistCalls, deletes, photoStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reachReview(page: Page) {
|
||||||
|
await page.goto('/?noload');
|
||||||
|
await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11 });
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 });
|
||||||
|
await page.getByTestId('continue-to-review').click();
|
||||||
|
await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeForReady(page: Page) {
|
||||||
|
await page.getByTestId('species').selectOption(String(SPECIES_ID));
|
||||||
|
await page.getByTestId('wood-cat-btn').click();
|
||||||
|
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||||
|
await page.getByTestId('wood-cat-done').click();
|
||||||
|
await page.getByTestId('edge-type').selectOption('31');
|
||||||
|
await page.getByTestId('price').fill('120');
|
||||||
|
await expect(page.getByTestId('create-title')).toBeEnabled();
|
||||||
|
await page.getByTestId('create-title').click();
|
||||||
|
await expect(page.getByTestId('title')).not.toHaveValue('');
|
||||||
|
await page.getByTestId('sku').fill('SLAB-0001');
|
||||||
|
await page.getByTestId('sku').press('Tab');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('M3 Review photo strip (#90)', () => {
|
||||||
|
test('renders stored photos; leftmost kept = primary; tap keep/remove; drag reorder; minimum; persist', async ({ page }) => {
|
||||||
|
const mock = await mockApi(page);
|
||||||
|
await reachReview(page);
|
||||||
|
|
||||||
|
// Strip renders the 3 stored photos in stored seq order.
|
||||||
|
const cards = page.getByTestId('photo-card');
|
||||||
|
await expect(cards).toHaveCount(3);
|
||||||
|
// Leftmost (cal, seq 1) is the primary.
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
||||||
|
await expect(page.getByText('PRIMARY', { exact: true })).toHaveCount(1);
|
||||||
|
|
||||||
|
// Bug #1 regression: the primary photo's <img src> must be a valid URL
|
||||||
|
// (blob: for an in-memory capture blob), never empty or "object Blob".
|
||||||
|
const primaryImg = cards.nth(0).locator('img');
|
||||||
|
await expect(primaryImg).not.toHaveAttribute('src', /object Blob|^$/);
|
||||||
|
const src = await primaryImg.getAttribute('src');
|
||||||
|
expect(src && src.length > 4).toBeTruthy();
|
||||||
|
|
||||||
|
// Bug #2 regression: the card carries the gray checkerboard background
|
||||||
|
// (matching the Capture lineup), not a flat color.
|
||||||
|
const bgImage = await cards.nth(0).evaluate((el) => getComputedStyle(el).backgroundImage);
|
||||||
|
expect(bgImage).toContain('45deg');
|
||||||
|
|
||||||
|
// Mandatory photos: the clean calibration (seq 1) and the dimension-overlay
|
||||||
|
// copy (seq 2) are required — their keep checkbox is checked AND disabled,
|
||||||
|
// and a REQUIRED tag marks them.
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('data-required', 'true');
|
||||||
|
await expect(cards.nth(1)).toHaveAttribute('data-required', 'true');
|
||||||
|
await expect(cards.nth(2)).toHaveAttribute('data-required', '');
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(cards.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect(cards.nth(1)).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect(cards.nth(2)).toHaveAttribute('aria-disabled', 'false');
|
||||||
|
// Two REQUIRED tags (cal + overlay), none on the batch photo.
|
||||||
|
await expect(page.getByText('REQUIRED', { exact: true })).toHaveCount(2);
|
||||||
|
|
||||||
|
// Tapping a mandatory photo is a no-op: it stays checked + primary (can't remove).
|
||||||
|
// The card is aria-disabled, so force the click to exercise the handler directly.
|
||||||
|
await cards.nth(0).click({ force: true });
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
||||||
|
// No minimum nudge — both required photos are kept by construction.
|
||||||
|
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Remove the OPTIONAL batch photo (index 2) → it un-keeps; required stay kept.
|
||||||
|
await cards.nth(2).click();
|
||||||
|
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
// Primary is still the leftmost required (cal, index 0).
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
||||||
|
// Re-add the batch photo.
|
||||||
|
await cards.nth(2).click();
|
||||||
|
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Drag the batch photo (index 2) to the front → it becomes the primary.
|
||||||
|
await cards.nth(2).scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
const first = await cards.nth(0).boundingBox();
|
||||||
|
const third = await cards.nth(2).boundingBox();
|
||||||
|
if (first && third) {
|
||||||
|
const sx = third.x + third.width / 2, sy = third.y + third.height / 2;
|
||||||
|
const dx = first.x + first.width / 2, dy = first.y + first.height / 2;
|
||||||
|
await page.mouse.move(sx, sy);
|
||||||
|
await page.mouse.down();
|
||||||
|
// Several small steps so the HTML5 DnD picks up the drag.
|
||||||
|
for (let i = 1; i <= 14; i++) {
|
||||||
|
await page.mouse.move(sx + (dx - sx) * (i / 14), sy + (dy - sy) * (i / 14));
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(60);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
// After reorder the batch is leftmost = primary; seq badges renumber.
|
||||||
|
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
|
||||||
|
|
||||||
|
// Fill fields to ready, then Continue → persistLineup runs (re-seq kept, drop none).
|
||||||
|
await completeForReady(page);
|
||||||
|
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
|
||||||
|
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||||
|
await page.getByTestId('continue-publish').click();
|
||||||
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||||
|
|
||||||
|
// Persisted: 3 re-uploads (re-seq 1..3), no deletes (nothing was removed in final state).
|
||||||
|
expect(mock.persistCalls.length).toBe(3);
|
||||||
|
const seqs = mock.persistCalls.map((c) => c.seq).sort((a, b) => a - b);
|
||||||
|
expect(seqs).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('required photos guarantee the minimum; removing an OPTIONAL batch persists a DELETE', async ({ page }) => {
|
||||||
|
const mock = await mockApi(page);
|
||||||
|
await reachReview(page);
|
||||||
|
const cards = page.getByTestId('photo-card');
|
||||||
|
|
||||||
|
// The clean calibration + dimension-overlay are mandatory and always kept,
|
||||||
|
// so the submission minimum can never be broken — publish is never lineup-blocked.
|
||||||
|
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Removing an OPTIONAL batch photo is allowed and persists a DELETE.
|
||||||
|
await cards.nth(2).click();
|
||||||
|
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
// Still no nudge: both required photos remain kept.
|
||||||
|
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Complete the rest of the form; the lineup is valid (required photos kept).
|
||||||
|
await completeForReady(page);
|
||||||
|
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
|
||||||
|
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||||
|
await page.getByTestId('continue-publish').click();
|
||||||
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||||
|
|
||||||
|
// The removed batch photo persisted as a DELETE; the required photos re-uploaded.
|
||||||
|
expect(mock.deletes).toEqual(['ph-b1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user