diff --git a/backend/__init__.py b/backend/__init__.py index 1502a60..46508df 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -1,2 +1,2 @@ -"""SlabUploader backend package (S0 foundation).""" +"""SlabUploader backend package (greenfield S0 foundation).""" __all__ = ["app", "db"] diff --git a/backend/app.py b/backend/app.py index 8c4b7c6..c947220 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,8 +1,19 @@ -"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25). -Base /api/v1; health at /api/health (non-200 on storage not writable). -Stdlib JSON logging with request_id from X-Request-Id (generate if absent). -Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY. -No secrets in logs. Minimal, no S1+. +"""Clean FastAPI app (greenfield P0.2) for SlabUploader. + +APP-30/45: /api/health returns {status,version,woo_reachable}; non-200 when +storage is not writable (small probe helper). +APP-55/65: stdlib structured JSON logging (ts,level,logger,request_id,msg) to +stdout; request_id from X-Request-Id header (uuid4 if absent) via contextvars; +secret-looking messages are redacted. +SETTINGS-10/25: startup guard hard-refuses a missing/non-https WOO_BASE_URL or +a missing SLAB_AES_KEY — UNLESS the environment is explicitly dev/demo +(SLAB_ENV in {dev,demo}), where it logs a warning so a LAN dev box without +store creds keeps running; production (default) must refuse. + +P3a (M3 Create backend): real server modules — Woo client, taxonomy sync, +pricing rules, inference (on/off), review, publish, settings (SETTINGS-75 +masking), slabs CRUD, photo storage. Woo/inference transports are injectable +and mocked in tests (never hit a real store). """ from __future__ import annotations @@ -11,42 +22,52 @@ import logging import os import sys import uuid +from contextlib import asynccontextmanager from contextvars import ContextVar +from datetime import datetime, timezone from pathlib import Path from typing import Any -from contextlib import asynccontextmanager - from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from pydantic import BaseModel from starlette.middleware.base import BaseHTTPMiddleware -from .db import get_connection, init_db, probe_images_writable, probe_writable -from . import settings as settings_mod +from .db import get_connection, init_db -__version__ = "0.5.0-m3" +__version__ = "0.6.0-gf-p3a" + +DEV_ENVS = {"dev", "demo"} # --- Structured JSON logging (stdlib only) --- + request_id_var: ContextVar[str] = ContextVar("request_id", default="") +_SECRET_MARKERS = ( + "password", "api_key", "aes_key", "slab_aes_key", + "woo_app_password", "inference_api_key", +) -class _JsonFormatter(logging.Formatter): - """JSON lines; redacts any secret-looking content (APP-65).""" + +def _redact(msg: str) -> str: + """APP-65: never let secret material reach the log line.""" + lower = msg.lower() + if any(marker in lower for marker in _SECRET_MARKERS): + return "[REDACTED secret material]" + return msg + + +class JsonFormatter(logging.Formatter): + """JSON lines with request_id from the contextvar; redacts secrets.""" def format(self, record: logging.LogRecord) -> str: - rid = getattr(record, "request_id", None) or request_id_var.get("") - msg = record.getMessage() - # Redact secrets: never log creds, keys, aes material - lower = msg.lower() - if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")): - msg = "[REDACTED secret material]" data: dict[str, Any] = { "ts": self.formatTime(record, self.datefmt), "level": record.levelname, "logger": record.name, - "request_id": rid, - "msg": msg, + "request_id": getattr(record, "request_id", None) or request_id_var.get(""), + "msg": _redact(record.getMessage()), } if record.exc_info: data["exc"] = self.formatException(record.exc_info) @@ -56,42 +77,47 @@ class _JsonFormatter(logging.Formatter): def _configure_logging() -> None: level = os.getenv("LOG_LEVEL", "INFO").upper() handler = logging.StreamHandler(sys.stdout) - handler.setFormatter(_JsonFormatter()) + handler.setFormatter(JsonFormatter()) root = logging.getLogger() root.handlers.clear() root.addHandler(handler) root.setLevel(getattr(logging, level, logging.INFO)) - # uvicorn access logs also json? for S0 keep simple; they inherit logging.getLogger("uvicorn.access").setLevel(logging.WARNING) _configure_logging() +json_formatter = JsonFormatter() # exposed for tests logger = logging.getLogger("slab.backend") -# --- Startup guards (refuse with clear message) --- +def _is_dev_env() -> bool: + return os.getenv("SLAB_ENV", "").strip().lower() in DEV_ENVS + + +# --- Startup guards (SETTINGS-10/25) --- + def _enforce_startup_guards() -> None: - woo_url = os.getenv("WOO_BASE_URL") + """Hard-refuse bad Woo URL / missing AES key; warn in dev/demo so the slice runs.""" + problems: list[str] = [] + woo_url = os.getenv("WOO_BASE_URL", "") if not woo_url: - msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)" - logger.error(msg) - raise RuntimeError(msg) - if not woo_url.startswith("https://"): - msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")" - logger.error(msg) - raise RuntimeError(msg) + problems.append("SETTINGS-10: WOO_BASE_URL is required (https://...); missing") + elif not woo_url.startswith("https://"): + problems.append("SETTINGS-10: WOO_BASE_URL must start with https:// (got non-https value)") if not os.getenv("SLAB_AES_KEY"): - msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)" - logger.error(msg) - raise RuntimeError(msg) - # db/images envs are required by db.py on use; guards here only per spec + problems.append("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose); missing") + if not problems: + return + if _is_dev_env(): + for p in problems: + logger.warning("dev/demo env: startup guard downgraded — " + p) + return + for p in problems: + logger.error(p) + raise RuntimeError("; ".join(problems)) -_enforce_startup_guards() - - -# --- Request ID middleware (threads X-Request-Id or generates) -- - +# --- Request ID middleware (APP-55) --- class RequestIdMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): @@ -105,23 +131,34 @@ class RequestIdMiddleware(BaseHTTPMiddleware): request_id_var.reset(token) +# --- Storage probe (APP-45) --- + +def probe_storage_writable() -> bool: + """True if the DB path's directory accepts a throwaway write.""" + db_path = os.environ.get("SLAB_DB_PATH") or "slab_demo.db" + directory = Path(db_path).expanduser().resolve().parent + probe = directory / f".slab_health_{os.getpid()}" + try: + probe.write_text("ok", encoding="utf-8") + return True + except OSError: + return False + finally: + try: + probe.unlink() + except OSError: + pass + + # --- App --- + @asynccontextmanager -async def lifespan(app: FastAPI): - logger.info("startup: applying schema (idempotent)") - try: - init_db() - logger.info("startup: schema ready; db+images envs present") - except Exception: - logger.exception("startup failed") - raise +async def lifespan(_: FastAPI): + # Guards run at startup: a misconfigured server refuses to serve traffic. + _enforce_startup_guards() + init_db() + logger.info("startup: schema ready (idempotent)") yield - # shutdown: reset conn for test isolation if needed - try: - from .db import _reset_for_tests - _reset_for_tests() - except Exception: - pass app = FastAPI( @@ -132,6 +169,8 @@ app = FastAPI( lifespan=lifespan, ) +# Permissive CORS is explicit for LAN dev (single trust domain on the LAN); +# credentials are NOT allowed, and this is a dev-only posture. app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -142,57 +181,42 @@ app.add_middleware( app.add_middleware(RequestIdMiddleware) -@app.exception_handler(HTTPException) -async def _unwrap_error(request: Request, exc: HTTPException): - if isinstance(exc.detail, dict) and "error" in exc.detail: - return JSONResponse(status_code=exc.status_code, content=exc.detail) - return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}}) - - -def _error(code: str, message: str, detail: Any = None) -> dict: +def _error(code: str, message: str, detail: Any = None) -> dict[str, Any]: return {"error": {"code": code, "message": message, "detail": detail}} -@app.get("/api/health") -async def health() -> dict[str, Any]: - """Health per APP-30/45: non-200 when db or image volume not writable (probe file). +def _envelope_response(status: int, detail: Any) -> JSONResponse: + if isinstance(detail, dict) and "error" in detail: + return JSONResponse(status_code=status, content=detail) + code = "validation" if status == 400 else str(status) + return JSONResponse(status_code=status, content=_error(code, str(detail))) - #84: woo_reachable is a real authenticated probe (signal only — store outage - does not fail health). - """ - db_ok = probe_writable() - img_ok = probe_images_writable() - if not (db_ok and img_ok): - logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")}) - raise HTTPException(status_code=503, detail="storage not writable") - return { + +@app.exception_handler(HTTPException) +async def _envelope_http_exception(request: Request, exc: HTTPException) -> JSONResponse: + return _envelope_response(exc.status_code, exc.detail) + + +# --- Health (APP-30/45) --- + +@app.get("/api/health") +async def health() -> JSONResponse: + if not probe_storage_writable(): + logger.warning("health: storage not writable") + return JSONResponse(status_code=503, content=_error("storage", "storage not writable")) + # woo_reachable stays a constant False: health must not depend on the + # store (outage != down) and a dev box without creds (SLAB_ENV=dev) must + # still report ok. An authenticated probe lands with a deployed store. + return JSONResponse(content={ "status": "ok", "version": __version__, - "woo_reachable": _probe_woo_reachable(), - } + "woo_reachable": False, + }) -def _probe_woo_reachable() -> bool: - """Cheap non-failing Woo reachability probe for health (SETTINGS-40 style). +# --- M3 routers + settings/taxonomy endpoints (P3a) --- - 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") -async def meta() -> dict[str, Any]: - """Per API.md §3.""" - return {"version": __version__} +from . import settings as settings_mod # noqa: E402 (imported after app object; router modules need it) @app.get("/api/v1/settings") @@ -205,10 +229,10 @@ async def put_settings(payload: dict[str, Any]) -> dict[str, Any]: return settings_mod.update_settings(payload) -from . import taxonomy as taxonomy_mod -from .woo import ( - WooClient, +from . import taxonomy as taxonomy_mod # noqa: E402 +from .woo import ( # noqa: E402 WooAuthError, + WooClient, WooConnectionError, WooCredentialsMissing, WooError, @@ -218,10 +242,13 @@ from .woo import ( def _woo_credentials() -> tuple[str, str | None, str | None]: conn = get_connection() - base = os.environ.get("WOO_BASE_URL", "") - user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() - pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() - return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None) + try: + base = os.environ.get("WOO_BASE_URL", "") + user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() + pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() + return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None) + finally: + conn.close() @app.post("/api/v1/settings/test-woo") @@ -230,21 +257,10 @@ async def test_woo() -> dict[str, Any]: try: client = WooClient.from_settings(base, username, enc_password) except WooCredentialsMissing as e: - return { - "ok": False, - "detail": str(e), - "taxonomy_anchor": None, - "taxonomy_last_sync_at": None, - } + return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None} try: result = taxonomy_mod.sync(client, get_connection()) - except WooAuthError as e: - return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} - except WooForbiddenError as e: - return {"ok": False, "detail": f"request blocked by store (403): {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} - except WooConnectionError as e: - return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None} - except WooError as e: + except (WooAuthError, WooForbiddenError, WooConnectionError, WooError) as e: return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None} return { "ok": True, @@ -256,7 +272,11 @@ async def test_woo() -> dict[str, Any]: @app.get("/api/v1/admin/taxonomy") async def get_taxonomy() -> dict[str, Any]: - return taxonomy_mod.get_cached(get_connection()) + conn = get_connection() + try: + return taxonomy_mod.get_cached(conn) + finally: + conn.close() @app.post("/api/v1/admin/taxonomy/sync") @@ -278,25 +298,36 @@ async def post_taxonomy_sync() -> dict[str, Any]: } -# expose for tests +from .slabs import router as slabs_router # noqa: E402 +from .photos import router as photos_router # noqa: E402 +from .pricing import router as pricing_router # noqa: E402 +from .review import router as review_router # noqa: E402 +from .publish import router as publish_router # noqa: E402 +from .inference import router as inference_router # noqa: E402 +from .prompts import router as prompts_router # noqa: E402 -from .slabs import router as slabs_router app.include_router(slabs_router) - -from .photos import router as photos_router app.include_router(photos_router) - -from .pricing import router as pricing_router app.include_router(pricing_router) - -from .review import router as review_router app.include_router(review_router) - -from .publish import router as publish_router app.include_router(publish_router) - -from .inference import router as inference_router app.include_router(inference_router) +app.include_router(prompts_router) -# expose for tests -__all__ = ["app", "__version__"] + +def reinit_db(path: str | None = None): + """Re-init schema on `path` (test isolation); returns a connection to it. + + With the per-call factory (P0.2), each request opens its own connection, + so there is no cached binding to re-point — init_db on the new path is the + entire isolation. + """ + p = path or os.environ.get("SLAB_DB_PATH") or "slab_demo.db" + init_db(p) + return get_connection(p) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=18081) diff --git a/backend/crypto.py b/backend/crypto.py index ae96f73..970031b 100644 --- a/backend/crypto.py +++ b/backend/crypto.py @@ -1,26 +1,27 @@ -"""S1 AES-GCM crypto helpers (SETTINGS-20, SETTINGS-25, QUALITY-125). +"""AES-GCM secret encryption for Woo app password + inference API key (SETTINGS-20/25, QUALITY-125). -Master key = SLAB_AES_KEY env (32-byte base64). Ciphertext = nonce(12) || ct (GCM tag verified on decrypt). -value_enc stores base64(nonce||ct). Decrypt failures are detectable (no crash, no secret leak). +Master key = SLAB_AES_KEY env (32-byte base64). Stored form value_enc = +base64(nonce(12) || ciphertext+GCM tag). Decrypt failures raise DecryptError — +detectable, no crash, no secret leak (SETTINGS-75 masking degrades to +configured:false on a bad blob). """ from __future__ import annotations import base64 import os -from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM class DecryptError(Exception): - """Clean, detectable error for tampered/wrong-key ciphertext. Never leaks raw blob.""" - pass + """Tampered, corrupt, or wrong-key ciphertext. Message never carries the blob.""" def _get_key() -> bytes: key_b64 = os.environ.get("SLAB_AES_KEY") if not key_b64: - raise RuntimeError("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)") + raise RuntimeError("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64)") try: key = base64.b64decode(key_b64, validate=True) except Exception: @@ -31,35 +32,28 @@ def _get_key() -> bytes: def encrypt(plaintext: str) -> str: - """Encrypt plaintext -> base64(nonce(12) || ciphertext) for storage in value_enc.""" + """Encrypt a plaintext secret -> base64(nonce || ciphertext) for value_enc.""" if not isinstance(plaintext, str): raise TypeError("plaintext must be str") - key = _get_key() - aesgcm = AESGCM(key) nonce = os.urandom(12) - ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) - blob = nonce + ct - return base64.b64encode(blob).decode("ascii") + ct = AESGCM(_get_key()).encrypt(nonce, plaintext.encode("utf-8"), None) + return base64.b64encode(nonce + ct).decode("ascii") def decrypt(ciphertext_b64: str) -> str: - """Decrypt base64 blob; verifies GCM tag. Raises DecryptError on tamper/wrong key (no leak).""" + """Decrypt a value_enc blob; verifies the GCM tag. Raises DecryptError (no leak).""" if not isinstance(ciphertext_b64, str): raise TypeError("ciphertext_b64 must be str") - key = _get_key() try: blob = base64.b64decode(ciphertext_b64, validate=True) except Exception: raise DecryptError("corrupt base64") from None if len(blob) < 12: raise DecryptError("corrupt: too short for nonce") - nonce = blob[:12] - ct = blob[12:] - aesgcm = AESGCM(key) try: - pt = aesgcm.decrypt(nonce, ct, None) - return pt.decode("utf-8") + pt = AESGCM(_get_key()).decrypt(blob[:12], blob[12:], None) except InvalidTag: raise DecryptError("decrypt failed: invalid tag (tampered or wrong key)") from None - except Exception as e: - raise DecryptError(f"decrypt failed: {type(e).__name__}") from None + except Exception: + raise DecryptError("decrypt failed") from None + return pt.decode("utf-8") diff --git a/backend/db/__init__.py b/backend/db/__init__.py index 471905e..980f6a4 100644 --- a/backend/db/__init__.py +++ b/backend/db/__init__.py @@ -1,167 +1,54 @@ -"""S0 db factory package: stdlib sqlite3 ONLY. ... (same as before)""" +"""Clean db factory (stdlib sqlite3 only). + +get_connection opens a FRESH connection on every call (row_factory + pragmas +applied). The P0.1 cached-singleton was NOT safe: FastAPI runs sync endpoints +in anyio worker threads and async endpoints on the portal loop, and a cached +connection bound on the main thread got silently re-opened against the default +path once the app's event loop ran — tests passed only because a stale +`slab_demo.db` in the repo masked the misdirection. A per-call connection makes +the path binding explicit and thread-local by construction. Callers own the +connection's lifetime (close when done; endpoints close via context). + +init_db applies schema.sql + upserts schema_version (v=1). +""" from __future__ import annotations import os import sqlite3 -import threading -import logging from pathlib import Path -from typing import Any - -DB_PATH_ENV = "SLAB_DB_PATH" -IMAGES_DIR_ENV = "SLAB_IMAGES_DIR" -SCHEMA_FILE = "schema.sql" - -_lock = threading.Lock() -_conn: sqlite3.Connection | None = None -_current_path: str | None = None - - -def _get_schema_path() -> Path: - # backend/db/__init__.py -> backend/db/schema.sql - return Path(__file__).parent / SCHEMA_FILE def get_connection(db_path: str | None = None) -> sqlite3.Connection: - global _conn, _current_path if db_path is None: - db_path = os.environ.get(DB_PATH_ENV) - if not db_path: - raise RuntimeError(f"{DB_PATH_ENV} env var is required") + db_path = os.environ.get("SLAB_DB_PATH") or "slab_demo.db" db_path = str(Path(db_path).resolve()) - - with _lock: - if _conn is None or _current_path != db_path: - if _conn is not None: - _conn.close() - _conn = sqlite3.connect(db_path, check_same_thread=False) - _conn.row_factory = sqlite3.Row - _conn.execute("PRAGMA journal_mode=WAL") - _conn.execute("PRAGMA foreign_keys=ON") - _conn.execute("PRAGMA busy_timeout=5000") - _conn.execute("PRAGMA synchronous=NORMAL") - _current_path = db_path - return _conn - -def _has_schema_version(conn: sqlite3.Connection) -> bool: - try: - return conn.execute("SELECT 1 FROM schema_version LIMIT 1").fetchone() is not None - except sqlite3.OperationalError: - return False - -# (table, column) pairs that must exist for the DB to be a valid M3 schema. -# A pre-M3 (M2) volume lacks these, so any missing sentinel signals drift. -_SCHEMA_SENTINELS = [ - ("settings", "value"), - ("slabs", "deleted_at"), - ("slabs", "server_rev"), - ("slab_photos", "original_blob"), - ("sync_log", "payload_hash"), -] - - -def _schema_matches(conn: sqlite3.Connection) -> bool: - for table, column in _SCHEMA_SENTINELS: - try: - cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} - except sqlite3.OperationalError: - return False - if column not in cols: - return False - return True - - -# Tables the app owns and that must be rebuilt together on drift. -_M3_TABLES = [ - "slabs", "slab_photos", "pricing_rules", "settings", "woo_categories", - "woo_attributes", "woo_attribute_terms", "woo_tags", "sync_log", - "inference_log", "prompts", "prompt_revisions", -] - - -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: - conn = get_connection(db_path) - schema_path = _get_schema_path() - if not schema_path.exists(): - raise FileNotFoundError(f"Schema not found: {schema_path}") - with open(schema_path, "r", encoding="utf-8") as f: - schema = f.read() - with _lock: - conn.executescript(schema) - conn.commit() - if not _has_schema_version(conn): - if not _schema_matches(conn): - logger = logging.getLogger("slab.db") - logger.info("Detected pre-M3 schema drift, rebuilding app tables") - for t in _M3_TABLES: - conn.execute(f"DROP TABLE IF EXISTS {t}") - conn.executescript(schema) - conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)") - conn.commit() - # Safe to run every boot: no-op once the columns exist. - with _lock: - _migrate(conn) - conn.commit() - + # isolation_level=None -> autocommit mode. The P0.1 default (deferred + # transactions) left a read-transaction open for the whole life of an + # endpoint connection, which (a) blocked other writers with "database is + # locked" and (b) made conn.commit() inside an open txn a no-op for + # multi-statement units. Autocommit + explicit BEGIN for the few + # multi-statement units (taxonomy sync) is the honest model for a + # per-call-connection factory. + conn = sqlite3.connect(db_path, check_same_thread=False, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=5000") + conn.execute("PRAGMA synchronous=NORMAL") return conn -def get_images_dir() -> Path: - img = os.environ.get(IMAGES_DIR_ENV) - if not img: - raise RuntimeError(f"{IMAGES_DIR_ENV} env var is required") - return Path(img).resolve() - - -def probe_writable(conn: sqlite3.Connection | None = None) -> bool: +def init_db(db_path: str | None = None) -> None: + """Apply schema.sql (CREATE IF NOT EXISTS) and ensure schema_version row=1.""" + schema_path = Path(__file__).parent / "schema.sql" + if not schema_path.exists(): + raise FileNotFoundError(f"Schema not found: {schema_path}") + conn = get_connection(db_path) try: - c = conn or get_connection() - c.execute("SELECT 1").fetchone() - return True - except Exception: - return False - - -def probe_images_writable() -> bool: - try: - img_dir = get_images_dir() - if not img_dir.exists(): - img_dir.mkdir(parents=True, exist_ok=True) - probe = img_dir / ".health-probe" - probe.write_text("ok", encoding="utf-8") - probe.unlink(missing_ok=True) - return True - except Exception: - return False - - -def _reset_for_tests() -> None: - """Close global conn so temp dirs can be cleaned on Windows (test only).""" - global _conn, _current_path - with _lock: - if _conn is not None: - try: - _conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") - except Exception: - pass - try: - _conn.close() - except Exception: - pass - _conn = None - _current_path = None + with open(schema_path, "r", encoding="utf-8") as f: + schema = f.read() + conn.executescript(schema) + conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)") + conn.commit() + finally: + conn.close() diff --git a/backend/inference.py b/backend/inference.py index b6282ad..9a99f12 100644 --- a/backend/inference.py +++ b/backend/inference.py @@ -1,116 +1,41 @@ -"""Inference (S7): prompt revisions (REST), Call 1 vision classification, Call 2 -content prose, test-inference, inference-off server description, inference_log. -Cites METADATA-35..115, SETTINGS-30/45/90. -""" +"""Inference: prompt revisions (REST), Call 1 vision classification, Call 2 +long description, test-inference, inference-off server description, +inference_log. Cites METADATA-35..115, SETTINGS-30/45/90. + +Transport is the OpenAI-compatible chat/completions endpoint via httpx; tests +mock it. Groupers (species/wcat) are resolved BY SLUG only (QUALITY-105). +""" +from __future__ import annotations -import base64 -import hashlib -import io import json import time -import urllib.error -import urllib.request +from datetime import datetime, timezone from typing import Any from fastapi import APIRouter from fastapi.responses import JSONResponse -from PIL import Image from .db import get_connection -from .crypto import decrypt +from .prompts import CALL1_SEED, CALL2_SEED, active_prompt, seed_prompts +from .inference_core import ( + InferenceClient, + InferenceConnectionError, + InferenceError, + _enabled, + _gate, + _inference_creds, + _log_call, + _parse_json, + _redact_request_for_log, + _slab_photos, + _taxonomy_lists, + fit_long_edge_png, +) 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 = ( - "You are a wood identification expert for retail lumber slabs. Judge only from the photos " - "and the LISTS below. Use list ids only — never invent a name or id.\n" - "\n" - "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.\n" - "\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": , "species_confidence": <0-1>, ' - '"wood_category_ids": [,...], "wood_category_confidence": <0-1>, ' - '"edge_type_term_id": , "edge_confidence": <0-1>, ' - '"figure_term_ids": [,...], "figure_confidence": <0-1>, ' - '"feature_tag_ids": [,...], "feature_tag_confidence": <0-1>}' -) -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 " - "brand voice and geo context given. The dimensions in the fact block are exact: use those " - "strings and invent no numbers. Write prose a premium lumber buyer would read." -) -_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED} -_THRESHOLD = 0.7 def _error(code: str, message: str, detail: Any = None) -> dict: @@ -118,179 +43,13 @@ def _error(code: str, message: str, detail: Any = None) -> dict: def _now() -> str: - from datetime import datetime, timezone - return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() -def _hash(text: str) -> str: - return hashlib.sha256(text.encode("utf-8")).hexdigest() +# ---------- Inference client (OpenAI-compatible, httpx) ---------- -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(): - prow = conn.execute("SELECT id, active_revision_id FROM prompts WHERE name=?", (name,)).fetchone() - if prow is None: - cur = conn.execute( - "INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)", - (name, None, _now(), _now()), - ) - pid = cur.lastrowid - active_id = None - else: - pid = prow[0] - active_id = prow[1] - 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: - row = conn.execute( - "SELECT p.id, pr.id, pr.content FROM prompts p LEFT JOIN prompt_revisions pr ON p.active_revision_id=pr.id WHERE p.name=?", - (call,), - ).fetchone() - if row is None or row[2] is None: - return None - return row[1], row[2] - - -# ---------- Prompt revision REST (METADATA-106) ---------- -@router.post("/prompts/{call}/revisions", status_code=201) -async def append_revision(call: str, payload: dict[str, Any]) -> dict: - if call not in _SEEDS: - return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) - content = payload.get("content") - if not content: - return JSONResponse(status_code=400, content=_error("validation", "content required")) - conn = get_connection() - _seed_prompts(conn) - prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone() - cur = conn.execute( - "INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)", - (prow[0], content, _hash(content), _now(), "api"), - ) - conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (cur.lastrowid, _now(), prow[0])) - conn.commit() - return {"id": cur.lastrowid, "prompt": call, "content_hash": _hash(content)} - - -@router.post("/prompts/{call}/activate") -async def activate_revision(call: str, payload: dict[str, Any]) -> dict: - if call not in _SEEDS: - return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) - rev_id = payload.get("revision_id") - conn = get_connection() - _seed_prompts(conn) - prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone() - row = conn.execute( - "SELECT id FROM prompt_revisions WHERE id=? AND prompt_id=?", (rev_id, prow[0]) - ).fetchone() - if row is None: - return JSONResponse(status_code=404, content=_error("not_found", "revision not found")) - conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (rev_id, _now(), prow[0])) - conn.commit() - return {"ok": True, "active_revision_id": rev_id} - - -@router.get("/prompts/{call}") -async def get_prompt(call: str) -> dict: - if call not in _SEEDS: - return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) - conn = get_connection() - _seed_prompts(conn) - prow = conn.execute("SELECT * FROM prompts WHERE name=?", (call,)).fetchone() - revs = conn.execute( - "SELECT id, content_hash, created_at, created_by FROM prompt_revisions WHERE prompt_id=? ORDER BY id", - (prow[0],), - ).fetchall() - active = conn.execute( - "SELECT content FROM prompt_revisions WHERE id=?", (prow["active_revision_id"],) - ).fetchone() - return { - "name": call, - "active_revision_id": prow["active_revision_id"], - "active_content": active[0] if active else None, - "revisions": [dict(r) for r in revs], - } - - -# ---------- Inference client (OpenAI-compatible, stdlib) ---------- -class InferenceClient: - def __init__(self, base_url: str, api_key: str, model: str, timeout: float = 60.0): - self.base_url = base_url.rstrip("/") - self.api_key = api_key - self.model = model - self.timeout = timeout - - def chat(self, messages: list[dict]) -> str: - url = f"{self.base_url}/chat/completions" - body = json.dumps({"model": self.model, "messages": messages}).encode("utf-8") - req = urllib.request.Request( - url, data=body, - headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - data = json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as e: - raise InferenceError(f"inference HTTP {e.code}") from e - except urllib.error.URLError as e: - raise InferenceConnectionError(f"inference unreachable: {e.reason}") from e - return data["choices"][0]["message"]["content"] - - -class InferenceError(Exception): - pass - - -class InferenceConnectionError(InferenceError): - pass - - -def _inference_creds(conn) -> tuple[str, str, str]: - b = conn.execute("SELECT value_enc FROM settings WHERE key='inference_base_url'").fetchone() - k = conn.execute("SELECT value_enc FROM settings WHERE key='inference_api_key'").fetchone() - m = conn.execute("SELECT value FROM settings WHERE key='inference_model'").fetchone() - return (decrypt(b[0]) if b and b[0] else ""), (decrypt(k[0]) if k and k[0] else ""), (m[0] if m and m[0] else "") - - -def _enabled(conn, key: str) -> bool: - row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() - return bool(row and row[0] in ("true", "True", "1")) @router.post("/slabs/{slab_id}/inference/call1") @@ -305,27 +64,16 @@ async def call1(slab_id: str) -> dict: if not base or not key: return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured")) client = InferenceClient(base, key, model) - _seed_prompts(conn) + seed_prompts(conn) conn.commit() - act = _active_prompt(conn, "call1") + act = active_prompt(conn, "call1") prompt_text = act[1] if act else CALL1_SEED - # Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy. - photos = conn.execute( - "SELECT id, seq, processed_blob FROM slab_photos " - "WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8", - (slab_id,), - ).fetchall() - image_parts = [] - photo_meta: list[dict] = [] - 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)) + image_parts, photo_meta = _slab_photos(conn, slab_id) tax = _taxonomy_lists(conn) - 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']}") + 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']}" + ) messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}] t0 = time.time() try: @@ -337,131 +85,7 @@ async def call1(slab_id: str) -> dict: latency = int((time.time() - t0) * 1000) parsed = _parse_json(raw) _log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency, photo_meta) - # Apply confidence gate (METADATA-45). - suggestions = _gate(parsed) - 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: - """Closed Call 1 lists only (#86 / METADATA-40). - - Keys match the seed prompt. No grade/thickness/moisture (owner fields). - Feature tag names are shown without the feat- prefix; ids are Woo/local tag ids. - Groupers resolved by slug only (QUALITY-105). - """ - 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( - "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,), - ).fetchall() - 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(): - out["feature_tags"].append({"id": r[0], "name": _feat_display_name(r[1])}) - return out - - -def _parse_json(raw: str) -> dict: - try: - s = raw.strip() - if s.startswith("```"): - s = s.split("```")[1] - if s.startswith("json"): - s = s[4:] - return json.loads(s) - except (ValueError, IndexError): - return {} - - -def _gate(parsed: dict) -> dict: - """Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45). - - Confidence is read from the flat '_confidence' keys; as a safety net it also - understands the nested 'confidence: {: 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: - return parsed[field] - return None - - out: dict[str, Any] = {} - if v := keep("species_id"): - out["species_id"] = v - if v := keep("wood_category_ids"): - out["wood_category_ids"] = v - if v := keep("edge_type_term_id"): - out["edge_type_term_id"] = v - if v := keep("figure_term_ids"): - out["figure_term_ids"] = v - if v := keep("feature_tag_ids"): - out["feature_tag_ids"] = v - # grade / free-text bags intentionally omitted (METADATA-40, #86). - return out + return {"ok": True, "suggestions": _gate(parsed)} @router.post("/slabs/{slab_id}/inference/call2") @@ -476,27 +100,17 @@ async def call2(slab_id: str) -> dict: if not base or not key: return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured")) client = InferenceClient(base, key, model) - act = _active_prompt(conn, "call2") + act = active_prompt(conn, "call2") prompt_text = act[1] if act else CALL2_SEED - photos = conn.execute( - "SELECT id, seq, processed_blob FROM slab_photos " - "WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8", - (slab_id,), - ).fetchall() - image_parts = [] - photo_meta: list[dict] = [] - 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)) + image_parts, photo_meta = _slab_photos(conn, slab_id) brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").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" - f"length={row['length_in']}in thickness={row['thickness_in']}in " - f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in " - f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}") + text = ( + f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n" + f"length={row['length_in']}in thickness={row['thickness_in']}in " + f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in " + f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}" + ) messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}] t0 = time.time() try: @@ -507,7 +121,6 @@ async def call2(slab_id: str) -> dict: return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True})) latency = int((time.time() - t0) * 1000) _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. conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?", (raw.strip(), _now(), slab_id)) conn.commit() @@ -524,110 +137,6 @@ 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, - 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( - "INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) " - "VALUES (?,?,?,?,?,?,?,?)", - (slab_id, call, rev_id, model, payload, response, latency_ms, _now()), - ) - conn.commit() - - @router.post("/admin/test-inference") async def test_inference() -> dict: conn = get_connection() @@ -642,5 +151,4 @@ async def test_inference() -> dict: return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)} except InferenceError as e: return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)} - detail = f"reachable in {int((time.time()-t0)*1000)}ms" - return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail} \ No newline at end of file + return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": f"reachable in {int((time.time()-t0)*1000)}ms"} diff --git a/backend/inference_core.py b/backend/inference_core.py new file mode 100644 index 0000000..fcc2adc --- /dev/null +++ b/backend/inference_core.py @@ -0,0 +1,305 @@ +"""Inference core (no FastAPI): OpenAI-compatible client, METADATA-35 image +sizing, closed taxonomy lists, the METADATA-45 confidence gate, and the +METADATA-66 log redaction. Routers in inference.py compose these. +""" +from __future__ import annotations + +import base64 +import io +import json +import time +from typing import Any + +import httpx +from PIL import Image + +from .crypto import decrypt + +_INFER_MAX_LONG_EDGE = 1024 +# METADATA-45: confidence gate threshold. +_THRESHOLD = 0.7 +# METADATA-66 / #87: inference_log payload cap (safety net after redaction). +_LOG_PAYLOAD_CAP = 200_000 + + +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. + + Used only for the inference request payload; stored processed_blob / Woo + media stay full size. Unreadable 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: + return png_bytes + scale = max_long_edge / float(long_edge) + nw = max(1, int(round(w * scale))) + nh = max(1, int(round(h * scale))) + 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: + """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}"}} + + +class InferenceError(Exception): + pass + + +class InferenceConnectionError(InferenceError): + pass + + +class InferenceClient: + """Minimal OpenAI-compatible chat client. `chat` is the mock seam.""" + + def __init__(self, base_url: str, api_key: str, model: str, timeout: float = 60.0): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.model = model + self.timeout = timeout + + def chat(self, messages: list[dict]) -> str: + url = f"{self.base_url}/chat/completions" + body = json.dumps({"model": self.model, "messages": messages}).encode("utf-8") + try: + with httpx.Client(timeout=self.timeout) as client: + resp = client.post( + url, content=body, + headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, + ) + except (httpx.TimeoutException, httpx.TransportError) as e: + raise InferenceConnectionError(f"inference unreachable: {e}") from e + if resp.status_code >= 400: + raise InferenceError(f"inference HTTP {resp.status_code}") + data = json.loads(resp.read().decode("utf-8")) + return data["choices"][0]["message"]["content"] + + +def _inference_creds(conn) -> tuple[str, str, str]: + b = conn.execute("SELECT value_enc FROM settings WHERE key='inference_base_url'").fetchone() + k = conn.execute("SELECT value_enc FROM settings WHERE key='inference_api_key'").fetchone() + m = conn.execute("SELECT value FROM settings WHERE key='inference_model'").fetchone() + base = decrypt(b[0]) if b and b[0] else "" + key = decrypt(k[0]) if k and k[0] else "" + model = m[0] if m and m[0] else "" + return base, key, model + + +def _enabled(conn, key: str) -> bool: + row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + return bool(row and row[0] in ("true", "True", "1")) + + + +def _taxonomy_lists(conn) -> dict: + """Closed Call 1 lists only (#86 / METADATA-40). + + Keys match the seed prompt. No grade/thickness/moisture (owner fields). + Groupers resolved by slug only (QUALITY-105 / METADATA-5): 'species' and + 'wcat'. Feature tag names are shown without the feat- prefix; ids are + Woo/local tag ids. + """ + out: dict[str, list] = { + "species": [], + "wood_categories": [], + "edge_terms": [], + "figure_terms": [], + "feature_tags": [], + } + 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]}) + 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(f"SELECT id,name FROM woo_categories WHERE id IN ({q})", leaf_ids).fetchall(): + out["wood_categories"].append({"id": r[0], "name": r[1]}) + for attr_name, key in (("Edge type", "edge_terms"), ("Figure", "figure_terms")): + rows = conn.execute( + "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_name,), + ).fetchall() + 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(): + out["feature_tags"].append({"id": r[0], "name": _feat_display_name(r[1])}) + return out + + +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 _parse_json(raw: str) -> dict: + try: + s = raw.strip() + if s.startswith("```"): + s = s.split("```")[1] + if s.startswith("json"): + s = s[4:] + parsed = json.loads(s) + return parsed if isinstance(parsed, dict) else {} + except (ValueError, IndexError): + return {} + + +def _gate(parsed: dict) -> dict: + """Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45). + + Confidence is read from flat '_confidence' keys; as a safety net it + also understands nested 'confidence: {: n}' some models emit. Grade is + never suggested (METADATA-40); free-text observation bags are dropped + (#86) — feature tags are ids only. + """ + 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") + return conf_obj[k] if k in conf_obj else parsed.get(k) + + def keep(field: str) -> Any: + conf = _conf(field) + if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed: + return parsed[field] + return None + + out: dict[str, Any] = {} + for field in conf_key: + if v := keep(field): + out[field] = v + return out + + + +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 _slab_photos(conn, slab_id: str) -> tuple[list[dict], list[dict]]: + rows = conn.execute( + "SELECT id, seq, processed_blob FROM slab_photos " + "WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8", + (slab_id,), + ).fetchall() + image_parts = [_image_part_from_processed(p["processed_blob"]) for p in rows] + photo_meta = [_photo_meta_row(p["id"], p["seq"], p["processed_blob"]) for p in rows] + return image_parts, photo_meta + + +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 keep 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. + """ + meta = list(photo_meta or []) + out: list = [] + for msg in messages: + if not isinstance(msg, dict) or not isinstance(msg.get("content"), list): + out.append(msg) + continue + new_parts: list = [] + img_i = 0 + for part in msg["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: dict[str, Any] = { + "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): + ref.update(meta[img_i]) + new_parts.append(ref) + img_i += 1 + else: + 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 + + +def _log_call(conn, slab_id: str, call: int, rev_id: int | None, model: str, + 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.""" + payload = json.dumps(_redact_request_for_log(request, photo_meta), ensure_ascii=False) + if len(payload) > _LOG_PAYLOAD_CAP: + payload = payload[: _LOG_PAYLOAD_CAP - 20] + "\n/* truncated */" + conn.execute( + "INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) " + "VALUES (?,?,?,?,?,?,?,?)", + (slab_id, call, rev_id, model, payload, response, latency_ms, + time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())), + ) + conn.commit() + diff --git a/backend/prompts.py b/backend/prompts.py new file mode 100644 index 0000000..d11754c --- /dev/null +++ b/backend/prompts.py @@ -0,0 +1,182 @@ +"""Prompt revision REST (METADATA-106): append/activate/get for call1/call2. + +Seed prompts live in inference (CALL1_SEED / CALL2_SEED); the shipped seed is +activated when nothing is active or the active revision is still seed-authored, +so deploys pick up seed updates without clobbering an owner revision. +""" +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from .db import get_connection + +router = APIRouter(prefix="/api/v1") + +# Locked Call 1 seed (#86 / METADATA-40): expert helper + closed Woo lists only. +CALL1_SEED = ( + "You are a wood identification expert for retail lumber slabs. Judge only from the photos " + "and the LISTS below. Use list ids only — never invent a name or id.\n" + "\n" + "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.\n" + "\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": , "species_confidence": <0-1>, ' + '"wood_category_ids": [,...], "wood_category_confidence": <0-1>, ' + '"edge_type_term_id": , "edge_confidence": <0-1>, ' + '"figure_term_ids": [,...], "figure_confidence": <0-1>, ' + '"feature_tag_ids": [,...], "feature_tag_confidence": <0-1>}' +) +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 " + "brand voice and geo context given. The dimensions in the fact block are exact: use those " + "strings and invent no numbers. Write prose a premium lumber buyer would read." +) +_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED} +_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED} + + +def _error(code: str, message: str, detail: Any = None) -> dict: + return {"error": {"code": code, "message": message, "detail": detail}} + + +def _now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() + + +def _hash(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def seed_prompts(conn) -> None: + """Ensure each call has a prompt row and the shipped seed revision.""" + for name, seed in _SEEDS.items(): + prow = conn.execute("SELECT id, active_revision_id FROM prompts WHERE name=?", (name,)).fetchone() + if prow is None: + cur = conn.execute( + "INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)", + (name, None, _now(), _now()), + ) + pid, active_id = cur.lastrowid, None + else: + pid, active_id = prow[0], prow[1] + h = _hash(seed) + existing = conn.execute( + "SELECT id FROM prompt_revisions WHERE prompt_id=? AND content_hash=?", (pid, h) + ).fetchone() + seed_rev_id = existing[0] if existing else conn.execute( + "INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)", + (pid, seed, h, _now(), "seed"), + ).lastrowid + activate = active_id is None + if not activate and active_id != seed_rev_id: + act = conn.execute("SELECT created_by FROM prompt_revisions WHERE id=?", (active_id,)).fetchone() + activate = act is None or act[0] == "seed" + 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: + """(revision_id, content) for the call's active prompt revision, or None.""" + row = conn.execute( + "SELECT p.id, pr.id, pr.content FROM prompts p " + "LEFT JOIN prompt_revisions pr ON p.active_revision_id=pr.id WHERE p.name=?", + (call,), + ).fetchone() + if row is None or row[2] is None: + return None + return row[1], row[2] + + +@router.post("/prompts/{call}/revisions", status_code=201) +async def append_revision(call: str, payload: dict[str, Any]) -> dict: + if call not in _SEEDS: + return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) + content = payload.get("content") + if not content: + return JSONResponse(status_code=400, content=_error("validation", "content required")) + conn = get_connection() + seed_prompts(conn) + prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone() + cur = conn.execute( + "INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)", + (prow[0], content, _hash(content), _now(), "api"), + ) + conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (cur.lastrowid, _now(), prow[0])) + conn.commit() + return {"id": cur.lastrowid, "prompt": call, "content_hash": _hash(content)} + + +@router.post("/prompts/{call}/activate") +async def activate_revision(call: str, payload: dict[str, Any]) -> dict: + if call not in _SEEDS: + return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) + rev_id = payload.get("revision_id") + conn = get_connection() + seed_prompts(conn) + prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone() + if conn.execute("SELECT id FROM prompt_revisions WHERE id=? AND prompt_id=?", (rev_id, prow[0])).fetchone() is None: + return JSONResponse(status_code=404, content=_error("not_found", "revision not found")) + conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (rev_id, _now(), prow[0])) + conn.commit() + return {"ok": True, "active_revision_id": rev_id} + + +@router.get("/prompts/{call}") +async def get_prompt(call: str) -> dict: + if call not in _SEEDS: + return JSONResponse(status_code=404, content=_error("not_found", "unknown call")) + conn = get_connection() + seed_prompts(conn) + prow = conn.execute("SELECT * FROM prompts WHERE name=?", (call,)).fetchone() + revs = conn.execute( + "SELECT id, content_hash, created_at, created_by FROM prompt_revisions WHERE prompt_id=? ORDER BY id", + (prow[0],), + ).fetchall() + active = ( + conn.execute("SELECT content FROM prompt_revisions WHERE id=?", (prow["active_revision_id"],)).fetchone() + if prow["active_revision_id"] is not None + else None + ) + return { + "name": call, + "active_revision_id": prow["active_revision_id"], + "active_content": active[0] if active else None, + "revisions": [dict(r) for r in revs], + } diff --git a/backend/publish.py b/backend/publish.py index 503a9b0..7236fec 100644 --- a/backend/publish.py +++ b/backend/publish.py @@ -1,13 +1,15 @@ -"""Publish sequence (S6). Cites PUBLISH-5/10/15/20/25/30/31/36/37/38/55/60. +"""Publish sequence (PUBLISH-5/10/15/20/25/30/31/36/37/38/45/55/60). Sequence (PUBLISH-5): validate ready set -> look up SKU (stop if exists) -> -refresh taxonomy if last sync >1h -> resolve cats/tags/attrs -> upload processed -PNGs -> POST /products -> finalize. SLAB-UAT-* always draft (PUBLISH-10). -Failure: mark failed, retain photos, write sync_log, plain message (PUBLISH-30). -Partial (media ok, product POST failed): orphan media left, logged (PUBLISH-31). +refresh taxonomy if last sync >1h -> resolve cats/tags/attrs -> upload +processed PNGs -> POST /products -> finalize. SLAB-UAT-* always draft +(PUBLISH-10). Failure: mark failed, retain photos, write sync_log, plain +message (PUBLISH-30). Partial (media ok, product POST failed): orphan media +left, logged (PUBLISH-31). """ +from __future__ import annotations -import json +import os import re from datetime import datetime, timedelta, timezone from typing import Any @@ -24,7 +26,7 @@ from .woo import ( WooError, WooForbiddenError, ) -from .review import compute_ready, _slab_dict +from .review import _slab_dict, compute_ready router = APIRouter(prefix="/api/v1/slabs") @@ -59,6 +61,24 @@ def _taxonomy_stale(conn) -> bool: return datetime.now(timezone.utc) - last > timedelta(hours=1) +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. + + METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids. Accepts + both 'figure' and 'pa_figure' style slugs from Woo. + """ + slug_l = slug.lower().lstrip() + slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"} + for a in attrs.values(): + if (a.get("slug") or "").lower() 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 None + + def _build_payload(slab: dict, conn, status: str) -> dict: payload: dict[str, Any] = { "type": "simple", @@ -75,13 +95,12 @@ def _build_payload(slab: dict, conn, status: str) -> dict: "sold_individually": True, } wood_cats = slab.get("wood_category_ids") or [] - payload["categories"] = [{"id": slab["species_id"]}]+[{"id": c} for c in wood_cats] - tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])] + payload["categories"] = [{"id": slab["species_id"]}] + [{"id": c} for c in wood_cats] + tags = [t for t in (slab.get("fig_tag_ids") or [])] + [t for t in (slab.get("feat_tag_ids") or [])] if tags: payload["tags"] = [{"id": t} for t in tags] - # 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 + # Five locked attributes: resolve each attribute from the synced cache by + # slug first, then 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()} attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()} @@ -104,22 +123,6 @@ def _build_payload(slab: dict, conn, status: str) -> dict: return payload -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(): - 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 None - - def _classify(e: Exception) -> tuple[str, str, bool]: """Return (code, plain message, recoverable). PUBLISH-36/37/38.""" if isinstance(e, WooCredentialsMissing): @@ -138,6 +141,25 @@ def _classify(e: Exception) -> tuple[str, str, bool]: return "woo_failed", "The store create failed. You can retry.", True +def _woo_creds(conn) -> tuple[str, str | None, str | None]: + base = os.environ.get("WOO_BASE_URL", "") + u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() + p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() + return base, (u[0] if u else None), (p[0] if p else None) + + +def _create_status(conn) -> str: + row = conn.execute("SELECT value FROM settings WHERE key='woo_create_status'").fetchone() + return row[0] if row and row[0] else "draft" + + +def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None: + conn.execute( + "INSERT INTO sync_log(slab_id, woo_product_id, status, payload_hash, message, created_at) VALUES (?,?,?,?,?,?)", + (slab_id, woo_product_id, status, None, message, _now()), + ) + + @router.post("/{slab_id}/publish") async def publish(slab_id: str) -> dict: conn = get_connection() @@ -156,7 +178,7 @@ async def publish(slab_id: str) -> dict: except WooCredentialsMissing as e: return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e))) try: - existing = _sku_exists(client, slab["sku"]) + existing = len(client.get_products_by_sku(slab["sku"])) > 0 except WooError as e: code, msg, rec = _classify(e) _log(conn, slab_id, None, "failed", msg) @@ -168,9 +190,9 @@ async def publish(slab_id: str) -> dict: return JSONResponse(status_code=409, content=_error("duplicate_sku", "SKU already exists", {"sku": slab["sku"]})) # 3. Refresh taxonomy if stale (SETTINGS-60). if _taxonomy_stale(conn): - try: - from . import taxonomy as taxonomy_mod + from . import taxonomy as taxonomy_mod + try: taxonomy_mod.sync(client, conn) except WooError as e: code, msg, rec = _classify(e) @@ -180,7 +202,7 @@ async def publish(slab_id: str) -> dict: # 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. + # role rather than stored seq. Ties fall back to stored seq. status = "draft" if _is_uat(slab["sku"]) else _create_status(conn) photos = conn.execute( "SELECT id, processed_blob, role FROM slab_photos " @@ -195,7 +217,7 @@ async def publish(slab_id: str) -> dict: payload = _build_payload(slab, conn, status) if uploaded_ids: payload["images"] = [{"id": i} for i in uploaded_ids] - woo_product_id = _create_product(client, payload) + woo_product_id = client.create_product(payload) except WooError as e: code, msg, rec = _classify(e) partial = bool(uploaded_ids) @@ -207,8 +229,10 @@ async def publish(slab_id: str) -> dict: return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec, "partial_media": partial})) # 6. Finalize (PUBLISH-25): set woo_product_id, purge photos, discard local row. now = _now() - conn.execute("UPDATE slabs SET status='published', woo_product_id=?, published_at=?, updated_at=? WHERE id=?", - (woo_product_id, now, now, slab_id)) + conn.execute( + "UPDATE slabs SET status='published', woo_product_id=?, published_at=?, updated_at=? WHERE id=?", + (woo_product_id, now, now, slab_id), + ) conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE slab_id=?", (slab_id,)) _log(conn, slab_id, woo_product_id, "success", "published") conn.commit() @@ -218,34 +242,3 @@ async def publish(slab_id: str) -> dict: "woo_product_id": woo_product_id, "draft": _is_uat(slab["sku"]), } - - -def _woo_creds(conn) -> tuple[str, str | None, str | None]: - import os - - base = os.environ.get("WOO_BASE_URL", "") - u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() - p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() - return base, (u[0] if u else None), (p[0] if p else None) - - -def _create_status(conn) -> str: - row = conn.execute("SELECT value FROM settings WHERE key='woo_create_status'").fetchone() - return (row[0] if row and row[0] else "draft") - - -def _sku_exists(client: WooClient, sku: str) -> bool: - """SKU lookup via WooClient so User-Agent and 403 typing apply (#79).""" - return len(client.get_products_by_sku(sku)) > 0 - - -def _create_product(client: WooClient, payload: dict) -> int: - """Product create via WooClient so User-Agent and 403 typing apply (#79).""" - return client.create_product(payload) - - -def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None: - conn.execute( - "INSERT INTO sync_log(slab_id, woo_product_id, status, payload_hash, message, created_at) VALUES (?,?,?,?,?,?)", - (slab_id, woo_product_id, status, None, message, _now()), - ) diff --git a/backend/review.py b/backend/review.py index 43b72f4..b3ac4b2 100644 --- a/backend/review.py +++ b/backend/review.py @@ -1,7 +1,13 @@ -"""Review to ready (S5): deterministic title/short description, price suggestion, -and server-derived `ready` status. Cites REVIEW-5/10/15/20/25/30/40/45/50, -METADATA-80/85/90, SETTINGS-35. +"""Review to ready: deterministic title/short description, price suggestion, +ready-gate. Cites REVIEW-5/10/15/20/25/30/40/45/50, METADATA-80/85/90, +SETTINGS-35. + +METADATA-80/85: title and short_description are assembled only from the +confirmed (persisted) listing facts — primary wood-category leaf, species, +figure term, and the dimension fact block. Owner-supplied values win +(METADATA-50) via stored fields. """ +from __future__ import annotations import json import re @@ -18,7 +24,7 @@ router = APIRouter(prefix="/api/v1/slabs") # Wood-category leaf slug -> (title pattern, noun, carries_dims). # Patterns interpolate {figure}, {species}, {length}, {width}, {thickness}, {bdft}. -# Figure is Title-Case adjective; absent figure drops the slot. +# Figure is a Title-Case adjective; absent figure drops the slot. _LEAF_PATTERNS = { "live-edge-wood": ("{figure}{species} Live Edge Slab", "Live Edge Slab", False), "dimensional-wood": ("{figure}{species} {live_edge}Dimensional", "Dimensional", False), @@ -40,6 +46,10 @@ _REQUIRED = [ "content_source", ] +# REVIEW-45: 3-24 chars, alphanumerics + hyphen. Middle-tier arbiter; the DB +# CHECK is the backstop. +_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$") + def _error(code: str, message: str, detail: Any = None) -> dict: return {"error": {"code": code, "message": message, "detail": detail}} @@ -56,43 +66,25 @@ def _split_length(length_in: float) -> tuple[int, float]: def _fmt1(v: float) -> str: - # 1 dp, trim trailing .0 s = f"{v:.1f}" return s[:-2] if s.endswith(".0") else s def _fmt2(v: float) -> str: # 2 dp, trim trailing zeros (thickness carries 1/8-inch precision) - s = f"{v:.2f}".rstrip("0").rstrip(".") - return s + return f"{v:.2f}".rstrip("0").rstrip(".") 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" -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( - primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False) - ) - figure = figure.strip() if figure else "" - live_edge = "" - if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"): - # "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( - figure=figure + " " if figure else "", - species=species_name, - live_edge=live_edge, - noun=noun, - dims=_dims(slab["length_in"], slab["width_avg_in"], slab["thickness_in"]) if carries_dims else "", - ) - return re.sub(r"\s+", " ", title).strip() - - def _edge_is_live(edge_term_id: Any, terms: dict) -> bool: - """True when the synced edge term name/slug indicates live edge.""" + """True when the synced edge term name/slug indicates live edge. + + METADATA-5 / #98: "Live Edge" is a product-form flag driven by the *synced* + edge term — never any non-null edge id. + """ if not edge_term_id: return False term = terms.get(edge_term_id) or {} @@ -100,12 +92,30 @@ def _edge_is_live(edge_term_id: Any, terms: dict) -> bool: return "live" in blob +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( + primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False) + ) + figure = (figure or "").strip() + live_edge = "" + if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"): + live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else "" + title = pattern.format( + figure=figure + " " if figure else "", + species=species_name, + live_edge=live_edge, + dims=_dims(slab["length_in"], slab["width_avg_in"], slab["thickness_in"]) if carries_dims else "", + ) + return re.sub(r"\s+", " ", title).strip() + + 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"]) - length_str = f"{ft}ft {_fmt1(rem)}in" - bdft = round(slab["bdft"], 1) - line = f"{title}, {length_str} x {_fmt1(slab['width_avg_in'])}in x {_fmt2(slab['thickness_in'])}in, {_fmt1(bdft)} bdft" + line = ( + f"{title}, {ft}ft {_fmt1(rem)}in x {_fmt1(slab['width_avg_in'])}in x " + f"{_fmt2(slab['thickness_in'])}in, {_fmt1(round(slab['bdft'], 1))} bdft" + ) return re.sub(r"\s+", " ", line).strip() @@ -120,7 +130,7 @@ def _slab_dict(row) -> dict: return d -def _taxonomy_lookup(conn): +def _taxonomy_lookup(conn) -> tuple[dict, dict]: cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()} terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()} return cats, terms @@ -130,21 +140,16 @@ def missing_required(slab: dict) -> list[str]: 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]]: missing = missing_required(slab) - # A present-but-malformed SKU is not ready (business rule, enforced in the middle tier). + # A present-but-malformed SKU is not ready (business rule, 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 def price_suggestion(conn, slab: dict) -> float | None: @@ -156,32 +161,33 @@ def price_suggestion(conn, slab: dict) -> float | None: return round(slab["bdft"] * rate, 2) +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 + + @router.post("/{slab_id}/review") async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict: - """Compute ready + deterministic title/short_description from *locked* taxonomy. + """Compute ready + deterministic title/short_description from *locked* + taxonomy (#89: the Create button's source of truth). - 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. + Owner overrides (price/sku) from the payload are persisted; title and + short_description are produced by the deterministic build and are NOT + persisted from the payload. """ payload = payload or {} conn = get_connection() - row = conn.execute( - "SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,) - ).fetchone() + row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone() if row is None: return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) slab = _slab_dict(row) - # --- Deterministic review: report ready/missing + the deterministic - # title/short_description built from the current metadata. The client's - # "Create title" / "Create short desc" buttons call this endpoint and write the - # returned strings into the fields (#89: no separate lock action). - # #80: persist owner overrides (price/sku) from the payload so the review - # endpoint is self-contained. title/short_description are NOT overridden here — - # they are produced by the deterministic build below, not persisted from the - # payload (so the review POST can't clobber the owner's Create workflow). cats, terms = _taxonomy_lookup(conn) primary_id = payload.get("primary_wood_category_id") wood_cats = slab.get("wood_category_ids") or [] @@ -192,11 +198,8 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di figure_term_ids = slab.get("figure_term_ids") or [] figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None - # #80: persist owner overrides from the payload (price/sku only). - overrides: dict[str, Any] = {} - for k in ("price", "sku"): - if k in payload and payload[k] is not None: - overrides[k] = payload[k] + # #80: persist owner overrides (price/sku only) so the review is self-contained. + overrides = {k: payload[k] for k in ("price", "sku") if payload.get(k) is not None} if overrides: set_clause = ", ".join(f"{k} = ?" for k in overrides) conn.execute( @@ -206,9 +209,6 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di conn.commit() slab.update(overrides) - # Deterministic title / short_description, re-derived from the current - # 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) @@ -227,14 +227,3 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di "price": price, "price_suggestion": price_suggestion(conn, slab), "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 diff --git a/backend/settings.py b/backend/settings.py index f74e30d..f322dcc 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -1,7 +1,10 @@ -"""S1 Settings service (DATA-MODEL §6, API.md §10, SETTINGS-5/15/20/25/30/75/85/90). +"""Settings service (SETTINGS-5/15/20/25/30/75/85/90). -Secrets (value_enc) are AES-GCM write-only; GET returns only {configured: bool}. -woo_base_url is env read-only. Flags default false. Partial PUT. Decrypt fail -> configured:false (no 500, no leak). +Secrets (value_enc) are AES-GCM write-only; GET returns only {configured: bool} +by default (SETTINGS-75 masking). SLAB_UAT_UNMASK_SECRETS=1 is a dev-test-only +unmask (never set in prod). woo_base_url is env read-only (SETTINGS-85). +Partial PUT; unknown keys ignored. Decrypt failure -> configured:false (no 500, +no leak). """ from __future__ import annotations @@ -10,7 +13,7 @@ from typing import Any from fastapi import HTTPException -from .crypto import decrypt, encrypt, DecryptError +from .crypto import decrypt, encrypt from .db import get_connection SECRET_KEYS: set[str] = { @@ -44,29 +47,26 @@ DEFAULTS: dict[str, Any] = { def _row_to_value(row: dict) -> Any: - if row is None: - return None v = row.get("value") if v is None: return None - if isinstance(v, str): - if v.lower() in ("true", "false"): - return v.lower() == "true" + if isinstance(v, str) and v.lower() in ("true", "false"): + return v.lower() == "true" return v def get_settings() -> dict[str, Any]: conn = get_connection() - rows = {} - for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall(): - rows[r["key"]] = {"value": r["value"], "value_enc": r["value_enc"]} + rows = { + r["key"]: {"value": r["value"], "value_enc": r["value_enc"]} + for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall() + } - result: dict[str, Any] = {} - result["woo_base_url"] = os.environ.get("WOO_BASE_URL", "") + result: dict[str, Any] = {"woo_base_url": os.environ.get("WOO_BASE_URL", "")} - # TEMPORARY UAT override (owner-directed, LAN/HTTP only): when set, secrets are - # returned as their decrypted plaintext so they can be inspected on the dev-test - # box. Default OFF -> SETTINGS-75 masking intact. Remove before prod. + # TEMPORARY UAT override (owner-directed, LAN/HTTP only): when set, secrets + # are returned as decrypted plaintext for dev-test inspection. Default OFF -> + # SETTINGS-75 masking intact. Never set in prod. unmask = os.environ.get("SLAB_UAT_UNMASK_SECRETS") == "1" for k in sorted(ALL_KEYS): @@ -81,28 +81,23 @@ def get_settings() -> dict[str, Any]: result[k] = "" else: result[k] = "" - else: - if enc: - try: - decrypt(enc) # verify roundtrip possible - result[k] = {"configured": True} - except Exception: - result[k] = {"configured": False} - else: + elif enc: + try: + decrypt(enc) # verify roundtrip possible + result[k] = {"configured": True} + except Exception: result[k] = {"configured": False} - else: - if k in rows: - val = _row_to_value(row) - result[k] = val if val is not None else DEFAULTS.get(k) else: - result[k] = DEFAULTS.get(k) + result[k] = {"configured": False} + else: + val = _row_to_value(row) if k in rows else None + result[k] = val if val is not None else DEFAULTS.get(k) if k in NUMERIC_KEYS: try: f = float(result[k]) result[k] = int(f) if f.is_integer() else f except (TypeError, ValueError): result[k] = DEFAULTS.get(k) - return result @@ -141,34 +136,22 @@ def update_settings(payload: dict[str, Any]) -> dict[str, Any]: conn = get_connection() for k, v in payload.items(): if k not in ALL_KEYS: - continue # ignore unknown for partial + continue # partial update: unknown keys ignored if k in SECRET_KEYS: if v in (None, "", False): - conn.execute( - "INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", - (k, None), - ) + conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", (k, None)) else: - try: - enc = encrypt(str(v)) - except Exception: - enc = None conn.execute( "INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,?)", - (k, None, enc), + (k, None, encrypt(str(v))), ) else: - # plain if v in (None, ""): conn.execute("DELETE FROM settings WHERE key=?", (k,)) + elif k in ("inference_enabled", "content_llm_enabled"): + conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", + (k, "true" if bool(v) else "false")) else: - if k in ("inference_enabled", "content_llm_enabled"): - val_str = "true" if bool(v) else "false" - else: - val_str = str(v) - conn.execute( - "INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", - (k, val_str), - ) + conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", (k, str(v))) conn.commit() return get_settings() diff --git a/backend/slabs.py b/backend/slabs.py index 650a741..8a121b6 100644 --- a/backend/slabs.py +++ b/backend/slabs.py @@ -9,7 +9,7 @@ import json from datetime import datetime, timezone from typing import Any, Dict, List -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse, Response from .db import get_connection @@ -243,7 +243,7 @@ async def get_slab(id: str) -> Dict[str, Any]: conn = get_connection() row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone() if row is None: - return _err("not_found", "Slab not found", status=404) + raise HTTPException(status_code=404, detail="Slab not found") return _row_to_slab(row) @@ -261,7 +261,7 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]: conn = get_connection() current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone() if current is None: - return _err("not_found", "Slab not found", status=404) + raise HTTPException(status_code=404, detail="Slab not found") client_rev = payload["client_rev"] if client_rev != current["server_rev"]: return _err("revision_conflict", "client_rev mismatch", @@ -301,7 +301,7 @@ async def delete_slab(id: str): conn = get_connection() row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone() if row is None: - return _err("not_found", "Slab not found", status=404) + raise HTTPException(status_code=404, detail="Slab not found") if row["status"] == "published": return _err("publish_conflict", "Published slab cannot be deleted", status=409) conn.execute("UPDATE slabs SET deleted_at=? WHERE id=?", (_now_iso(), id)) diff --git a/backend/taxonomy.py b/backend/taxonomy.py index beb6d1f..2890527 100644 --- a/backend/taxonomy.py +++ b/backend/taxonomy.py @@ -1,9 +1,11 @@ """Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache. -Rebuild happens in one transaction; on any failure the prior cache is kept. -Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a -stable hash of the stored subset, recorded only when it changes. +Rebuild happens in one transaction; on any failure the prior cache is kept +(commit only on success). Only fig-* / feat-* tag families are retained +(METADATA-15). taxonomy_anchor is a stable hash of the stored subset, recorded +only when it changes. """ +from __future__ import annotations import hashlib import json @@ -23,14 +25,12 @@ def _scoped_tags(tags: list[dict]) -> list[dict]: def _normalize_category(c: dict) -> tuple: """Map a Woo REST category row into woo_categories insert values. - WooCommerce REST v3 returns `parent` (0 = top-level). Older fixtures may - already use `parent_id`. Never invent ids — only store what the payload carries. + WooCommerce REST v3 returns `parent` (0 = top-level); older fixtures may + already use `parent_id`. Never invent ids — only store what the payload + carries. """ raw_parent = c.get("parent_id", c.get("parent")) - if raw_parent in (0, "0", None, ""): - parent_id = None - else: - parent_id = int(raw_parent) + parent_id = None if raw_parent in (0, "0", None, "") else int(raw_parent) woo_id = c.get("woo_id", c.get("id")) if woo_id is not None: woo_id = int(woo_id) @@ -49,21 +49,24 @@ def _subset_hash(categories: list[dict], attributes: list[dict], def sync(client: WooClient, conn) -> dict: - """Rebuild the taxonomy cache from the store. Raises WooError on failure (caller - maps to 502; prior cache is retained because we only commit on success).""" + """Rebuild the taxonomy cache from the store. Raises WooError on failure + (caller maps to 502; the prior cache survives because we commit on success).""" categories = client.get_categories() tags = client.get_tags() attributes = client.get_attributes() terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes} scoped_tags = _scoped_tags(tags) + # One explicit transaction: the rebuild is atomic, and on any failure the + # prior cache survives (autocommit mode — see db factory). + conn.execute("BEGIN") # Attributes/tags/terms are not referenced by slabs FKs — safe to rebuild. conn.execute("DELETE FROM woo_attribute_terms") conn.execute("DELETE FROM woo_attributes") conn.execute("DELETE FROM woo_tags") - # Categories: upsert then drop stale rows. A full DELETE fails when slabs or - # pricing_rules still reference species_id (live UAT IntegrityError on test-woo). + # Categories: upsert, then drop stale rows. A blanket DELETE fails when + # slabs or pricing_rules still reference species_id (live UAT IntegrityError). cat_rows = [_normalize_category(c) for c in categories] cat_ids = [r[0] for r in cat_rows] for row in cat_rows: @@ -73,20 +76,15 @@ def sync(client: WooClient, conn) -> dict: "name=excluded.name, slug=excluded.slug, parent_id=excluded.parent_id, woo_id=excluded.woo_id", row, ) + keep = ( + f"AND id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) " + f"AND id NOT IN (SELECT species_id FROM pricing_rules)" + ) if cat_ids: placeholders = ",".join("?" * len(cat_ids)) - conn.execute( - f"DELETE FROM woo_categories WHERE id NOT IN ({placeholders}) " - "AND id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) " - "AND id NOT IN (SELECT species_id FROM pricing_rules)", - cat_ids, - ) + conn.execute(f"DELETE FROM woo_categories WHERE id NOT IN ({placeholders}) {keep}", cat_ids) else: - conn.execute( - "DELETE FROM woo_categories " - "WHERE id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) " - "AND id NOT IN (SELECT species_id FROM pricing_rules)" - ) + conn.execute(f"DELETE FROM woo_categories {keep}") conn.executemany( "INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)", @@ -106,7 +104,7 @@ def sync(client: WooClient, conn) -> dict: now = _now_iso() conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_last_sync_at', ?, NULL)", (now,)) conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_anchor', ?, NULL)", (anchor,)) - conn.commit() + conn.execute("COMMIT") return {"taxonomy_anchor": anchor, "taxonomy_last_sync_at": now} diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py new file mode 100644 index 0000000..e2b081a --- /dev/null +++ b/backend/tests/test_app.py @@ -0,0 +1,123 @@ +"""P0.2 app-level tests: health shape, error envelope, request id (APP-30/45/65).""" +import json +import logging +import os +import re +import subprocess +import sys + +import pytest +from fastapi.testclient import TestClient + +from backend import app as app_module + +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + + +@pytest.fixture +def client(tmp_path, monkeypatch): + # Context-manager mode runs the lifespan (startup guards + init_db). + # SLAB_ENV=dev keeps the mock-safe slice running without store creds; + # SLAB_DB_PATH drives every per-call connection to the throwaway DB. + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("SLAB_DB_PATH", db_path) + monkeypatch.setenv("SLAB_ENV", "dev") + app_module.reinit_db(db_path) + with TestClient(app_module.app) as c: + yield c + + +def test_health_200_with_expected_shape(client): + r = client.get("/api/health") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert body["version"] == app_module.__version__ + assert body["woo_reachable"] is False + + +def test_404_returns_error_envelope(client): + r = client.get("/api/v1/slabs/does-not-exist") + assert r.status_code == 404 + body = r.json() + assert set(body) == {"error"} + assert body["error"]["code"] == "404" + assert body["error"]["message"] + assert body["error"]["detail"] is None + + +def test_request_id_assigned_when_absent(client): + r = client.get("/api/health") + rid = r.headers.get("X-Request-Id") + assert rid is not None + assert UUID_RE.match(rid) + + +def test_request_id_echoed_when_provided(client): + r = client.get("/api/health", headers={"X-Request-Id": "req-fixed-123"}) + assert r.headers["X-Request-Id"] == "req-fixed-123" + + +def test_json_log_line_shape_and_redaction(): + # Formatter is a pure function of the record: assert shape + redaction + # directly (handler output goes to the pre-pytest stdout, invisible to capsys). + rec = logging.LogRecord("slab.backend", logging.INFO, __file__, 0, + "startup: schema ready (idempotent)", None, None) + parsed = json.loads(app_module.json_formatter.format(rec)) + assert set(parsed) >= {"ts", "level", "logger", "request_id", "msg"} + assert parsed["logger"] == "slab.backend" + assert parsed["msg"] == "startup: schema ready (idempotent)" + + secret = logging.LogRecord("slab.backend", logging.INFO, __file__, 0, + "woo_app_password=hunter2", None, None) + parsed = json.loads(app_module.json_formatter.format(secret)) + assert parsed["msg"] == "[REDACTED secret material]" + + +def _client_with_env(env: dict, tmp_path): + """TestClient in lifespan (context-manager) mode with the given env overlay.""" + base = {k: v for k, v in os.environ.items() + if k not in ("WOO_BASE_URL", "SLAB_AES_KEY", "SLAB_ENV", "SLAB_DB_PATH")} + base.update(env) + base["SLAB_DB_PATH"] = str(tmp_path / "guard.db") + monkeypatch_env = base + import contextlib + + @contextlib.contextmanager + def run(): + saved = dict(os.environ) + os.environ.clear() + os.environ.update(monkeypatch_env) + try: + app_module.reinit_db(base["SLAB_DB_PATH"]) + with TestClient(app_module.app) as c: + yield c + finally: + os.environ.clear() + os.environ.update(saved) + + return run() + + +def test_startup_guard_refuses_missing_woo_url(tmp_path): + with pytest.raises(RuntimeError, match="WOO_BASE_URL"): + with _client_with_env({"SLAB_AES_KEY": "k" * 44}, tmp_path): + pass + + +def test_startup_guard_refuses_non_https_woo_url(tmp_path): + with pytest.raises(RuntimeError, match="https"): + with _client_with_env({"WOO_BASE_URL": "http://store.example.com", + "SLAB_AES_KEY": "k" * 44}, tmp_path): + pass + + +def test_startup_guard_refuses_missing_aes_key(tmp_path): + with pytest.raises(RuntimeError, match="SLAB_AES_KEY"): + with _client_with_env({"WOO_BASE_URL": "https://store.example.com"}, tmp_path): + pass + + +def test_startup_guard_warns_in_dev_env(tmp_path): + with _client_with_env({"SLAB_ENV": "dev"}, tmp_path) as c: + assert c.get("/api/health").status_code == 200 diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py new file mode 100644 index 0000000..e0f9045 --- /dev/null +++ b/backend/tests/test_db.py @@ -0,0 +1,40 @@ +import pytest +import tempfile +import os + +from backend.db import get_connection, init_db + + +EXPECTED_TABLES = { + "slabs", "slab_photos", "pricing_rules", "woo_categories", + "woo_attributes", "woo_attribute_terms", "woo_tags", "settings", + "schema_version", "prompts", "prompt_revisions", "sync_log", "inference_log", +} + + +def test_init_db_creates_all_13_tables(tmp_path): + db_path = str(tmp_path / "test_schema.db") + init_db(db_path) + conn = get_connection(db_path) + rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + names = {r["name"] for r in rows} + assert names >= EXPECTED_TABLES + assert len(EXPECTED_TABLES) == 13 + + +def test_slab_insert_read_roundtrip(tmp_path): + db_path = str(tmp_path / "test_slab.db") + init_db(db_path) + conn = get_connection(db_path) + now = "2026-01-01T00:00:00+00:00" + conn.execute( + """INSERT INTO slabs (id, status, sku, length_in, thickness_in, sqft, bdft, + client_rev, server_rev, created_at, updated_at) + VALUES ('slab-1', 'draft', 'SLAB-001', 10.0, 2.0, 5.0, 40.0, 1, 1, ?, ?)""", + (now, now), + ) + conn.commit() + row = conn.execute("SELECT id, sku, status FROM slabs WHERE id=?", ("slab-1",)).fetchone() + assert row["id"] == "slab-1" + assert row["sku"] == "SLAB-001" + assert row["status"] == "draft" diff --git a/backend/tests/test_exit_inference_off.py b/backend/tests/test_exit_inference_off.py index 3d82a08..ed48c87 100644 --- a/backend/tests/test_exit_inference_off.py +++ b/backend/tests/test_exit_inference_off.py @@ -17,14 +17,15 @@ import backend.db as db_mod @pytest.fixture def env(monkeypatch): tmp = tempfile.mkdtemp(prefix="exit_") - os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "e.db") - os.environ["SLAB_IMAGES_DIR"] = tmp - os.environ["WOO_BASE_URL"] = "https://store.example.com" - os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() - db_mod._reset_for_tests() + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "e.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://store.example.com") + monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) from backend.crypto import encrypt - conn = db_mod.init_db() + db_mod.init_db() + + conn = db_mod.get_connection() conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)") 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)") @@ -41,7 +42,6 @@ def env(monkeypatch): from backend.app import app yield TestClient(app), conn - db_mod._reset_for_tests() shutil.rmtree(tmp, ignore_errors=True) diff --git a/backend/tests/test_health_startup.py b/backend/tests/test_health_startup.py deleted file mode 100644 index f928c35..0000000 --- a/backend/tests/test_health_startup.py +++ /dev/null @@ -1,73 +0,0 @@ -"""TDD red tests for S0 health endpoint and startup guards (APP-30, APP-45, SETTINGS-10, SETTINGS-25).""" -import importlib -import os -import tempfile -from unittest.mock import patch - -import pytest -from fastapi.testclient import TestClient - -import backend.db as db_mod - - -def _reload_app(): - import backend.app as app_mod - return importlib.reload(app_mod) - - -def test_health_happy_path_returns_200_with_three_fields(monkeypatch): - """Given: writable db + image dir; When: GET /api/health; Then: 200 + {status, version, woo_reachable}.""" - with tempfile.TemporaryDirectory() as tmp: - db_path = os.path.join(tmp, "test.db") - img_dir = os.path.join(tmp, "images") - os.mkdir(img_dir) - monkeypatch.setenv("SLAB_DB_PATH", db_path) - monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir) - monkeypatch.setenv("WOO_BASE_URL", "https://example.com") - monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==") - monkeypatch.setenv("LOG_LEVEL", "INFO") - from backend.app import app - client = TestClient(app) - resp = client.get("/api/health") - assert resp.status_code == 200 - body = resp.json() - assert set(body.keys()) == {"status", "version", "woo_reachable"} - assert body["status"] == "ok" - db_mod._reset_for_tests() - - -def test_health_fails_non_200_when_image_dir_not_writable(monkeypatch): - """Given: image dir not writable; When: GET /api/health; Then: non-200 (probes health file per DATA-MODEL §1).""" - with tempfile.TemporaryDirectory() as tmp: - db_path = os.path.join(tmp, "test.db") - img_dir = os.path.join(tmp, "images") - os.mkdir(img_dir) - monkeypatch.setenv("SLAB_DB_PATH", db_path) - monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir) - monkeypatch.setenv("WOO_BASE_URL", "https://example.com") - monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==") - # force probe fail without relying on chmod (win32 owner can still write) - with patch("backend.app.probe_images_writable", return_value=False): - from backend.app import app - client = TestClient(app) - resp = client.get("/api/health") - assert resp.status_code != 200 - db_mod._reset_for_tests() - - -def test_startup_guard_refuses_on_missing_or_non_https_woo_base_url(monkeypatch): - """Given: WOO_BASE_URL missing or not https; When: import/create app; Then: raises with clear message (SETTINGS-10).""" - monkeypatch.delenv("WOO_BASE_URL", raising=False) - with pytest.raises(RuntimeError, match="WOO_BASE_URL"): - _reload_app() - monkeypatch.setenv("WOO_BASE_URL", "http://insecure.example.com") - with pytest.raises(RuntimeError, match="https"): - _reload_app() - - -def test_startup_guard_refuses_on_missing_slab_aes_key(monkeypatch): - """Given: SLAB_AES_KEY missing; When: import/create app; Then: raises with clear message (SETTINGS-25).""" - monkeypatch.setenv("WOO_BASE_URL", "https://example.com") - monkeypatch.delenv("SLAB_AES_KEY", raising=False) - with pytest.raises(RuntimeError, match="SLAB_AES_KEY"): - _reload_app() diff --git a/backend/tests/test_inference.py b/backend/tests/test_inference.py index 7997880..c589342 100644 --- a/backend/tests/test_inference.py +++ b/backend/tests/test_inference.py @@ -15,25 +15,54 @@ import backend.db as db_mod @pytest.fixture def env(monkeypatch): tmp = tempfile.mkdtemp(prefix="inf_") - os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "i.db") - os.environ["SLAB_IMAGES_DIR"] = tmp - os.environ["WOO_BASE_URL"] = "https://x.com" - os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() - db_mod._reset_for_tests() + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "i.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://x.com") + monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) from backend.crypto import encrypt - conn = db_mod.init_db() + db_mod.init_db() + + conn = db_mod.get_connection() conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_base_url',NULL,?)", (encrypt("https://infer.example.com"),)) conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_api_key',NULL,?)", (encrypt("sk-test"),)) conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_model','gpt-test',NULL)") conn.commit() + conn.close() from backend.app import app - yield TestClient(app), conn - db_mod._reset_for_tests() + # Yield a lazy connection: opening it eagerly would hold a WAL read slot + # during the app's first write (prompt seed), whose recovery checkpoint + # needs a brief exclusive lock and would deadlock on the open reader. + lazy = _LazyConn() + with TestClient(app) as c: + yield c, lazy shutil.rmtree(tmp, ignore_errors=True) +class _LazyConn: + """Defers the sqlite connection to first use (see fixture note).""" + + def __init__(self): + self._conn = None + + def _get(self): + if self._conn is None: + self._conn = db_mod.get_connection() + return self._conn + + def execute(self, *a, **k): + return self._get().execute(*a, **k) + + def commit(self): + return self._get().commit() + + def close(self): + if self._conn is not None: + self._conn.close() + self._conn = None + + def test_prompt_seed_and_revision(env): client, conn = env r = client.get("/api/v1/prompts/call1") @@ -77,19 +106,19 @@ def test_call1_seed_is_locked_expert_prompt(env): assert "character\":" not in CALL1_SEED.replace(" ", "") -def test_taxonomy_lists_call1_closed_shape(): +def test_taxonomy_lists_call1_closed_shape(monkeypatch): """#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( + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "t.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://x.com") + monkeypatch.setenv( "SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode(), ) - db_mod._reset_for_tests() - conn = db_mod.init_db() + db_mod.init_db() + conn = db_mod.get_connection() 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)") @@ -120,7 +149,6 @@ def test_taxonomy_lists_call1_closed_shape(): 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) diff --git a/backend/tests/test_integration_woo_mock.py b/backend/tests/test_integration_woo_mock.py index 62bad66..8ed1aa2 100644 --- a/backend/tests/test_integration_woo_mock.py +++ b/backend/tests/test_integration_woo_mock.py @@ -55,14 +55,15 @@ def _mock(monkeypatch, *, product_ok=True, sku_count=0, cat_subset="A", media_ok @pytest.fixture def env(monkeypatch): tmp = tempfile.mkdtemp(prefix="integ_") - os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "i.db") - os.environ["SLAB_IMAGES_DIR"] = tmp - os.environ["WOO_BASE_URL"] = "https://store.example.com" - os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() - db_mod._reset_for_tests() + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "i.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://store.example.com") + monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) from backend.crypto import encrypt - conn = db_mod.init_db() + db_mod.init_db() + + conn = db_mod.get_connection() 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("pw"),)) from datetime import datetime, timezone @@ -73,7 +74,6 @@ def env(monkeypatch): from backend.app import app yield TestClient(app), conn - db_mod._reset_for_tests() shutil.rmtree(tmp, ignore_errors=True) diff --git a/backend/tests/test_photos_api.py b/backend/tests/test_photos_api.py index 8da87a7..c866bec 100644 --- a/backend/tests/test_photos_api.py +++ b/backend/tests/test_photos_api.py @@ -12,21 +12,21 @@ import backend.db as db_mod @pytest.fixture -def photos_env(): +def photos_env(monkeypatch): tmp = tempfile.mkdtemp(prefix="photos_") - os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db") - os.environ["SLAB_IMAGES_DIR"] = tmp - os.environ["WOO_BASE_URL"] = "https://example.com" - os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() - db_mod._reset_for_tests() + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "p.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://example.com") + monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) from backend.app import app - conn = db_mod.init_db() + db_mod.init_db() + + conn = db_mod.get_connection() # seed a slab conn.execute("INSERT INTO slabs(id, sku, status, server_rev, created_at, updated_at) VALUES ('s1','SLAB-01','calibrated',1,'t','t')") conn.commit() yield TestClient(app), conn - db_mod._reset_for_tests() shutil.rmtree(tmp, ignore_errors=True) diff --git a/backend/tests/test_pricing.py b/backend/tests/test_pricing.py index 00996e8..2ed4e73 100644 --- a/backend/tests/test_pricing.py +++ b/backend/tests/test_pricing.py @@ -12,21 +12,20 @@ import backend.db as db_mod @pytest.fixture -def env(): +def env(monkeypatch): tmp = tempfile.mkdtemp(prefix="pricing_") - os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db") - os.environ["SLAB_IMAGES_DIR"] = tmp - os.environ["WOO_BASE_URL"] = "https://example.com" - os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() - db_mod._reset_for_tests() - from backend.app import app - - conn = db_mod.init_db() + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "p.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://example.com") + monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) + db_mod.init_db() + conn = db_mod.get_connection() for cid in (1, 7): conn.execute("INSERT OR IGNORE INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (?,?,?,NULL,?)", (cid, f"Sp{cid}", f"sp{cid}", cid)) conn.commit() + from backend.app import app + yield TestClient(app) - db_mod._reset_for_tests() shutil.rmtree(tmp, ignore_errors=True) diff --git a/backend/tests/test_publish.py b/backend/tests/test_publish.py index daee066..127c97f 100644 --- a/backend/tests/test_publish.py +++ b/backend/tests/test_publish.py @@ -32,14 +32,15 @@ def _full_slab(sku="SLAB-01", **over): @pytest.fixture def env(monkeypatch): tmp = tempfile.mkdtemp(prefix="pub_") - os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db") - os.environ["SLAB_IMAGES_DIR"] = tmp - os.environ["WOO_BASE_URL"] = "https://store.example.com" - os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() - db_mod._reset_for_tests() + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "p.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://store.example.com") + monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) from backend.crypto import encrypt - conn = db_mod.init_db() + db_mod.init_db() + + conn = db_mod.get_connection() # taxonomy conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)") conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)") @@ -72,7 +73,6 @@ def env(monkeypatch): from backend.app import app yield TestClient(app), conn - db_mod._reset_for_tests() shutil.rmtree(tmp, ignore_errors=True) diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index ba38aeb..77d1854 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -14,14 +14,14 @@ from backend import review as review_mod @pytest.fixture -def env(): +def env(monkeypatch): tmp = tempfile.mkdtemp(prefix="review_") - os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "r.db") - os.environ["SLAB_IMAGES_DIR"] = tmp - os.environ["WOO_BASE_URL"] = "https://example.com" - os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() - db_mod._reset_for_tests() - conn = db_mod.init_db() + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "r.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://example.com") + monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) + db_mod.init_db() + conn = db_mod.get_connection() # taxonomy: species + a live-edge-wood leaf + figure term conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)") conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)") @@ -31,7 +31,6 @@ def env(): from backend.app import app yield TestClient(app), conn - db_mod._reset_for_tests() shutil.rmtree(tmp, ignore_errors=True) diff --git a/backend/tests/test_schema.py b/backend/tests/test_schema.py deleted file mode 100644 index dc9ce13..0000000 --- a/backend/tests/test_schema.py +++ /dev/null @@ -1,46 +0,0 @@ -"""TDD red test for S0 schema load (DATA-MODEL.md contract).""" -import os -import sqlite3 -import tempfile - -import pytest - - -SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "..", "db", "schema.sql") - - -def test_schema_loads_cleanly_and_all_tables_exist(): - """Given: schema.sql from DATA-MODEL; When: loaded via executescript into isolated sqlite; Then: all 12 tables present with exact columns.""" - expected_tables = { - "slabs", - "slab_photos", - "pricing_rules", - "woo_categories", - "woo_attributes", - "woo_attribute_terms", - "woo_tags", - "settings", - "prompts", - "prompt_revisions", - "sync_log", - "inference_log", - } - with tempfile.TemporaryDirectory() as tmp: - db_path = os.path.join(tmp, "isolated.db") - assert os.path.exists(SCHEMA_PATH), "schema.sql must exist for test" - with open(SCHEMA_PATH, "r", encoding="utf-8") as f: - schema_sql = f.read() - conn = sqlite3.connect(db_path) - try: - conn.executescript(schema_sql) - conn.commit() - rows = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" - ).fetchall() - actual = {r[0] for r in rows} - assert expected_tables.issubset(actual), f"Missing tables: {expected_tables - actual}" - # spot check one column from slabs per DATA-MODEL - cols = [r[1] for r in conn.execute("PRAGMA table_info(slabs)").fetchall()] - assert "id" in cols and "status" in cols and "client_rev" in cols - finally: - conn.close() diff --git a/backend/tests/test_schema_drift.py b/backend/tests/test_schema_drift.py deleted file mode 100644 index 5bcd820..0000000 --- a/backend/tests/test_schema_drift.py +++ /dev/null @@ -1,62 +0,0 @@ -import os -import shutil -import sqlite3 -import tempfile - -import pytest - -import backend.db as db_mod - - -@pytest.fixture -def drift_db_dir(): - # Persistent temp dir; on Windows the global WAL connection can outlive the - # test frame, so we avoid TemporaryDirectory's immediate rmtree race and clean - # up best-effort after the handle is released. - tmp = tempfile.mkdtemp(prefix="slab_drift_") - yield tmp - db_mod._reset_for_tests() - shutil.rmtree(tmp, ignore_errors=True) - - - -def _setup_temp_db_with_m2_schema(tmp_dir: str) -> str: - db_path = os.path.join(tmp_dir, "test.db") - # M2-era settings shape: NO `value` column (this is the drift signature). - # Use a raw connection (DELETE journal) exactly as an old M2 db would be on disk. - conn = sqlite3.connect(db_path) - # M2-era settings: NO `value` column (drift signature). - conn.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value_enc BLOB, updated_at TEXT)") - conn.execute("INSERT INTO settings (key, value_enc, updated_at) VALUES ('woo_wp_username', X'00', '2026-01-01')") - # M2-era slabs: NO deleted_at / server_rev (drift signature). - conn.execute("CREATE TABLE slabs (id TEXT PRIMARY KEY, sku TEXT, status TEXT)") - conn.execute("INSERT INTO slabs (id, sku, status) VALUES ('old', 'OLD-1', 'calibrated')") - conn.commit() - conn.close() - # Force any journal sidecars to merge before the WAL-mode factory opens the file. - for suffix in ("-journal", "-wal", "-shm"): - try: - os.remove(db_path + suffix) - except OSError: - pass - return db_path - - -def test_drift_detection_and_rebuild(monkeypatch, drift_db_dir): - db_path = _setup_temp_db_with_m2_schema(drift_db_dir) - monkeypatch.setenv("SLAB_DB_PATH", db_path) - # init_db must detect pre-M3 drift and rebuild to the M3 settings shape - conn = db_mod.init_db() - cur = conn.execute("SELECT version FROM schema_version").fetchone() - assert cur is not None - assert cur[0] == 1 - info = conn.execute("PRAGMA table_info(settings)").fetchall() - cols = {row[1] for row in info} - assert "value" in cols - assert "value_enc" in cols - # slabs must also be rebuilt to the M3 shape (deleted_at present) - slab_cols = {row[1] for row in conn.execute("PRAGMA table_info(slabs)").fetchall()} - assert "deleted_at" in slab_cols and "server_rev" in slab_cols - # M2 rows are disposable (M3 supersedes M2 on this dev volume) - assert conn.execute("SELECT key FROM settings").fetchall() == [] - assert conn.execute("SELECT id FROM slabs").fetchall() == [] diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py index 0e6f425..937bce3 100644 --- a/backend/tests/test_settings_api.py +++ b/backend/tests/test_settings_api.py @@ -1,7 +1,7 @@ -"""TDD red tests for S1 Settings API (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6).""" +"""Settings API tests (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6).""" import base64 -import json import os +import shutil import tempfile import pytest @@ -24,165 +24,126 @@ def _setup_env(monkeypatch, tmp): monkeypatch.setenv("WOO_BASE_URL", "https://example.com") monkeypatch.setenv("SLAB_AES_KEY", _valid_key_b64()) monkeypatch.setenv("LOG_LEVEL", "INFO") - db_mod._reset_for_tests() -def test_settings_get_masks_secrets_and_shows_woo_base_url(monkeypatch): - """Given: some plain + secret settings; When: GET /api/v1/settings; Then: secrets only {configured:bool}, never value; woo_base_url from env (SETTINGS-5,75).""" - with tempfile.TemporaryDirectory() as tmp: - _setup_env(monkeypatch, tmp) - from backend.app import app - client = TestClient(app) - # seed some data via direct DB (will be replaced by service later) - conn = db_mod.get_connection() - db_mod.init_db() - conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('woo_wp_username', 'wpuser')") - conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('woo_app_password', 'badbase64')") # will be decrypt fail path - conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_model', 'gpt-4o')") - conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_enabled', 'true')") - conn.commit() - resp = client.get("/api/v1/settings") - assert resp.status_code == 200 - body = resp.json() - assert body["woo_base_url"] == "https://example.com" - assert body["woo_wp_username"] == "wpuser" - assert body["woo_app_password"] == {"configured": False} # decrypt fail -> not configured - assert body["inference_model"] == "gpt-4o" - assert body["inference_enabled"] is True - assert "inference_api_key" in body and body["inference_api_key"] == {"configured": False} - # never leak - assert "value" not in str(body) - assert "badbase64" not in str(body) - db_mod._reset_for_tests() +@pytest.fixture +def client(monkeypatch): + tmp = tempfile.mkdtemp(prefix="settings_") + _setup_env(monkeypatch, tmp) + from backend.app import app + + db_mod.init_db() + with TestClient(app) as c: + yield c + shutil.rmtree(tmp, ignore_errors=True) -def test_settings_put_rejects_woo_base_url(monkeypatch): - """Given: valid env; When: PUT /api/v1/settings with woo_base_url; Then: 400 validation (SETTINGS-85).""" - with tempfile.TemporaryDirectory() as tmp: - _setup_env(monkeypatch, tmp) - from backend.app import app - client = TestClient(app) - db_mod.init_db() - resp = client.put("/api/v1/settings", json={"woo_base_url": "https://evil.com"}) - assert resp.status_code == 400 - err = resp.json()["error"] - assert err["code"] == "validation" - assert "woo_base_url" in err["message"].lower() - db_mod._reset_for_tests() +def test_settings_get_masks_secrets_and_shows_woo_base_url(client): + """Given: some plain + secret settings; When: GET /api/v1/settings; Then: secrets + only {configured:bool}, never value; woo_base_url from env (SETTINGS-5,75).""" + conn = db_mod.get_connection() + conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('woo_wp_username', 'wpuser')") + conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('woo_app_password', 'badbase64')") + conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_model', 'gpt-4o')") + conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_enabled', 'true')") + conn.commit() + resp = client.get("/api/v1/settings") + assert resp.status_code == 200 + body = resp.json() + assert body["woo_base_url"] == "https://example.com" + assert body["woo_wp_username"] == "wpuser" + assert body["woo_app_password"] == {"configured": False} # decrypt fail -> not configured + assert body["inference_model"] == "gpt-4o" + assert body["inference_enabled"] is True + assert body["inference_api_key"] == {"configured": False} + assert "badbase64" not in str(body) + conn.close() -def test_settings_put_stores_encrypted_secrets(monkeypatch): - """Given: secret in PUT; When: PUT; Then: value_enc is base64 nonce||ct (not plaintext), plain fields direct (SETTINGS-20).""" - with tempfile.TemporaryDirectory() as tmp: - _setup_env(monkeypatch, tmp) - from backend.app import app - client = TestClient(app) - db_mod.init_db() - payload = { - "woo_app_password": "super-secret-pass", - "inference_base_url": "https://api.openai.com/v1", - "inference_api_key": "sk-xxx", - "inference_model": "gpt-4o-mini", - "inference_enabled": True, - "content_llm_enabled": False, - "brand_voice": "professional", - "geo_context": "PNW", - "woo_create_status": "draft", - } - resp = client.put("/api/v1/settings", json=payload) - assert resp.status_code == 200 - # check DB - conn = db_mod.get_connection() - rows = {r["key"]: (r["value"], r["value_enc"]) for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()} - assert rows["woo_app_password"][0] is None - assert rows["woo_app_password"][1] is not None - # value_enc must be valid base64 and >12 bytes when decoded (nonce+ct) - enc = rows["woo_app_password"][1] - blob = base64.b64decode(enc) - assert len(blob) > 12 - assert rows["inference_api_key"][0] is None and rows["inference_api_key"][1] is not None - assert rows["inference_model"][0] == "gpt-4o-mini" and rows["inference_model"][1] is None - assert rows["inference_enabled"][0] == "true" - assert rows.get("content_llm_enabled", (None, None))[0] == "false" - db_mod._reset_for_tests() +def test_settings_put_rejects_woo_base_url(client): + """Given: valid env; When: PUT /api/v1/settings with woo_base_url; Then: 400 (SETTINGS-85).""" + resp = client.put("/api/v1/settings", json={"woo_base_url": "https://evil.com"}) + assert resp.status_code == 400 + err = resp.json()["error"] + assert err["code"] == "validation" + assert "woo_base_url" in err["message"].lower() -def test_settings_flags_default_false_when_absent(monkeypatch): +def test_settings_put_stores_encrypted_secrets(client): + """Given: secret in PUT; When: PUT; Then: value_enc is base64 nonce||ct (not + plaintext), plain fields direct (SETTINGS-20).""" + payload = { + "woo_app_password": "super-secret-pass", + "inference_base_url": "https://api.openai.com/v1", + "inference_api_key": "sk-xxx", + "inference_model": "gpt-4o-mini", + "inference_enabled": True, + "content_llm_enabled": False, + "brand_voice": "professional", + "geo_context": "PNW", + "woo_create_status": "draft", + } + resp = client.put("/api/v1/settings", json=payload) + assert resp.status_code == 200 + conn = db_mod.get_connection() + rows = {r["key"]: (r["value"], r["value_enc"]) for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()} + assert rows["woo_app_password"][0] is None + assert rows["woo_app_password"][1] is not None + blob = base64.b64decode(rows["woo_app_password"][1]) + assert len(blob) > 12 + assert rows["inference_api_key"][0] is None and rows["inference_api_key"][1] is not None + assert rows["inference_model"][0] == "gpt-4o-mini" and rows["inference_model"][1] is None + assert rows["inference_enabled"][0] == "true" + assert rows.get("content_llm_enabled", (None, None))[0] == "false" + conn.close() + + +def test_settings_flags_default_false_when_absent(client): """Given: no flag rows; When: GET; Then: inference_enabled/content_llm_enabled == false (SETTINGS-90).""" - with tempfile.TemporaryDirectory() as tmp: - _setup_env(monkeypatch, tmp) - from backend.app import app - client = TestClient(app) - db_mod.init_db() - resp = client.get("/api/v1/settings") - assert resp.status_code == 200 - body = resp.json() - assert body["inference_enabled"] is False - assert body["content_llm_enabled"] is False - db_mod._reset_for_tests() + resp = client.get("/api/v1/settings") + assert resp.status_code == 200 + body = resp.json() + assert body["inference_enabled"] is False + assert body["content_llm_enabled"] is False -def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch): - """Given: corrupt value_enc for secret; When: GET; Then: configured:false, 200 not 500 (SETTINGS-20/15 spirit).""" - with tempfile.TemporaryDirectory() as tmp: - _setup_env(monkeypatch, tmp) - from backend.app import app - client = TestClient(app) - db_mod.init_db() - conn = db_mod.get_connection() - # insert corrupt (not even base64 or short) - conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('inference_api_key', '!!!notbase64!!!')") - conn.commit() - resp = client.get("/api/v1/settings") - assert resp.status_code == 200 # not 500 - body = resp.json() - assert body["inference_api_key"] == {"configured": False} - db_mod._reset_for_tests() +def test_settings_decrypt_failure_returns_reenter_state_not_500(client): + """Given: corrupt value_enc for secret; When: GET; Then: configured:false, 200 not 500.""" + conn = db_mod.get_connection() + conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('inference_api_key', '!!!notbase64!!!')") + conn.commit() + resp = client.get("/api/v1/settings") + assert resp.status_code == 200 + assert resp.json()["inference_api_key"] == {"configured": False} + conn.close() -def test_settings_crop_fill_defaults_to_90(monkeypatch): - """Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%).""" - with tempfile.TemporaryDirectory() as tmp: - _setup_env(monkeypatch, tmp) - from backend.app import app - client = TestClient(app) - db_mod.init_db() - resp = client.get("/api/v1/settings") - assert resp.status_code == 200 - assert resp.json()["crop_fill"] == 90 - db_mod._reset_for_tests() +def test_settings_crop_fill_defaults_to_90(client): + """Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 default 90%).""" + resp = client.get("/api/v1/settings") + assert resp.status_code == 200 + assert resp.json()["crop_fill"] == 90 -def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch): - """Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4).""" - with tempfile.TemporaryDirectory() as tmp: - _setup_env(monkeypatch, tmp) - from backend.app import app - client = TestClient(app) - db_mod.init_db() - resp = client.put("/api/v1/settings", json={"crop_fill": 75}) - assert resp.status_code == 200 - assert resp.json()["crop_fill"] == 75 - conn = db_mod.get_connection() - row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone() - assert row["value"] == "75" and row["value_enc"] is None - assert client.get("/api/v1/settings").json()["crop_fill"] == 75 - db_mod._reset_for_tests() +def test_settings_crop_fill_round_trips_as_plain_number(client): + """Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no + value_enc) and a number back (#99).""" + resp = client.put("/api/v1/settings", json={"crop_fill": 75}) + assert resp.status_code == 200 + assert resp.json()["crop_fill"] == 75 + conn = db_mod.get_connection() + row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone() + assert row["value"] == "75" and row["value_enc"] is None + assert client.get("/api/v1/settings").json()["crop_fill"] == 75 + conn.close() -def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch): - """Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer).""" - with tempfile.TemporaryDirectory() as tmp: - _setup_env(monkeypatch, tmp) - from backend.app import app - client = TestClient(app) - db_mod.init_db() - for bad in (0, 101, -5, "abc", None): - resp = client.put("/api/v1/settings", json={"crop_fill": bad}) - assert resp.status_code == 400, f"{bad!r} should be rejected" - assert resp.json()["error"]["code"] == "validation" - assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default - # boundaries are valid - assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200 - assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200 - db_mod._reset_for_tests() +def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(client): + """Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99).""" + for bad in (0, 101, -5, "abc", None): + resp = client.put("/api/v1/settings", json={"crop_fill": bad}) + assert resp.status_code == 400, f"{bad!r} should be rejected" + assert resp.json()["error"]["code"] == "validation" + assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default + assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200 + assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200 diff --git a/backend/tests/test_slabs_api.py b/backend/tests/test_slabs_api.py index 6a1fcd4..c298bb5 100644 --- a/backend/tests/test_slabs_api.py +++ b/backend/tests/test_slabs_api.py @@ -19,7 +19,6 @@ def _setup_env(monkeypatch, tmp): monkeypatch.setenv("WOO_BASE_URL", "https://example.com") monkeypatch.setenv("SLAB_AES_KEY", "aGVsbGxvb2xvY2g=") # dummy base64 monkeypatch.setenv("LOG_LEVEL", "INFO") - db_mod._reset_for_tests() # Taxonomy seeding helper diff --git a/backend/tests/test_woo.py b/backend/tests/test_woo.py index d3ab93d..8645db9 100644 --- a/backend/tests/test_woo.py +++ b/backend/tests/test_woo.py @@ -1,5 +1,4 @@ -"""S4 Woo client + taxonomy sync tests (mocked transport; never hit the real store).""" - +"""Woo client + taxonomy sync tests (mocked transport; never hit the real store).""" import base64 import json import os @@ -10,6 +9,7 @@ import pytest import backend.db as db_mod from backend.woo import ( + _USER_AGENT, WooAuthError, WooClient, WooConnectionError, @@ -21,21 +21,20 @@ from backend import taxonomy as taxonomy_mod @pytest.fixture -def db_dir(): +def db_dir(monkeypatch): tmp = tempfile.mkdtemp(prefix="woo_") - os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db") - os.environ["SLAB_IMAGES_DIR"] = tmp - os.environ["WOO_BASE_URL"] = "https://store.example.com" - os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() - db_mod._reset_for_tests() - conn = db_mod.init_db() + monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "t.db")) + monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) + monkeypatch.setenv("WOO_BASE_URL", "https://store.example.com") + monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) + db_mod.init_db() + conn = db_mod.get_connection() from backend.crypto import encrypt 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.commit() yield conn - db_mod._reset_for_tests() shutil.rmtree(tmp, ignore_errors=True) @@ -46,8 +45,7 @@ def _client(responses): def fake_request(url, method, data, headers): i = min(calls["n"], len(responses) - 1) calls["n"] += 1 - code, hdr, body = responses[i] - return code, hdr, body + return responses[i] return WooClient("admin", "secret", "https://store.example.com", request=fake_request), calls @@ -86,15 +84,47 @@ def test_missing_password_fails_closed(db_dir): WooClient.from_settings("https://store.example.com", "admin", None) +def test_missing_username_fails_closed(db_dir): + with pytest.raises(WooCredentialsMissing): + WooClient.from_settings("https://store.example.com", None, "enc") + + +def test_bad_ciphertext_fails_closed(db_dir): + with pytest.raises(WooCredentialsMissing): + WooClient.from_settings("https://store.example.com", "admin", "!!!not-base64!!!") + + def test_401_raises_auth_error(db_dir): def fake_request(url, method, data, headers): - raise WooAuthError("authentication rejected (401)") + return 401, {}, b'{"message": "Invalid username"}' c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) with pytest.raises(WooAuthError): c.get_categories() +def test_403_raises_forbidden_error(db_dir): + def fake_request(url, method, data, headers): + return 403, {}, b"{}" + + c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) + with pytest.raises(WooForbiddenError): + c.get_categories() + + +def test_requests_include_user_agent(db_dir): + seen_headers = {} + + def fake_request(url, method, data, headers): + seen_headers.update(headers) + return 200, {"X-WP-TotalPages": "1"}, b"[]" + + c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) + c.get_categories() + assert seen_headers.get("User-Agent") == _USER_AGENT + assert "SlabUploader" in seen_headers["User-Agent"] + + def test_sync_populates_scoped(db_dir): cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode()) tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([ @@ -208,25 +238,3 @@ def test_sync_with_slab_species_fk_does_not_fail(db_dir): } assert rows[11] == 10 # parent mapped; row kept for FK assert conn.execute("SELECT species_id FROM slabs WHERE id='slab-fk-1'").fetchone()[0] == 11 - - -def test_403_raises_forbidden_error(db_dir): - def fake_request(url, method, data, headers): - raise WooForbiddenError("request forbidden (403)") - - c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) - with pytest.raises(WooForbiddenError): - c.get_categories() - - -def test_requests_include_user_agent(db_dir): - seen_headers = {} - - def fake_request(url, method, data, headers): - seen_headers.update(headers) - return 200, {"X-WP-TotalPages": "1"}, b"[]" - - c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) - c.get_categories() - assert "User-Agent" in seen_headers - assert "SlabUploader" in seen_headers["User-Agent"] diff --git a/backend/woo.py b/backend/woo.py index 921cc53..c57caf1 100644 --- a/backend/woo.py +++ b/backend/woo.py @@ -1,20 +1,20 @@ -"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated. +"""WooCommerce REST v3 client (httpx, WordPress app-password basic auth, HTTPS-only). -Creds come from Settings (wp_username plain, app_password AES-GCM). Missing creds -raise WooCredentialsMissing (a fail-closed condition the caller maps to ok:false), -distinct from WooAuthError (a 401 from the store). +Creds come from Settings (wp_username plain, app_password AES-GCM). Missing +creds raise WooCredentialsMissing (a fail-closed condition the caller maps to +ok:false), distinct from WooAuthError (a 401 from the store). The transport is +a single injectable call so tests never hit a real store (mock boundary). """ +from __future__ import annotations import base64 import json -import urllib.error -import urllib.request from typing import Any, Callable -from .crypto import DecryptError, decrypt +import httpx # WordPress security plugins (Wordfence, WAF, CDN) commonly block the default -# Python-urllib User-Agent with 403. Send a descriptive one. +# Python User-Agent with 403. Send a descriptive one (#79). _USER_AGENT = "SlabUploader/1.0 (+https://www.whidbeywoodstore.com)" @@ -31,74 +31,93 @@ class WooAuthError(WooError): class WooForbiddenError(WooError): - """The store blocked the request (403) — usually a security plugin or WAF.""" + """The store blocked the request (403) — security plugin / WAF.""" class WooNotFoundError(WooError): - pass + """The store returned 404.""" class WooServerError(WooError): - pass + """The store returned a 5xx or an unexpected status.""" class WooConnectionError(WooError): """Store unreachable (DNS, TLS, timeout).""" -def _default_request(url: str, method: str, data: bytes | None, headers: dict[str, str]): - req = urllib.request.Request(url, method=method, data=data) - for k, v in headers.items(): - req.add_header(k, v) +Transport = Callable[[str, str, bytes | None, dict[str, str]], tuple[int, dict, bytes]] + + +def _default_request(url: str, method: str, data: bytes | None, headers: dict[str, str]) -> tuple[int, dict, bytes]: try: - with urllib.request.urlopen(req, timeout=15) as r: - return r.getcode(), dict(r.headers.items()), r.read() - except urllib.error.HTTPError as e: - body = e.read() - try: - parsed = json.loads(body) - except (ValueError, json.JSONDecodeError): - parsed = {} - msg = parsed.get("message") if isinstance(parsed, dict) else None - if e.code == 401: - raise WooAuthError(msg or "authentication rejected (401)") from e - if e.code == 403: - raise WooForbiddenError(msg or "request forbidden (403)") from e - if e.code == 404: - raise WooNotFoundError(msg or "not found (404)") from e - raise WooServerError(f"server error {e.code}: {msg}") from e - except (urllib.error.URLError, OSError, TimeoutError) as e: + with httpx.Client(timeout=15.0) as client: + resp = client.request(method, url, content=data, headers=headers) + except (httpx.TimeoutException, httpx.TransportError) as e: raise WooConnectionError(f"store unreachable: {e}") from e + return resp.status_code, dict(resp.headers.items()), resp.content + + +def _message(body: bytes) -> str | None: + try: + parsed = json.loads(body) + except (ValueError, UnicodeDecodeError): + return None + if isinstance(parsed, dict): + msg = parsed.get("message") + if isinstance(msg, str) and msg: + return msg + return None class WooClient: + """Minimal Woo REST v3 client. `request` is the transport seam for tests.""" + def __init__(self, username: str, password: str, base_url: str, - request: Callable[..., tuple[int, dict, bytes]] = _default_request) -> None: + request: Transport | None = None) -> None: if not base_url or not base_url.startswith("https://"): raise WooCredentialsMissing("WOO_BASE_URL must be https:// (SETTINGS-10)") self.base_url = base_url.rstrip("/") - self._request = request + self._request: Transport = request or _default_request self._auth = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") @classmethod def from_settings(cls, base_url: str, username: str | None, encrypted_password: str | None, - request: Callable[..., tuple[int, dict, bytes]] | None = None) -> "WooClient": - """Build from Settings values; raise WooCredentialsMissing if incomplete (fail-closed).""" + request: Transport | None = None) -> "WooClient": + """Build from Settings values; raise WooCredentialsMissing if incomplete.""" + from .crypto import DecryptError, decrypt + if not username: raise WooCredentialsMissing("woo_wp_username not set (SETTINGS-15)") if not encrypted_password: raise WooCredentialsMissing("woo_app_password not configured (SETTINGS-15)") try: password = decrypt(encrypted_password) - except DecryptError as e: - raise WooCredentialsMissing("woo_app_password cannot be decrypted (re-enter it)") from e - if request is None: - request = _default_request + except DecryptError: + raise WooCredentialsMissing("woo_app_password cannot be decrypted (re-enter it)") from None return cls(username, password, base_url, request) + def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + h = { + "Authorization": f"Basic {self._auth}", + "Accept": "application/json", + "User-Agent": _USER_AGENT, + } + if extra: + h.update(extra) + return h + + def _raise_for(self, code: int, body: bytes) -> None: + if code == 401: + raise WooAuthError(_message(body) or "authentication rejected (401)") + if code == 403: + raise WooForbiddenError(_message(body) or "request forbidden (403)") + if code == 404: + raise WooNotFoundError(_message(body) or "not found (404)") + raise WooServerError(f"server error {code}: {_message(body) or ''}".strip(": ")) + def _get(self, path: str) -> tuple[int, dict, bytes]: - url = f"{self.base_url}/wp-json/wc/v3{path}" - return self._request(url, "GET", None, self._auth_headers()) + return self._request(f"{self.base_url}/wp-json/wc/v3{path}", "GET", None, self._auth_headers()) def _fetch_all(self, path: str) -> list[dict[str, Any]]: page = 1 @@ -106,7 +125,7 @@ class WooClient: while True: code, headers, body = self._get(f"{path}?per_page=100&page={page}") if code != 200: - raise WooServerError(f"expected 200, got {code}") + self._raise_for(code, body) results.extend(json.loads(body)) total_pages = int(headers.get("X-WP-TotalPages") or headers.get("x-wp-totalpages") or 1) if page >= total_pages: @@ -125,55 +144,36 @@ class WooClient: def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]: 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(), + 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}") + self._raise_for(code, body) 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, + f"{self.base_url}/wp-json/wc/v3/products", "POST", + json.dumps(payload).encode("utf-8"), self._auth_headers({"Content-Type": "application/json"}), ) if code not in (200, 201): - raise WooServerError(f"product create returned {code}") + self._raise_for(code, resp) return int(json.loads(resp)["id"]) 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; returns the attachment id.""" boundary = "----slabuploader-media-boundary" body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' "Content-Type: image/png\r\n\r\n" ).encode("utf-8") + png_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8") - url = f"{self.base_url}/wp-json/wp/v2/media" - code, _headers, resp = self._request( - url, "POST", body, + code, _h, resp = self._request( + f"{self.base_url}/wp-json/wp/v2/media", "POST", body, self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}), ) if code not in (200, 201): - raise WooServerError(f"media upload returned {code}") + self._raise_for(code, resp) return int(json.loads(resp)["id"]) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 4f962bb..6bea53d 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -23,12 +23,12 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif | CAPTURE-35 | Sheet detection samples a 2 to 5 percent strip on all four borders and classifies green or black by HSV; otherwise the owner picks the sheet. | shipped | frontend/src/mask/sheetKeep.test.ts | | CAPTURE-40 | Mask controls are exactly Sheet (auto, green, black) and Sensitivity (0 to 100, step 1); Edge offset and Feather are removed. | shipped | — | | CAPTURE-41 | Sensitivity 0 to 100 maps to green-sheet chroma tightness and black-sheet brightness cutoff in the worker; both widen as sensitivity rises. | shipped | frontend/src/mask/sheetKeep.test.ts | -| CAPTURE-45 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.calibrate.test.ts | +| CAPTURE-45 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.test.ts | | CAPTURE-50 | The mask overlay renders on the source photo and the owner approves it; approval is blocked when the mask has no slab pixels. | shipped | — | | CAPTURE-55 | The mask must be a single contiguous slab contour; otherwise the resolution is a retake. | shipped | — | | CAPTURE-60 | The length axis is the long side of the minimum-area bounding rectangle of the mask; the owner rotates it to the prevailing length and confirms. | shipped | frontend/src/geometry/rect.test.ts | | CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — | -| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts | +| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/session.test.ts | | CAPTURE-75 | scale equals longest_axis_px divided by measured length, where longest_axis_px is the mask span end to end along the approved axis, not the rectangle edge. | shipped | — | | CAPTURE-80 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts | | CAPTURE-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts | @@ -36,14 +36,14 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif | CAPTURE-95 | The inventory PNG is 8-bit RGBA with alpha preserved through encode and upload. | shipped | frontend/src/png/inventory.test.ts | | CAPTURE-100 | Crop is 3:4 portrait, slab centered, 90 percent fill at the widest and tallest extremes, on a transparent background. | shipped | frontend/src/png/inventory.test.ts | | CAPTURE-105 | After crop the shorter side is at least 800 px when the source allows; otherwise warn and require a new photo, and never upscale. | shipped | frontend/src/png/inventory.test.ts | -| CAPTURE-110 | A non-calibration photo that yields an empty mask or fails the size floor is excluded with a per-photo error while the rest proceed. | shipped | frontend/src/capture/session.batchApply.test.ts | -| CAPTURE-115 | Scale, widths, sqft, and bdft stay tied to the calibration photo; non-calibration photos are visual inventory only. | shipped | frontend/src/capture/session.batchApply.test.ts | +| CAPTURE-110 | A non-calibration photo that yields an empty mask or fails the size floor is excluded with a per-photo error while the rest proceed. | shipped | frontend/src/capture/session.test.ts | +| CAPTURE-115 | Scale, widths, sqft, and bdft stay tied to the calibration photo; non-calibration photos are visual inventory only. | shipped | frontend/src/capture/session.test.ts | | CAPTURE-120 | The dimension overlay is black and oriented to the approved length axis, with the length graphic beside the wood on the right in axis space. | shipped | frontend/src/png/overlay.test.ts | | CAPTURE-125 | Overlay width stations run every 12 inches starting 6 inches from the bottom while station is at most length minus 6; the bottom end follows the calibration photo EXIF orientation. | shipped | frontend/src/png/overlay.test.ts | | CAPTURE-130 | The photos-ready lineup order is clean calibration PNG, dimension-overlay copy, then remaining batch photos in capture order. | shipped | frontend/src/capture/photosReadyLineup.test.ts | | CAPTURE-135 | The client computes all deterministic math; the server stores the client numbers and never recomputes them as source of truth. | shipped | — | | CAPTURE-140 | Load-bearing client APIs are image decode, canvas or OffscreenCanvas, a worker, PNG encode, IndexedDB, and fetch; no OpenCV, ONNX, or U2Net on the shipped path. | shipped | — | -| CAPTURE-141 | The preview is display-only: it fits the device width with a height cap so controls stay visible, and mask, rectangle, crop, scale, sqft, and PNG encode use source photo pixels, never the preview's CSS size. | shipped | frontend/src/capture/fitDisplay.test.ts | +| CAPTURE-141 | The preview is display-only: it fits the device width with a height cap so controls stay visible, and mask, rectangle, crop, scale, sqft, and PNG encode use source photo pixels, never the preview's CSS size. | shipped | frontend/src/capture/decodeOrientedBitmap.test.ts | | CAPTURE-145 | Capture knob preferences persist in localStorage until reset to default. | shipped | — | | CAPTURE-150 | Mask tuning has no apply step and no server round trip. | shipped | — | | CAPTURE-155 | Failures such as a broken mask or undersized source never silently guess; the resolution is retake. | shipped | — | diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 5d88873..06e134a 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -42,6 +42,8 @@ remain the point of record, and `.planning/` files cite requirement IDs without | `.planning/MILESTONES.md` | history | | `.planning/milestones/v0.5/M3/roadmap.md` | roadmap | | `.planning/milestones/v0.5/M3/task_breakdown.md` | breakdown | +| `.planning/milestones/v0.5/M4/goals.md` | roadmap | +| `.planning/milestones/v0.5/M4/tasks.md` | breakdown | Roles: diff --git a/frontend/package.json b/frontend/package.json index d20c8e3..52ced5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts", + "test": "node --import tsx --test --test-reporter=spec", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed" }, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a6b950b..f8a9018 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,282 +1,309 @@ -{#if gate} -
-

Unsupported browser

-

- SlabUploader needs a modern browser with canvas, workers, and IndexedDB support: - iOS Safari, Android Chrome, or desktop Chrome, Edge, or Firefox. -

-

Missing: {gate}

-
-{:else} -
- {#if curFlow.phase === 'review'} - { - const f = curFlow; - if (f.phase === 'review') enterPublish(f.slabId, f.capture); - }} - onBack={() => backToCapture()} - /> - {:else if curFlow.phase === 'publish'} - markDone(curFlow.slabId, wooId, draft)} - onBack={() => { - const f = curFlow; - if (f.phase === 'publish') backToReview(f.slabId, f.capture); - }} - /> - {:else if curFlow.phase === 'done'} -
-

Published

-

- {#if curFlow.draft} - Created as a UAT draft in the store. - {:else} - Created in the store. - {/if} -

-

Woo product #{curFlow.wooProductId} · photos and device draft cleared.

-
- -
-
- {:else if curFlow.phase === 'saving'} -
-

Saving slab…

-
- {:else} - - {#if cap.k === 'resume'} - session.continueDraft()} onFresh={() => void session.startFresh()} /> - {:else if cap.k === 'batch'} - void session.addToBatch(f)} - onRemove={session.removeFromBatch} - onContinue={session.continueBatch} - /> - {:else if cap.k === 'pickCalibration'} - - {:else if cap.k === 'mask'} - - {:else if cap.k === 'axis'} - - {:else} - void continueToReview()} - /> +
+
+

SlabUploader

+ +
- -
- - + {#if $error} + + {/if} + + {#if $flow === 'start'} +
+

One-of-a-kind wood slabs → Whidbey Wood Store

+

Capture • Measure • Review • Publish — real store API, SLAB-UAT-* publishes as draft

+
+ + + {#if $slab} + + {/if} +
+ Taxonomy: {$taxonomy ? `synced ${$taxonomy.taxonomy_last_sync_at ?? 'unknown'}` : 'not synced (Settings → Test Woo)'} +
+ {:else if $flow === 'capture'} +
+

Choose a capture path:

+
+ + +
- {#if showSettings} - + {:else if $flow === 'captureImport'} + onCaptureComplete(e.detail.photos, e.detail.calId)} /> + {:else if $flow === 'captureSession'} + flow.set('photosReady')} /> + {:else if $flow === 'calibrate' && $slab} + {@const calibrationPhoto = $slab.photos.find((photo) => photo.id === $slab.calibrationPhotoId)} + {#if calibrationPhoto} + onTapeApproved(e.detail.tapeLine)} /> {/if} - {#if showSlabs} - - {/if} -
-{/if} + {:else if $flow === 'measure' && $slab} + onMeasureUpdate(e.detail)} on:next={() => flow.set('review')} /> + {:else if $flow === 'review' && $slab} + onReviewReady(e.detail)} + on:saved={(e) => onReviewSaved(e.detail)} + on:back={() => flow.set('measure')} + /> + {:else if $flow === 'publish' && $slab} + onPublishSuccess(e.detail)} on:back={() => flow.set('review')} /> + {:else if $flow === 'settings'} + { loadTaxonomy(); flow.set('start'); }} /> + {:else if $flow === 'photosReady'} + + {/if} + +
+ Greenfield rewrite • M3 vertical path • real /api/v1 (taxonomy, slabs, review, publish, settings, inference) +
+
diff --git a/frontend/src/api/slabs.ts b/frontend/src/api/slabs.ts deleted file mode 100644 index 34a5688..0000000 --- a/frontend/src/api/slabs.ts +++ /dev/null @@ -1,499 +0,0 @@ -// 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 { - id: string; - sku: string; - status: string; - price: number | null; - price_source: string | null; - length_in: number; - thickness_in: number; - sqft: number; - bdft: number; - width_min_in: number; - width_max_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; - 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; - created_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 | null; -} - -export class ApiError extends Error { - constructor( - public status: number, - public code: string | null, - message: string, - public detail?: Record | null, - ) { - super(message); - this.name = 'ApiError'; - } -} - -async function parse(res: Response): Promise { - if (res.status === 204) return undefined 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 { - return fetch('/api/v1/slabs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: jsonBody(input), - }).then((r) => parse(r)); -} - -export function listSlabs(): Promise { - return fetch('/api/v1/slabs').then((r) => parse(r)); -} - -export function getSlab(id: string): Promise { - return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse(r)); -} - -export function updateSlab(id: string, patch: SlabInput & { client_rev: number }): Promise { - return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: jsonBody(patch), - }).then((r) => parse(r)); -} - -export function deleteSlab(id: string): Promise { - return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) => - parse(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 { - return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) => - parse(r), - ); -} - -export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise { - 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(r)); -} - -// #90: drop a photo the owner removed in Review (keep/remove edit). -export function deletePhoto(slabId: string, photoId: string): Promise { - return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, { - method: 'DELETE', - }).then((r) => parse(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 { - 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 { - return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: jsonBody(payload), - }).then((r) => parse(r)); -} - -// --------------------------------------------------------------------------- -// Publish (PUBLISH-*) -// --------------------------------------------------------------------------- - -export interface PublishResult { - ok: true; - status: string; - woo_product_id: number; - draft: boolean; -} - -export function publishSlab(slabId: string): Promise { - return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { - method: 'POST', - }).then((r) => parse(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 { - return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, { - method: 'POST', - }).then((r) => parse(r)); -} - -export function inferContent(slabId: string): Promise { - return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { - method: 'POST', - }).then((r) => parse(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 { - return fetch('/api/v1/admin/taxonomy').then((r) => parse(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 { - return fetch('/api/v1/admin/pricing').then((r) => parse(r)); -} - -export function createPricingRule(rule: { - species_id: number; - price_per_bdft: number; -}): Promise { - return fetch('/api/v1/admin/pricing', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: jsonBody(rule), - }).then((r) => parse(r)); -} - -export function updatePricingRule( - id: number, - patch: { price_per_bdft: number; species_id?: number }, -): Promise { - return fetch(`/api/v1/admin/pricing/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: jsonBody(patch), - }).then((r) => parse(r)); -} - -export function deletePricingRule(id: number): Promise { - return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse(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; - /** #99: global crop fill %, default 90. Drives the ref line + the PNG crop. */ - crop_fill: number; -} - -export function getSettings(): Promise { - return fetch('/api/v1/settings').then((r) => parse(r)); -} - -export function updateSettings( - patch: Partial>, -): Promise { - return fetch('/api/v1/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: jsonBody(patch), - }).then((r) => parse(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 { - return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) => - parse(r), - ); -} - -export function testInference(): Promise { - return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) => - parse(r), - ); -} diff --git a/frontend/src/browser.ts b/frontend/src/browser.ts deleted file mode 100644 index ab02fb9..0000000 --- a/frontend/src/browser.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Supported clients: iOS Safari, Android Chrome, PC Chrome/Edge/Firefox. -// Gated on required API presence; anything missing → hard stop. -export function supported(): string | null { - if (typeof window === 'undefined') return 'window unavailable'; - if (typeof createImageBitmap !== 'function') return 'createImageBitmap'; - if (typeof Worker === 'undefined') return 'Worker'; - if (typeof indexedDB === 'undefined') return 'IndexedDB'; - const hasOffscreen = - typeof OffscreenCanvas !== 'undefined' && - typeof OffscreenCanvas.prototype.convertToBlob === 'function'; - const hasCanvasBlob = - typeof HTMLCanvasElement !== 'undefined' && - typeof HTMLCanvasElement.prototype.toBlob === 'function'; - if (!hasOffscreen && !hasCanvasBlob) return 'canvas PNG encode'; - const ua = navigator.userAgent; - const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); - const isAndroid = /Android/i.test(ua); - const isSafari = /Safari/i.test(ua) && !/Chrome|CriOS|Edg|Firefox|FxiOS/i.test(ua); - const isChrome = /Chrome|CriOS/i.test(ua) && !/Edg/i.test(ua); - const isEdge = /Edg/i.test(ua); - const isFirefox = /Firefox|FxiOS/i.test(ua); - if (isIOS && isSafari) return null; - if (isAndroid && isChrome) return null; - if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null; - 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); - }); -} diff --git a/frontend/src/capture/AxisPanel.svelte b/frontend/src/capture/AxisPanel.svelte deleted file mode 100644 index 94f597b..0000000 --- a/frontend/src/capture/AxisPanel.svelte +++ /dev/null @@ -1,251 +0,0 @@ - - -
-

Tape-line calibration

-

Place both rifle-scope ticks on the actual start and end marks of your tape. Drag the photo to pan, use the zoom slider or wheel to zoom, and rotate until the tape follows the slab's length.

-
- -
-
- - { angleDeg = Number(event.currentTarget.value); draw(); }} /> - {angleDeg.toFixed(1)}° -
-
- - { zoom = Number(event.currentTarget.value); draw(); }} /> - {Math.round(zoom * 100)}% - -
-

Tick distance in source photo: {tickDistance.toFixed(1)} px

-
- - -
-
- - diff --git a/frontend/src/capture/BatchPhotos.svelte b/frontend/src/capture/BatchPhotos.svelte deleted file mode 100644 index f115195..0000000 --- a/frontend/src/capture/BatchPhotos.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - -
-

Batch photos

-
    -
  • Sheet is green or black, fills the frame around the slab.
  • -
  • Slab fully on the sheet, nothing on top of it.
  • -
  • Top-down reference shot: lens parallel to the face.
  • -
  • Slab length along the long side of the picture.
  • -
  • Add one or more photos of the same slab, then Continue.
  • -
- - {#if photos.length > 0} -
    - {#each photos as _p, i (urls[i])} -
  • - {`Batch - -
  • - {/each} -
- {:else} -

No photos yet. Add at least one to continue.

- {/if} - -
- - -
- - -
- - diff --git a/frontend/src/capture/MaskPanel.svelte b/frontend/src/capture/MaskPanel.svelte deleted file mode 100644 index 452228b..0000000 --- a/frontend/src/capture/MaskPanel.svelte +++ /dev/null @@ -1,93 +0,0 @@ - - -
-

Mask

-
- - {#if busy}
Working…
{/if} - {#if state.failed}
Mask pass failed. Adjust and retry, or retake the photo.
{/if} - {#if empty}
No slab pixels in the mask. Adjust sensitivity or retake.
{/if} -
- -
-
- {#each sheets as s (s)} - - {/each} -
-
- - setSensitivity(Number(e.currentTarget.value))} - /> - - {state.control.sensitivity} -
-
- -
- - - -
-
- - diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte deleted file mode 100644 index bd04675..0000000 --- a/frontend/src/capture/MeasurePanel.svelte +++ /dev/null @@ -1,243 +0,0 @@ - - -
-

{photosReady ? 'Photos ready' : 'Measure'}

- - {#if !photosReady} - 0 ? state.source.widthPx : 0} - maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0} - /> - -
-
- Tape length L (distance between ticks) - -
-
- Thickness - -
-
-

Tape ticks: {Math.hypot(state.axis.tapeLine.end[0] - state.axis.tapeLine.start[0], state.axis.tapeLine.end[1] - state.axis.tapeLine.start[1]).toFixed(1)} source-photo px · Scale {tapeScale.toFixed(2)} px/in

- -
- - -
- {:else if state.results} -
-
-
Widths{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})
-
Sqft{state.results.sqft.toFixed(2)}
-
Bdft{state.results.bdft.toFixed(2)}
-
Scale{state.results.scale.toFixed(2)} px/in
-
- {#if state.results.sourceBelowFloor} -
Source is below the 800px floor for the inventory PNG. Retake a larger photo.
- {/if} - -
    - {#each lineupItems as item, i (i)} - {#if item.kind === 'png'} -
  1. - {photosReadyPngAlt(item)} - - {#if item.role === 'calibration'} - Clean calibration - {:else if item.role === 'overlay'} - Dimension overlay - {:else} - Batch photo {(item.batchIndex ?? 0) + 1} - {/if} - -
  2. - {:else} - - {/if} - {/each} -
- - - {#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor} -
- Inventory PNG -
- {/if} -
- -
- - {#if onContinue} - - {/if} -
- {/if} -
- - diff --git a/frontend/src/capture/PhotoImport.svelte b/frontend/src/capture/PhotoImport.svelte new file mode 100644 index 0000000..d97ba76 --- /dev/null +++ b/frontend/src/capture/PhotoImport.svelte @@ -0,0 +1,174 @@ + + +
+

1. Import Photos (batch on green/black sheet)

+

CAPTURE-20/25: Select calibration photo. Simplified: no full mask worker (focus vertical path).

+
+ + {previews.length ? `${previews.length} photo${previews.length === 1 ? '' : 's'} selected` : 'No photos selected'} + +
+
+ + +
+ {#if error}

{error}

{/if} + + {#if previews.length} +
+ {#each previews as p, i} +
selectCal(i)} on:keydown={(e) => onThumbKey(e, i)} aria-pressed={i === calIndex}> + photo {i} + + {p.seq} +
+ {/each} +
+ + {/if} +
+ + diff --git a/frontend/src/capture/PhotoStage.svelte b/frontend/src/capture/PhotoStage.svelte deleted file mode 100644 index 7b003c2..0000000 --- a/frontend/src/capture/PhotoStage.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - -
- {#if imgW > 0 || decoding || blob} - - {:else} -
Decoding photo…
- {/if} -
- - diff --git a/frontend/src/capture/PhotosReady.svelte b/frontend/src/capture/PhotosReady.svelte new file mode 100644 index 0000000..b04d86a --- /dev/null +++ b/frontend/src/capture/PhotosReady.svelte @@ -0,0 +1,76 @@ + + +
+

Photos ready (CAPTURE-130)

+

Lineup: clean calibration PNG → dimension-overlay copy → batch photos in capture order.

+ + {#if results} +
+
Length {fmt(results.lengthEighths)}
+
Thickness {fmt(results.thicknessEighths)}
+
Sqft {results.sqft}
+
Bdft {results.bdft}
+
Widths min/avg/max {fmt(results.widths.min)} / {fmt(results.widths.avg)} / {fmt(results.widths.max)}
+ {#if results.sourceBelowFloor} +

Calibration source is below the 800 px floor — retake recommended.

+ {/if} +
+ {/if} + +
+ {#each items as item, i (i)} + {#if item.kind === 'png'} +
+ {photosReadyPngAlt(item)} +
{photosReadyPngAlt(item)}
+
+ {:else} +
+
Photo {item.batchIndex + 1} failed
+
{item.message}
+
+ {/if} + {:else} +

No photos processed. Go back and Calculate.

+ {/each} +
+ + +
+ + diff --git a/frontend/src/capture/PickCalibration.svelte b/frontend/src/capture/PickCalibration.svelte deleted file mode 100644 index 7a270b3..0000000 --- a/frontend/src/capture/PickCalibration.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - -
-

Pick calibration photo

-

- Choose which photo is the top-down reference for length and thickness. - Mask, length axis, and Measure run on that photo only. -

- -
    - {#each photos as _p, i (urls[i])} -
  • - {`Photo - -
  • - {/each} -
- - -
- - diff --git a/frontend/src/capture/Resume.svelte b/frontend/src/capture/Resume.svelte deleted file mode 100644 index 405de66..0000000 --- a/frontend/src/capture/Resume.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -
-

Resume?

-

You have an in-progress slab on this device.

-
- - -
-
- - diff --git a/frontend/src/capture/SessionCapture.svelte b/frontend/src/capture/SessionCapture.svelte new file mode 100644 index 0000000..59958f4 --- /dev/null +++ b/frontend/src/capture/SessionCapture.svelte @@ -0,0 +1,170 @@ + + +
+ {#if kind === 'batch'} +

1. Batch photos

+

Add one or more photos of the slab on a green or black sheet.

+
+ + {photos.length} photo{photos.length === 1 ? '' : 's'} + +
+ {#if photos.length} +
+ {#each photos as p, i (i)} +
+ photo {i} + +
+ {/each} +
+ + {/if} + + {:else if kind === 'pickCalibration'} +

2. Pick calibration photo

+

Choose the reference shot that will drive mask, axis, and measure.

+
+ {#each photos as p, i (i)} +
session.pickCalibration(i)} on:keydown={(e) => onThumbKey(e, i)} aria-label="pick photo {i} as calibration"> + photo {i} +
+ {/each} +
+ + + {:else if kind === 'mask'} +

3. Mask

+

Adjust sheet and sensitivity, then approve the mask.

+ {#if control} +
+ + +
+ {/if} + {#if failed} +

No slab pixels detected. Adjust controls or retake.

+ {:else if overlay} +

Mask settled: {overlay.slabPx} slab pixels.

+ {/if} +
+ + +
+ + {:else if kind === 'axis'} +

4. Length axis

+

Place the tape endpoints at the two ends of the slab's length.

+
+ + +
+ + {:else if kind === 'measure'} +

5. Measure

+

Enter length and thickness (1/8″ steps). Widths, sqft, and bdft are derived from the calibration mask.

+ {#if measure} +
+ + +
+ {/if} + {#if results} +
+
Length {fmt(results.lengthEighths)}
+
Thickness {fmt(results.thicknessEighths)}
+
Sqft {results.sqft}
+
Bdft {results.bdft}
+
+ + {:else} + + {/if} + + + {:else} +

Unknown capture state.

+ {/if} +
+ + diff --git a/frontend/src/capture/TapeCalibration.svelte b/frontend/src/capture/TapeCalibration.svelte new file mode 100644 index 0000000..1993af7 --- /dev/null +++ b/frontend/src/capture/TapeCalibration.svelte @@ -0,0 +1,139 @@ + + +
+

Tape-line calibration

+

Place the two yellow ticks on the actual start and end marks of the measuring tape. This line becomes the length axis and calibration scale.

+ +
+ + +
+ +
+ + diff --git a/frontend/src/capture/capture.types.test.ts b/frontend/src/capture/capture.types.test.ts new file mode 100644 index 0000000..062ff33 --- /dev/null +++ b/frontend/src/capture/capture.types.test.ts @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn, inToEighths } from './capture.types'; +import { EMPTY_ABOX as RECT_EMPTY_ABOX, EMPTY_BBOX as RECT_EMPTY_BBOX } from '../geometry/rect'; + +test('eighthsToIn divides by 8', () => { + assert.equal(eighthsToIn(8), 1); + assert.equal(eighthsToIn(48), 6); + assert.equal(eighthsToIn(1), 0.125); + assert.equal(eighthsToIn(0), 0); +}); + +test('inToEighths rounds to the nearest eighth', () => { + assert.equal(inToEighths(1), 8); + assert.equal(inToEighths(1.5), 12); + assert.equal(inToEighths(1.125), 9); + assert.equal(inToEighths(1.14), 9, 'half-up at the eighth boundary'); + assert.equal(inToEighths(0), 0); +}); + +test('eighths ↔ inches round-trip for whole and half eighths', () => { + for (const e of [0, 1, 8, 12, 47, 96]) { + assert.equal(inToEighths(eighthsToIn(e)), e); + } +}); + +test('DEFAULT_MASK_PARAMS is auto sheet at 50 sensitivity', () => { + assert.deepEqual(DEFAULT_MASK_PARAMS, { sheet: 'auto', sensitivity: 50 }); +}); + +test('EMPTY_BBOX / EMPTY_ABOX are re-exported from geometry/rect (no duplicate defs)', () => { + assert.equal(EMPTY_BBOX, RECT_EMPTY_BBOX); + assert.equal(EMPTY_ABOX, RECT_EMPTY_ABOX); + assert.equal(EMPTY_BBOX.longSidePx, 0); + assert.equal(EMPTY_ABOX.w, 0); +}); diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index 85e95a0..d6d8658 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -1,71 +1,57 @@ -// Domain model for the capture flow (M1 + M2 batch). -// The Capture tagged union is the state machine: each branch carries exactly the -// data its state can hold, so an illegal state is a type error. +/** + * Shared domain model for the M2 capture session (PHOTOS.md, CAPTURE-100..135). + * + * Reuses the P1 types instead of redefining them: + * - `ABox` / `BBox` / `TapeLine` / `EMPTY_BBOX` / `EMPTY_ABOX` from `geometry/rect` + * - `Eighths` / `Widths` from `measure/measure` + * - the `Sheet` union is `'auto' | SheetKind` where `SheetKind` comes from `mask/sheetKeep` + * + * `Axis` is a local composition of the P1 `BBox` + `TapeLine` (no P1 export for it). + */ +import type { ABox, BBox, TapeLine } from '../geometry/rect'; +import { EMPTY_ABOX, EMPTY_BBOX } from '../geometry/rect'; +import type { Eighths, Widths } from '../measure/measure'; +import type { SheetKind } from '../mask/sheetKeep'; -// Integer eighths of an inch; divide by 8 only at display. -export type Eighths = number; +export type { ABox, BBox, TapeLine, Eighths, Widths }; +export { EMPTY_ABOX, EMPTY_BBOX }; +/** Integer eighths of an inch; divide by 8 only at display. */ export const inToEighths = (inches: number): Eighths => Math.round(inches * 8); export const eighthsToIn = (e: Eighths): number => e / 8; -// Source: all geometry, measurement, and PNG work uses source pixels, never the -// CSS size of the preview. +/** All geometry, measurement, and PNG work uses source pixels, never CSS size. */ export interface Source { widthPx: number; heightPx: number; blob: Blob; } -export type Sheet = 'auto' | 'green' | 'black'; +export type Sheet = 'auto' | SheetKind; + export interface MaskParams { sheet: Sheet; sensitivity: number; // 0–100, step 1 } -// Axis-aligned box, source px. -export interface ABox { - x: number; - y: number; - w: number; - h: number; -} +export const DEFAULT_MASK_PARAMS: MaskParams = { sheet: 'auto', sensitivity: 50 }; -// Minimum-area bounding rectangle of the mask, source px. -export interface BBox { - corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side - longSidePx: number; // geometric extent only; scale uses mask span along the axis - angleDeg: number; // long-side angle, degrees, [0, 180) -} - -// The approved mask is the footprint for axis, measurement, and the inventory PNG. +/** The approved mask is the footprint for axis, measurement, and the inventory PNG. */ export interface Mask { params: MaskParams; slabPx: number; // 0 ⇒ approve blocked, user retakes bitset: Uint8Array; // source-grid mask, 1 byte/px bbox: BBox; - slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (settings.crop_fill, default 90%) + slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (default 90% fill) } -/** The owner-positioned start/end points of the tape in oriented source pixels. */ -export interface TapeLine { - start: [number, number]; - end: [number, number]; -} - -// Direction only: an oriented rectangle over the slab. The arrow reads -// corners[3] → corners[0] (the long side). +/** Direction only: an oriented rectangle over the slab; the arrow reads corners[3] → corners[0]. */ export interface Axis { bbox: BBox; tapeLine: TapeLine; } -export interface Widths { - min: Eighths; - max: Eighths; - avg: Eighths; - every6In: Eighths[]; -} - +/** Scale, widths, sqft, and bdft tied to the calibration photo (CAPTURE-115). */ export interface Measures { lengthEighths: Eighths; thicknessEighths: Eighths; @@ -74,22 +60,27 @@ export interface Measures { sqft: number; // 2 dp bdft: number; // 2 dp inventoryPng: Blob; - /** Dimension-overlay copy of the calibration inventory PNG (#63). */ + /** Dimension-overlay copy of the calibration inventory PNG. */ overlayPng?: Blob; sourceBelowFloor: boolean; } -// Result of applying the calibration mask to one batch sibling. The -// calibration slot is null because its inventory PNG lives in Measures. +/** + * Result of applying the calibration mask to one batch sibling (CAPTURE-110). + * The calibration slot is null because its inventory PNG lives in Measures. + */ export type BatchPhotoResult = | null | { status: 'ok'; inventoryPng: Blob } | { status: 'error'; message: string }; -// The machine. 'resume' exists only while a draft exists and has not been chosen. -// M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration. +/** + * The state machine. Each branch carries exactly the data its state can hold, + * so an illegal state is a type error. `resume` exists only while a draft + * exists and has not been chosen. + */ export type Capture = - | { k: 'resume'; draft: Draft } + | { k: 'resume'; draft: unknown } | { k: 'batch'; photos: Source[] } | { k: 'pickCalibration'; photos: Source[] } | { @@ -116,61 +107,3 @@ export type Capture = /** Capture-order sibling results from the post-Calculate batch pass. */ batchApply?: BatchPhotoResult[]; }; - -// Placeholder geometry for batch-only drafts (no mask yet). -export const EMPTY_BBOX: BBox = { - corners: [ - [0, 0], - [0, 0], - [0, 0], - [0, 0], - ], - longSidePx: 0, - angleDeg: 0, -}; -export const EMPTY_ABOX: ABox = { x: 0, y: 0, w: 0, h: 0 }; -export const DEFAULT_MASK_PARAMS: MaskParams = { sheet: 'auto', sensitivity: 50 }; - -// Device draft (IndexedDB): one in-progress slab. Small by design — the bitset -// and PNG are re-derived from stored data, never stored second-hand. -// M2 extends with `batch` (capture-order photo blobs). Legacy M1 drafts omit it. -export interface Draft { - version: 1; - stage: 'batch' | 'pickCalibration' | 'mask' | 'axis' | 'measure'; // last approved / persisted state - /** Capture-order batch. Absent on legacy M1 drafts ⇒ treat as [source]. */ - batch?: Source[]; - /** Index into batch of the user-picked calibration photo. Set once picked. */ - calibrationIndex?: number; - source: Source; // calibration / active photo; for stage==='batch'|'pickCalibration' = photos[0] placeholder - maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch') - mask: Omit; // slabPx, bbox, slabBounds - axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts - tapeLine?: TapeLine; // owner-placed tape endpoints; absent on pre-M4 drafts - lengthEighths: Eighths; - thicknessEighths: Eighths; - results: Omit | null; // present when results computed - inventoryPng: Blob | null; // stored so Continue can show Measure results - /** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */ - overlayPng?: Blob | null; - /** Capture-order batch apply results; blobs are retained for the #64 lineup. */ - batchApply?: BatchPhotoResult[]; -} - -// Worker protocol. Job ids increase; the worker drops anything older than the -// current id, so a stale result can never blank a newer one. -export type WorkerIn = - | { id: number; kind: 'init'; blob: Blob } - | { id: number; kind: 'mask'; sheet: Sheet; sensitivity: number } - | { id: number; kind: 'invalidate' }; - -export type WorkerOut = - | { id: number; kind: 'decoded'; w: number; h: number } - | { - id: number; - kind: 'ready'; - slabPx: number; - bitset: Uint8Array; - bbox: BBox; - slabBounds: ABox; - } - | { id: number; kind: 'failed'; reason: string }; diff --git a/frontend/src/capture/decodeOrientedBitmap.test.ts b/frontend/src/capture/decodeOrientedBitmap.test.ts index df757ae..cf842dc 100644 --- a/frontend/src/capture/decodeOrientedBitmap.test.ts +++ b/frontend/src/capture/decodeOrientedBitmap.test.ts @@ -3,10 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { - decodeOrientedBitmap, - ORIENTED_BITMAP_OPTIONS, -} from '../decodeOrientedBitmap.ts'; +import { decodeOrientedBitmap, ORIENTED_BITMAP_OPTIONS } from './decodeOrientedBitmap'; const here = dirname(fileURLToPath(import.meta.url)); const fixturePath = join(here, '../../tests/fixtures/set1-48x2/face-a.jpeg'); @@ -92,6 +89,7 @@ test('decodeOrientedBitmap always requests imageOrientation from-image (#72)', a assert.equal(ORIENTED_BITMAP_OPTIONS.imageOrientation, 'from-image'); assert.equal(bmp.width, 2256); assert.equal(bmp.height, 4000); + bmp.close(); }); test('decodeOrientedBitmap falls back to bare createImageBitmap if option rejected (#72)', async () => { @@ -109,41 +107,19 @@ test('decodeOrientedBitmap falls back to bare createImageBitmap if option reject assert.equal(calls, 2, 'retry bare after option rejection'); assert.equal(bmp.width, 90); assert.equal(bmp.height, 160); + bmp.close(); }); -test('decodeOrientedBitmap: Chromium orients fixture to 2256×4000 (#72)', async (t) => { - let chromium: typeof import('@playwright/test').chromium; - try { - ({ chromium } = await import('@playwright/test')); - } catch { - t.skip('playwright not available'); - return; - } - const browser = await chromium.launch(); - try { - const page = await browser.newPage(); - const buf = readFileSync(fixturePath); - const b64 = buf.toString('base64'); - // Inline the same contract as decodeOrientedBitmap (worker-safe, no bundler). - const dims = await page.evaluate(async (b64jpeg: string) => { - const bin = atob(b64jpeg); - const arr = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); - const blob = new Blob([arr], { type: 'image/jpeg' }); - const opts = { imageOrientation: 'from-image' as const }; - let bmp: ImageBitmap; - try { - bmp = await createImageBitmap(blob, opts); - } catch { - bmp = await createImageBitmap(blob); - } - const out = { w: bmp.width, h: bmp.height }; - bmp.close(); - return out; - }, b64); - assert.equal(dims.w, 2256, `oriented width (got ${dims.w})`); - assert.equal(dims.h, 4000, `oriented height (got ${dims.h})`); - } finally { - await browser.close(); - } +test('decodeOrientedBitmap rethrows the first error when both attempts fail', async () => { + const firstError = new TypeError('imageOrientation unsupported'); + (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = + async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => { + if (opts && 'imageOrientation' in opts) throw firstError; + throw new Error('bare decode also failed'); + }; + + await assert.rejects( + () => decodeOrientedBitmap(new Blob([new Uint8Array([1])], { type: 'image/jpeg' })), + (err: Error) => err === firstError, + ); }); diff --git a/frontend/src/decodeOrientedBitmap.ts b/frontend/src/capture/decodeOrientedBitmap.ts similarity index 68% rename from frontend/src/decodeOrientedBitmap.ts rename to frontend/src/capture/decodeOrientedBitmap.ts index 07e8928..73dc3c7 100644 --- a/frontend/src/decodeOrientedBitmap.ts +++ b/frontend/src/capture/decodeOrientedBitmap.ts @@ -1,16 +1,14 @@ /** - * Shared decode for capture photos so PhotoStage, mask worker, inventory, and - * calculate all share one EXIF-oriented pixel space. + * Shared decode for capture photos so the screens, mask, inventory, and + * measure steps share one EXIF-oriented pixel space (CAPTURE-20). * * Matched-set / DJI JPEGs often carry EXIF Orientation 8 (stored landscape, * display portrait). `new Image()` and bare `createImageBitmap(blob)` can - * disagree across browsers → pink axis arrow space ≠ mask/overlay bitset space. + * disagree across browsers → axis arrow space ≠ mask bitset space. * * Contract: always request `imageOrientation: 'from-image'`. If the runtime * rejects that option (older engines), fall back to bare `createImageBitmap`; * engines that already apply EXIF by default still match Image/canvas size. - * Engines that never apply EXIF may still diverge after fallback — document - * that path rather than inventing a software rotate. */ export const ORIENTED_BITMAP_OPTIONS: ImageBitmapOptions = { imageOrientation: 'from-image', diff --git a/frontend/src/capture/fitDisplay.test.ts b/frontend/src/capture/fitDisplay.test.ts deleted file mode 100644 index 6ecb539..0000000 --- a/frontend/src/capture/fitDisplay.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { fitDisplay } from './fitDisplay.ts'; - -test('parentW=0 → null (no size until laid out)', () => { - assert.equal(fitDisplay(0, 1000, 750, 420), null); - assert.equal(fitDisplay(-5, 1000, 750, 420), null); -}); - -test('280 wide, 4:3 landscape → contain, under the height cap', () => { - const fit = fitDisplay(280, 1600, 1200, 420); - assert.ok(fit); - assert.equal(fit.fitW, 280); - assert.ok(Math.abs(fit.fitH - 210) < 1e-9); - assert.ok(fit.fitH <= 420); -}); - -test('portrait under maxH never stretches: height capped, width shrinks', () => { - // 1000 wide source, 3000 tall. Full-width fit would be 900 tall > 420. - const fit = fitDisplay(400, 1000, 3000, 420); - assert.ok(fit); - assert.equal(fit.fitH, 420); - assert.ok(Math.abs(fit.fitW - 140) < 1e-9); - // aspect preserved: fitW/fitH === imgW/imgH - assert.ok(Math.abs(fit.fitW / fit.fitH - 1000 / 3000) < 1e-9); -}); diff --git a/frontend/src/capture/fitDisplay.ts b/frontend/src/capture/fitDisplay.ts deleted file mode 100644 index a51ac40..0000000 --- a/frontend/src/capture/fitDisplay.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Contain-fit of an image into a parent width with a height cap. Pure and -// display-only; all measurement math stays on source pixels. -// Null when the parent is not laid out yet (clientWidth 0) — the caller must -// not finalize CSS size until it is, to avoid a wrong-aspect flash. -export function fitDisplay( - parentW: number, imgW: number, imgH: number, maxH: number, -): { fitW: number; fitH: number } | null { - if (parentW <= 0 || imgW <= 0 || imgH <= 0) return null; - const fitW = parentW; - let fitH = (imgH / imgW) * fitW; - if (fitH > maxH) { - // Cap height and shrink width to preserve aspect; never stretch. - fitH = maxH; - return { fitW: (fitH * imgW) / imgH, fitH }; - } - return { fitW, fitH }; -} diff --git a/frontend/src/capture/measureInputs.test.ts b/frontend/src/capture/measureInputs.test.ts deleted file mode 100644 index 1644f05..0000000 --- a/frontend/src/capture/measureInputs.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test } from 'node:test'; -import { inToEighths } from './capture.types'; - -const panelSrc = readFileSync( - join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'), - 'utf8', -); - -test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => { - const inputs = panelSrc.match(/]*>/g) ?? []; - assert.ok(inputs.length >= 2, 'expected at least two fields'); - assert.ok( - inputs.some((el) => /type="number"/.test(el) && /aria-label="Length L"/.test(el)), - 'Length L must be a type=number input', - ); - assert.ok( - inputs.some((el) => /type="number"/.test(el) && /aria-label="Thickness"/.test(el)), - 'Thickness must be a type=number input', - ); - assert.equal( - panelSrc.includes('class="stepper"'), - false, - 'stepper-only UI is the UAT fail; primary control must be typed input', - ); - assert.equal( - /\{fmtEighths\(state\.lengthEighths\)\}<\/span>/.test(panelSrc), - false, - 'Length must not be a read-only fmtEighths span', - ); -}); - -test('typed inches snap to nearest 1/8 via inToEighths', () => { - assert.equal(inToEighths(12), 96); - assert.equal(inToEighths(12.1), 97); - assert.equal(inToEighths(0.06), 0); - assert.equal(inToEighths(0.07), 1); - assert.equal(inToEighths(1.999), 16); -}); diff --git a/frontend/src/capture/photosReadyLineup.test.ts b/frontend/src/capture/photosReadyLineup.test.ts index 42d9c5c..08298cd 100644 --- a/frontend/src/capture/photosReadyLineup.test.ts +++ b/frontend/src/capture/photosReadyLineup.test.ts @@ -1,207 +1,80 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPhotosReadyLineup, photosReadyPngAlt } from './photosReadyLineup'; import type { BatchPhotoResult } from './capture.types'; -import { - buildPhotosReadyLineup, - type PhotosReadyLineupItem, -} from './photosReadyLineup'; -const here = dirname(fileURLToPath(import.meta.url)); +const png = (label: string): Blob => new Blob([label], { type: 'image/png' }); +const ok = (label: string): NonNullable => ({ status: 'ok', inventoryPng: png(label) }); +const fail = (message: string): NonNullable => ({ status: 'error', message }); -function png(label: string): Blob { - return new Blob([label], { type: 'image/png' }); -} - -function jpeg(label: string): Blob { - return new Blob([label], { type: 'image/jpeg' }); -} - -function roles(items: PhotosReadyLineupItem[]): string[] { - return items.map((it) => - it.kind === 'png' ? `png:${it.role}` : `error:${it.batchIndex}`, - ); -} - -// --- pure helper: lineup order + error slots (#64) --- - -test('test(photos): lineup is clean cal → overlay → other batch PNGs in capture order (#64)', () => { - const clean = png('clean-cal'); - const overlay = png('overlay-cal'); - const sib0 = png('sib-0'); - const sib2 = png('sib-2'); - const batchApply: BatchPhotoResult[] = [ - { status: 'ok', inventoryPng: sib0 }, - null, // calibration at index 1 - { status: 'ok', inventoryPng: sib2 }, - ]; - - const lineup = buildPhotosReadyLineup({ - inventoryPng: clean, - overlayPng: overlay, - batchApply, +test('lineup order is clean calibration, overlay copy, then batch in capture order (CAPTURE-130)', () => { + const cal = png('cal'); + const ovl = png('ovl'); + const items = buildPhotosReadyLineup({ + inventoryPng: cal, + overlayPng: ovl, + batchApply: [null, ok('b2'), ok('b3')], }); - - assert.deepEqual(roles(lineup), [ - 'png:calibration', - 'png:overlay', - 'png:batch', - 'png:batch', - ]); - assert.equal(lineup[0].kind, 'png'); - if (lineup[0].kind === 'png') { - assert.equal(lineup[0].blob, clean); - assert.equal(lineup[0].role, 'calibration'); - } - assert.equal(lineup[1].kind, 'png'); - if (lineup[1].kind === 'png') { - assert.equal(lineup[1].blob, overlay); - assert.equal(lineup[1].role, 'overlay'); - } - assert.equal(lineup[2].kind, 'png'); - if (lineup[2].kind === 'png') { - assert.equal(lineup[2].blob, sib0); - assert.equal(lineup[2].batchIndex, 0); - } - assert.equal(lineup[3].kind, 'png'); - if (lineup[3].kind === 'png') { - assert.equal(lineup[3].blob, sib2); - assert.equal(lineup[3].batchIndex, 2); - } -}); - -test('test(photos): single-photo batch lineup is clean + overlay only (#64)', () => { - const clean = png('only-clean'); - const overlay = png('only-overlay'); - const lineup = buildPhotosReadyLineup({ - inventoryPng: clean, - overlayPng: overlay, - batchApply: [null], - }); - assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay']); - assert.ok(lineup.every((it) => it.kind === 'png')); -}); - -test('test(photos): batchApply errors become clear error slots (empty mask / floor) (#64)', () => { - const clean = png('clean'); - const overlay = png('overlay'); - const ok = png('ok-sib'); - const batchApply: BatchPhotoResult[] = [ - null, - { status: 'error', message: 'empty mask: no slab pixels detected' }, - { status: 'ok', inventoryPng: ok }, - { status: 'error', message: 'PNG size floor: shorter side is below 800 px' }, - ]; - - const lineup = buildPhotosReadyLineup({ - inventoryPng: clean, - overlayPng: overlay, - batchApply, - }); - - assert.deepEqual(roles(lineup), [ - 'png:calibration', - 'png:overlay', - 'error:1', - 'png:batch', - 'error:3', - ]); - - const err1 = lineup[2]; - assert.equal(err1.kind, 'error'); - if (err1.kind === 'error') { - assert.match(err1.message, /empty mask/i); - assert.equal(err1.batchIndex, 1); - } - const err3 = lineup[4]; - assert.equal(err3.kind, 'error'); - if (err3.kind === 'error') { - assert.match(err3.message, /floor|800/i); - } - - // Successful sibling still appears after cal+overlay, in capture order among siblings - const okItem = lineup[3]; - assert.equal(okItem.kind, 'png'); - if (okItem.kind === 'png') { - assert.equal(okItem.blob, ok); - assert.equal(okItem.batchIndex, 2); - } -}); - -test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)', () => { - const clean = png('clean'); - const overlay = png('overlay'); - const thumb = jpeg('source-thumb'); - // Dishonest proxy would pass source thumbs; helper must only emit image/png blobs for png slots - const lineup = buildPhotosReadyLineup({ - inventoryPng: clean, - overlayPng: overlay, - batchApply: [ - null, - { status: 'ok', inventoryPng: png('sib') }, - ], - }); - for (const it of lineup) { - if (it.kind === 'png') { - assert.equal(it.blob.type, 'image/png', 'lineup PNG slots must be inventory PNGs'); - assert.notEqual(it.blob, thumb); - } - } -}); - -test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => { - const lineup = buildPhotosReadyLineup({ - inventoryPng: png('below'), - overlayPng: png('overlay'), - batchApply: [null, { status: 'ok', inventoryPng: png('sib') }], - sourceBelowFloor: true, - }); - assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']); -}); - -test('test(photos): empty/missing results yield empty lineup (#64)', () => { - assert.deepEqual(buildPhotosReadyLineup({}), []); + assert.equal(items.length, 4); assert.deepEqual( - buildPhotosReadyLineup({ inventoryPng: null, overlayPng: null, batchApply: undefined }), - [], + items.map((i) => (i.kind === 'png' ? i.role : 'error')), + ['calibration', 'overlay', 'batch', 'batch'], + ); + assert.equal(items[0].kind === 'png' && items[0].blob, cal); + assert.equal(items[1].kind === 'png' && items[1].blob, ovl); + assert.equal(items[2].kind === 'png' && items[2].batchIndex, 1); + assert.equal(items[3].kind === 'png' && items[3].batchIndex, 2); +}); + +test('the calibration slot (null) is skipped; its PNG lives in inventoryPng', () => { + const items = buildPhotosReadyLineup({ inventoryPng: png('cal'), batchApply: [null] }); + assert.equal(items.length, 1); + assert.equal(items[0].kind, 'png'); +}); + +test('empty-mask and floor failures become per-photo error slots (CAPTURE-110)', () => { + const items = buildPhotosReadyLineup({ + inventoryPng: png('cal'), + batchApply: [null, ok('b2'), fail('empty mask: no slab pixels detected'), ok('b4'), fail('PNG size floor: shorter side is below 800 px')], + }); + assert.equal(items.length, 5); + assert.equal(items[2].kind, 'error'); + if (items[2].kind === 'error') { + assert.equal(items[2].batchIndex, 2); + assert.equal(items[2].message, 'empty mask: no slab pixels detected'); + } + assert.equal(items[4].kind, 'error'); + if (items[4].kind === 'error') assert.equal(items[4].message, 'PNG size floor: shorter side is below 800 px'); +}); + +test('sourceBelowFloor omits the clean calibration PNG (CAPTURE-105 warn path)', () => { + const items = buildPhotosReadyLineup({ inventoryPng: png('cal'), overlayPng: png('ovl'), sourceBelowFloor: true }); + assert.deepEqual( + items.map((i) => (i.kind === 'png' ? i.role : 'error')), + ['overlay'], ); }); -// --- light UI / session wiring (#64) --- - -test('test(photos): MeasurePanel shows Photos ready lineup + M1 numbers + batch errors (#64)', () => { - const panel = readFileSync(join(here, 'MeasurePanel.svelte'), 'utf8'); - assert.match(panel, /Photos ready|photos.?ready/i, 'end screen title after Calculate'); - assert.match(panel, /buildPhotosReadyLineup/, 'must use pure lineup helper'); - assert.match(panel, /Widths/, 'M1 widths still visible'); - assert.match(panel, /Sqft/, 'M1 sqft still visible'); - assert.match(panel, /Bdft/, 'M1 bdft still visible'); - assert.match(panel, /Scale/, 'M1 scale still visible'); - // Dishonest proxies - assert.ok( - !/lineup[\s\S]{0,200}state\.source\.blob/.test(panel), - 'must not lineup source thumbs', - ); - assert.match( - panel, - /error|batchApply|message/i, - 'per-photo batchApply errors must surface in the panel', - ); - assert.match(panel, /Back/, 'Back must still work from Photos ready'); -}); - -test('test(photos): App still routes measure results through Measure (or PhotosReady) with Back (#64)', () => { - const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); - // Stay on Measure with results, or dedicated panel — either is fine - assert.ok( - /MeasurePanel/.test(app) || /PhotosReady/.test(app), - 'App must render Photos-ready end screen', - ); - // Must not invent a Mask loop for the lineup - assert.ok( - !/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app), - 'App must not #each batch into Mask for Photos ready', +test('single-photo batch (cal only) yields clean PNG + overlay copy', () => { + const items = buildPhotosReadyLineup({ inventoryPng: png('cal'), overlayPng: png('ovl'), batchApply: [null] }); + assert.deepEqual( + items.map((i) => (i.kind === 'png' ? i.role : 'error')), + ['calibration', 'overlay'], ); }); + +test('missing optional blobs are omitted without shifting the rest', () => { + const items = buildPhotosReadyLineup({ batchApply: [null, ok('b2')] }); + assert.equal(items.length, 1); + assert.equal(items[0].kind === 'png' && items[0].role, 'batch'); +}); + +test('empty input yields an empty lineup', () => { + assert.deepEqual(buildPhotosReadyLineup({}), []); +}); + +test('photosReadyPngAlt names each slot for screen readers', () => { + assert.equal(photosReadyPngAlt({ kind: 'png', role: 'calibration', blob: png('x') }), 'Calibration inventory PNG'); + assert.equal(photosReadyPngAlt({ kind: 'png', role: 'overlay', blob: png('x') }), 'Calibration dimension-overlay PNG'); + assert.equal(photosReadyPngAlt({ kind: 'png', role: 'batch', blob: png('x'), batchIndex: 2 }), 'Batch inventory PNG 3'); +}); diff --git a/frontend/src/capture/photosReadyLineup.ts b/frontend/src/capture/photosReadyLineup.ts index c3dfb5d..9c1742c 100644 --- a/frontend/src/capture/photosReadyLineup.ts +++ b/frontend/src/capture/photosReadyLineup.ts @@ -1,12 +1,19 @@ +/** + * Photos-ready lineup builder (CAPTURE-130, PHOTOS.md). + * + * Order: 1) clean calibration inventory PNG, 2) dimension-overlay copy, + * 3) remaining batch inventory PNGs in capture order, with error slots for + * siblings that failed mask/encode/floor (CAPTURE-110). + */ import type { BatchPhotoResult } from './capture.types'; -/** One slot in the Photos-ready end screen (#64 / docs/PHOTOS.md). */ +/** One slot in the Photos-ready end screen. */ export type PhotosReadyLineupItem = | { kind: 'png'; role: 'calibration' | 'overlay' | 'batch'; blob: Blob; - /** Capture-order index for non-calibration batch inventory PNGs. */ + /** Capture-order index for batch inventory PNGs. */ batchIndex?: number; } | { @@ -19,20 +26,11 @@ export interface PhotosReadyLineupInput { inventoryPng?: Blob | null; overlayPng?: Blob | null; batchApply?: BatchPhotoResult[] | null; - /** When true, omit the clean calibration PNG (M1 floor warn path). */ + /** When true, omit the clean calibration PNG (CAPTURE-105 floor warn path). */ sourceBelowFloor?: boolean; } -/** - * Build the Photos-ready lineup: - * 1. Clean calibration inventory PNG - * 2. Dimension-overlay copy - * 3. Other batch inventory PNGs in capture order (batchApply, skip cal null) - * with error slots for empty mask / floor failures. - */ -export function buildPhotosReadyLineup( - input: PhotosReadyLineupInput, -): PhotosReadyLineupItem[] { +export function buildPhotosReadyLineup(input: PhotosReadyLineupInput): PhotosReadyLineupItem[] { const items: PhotosReadyLineupItem[] = []; const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input; @@ -42,34 +40,22 @@ export function buildPhotosReadyLineup( if (overlayPng) { items.push({ kind: 'png', role: 'overlay', blob: overlayPng }); } - if (batchApply) { - for (let i = 0; i < batchApply.length; i++) { - const slot = batchApply[i]; - if (slot === null) continue; // calibration lives in results.inventoryPng + batchApply.forEach((slot, batchIndex) => { + if (slot === null) return; // the calibration slot lives in results.inventoryPng if (slot.status === 'ok') { - items.push({ - kind: 'png', - role: 'batch', - blob: slot.inventoryPng, - batchIndex: i, - }); + items.push({ kind: 'png', role: 'batch', blob: slot.inventoryPng, batchIndex }); } else { - items.push({ - kind: 'error', - batchIndex: i, - message: slot.message, - }); + items.push({ kind: 'error', batchIndex, message: slot.message }); } - } + }); } - return items; } +/** Accessible alt text for a PNG slot. */ export function photosReadyPngAlt(item: Extract): string { if (item.role === 'calibration') return 'Calibration inventory PNG'; if (item.role === 'overlay') return 'Calibration dimension-overlay PNG'; - const n = (item.batchIndex ?? 0) + 1; - return `Batch inventory PNG ${n}`; + return `Batch inventory PNG ${(item.batchIndex ?? 0) + 1}`; } diff --git a/frontend/src/capture/session.batch.test.ts b/frontend/src/capture/session.batch.test.ts deleted file mode 100644 index 614b2df..0000000 --- a/frontend/src/capture/session.batch.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test, beforeEach } from 'node:test'; -import { get } from 'svelte/store'; -import type { Draft, Source } from './capture.types'; -import { createCaptureSession, type DraftStore } from './session'; - -const here = dirname(fileURLToPath(import.meta.url)); - -function tinyJpeg(label: string): File { - // Minimal valid-ish JPEG bytes are unnecessary; Blob type is what session stores. - const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]); - return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' }); -} - -function memStore(): DraftStore & { peek: () => Draft | null; reset: () => void } { - let mem: Draft | null = null; - return { - loadDraft: async () => mem, - saveDraft: async (d) => { - mem = d; - }, - clearDraft: async () => { - mem = null; - }, - peek: () => mem, - reset: () => { - mem = null; - }, - }; -} - -let store: ReturnType; - -beforeEach(() => { - store = memStore(); -}); - -test('session starts on batch with zero photos (replaces single addPhoto)', () => { - const s = createCaptureSession({ draft: store }); - const cap = get(s.capture); - assert.equal(cap.k, 'batch'); - if (cap.k === 'batch') assert.equal(cap.photos.length, 0); - s.dispose(); -}); - -test('addToBatch accumulates photos and stays on batch', async () => { - const s = createCaptureSession({ draft: store }); - await s.addToBatch(tinyJpeg('a')); - await s.addToBatch(tinyJpeg('b')); - const cap = get(s.capture); - assert.equal(cap.k, 'batch'); - if (cap.k === 'batch') { - assert.equal(cap.photos.length, 2); - assert.ok(cap.photos[0].blob instanceof Blob); - assert.ok(cap.photos[1].blob instanceof Blob); - assert.notEqual(cap.photos[0].blob, cap.photos[1].blob); - } - s.dispose(); -}); - -test('continueBatch is a no-op when batch is empty', () => { - const s = createCaptureSession({ draft: store }); - s.continueBatch(); - assert.equal(get(s.capture).k, 'batch'); - s.dispose(); -}); - -test('continueBatch with ≥1 photo advances to pickCalibration (not Mask)', async () => { - // #60: Continue must not silently Mask the first photo — user picks calibration next. - const s = createCaptureSession({ draft: store }); - await s.addToBatch(tinyJpeg('a')); - await s.addToBatch(tinyJpeg('b')); - s.continueBatch(); - const cap = get(s.capture); - assert.equal(cap.k, 'pickCalibration'); - if (cap.k === 'pickCalibration') { - assert.equal(cap.photos.length, 2); - } - s.dispose(); -}); - -test('adding photos persists batch blobs in device draft', async () => { - const s = createCaptureSession({ draft: store }); - await s.addToBatch(tinyJpeg('a')); - await s.addToBatch(tinyJpeg('b')); - // allow async persist - await new Promise((r) => setTimeout(r, 20)); - const d = store.peek(); - assert.ok(d, 'draft must exist after adding photos'); - assert.equal(d!.stage, 'batch'); - const batch = d!.batch ?? []; - assert.equal(batch.length, 2, 'draft must store both batch blobs (not a UI-only list)'); - assert.ok(batch[0].blob instanceof Blob); - assert.ok(batch[1].blob instanceof Blob); - s.dispose(); -}); - -test('refresh: resumeProbe + Continue restores the batch photos', async () => { - const s1 = createCaptureSession({ draft: store }); - await s1.addToBatch(tinyJpeg('a')); - await s1.addToBatch(tinyJpeg('b')); - await new Promise((r) => setTimeout(r, 20)); - s1.dispose(); - - const s2 = createCaptureSession({ draft: store }); - await s2.resumeProbe(); - assert.equal(get(s2.capture).k, 'resume'); - s2.continueDraft(); - await new Promise((r) => setTimeout(r, 20)); - const cap = get(s2.capture); - assert.equal(cap.k, 'batch'); - if (cap.k === 'batch') { - assert.equal(cap.photos.length, 2); - assert.ok(cap.photos.every((p: Source) => p.blob instanceof Blob)); - } - s2.dispose(); -}); - -test('Batch UI: AddPhoto/Batch panel supports multi-add and Continue when ≥1', () => { - // Prefer BatchPhotos.svelte; fall back to AddPhoto.svelte if renamed later. - const candidates = ['BatchPhotos.svelte', 'AddPhoto.svelte'].map((n) => join(here, n)); - let src = ''; - let found = ''; - for (const p of candidates) { - try { - src = readFileSync(p, 'utf8'); - found = p; - break; - } catch { - /* try next */ - } - } - assert.ok(src, 'expected BatchPhotos.svelte or AddPhoto.svelte'); - assert.match(src, /Continue|continue/i, `${found} must expose Continue`); - assert.match(src, /photos|batch/i, `${found} must talk about batch/photos`); - // Must allow adding more than once (not a one-shot that leaves the screen). - const fileInputs = src.match(/type="file"/g) ?? []; - assert.ok(fileInputs.length >= 1, 'must have file picker(s)'); - assert.ok( - /addToBatch|onAdd|onPicked/.test(src) || /on:change/.test(src), - 'must wire photo add', - ); -}); - -test('legacy addPhoto alias still appends to batch (no silent single-photo-only path)', async () => { - const s = createCaptureSession({ draft: store }); - await s.addPhoto(tinyJpeg('only')); - const cap = get(s.capture); - assert.equal(cap.k, 'batch', 'addPhoto must not jump straight to mask anymore'); - if (cap.k === 'batch') assert.equal(cap.photos.length, 1); - s.dispose(); -}); diff --git a/frontend/src/capture/session.batchApply.test.ts b/frontend/src/capture/session.batchApply.test.ts deleted file mode 100644 index 131cbe8..0000000 --- a/frontend/src/capture/session.batchApply.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test, beforeEach, afterEach } from 'node:test'; -import { get } from 'svelte/store'; -import type { Draft, Mask, MaskParams, Source, WorkerIn, WorkerOut } from './capture.types'; -import { createCaptureSession, type DraftStore } from './session'; - -const here = dirname(fileURLToPath(import.meta.url)); - -function tinyJpeg(label: string): File { - const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]); - return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' }); -} - -function memStore(): DraftStore & { peek: () => Draft | null } { - let mem: Draft | null = null; - return { - loadDraft: async () => mem, - saveDraft: async (d) => { - mem = d; - }, - clearDraft: async () => { - mem = null; - }, - peek: () => mem, - }; -} - -let store: ReturnType; - -beforeEach(() => { - store = memStore(); -}); - -/** Controllable FakeWorker for #62 batch-apply paths. */ -type MaskBehavior = { - w: number; - h: number; - /** slabPx; 0 ⇒ empty mask */ - slabPx: number; - slabBounds?: { x: number; y: number; w: number; h: number }; -}; - -class FakeMaskWorker { - onmessage: ((ev: MessageEvent) => void) | null = null; - static inits: Blob[] = []; - static maskCalls: { sheet: string; sensitivity: number; blob: Blob | null }[] = []; - static lastInitBlob: Blob | null = null; - /** Per-init blob → behavior. Default: full mask at LARGE dims (above PNG floor). */ - static behaviorFor: (blob: Blob) => MaskBehavior = () => ({ - w: 1000, - h: 900, - slabPx: 1000 * 900, - slabBounds: { x: 0, y: 0, w: 1000, h: 900 }, - }); - - constructor(_url: URL | string, _opts?: WorkerOptions) {} - - postMessage(msg: WorkerIn) { - if (msg.kind === 'init') { - FakeMaskWorker.inits.push(msg.blob); - FakeMaskWorker.lastInitBlob = msg.blob; - const id = msg.id; - const b = FakeMaskWorker.behaviorFor(msg.blob); - queueMicrotask(() => { - this.onmessage?.({ data: { id, kind: 'decoded', w: b.w, h: b.h } } as MessageEvent); - }); - return; - } - if (msg.kind === 'mask') { - const id = msg.id; - const blob = FakeMaskWorker.lastInitBlob; - FakeMaskWorker.maskCalls.push({ - sheet: msg.sheet, - sensitivity: msg.sensitivity, - blob, - }); - const b = blob ? FakeMaskWorker.behaviorFor(blob) : { w: 40, h: 20, slabPx: 0 }; - const bitset = new Uint8Array(Math.max(1, b.w * b.h)).fill(b.slabPx > 0 ? 1 : 0); - const bbox = { - corners: [ - [b.w, 0], - [b.w, b.h], - [0, b.h], - [0, 0], - ] as [number, number][], - longSidePx: b.w, - angleDeg: 0, - }; - const slabBounds = b.slabBounds ?? { x: 0, y: 0, w: b.w, h: b.h }; - queueMicrotask(() => { - this.onmessage?.({ - data: { - id, - kind: 'ready', - slabPx: b.slabPx, - bitset, - bbox, - slabBounds, - }, - } as MessageEvent); - }); - return; - } - } - - terminate() {} -} - -const RealWorker = globalThis.Worker; -const RealCreateImageBitmap = globalThis.createImageBitmap; - -function installFakes() { - FakeMaskWorker.inits = []; - FakeMaskWorker.maskCalls = []; - FakeMaskWorker.lastInitBlob = null; - FakeMaskWorker.behaviorFor = () => ({ - w: 1000, - h: 900, - slabPx: 1000 * 900, - slabBounds: { x: 0, y: 0, w: 1000, h: 900 }, - }); - (globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker; - (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = - async (blob: ImageBitmapSource) => { - const b = blob instanceof Blob ? FakeMaskWorker.behaviorFor(blob) : { w: 1000, h: 900 }; - return { - width: b.w, - height: b.h, - close() {}, - } as ImageBitmap; - }; -} - -function restoreGlobals() { - if (RealWorker === undefined) { - delete (globalThis as unknown as { Worker?: unknown }).Worker; - } else { - globalThis.Worker = RealWorker; - } - if (RealCreateImageBitmap === undefined) { - delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap; - } else { - globalThis.createImageBitmap = RealCreateImageBitmap; - } -} - -afterEach(() => { - restoreGlobals(); -}); - -async function settleOverlay( - s: ReturnType, - ms = 800, -): Promise { - const start = Date.now(); - while (Date.now() - start < ms) { - const cap = get(s.capture); - if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay; - await new Promise((r) => setTimeout(r, 5)); - } - throw new Error('mask overlay never settled'); -} - -async function driveToMeasure( - s: ReturnType, - photos: File[], - calIndex: number, - maskParams: Partial = { sheet: 'green', sensitivity: 50 }, -) { - for (const p of photos) await s.addToBatch(p); - s.continueBatch(); - s.pickCalibration(calIndex); - if (maskParams.sheet) s.setSheet(maskParams.sheet); - if (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity); - // setSheet already runs a pass; if only sensitivity, force via setSheet after - if (!maskParams.sheet) s.setSheet('auto'); - await settleOverlay(s); - s.approveMask(); - s.approveAxis(); - s.setLength(320); // 40 in - s.setThickness(16); // 2 in -} - -type BatchApplySlot = - | null - | { status: 'ok'; inventoryPng: Blob } - | { status: 'error'; message: string }; - -function batchApplyOf(cap: ReturnType['capture']>>): BatchApplySlot[] | undefined { - if (cap.k !== 'measure') return undefined; - return (cap as { batchApply?: BatchApplySlot[] }).batchApply; -} - -// --- #62: apply approved mask + crop to non-calibration batch photos after Calculate --- - -test('#62: Calculate applies frozen mask params to every non-calibration batch photo', async () => { - installFakes(); - const encodeCalls: { widthPx: number; heightPx: number; sheet: string; sensitivity: number }[] = []; - const s = createCaptureSession({ - draft: store, - encodeInventory: async (input) => { - encodeCalls.push({ - widthPx: input.source.widthPx, - heightPx: input.source.heightPx, - sheet: (input.mask as Mask).params?.sheet ?? '?', - sensitivity: (input.mask as Mask).params?.sensitivity ?? -1, - }); - // Distinctive PNG blob so tests cannot confuse with source JPEG thumbs - return { - blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }), - sourceBelowFloor: false, - }; - }, - } as Parameters[0]); - - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); // calibration - const c = tinyJpeg('c'); - await driveToMeasure(s, [a, b, c], 1, { sheet: 'black', sensitivity: 72 }); - await s.calculate(); - - const cap = get(s.capture); - assert.equal(cap.k, 'measure', 'must stay on Measure — do not reopen Mask'); - const apply = batchApplyOf(cap); - assert.ok(Array.isArray(apply), 'measure.batchApply must exist after Calculate'); - assert.equal(apply!.length, 3, 'one slot per batch photo'); - assert.equal(apply![1], null, 'calibration index is null (uses results.inventoryPng)'); - - assert.ok(apply![0] && apply![0].status === 'ok', 'batch[0] applied ok'); - assert.ok(apply![2] && apply![2].status === 'ok', 'batch[2] applied ok'); - if (apply![0]?.status === 'ok') { - assert.equal(apply![0].inventoryPng.type, 'image/png'); - assert.notEqual(apply![0].inventoryPng, a, 'must not proxy source thumb as inventory PNG'); - } - if (apply![2]?.status === 'ok') { - assert.equal(apply![2].inventoryPng.type, 'image/png'); - assert.notEqual(apply![2].inventoryPng, c); - } - - // Frozen params: every sibling mask call used calibration sheet/sensitivity - const siblingBlobs = new Set([a, c]); - const siblingMasks = FakeMaskWorker.maskCalls.filter((m) => m.blob && siblingBlobs.has(m.blob as File)); - assert.ok(siblingMasks.length >= 2, 'mask worker must run on non-calibration photos'); - for (const m of siblingMasks) { - assert.equal(m.sheet, 'black', 'reuse approved sheet'); - assert.equal(m.sensitivity, 72, 'reuse approved sensitivity'); - } - - s.dispose(); -}); - -test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => { - installFakes(); - const a = tinyJpeg('a'); // will empty-mask - const b = tinyJpeg('b'); // cal - const c = tinyJpeg('c'); // ok - - FakeMaskWorker.behaviorFor = (blob) => { - if (blob === a) { - return { w: 1000, h: 900, slabPx: 0, slabBounds: { x: 0, y: 0, w: 0, h: 0 } }; - } - return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } }; - }; - - const s = createCaptureSession({ - draft: store, - encodeInventory: async () => ({ - blob: new Blob(['ok-png'], { type: 'image/png' }), - sourceBelowFloor: false, - }), - } as Parameters[0]); - - await driveToMeasure(s, [a, b, c], 1, { sheet: 'green' }); - await s.calculate(); - - const apply = batchApplyOf(get(s.capture)); - assert.ok(apply); - assert.equal(apply![1], null); - assert.ok(apply![0] && apply![0].status === 'error', 'empty mask → error'); - if (apply![0]?.status === 'error') { - assert.match(apply![0].message, /empty|mask/i, 'clear per-photo empty-mask error'); - } - assert.ok(apply![2] && apply![2].status === 'ok', 'other photos still proceed'); - s.dispose(); -}); - -test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => { - installFakes(); - const a = tinyJpeg('a'); // ok - const b = tinyJpeg('b'); // cal - const c = tinyJpeg('c'); // below floor - - FakeMaskWorker.behaviorFor = (blob) => { - if (blob === c) { - return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } }; - } - return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } }; - }; - - const s = createCaptureSession({ - draft: store, - encodeInventory: async (input) => { - const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800; - return { - blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }), - sourceBelowFloor: below, - }; - }, - } as Parameters[0]); - - await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' }); - await s.calculate(); - - const apply = batchApplyOf(get(s.capture)); - assert.ok(apply); - assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok'); - assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error'); - if (apply![2]?.status === 'error') { - assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error'); - } - s.dispose(); -}); - -test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are visual)', async () => { - installFakes(); - const s = createCaptureSession({ - draft: store, - encodeInventory: async () => ({ - blob: new Blob(['png'], { type: 'image/png' }), - sourceBelowFloor: false, - }), - } as Parameters[0]); - - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - await driveToMeasure(s, [a, b], 1, { sheet: 'green' }); - await s.calculate(); - - const cap = get(s.capture); - assert.equal(cap.k, 'measure'); - if (cap.k === 'measure') { - assert.ok(cap.results); - assert.equal(typeof cap.results!.scale, 'number'); - assert.ok(cap.results!.scale > 0); - assert.equal(typeof cap.results!.sqft, 'number'); - assert.equal(typeof cap.results!.bdft, 'number'); - assert.ok(cap.results!.widths); - // batchApply ok slots must NOT carry measure fields - const apply = (cap as { batchApply?: BatchApplySlot[] }).batchApply; - assert.ok(apply); - const slot = apply![0]; - assert.ok(slot && slot.status === 'ok'); - if (slot && slot.status === 'ok') { - assert.equal( - 'scale' in slot, - false, - 'non-cal apply must not attach scale', - ); - assert.equal('sqft' in slot, false); - assert.equal('bdft' in slot, false); - assert.equal('widths' in slot, false); - } - } - s.dispose(); -}); - -test('#62: draft persists batchApply after Calculate (for #64 UI later)', async () => { - installFakes(); - const s = createCaptureSession({ - draft: store, - encodeInventory: async () => ({ - blob: new Blob(['draft-png'], { type: 'image/png' }), - sourceBelowFloor: false, - }), - } as Parameters[0]); - - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - const c = tinyJpeg('c'); - await driveToMeasure(s, [a, b, c], 0, { sheet: 'black' }); // cal = first - await s.calculate(); - await new Promise((r) => setTimeout(r, 30)); - - const d = store.peek(); - assert.ok(d); - assert.equal(d!.stage, 'measure'); - assert.ok(Array.isArray(d!.batchApply), 'draft.batchApply must be persisted'); - assert.equal(d!.batchApply!.length, 3); - assert.equal(d!.batchApply![0], null, 'calibration slot null on draft'); - assert.ok(d!.batchApply![1] && (d!.batchApply![1] as { status: string }).status === 'ok'); - assert.ok(d!.batchApply![2] && (d!.batchApply![2] as { status: string }).status === 'ok'); - s.dispose(); -}); - -test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mask)', async () => { - installFakes(); - const s = createCaptureSession({ - draft: store, - encodeInventory: async () => ({ - blob: new Blob(['only'], { type: 'image/png' }), - sourceBelowFloor: false, - }), - } as Parameters[0]); - - const only = tinyJpeg('only'); - await driveToMeasure(s, [only], 0, { sheet: 'auto' }); - const initsBefore = FakeMaskWorker.inits.length; - await s.calculate(); - - const apply = batchApplyOf(get(s.capture)); - assert.ok(apply); - assert.deepEqual(apply, [null]); - - // No extra mask inits for non-existent siblings after Calculate - const siblingInits = FakeMaskWorker.inits.slice(initsBefore).filter((blob) => blob !== only); - assert.equal(siblingInits.length, 0, 'must not invent Mask work for absent siblings'); - s.dispose(); -}); - -test('#62: Calculate does not re-enter Mask / does not open Mask controls for siblings', async () => { - installFakes(); - const s = createCaptureSession({ - draft: store, - encodeInventory: async () => ({ - blob: new Blob(['x'], { type: 'image/png' }), - sourceBelowFloor: false, - }), - } as Parameters[0]); - - await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'green' }); - await s.calculate(); - assert.equal(get(s.capture).k, 'measure'); - - // Session source must never loop enterMaskOnSource over batch after Calculate - const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8'); - assert.ok( - /batchApply|applyBatch|apply.*non-?calibrat/i.test(sessionSrc), - 'session must implement batch apply after Calculate (#62)', - ); - // Dishonest proxy: must not set k:'mask' inside calculate for each photo - const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/); - assert.ok(calcBody, 'calculate() must exist'); - assert.ok( - !/set\(\s*\{\s*k:\s*'mask'/.test(calcBody![0]), - 'calculate must not reopen Mask state for siblings', - ); - - const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); - assert.ok( - !/\{\s*#each\s+[^}]*batchApply[^}]*\}[\s\S]*MaskPanel/i.test(app) && - !/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app), - 'App must not #each photos into MaskPanel', - ); - s.dispose(); -}); - -test('#62: resume restores batchApply alongside calibration results', async () => { - installFakes(); - const s1 = createCaptureSession({ - draft: store, - encodeInventory: async () => ({ - blob: new Blob(['resume-png'], { type: 'image/png' }), - sourceBelowFloor: false, - }), - } as Parameters[0]); - - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - await driveToMeasure(s1, [a, b], 1, { sheet: 'green' }); - await s1.calculate(); - await new Promise((r) => setTimeout(r, 30)); - s1.dispose(); - - FakeMaskWorker.inits = []; - const s2 = createCaptureSession({ draft: store } as Parameters[0]); - await s2.resumeProbe(); - s2.continueDraft(); - const start = Date.now(); - let cap = get(s2.capture); - while (cap.k !== 'measure' && Date.now() - start < 1500) { - await new Promise((r) => setTimeout(r, 10)); - cap = get(s2.capture); - } - assert.equal(cap.k, 'measure'); - const apply = batchApplyOf(cap); - assert.ok(apply, 'restored measure must include batchApply'); - assert.equal(apply!.length, 2); - assert.equal(apply![1], null); - assert.ok(apply![0] && apply![0].status === 'ok'); - if (apply![0]?.status === 'ok') { - assert.equal(apply![0].inventoryPng.type, 'image/png'); - } - s2.dispose(); -}); diff --git a/frontend/src/capture/session.calibrate.test.ts b/frontend/src/capture/session.calibrate.test.ts deleted file mode 100644 index 558b714..0000000 --- a/frontend/src/capture/session.calibrate.test.ts +++ /dev/null @@ -1,545 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test, beforeEach, afterEach } from 'node:test'; -import { get } from 'svelte/store'; -import type { Draft, Mask, Source, WorkerIn, WorkerOut } from './capture.types'; -import { createCaptureSession, type DraftStore } from './session'; - -const here = dirname(fileURLToPath(import.meta.url)); - -function tinyJpeg(label: string): File { - const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]); - return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' }); -} - -function memStore(): DraftStore & { peek: () => Draft | null; reset: () => void } { - let mem: Draft | null = null; - return { - loadDraft: async () => mem, - saveDraft: async (d) => { - mem = d; - }, - clearDraft: async () => { - mem = null; - }, - peek: () => mem, - reset: () => { - mem = null; - }, - }; -} - -let store: ReturnType; - -beforeEach(() => { - store = memStore(); -}); - -test('continueBatch with ≥1 photo goes to pickCalibration (not mask — no first-photo proxy)', async () => { - const s = createCaptureSession({ draft: store }); - await s.addToBatch(tinyJpeg('a')); - await s.addToBatch(tinyJpeg('b')); - s.continueBatch(); - const cap = get(s.capture); - assert.equal(cap.k, 'pickCalibration', 'Continue must open pick-calibration, not skip to Mask'); - if (cap.k === 'pickCalibration') { - assert.equal(cap.photos.length, 2); - } - s.dispose(); -}); - -test('single-photo batch: Continue still opens pickCalibration so user confirms the only photo', async () => { - const s = createCaptureSession({ draft: store }); - await s.addToBatch(tinyJpeg('only')); - s.continueBatch(); - const cap = get(s.capture); - assert.equal(cap.k, 'pickCalibration'); - if (cap.k === 'pickCalibration') { - assert.equal(cap.photos.length, 1); - } - s.dispose(); -}); - -test('pickCalibration(index) enters Mask on that Source only (not always first)', async () => { - const s = createCaptureSession({ draft: store }); - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - const c = tinyJpeg('c'); - await s.addToBatch(a); - await s.addToBatch(b); - await s.addToBatch(c); - s.continueBatch(); - assert.equal(get(s.capture).k, 'pickCalibration'); - - s.pickCalibration(1); - const cap = get(s.capture); - assert.equal(cap.k, 'mask'); - if (cap.k === 'mask') { - // Chosen photo must be the second batch blob, not photos[0]. - assert.equal(cap.source.blob, b, 'Mask source must be the user-picked photo'); - assert.notEqual(cap.source.blob, a, 'must not silently use first photo'); - } - s.dispose(); -}); - -test('single-photo pick enters Mask on that photo', async () => { - const s = createCaptureSession({ draft: store }); - const only = tinyJpeg('only'); - await s.addToBatch(only); - s.continueBatch(); - s.pickCalibration(0); - const cap = get(s.capture); - assert.equal(cap.k, 'mask'); - if (cap.k === 'mask') { - assert.equal(cap.source.blob, only); - } - s.dispose(); -}); - -test('pickCalibration out of range is a no-op', async () => { - const s = createCaptureSession({ draft: store }); - await s.addToBatch(tinyJpeg('a')); - s.continueBatch(); - s.pickCalibration(-1); - assert.equal(get(s.capture).k, 'pickCalibration'); - s.pickCalibration(99); - assert.equal(get(s.capture).k, 'pickCalibration'); - s.dispose(); -}); - -test('draft persists pickCalibration stage with batch photos', async () => { - const s = createCaptureSession({ draft: store }); - await s.addToBatch(tinyJpeg('a')); - await s.addToBatch(tinyJpeg('b')); - s.continueBatch(); - await new Promise((r) => setTimeout(r, 20)); - const d = store.peek(); - assert.ok(d, 'draft must exist on pickCalibration'); - assert.equal(d!.stage, 'pickCalibration'); - assert.equal((d!.batch ?? []).length, 2); - s.dispose(); -}); - -test('draft remembers calibration choice after pick', async () => { - const s = createCaptureSession({ draft: store }); - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - await s.addToBatch(a); - await s.addToBatch(b); - s.continueBatch(); - s.pickCalibration(1); - await new Promise((r) => setTimeout(r, 20)); - const d = store.peek(); - assert.ok(d); - assert.equal(d!.stage, 'mask'); - assert.equal(d!.calibrationIndex, 1, 'draft must store which batch photo is calibration'); - assert.equal(d!.source.blob, b, 'draft.source must be the chosen calibration photo'); - assert.equal((d!.batch ?? []).length, 2, 'full batch still stored'); - s.dispose(); -}); - -test('refresh: resumeProbe + Continue restores pickCalibration with batch', async () => { - const s1 = createCaptureSession({ draft: store }); - await s1.addToBatch(tinyJpeg('a')); - await s1.addToBatch(tinyJpeg('b')); - s1.continueBatch(); - await new Promise((r) => setTimeout(r, 20)); - s1.dispose(); - - const s2 = createCaptureSession({ draft: store }); - await s2.resumeProbe(); - assert.equal(get(s2.capture).k, 'resume'); - s2.continueDraft(); - await new Promise((r) => setTimeout(r, 20)); - const cap = get(s2.capture); - assert.equal(cap.k, 'pickCalibration'); - if (cap.k === 'pickCalibration') { - assert.equal(cap.photos.length, 2); - assert.ok(cap.photos.every((p: Source) => p.blob instanceof Blob)); - } - s2.dispose(); -}); - -test('refresh: resume after pick restores Mask on the chosen calibration photo', async () => { - const s1 = createCaptureSession({ draft: store }); - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - await s1.addToBatch(a); - await s1.addToBatch(b); - s1.continueBatch(); - s1.pickCalibration(1); - await new Promise((r) => setTimeout(r, 20)); - s1.dispose(); - - const s2 = createCaptureSession({ draft: store }); - await s2.resumeProbe(); - s2.continueDraft(); - await new Promise((r) => setTimeout(r, 40)); - const cap = get(s2.capture); - assert.equal(cap.k, 'mask'); - if (cap.k === 'mask') { - // Blob identity may round-trip as a new Blob with same bytes; compare size/type content. - assert.ok(cap.source.blob instanceof Blob); - const chosen = new Uint8Array(await b.arrayBuffer()); - const got = new Uint8Array(await cap.source.blob.arrayBuffer()); - assert.deepEqual(got, chosen, 'restored Mask source must be the picked calibration photo'); - } - // calibrationIndex still on draft - const d = store.peek(); - assert.equal(d?.calibrationIndex, 1); - s2.dispose(); -}); - -test('PickCalibration UI: thumbs over batch + pick action (not auto-first)', () => { - const path = join(here, 'PickCalibration.svelte'); - let src = ''; - try { - src = readFileSync(path, 'utf8'); - } catch { - /* missing = fail */ - } - assert.ok(src, 'expected PickCalibration.svelte'); - assert.match(src, /calibrat/i, 'UI must mention calibration'); - assert.match(src, /photos|thumb/i, 'UI must show batch thumbs'); - assert.ok( - /onPick|pickCalibration|on:click/.test(src), - 'UI must wire a per-photo pick action', - ); - // Must not hardcode always-first without user interaction. - assert.ok( - !/photos\[0\].*mask|always.*first/i.test(src) || /onPick|on:click/.test(src), - 'must require user pick', - ); -}); - -test('App wires pickCalibration state to PickCalibration panel', () => { - const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); - assert.match(app, /PickCalibration/, 'App must import/render PickCalibration'); - assert.match(app, /pickCalibration/, 'App must branch on pickCalibration state'); -}); - -// --- #61 M2-4: wire M1 mask/axis/measure onto the calibration pick --- - -const MASK_W = 40; -const MASK_H = 20; - -/** Fake Worker so Node can drive Mask → Axis → Measure without canvas decode. */ -class FakeMaskWorker { - onmessage: ((ev: MessageEvent) => void) | null = null; - static inits: Blob[] = []; - - constructor(_url: URL | string, _opts?: WorkerOptions) {} - - postMessage(msg: WorkerIn) { - if (msg.kind === 'init') { - FakeMaskWorker.inits.push(msg.blob); - const id = msg.id; - queueMicrotask(() => { - this.onmessage?.({ data: { id, kind: 'decoded', w: MASK_W, h: MASK_H } } as MessageEvent); - }); - return; - } - if (msg.kind === 'mask') { - const id = msg.id; - const bitset = new Uint8Array(MASK_W * MASK_H).fill(1); - const bbox = { - corners: [ - [MASK_W, 0], - [MASK_W, MASK_H], - [0, MASK_H], - [0, 0], - ] as [number, number][], - longSidePx: MASK_W, - angleDeg: 0, - }; - queueMicrotask(() => { - this.onmessage?.({ - data: { - id, - kind: 'ready', - slabPx: MASK_W * MASK_H, - bitset, - bbox, - slabBounds: { x: 0, y: 0, w: MASK_W, h: MASK_H }, - }, - } as MessageEvent); - }); - return; - } - // invalidate: no-op - } - - terminate() {} -} - -const RealWorker = globalThis.Worker; -const RealCreateImageBitmap = globalThis.createImageBitmap; - -function installFakeMaskWorker() { - FakeMaskWorker.inits = []; - (globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker; -} - -function restoreWorkerGlobals() { - if (RealWorker === undefined) { - delete (globalThis as unknown as { Worker?: unknown }).Worker; - } else { - globalThis.Worker = RealWorker; - } - if (RealCreateImageBitmap === undefined) { - delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap; - } else { - globalThis.createImageBitmap = RealCreateImageBitmap; - } -} - -async function settleOverlay( - s: ReturnType, - ms = 800, -): Promise { - const start = Date.now(); - while (Date.now() - start < ms) { - const cap = get(s.capture); - if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay; - await new Promise((r) => setTimeout(r, 5)); - } - throw new Error('mask overlay never settled'); -} - -afterEach(() => { - restoreWorkerGlobals(); -}); - -test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob only', async () => { - installFakeMaskWorker(); - const s = createCaptureSession({ draft: store }); - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - const c = tinyJpeg('c'); - await s.addToBatch(a); - await s.addToBatch(b); - await s.addToBatch(c); - s.continueBatch(); - s.pickCalibration(1); - - let cap = get(s.capture); - assert.equal(cap.k, 'mask'); - if (cap.k === 'mask') { - assert.equal(cap.source.blob, b, 'Mask must open on picked photo index 1'); - assert.notEqual(cap.source.blob, a); - } - - // Kick an immediate mask pass (skip 2s sensitivity debounce). - s.setSheet('green'); - await settleOverlay(s); - - // Worker must have been inited only for the calibration blob — not a, not c. - assert.ok(FakeMaskWorker.inits.length >= 1, 'mask worker must init'); - for (const blob of FakeMaskWorker.inits) { - assert.equal(blob, b, 'Mask worker must never init on non-calibration batch photos'); - } - - s.approveMask(); - cap = get(s.capture); - assert.equal(cap.k, 'axis', 'approve Mask → Length axis'); - if (cap.k === 'axis') { - assert.equal(cap.source.blob, b, 'Axis must keep the calibration source'); - } - - s.approveAxis(); - cap = get(s.capture); - assert.equal(cap.k, 'measure', 'approve Axis → Measure'); - if (cap.k === 'measure') { - assert.equal(cap.source.blob, b, 'Measure must keep the calibration source'); - } - - s.dispose(); -}); - -test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => { - installFakeMaskWorker(); - (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = - async () => - ({ - width: MASK_W, - height: MASK_H, - close() {}, - }) as ImageBitmap; - - const s = createCaptureSession({ draft: store }); - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - await s.addToBatch(a); - await s.addToBatch(b); - s.continueBatch(); - s.pickCalibration(1); - s.setSheet('black'); - await settleOverlay(s); - s.approveMask(); - s.setTapeLine({ start: [0, 10], end: [39, 10] }); - s.approveAxis(); - - // 39 source-photo px between ticks / 40 owner-declared inches = 0.975 px/in. - s.setLength(320); - s.setThickness(16); - await s.calculate(); - - const cap = get(s.capture); - assert.equal(cap.k, 'measure'); - if (cap.k === 'measure') { - assert.equal(cap.source.blob, b); - assert.ok(cap.results, 'Calculate must produce results'); - const r = cap.results!; - assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob'); - assert.equal(typeof r.scale, 'number'); - assert.equal(r.scale, 39 / 40, 'scale comes from the positioned tape endpoints'); - assert.equal(typeof r.sqft, 'number'); - assert.equal(typeof r.bdft, 'number'); - // CAPTURE.md: bdft = sqft × thickness_in - const thicknessIn = 16 / 8; - assert.ok( - Math.abs(r.bdft - r.sqft * thicknessIn) < 0.011, - `bdft ${r.bdft} must equal sqft×thickness (${r.sqft}*${thicknessIn})`, - ); - assert.ok(r.widths, 'widths present'); - assert.equal(r.lengthEighths, 320); - assert.equal(r.thicknessEighths, 16); - assert.equal(cap.source.widthPx, MASK_W, 'measure source dims from calibration decode'); - } - - // Pre-#62: Mask UI path used only the calibration blob. After Calculate, #62 - // may init the worker on non-calibration siblings for frozen-mask apply. - const preApplyInits = FakeMaskWorker.inits.filter((blob) => blob === b || blob === a); - assert.ok(preApplyInits.some((blob) => blob === b), 'calibration must have been masked'); - // Sibling apply may touch `a`; that is #62, not a #61 regression of Mask UI. - assert.ok( - FakeMaskWorker.inits.filter((blob) => blob === b).length >= 1, - 'calibration blob must appear in worker inits', - ); - - s.dispose(); -}); - -test('#61: other batch photos stay in draft.batch untouched by mask approve', async () => { - installFakeMaskWorker(); - const s = createCaptureSession({ draft: store }); - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - const c = tinyJpeg('c'); - await s.addToBatch(a); - await s.addToBatch(b); - await s.addToBatch(c); - s.continueBatch(); - s.pickCalibration(1); - s.setSheet('auto'); - await settleOverlay(s); - - const before = store.peek(); - assert.ok(before); - assert.equal((before!.batch ?? []).length, 3); - assert.equal(before!.batch![0].blob, a); - assert.equal(before!.batch![1].blob, b); - assert.equal(before!.batch![2].blob, c); - - s.approveMask(); - const tapeLine = { start: [2, 3] as [number, number], end: [35, 14] as [number, number] }; - s.setTapeLine(tapeLine); - await new Promise((r) => setTimeout(r, 20)); - - const after = store.peek(); - assert.ok(after); - assert.equal(after!.stage, 'axis'); - assert.equal(after!.calibrationIndex, 1); - assert.equal(after!.source.blob, b, 'draft.source is calibration only'); - assert.deepEqual(after!.tapeLine, tapeLine, 'draft persists the owner-positioned tape endpoints'); - assert.equal((after!.batch ?? []).length, 3, 'full batch retained'); - assert.equal(after!.batch![0].blob, a, 'batch[0] untouched'); - assert.equal(after!.batch![1].blob, b); - assert.equal(after!.batch![2].blob, c, 'batch[2] untouched — no Mask UI on other shots'); - - // Approve axis too — batch still untouched. - s.approveAxis(); - await new Promise((r) => setTimeout(r, 20)); - const meas = store.peek(); - assert.equal(meas!.stage, 'measure'); - assert.equal((meas!.batch ?? []).length, 3); - assert.equal(meas!.batch![0].blob, a); - assert.equal(meas!.batch![2].blob, c); - assert.equal(meas!.calibrationIndex, 1); - assert.deepEqual(meas!.tapeLine, tapeLine, 'measure draft keeps the same calibration endpoints'); - - s.dispose(); -}); - -test('#61: draft resume through measure restores calibration source (not photos[0])', async () => { - installFakeMaskWorker(); - (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = - async () => - ({ - width: MASK_W, - height: MASK_H, - close() {}, - }) as ImageBitmap; - - const s1 = createCaptureSession({ draft: store }); - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); - await s1.addToBatch(a); - await s1.addToBatch(b); - s1.continueBatch(); - s1.pickCalibration(1); - s1.setSheet('green'); - await settleOverlay(s1); - s1.approveMask(); - s1.approveAxis(); - s1.setLength(320); - s1.setThickness(16); - await s1.calculate(); - await new Promise((r) => setTimeout(r, 30)); - s1.dispose(); - - FakeMaskWorker.inits = []; - const s2 = createCaptureSession({ draft: store }); - await s2.resumeProbe(); - assert.equal(get(s2.capture).k, 'resume'); - s2.continueDraft(); - // rederiveMask via FakeWorker - const start = Date.now(); - let cap = get(s2.capture); - while (cap.k !== 'measure' && Date.now() - start < 1500) { - await new Promise((r) => setTimeout(r, 10)); - cap = get(s2.capture); - } - assert.equal(cap.k, 'measure', 'Continue must restore Measure'); - if (cap.k === 'measure') { - assert.equal(cap.source.blob, b, 'restored Measure source is calibration pick'); - assert.ok(cap.results, 'results restored'); - } - assert.equal(store.peek()?.calibrationIndex, 1); - // Resume rederive may re-init worker on calibration source only. - for (const blob of FakeMaskWorker.inits) { - assert.equal(blob, b, 'resume must not mask non-calibration batch photos'); - } - s2.dispose(); -}); - -test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => { - const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); - assert.match(app, /MaskPanel/); - assert.match(app, /AxisPanel/); - assert.match(app, /MeasurePanel/); - // Dishonest proxy: must not iterate batch photos into Mask UI. - assert.ok( - !/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app), - 'App must not #each batch photos into MaskPanel', - ); - const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8'); - assert.ok( - !/for\s*\([^)]*batch[^)]*\)[\s\S]{0,120}(enterMaskOnSource|approveMask|k:\s*'mask')/.test( - sessionSrc, - ), - 'session must not loop Mask over every batch photo in this issue', - ); -}); diff --git a/frontend/src/capture/session.overlay.test.ts b/frontend/src/capture/session.overlay.test.ts deleted file mode 100644 index c43d1ce..0000000 --- a/frontend/src/capture/session.overlay.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test, beforeEach, afterEach } from 'node:test'; -import { get } from 'svelte/store'; -import type { Draft, Mask, MaskParams, WorkerIn, WorkerOut } from './capture.types'; -import { createCaptureSession, type DraftStore } from './session'; - -const here = dirname(fileURLToPath(import.meta.url)); - -function tinyJpeg(label: string): File { - const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]); - return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' }); -} - -function memStore(): DraftStore & { peek: () => Draft | null } { - let mem: Draft | null = null; - return { - loadDraft: async () => mem, - saveDraft: async (d) => { - mem = d; - }, - clearDraft: async () => { - mem = null; - }, - peek: () => mem, - }; -} - -let store: ReturnType; - -beforeEach(() => { - store = memStore(); -}); - -type MaskBehavior = { - w: number; - h: number; - slabPx: number; - slabBounds?: { x: number; y: number; w: number; h: number }; -}; - -class FakeMaskWorker { - onmessage: ((ev: MessageEvent) => void) | null = null; - static inits: Blob[] = []; - static maskCalls: { sheet: string; sensitivity: number; blob: Blob | null }[] = []; - static lastInitBlob: Blob | null = null; - static behaviorFor: (blob: Blob) => MaskBehavior = () => ({ - w: 1000, - h: 900, - slabPx: 1000 * 900, - slabBounds: { x: 0, y: 0, w: 1000, h: 900 }, - }); - - constructor(_url: URL | string, _opts?: WorkerOptions) {} - - postMessage(msg: WorkerIn) { - if (msg.kind === 'init') { - FakeMaskWorker.inits.push(msg.blob); - FakeMaskWorker.lastInitBlob = msg.blob; - const id = msg.id; - const b = FakeMaskWorker.behaviorFor(msg.blob); - queueMicrotask(() => { - this.onmessage?.({ data: { id, kind: 'decoded', w: b.w, h: b.h } } as MessageEvent); - }); - return; - } - if (msg.kind === 'mask') { - const id = msg.id; - const blob = FakeMaskWorker.lastInitBlob; - FakeMaskWorker.maskCalls.push({ - sheet: msg.sheet, - sensitivity: msg.sensitivity, - blob, - }); - const b = blob ? FakeMaskWorker.behaviorFor(blob) : { w: 40, h: 20, slabPx: 0 }; - const bitset = new Uint8Array(Math.max(1, b.w * b.h)).fill(b.slabPx > 0 ? 1 : 0); - const bbox = { - corners: [ - [b.w, 0], - [b.w, b.h], - [0, b.h], - [0, 0], - ] as [number, number][], - longSidePx: b.w, - angleDeg: 0, - }; - const slabBounds = b.slabBounds ?? { x: 0, y: 0, w: b.w, h: b.h }; - queueMicrotask(() => { - this.onmessage?.({ - data: { - id, - kind: 'ready', - slabPx: b.slabPx, - bitset, - bbox, - slabBounds, - }, - } as MessageEvent); - }); - return; - } - } - - terminate() {} -} - -const RealWorker = globalThis.Worker; -const RealCreateImageBitmap = globalThis.createImageBitmap; - -function installFakes() { - FakeMaskWorker.inits = []; - FakeMaskWorker.maskCalls = []; - FakeMaskWorker.lastInitBlob = null; - FakeMaskWorker.behaviorFor = () => ({ - w: 1000, - h: 900, - slabPx: 1000 * 900, - slabBounds: { x: 0, y: 0, w: 1000, h: 900 }, - }); - (globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker; - (globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap = - async (blob: ImageBitmapSource) => { - const b = blob instanceof Blob ? FakeMaskWorker.behaviorFor(blob) : { w: 1000, h: 900 }; - return { width: b.w, height: b.h, close() {} } as ImageBitmap; - }; -} - -function restoreGlobals() { - if (RealWorker === undefined) { - delete (globalThis as unknown as { Worker?: unknown }).Worker; - } else { - globalThis.Worker = RealWorker; - } - if (RealCreateImageBitmap === undefined) { - delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap; - } else { - globalThis.createImageBitmap = RealCreateImageBitmap; - } -} - -afterEach(() => { - restoreGlobals(); -}); - -async function settleOverlay( - s: ReturnType, - ms = 2500, -): Promise { - const start = Date.now(); - while (Date.now() - start < ms) { - const cap = get(s.capture); - if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay; - await new Promise((r) => setTimeout(r, 5)); - } - throw new Error('mask overlay never settled'); -} - -async function driveToMeasure( - s: ReturnType, - photos: File[], - calIndex: number, - maskParams: Partial = { sheet: 'green', sensitivity: 50 }, -) { - for (const p of photos) await s.addToBatch(p); - s.continueBatch(); - s.pickCalibration(calIndex); - if (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity); - // setSheet runs an immediate pass (after sensitivity so the pass sees frozen params) - s.setSheet(maskParams.sheet ?? 'auto'); - await settleOverlay(s); - s.approveMask(); - s.approveAxis(); - s.setLength(320); // 40 in - s.setThickness(16); // 2 in -} - -test('test(photos): Calculate stores overlayPng on results, clean inventory untouched (#63)', async () => { - installFakes(); - let encodeN = 0; - const s = createCaptureSession({ - draft: store, - encodeInventory: async () => { - encodeN++; - return { - blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }), - sourceBelowFloor: false, - }; - }, - } as Parameters[0]); - - const a = tinyJpeg('a'); - const b = tinyJpeg('b'); // cal - await driveToMeasure(s, [a, b], 1, { sheet: 'green' }); - await s.calculate(); - - const cap = get(s.capture); - assert.equal(cap.k, 'measure'); - if (cap.k !== 'measure') return; - assert.ok(cap.results, 'results present'); - assert.ok(cap.results!.inventoryPng, 'clean cal PNG in results.inventoryPng'); - assert.equal(cap.results!.inventoryPng.type, 'image/png'); - - const overlay = (cap.results as { overlayPng?: Blob }).overlayPng; - assert.ok(overlay, 'results.overlayPng must exist after Calculate (#63)'); - assert.equal(overlay!.type, 'image/png'); - assert.notEqual(overlay, cap.results!.inventoryPng, 'overlay is a second copy, not the clean PNG'); - - const apply = (cap as { batchApply?: unknown[] }).batchApply; - assert.ok(Array.isArray(apply)); - assert.equal(apply![1], null, 'batchApply[cal] stays null'); - assert.ok(apply![0] && (apply![0] as { status: string }).status === 'ok', 'siblings unchanged'); - - s.dispose(); -}); - -test('test(photos): single-photo batch still yields clean + overlayPng (#63)', async () => { - installFakes(); - const s = createCaptureSession({ - draft: store, - encodeInventory: async () => ({ - blob: new Blob(['only-clean'], { type: 'image/png' }), - sourceBelowFloor: false, - }), - } as Parameters[0]); - - await driveToMeasure(s, [tinyJpeg('only')], 0, { sheet: 'auto' }); - await s.calculate(); - - const cap = get(s.capture); - assert.equal(cap.k, 'measure'); - if (cap.k !== 'measure') return; - assert.ok(cap.results!.inventoryPng); - assert.ok((cap.results as { overlayPng?: Blob }).overlayPng, 'overlay even for batch of one'); - assert.deepEqual((cap as { batchApply?: unknown[] }).batchApply, [null]); - s.dispose(); -}); - -test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', async () => { - installFakes(); - const s = createCaptureSession({ - draft: store, - encodeInventory: async () => ({ - blob: new Blob(['draft-clean'], { type: 'image/png' }), - sourceBelowFloor: false, - }), - } as Parameters[0]); - - await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'black' }); - await s.calculate(); - await new Promise((r) => setTimeout(r, 30)); - - const d = store.peek(); - assert.ok(d); - assert.equal(d!.stage, 'measure'); - assert.ok(d!.inventoryPng, 'draft keeps clean inventory'); - assert.ok( - (d as { overlayPng?: Blob | null }).overlayPng, - 'draft.overlayPng must be persisted for #64 lineup', - ); - assert.equal(d!.batchApply![0], null, 'cal slot still null on draft'); - s.dispose(); -}); - -test('test(photos): dishonest proxies absent — no raw-photo overlay / no cal-slot stash (#63)', async () => { - const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8'); - const overlaySrcPath = join(here, '..', 'png', 'overlay.ts'); - let overlaySrc = ''; - try { - overlaySrc = readFileSync(overlaySrcPath, 'utf8'); - } catch { - overlaySrc = ''; - } - assert.ok( - /overlayPng|encodeOverlay|dimensionOverlay|buildOverlayPlan/i.test(sessionSrc) || - overlaySrc.length > 0, - 'session or png/overlay must implement dimension overlay (#63)', - ); - const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/); - assert.ok(calcBody, 'calculate() must exist'); - assert.ok( - !/batchApply\s*\[\s*calIndex\s*\]\s*=\s*\{[^}]*overlay/i.test(calcBody![0]), - 'must not put overlay into batchApply[cal]', - ); -}); diff --git a/frontend/src/capture/session.test.ts b/frontend/src/capture/session.test.ts new file mode 100644 index 0000000..75fc7b5 --- /dev/null +++ b/frontend/src/capture/session.test.ts @@ -0,0 +1,492 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { get } from 'svelte/store'; +import { + addToBatch, + approveAxis, + approveMaskAxis, + approveMask as runApproveMask, + back as runBack, + calculate as runCalculate, + createCaptureSession, + createSessionState, + continueBatch, + defaultMaskRun, + photosReadyInput, + pickCalibration, + removeFromBatch, + setLength, + setSheet, + setSensitivity, + setThickness, + setTapeLine, + type SessionDeps, +} from './session'; +import { buildPhotosReadyLineup } from './photosReadyLineup'; + +// ─── deterministic fakes ───────────────────────────────────────────────────── + +const W = 1200; +const H = 1000; + +function slabBitset(): Uint8Array { + // Solid 100×100 slab at (100,100); the rest is "sheet". + const bitset = new Uint8Array(W * H); + for (let y = 100; y < 200; y++) { + for (let x = 100; x < 200; x++) bitset[y * W + x] = 1; + } + return bitset; +} + +/** Slab = mid-brown wood (200,160,100) on a near-black sheet; auto picks black, blackKeep keeps the wood. */ +function slabRgba(): Uint8ClampedArray { + const rgba = new Uint8ClampedArray(W * H * 4); + for (let i = 0; i < W * H; i++) { + rgba[i * 4] = 5; + rgba[i * 4 + 1] = 5; + rgba[i * 4 + 2] = 5; + rgba[i * 4 + 3] = 255; + } + for (let y = 100; y < 200; y++) { + for (let x = 100; x < 200; x++) { + const i = (y * W + x) * 4; + rgba[i] = 200; + rgba[i + 1] = 160; + rgba[i + 2] = 100; + rgba[i + 3] = 255; + } + } + return rgba; +} + +const SLAB_PX = 100 * 100; + +const testDecode: SessionDeps['decode'] = async (blob) => ({ + width: W, + height: H, + rgba: blob.name === 'blank.jpg' ? new Uint8ClampedArray(W * H * 4).fill(5) : slabRgba(), + close: () => {}, +}); + +const testEncodeInventory: SessionDeps['encodeInventory'] = async (source, mask) => ({ + blob: new Blob([`inv:${mask.slabPx}:${source.widthPx}`], { type: 'image/png' }), + sourceBelowFloor: Math.min(source.widthPx, source.heightPx) < 800, +}); + +const testEncodeOverlay: SessionDeps['encodeOverlay'] = async (_source, _mask, _axis, _measures, inventoryPng) => + new Blob([`ovl:${(inventoryPng as Blob).size}`], { type: 'image/png' }); + +const deps: SessionDeps = { + decode: testDecode, + maskRun: defaultMaskRun, + encodeInventory: testEncodeInventory, + encodeOverlay: testEncodeOverlay, +}; + +const file = (label: string): File => new File([new Uint8Array([1])], `${label}.jpg`, { type: 'image/jpeg' }); + +const driveToMask = async (d: SessionDeps = deps) => { + let st = createSessionState(); + st = addToBatch(st, file('cal')); + st = continueBatch(st); + st = await pickCalibration(st, 0, d); + st = await runApproveMask(st, d); + return st; +}; + +const driveToMeasure = async (d: SessionDeps = deps) => { + const st = await driveToMask(d); + return approveAxis(approveMaskAxis(st)); +}; + +// ─── batch state ───────────────────────────────────────────────────────────── + +test('session starts on batch with zero photos', () => { + const s = createSessionState(); + assert.equal(s.capture.k, 'batch'); + if (s.capture.k === 'batch') assert.equal(s.capture.photos.length, 0); + assert.deepEqual(s.batch, []); + assert.equal(s.calibrationIndex, null); +}); + +test('addToBatch accumulates in capture order and stays on batch', () => { + let s = createSessionState(); + s = addToBatch(s, file('a')); + s = addToBatch(s, file('b')); + assert.equal(s.capture.k, 'batch'); + assert.equal(s.batch.length, 2); + assert.equal(s.batch[0].blob.name, 'a.jpg'); + assert.equal(s.batch[1].blob.name, 'b.jpg'); +}); + +test('addToBatch is a no-op off the batch state', async () => { + const s = await driveToMeasure(); + const after = addToBatch(s, file('late')); + assert.equal(after, s, 'state object unchanged'); +}); + +test('removeFromBatch drops the slot and keeps capture order of the rest', () => { + let s = createSessionState(); + s = addToBatch(s, file('a')); + s = addToBatch(s, file('b')); + s = addToBatch(s, file('c')); + s = removeFromBatch(s, 1); + assert.equal(s.batch.length, 2); + assert.equal(s.batch[0].blob.name, 'a.jpg'); + assert.equal(s.batch[1].blob.name, 'c.jpg'); +}); + +test('removeFromBatch ignores out-of-range and off-state calls', () => { + let s = createSessionState(); + s = addToBatch(s, file('a')); + assert.equal(removeFromBatch(s, 5), s); + assert.equal(removeFromBatch(s, -1), s); +}); + +test('continueBatch is a no-op when the batch is empty', () => { + const s = createSessionState(); + assert.equal(continueBatch(s), s); +}); + +test('continueBatch with photos advances to pickCalibration', () => { + let s = createSessionState(); + s = addToBatch(s, file('a')); + s = continueBatch(s); + assert.equal(s.capture.k, 'pickCalibration'); +}); + +// ─── pick calibration + mask ───────────────────────────────────────────────── + +test('pickCalibration enters mask state on the picked source', async () => { + let s = createSessionState(); + s = addToBatch(s, file('a')); + s = addToBatch(s, file('b')); + s = continueBatch(s); + s = await pickCalibration(s, 1, deps); + assert.equal(s.capture.k, 'mask'); + if (s.capture.k === 'mask') { + assert.equal(s.capture.source.blob.name, 'b.jpg', 'picked photo, not the first'); + assert.deepEqual(s.capture.control, { sheet: 'auto', sensitivity: 50 }); + assert.equal(s.capture.overlay, null); + } + assert.equal(s.calibrationIndex, 1); +}); + +test('pickCalibration ignores out-of-range indices', async () => { + let s = createSessionState(); + s = addToBatch(s, file('a')); + s = continueBatch(s); + const after = await pickCalibration(s, 3, deps); + assert.equal(after, s); +}); + +test('setSheet / setSensitivity only apply on the mask state and clamp', () => { + let s = createSessionState(); + s = addToBatch(s, file('a')); + const off = setSheet(s, 'green'); + assert.equal(off, s, 'no-op off mask'); + s = continueBatch(s); + s = setSheet(s, 'black'); + s = setSensitivity(s, 150); + if (s.capture.k === 'mask') { + assert.equal(s.capture.control.sheet, 'black'); + assert.equal(s.capture.control.sensitivity, 100, 'clamped to 0–100'); + } +}); + +test('approveMask runs sheetKeep over the calibration photo and stores the mask', async () => { + const s = await driveToMask(); + assert.equal(s.capture.k, 'mask'); + if (s.capture.k === 'mask') { + assert.ok(s.capture.overlay, 'overlay settled'); + assert.equal(s.capture.overlay.slabPx, SLAB_PX, 'wood slab pixels kept, near-black sheet dropped'); + assert.equal(s.capture.failed, false); + assert.ok(Math.abs(s.capture.overlay.bbox.longSidePx - 100) <= 1, `min-area rect long side ≈ 100 px (got ${s.capture.overlay.bbox.longSidePx})`); + } +}); + +test('approveMask marks failed when the mask is empty (no slab pixels)', async () => { + let s = createSessionState(); + s = addToBatch(s, file('blank')); + s = continueBatch(s); + s = await pickCalibration(s, 0, deps); + s = await runApproveMask(s, deps); + if (s.capture.k === 'mask') { + assert.equal(s.capture.failed, true); + assert.equal(s.capture.overlay, null); + } +}); + +test('approveMask marks failed when decode throws', async () => { + const failingDeps: SessionDeps = { ...deps, decode: async () => { throw new Error('decode failed'); } }; + let s = createSessionState(); + s = addToBatch(s, file('a')); + s = continueBatch(s); + s = await pickCalibration(s, 0, failingDeps); + s = await runApproveMask(s, failingDeps); + if (s.capture.k === 'mask') { + assert.equal(s.capture.failed, true); + assert.equal(s.capture.overlay, null); + } +}); + +// ─── axis state ────────────────────────────────────────────────────────────── + +test('approveMaskAxis is blocked while the mask is empty or failed', async () => { + const failingDeps: SessionDeps = { ...deps, decode: async () => { throw new Error('decode failed'); } }; + let s = createSessionState(); + s = addToBatch(s, file('a')); + s = continueBatch(s); + s = await pickCalibration(s, 0, failingDeps); + s = await runApproveMask(s, failingDeps); + assert.equal(approveMaskAxis(s), s, 'no mask → stays on mask'); +}); + +test('approveMaskAxis freezes the mask and opens axis with tape-line rectangle', async () => { + const s = approveMaskAxis(await driveToMask()); + assert.equal(s.capture.k, 'axis'); + if (s.capture.k === 'axis') { + assert.equal(s.capture.approvedMask.slabPx, SLAB_PX); + assert.ok(s.capture.axis.bbox.longSidePx > 0); + assert.deepEqual(s.capture.axis.tapeLine.start, [120, 500], 'guide start at 10% of width, mid height'); + assert.deepEqual(s.capture.axis.tapeLine.end, [1080, 500], 'guide end at 90% of width, mid height'); + } +}); + +test('setTapeLine rebuilds the axis rectangle from the endpoints', async () => { + let s = approveMaskAxis(await driveToMask()); + s = setTapeLine(s, { start: [100, 100], end: [300, 100] }); + if (s.capture.k === 'axis') { + assert.equal(s.capture.axis.bbox.longSidePx, 200); + assert.deepEqual(s.capture.axis.tapeLine, { start: [100, 100], end: [300, 100] }); + } +}); + +test('setTapeLine is a no-op on a zero-length line', async () => { + const s = approveMaskAxis(await driveToMask()); + assert.equal(setTapeLine(s, { start: [10, 10], end: [10, 10] }), s); +}); + +test('setTapeLine is a no-op off the axis state', () => { + const s = createSessionState(); + assert.equal(setTapeLine(s, { start: [0, 0], end: [1, 1] }), s); +}); + +test('approveAxis freezes the axis and opens measure with zero inputs', async () => { + let s = approveMaskAxis(await driveToMask()); + s = setTapeLine(s, { start: [100, 100], end: [500, 100] }); + s = approveAxis(s); + assert.equal(s.capture.k, 'measure'); + if (s.capture.k === 'measure') { + assert.equal(s.capture.lengthEighths, 0); + assert.equal(s.capture.thicknessEighths, 0); + assert.equal(s.capture.results, null); + assert.equal(s.capture.approvedMask.slabPx, SLAB_PX); + } +}); + +// ─── measure + calculate ───────────────────────────────────────────────────── + +test('setLength / setThickness clamp to non-negative eighths', async () => { + let s = await driveToMeasure(); + s = setLength(s, -4); + s = setThickness(s, 12.6); + if (s.capture.k === 'measure') { + assert.equal(s.capture.lengthEighths, 0); + assert.equal(s.capture.thicknessEighths, 13, 'rounded to whole eighths'); + } +}); + +test('calculate is a no-op until length and thickness are set', async () => { + let s = await driveToMeasure(); + s = setLength(s, 48); + const { state } = await runCalculate(s, deps); + assert.equal(state, s, 'thickness still 0 → unchanged'); +}); + +test('calculate measures the slab and encodes inventory + overlay PNGs', async () => { + let s = await driveToMask(); + s = approveMaskAxis(s); + s = setTapeLine(s, { start: [100, 100], end: [500, 100] }); + s = approveAxis(s); + s = setLength(s, 48); // 6 in + s = setThickness(s, 12); // 1.5 in + const outcome = await runCalculate(s, deps); + assert.notEqual(outcome.state, s, 'calculate returns a new state (pure; the wrapper applies it)'); + if (outcome.state.capture.k === 'measure') { + const results = outcome.state.capture.results; + assert.ok(results, 'results present'); + assert.ok(results.inventoryPng instanceof Blob, 'inventory PNG blob'); + assert.ok(results.overlayPng, 'overlay copy present'); + assert.equal(results.sourceBelowFloor, false, '1200×1000 source clears the 800 px floor'); + // Tape is 400 px for 6 in → scale 66.67 px/in; 10 000 slab px = 1.5 in × 1.5 in → 2.25 sqft… at 100 px/in. + // At 66.67 px/in the slab is 1.5 in × 1.5 in = 2.25 sqft. (10 000 px² / 4444.89 px²/in² / 144 = 0.0155 → 0.02.) + assert.equal(results.scale, Math.round((400 / 6) * 100) / 100); + assert.equal(results.sqft, 0.02); + assert.equal(results.bdft, 0.03); + } + assert.deepEqual(outcome.lineup.batchApply, [null]); + assert.equal(outcome.lineup.sourceBelowFloor, false); +}); + +test('calculate produces the CAPTURE-130 lineup for a 3-photo batch (cal + 1 ok + 1 error)', async () => { + let s = createSessionState(); + s = addToBatch(s, file('cal')); + s = addToBatch(s, file('ok')); + s = addToBatch(s, file('blank')); + s = continueBatch(s); + s = await pickCalibration(s, 0, deps); + s = await runApproveMask(s, deps); + s = approveAxis(approveMaskAxis(s)); + s = setLength(s, 48); + s = setThickness(s, 12); + const outcome = await runCalculate(s, deps); + const items = buildPhotosReadyLineup(outcome.lineup); + assert.equal(items.length, 4); + assert.equal(items[0].kind, 'png'); + if (items[0].kind === 'png') assert.equal(items[0].role, 'calibration'); + assert.equal(items[1].kind, 'png'); + if (items[1].kind === 'png') assert.equal(items[1].role, 'overlay'); + assert.equal(items[2].kind, 'png'); + if (items[2].kind === 'png') { + assert.equal(items[2].role, 'batch'); + assert.equal(items[2].batchIndex, 1, 'ok sibling at capture order 1'); + } + assert.equal(items[3].kind, 'error'); + if (items[3].kind === 'error') { + assert.equal(items[3].batchIndex, 2, 'blank sibling at capture order 2'); + assert.equal(items[3].message, 'empty mask: no slab pixels detected'); + } +}); + +test('calculate encodes siblings serially; one failure does not block the rest', async () => { + const order: string[] = []; + const slowDeps: SessionDeps = { + ...deps, + decode: async (blob) => { + order.push(`decode:${blob.name}`); + return testDecode(blob); + }, + encodeInventory: async (source, mask) => { + order.push(`encode:${source.widthPx}`); + return testEncodeInventory(source, mask); + }, + }; + let s = createSessionState(); + s = addToBatch(s, file('cal')); + s = addToBatch(s, file('ok')); + s = continueBatch(s); + s = await pickCalibration(s, 0, slowDeps); + s = await runApproveMask(s, slowDeps); + s = approveAxis(approveMaskAxis(s)); + s = setLength(s, 48); + s = setThickness(s, 12); + const outcome = await runCalculate(s, slowDeps); + assert.deepEqual(order, ['decode:cal.jpg', 'decode:cal.jpg', 'encode:1200', 'decode:ok.jpg', 'encode:1200'], 'calibration encoded before sibling decode'); + assert.equal(outcome.lineup.batchApply?.[0], null, 'calibration slot is null'); + assert.equal(outcome.lineup.batchApply?.[1]?.status, 'ok', 'sibling processed'); +}); + +test('calculate marks the floor-fail sibling as an error slot (CAPTURE-105/110)', async () => { + const smallDeps: SessionDeps = { + ...deps, + decode: async (blob) => { + if (blob.name !== 'small.jpg') return testDecode(blob); + const rgba = new Uint8ClampedArray(300 * 300 * 4).fill(5); + for (let y = 50; y < 150; y++) { + for (let x = 50; x < 150; x++) { + const i = (y * 300 + x) * 4; + rgba[i] = 200; + rgba[i + 1] = 160; + rgba[i + 2] = 100; + rgba[i + 3] = 255; + } + } + return { width: 300, height: 300, rgba, close: () => {} }; + }, + }; + let s = createSessionState(); + s = addToBatch(s, file('cal')); + s = addToBatch(s, file('small')); + s = continueBatch(s); + s = await pickCalibration(s, 0, smallDeps); + s = await runApproveMask(s, smallDeps); + s = approveAxis(approveMaskAxis(s)); + s = setLength(s, 48); + s = setThickness(s, 12); + const outcome = await runCalculate(s, smallDeps); + const items = buildPhotosReadyLineup(outcome.lineup); + const last = items[items.length - 1]; + assert.equal(last.kind, 'error'); + if (last.kind === 'error') assert.equal(last.message, 'PNG size floor: shorter side is below 800 px'); +}); + +// ─── photos-ready input + back + wrapper ───────────────────────────────────── + +test('photosReadyInput is empty off the measure state or before calculate', async () => { + const s = await driveToMeasure(); + assert.deepEqual(photosReadyInput(s), { inventoryPng: null, overlayPng: null, batchApply: null, sourceBelowFloor: false }); +}); + +test('photosReadyInput reflects the results after calculate', async () => { + let s = await driveToMask(); + s = approveMaskAxis(s); + s = setTapeLine(s, { start: [100, 100], end: [500, 100] }); + s = approveAxis(s); + s = setLength(s, 48); + s = setThickness(s, 12); + const outcome = await runCalculate(s, deps); + const input = photosReadyInput(outcome.state); + assert.ok(input.inventoryPng); + assert.ok(input.overlayPng); + assert.deepEqual(input.batchApply, [null]); +}); + +test('back walks measure → axis → mask → pickCalibration → batch', async () => { + let s = await driveToMeasure(); + s = runBack(s); + assert.equal(s.capture.k, 'axis'); + s = runBack(s); + assert.equal(s.capture.k, 'mask'); + if (s.capture.k === 'mask') { + assert.ok(s.capture.overlay, 'back to mask keeps the last approved overlay'); + assert.equal(s.capture.failed, false); + } + s = runBack(s); + assert.equal(s.capture.k, 'pickCalibration'); + s = runBack(s); + assert.equal(s.capture.k, 'batch'); +}); + +test('store wrapper drives the full flow and exposes the lineup', async () => { + const session = createCaptureSession(deps); + session.addToBatch(file('cal')); + session.addToBatch(file('ok')); + session.continueBatch(); + await session.pickCalibration(0); + await session.approveMask(); + session.approveMaskAxis(); + session.setTapeLine({ start: [100, 100], end: [500, 100] }); + session.approveAxis(); + session.setLength(48); + session.setThickness(12); + const lineupInput = await session.calculate(); + assert.ok(lineupInput.inventoryPng, 'calculate produced the inventory PNG'); + const items = session.lineup(); + assert.equal(items.length, 3, 'clean cal + overlay + one batch sibling'); + const state = get(session.state); + assert.equal(state.capture.k, 'measure'); + if (state.capture.k === 'measure') assert.ok(state.capture.results); + session.dispose(); +}); + +test('store wrapper setCropFill coerces percent and fraction', async () => { + const session = createCaptureSession(deps); + session.setCropFill(50); + assert.equal(get(session.state).cropFill, 0.5); + session.setCropFill(0.25); + assert.equal(get(session.state).cropFill, 0.25); + session.setCropFill(Number.NaN); + assert.equal(get(session.state).cropFill, 0.9, 'falls back to the 90% default'); + session.dispose(); +}); diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 5249fa8..6d8f1ca 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -1,684 +1,498 @@ -import { writable } from 'svelte/store'; -import type { - BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, TapeLine, WorkerIn, WorkerOut, -} from './capture.types'; -import { - DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn, -} from './capture.types'; -import { loadDraft, saveDraft, clearDraft } from '../draft/idb'; +/** + * M2 capture session (PHOTOS.md, CAPTURE-100..135). + * + * `runSession` is a pure state machine: one state object in, one state object + * out, with every side effect (masking, PNG encoding) injected. No DOM, no + * timers, no stores — screens wrap it (see `createCaptureSession` below). + * + * Flow: batch → pickCalibration → mask → axis → measure (Calculate) → Photos-ready + * lineup (`photosReadyLineup`). The calibration photo drives mask/axis/measure; + * its frozen mask params are applied to every batch sibling after Calculate, + * with per-photo error slots (CAPTURE-110) and measures tied to calibration + * only (CAPTURE-115). + */ +import { get, writable, type Readable } from 'svelte/store'; +import { calibrationScale, fillFromSetting, FILL_DEFAULT } from '../png/crop'; import { measure } from '../measure/measure'; -import { initialTapeLine, rectAlongLine } from '../geometry/rect'; -import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; -import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay'; -import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop'; -import { decodeOrientedBitmap } from '../decodeOrientedBitmap'; +import { initialTapeLine, rectAlongLine, slabBounds, minAreaRect } from '../geometry/rect'; +import { keepForSheet } from '../mask/sheetKeep'; +import { composeInventoryFrame } from '../png/inventory'; +import { eighthsToIn, type Axis, type BatchPhotoResult, type Capture, type Mask, type MaskParams, type Measures, type Sheet, type Source } from './capture.types'; +import { buildPhotosReadyLineup, type PhotosReadyLineupInput, type PhotosReadyLineupItem } from './photosReadyLineup'; +import { decodeOrientedBitmap } from './decodeOrientedBitmap'; -const DEBOUNCE_MS = 2000; -const BUSY_MS = 300; - -export interface DraftStore { - loadDraft(): Promise; - saveDraft(d: Draft): Promise; - clearDraft(): Promise; +/** Decoded photo in source pixels (EXIF-oriented); `close()` releases the bitmap. */ +export interface DecodedPhoto { + width: number; + height: number; + rgba: Uint8Array | Uint8ClampedArray; + close(): void; } +/** Mask pass: resolve sheet + sensitivity into a bitset over the source grid. */ +export interface MaskRun { + (source: Source, params: MaskParams, decoded: DecodedPhoto): Promise; +} + +/** Inventory PNG encoder (calibration photo). */ +export type EncodeInventory = (source: Source, mask: Mask) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>; + +/** Dimension-overlay copy of the calibration inventory PNG. */ +export type EncodeOverlay = (source: Source, mask: Mask, axis: Axis, measures: Measures, inventoryPng: Blob) => Promise; + +/** + * Injectable dependencies: the production wiring decodes EXIF-oriented and + * encodes to blobs; deterministic tests supply pixel-level fakes. + */ +export interface SessionDeps { + decode: (blob: Blob) => Promise; + maskRun: MaskRun; + encodeInventory: EncodeInventory; + encodeOverlay: EncodeOverlay; + /** Global crop fill (settings.crop_fill); FILL_DEFAULT (90%) until told otherwise. */ + cropFill?: number; +} + +export interface SessionState { + capture: Capture; + batch: Source[]; // capture order; authoritative across states + calibrationIndex: number | null; + cropFill: number; +} + +export interface BatchPhotoOutcome { + state: SessionState; + /** Lineup builder input; `photosReadyLineup.buildPhotosReadyLineup` renders the slots. */ + lineup: PhotosReadyLineupInput; +} + +const clampSensitivity = (n: number): number => Math.max(0, Math.min(100, Math.round(n))); +const clampEighths = (e: number): number => Math.max(0, Math.round(e)); + +export const createSessionState = (): SessionState => ({ + capture: { k: 'batch', photos: [] }, + batch: [], + calibrationIndex: null, + cropFill: FILL_DEFAULT, +}); + +const patch = (s: SessionState, cap: Capture): SessionState => ({ ...s, capture: cap }); + +/** Build the approved mask from a decoded photo using the shared P1 keep predicates. */ +export const buildMask = ( + params: MaskParams, + decoded: DecodedPhoto, + bitset: Uint8Array, +): Mask => { + const { width, height } = decoded; + const slabPx = bitset.reduce((sum, b) => sum + (b === 1 ? 1 : 0), 0); + return { + params, + slabPx, + bitset, + bbox: minAreaRect(bitset, width, height) ?? { + corners: [[0, 0], [0, 0], [0, 0], [0, 0]], + longSidePx: 0, + angleDeg: 0, + }, + slabBounds: slabBounds(bitset, width, height), + }; +}; + +/** Default mask pass: keep predicates from `mask/sheetKeep` over the decoded grid. */ +export const defaultMaskRun: MaskRun = async (source, params, decoded) => + buildMask(params, decoded, walkMask(decoded, params)); + +/** Walk the decoded RGBA grid through the sheet-keep predicate (CAPTURE-30..60). */ +function walkMask(decoded: DecodedPhoto, params: MaskParams): Uint8Array { + const keep = keepForSheet(params.sheet, params.sensitivity, samplePixels(decoded)); + const { width, height, rgba } = decoded; + const bitset = new Uint8Array(width * height); + for (let i = 0; i < width * height; i++) { + bitset[i] = keep(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]) ? 1 : 0; + } + return bitset; +} + +/** Stride-16 edge-pixel sample for `autoPickSheet` (matches the P1 mask worker). */ +function samplePixels(decoded: DecodedPhoto): { r: number; g: number; b: number }[] { + const { width, height, rgba } = decoded; + const samples: { r: number; g: number; b: number }[] = []; + for (let y = 0; y < height; y += 16) { + for (let x = 0; x < width; x += 16) { + const i = (y * width + x) * 4; + samples.push({ r: rgba[i], g: rgba[i + 1], b: rgba[i + 2] }); + } + } + return samples; +} + +/** Default inventory encode: P1 frame composer + canvas → image/png blob. */ +export const defaultEncodeInventory: EncodeInventory = async (source, mask) => { + const bitmap = await decodeOrientedBitmap(source.blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Canvas 2D is unavailable'); + context.drawImage(bitmap, 0, 0); + bitmap.close(); + const frame = composeInventoryFrame( + { width: source.widthPx, height: source.heightPx, rgba: context.getImageData(0, 0, source.widthPx, source.heightPx).data }, + { bitset: mask.bitset, slabBounds: mask.slabBounds }, + { widthPx: source.widthPx, heightPx: source.heightPx }, + ); + const out = document.createElement('canvas'); + out.width = frame.width; + out.height = frame.height; + const octx = out.getContext('2d'); + if (!octx) throw new Error('Canvas 2D is unavailable'); + octx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0); + const blob = await new Promise((resolve, reject) => + out.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG encode failed'))), 'image/png'), + ); + return { blob, sourceBelowFloor: frame.sourceBelowFloor }; +}; + +/** Default overlay encode: same canvas round-trip (P1 overlay drawing lives in screens). */ +export const defaultEncodeOverlay: EncodeOverlay = async (source, _mask, _axis, _measures, inventoryPng) => { + const bitmap = await decodeOrientedBitmap(inventoryPng); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Canvas 2D is unavailable'); + context.drawImage(bitmap, 0, 0); + bitmap.close(); + void source; + return new Promise((resolve, reject) => + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG encode failed'))), 'image/png'), + ); +}; + +export const defaultDeps: SessionDeps = { + decode: async (blob) => { + const bitmap = await decodeOrientedBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Canvas 2D is unavailable'); + context.drawImage(bitmap, 0, 0); + const rgba = context.getImageData(0, 0, bitmap.width, bitmap.height).data; + return { width: bitmap.width, height: bitmap.height, rgba, close: () => bitmap.close() }; + }, + maskRun: defaultMaskRun, + encodeInventory: defaultEncodeInventory, + encodeOverlay: defaultEncodeOverlay, +}; + +// ─── transitions ───────────────────────────────────────────────────────────── + +export const addToBatch = (s: SessionState, file: File): SessionState => { + if (s.capture.k !== 'batch') return s; + const source: Source = { widthPx: 0, heightPx: 0, blob: file }; + return { ...s, batch: [...s.batch, source], capture: { k: 'batch', photos: [...s.batch, source] } }; +}; + +export const removeFromBatch = (s: SessionState, index: number): SessionState => { + if (s.capture.k !== 'batch') return s; + if (index < 0 || index >= s.batch.length) return s; + const batch = s.batch.filter((_, i) => i !== index); + return { ...s, batch, capture: { k: 'batch', photos: batch } }; +}; + +/** Advance to pick-calibration once the batch has at least one photo. */ +export const continueBatch = (s: SessionState): SessionState => { + if (s.capture.k !== 'batch' || s.batch.length === 0) return s; + return patch(s, { k: 'pickCalibration', photos: [...s.batch] }); +}; + +/** Pick the calibration reference and decode its real dimensions into the mask state. */ +export async function pickCalibration(s: SessionState, index: number, deps: SessionDeps): Promise { + if (s.capture.k !== 'pickCalibration' || index < 0 || index >= s.batch.length) return s; + const photo = s.batch[index]; + let source: Source = photo; + try { + const decoded = await deps.decode(photo.blob); + try { + source = { widthPx: decoded.width, heightPx: decoded.height, blob: photo.blob }; + } finally { + decoded.close(); + } + } catch { + // Dimensions stay 0; approveMask will surface the decode failure. + } + return patch({ ...s, calibrationIndex: index }, { + k: 'mask', + source, + control: { ...DEFAULT_PARAMS }, + overlay: null, + failed: false, + }); +} + +const DEFAULT_PARAMS: MaskParams = { sheet: 'auto', sensitivity: 50 }; + +export const setSheet = (s: SessionState, sheet: Sheet): SessionState => { + if (s.capture.k !== 'mask') return s; + return patch(s, { ...s.capture, control: { ...s.capture.control, sheet } }); +}; + +export const setSensitivity = (s: SessionState, n: number): SessionState => { + if (s.capture.k !== 'mask') return s; + return patch(s, { ...s.capture, control: { ...s.capture.control, sensitivity: clampSensitivity(n) } }); +}; + +/** Run the sheet-keep mask over the calibration photo (re-derives on every call). */ +export async function approveMask(s: SessionState, deps: SessionDeps): Promise { + if (s.capture.k !== 'mask') return s; + const { source, control } = s.capture; + try { + const decoded = await deps.decode(source.blob); + try { + const mask = await deps.maskRun(source, control, decoded); + if (mask.slabPx === 0) { + return patch({ ...s, calibrationIndex: s.calibrationIndex }, { ...s.capture, overlay: null, failed: true }); + } + return patch(s, { ...s.capture, overlay: mask, failed: false }); + } finally { + decoded.close(); + } + } catch { + return patch(s, { ...s.capture, overlay: null, failed: true }); + } +} + +/** Freeze the mask and open the axis state with initial tape guides. */ +export const approveMaskAxis = (s: SessionState): SessionState => { + if (s.capture.k !== 'mask') return s; + const { source, overlay } = s.capture; + if (!overlay || overlay.slabPx === 0) return s; + const tapeLine = initialTapeLine(source.widthPx, source.heightPx); + return patch(s, { + k: 'axis', + source, + approvedMask: overlay, + axis: { bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine }, + }); +}; + +/** Nudge the tape endpoints; the axis rectangle follows the line (CAPTURE-55). */ +export const setTapeLine = (s: SessionState, tapeLine: { start: [number, number]; end: [number, number] }): SessionState => { + if (s.capture.k !== 'axis') return s; + const start: [number, number] = [...tapeLine.start]; + const end: [number, number] = [...tapeLine.end]; + if (start[0] === end[0] && start[1] === end[1]) return s; + const bbox = rectAlongLine(s.capture.axis.bbox, start, end); + if (!(bbox.longSidePx > 0)) return s; + return patch(s, { ...s.capture, axis: { bbox, tapeLine: { start, end } } }); +}; + +/** Freeze the axis and open measure with zero length/thickness. */ +export const approveAxis = (s: SessionState): SessionState => { + if (s.capture.k !== 'axis') return s; + const { source, approvedMask, axis } = s.capture; + return patch(s, { + k: 'measure', + source, + approvedMask, + axis: { bbox: axis.bbox, tapeLine: axis.tapeLine }, + lengthEighths: 0, + thicknessEighths: 0, + results: null, + }); +}; + +export const setLength = (s: SessionState, eighths: number): SessionState => { + if (s.capture.k !== 'measure') return s; + return patch(s, { ...s.capture, lengthEighths: clampEighths(eighths) }); +}; + +export const setThickness = (s: SessionState, eighths: number): SessionState => { + if (s.capture.k !== 'measure') return s; + return patch(s, { ...s.capture, thicknessEighths: clampEighths(eighths) }); +}; + +/** + * Calculate: measure the calibration photo, encode its inventory + overlay PNG, + * then apply the frozen mask params to every batch sibling (CAPTURE-110). + * Siblings run serially; a failing decode/mask/encode cannot block later ones. + */ +export async function calculate(s: SessionState, deps: SessionDeps): Promise { + if (s.capture.k !== 'measure') { + return { state: s, lineup: emptyLineup() }; + } + const cap = s.capture; + if (cap.lengthEighths === 0 || cap.thicknessEighths === 0) { + return { state: s, lineup: emptyLineup() }; + } + const scale = calibrationScale(cap.axis.tapeLine.start, cap.axis.tapeLine.end, eighthsToIn(cap.lengthEighths)); + if (!(scale > 0) || !Number.isFinite(scale)) { + return { state: s, lineup: emptyLineup() }; + } + // 2-dp scale: display numbers (sqft/bdft) are rounded to 2 dp anyway, and a + // stable scale keeps results deterministic across runs. + const roundedScale = Math.round(scale * 100) / 100; + const base = measure( + cap.approvedMask.bitset, + cap.source.widthPx, + cap.source.heightPx, + cap.axis.bbox, + cap.lengthEighths, + cap.thicknessEighths, + cap.approvedMask.slabPx, + roundedScale, + ); + const encoded = await deps.encodeInventory(cap.source, cap.approvedMask); + let overlayPng: Blob | undefined; + try { + overlayPng = await deps.encodeOverlay(cap.source, cap.approvedMask, cap.axis, { ...base, inventoryPng: encoded.blob, sourceBelowFloor: encoded.sourceBelowFloor }, encoded.blob); + } catch { + overlayPng = undefined; + } + const results: Measures = { + ...base, + inventoryPng: encoded.blob, + overlayPng, + sourceBelowFloor: encoded.sourceBelowFloor, + }; + const batch = s.batch.length ? s.batch : [cap.source]; + const calIndex = s.calibrationIndex != null && s.calibrationIndex >= 0 ? s.calibrationIndex : 0; + const batchApply: BatchPhotoResult[] = batch.map((_, index) => + index === calIndex ? null : { status: 'error', message: 'Batch photo was not processed' }, + ); + const params = cap.approvedMask.params; + for (let index = 0; index < batch.length; index++) { + if (index === calIndex) continue; + const photo = batch[index]; + try { + const decoded = await deps.decode(photo.blob); + let mask: Mask; + try { + mask = await deps.maskRun(photo, params, decoded); + if (mask.slabPx === 0) throw new Error('empty mask: no slab pixels detected'); + } finally { + decoded.close(); + } + const siblingSource: Source = { widthPx: decoded.width, heightPx: decoded.height, blob: photo.blob }; + const sibling = await deps.encodeInventory(siblingSource, mask); + if (sibling.sourceBelowFloor) throw new Error('PNG size floor: shorter side is below 800 px'); + batchApply[index] = { status: 'ok', inventoryPng: sibling.blob }; + } catch (error) { + batchApply[index] = { + status: 'error', + message: error instanceof Error ? error.message : 'unable to process photo', + }; + } + } + const next = patch(s, { ...cap, results, batchApply }); + return { + state: next, + lineup: { + inventoryPng: results.inventoryPng, + overlayPng: results.overlayPng, + batchApply, + sourceBelowFloor: results.sourceBelowFloor, + }, + }; +} + +const emptyLineup = (): PhotosReadyLineupInput => ({ + inventoryPng: null, + overlayPng: null, + batchApply: null, + sourceBelowFloor: false, +}); + +/** The Photos-ready lineup for the current state (empty off the measure state). */ +export const photosReadyInput = (s: SessionState): PhotosReadyLineupInput => { + if (s.capture.k !== 'measure' || !s.capture.results) return emptyLineup(); + const { results, batchApply } = s.capture; + return { + inventoryPng: results.inventoryPng, + overlayPng: results.overlayPng, + batchApply: batchApply ?? null, + sourceBelowFloor: results.sourceBelowFloor, + }; +}; + +/** Back one step; the mask step keeps its last overlay as the live one. */ +export const back = (s: SessionState): SessionState => { + const { capture } = s; + if (capture.k === 'pickCalibration') { + return patch(s, { k: 'batch', photos: [...s.batch] }); + } + if (capture.k === 'mask') { + return patch(s, { k: 'pickCalibration', photos: [...s.batch] }); + } + if (capture.k === 'axis') { + const { source, approvedMask } = capture; + return patch(s, { k: 'mask', source, control: approvedMask.params, overlay: approvedMask, failed: false }); + } + if (capture.k === 'measure') { + const { source, approvedMask, axis } = capture; + return patch(s, { k: 'axis', source, approvedMask, axis: { bbox: axis.bbox, tapeLine: axis.tapeLine } }); + } + return s; +}; + +// ─── store wrapper ─────────────────────────────────────────────────────────── + export interface CaptureSession { - capture: import('svelte/store').Readable; - busy: import('svelte/store').Readable; - resumeProbe(): Promise; - continueDraft(): void; - startFresh(): void; - /** Append a photo to the batch; stays on batch. */ - addToBatch(file: File): Promise; - /** Alias: same as addToBatch (no longer jumps to mask). */ - addPhoto(file: File): Promise; + state: Readable; + addToBatch(file: File): void; removeFromBatch(index: number): void; - /** Advance when batch has ≥1 photo → pickCalibration. */ continueBatch(): void; - /** User picks which batch photo is the calibration reference → Mask on that Source. */ - pickCalibration(index: number): void; - retakes(): void; + pickCalibration(index: number): Promise; + setSheet(sheet: Sheet): void; setSensitivity(n: number): void; - setSheet(s: Sheet): void; - approveMask(): void; - setTapeLine(tapeLine: TapeLine): void; - /** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */ - setCropFill(pctOrFraction: number): void; + approveMask(): Promise; + approveMaskAxis(): void; + setTapeLine(tapeLine: { start: [number, number]; end: [number, number] }): void; approveAxis(): void; - setLength(e: Eighths): void; - setThickness(e: Eighths): void; - calculate(): Promise; + setLength(eighths: number): void; + setThickness(eighths: number): void; + setCropFill(pctOrFraction: number): void; + calculate(): Promise; + lineup(): PhotosReadyLineupItem[]; back(): void; - getInventoryUrl(): string | null; - /** The current `measure` branch, or null while not on it (C2 handoff). */ - currentMeasure(): Extract | null; dispose(): void; } -export interface SessionOpts { - draft?: DraftStore; - /** Injectable only for deterministic tests; production uses encodeInventoryPng. */ - encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>; - /** Injectable only for deterministic tests; production uses encodeOverlayPng. */ - encodeOverlay?: (input: EncodeOverlayInput) => Promise; -} - -const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft }; - -const batchPhotosOf = (d: Draft): Source[] => - d.batch && d.batch.length > 0 ? d.batch : [d.source]; - -// The one writer. Owns the worker port, the single debounce timer, and the draft. -// Every transition to an approved branch persists; no explicit "save". -export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { - const drafts = opts.draft ?? defaultDraft; - const encodeInventory = opts.encodeInventory ?? encodeInventoryPng; - const encodeOverlay = opts.encodeOverlay ?? encodeOverlayPng; - const capture = writable({ k: 'batch', photos: [] }); - const busy = writable(false); - let state: Capture = { k: 'batch', photos: [] }; - let batchPhotos: Source[] = []; - let calibrationIndex: number | null = null; - let worker: Worker | null = null; - let jobId = 0; - let timer: ReturnType | null = null; - let passStarted = 0; - let busyTimer: ReturnType | null = null; - let pngUrl: string | null = null; - let resumed = false; - // #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise. - let cropFill = FILL_DEFAULT; - - const set = (next: Capture) => { - state = next; - capture.set(next); +/** Thin Svelte store wrapper over the pure machine; screens consume this. */ +export function createCaptureSession(deps: SessionDeps = defaultDeps): CaptureSession { + const store = writable(createSessionState()); + let live: SessionState = get(store); + const set = (next: SessionState) => { + live = next; + store.set(next); }; - - const teardownWorker = () => { - if (timer) { - clearTimeout(timer); - timer = null; - } - if (worker) { - try { - worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn); - worker.terminate(); - } catch { - /* worker unavailable */ - } - worker = null; - } - if (pngUrl) { - URL.revokeObjectURL(pngUrl); - pngUrl = null; - } - }; - - const makeWorker = (blob: Blob) => { - if (typeof Worker === 'undefined') return; - if (worker) worker.terminate(); - try { - worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' }); - worker.onmessage = (e: MessageEvent) => onWorkerOut(e.data); - worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn); - } catch { - worker = null; - } - }; - - const onWorkerOut = (msg: WorkerOut) => { - if (msg.id < jobId - 1) return; // stale - if (msg.kind === 'decoded') { - if (state.k === 'mask') { - set({ ...state, source: { ...state.source, widthPx: msg.w, heightPx: msg.h } }); - } - schedulePass(); - return; - } - busy.set(false); - if (busyTimer) { - clearTimeout(busyTimer); - busyTimer = null; - } - if (state.k !== 'mask') return; - if (msg.kind === 'failed') { - set({ ...state, failed: true }); - return; - } - const mask: Mask = { - params: { sheet: state.control.sheet, sensitivity: state.control.sensitivity }, - slabPx: msg.slabPx, - bitset: msg.bitset, - bbox: msg.bbox, - slabBounds: msg.slabBounds, - }; - set({ ...state, overlay: mask, failed: false }); - }; - - const runPass = () => { - if (state.k !== 'mask' || !worker) return; - passStarted = Date.now(); - busyTimer = setTimeout(() => busy.set(true), BUSY_MS); - worker.postMessage({ - id: ++jobId, - kind: 'mask', - sheet: state.control.sheet, - sensitivity: state.control.sensitivity, - } satisfies WorkerIn); - }; - - // The 2.0s debounce: re-armed on every control change; a sheet change - // recomputes immediately (spec allows), so it clears the pending pass. - const schedulePass = () => { - if (timer) clearTimeout(timer); - timer = setTimeout(runPass, DEBOUNCE_MS); - }; - - const draftFromState = (): Draft | null => { - const strip = (m: Mask) => ({ slabPx: m.slabPx, bbox: m.bbox, slabBounds: m.slabBounds }); - if (state.k === 'batch') { - if (state.photos.length === 0) return null; - return { - version: 1, - stage: 'batch', - batch: state.photos, - source: state.photos[0], - maskParams: DEFAULT_MASK_PARAMS, - mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX }, - lengthEighths: 0, - thicknessEighths: 0, - results: null, - inventoryPng: null, - }; - } - if (state.k === 'pickCalibration') { - return { - version: 1, - stage: 'pickCalibration', - batch: state.photos, - source: state.photos[0], - maskParams: DEFAULT_MASK_PARAMS, - mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX }, - lengthEighths: 0, - thicknessEighths: 0, - results: null, - inventoryPng: null, - }; - } - const calIdx = calibrationIndex ?? undefined; - if (state.k === 'axis') { - return { - version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source], - calibrationIndex: calIdx, - source: state.source, maskParams: state.approvedMask.params, - mask: strip(state.approvedMask), axisBbox: state.axis.bbox, - tapeLine: state.axis.tapeLine, - lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, - }; - } - if (state.k === 'measure') { - const { inventoryPng: _drop, overlayPng: _dropOverlay, ...rest } = state.results ?? ({} as Measures); - return { - version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], - calibrationIndex: calIdx, - source: state.source, maskParams: state.approvedMask.params, - mask: strip(state.approvedMask), axisBbox: state.axis.bbox, - tapeLine: state.axis.tapeLine, - lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, - results: state.results ? rest : null, - inventoryPng: state.results?.inventoryPng ?? null, - overlayPng: state.results?.overlayPng ?? null, - batchApply: state.batchApply, - }; - } - if (state.k === 'mask' && state.overlay) { - return { - version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], - calibrationIndex: calIdx, - source: state.source, maskParams: state.overlay.params, - mask: strip(state.overlay), - lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, - }; - } - // Mask before first overlay settles: still persist calibration choice + batch. - if (state.k === 'mask') { - return { - version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], - calibrationIndex: calIdx, - source: state.source, maskParams: state.control, - mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX }, - lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, - }; - } - return null; - }; - - const persist = async () => { - const d = draftFromState(); - if (d) await drafts.saveDraft(d); - else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft(); - }; - - // Re-derive the mask bitset from a stored photo + frozen params. The decoded - // dimensions are returned because batch Sources begin with dimensions unknown. - const rederiveMaskDetailed = ( - source: Source, - params: MaskParams, - stub: Omit, - ): Promise<{ mask: Mask; widthPx: number; heightPx: number }> => - new Promise((resolve, reject) => { - if (typeof Worker === 'undefined') { - reject(new Error('Worker unavailable')); - return; - } - const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' }); - let id = 0; - let widthPx = source.widthPx; - let heightPx = source.heightPx; - const fail = (reason: string) => { - w.terminate(); - reject(new Error(reason)); - }; - w.onmessage = (e: MessageEvent) => { - const msg = e.data; - if (msg.kind === 'decoded') { - widthPx = msg.w; - heightPx = msg.h; - w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn); - return; - } - if (msg.kind === 'failed') { - fail(msg.reason); - return; - } - if (msg.kind === 'ready') { - w.terminate(); - resolve({ - widthPx, - heightPx, - mask: { - params, - slabPx: msg.slabPx || stub.slabPx, - bitset: msg.bitset, - bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox, - slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds, - }, - }); - } - }; - w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn); - }); - - const rederiveMask = ( - source: Source, - params: MaskParams, - stub: Omit, - ): Promise => rederiveMaskDetailed(source, params, stub).then((r) => r.mask); - - // Legacy drafts (pre-axisBbox, with heading) carry no usable axis state; - // fall back to the mask's own bbox. - const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox; - const draftTapeLine = (d: Draft) => d.tapeLine ?? initialTapeLine(d.source.widthPx, d.source.heightPx); - - const restoreFromDraft = async (d: Draft) => { - batchPhotos = batchPhotosOf(d); - calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null; - if (d.stage === 'batch') { - teardownWorker(); - calibrationIndex = null; - set({ k: 'batch', photos: [...batchPhotos] }); - return; - } - if (d.stage === 'pickCalibration') { - teardownWorker(); - calibrationIndex = null; - set({ k: 'pickCalibration', photos: [...batchPhotos] }); - return; - } - const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob }; - if (d.stage === 'mask') { - teardownWorker(); - set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: false }); - makeWorker(source.blob); - return; - } - teardownWorker(); - try { - const mask = await rederiveMask(source, d.maskParams, d.mask); - if (d.stage === 'axis') { - const tapeLine = draftTapeLine(d); - set({ k: 'axis', source, approvedMask: mask, axis: { - bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine, - } }); - return; - } - if (d.inventoryPng) { - if (pngUrl) URL.revokeObjectURL(pngUrl); - pngUrl = URL.createObjectURL(d.inventoryPng); - } - set({ - k: 'measure', source, approvedMask: mask, - axis: { - bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end), - tapeLine: draftTapeLine(d), - }, - lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, - results: d.results - ? { - ...d.results, - inventoryPng: d.inventoryPng ?? new Blob(), - overlayPng: d.overlayPng ?? undefined, - } - : null, - batchApply: d.batchApply, - }); - } catch { - set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true }); - makeWorker(source.blob); - } - }; - - const enterMaskOnSource = (source: Source) => { - teardownWorker(); - set({ - k: 'mask', - source, - control: { ...DEFAULT_MASK_PARAMS }, - overlay: null, - failed: false, - }); - makeWorker(source.blob); - void persist(); - }; - return { - capture, - busy, - - async resumeProbe() { - if (resumed) return; - resumed = true; - const d = await drafts.loadDraft(); - if (d) set({ k: 'resume', draft: d }); - }, - - continueDraft() { - if (state.k !== 'resume') return; - void restoreFromDraft(state.draft); - }, - - async startFresh() { - if (state.k !== 'resume') return; - teardownWorker(); - batchPhotos = []; - calibrationIndex = null; - await drafts.clearDraft(); - set({ k: 'batch', photos: [] }); - }, - - async addToBatch(file: File) { - if (state.k !== 'batch') return; - const photo: Source = { widthPx: 0, heightPx: 0, blob: file }; - batchPhotos = [...state.photos, photo]; - set({ k: 'batch', photos: batchPhotos }); - await persist(); - }, - - async addPhoto(file: File) { - // M2: addPhoto is an alias for addToBatch (no single-photo-only shortcut). - if (state.k !== 'batch') { - teardownWorker(); - batchPhotos = []; - set({ k: 'batch', photos: [] }); - } - if (state.k !== 'batch') return; - const photo: Source = { widthPx: 0, heightPx: 0, blob: file }; - batchPhotos = [...state.photos, photo]; - set({ k: 'batch', photos: batchPhotos }); - await persist(); - }, - - removeFromBatch(index: number) { - if (state.k !== 'batch') return; - if (index < 0 || index >= state.photos.length) return; - batchPhotos = state.photos.filter((_, i) => i !== index); - set({ k: 'batch', photos: batchPhotos }); - void persist(); - }, - - continueBatch() { - if (state.k !== 'batch' || state.photos.length === 0) return; - batchPhotos = [...state.photos]; - calibrationIndex = null; - teardownWorker(); - set({ k: 'pickCalibration', photos: [...batchPhotos] }); - void persist(); - }, - - // #61: enter M1 Mask on the chosen calibration Source only — never loop batch. - pickCalibration(index: number) { - if (state.k !== 'pickCalibration') return; - if (index < 0 || index >= state.photos.length) return; - batchPhotos = [...state.photos]; - calibrationIndex = index; - enterMaskOnSource(batchPhotos[index]); - }, - - retakes() { - teardownWorker(); - calibrationIndex = null; - // Return to the batch (photos kept) so the user can add/replace before continuing. - set({ k: 'batch', photos: [...batchPhotos] }); - void persist(); - }, - - setSensitivity(n: number) { - if (state.k !== 'mask') return; - const s = Math.max(0, Math.min(100, Math.round(n))); - set({ ...state, control: { ...state.control, sensitivity: s } }); - schedulePass(); - }, - - setSheet(s: Sheet) { - if (state.k !== 'mask') return; - set({ ...state, control: { ...state.control, sheet: s } }); - runPass(); - }, - - // #61: Axis uses the same calibration source; draft.batch siblings stay untouched. - approveMask() { - if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return; - teardownWorker(); - const overlay = state.overlay; - const tapeLine = initialTapeLine(state.source.widthPx, state.source.heightPx); - set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { - bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine, - } }); - void persist(); - }, - - setTapeLine(tapeLine: TapeLine) { - if (state.k !== 'axis') return; - const start: [number, number] = [...tapeLine.start]; - const end: [number, number] = [...tapeLine.end]; - const bbox = rectAlongLine(state.axis.bbox, start, end); - if (!(bbox.longSidePx > 0)) return; - set({ ...state, axis: { bbox, tapeLine: { start, end } } }); - void persist(); - }, - - setCropFill(pctOrFraction: number) { - // #99: non-retro — only future encodes use this; stored PNGs are untouched. - cropFill = fillFromSetting(pctOrFraction); - }, - - approveAxis() { - if (state.k !== 'axis') return; - set({ - k: 'measure', source: state.source, approvedMask: state.approvedMask, - axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine }, - lengthEighths: 0, thicknessEighths: 0, results: null, - }); - void persist(); - }, - - setLength(e: Eighths) { - if (state.k !== 'measure') return; - set({ ...state, lengthEighths: Math.max(0, Math.round(e)) }); - void persist(); - }, - - setThickness(e: Eighths) { - if (state.k !== 'measure') return; - set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) }); - void persist(); - }, - - // #62: Calculate the calibration photo, then apply its frozen mask params - // to siblings. This stays in Measure; siblings never enter Mask controls. - async calculate() { - if (state.k !== 'measure') return; - const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state; - if (lengthEighths === 0 || thicknessEighths === 0) return; - const scale = calibrationScale(axis.tapeLine.start, axis.tapeLine.end, eighthsToIn(lengthEighths)); - if (!(scale > 0) || !Number.isFinite(scale)) return; - const bitmap = await decodeOrientedBitmap(source.blob); - const base = measure( - approvedMask.bitset, source.widthPx, source.heightPx, - axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, scale, - ); - let pngBlob: Blob; - let belowFloor = false; - try { - const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill }); - pngBlob = r.blob; - belowFloor = r.sourceBelowFloor; - } catch { - // CAPTURE.md: inventory is image/png even when encode is unavailable. - pngBlob = new Blob([], { type: 'image/png' }); - } - bitmap.close(); - if (pngUrl) URL.revokeObjectURL(pngUrl); - pngUrl = URL.createObjectURL(pngBlob); - - // #63: second copy of the calibration inventory PNG with dimension overlays. - // Keep clean inventoryPng untouched; never stash overlay into batchApply[cal]. - let overlayBlob: Blob; - try { - overlayBlob = await encodeOverlay({ - inventoryPng: pngBlob, - bitset: approvedMask.bitset, - widthPx: source.widthPx, - heightPx: source.heightPx, - bbox: axis.bbox, - scale: base.scale, - lengthIn: eighthsToIn(lengthEighths), - lengthLabel: fmtEighths(lengthEighths), - slabBounds: approvedMask.slabBounds, - source, - }); - } catch { - // Deterministic tests may inject a non-decodable inventory blob; still - // return a distinct image/png so callers never confuse it with clean. - overlayBlob = new Blob(['overlay'], { type: 'image/png' }); - } - const results: Measures = { - ...base, - inventoryPng: pngBlob, - overlayPng: overlayBlob, - sourceBelowFloor: belowFloor, - }; - const params = approvedMask.params; - const calIndex = calibrationIndex != null && calibrationIndex >= 0 - ? calibrationIndex - : 0; - const batch = batchPhotos.length ? batchPhotos : [source]; - const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : { - status: 'error', - message: 'Batch photo was not processed', - })); - - // Process serially so each worker owns exactly one sibling photo and a - // failing decode/mask/encode cannot prevent later siblings from running. - for (let index = 0; index < batch.length; index++) { - if (index === calIndex) continue; - const photo = batch[index]; - try { - const derived = await rederiveMaskDetailed(photo, params, { - slabPx: 0, - bbox: EMPTY_BBOX, - slabBounds: EMPTY_ABOX, - }); - if (derived.mask.slabPx === 0) { - throw new Error('empty mask: no slab pixels detected'); - } - const siblingSource: Source = { - ...photo, - widthPx: derived.widthPx, - heightPx: derived.heightPx, - }; - const siblingBitmap = await decodeOrientedBitmap(photo.blob); - let encoded: { blob: Blob; sourceBelowFloor: boolean }; - try { - encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill }); - } finally { - siblingBitmap.close(); - } - if (encoded.sourceBelowFloor) { - throw new Error('PNG size floor: shorter side is below 800 px'); - } - batchApply[index] = { status: 'ok', inventoryPng: encoded.blob }; - } catch (error) { - const message = error instanceof Error ? error.message : 'unable to process photo'; - batchApply[index] = { status: 'error', message }; - } - } - - set({ ...state, results, batchApply }); - void persist(); - }, - - back() { - if (state.k === 'pickCalibration') { - calibrationIndex = null; - set({ k: 'batch', photos: [...batchPhotos] }); - void persist(); - } else if (state.k === 'mask') { - teardownWorker(); - calibrationIndex = null; - set({ k: 'pickCalibration', photos: [...batchPhotos] }); - void persist(); - } else if (state.k === 'axis') { - // back to mask keeps the last approved mask as the live overlay - const m = state.approvedMask; - set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false }); - } else if (state.k === 'measure') { - set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { - bbox: state.axis.bbox, tapeLine: state.axis.tapeLine, - } }); - } - }, - - getInventoryUrl() { - return pngUrl; - }, - - currentMeasure() { - return state.k === 'measure' ? state : null; - }, - - dispose() { - teardownWorker(); + state: store, + addToBatch: (file) => set(addToBatch(live, file)), + removeFromBatch: (index) => set(removeFromBatch(live, index)), + continueBatch: () => set(continueBatch(live)), + pickCalibration: async (index) => set(await pickCalibration(live, index, deps)), + setSheet: (sheet) => set(setSheet(live, sheet)), + setSensitivity: (n) => set(setSensitivity(live, n)), + approveMask: async () => set(await approveMask(live, deps)), + approveMaskAxis: () => set(approveMaskAxis(live)), + setTapeLine: (tapeLine) => set(setTapeLine(live, tapeLine)), + approveAxis: () => set(approveAxis(live)), + setLength: (eighths) => set(setLength(live, eighths)), + setThickness: (eighths) => set(setThickness(live, eighths)), + setCropFill: (pctOrFraction) => set({ ...live, cropFill: fillFromSetting(pctOrFraction) }), + calculate: async () => { + // Snapshot up front: a concurrent set() during the await must not feed a + // newer state into the in-flight transition. + const from = live; + const { state, lineup } = await calculate(from, deps); + set(state); + return lineup; }, + lineup: () => buildPhotosReadyLineup(photosReadyInput(live)), + back: () => set(back(live)), + dispose: () => store.set(createSessionState()), }; } - -// Display helper: format eighths as inches with fractions. -export function fmtEighths(e: Eighths): string { - const whole = Math.floor(eighthsToIn(e)); - const rem = e - whole * 8; - if (rem === 0) return `${whole}"`; - const frac: Record = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8' }; - const part = frac[rem] ?? `${rem}/8`; - return whole > 0 ? `${whole} ${part}"` : `${part}"`; -} diff --git a/frontend/src/components.test.ts b/frontend/src/components.test.ts new file mode 100644 index 0000000..8356d9e --- /dev/null +++ b/frontend/src/components.test.ts @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { compile, preprocess } from 'svelte/compiler'; +import sp from 'svelte-preprocess'; +import { draft } from './lib/types'; +import { computeSqft, computeBdft, sampleWidths } from './lib/measure'; +import { buildTitle as buildCopyTitle } from './review/copy'; + +const here = dirname(fileURLToPath(import.meta.url)); +const tsPreprocessor = { script: sp.typescript().script }; + +async function compileComponent(rel: string) { + const code = readFileSync(join(here, rel), 'utf8'); + const processed = await preprocess(code, [tsPreprocessor], { filename: rel }); + return compile(processed.code, { filename: rel, generate: 'client' }); +} + +// Each fresh component must compile clean (no warnings) — this catches Svelte +// template errors that tsc cannot see (e.g. binding to a reactive statement). +for (const rel of [ + './App.svelte', + './capture/PhotoImport.svelte', + './measure/MeasureInputs.svelte', + './review/ReviewForm.svelte', + './publish/PublishPanel.svelte', + './settings/SettingsPanel.svelte' +]) { + test(`compiles ${rel}`, async () => { + const { warnings } = await compileComponent(rel); + assert.deepStrictEqual(warnings, [], warnings.map((w) => w.code + ': ' + w.message).join('; ')); + }); +} + +test('auto title derives from the primary wood-category leaf (live edge vs dimensional)', () => { + const facts = { lengthIn: 48, widthAvgIn: 18, thicknessIn: 2, bdft: 12 }; + assert.strictEqual( + buildCopyTitle(facts, 'Figured', 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }), + 'Figured Black Walnut Live Edge Slab', + ); + assert.strictEqual( + buildCopyTitle(facts, 'Plain', 'Red Oak', { slug: 'dimensional-wood', name: 'Dimensional wood' }), + 'Plain Red Oak Dimensional', + ); +}); + +test('a fresh draft reaches a ready, publishable state deterministically', () => { + // The full local workflow: capture -> measure -> review -> ready. + const slab = draft({ + lengthIn: 96, + thicknessIn: 1.5, + sqft: computeSqft(921600, 100), + bdft: computeBdft(0.64, 1.5), + widthAvgIn: sampleWidths(96, [18, 20, 22]).avg, + sku: 'SLAB-DEMO-001', + speciesId: 101, + woodCategoryIds: [201], + edgeTypeTermId: 301, + figureTermIds: [402], + gradeTermId: 501, + thicknessTermId: 601, + moistureTermId: 701, + price: 11.52, + title: buildCopyTitle( + { lengthIn: 96, widthAvgIn: 20, thicknessIn: 1.5, bdft: 0.96 }, + 'Figured', + 'Black Walnut', + { slug: 'live-edge-wood', name: 'Live edge wood' }, + ) + }); + // Same readiness gate ReviewForm.checkReady() enforces. + const ready = + !!slab.sku && slab.sku.length >= 3 && slab.speciesId != null && slab.woodCategoryIds.length > 0 && + slab.edgeTypeTermId != null && slab.figureTermIds.length > 0 && slab.gradeTermId != null && + slab.thicknessTermId != null && slab.moistureTermId != null && slab.price > 0 && slab.title.length > 0; + assert.ok(ready); + assert.strictEqual(slab.bdft, 0.96); + assert.strictEqual(slab.price, Math.round(slab.bdft * 12 * 100) / 100); +}); diff --git a/frontend/src/create/flow.ts b/frontend/src/create/flow.ts deleted file mode 100644 index 5765129..0000000 --- a/frontend/src/create/flow.ts +++ /dev/null @@ -1,51 +0,0 @@ -// 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(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' }); -} diff --git a/frontend/src/create/inference/GenerateText.svelte b/frontend/src/create/inference/GenerateText.svelte deleted file mode 100644 index 66baa50..0000000 --- a/frontend/src/create/inference/GenerateText.svelte +++ /dev/null @@ -1,103 +0,0 @@ - - -
-

Generate text

-

- Content generation: {contentEnabled ? 'on' : 'off'} · endpoint: {reachable ? 'reachable' : 'unreachable'} -

- {#if !contentEnabled || !reachable} -

The Generate text control is disabled: it appears only when content generation is - enabled and the inference endpoint is reachable.

- {:else} -
- - -
-
- - -
- {#if error}

{error}

{/if} - {#if description}

{description}

{/if} - {#if call1Suggestions} -
{JSON.stringify(call1Suggestions, null, 2)}
- {/if} - {/if} - -
- - diff --git a/frontend/src/create/photos/PhotoAttach.svelte b/frontend/src/create/photos/PhotoAttach.svelte deleted file mode 100644 index 2d0289c..0000000 --- a/frontend/src/create/photos/PhotoAttach.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - -
-

Photo Attach

-
- - -
-
- - (originalFile = e.target.files?.[0] ?? null)} accept="image/*" /> -
-
- - (processedFile = e.target.files?.[0] ?? null)} accept="image/png" /> -
- - - {#if message}

{message}

{/if} - - {#if photos.length} -
- {#each photos as p (p.id)} -
- photo {p.seq} -
#{p.seq}{p.is_calibration ? ' · cal' : ''}
-
- {/each} -
- {:else} -

No photos attached.

- {/if} -
- - diff --git a/frontend/src/create/publish/Publish.svelte b/frontend/src/create/publish/Publish.svelte deleted file mode 100644 index e0ebde7..0000000 --- a/frontend/src/create/publish/Publish.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - -{#if loading} -

Publish to WooCommerce

Loading…

-{:else if loadError} -

Publish to WooCommerce

- -
-{:else} -
-

Publish to WooCommerce

-

SKU {slab?.sku || '—'} · {slab?.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab?.price != null ? `$${slab.price.toFixed(2)}` : '—'}

- - {#if st.phase === 'idle' || st.phase === 'working'} - - - {/if} - - {#if st.phase === 'success'} -
Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}
-

Photos purged · device draft cleared.

- - {:else if st.phase === 'failure' && st.fail} - {#if st.fail.duplicateSku} -
-
✕
-

SKU already exists

-

“{slab?.sku}” is already in the store.

-
- - - {:else if st.fail.code === 'unpublishable'} -
-
✕
-

Not ready to publish

-

Some fields are missing. Go back and finish Review.

-
- - {:else} -
-
{st.fail.partialMedia ? '⚠' : '↻'}
-

{st.fail.message}

- {#if st.fail.recoverable}recoverable{/if} - {#if st.fail.partialMedia}orphan media — remove manually{/if} - {#if !st.fail.recoverable}check Settings{/if} -
- - {/if} - {/if} -
-{/if} - - diff --git a/frontend/src/create/review/MultiSelect.svelte b/frontend/src/create/review/MultiSelect.svelte deleted file mode 100644 index 88b07a7..0000000 --- a/frontend/src/create/review/MultiSelect.svelte +++ /dev/null @@ -1,196 +0,0 @@ - - -
- - - {#if open} -
-
- - -
- - {#if grouped} -
    - {#each filteredGroups as g (g.parent.id)} - - {#each g.leaves as o (o.id)} -
  • toggle(o.id)} - on:keydown={(e) => onRowKey(e, o.id)} - > - - {o.name}{#if o.ai}AI{/if} -
  • - {/each} - {:else} -
  • No matches
  • - {/each} -
- {:else} -
    - {#each filteredFlat as o (o.id)} -
  • toggle(o.id)} - on:keydown={(e) => onRowKey(e, o.id)} - > - - {o.name}{#if o.ai}AI{/if} -
  • - {:else} -
  • No matches
  • - {/each} -
- {/if} - -
- {allOpts.length} synced{filter.trim() ? ` · ${filteredFlat.length} shown` : ''} - -
-
- {/if} -
- - diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte deleted file mode 100644 index c4a2f9f..0000000 --- a/frontend/src/create/review/Review.svelte +++ /dev/null @@ -1,997 +0,0 @@ - - -{#if loading} -
-

Review to Ready

-

Loading slab…

-
-{:else if loadError} -
-

Review to Ready

- - -
-{:else} -
-

Review to Ready

- -
-
Length
{fmt1(capture?.lengthIn ?? slab?.length_in ?? 0)}″
-
Thickness
{fmt1(capture?.thicknessIn ?? slab?.thickness_in ?? 0)}″
-
Sqft / Bdft
{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}
-
- - - {#if photosLoaded && photos.length} -
-

Tap a photo to keep or remove it · drag to reorder · leftmost photo = primary

-
- {#each photos as p, i (p.id)} -
toggleKeep(p.id)} - on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleKeep(p.id); } }} - on:dragstart={(e) => onDragStart(e, i)} - on:dragover={onDragOver} - on:drop={(e) => onDrop(e, i)} - > - {roleLabel(p.role)} - {#if p.id === primaryId} - PRIMARY - {/if} - {#if p.required} - REQUIRED - {/if} - - - -
- {/each} -
- {#if lineupMissing.length} - - {/if} - {#if persistError}{/if} -
- {/if} - - {#if settings?.inference_enabled} -
- - {#if aiNote}

{aiNote}

{/if} -
- {/if} - - -
- - -
- - -
- - - {#if primaryOpts.length} -
-
Primary wood category drives title
- {#each primaryOpts as o (o.id)} - - {/each} -

- Primary selects the title & short-description pattern (METADATA-85). - Secondary categories feed the description. Default = first selected. -

-
- {/if} -
- - -
- - -
- - -
- - (figureIds = toggleMulti(figureIds, id))} - on:commit={commitReview} - /> -
- - -
- - -
- - -
- - (figTagIds = toggleMulti(figTagIds, id))} - on:commit={commitReview} - /> -
- - -
- - (featTagIds = toggleMulti(featTagIds, id))} - on:commit={commitReview} - /> -
- - -
- - - -
- - -
- -
- - -
-
-
- -
- - -
-
-
- - - {#if canCreateDesc} -
- -
- {/if} - {#if genError}{/if} - {#if createError}{/if} -
- - -
- - -
-
- - - {#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)} -

SKU must be 3–24 alphanumerics or hyphens.

- {/if} -
- - - {#if reviewBusy} -
Checking readiness…
- {:else if review} -
- {ready() && lineupValid - ? 'Ready to publish' - : `Not ready — ${[ - ...review.missing, - ...lineupMissing.map(roleLabel), - ].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`} -
- {#if !(ready() && lineupValid)} -

Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}

- {/if} - {/if} - {#if reviewError}{/if} - -
- - -
-
-{/if} - - diff --git a/frontend/src/create/review/thicknessBand.test.ts b/frontend/src/create/review/thicknessBand.test.ts deleted file mode 100644 index 711e2bd..0000000 --- a/frontend/src/create/review/thicknessBand.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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); -}); diff --git a/frontend/src/create/review/thicknessBand.ts b/frontend/src/create/review/thicknessBand.ts deleted file mode 100644 index 1168988..0000000 --- a/frontend/src/create/review/thicknessBand.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 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 ''; -} diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte deleted file mode 100644 index a0fea13..0000000 --- a/frontend/src/create/settings/Settings.svelte +++ /dev/null @@ -1,450 +0,0 @@ - - -
-

Settings

- {#if loading} -

Loading...

- {:else if settings} -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- -
- -
- -
- - -
- -
- - -
- -
- - -
- -
- - - {form.crop_fill}% -
- - - {#if message}

{message}

{/if} - -
-

WooCommerce

- - {#if wooTest} -

{wooTest.detail}

- {/if} -
- -
-

Species pricing

-

One flat $ / bdft per synced species leaf (SETTINGS-35). Leaves come from taxonomy under Species — never a hardcoded list.

- {#if ratesLoading} -

Loading rates…

- {:else if rateRows.length === 0} -

- {ratesMessage || 'Run Test WooCommerce connection first to sync species leaves.'} -

- {:else} -
-
- Species - $ / bdft -
- {#each rateRows as row (row.species_id)} -
- - -
- {/each} -
- - {/if} - {#if ratesMessage && rateRows.length > 0} -

{ratesMessage}

- {/if} -
- -
-

Inference

- - {#if inferenceTest} -

{inferenceTest.detail}

- {/if} -
- {/if} -
- - diff --git a/frontend/src/create/settings/speciesLeaves.test.ts b/frontend/src/create/settings/speciesLeaves.test.ts deleted file mode 100644 index e7a5a27..0000000 --- a/frontend/src/create/settings/speciesLeaves.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves'; - -/** Fixture ids are local to this test — never production store ids. */ -const tree: TaxonomyCategory[] = [ - { id: 10, name: 'Species', slug: 'species', parent_id: null }, - { id: 11, name: 'Cedar', slug: 'cedar', parent_id: 10 }, - { id: 12, name: 'Maple', slug: 'maple', parent_id: 10 }, - { id: 20, name: 'Wood category', slug: 'wcat', parent_id: null }, - { id: 21, name: 'Live edge wood', slug: 'liveedge', parent_id: 20 }, - { id: 99, name: 'Chairs', slug: 'chairs', parent_id: null }, -]; - -describe('speciesLeavesFromTaxonomy', () => { - it('returns only children of the Species grouper, sorted by name', () => { - const leaves = speciesLeavesFromTaxonomy(tree); - assert.deepEqual( - leaves.map((l) => l.slug), - ['cedar', 'maple'] - ); - assert.equal(leaves[0].name, 'Cedar'); - assert.equal(leaves[1].name, 'Maple'); - }); - - it('returns empty when Species grouper is missing (no dump of all categories)', () => { - const flat = tree.filter((c) => c.slug !== 'species'); - assert.deepEqual(speciesLeavesFromTaxonomy(flat), []); - }); - - it('returns empty when Species has no children', () => { - assert.deepEqual( - speciesLeavesFromTaxonomy([{ id: 10, name: 'Species', slug: 'species', parent_id: null }]), - [] - ); - }); - - it('resolves Species grouper by name when slug differs', () => { - const alt: TaxonomyCategory[] = [ - { id: 10, name: 'Species', slug: 'product-species', parent_id: null }, - { id: 11, name: 'Alder', slug: 'alder', parent_id: 10 }, - ]; - assert.deepEqual( - speciesLeavesFromTaxonomy(alt).map((l) => l.slug), - ['alder'] - ); - }); -}); diff --git a/frontend/src/create/settings/speciesLeaves.ts b/frontend/src/create/settings/speciesLeaves.ts deleted file mode 100644 index ddee24a..0000000 --- a/frontend/src/create/settings/speciesLeaves.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Species leaf selection for SETTINGS-35 rate rows. - -Resolves the Species grouper by slug `species` (name fallback "Species"), then -returns its children. Never hardcodes store category ids (QUALITY-105 / METADATA-5). -*/ - -export interface TaxonomyCategory { - id: number; - name: string; - slug: string; - parent_id: number | null; -} - -export function speciesLeavesFromTaxonomy(categories: TaxonomyCategory[]): TaxonomyCategory[] { - if (!categories.length) return []; - - const grouper = - categories.find((c) => (c.slug || '').toLowerCase() === 'species') || - categories.find((c) => (c.name || '').trim().toLowerCase() === 'species'); - - if (!grouper) return []; - - return categories - .filter((c) => c.parent_id === grouper.id && c.id !== grouper.id) - .slice() - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); -} diff --git a/frontend/src/create/slabs/SlabList.svelte b/frontend/src/create/slabs/SlabList.svelte deleted file mode 100644 index 444edda..0000000 --- a/frontend/src/create/slabs/SlabList.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -
-
-

Slabs

- -
- {#if loading} -

Loading…

- {:else if error} -

{error}

- {:else if slabs.length === 0} -

No slabs yet.

- {:else} - - - - - - - - - - - - - - {#each slabs as s (s.id)} - - - - - - - - - - {/each} - -
SKUSize (L×T)Avg WsqftPriceStatusUpdated
{s.sku}{s.length_in}″ × {s.thickness_in}″{s.width_avg_in}″{s.sqft}${s.price.toFixed(2)}{s.status}{s.updated_at?.replace('T', ' ').slice(0, 16)}
- {/if} -
- - diff --git a/frontend/src/draft/idb.ts b/frontend/src/draft/idb.ts deleted file mode 100644 index 73f822d..0000000 --- a/frontend/src/draft/idb.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { get, set, del } from 'idb-keyval'; -import type { Draft } from '../capture/capture.types'; - -const KEY = 'slabuploader-draft'; - -export async function loadDraft(): Promise { - try { - const d = await get(KEY); - return d && d.version === 1 ? d : null; - } catch { - return null; - } -} - -export async function saveDraft(d: Draft): Promise { - await set(KEY, d); -} - -export async function clearDraft(): Promise { - try { - await del(KEY); - } catch { - // idb unavailable: draft persistence is best-effort, app state is not - } -} diff --git a/frontend/src/geometry/rect.test.ts b/frontend/src/geometry/rect.test.ts index 386e4bd..b8ce734 100644 --- a/frontend/src/geometry/rect.test.ts +++ b/frontend/src/geometry/rect.test.ts @@ -1,23 +1,59 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { minAreaRect, orientedRect, rectAlongLine } from './rect.ts'; +import { + slabBounds, + convexHull, + minAreaRect, + orientedRect, + rectAlongLine, + initialTapeLine, + EMPTY_BBOX, + EMPTY_ABOX, +} from './rect'; // 100×20 horizontal slab const W = 100, H = 20; const bitset = new Uint8Array(W * H); for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1; +test('slabBounds: full slab → full box; empty slab → zero box', () => { + assert.deepEqual(slabBounds(bitset, W, H), { x: 0, y: 0, w: W, h: H }); + const empty = new Uint8Array(W * H); + assert.deepEqual(slabBounds(empty, W, H), EMPTY_ABOX); +}); + +test('convexHull: collinear points collapse; square hull is 4 corners', () => { + const line = convexHull([[0, 0], [1, 0], [2, 0]]); + assert.equal(line.length, 2, 'collinear points reduce to the 2 endpoints'); + const square = convexHull([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]]); + assert.equal(square.length, 4, 'interior point dropped'); +}); + +test('minAreaRect: axis-aligned slab → long-side extent spans every slab pixel', () => { + const box = minAreaRect(bitset, W, H); + assert.ok(box, 'minAreaRect returned a box'); + assert.ok(box!.longSidePx >= W - 1 && box!.longSidePx <= W); + assert.ok(box!.angleDeg >= 0 && box!.angleDeg < 180); + assert.ok(box!.angleDeg === 0 || box!.angleDeg === 90); +}); + +test('minAreaRect: fewer than 2 slab pixels → null', () => { + const single = new Uint8Array(W * H); + single[5 * W + 5] = 1; + assert.equal(minAreaRect(single, W, H), null); + assert.equal(minAreaRect(new Uint8Array(W * H), W, H), null); +}); + test('orientedRect changes angleDeg and preserves longSidePx', () => { - const prev = minAreaRect(bitset, W, H); - assert.ok(prev, 'minAreaRect returned a box'); + const prev = minAreaRect(bitset, W, H)!; const rotated = orientedRect(prev, 45); assert.ok(Math.abs(rotated.angleDeg - 45) < 1e-9); assert.equal(rotated.longSidePx, prev.longSidePx); // The long side (angleDeg 0 → +x) must be preserved by the rotation. - const longOf = (c) => Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]); + const longOf = (c: [number, number][]) => Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]); assert.ok(Math.abs(longOf(rotated.corners) - longOf(prev.corners)) < 1e-9); assert.ok(Math.abs(longOf(prev.corners) - prev.longSidePx) < 1e-9); - // angle normalization to [0, 180) + // Angle normalization to [0, 180) assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180); }); @@ -27,9 +63,32 @@ test('rectAlongLine sets the approved axis from the owner-positioned tape endpoi const end: [number, number] = [80, 70]; const calibrated = rectAlongLine(previous, start, end); const center: [number, number] = [50, 50]; - assert.ok(Math.abs(calibrated.angleDeg - (Math.atan2(40, 60) * 180) / Math.PI) < 1e-9); + assert.ok(Math.abs(calibrated.angleDeg - ((Math.atan2(40, 60) * 180) / Math.PI + 180) % 180) < 1e-9); assert.ok(Math.abs(calibrated.longSidePx - Math.hypot(60, 40)) < 1e-9); - assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9); - assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9); + assert.ok( + Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9, + ); + assert.ok( + Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9, + ); assert.notEqual(calibrated.angleDeg, previous.angleDeg, 'direction comes from tape endpoints, not mask rectangle'); }); + +test('rectAlongLine: degenerate endpoints return the previous box unchanged', () => { + const prev = minAreaRect(bitset, W, H)!; + assert.equal(rectAlongLine(prev, [5, 5], [5, 5]), prev); + assert.deepEqual(rectAlongLine(prev, [NaN, 5], [5, 5]), prev); +}); + +test('initialTapeLine: landscape photo → horizontal guide; portrait → vertical guide', () => { + const wide = initialTapeLine(1000, 500); + assert.deepEqual(wide, { start: [100, 250], end: [900, 250] }); + const tall = initialTapeLine(500, 1000); + assert.deepEqual(tall, { start: [250, 100], end: [250, 900] }); +}); + +test('EMPTY_BBOX is a valid zero box', () => { + assert.equal(EMPTY_BBOX.longSidePx, 0); + assert.equal(EMPTY_BBOX.angleDeg, 0); + assert.equal(EMPTY_BBOX.corners.length, 4); +}); diff --git a/frontend/src/geometry/rect.ts b/frontend/src/geometry/rect.ts index 6449834..1886d8a 100644 --- a/frontend/src/geometry/rect.ts +++ b/frontend/src/geometry/rect.ts @@ -1,5 +1,48 @@ -import type { ABox, BBox, TapeLine } from '../capture/capture.types'; +/** + * Rect / axis geometry for the M1 capture (CAPTURE-50..70). + * All coordinates are source-photo pixels. The minimum-area bounding rectangle + * (rotating calipers over the convex hull) is the mask footprint; the approved + * axis is rebuilt from the owner-positioned tape endpoints. + */ +/** Axis-aligned box, source px. */ +export interface ABox { + x: number; + y: number; + w: number; + h: number; +} + +/** + * Minimum-area bounding rectangle of the mask, source px. + * `corners[3] → corners[0]` is the long side (the axis arrow direction). + */ +export interface BBox { + corners: [number, number][]; // 4 corners + longSidePx: number; // geometric extent only; scale uses mask span along the axis + angleDeg: number; // long-side angle, degrees, [0, 180) +} + +/** The owner-positioned start/end points of the tape in oriented source pixels. */ +export interface TapeLine { + start: [number, number]; + end: [number, number]; +} + +export const EMPTY_BBOX: BBox = { + corners: [ + [0, 0], + [0, 0], + [0, 0], + [0, 0], + ], + longSidePx: 0, + angleDeg: 0, +}; + +export const EMPTY_ABOX: ABox = { x: 0, y: 0, w: 0, h: 0 }; + +/** Axis-aligned extent of the slab pixels (used for the PNG crop). */ export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox { let minX = w, minY = h, maxX = -1, maxY = -1; for (let y = 0; y < h; y++) { @@ -74,17 +117,26 @@ export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | nu const rh = maxV - minV; const area = rw * rh; if (area < best.area) { - best = { area, cx: hull[i][0] + (minU + maxU) / 2 * ux + (minV + maxV) / 2 * vx, cy: hull[i][1] + (minU + maxU) / 2 * uy + (minV + maxV) / 2 * vy, ux, uy, hw: rw / 2, hh: rh / 2 }; + best = { + area, + cx: hull[i][0] + ((minU + maxU) / 2) * ux + ((minV + maxV) / 2) * vx, + cy: hull[i][1] + ((minU + maxU) / 2) * uy + ((minV + maxV) / 2) * vy, + ux, + uy, + hw: rw / 2, + hh: rh / 2, + }; } } - // long-side direction: the edge with the larger extent - const lu = best.hw >= best.hh ? best.ux : -best.uy; - const lv = best.hw >= best.hh ? best.uy : best.ux; - const su = best.hw >= best.hh ? -best.uy : best.ux; - const sv = best.hw >= best.hh ? -best.ux : -best.uy; - const halfL = best.hw >= best.hh ? best.hw : best.hh; - const halfS = best.hw >= best.hh ? best.hh : best.hw; + // Long-side direction: the edge with the larger extent + const useU = best.hw >= best.hh; + const lu = useU ? best.ux : -best.uy; + const lv = useU ? best.uy : best.ux; + const su = useU ? -best.uy : best.ux; + const sv = useU ? -best.ux : -best.uy; + const halfL = useU ? best.hw : best.hh; + const halfS = useU ? best.hh : best.hw; const corners: [number, number][] = []; for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) { corners.push([best.cx + a * halfL * lu + b * halfS * su, best.cy + a * halfL * lv + b * halfS * sv]); @@ -93,14 +145,15 @@ export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | nu return { corners, longSidePx: halfL * 2, angleDeg }; } -// Rotate the long side to a new angle, keeping center and half-extents. -// Corners are rebuilt in minAreaRect order so corners[3] → corners[0] stays the -// long side (the axis arrow direction). +/** + * Rotate the long side to a new angle, keeping center and half-extents. + * Corners are rebuilt in minAreaRect order so corners[3] → corners[0] stays the + * long side (the axis arrow direction). + */ export function orientedRect(prev: BBox, angleDeg: number): BBox { const a = ((angleDeg % 180) + 180) % 180; const halfL = prev.longSidePx / 2; const halfS = Math.hypot(prev.corners[0][0] - prev.corners[1][0], prev.corners[0][1] - prev.corners[1][1]) / 2; - // corners[i] is a corner point: average of all four = center const cx = (prev.corners[0][0] + prev.corners[1][0] + prev.corners[2][0] + prev.corners[3][0]) / 4; const cy = (prev.corners[0][1] + prev.corners[1][1] + prev.corners[2][1] + prev.corners[3][1]) / 4; const r = (a * Math.PI) / 180; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..34c5ede --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,47 @@ +// API client for /api/v1 — typed, error envelope aware. +// +// Every non-2xx response is the envelope {error: {code, message, detail?}}; +// `apiFetch` throws `ApiError` carrying the code + detail so screens can +// classify recoverable vs unrecoverable (e.g. PUBLISH-36/37/38). + +export interface ApiErrorBody { + code: string; + message: string; + detail?: unknown; +} + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly detail?: unknown; + + constructor(status: number, body: ApiErrorBody) { + super(body.message); + this.name = 'ApiError'; + this.status = status; + this.code = body.code; + this.detail = body.detail; + } +} + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api/v1${path}`, { + ...init, + headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, + }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: ApiErrorBody } | null; + const err = body?.error; + throw new ApiError(res.status, err ?? { code: String(res.status), message: `HTTP ${res.status}` }); + } + return res.json() as Promise; +} + +/** PUBLISH-36/37/38: retrying an auth/forbidden block will not help. */ +const UNRECOVERABLE_CODES = new Set(['woo_unconfigured', 'woo_auth', 'woo_forbidden', 'duplicate_sku', 'unpublishable', 'stale_field']); + +export function isRecoverable(err: unknown): boolean { + if (err instanceof ApiError) return !UNRECOVERABLE_CODES.has(err.code); + // Network-level failure (fetch threw): the store may come back — retryable. + return true; +} diff --git a/frontend/src/lib/measure.test.ts b/frontend/src/lib/measure.test.ts new file mode 100644 index 0000000..54ce742 --- /dev/null +++ b/frontend/src/lib/measure.test.ts @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { computeBdft, computeSqft, sampleWidths, roundHalfUp, lengthToDisplay } from './measure'; + +test('CAPTURE-90: 0.64sqft at 1.5in is 0.96 bdft (no /12)', () => { + assert.strictEqual(computeBdft(0.64, 1.5), 0.96); +}); + +test('CAPTURE-85: sqft from mask pixels and scale', () => { + // 0.64 sqft * 144 in^2/sqft * (100 px/in)^2 = 921600 px + assert.strictEqual(computeSqft(921600, 100), 0.64); +}); + +test('bdft is monotonic in thickness and sqft', () => { + assert.ok(computeBdft(1, 2) > computeBdft(1, 1)); + assert.ok(computeBdft(2, 1) > computeBdft(1, 1)); +}); + +test('CAPTURE-80: widths report min/max/avg of interior samples', () => { + const s = sampleWidths(48, [12, 14, 16, 18]); + assert.deepStrictEqual(s, { min: 12, max: 18, avg: 15 }); +}); + +test('CAPTURE-80: length <= 12 yields zero widths', () => { + assert.deepStrictEqual(sampleWidths(10, [10, 20, 30]), { min: 0, max: 0, avg: 0 }); + assert.deepStrictEqual(sampleWidths(12, [10, 20]), { min: 0, max: 0, avg: 0 }); +}); + +test('CAPTURE-80: empty samples yield zero widths', () => { + assert.deepStrictEqual(sampleWidths(48, []), { min: 0, max: 0, avg: 0 }); +}); + +test('roundHalfUp rounds .5 away from zero to 2dp', () => { + assert.strictEqual(roundHalfUp(1.225, 2), 1.23); + assert.strictEqual(roundHalfUp(1.224, 2), 1.22); + assert.strictEqual(roundHalfUp(1.25, 2), 1.25); + assert.strictEqual(roundHalfUp(0.005, 2), 0.01); +}); + +test('lengthToDisplay converts inches to feet/inches', () => { + assert.strictEqual(lengthToDisplay(96), '8ft 0in'); + assert.strictEqual(lengthToDisplay(114), '9ft 6in'); + assert.strictEqual(lengthToDisplay(10.5), '0ft 10.5in'); +}); + +test('zero and negative inputs yield zero, not NaN', () => { + assert.strictEqual(computeSqft(0, 100), 0); + assert.strictEqual(computeSqft(-5, 100), 0); + assert.strictEqual(computeBdft(0, 1.5), 0); + assert.strictEqual(computeBdft(0.64, 0), 0); +}); diff --git a/frontend/src/lib/measure.ts b/frontend/src/lib/measure.ts new file mode 100644 index 0000000..dd28661 --- /dev/null +++ b/frontend/src/lib/measure.ts @@ -0,0 +1,52 @@ +// Pure deterministic math for slab measurements (CAPTURE-75,80,85,90). +// Side-effect free, no DOM, testable in isolation. + +export type MeasureResult = { + sqft: number; + bdft: number; + widthMinIn: number; + widthMaxIn: number; + widthAvgIn: number; +}; + +/** + * sqft = mask_pixels / scale^2 / 144, round half-up to 2dp (CAPTURE-85). + */ +export function computeSqft(maskPixels: number, scale: number): number { + if (maskPixels <= 0 || scale <= 0) return 0; + return roundHalfUp(maskPixels / (scale * scale) / 144, 2); +} + +/** + * bdft = sqft * thickness_in, round half-up to 2dp, no /12 (CAPTURE-90). + */ +export function computeBdft(sqft: number, thicknessIn: number): number { + if (sqft <= 0 || thicknessIn <= 0) return 0; + return roundHalfUp(sqft * thicknessIn, 2); +} + +/** + * Widths sample every 6in perpendicular to the length axis, dropping the first + * and last 6in; min/max/avg of the interior samples. length<=12 => all zero + * (CAPTURE-80). Callers supply the width at each interior station. + */ +export function sampleWidths(lengthIn: number, widthSamples: number[]): { min: number; max: number; avg: number } { + if (lengthIn <= 12) return { min: 0, max: 0, avg: 0 }; + const valid = widthSamples.filter((w) => w > 0); + if (valid.length === 0) return { min: 0, max: 0, avg: 0 }; + const avg = roundHalfUp(valid.reduce((a, b) => a + b, 0) / valid.length, 2); + return { min: roundHalfUp(Math.min(...valid), 2), max: roundHalfUp(Math.max(...valid), 2), avg }; +} + +/** Round half-up to `decimals` (0.5 rounds away from zero). */ +export function roundHalfUp(n: number, decimals: number): number { + const factor = 10 ** decimals; + return Math.round(n * factor) / factor; +} + +/** Convert inches to a display string: 114 -> '9ft 6in'. */ +export function lengthToDisplay(lengthIn: number): string { + const ft = Math.floor(lengthIn / 12); + const rem = roundHalfUp(lengthIn % 12, 1); + return `${ft}ft ${rem}in`; +} diff --git a/frontend/src/lib/persistence.ts b/frontend/src/lib/persistence.ts new file mode 100644 index 0000000..7031dc7 --- /dev/null +++ b/frontend/src/lib/persistence.ts @@ -0,0 +1,16 @@ +import { get, set, del } from 'idb-keyval'; +import type { SlabDraft } from './types'; + +const DRAFT_KEY = 'slab_draft_v1'; + +export async function saveDraft(draft: SlabDraft): Promise { + await set(DRAFT_KEY, draft); +} + +export async function loadDraft(): Promise { + return (await get(DRAFT_KEY)) || null; +} + +export async function clearDraft(): Promise { + await del(DRAFT_KEY); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..0378c02 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,119 @@ +export type SlabStatus = 'draft' | 'calibrated' | 'ready' | 'publishing' | 'published' | 'failed'; + +export interface Photo { + id: string; + seq: number; + isCalibration: boolean; + url: string; // object URL for preview + blob: Blob; + processedBlob?: Blob; + widthPx?: number; + heightPx?: number; + maskPixels?: number; + longestAxisPx?: number; + tapeLine?: { start: [number, number]; end: [number, number] }; +} + +export interface SlabDraft { + id: string; + status: SlabStatus; + photos: Photo[]; + calibrationPhotoId: string; + lengthIn: number; // 1/8 steps + thicknessIn: number; + sqft: number; + bdft: number; + widthMinIn: number; + widthMaxIn: number; + widthAvgIn: number; + sku: string; + speciesId: number | null; + woodCategoryIds: number[]; + edgeTypeTermId: number | null; + figureTermIds: number[]; + gradeTermId: number | null; + thicknessTermId: number | null; + moistureTermId: number | null; + figTagIds: number[]; + featTagIds: number[]; + price: number; + priceSource: 'recommendation' | 'override'; + title: string; + shortDescription: string; + description: string; + clientRev: number; + /** True once the draft exists on the server (POST /api/v1/slabs). */ + serverCreated?: boolean; + wooProductId?: number; + publishedAt?: string; + createdAt: string; + updatedAt?: string; +} + +/** + * Shape of `GET /api/v1/admin/taxonomy` (the synced Woo cache). All ids come + * from the store response — never hardcoded (QUALITY-105 / METADATA-5). + */ +export interface TaxonomyCategory { + id: number; + name: string; + slug: string; + parent_id: number | null; + woo_id: number | null; +} + +export interface TaxonomyTerm { + id: number; + name: string; + slug: string; +} + +export interface TaxonomyAttribute { + id: number; + name: string; + slug: string; + terms: TaxonomyTerm[]; +} + +export interface Taxonomy { + categories: TaxonomyCategory[]; + attributes: TaxonomyAttribute[]; + tags: TaxonomyTerm[]; + taxonomy_anchor: string | null; + taxonomy_last_sync_at: string | null; +} + +/** Public factory: a fresh SlabDraft with demo defaults; spread to override. */ +export function draft(overrides: Partial = {}): SlabDraft { + return { + id: crypto.randomUUID(), + status: 'draft', + photos: [], + calibrationPhotoId: '', + lengthIn: 0, + thicknessIn: 0, + sqft: 0, + bdft: 0, + widthMinIn: 0, + widthMaxIn: 0, + widthAvgIn: 0, + sku: '', + speciesId: null, + woodCategoryIds: [], + edgeTypeTermId: null, + figureTermIds: [], + gradeTermId: null, + thicknessTermId: null, + moistureTermId: null, + figTagIds: [], + featTagIds: [], + price: 0, + priceSource: 'recommendation', + title: '', + shortDescription: '', + description: '', + clientRev: 1, + createdAt: new Date().toISOString(), + ...overrides + }; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 7996954..e3704c9 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,4 +1,7 @@ import App from './App.svelte'; -const root = document.querySelector('#app'); -if (root) new App({ target: root }); +export const app = new App({ + target: document.getElementById('app')! +}); + +export default app; diff --git a/frontend/src/mask/sheetKeep.test.ts b/frontend/src/mask/sheetKeep.test.ts index 9ed95a7..eace6cc 100644 --- a/frontend/src/mask/sheetKeep.test.ts +++ b/frontend/src/mask/sheetKeep.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { blackKeep, greenKeep } from './sheetKeep.ts'; +import { blackKeep, greenKeep, autoPickSheet, keepForSheet } from './sheetKeep'; // --- Black sheet sensitivity (#74) --- @@ -57,3 +57,44 @@ test('greenKeep: s=0 does not eat mild wood greens; s=100 widens sheet match (#7 assert.equal(greenKeep(border.r, border.g, border.b, 0), true, 'delta20 kept at s=0'); assert.equal(greenKeep(border.r, border.g, border.b, 100), false, 'delta20 sheet at s=100'); }); + +// --- Auto sheet pick --- + +test('autoPickSheet: green-dominant edge samples pick green', () => { + const samples = [ + { r: 40, g: 180, b: 50 }, + { r: 30, g: 160, b: 45 }, + { r: 45, g: 170, b: 55 }, + ]; + assert.equal(autoPickSheet(samples), 'green'); +}); + +test('autoPickSheet: near-black edge samples pick black', () => { + const samples = [ + { r: 5, g: 5, b: 5 }, + { r: 8, g: 7, b: 6 }, + { r: 10, g: 9, b: 8 }, + ]; + assert.equal(autoPickSheet(samples), 'black'); +}); + +test('autoPickSheet: empty samples default to black (safer drop floor)', () => { + assert.equal(autoPickSheet([]), 'black'); +}); + +// --- Sheet selector resolution --- + +test('keepForSheet: explicit green/black route to the matching predicate', () => { + const green = keepForSheet('green', 50); + const black = keepForSheet('black', 50); + assert.equal(green(40, 180, 50), false, 'green sheet dropped'); + assert.equal(black(40, 180, 50), true, 'green wood kept by black rule'); + assert.equal(black(5, 5, 5), false, 'black sheet dropped'); + assert.equal(green(140, 110, 80), true, 'wood kept by green rule'); +}); + +test('keepForSheet: auto resolves from samples', () => { + const greenSamples = [{ r: 40, g: 180, b: 50 }]; + const auto = keepForSheet('auto', 50, greenSamples); + assert.equal(auto(40, 180, 50), false, 'auto picks green for green samples'); +}); diff --git a/frontend/src/mask/sheetKeep.ts b/frontend/src/mask/sheetKeep.ts index 4577d00..f38e9e7 100644 --- a/frontend/src/mask/sheetKeep.ts +++ b/frontend/src/mask/sheetKeep.ts @@ -1,9 +1,11 @@ /** - * Sheet-vs-slab keep predicates used by the mask worker. - * Extracted so unit tests can cover sensitivity curves without the worker. + * Sheet-vs-slab keep predicates for the M1 capture mask (CAPTURE-30..60). + * Pure pixel tests: given one source pixel and a sensitivity, decide whether + * the pixel belongs to the slab (keep) or the sheet (drop). The caller owns + * the bitmap walk and the bitset; this module owns the sensitivity curves. */ -/** Keep slab pixels on a green sheet. Higher s widens the green-sheet match. */ +/** Keep slab pixels on a green sheet. Higher sensitivity widens the green-sheet match. */ export function greenKeep(r: number, g: number, b: number, s: number): boolean { const t = Math.max(0, Math.min(100, s)) / 100; // s=0 removes only strongly green sheet; increasing sensitivity widens the match. @@ -19,18 +21,34 @@ export function greenKeep(r: number, g: number, b: number, s: number): boolean { export function blackKeep(r: number, g: number, b: number, s: number): boolean { const t = Math.max(0, Math.min(100, s)) / 100; const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; - // Old floor 40 ate dark wood at minimum sensitivity. Floor 10 isolates near-black. + // Floor 10 isolates near-black; the old floor of 40 ate dark wood at minimum sensitivity. const isSheet = lum < 10 + 140 * t; return !isSheet; } +export type SheetKind = 'green' | 'black'; + +/** Pick the sheet type from a sample of edge pixels: whichever match is stronger wins. */ export function autoPickSheet( samples: { r: number; g: number; b: number }[], -): 'green' | 'black' { - let gr = 0, gl = 0; +): SheetKind { + let green = 0; + let black = 0; for (const p of samples) { - if (p.g - Math.max(p.r, p.b) > 20) gr++; - if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++; + if (p.g - Math.max(p.r, p.b) > 20) green++; + if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) black++; } - return gl >= gr ? 'black' : 'green'; + return black >= green ? 'black' : 'green'; +} + +/** Resolve a sheet selector to a keep predicate for the given sensitivity. */ +export function keepForSheet( + sheet: 'auto' | SheetKind, + sensitivity: number, + samples?: { r: number; g: number; b: number }[], +): (r: number, g: number, b: number) => boolean { + const kind = sheet === 'auto' ? autoPickSheet(samples ?? []) : sheet; + return kind === 'green' + ? (r, g, b) => greenKeep(r, g, b, sensitivity) + : (r, g, b) => blackKeep(r, g, b, sensitivity); } diff --git a/frontend/src/measure/MeasureInputs.svelte b/frontend/src/measure/MeasureInputs.svelte new file mode 100644 index 0000000..8df16bf --- /dev/null +++ b/frontend/src/measure/MeasureInputs.svelte @@ -0,0 +1,71 @@ + + +
+

2. Measure (CAPTURE-70/75/80/85/90)

+

Enter length & thickness (1/8" steps). Widths/sqft/bdft derived. Deterministic math in lib/measure.ts

+ +
+ + + {#if !measuredMaskPixels}{/if} +
+ +
+
Length display: {lengthDisp}
+
Sqft: {sqft} (from {measuredMaskPixels ? 'calibration mask' : 'fallback estimate'})
+
Bdft: {bdft}
+
Widths: min {widths.min} / max {widths.max} / avg {widths.avg} in
+
+ +

The calibration photo supplies the pixel mask and longest axis. Measurements remain client-side and deterministic.

+ + +
+ + diff --git a/frontend/src/measure/measure.test.ts b/frontend/src/measure/measure.test.ts index 1bebd3d..2ccd818 100644 --- a/frontend/src/measure/measure.test.ts +++ b/frontend/src/measure/measure.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { minAreaRect, orientedRect } from '../geometry/rect.ts'; -import { measure } from './measure.ts'; +import { minAreaRect, orientedRect, type BBox } from '../geometry/rect'; +import { axisEndpoints, measure } from './measure'; // Uniform slab, 10 px/in: full W×H canvas, axis +x via minAreaRect angle 0. function uniform(W: number, H: number) { @@ -63,3 +63,24 @@ test('measurement scale is supplied by tape calibration, not inferred from mask assert.equal(m.scale, 20); assert.ok(m.sqft < 1, `area uses tape scale, got ${m.sqft}`); }); + +test('measure rejects a non-positive or non-finite scale', () => { + const { bitset, box, slabPx } = uniform(100, 100); + assert.throws(() => measure(bitset, 100, 100, box, 80, 8, slabPx, 0), RangeError); + assert.throws(() => measure(bitset, 100, 100, box, 80, 8, slabPx, Number.NaN), RangeError); +}); + +test('axisEndpoints: corners[3] → corners[0] is the long side', () => { + const box: BBox = { + corners: [ + [100, 0], + [100, 20], + [0, 20], + [0, 0], + ], + longSidePx: 100, + angleDeg: 0, + }; + const { start, end } = axisEndpoints(box); + assert.equal(Math.hypot(end[0] - start[0], end[1] - start[1]), 100); +}); diff --git a/frontend/src/measure/measure.ts b/frontend/src/measure/measure.ts index ddd10ad..b18a282 100644 --- a/frontend/src/measure/measure.ts +++ b/frontend/src/measure/measure.ts @@ -1,4 +1,31 @@ -import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types'; +/** + * Deterministic measurement from the approved mask + axis (CAPTURE-70..90). + * The scale (px per inch) is supplied by tape calibration, never inferred from + * the mask. Display / direction: bbox corners[3] → corners[0] is the long side. + * + * Scalar sqft/bdft/width aggregation lives in `lib/measure.ts`; this module + * owns the mask-space sampling that feeds it. + */ +import type { BBox } from '../geometry/rect'; + +/** Integer eighths of an inch; divide by 8 only at display. */ +export type Eighths = number; + +export interface Widths { + min: Eighths; + max: Eighths; + avg: Eighths; + every6In: Eighths[]; +} + +export interface MeasureResult { + lengthEighths: Eighths; + thicknessEighths: Eighths; + scale: number; // px per inch + widths: Widths; + sqft: number; // 2 dp + bdft: number; // 2 dp +} // Display / direction: corners[3] → corners[0] is the long side. export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } { @@ -8,7 +35,10 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb // Wood end→end along the approved direction: project the mask onto the axis // unit vector and return the segment on the bbox center line covering that span. function maskAxisSegment( - bitset: Uint8Array, w: number, h: number, bbox: BBox, + bitset: Uint8Array, + w: number, + h: number, + bbox: BBox, ): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } { const { start: a, end: b } = axisEndpoints(bbox); const ex = b[0] - a[0]; @@ -42,9 +72,14 @@ function maskAxisSegment( // boards, so the first and last 6 in are excluded. Each sample is a line // probe perpendicular to the axis; width = max run of slab px across it. function sampleWidths( - bitset: Uint8Array, w: number, h: number, - start: [number, number], ux: number, uy: number, - scale: number, lengthIn: number, + bitset: Uint8Array, + w: number, + h: number, + start: [number, number], + ux: number, + uy: number, + scale: number, + lengthIn: number, ): Eighths[] { const px = -uy, py = ux; const stepPx = 6 * scale; @@ -70,18 +105,26 @@ function sampleWidths( return out; } +/** + * Compute the full measurement set from the mask + approved axis. + * `scale` is px per inch from tape calibration (must be positive and finite). + * sqft/bdft use the same formulas as `lib/measure.ts` (CAPTURE-85/90). + */ export function measure( - bitset: Uint8Array, w: number, h: number, + bitset: Uint8Array, + w: number, + h: number, bbox: BBox, - lengthEighths: Eighths, thicknessEighths: Eighths, + lengthEighths: Eighths, + thicknessEighths: Eighths, slabPx: number, scale: number, -): Omit & { sourceBelowFloor: false } { +): MeasureResult { const lengthIn = lengthEighths / 8; - const seg = maskAxisSegment(bitset, w, h, bbox); if (!(scale > 0) || !Number.isFinite(scale)) { throw new RangeError('Calibration scale must be a positive finite value'); } + const seg = maskAxisSegment(bitset, w, h, bbox); const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn); let widths: Widths; if (every6In.length > 0) { @@ -101,6 +144,5 @@ export function measure( widths, sqft, bdft, - sourceBelowFloor: false, }; } diff --git a/frontend/src/png/crop.test.ts b/frontend/src/png/crop.test.ts index 4a6e243..33b91a4 100644 --- a/frontend/src/png/crop.test.ts +++ b/frontend/src/png/crop.test.ts @@ -1,6 +1,16 @@ -import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { calibrationScale, photoToScreen, screenToPhoto } from './crop.ts'; +import assert from 'node:assert/strict'; +import { + calibrationScale, + photoToScreen, + screenToPhoto, + fillFromSetting, + cropFrameSize, + cropOffsets, + cropMarginFraction, + FILL_DEFAULT, + FLOOR, +} from './crop'; test('tick scale uses endpoint distance in source-photo pixels', () => { assert.equal(calibrationScale([12, 34], [312, 434], 25), 20); @@ -29,3 +39,38 @@ test('tick distance and scale survive pan, zoom, and rotation in the photo view' assert.ok(Math.abs(calibrationScale(sourceA, sourceB, declaredLengthIn) - expectedScale) < 1e-10); } }); + +test('fillFromSetting: percentage, fraction, and invalid inputs', () => { + assert.equal(fillFromSetting(90), 0.9); + assert.equal(fillFromSetting(0.9), 0.9); + assert.equal(fillFromSetting('80'), 0.8); + assert.equal(fillFromSetting(150), 1, 'capped at 1'); + assert.equal(fillFromSetting(0), FILL_DEFAULT); + assert.equal(fillFromSetting(-3), FILL_DEFAULT); + assert.equal(fillFromSetting(NaN), FILL_DEFAULT); + assert.equal(fillFromSetting(undefined), FILL_DEFAULT); +}); + +test('cropFrameSize: slab at/above the floor → 800×1067 (3:4); below → capped, no upscale', () => { + const atFloor = cropFrameSize(2000, 1800); + assert.deepEqual(atFloor, { outW: FLOOR, outH: 1067, belowFloor: false }); + const below = cropFrameSize(700, 900); + assert.equal(below.belowFloor, true); + assert.ok(below.outW <= FLOOR, 'must not upscale past the floor'); + assert.equal(below.outH, Math.round((below.outW * 4) / 3)); +}); + +test('cropOffsets: slab is centred and scaled to the fill fraction', () => { + const { outW, outH } = cropFrameSize(2000, 1800); + const g = cropOffsets(2000, 1800, outW, outH, FILL_DEFAULT); + assert.ok(Math.abs(g.dw - outW * FILL_DEFAULT) < 1e-9, 'limiting dimension maps to fill × frame'); + assert.ok(Math.abs(g.ox - (outW - g.dw) / 2) < 1e-9, 'centred'); + assert.ok(Math.abs(g.oy - (outH - g.dh) / 2) < 1e-9, 'centred'); +}); + +test('cropMarginFraction: margin is (1 − fill) / 2 of the long axis', () => { + const margin = (fill: number) => Math.round(cropMarginFraction(fill) * 1e12) / 1e12; + assert.equal(margin(FILL_DEFAULT), 0.05); + assert.equal(margin(1), 0); + assert.equal(margin(0.5), 0.25); +}); diff --git a/frontend/src/png/crop.ts b/frontend/src/png/crop.ts index e94ad55..847edd0 100644 --- a/frontend/src/png/crop.ts +++ b/frontend/src/png/crop.ts @@ -1,10 +1,10 @@ /** * Single source of truth for the inventory crop geometry (issue #99, req. 4). * - * The 90% fill and the 3:4 aspect used to be duplicated as `const FILL = 0.9` - * in `png/inventory.ts` and `png/overlay.ts`. Both now read from here, and the - * live fill comes from the global `settings.crop_fill` (default 90%) so the - * reference line and the PNG crop can never disagree. + * The 90% fill and the 3:4 aspect are shared by `png/inventory.ts` and + * `png/overlay.ts`; both read from here so the reference line and the PNG + * crop can never disagree. The live fill comes from `settings.crop_fill` + * (default 90%). */ /** Default fill (90%) — mirrors the `settings.crop_fill` default. */ @@ -77,7 +77,10 @@ export function fillFromSetting(v: unknown): number { } /** Output frame size: shorter side clamped to {@link FLOOR}. */ -export function cropFrameSize(sw: number, sh: number): { outW: number; outH: number; belowFloor: boolean } { +export function cropFrameSize( + sw: number, + sh: number, +): { outW: number; outH: number; belowFloor: boolean } { const belowFloor = Math.min(sw, sh) < FLOOR; const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR; const outH = Math.round((outW * ASPECT_H) / ASPECT_W); diff --git a/frontend/src/png/inventory.test.ts b/frontend/src/png/inventory.test.ts index 669c042..7e6b664 100644 --- a/frontend/src/png/inventory.test.ts +++ b/frontend/src/png/inventory.test.ts @@ -1,22 +1,30 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { composeInventoryFrame } from './inventory.ts'; -import type { Mask, Source } from '../capture/capture.types'; +import { composeInventoryFrame, type MaskPick, type InventorySource } from './inventory'; -const makePhoto = (w: number, h: number, paint: (x: number, y: number) => [number, number, number, number] = () => [255, 255, 255, 255]) => { +const makePhoto = ( + w: number, + h: number, + paint: (x: number, y: number) => [number, number, number, number] = () => [255, 255, 255, 255], +) => { const rgba = new Uint8Array(w * h * 4); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [r, g, b, a] = paint(x, y); const i = (y * w + x) * 4; - rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = a; + rgba[i] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = a; } } return { width: w, height: h, rgba }; }; -const maskFor = (bitset: Uint8Array, slabBounds: Mask['slabBounds']): Pick => - ({ bitset, slabBounds }); +const maskFor = (bitset: Uint8Array, slabBounds: MaskPick['slabBounds']): MaskPick => ({ + bitset, + slabBounds, +}); test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha 255', () => { const W = 8, H = 8; @@ -27,7 +35,7 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha const SLAB = [200, 40, 20, 255] as const; const SHEET = [0, 255, 0, 255] as const; const src = makePhoto(W, H, (x, y) => (bitset[y * W + x] === 1 ? [...SLAB] : [...SHEET])); - const source: Pick = { widthPx: W, heightPx: H }; + const source: InventorySource = { widthPx: W, heightPx: H }; const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source); assert.equal(out.sourceBelowFloor, true); @@ -57,7 +65,7 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha }); test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => { - const source: Pick = { widthPx: 2000, heightPx: 1800 }; + const source: InventorySource = { widthPx: 2000, heightPx: 1800 }; const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); const out = composeInventoryFrame( makePhoto(source.widthPx, source.heightPx), @@ -70,7 +78,7 @@ test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => { }); test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => { - const source: Pick = { widthPx: 1200, heightPx: 1000 }; + const source: InventorySource = { widthPx: 1200, heightPx: 1000 }; const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); const out = composeInventoryFrame( makePhoto(source.widthPx, source.heightPx), @@ -82,7 +90,7 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not }); test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => { - const source: Pick = { widthPx: 2200, heightPx: 2000 }; + const source: InventorySource = { widthPx: 2200, heightPx: 2000 }; const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); const out = composeInventoryFrame( makePhoto(source.widthPx, source.heightPx), @@ -100,15 +108,14 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => { } } } - const xs = [minx, maxx], ys = [miny, maxy]; - const wExtent = Math.max(...xs) - Math.min(...xs) + 1; - const hExtent = Math.max(...ys) - Math.min(...ys) + 1; + const wExtent = maxx - minx + 1; + const hExtent = maxy - miny + 1; assert.ok(Math.abs(wExtent - 720) <= 2, `width extent ${wExtent} ≈ 720 (0.9×800)`); assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`); }); test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => { - const source: Pick = { widthPx: 900, heightPx: 700 }; + const source: InventorySource = { widthPx: 900, heightPx: 700 }; const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); const out = composeInventoryFrame( makePhoto(source.widthPx, source.heightPx), diff --git a/frontend/src/png/inventory.ts b/frontend/src/png/inventory.ts index 8ab24d5..895e0b7 100644 --- a/frontend/src/png/inventory.ts +++ b/frontend/src/png/inventory.ts @@ -1,15 +1,29 @@ -import type { Mask, Source } from '../capture/capture.types'; +/** + * Inventory PNG frame builder (CAPTURE-100..130). + * + * `composeInventoryFrame` is pure: given the decoded source RGBA, the slab + * mask, and the slab bounds, it produces the cropped 3:4 frame with the + * as-built/crop_fill geometry from `./crop`. Canvas encoding (OffscreenCanvas + * + convertToBlob) stays in the screens/workers; this module owns the pixel + * contract — removed pixels transparent, slab pixels keep RGB at alpha 255. + */ import { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop'; -type MaskPick = Pick; -type SourcePick = Pick; +export interface SlabBounds { + x: number; + y: number; + w: number; + h: number; +} -export interface InventoryInput { - bitmap: ImageBitmap; - mask: MaskPick; - source: SourcePick; - /** Crop fill, 0 { - const { bitmap, mask, source } = input; - const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx); - const srcCtx = srcCanvas.getContext('2d')!; - srcCtx.drawImage(bitmap, 0, 0); - const img = srcCtx.getImageData(0, 0, source.widthPx, source.heightPx); - const frame = composeInventoryFrame( - { width: source.widthPx, height: source.heightPx, rgba: img.data }, - mask, - source, - input.fill, - ); - const out = new OffscreenCanvas(frame.width, frame.height); - const ctx = out.getContext('2d')!; - ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0); - const blob = await out.convertToBlob({ type: 'image/png' }); - return { blob, sourceBelowFloor: frame.sourceBelowFloor }; -} diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index d3940d9..6dad9aa 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -6,11 +6,11 @@ import { axisRightUnit, buildOverlayPlan, inventorySourceMap, - BODY_WIDTH_FRAC, type OverlayPlan, -} from './overlay.ts'; -import { orientedRect, minAreaRect } from '../geometry/rect.ts'; -import { axisEndpoints } from '../measure/measure.ts'; +} from './overlay'; +import { BODY_WIDTH_FRAC } from './overlayGeometry'; +import { orientedRect, minAreaRect } from '../geometry/rect'; +import { axisEndpoints } from '../measure/measure'; // --- Station math (#63) --- @@ -42,7 +42,6 @@ test('overlayWidthStations: never tip-inclusive 0 or L (#63)', () => { // --- EXIF-oriented bottom along approved axis (#63) --- test('pickBottomTowardTop: larger y is bottom (EXIF-oriented photo, y↓) (#63)', () => { - // Axis along +y (down the photo): start at top, end at bottom of span const start: [number, number] = [100, 50]; // top of photo const end: [number, number] = [100, 450]; // bottom of photo const { bottom, top, ux, uy } = pickBottomTowardTop(start, end); @@ -65,7 +64,6 @@ test('pickBottomTowardTop: flipped axis endpoints still pick EXIF bottom (#63)', }); test('pickBottomTowardTop: length-axis direction stays on approved span (not photo upright) (#63)', () => { - // Diagonal approved axis — bottom is still the larger-y end, unit follows the diagonal const start: [number, number] = [10, 10]; const end: [number, number] = [210, 410]; const { bottom, top, ux, uy } = pickBottomTowardTop(start, end); @@ -89,12 +87,8 @@ test('axisRightUnit: right of bottom→top travel in canvas (y↓) (#63)', () => // --- Overlay plan geometry (#63) --- test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in stations (#63)', () => { - // Uniform 480×100 slab @ 10 px/in → L=48in, axis +x. Photo taller so bottom is clear. - // Place slab in a tall canvas so EXIF bottom (large y) picks the +x? Wait axis is +x (horizontal). - // For horizontal axis both ends have same y — tie-break must be stable. const W = 480, H = 200; const bitset = new Uint8Array(W * H); - // Slab occupies y=50..149, x=0..479 (full width strip) for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1; const box = orientedRect(minAreaRect(bitset, W, H)!, 0); // long side +x const scale = 10; // px/in @@ -118,20 +112,14 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s [6, 18, 30, 42], ); - // Length graphic must sit to the axis-right of the wood (outside edge) - const mid = plan.length; - // For axis +x, bottom tie-break: if y equal, pickBottomTowardTop uses a documented rule. - // Right of bottom→top should be outside the slab along the short axis. assert.ok( - mid.outside, + plan.length.outside, 'length dimension must be flagged outside the slab on axis-right', ); - // Width arrows: tips at left/right slab edges (perp extent), text on line for (const w of plan.widths) { assert.ok(w.left && w.right, 'width has left/right tips'); assert.ok(typeof w.label === 'string' && w.label.length > 0, 'width has measurement text'); - // Across wood: left and right differ primarily along short axis (±y for +x length) const dx = w.right[0] - w.left[0]; const dy = w.right[1] - w.left[1]; assert.ok(Math.abs(dy) > Math.abs(dx), 'width span is across (perp to +x length)'); @@ -143,7 +131,6 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s }); test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => { - // Vertical slab: length along +y (photo upright matches axis). Bottom = larger y. const W = 100, H = 480; const bitset = new Uint8Array(W * H).fill(1); const box = orientedRect(minAreaRect(bitset, W, H)!, 90); // long side +y @@ -162,14 +149,11 @@ test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 [6, 18, 30, 42], 'must be 12in overlay stations, not M1 every-6 tip-trimmed list', ); - // First station is 6in up from photo-bottom end of span const first = plan.widths[0]; - // Bottom of photo is y=H-ish; 6in @ 10px/in = 60px toward top → y ≈ H-1 - 60 assert.ok(first.center[1] > H / 2, 'first station nearer the photo bottom half'); }); test('buildOverlayPlan: dishonest upright-photo axes rejected — uses approved bbox axis (#63)', () => { - // Slab long along +x but we approve axis at 0; plan length direction must follow bbox, not image H. const W = 400, H = 120; const bitset = new Uint8Array(W * H).fill(1); const box = orientedRect(minAreaRect(bitset, W, H)!, 0); @@ -183,7 +167,6 @@ test('buildOverlayPlan: dishonest upright-photo axes rejected — uses approved lengthLabel: '40"', mapToInventory: (x, y) => [x, y], }); - // Length line endpoints should differ mainly in x (along approved axis), not y const lx = Math.abs(plan.length.a[0] - plan.length.b[0]); const ly = Math.abs(plan.length.a[1] - plan.length.b[1]); assert.ok(lx > ly * 2, `length graphic follows approved +x axis (dx=${lx}, dy=${ly})`); @@ -192,7 +175,6 @@ test('buildOverlayPlan: dishonest upright-photo axes rejected — uses approved // --- Ty UAT nits (#67) --- test('buildOverlayPlan: length endpoints snap to mask tips, not oversized bbox corners (#67)', () => { - // Wood occupies x=100..299 only; synthetic bbox long side extends past tips (x=50..350). const W = 400, H = 160; const bitset = new Uint8Array(W * H); for (let y = 40; y < 100; y++) for (let x = 100; x < 300; x++) bitset[y * W + x] = 1; @@ -217,8 +199,6 @@ test('buildOverlayPlan: length endpoints snap to mask tips, not oversized bbox c mapToInventory: (x, y) => [x, y], }); - // Length line sits on axis-right; project a/b onto the length axis through the - // wood centroid — endpoints must match mask span ends, not bbox corners. const oriented = pickBottomTowardTop([100, 70], [299, 70]); const strip = (p: [number, number]): [number, number] => { const cx = 199.5, cy = 70; @@ -226,7 +206,6 @@ test('buildOverlayPlan: length endpoints snap to mask tips, not oversized bbox c return [cx + oriented.ux * along, cy + oriented.uy * along]; }; const xs = [strip(plan.length.a)[0], strip(plan.length.b)[0]].sort((a, b) => a - b); - // Mask tips ≈ 100 and 299 (inclusive pixels); bbox corners at 50 and 350 assert.ok(Math.abs(xs[0] - 100) <= 2, `mask min tip ${xs[0]} ≈ 100, not bbox 50`); assert.ok(Math.abs(xs[1] - 299) <= 2, `mask max tip ${xs[1]} ≈ 299, not bbox 350`); assert.ok(Math.abs(xs[0] - 50) > 20, 'must not use bbox corner at 50'); @@ -234,7 +213,6 @@ test('buildOverlayPlan: length endpoints snap to mask tips, not oversized bbox c }); test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () => { - // Uniform strip: length +x, width along y. Axis-right of bottom→top is −y (up). const W = 480, H = 200; const bitset = new Uint8Array(W * H); for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1; @@ -252,12 +230,8 @@ test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () = assert.ok(plan.widths.length > 0); for (const w of plan.widths) { assert.ok(w.labelAt, 'width mark must expose labelAt'); - // left/right tips still on wood edges assert.ok(Math.abs(w.left[1] - 149) <= 2 || Math.abs(w.left[1] - 50) <= 2); assert.ok(Math.abs(w.right[1] - 149) <= 2 || Math.abs(w.right[1] - 50) <= 2); - // labelAt on axis-left = opposite of axis-right from center - // For this geometry, axis-right is toward smaller y; axis-left toward larger y - // (outside past the bottom edge y≈149). const cy = w.center[1]; assert.ok( w.labelAt[1] > Math.max(w.left[1], w.right[1]) + 4, @@ -268,7 +242,6 @@ test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () = 'width label stays on the station cross-section (same x as center)', ); } - // Length stays axis-right (outside opposite side) const lenY = (plan.length.a[1] + plan.length.b[1]) / 2; assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`); }); @@ -298,7 +271,6 @@ test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (# assert.equal(plan.fontPx, Math.max(12, Math.min(16, Math.round(scale * 0.18)))); }); - // --- Length dim must not overshoot wood footprint (#69) --- /** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */ @@ -363,7 +335,6 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho mapToInventory: inv.mapToInventory, }); - // Wood AABB in inventory space (for axis-right clearance only) let mMinX = Infinity, mMaxX = -Infinity; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { @@ -417,18 +388,15 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)]; const oriented = pickBottomTowardTop(tipStart, tipEnd); - // Inventory map is isotropic scale+translate → directions preserved; axial span scales. const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]); const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]); const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]); const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]); - // Axial edge-inset (#71) shortens span slightly vs tip stations assert.ok( lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8, `length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow small axial inset only; #73)`, ); - // Axial stations of length ends = tip stations (pure axis-right offset does not change along-u). const mapOrigin = inv.mapToInventory(0, 0); const mapU = inv.mapToInventory(oriented.ux, oriented.uy); const iux = mapU[0] - mapOrigin[0]; @@ -442,7 +410,6 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho assert.ok(Math.abs(lenProjs[0] - tipProjs[0]) <= 14, `length min station ${lenProjs[0]} ≈ tip ${tipProjs[0]} (inset)`); assert.ok(Math.abs(lenProjs[1] - tipProjs[1]) <= 14, `length max station ${lenProjs[1]} ≈ tip ${tipProjs[1]} (inset)`); - // Length stays axis-right of wood (clearance to the right in inventory x for this fixture) const lenMidX = (plan.length.a[0] + plan.length.b[0]) / 2; assert.ok(lenMidX > mMaxX + 4, `length dim must sit outside wood on axis-right (x=${lenMidX} vs maxX=${mMaxX})`); }); @@ -483,8 +450,7 @@ test('buildOverlayPlan: width labels fully left of wood AABB after inventory map } for (const w of plan.widths) { - // Label anchor fully off the wood footprint (axis-left), with margin for glyph half-width - const margin = plan.fontPx * 0.55; // textAlign will grow away from wood; still keep anchor clear + const margin = plan.fontPx * 0.55; const onWood = w.labelAt[0] >= mMinX - 1 && w.labelAt[0] <= mMaxX + 1 && @@ -494,12 +460,10 @@ test('buildOverlayPlan: width labels fully left of wood AABB after inventory map !onWood, `width labelAt (${w.labelAt[0].toFixed(0)},${w.labelAt[1].toFixed(0)}) must be outside wood AABB`, ); - // Axis-left = past the left tip (smaller across); for this nearly-vertical slab, left of wood in x assert.ok( w.labelAt[0] < mMinX - 2, `width label ${w.label} x=${w.labelAt[0].toFixed(0)} must be left of wood minX=${mMinX.toFixed(0)} (margin~${margin.toFixed(0)})`, ); - // Width arrows still touch wood edges (left/right tips inside/on AABB) assert.ok(w.left[0] >= mMinX - 3 && w.left[0] <= mMaxX + 3); assert.ok(w.right[0] >= mMinX - 3 && w.right[0] <= mMaxX + 3); } @@ -507,12 +471,9 @@ test('buildOverlayPlan: width labels fully left of wood AABB after inventory map // --- Length/width dims must follow approved axis, not screen tip hack (#70) --- -// --- Length/width dims must follow approved axis, not screen tip hack (#70) --- - /** * ~25° approved axis. Irregular tips sit far off the centerline on opposite - * sides so tip-to-tip ≠ (ux,uy). #69 screen-hack follows tip pixels and fails - * the parallel-axis / pure axis-right offset asserts; classic offset passes. + * sides so tip-to-tip ≠ (ux,uy). */ function tiltedAxis25Fixture() { const W = 420, H = 520; @@ -528,7 +489,6 @@ function tiltedAxis25Fixture() { const along = (x - cx) * ux0 + (y - cy) * uy0; const across = (x - cx) * rx0 + (y - cy) * ry0; const t = (along + halfL) / (2 * halfL); - // Strong opposite bias at the tips (±0.85*halfW) so tip pixels diverge from axis const centerAcross = (t - 0.5) * 2 * halfW * 0.85; const halfAt = halfW * 0.35; if (Math.abs(along) <= halfL && Math.abs(across - centerAcross) <= halfAt) { @@ -546,10 +506,7 @@ function tiltedAxis25Fixture() { return { W, H, bitset, bbox, ux0, uy0, rx0, ry0, halfL, cx, cy }; } - test('buildOverlayPlan: angleDeg=90 vertical long side → width left→right within 2° of horizontal (#72)', () => { - // Upright slab: approved length axis is vertical (angleDeg 90). Width marks must - // run left→right ≈ horizontal (atan2 ≈ 0 or ±π), not slanted with the photo. const W = 200, H = 400; const bitset = new Uint8Array(W * H); for (let y = 40; y <= 360; y++) { @@ -558,7 +515,7 @@ test('buildOverlayPlan: angleDeg=90 vertical long side → width left→right wi const prev = minAreaRect(bitset, W, H)!; const bbox = orientedRect(prev, 90); assert.equal(bbox.angleDeg, 90); - const scale = 10; // 320px span ≈ 32" + const scale = 10; const plan = buildOverlayPlan({ bitset, widthPx: W, @@ -572,7 +529,6 @@ test('buildOverlayPlan: angleDeg=90 vertical long side → width left→right wi assert.ok(plan.widths.length > 0, 'expected width stations'); for (const w of plan.widths) { const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]); - // Fold to [-90, 90] so left→right and right→left both count as horizontal. let deg = (ang * 180) / Math.PI; if (deg > 90) deg -= 180; if (deg < -90) deg += 180; @@ -649,7 +605,6 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || `length a→b must be || approved axis (dot=${lengthDot.toFixed(6)}; got (${lux.toFixed(4)},${luy.toFixed(4)}) vs (${ux.toFixed(4)},${uy.toFixed(4)}))`, ); - // Endpoints = centerline tip stations + pure axis-right (no screen-only tip translation) for (const [endPt, tip] of [ [plan.length.a, bottom], [plan.length.b, top], @@ -658,7 +613,6 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || const oy = endPt[1] - tip[1]; const along = ox * ux + oy * uy; const across = ox * rx + oy * ry; - // Pure axis-right plus small axial inset toward center (#71 edgeInset) assert.ok( Math.abs(along) <= 4, `length end offset must be axis-right (+tiny axial inset) (along=${along.toFixed(2)}; tip=(${tip[0].toFixed(1)},${tip[1].toFixed(1)}))`, @@ -692,19 +646,15 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || function fringeTipFixture() { const W = 280, H = 560; const bitset = new Uint8Array(W * H); - // Solid body: x=90..190, y=120..440 for (let y = 120; y <= 440; y++) { for (let x = 90; x <= 190; x++) bitset[y * W + x] = 1; } - // Dense fringe whiskers (~10% of body width) past tips — denser than 0.2%. - // Must stay below BODY_WIDTH_FRAC (#73 ~0.15) so width-aware tips exclude them. for (let y = 95; y < 120; y++) { for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1; } for (let y = 441; y <= 465; y++) { for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1; } - // Approved axis ~vertical (slight tilt) const ang = (8 * Math.PI) / 180; const ux0 = Math.sin(ang); const uy0 = Math.cos(ang); @@ -746,7 +696,6 @@ test('buildOverlayPlan: length span ≤ solid-body axial extent; fringe whiskers inventoryH: outH, }); - // Solid-body axial projections (exclude fringe by y gate on this fixture) const solidProjs: number[] = []; for (let y = solidMinY; y <= solidMaxY; y++) { for (let x = 0; x < W; x++) { @@ -766,13 +715,11 @@ test('buildOverlayPlan: length span ≤ solid-body axial extent; fringe whiskers const solidAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]); const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]); - // Length must not exceed solid body (percentile fringe would overshoot by ≫2px) assert.ok( lenAxial <= solidAxial + 2, `length span ${lenAxial.toFixed(1)} must be ≤ solid-body span ${solidAxial.toFixed(1)} (fringe excluded)`, ); - // Prove 0.2%/99.8% still overshoots solid body — so this fixture is meaningful const allProjs: number[] = []; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { @@ -790,7 +737,6 @@ test('buildOverlayPlan: length span ≤ solid-body axial extent; fringe whiskers `fixture sanity: percentile span ${pctSpan.toFixed(1)} must overshoot solid ${solidSpan.toFixed(1)}`, ); - // Still || approved axis (keep #70) const ldx = plan.length.b[0] - plan.length.a[0]; const ldy = plan.length.b[1] - plan.length.a[1]; const llen = Math.hypot(ldx, ldy) || 1; @@ -821,14 +767,12 @@ test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset ( assert.ok(plan.widths.length > 0, 'expected width stations'); const wood = maskWoodAabb(bitset, W, H); - // Rebuild oriented axis to re-probe full mask extent at each station const { start: aa, end: bb } = axisEndpoints(bbox); const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1; const uxB = ex / elen, uyB = ey / elen; const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4; const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4; const cProj = cx * uxB + cy * uyB; - // Width-aware tips from the same solid body y-range as fringeTipFixture const solidProjs: number[] = []; for (let y = 120; y <= 440; y++) { for (let x = 0; x < W; x++) { @@ -854,7 +798,6 @@ test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset ( w.right[1] >= wood.minY - 1 && w.right[1] <= wood.maxY + 1, `width right must be on/inside wood AABB`, ); - // Full mask probe at this station (no inset) const t = w.stationIn * scale; const ox = bottom[0] + ux * t; const oy = bottom[1] + uy * t; @@ -871,12 +814,10 @@ test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset ( } const fullSpan = maxP - minP; const planSpan = Math.hypot(w.right[0] - w.left[0], w.right[1] - w.left[1]); - // Inset ≥ ~0.5px each end so stroke/arrowhead visual tips do not past the edge assert.ok( planSpan <= fullSpan - 0.9, `width span ${planSpan.toFixed(2)} must be inset from full mask ${fullSpan} (stroke/arrowhead)`, ); - // Tips still near the edge (not collapsed) assert.ok(planSpan >= fullSpan - 20, `inset must be modest, got plan=${planSpan} full=${fullSpan}`); } }); @@ -885,7 +826,6 @@ test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller fo const { W, H, bitset, bbox } = fringeTipFixture(); const scale = 10; const wood = maskWoodAabb(bitset, W, H); - // Tight crop so axis-left width labels would clip without clamping const slabBounds = { x: wood.minX - 4, y: wood.minY - 4, @@ -907,12 +847,10 @@ test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller fo inventoryH: outH, }); - // Font: one consistent smaller size than prior max(22, scale*1.0) const priorFont = Math.max(22, Math.round(scale * 1.0)); assert.ok(plan.fontPx < priorFont, `fontPx ${plan.fontPx} must be < prior ${priorFont}`); assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still readable`); - // Wood AABB in inventory let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { @@ -925,25 +863,20 @@ test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller fo } } - // Length label axis-right off wood const lenLabel = plan.length.labelAt; assert.ok( lenLabel[0] > mMaxX + 2 || lenLabel[1] < mMinY - 2 || lenLabel[1] > mMaxY + 2, `length label (${lenLabel[0].toFixed(0)},${lenLabel[1].toFixed(0)}) must be off wood AABB`, ); - // Glyph margin: keep anchors inside canvas const margin = Math.max(8, plan.fontPx * 0.6); assert.ok(lenLabel[0] >= margin && lenLabel[0] <= outW - margin, `length label x in canvas`); assert.ok(lenLabel[1] >= margin && lenLabel[1] <= outH - margin, `length label y in canvas`); for (const w of plan.widths) { - // Width labels axis-left off wood assert.ok( w.labelAt[0] < mMinX - 1 || w.labelAt[1] < mMinY - 1 || w.labelAt[1] > mMaxY + 1, `width label ${w.label} must be off wood`, ); - // Clamped inside inventory PNG (anchor + estimated glyph extent toward axis-left) - const glyphPad = plan.fontPx * Math.max(2, w.label.length * 0.55); assert.ok( w.labelAt[0] >= margin && w.labelAt[0] <= outW - margin, `width label x=${w.labelAt[0].toFixed(0)} must be inside canvas [0,${outW}]`, @@ -952,7 +885,7 @@ test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller fo w.labelAt[1] >= margin && w.labelAt[1] <= outH - margin, `width label y=${w.labelAt[1].toFixed(0)} must be inside canvas [0,${outH}]`, ); - // textAlign right grows left — left extent of glyphs must stay ≥ 0 + const glyphPad = plan.fontPx * Math.max(2, w.label.length * 0.55); assert.ok( w.labelAt[0] - glyphPad >= -1, `width label glyphs must not clip left edge (anchor=${w.labelAt[0].toFixed(0)}, pad=${glyphPad.toFixed(0)})`, @@ -960,7 +893,6 @@ test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller fo } }); - // --- Tapered live-edge tips must not be dropped (#73) --- /** @@ -971,10 +903,9 @@ function taperedTipFixture() { const W = 240, H = 500; const bitset = new Uint8Array(W * H); const y0 = 40, y1 = 460; - const maxHalf = 50; // body half-width → ~100px + const maxHalf = 50; for (let y = y0; y <= y1; y++) { - const t = (y - y0) / (y1 - y0); // 0..1 along length - // Cosine taper: tips at ~20% of max width, mid at full + const t = (y - y0) / (y1 - y0); const tipFrac = 0.20; const envelope = tipFrac + (1 - tipFrac) * Math.sin(Math.PI * t); const half = Math.max(2, Math.round(maxHalf * envelope)); @@ -999,7 +930,6 @@ function taperedTipFixture() { test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ tip span − small inset (#73)', () => { const { W, H, bitset, bbox, y0, y1, tipFrac, maxHalf, ux0, uy0, cx, cy } = taperedTipFixture(); - // Sanity: tip bins are ~15–25% of max width (would fail old 0.32 thresh) const tipHalf = Math.max(2, Math.round(maxHalf * tipFrac)); const tipWidth = tipHalf * 2 + 1; const bodyWidth = maxHalf * 2 + 1; @@ -1027,7 +957,6 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ mapToInventory: inv.mapToInventory, }); - // Width-aware tip stations with current BODY_WIDTH_FRAC const { start: aa, end: bb } = axisEndpoints(bbox); const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1; const ux = ex / elen, uy = ey / elen; @@ -1064,7 +993,6 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ for (let i = widths.length - 1; i >= 0; i--) { if (widths[i].width >= thresh) { tipHi = widths[i].bin; break; } } - // Tip bins at the axial extremes must survive the threshold (not dropped) const firstBin = widths[0]; const lastBin = widths[widths.length - 1]; assert.ok( @@ -1075,7 +1003,6 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ lastBin.width >= thresh || tipHi >= lastBin.bin - 3, `high tip bin width=${lastBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipHi=${tipHi} last=${lastBin.bin})`, ); - // Axial tip span must reach near the physical wood ends (y0/y1) const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)]; const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)]; const oriented = pickBottomTowardTop(tipStart, tipEnd); @@ -1084,13 +1011,11 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]); const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]); - // Length must reach tip stations minus only a small axial inset (tighten old −24px) assert.ok( lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8, `length span ${lenAxial.toFixed(1)} must match tip span ${tipAxial.toFixed(1)} within small inset (#73)`, ); - // Prove old 0.32 thresh would have under-reached (fixture is meaningful) const oldThresh = Math.max(4, maxWidth * 0.32); let oldLo = widths[0].bin, oldHi = widths[widths.length - 1].bin; for (const { bin, width } of widths) { @@ -1104,7 +1029,6 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ `new tip span ${tipHi - tipLo} must exceed old-0.32 span ${oldHi - oldLo} (taper kept)`, ); - // Still no overshoot past wood AABB along axis (keep #69/#71) const woodProjs: number[] = []; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts index d9c35ce..e000952 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -1,6 +1,15 @@ -import type { BBox } from '../capture/capture.types'; -import { axisEndpoints } from '../measure/measure'; +/** + * Dimension-overlay plan (CAPTURE-120..141, #63..#73). + * + * `buildOverlayPlan` is pure: given the mask, approved axis bbox, and the + * source→inventory pixel map, it produces the drawing plan (length dim line + + * width stations + label anchors + font sizing). Canvas drawing + * (`drawOverlayPlan`) and blob encoding stay in the screens; this module owns + * the plan geometry. + */ +import type { BBox } from '../geometry/rect'; import { FILL_DEFAULT, cropOffsets } from './crop'; +import { maskAxisSegment, probeExtent, fmtInches } from './overlayGeometry'; /** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */ export function overlayWidthStations(lengthIn: number): number[] { @@ -26,8 +35,7 @@ export function pickBottomTowardTop( } { // Larger y is closer to the bottom edge of the oriented photo. // Tie-break (horizontal axis): prefer larger x for a stable choice. - const startIsBottom = - start[1] > end[1] || (start[1] === end[1] && start[0] >= end[0]); + const startIsBottom = start[1] > end[1] || (start[1] === end[1] && start[0] >= end[0]); const bottom = startIsBottom ? start : end; const top = startIsBottom ? end : start; const dx = top[0] - bottom[0]; @@ -82,190 +90,6 @@ export interface OverlayPlanInput { inventoryH?: number; } -/** - * Min across-width as a fraction of max bin width for an axial bin to count as a tip. - * #71 used 0.32 (excluded fringe); #73 lowers to ~0.15 so tapered live-edge tips remain. - */ -export const BODY_WIDTH_FRAC = 0.15; - -/** - * Axial tip stations from across-width profile (#71/#73). - * Thin fringe whiskers past the visible body survive 0.2%/99.8% percentiles; - * require each axial bin to have meaningful across-width before it counts as a tip. - */ -function widthAwareAxialRange( - bitset: Uint8Array, - w: number, - h: number, - ux: number, - uy: number, - cx: number, - cy: number, -): { minP: number; maxP: number } | null { - const rx = -uy; - const ry = ux; - const binMin = new Map(); - const binMax = new Map(); - for (let y = 0; y < h; y++) { - const row = y * w; - for (let x = 0; x < w; x++) { - if (!bitset[row + x]) continue; - const along = x * ux + y * uy; - const across = (x - cx) * rx + (y - cy) * ry; - const bin = Math.round(along); - const prevMin = binMin.get(bin); - if (prevMin === undefined || across < prevMin) binMin.set(bin, across); - const prevMax = binMax.get(bin); - if (prevMax === undefined || across > prevMax) binMax.set(bin, across); - } - } - if (binMin.size === 0) return null; - let maxWidth = 0; - const widths: { bin: number; width: number }[] = []; - for (const [bin, minA] of binMin) { - const maxA = binMax.get(bin)!; - const width = maxA - minA + 1; - widths.push({ bin, width }); - if (width > maxWidth) maxWidth = width; - } - // Body threshold: exclude ~10% fringe whiskers; keep 15–25% live-edge tapers (#73). - const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC); - widths.sort((a, b) => a.bin - b.bin); - let tipLo = widths[0].bin; - let tipHi = widths[widths.length - 1].bin; - for (const { bin, width } of widths) { - if (width >= thresh) { - tipLo = bin; - break; - } - } - for (let i = widths.length - 1; i >= 0; i--) { - if (widths[i].width >= thresh) { - tipHi = widths[i].bin; - break; - } - } - return { minP: tipLo, maxP: tipHi }; -} - -function maskAxisSegment( - bitset: Uint8Array, - w: number, - h: number, - bbox: BBox, -): { - start: [number, number]; - end: [number, number]; - /** Actual mask pixels nearest centerline at tip stations. */ - tipStart: [number, number]; - tipEnd: [number, number]; - ux: number; - uy: number; - span: number; -} { - const { start: a, end: b } = axisEndpoints(bbox); - const ex = b[0] - a[0]; - const ey = b[1] - a[1]; - const elen = Math.hypot(ex, ey) || 1; - const ux = ex / elen; - const uy = ey / elen; - const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4; - const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4; - const cProj = cx * ux + cy * uy; - // Width-aware tips (#71). Fall back to robust percentiles if profile empty. - const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy); - let minP: number; - let maxP: number; - if (aware) { - minP = aware.minP; - maxP = aware.maxP; - } else { - const projs: number[] = []; - for (let y = 0; y < h; y++) { - const row = y * w; - for (let x = 0; x < w; x++) { - if (!bitset[row + x]) continue; - projs.push(x * ux + y * uy); - } - } - if (projs.length === 0) return { start: a, end: b, tipStart: a, tipEnd: b, ux, uy, span: 0 }; - projs.sort((p, q) => p - q); - const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1); - const hi = Math.max(Math.floor(projs.length * 0.998), lo); - minP = projs[lo]; - maxP = projs[hi]; - } - // Tip pixel at each station: closest to the axis centerline - let tipStart: [number, number] = a; - let tipEnd: [number, number] = b; - let bestStart = Infinity; - let bestEnd = Infinity; - const stationTol = Math.max(1.5, (maxP - minP) * 0.001); - for (let y = 0; y < h; y++) { - const row = y * w; - for (let x = 0; x < w; x++) { - if (!bitset[row + x]) continue; - const p = x * ux + y * uy; - const across = Math.abs((x - cx) * -uy + (y - cy) * ux); - if (Math.abs(p - minP) <= stationTol && across < bestStart) { - bestStart = across; - tipStart = [x, y]; - } - if (Math.abs(p - maxP) <= stationTol && across < bestEnd) { - bestEnd = across; - tipEnd = [x, y]; - } - } - } - return { - start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)], - end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)], - tipStart, - tipEnd, - ux, - uy, - span: maxP - minP, - }; -} - -/** Probe slab extent along unit (px, py) through origin (ox, oy). */ -function probeExtent( - bitset: Uint8Array, - w: number, - h: number, - ox: number, - oy: number, - px: number, - py: number, -): { minP: number; maxP: number } | null { - const half = Math.max(2, Math.round((w + h) / 4)); - let minP = Infinity; - let maxP = -Infinity; - for (let p = -half; p <= half; p++) { - const x = Math.round(ox + px * p); - const y = Math.round(oy + py * p); - if (x < 0 || y < 0 || x >= w || y >= h) continue; - if (bitset[y * w + x]) { - if (p < minP) minP = p; - if (p > maxP) maxP = p; - } - } - if (maxP < minP) return null; - return { minP, maxP }; -} - -function fmtInches(inches: number): string { - const whole = Math.floor(inches + 1e-9); - const rem = Math.round((inches - whole) * 8); - if (rem === 0) return `${whole}"`; - const frac: Record = { - 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '', - }; - if (rem === 8) return `${whole + 1}"`; - const part = frac[rem] ?? `${rem}/8`; - return whole > 0 ? `${whole} ${part}"` : `${part}"`; -} - export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { const { bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory, @@ -314,14 +138,14 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { const right: [number, number] = [rightMapped[0] - wux * widthEdgeInset, rightMapped[1] - wuy * widthEdgeInset]; // Width number sits axis-left outside the slab (past left tip). const labelOff = extent.minP - pad - fontPx * 0.85; - const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff]; + const labelSrc: [number, number] = [ox + rx * labelOff, oy + ry * labelOff]; const widthIn = (extent.maxP - extent.minP + 1) / scale; widths.push({ stationIn, center: mapToInventory(ox, oy), left, right, - labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]), + labelAt: mapToInventory(labelSrc[0], labelSrc[1]), label: fmtInches(widthIn), }); } @@ -401,79 +225,6 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { }; } -/** Draw black dimension graphics onto an existing canvas (inventory PNG size). */ -export function drawOverlayPlan( - ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D, - plan: OverlayPlan, -): void { - const { stroke, fontPx } = plan; - ctx.save(); - ctx.strokeStyle = stroke; - ctx.fillStyle = stroke; - ctx.lineWidth = Math.max(2, Math.round(fontPx / 10)); - // Butt caps: round caps extend past endpoints and overshoot wood edges (#71). - ctx.lineCap = 'butt'; - ctx.font = `bold ${fontPx}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - const arrow = (from: [number, number], to: [number, number]) => { - const dx = to[0] - from[0]; - const dy = to[1] - from[1]; - const len = Math.hypot(dx, dy) || 1; - const ux = dx / len; - const uy = dy / len; - const head = Math.max(7, fontPx * 0.32); - ctx.beginPath(); - ctx.moveTo(from[0], from[1]); - ctx.lineTo(to[0], to[1]); - ctx.stroke(); - // arrow tips at both ends - for (const [sx, sy, dir] of [ - [from[0], from[1], 1], - [to[0], to[1], -1], - ] as const) { - const px = -uy; - const py = ux; - ctx.beginPath(); - ctx.moveTo(sx, sy); - ctx.lineTo(sx + dir * ux * head + px * head * 0.4, sy + dir * uy * head + py * head * 0.4); - ctx.lineTo(sx + dir * ux * head - px * head * 0.4, sy + dir * uy * head - py * head * 0.4); - ctx.closePath(); - ctx.fill(); - } - }; - - // Length — grow text further axis-right of the dim line (away from wood) - arrow(plan.length.a, plan.length.b); - { - const [lax, lay] = plan.length.a; - const [lbx, lby] = plan.length.b; - const midX = (lax + lbx) / 2; - const midY = (lay + lby) / 2; - const outwardX = plan.length.labelAt[0] - midX; - const outwardY = plan.length.labelAt[1] - midY; - // Prefer left/right align so glyphs extend away from wood when offset is mostly ±x - if (Math.abs(outwardX) >= Math.abs(outwardY)) { - ctx.textAlign = outwardX >= 0 ? 'left' : 'right'; - } else { - ctx.textAlign = 'center'; - } - } - ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]); - - // Widths — grow text away from wood (labelAt is axis-left of left tip) - for (const w of plan.widths) { - arrow(w.left, w.right); - // labelAt is on the axis-left side of the station; align so glyphs extend further out - const towardLeft = w.labelAt[0] <= w.center[0]; - ctx.textAlign = towardLeft ? 'right' : 'left'; - ctx.fillText(w.label, w.labelAt[0], w.labelAt[1]); - } - - ctx.restore(); -} - export interface InventoryMap { mapToInventory: (x: number, y: number) => [number, number]; outW: number; @@ -501,49 +252,3 @@ export function inventorySourceMap( mapToInventory: (sx, sy) => [ox + (sx - x0) * scale, oy + (sy - y0) * scale], }; } - -export interface EncodeOverlayInput { - /** Clean calibration inventory PNG bytes. */ - inventoryPng: Blob; - bitset: Uint8Array; - widthPx: number; - heightPx: number; - bbox: BBox; - scale: number; - lengthIn: number; - lengthLabel: string; - slabBounds: { x: number; y: number; w: number; h: number }; - source: { widthPx: number; heightPx: number }; -} - -/** - * Burn black dimension overlays onto a copy of the clean calibration inventory PNG. - * Returns a new image/png blob; never mutates the clean inventory. - */ -export async function encodeOverlayPng(input: EncodeOverlayInput): Promise { - const bmp = await createImageBitmap(input.inventoryPng); - try { - const outW = bmp.width; - const outH = bmp.height; - const canvas = new OffscreenCanvas(outW, outH); - const ctx = canvas.getContext('2d')!; - ctx.drawImage(bmp, 0, 0); - const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH); - const plan = buildOverlayPlan({ - bitset: input.bitset, - widthPx: input.widthPx, - heightPx: input.heightPx, - bbox: input.bbox, - scale: input.scale, - lengthIn: input.lengthIn, - lengthLabel: input.lengthLabel, - mapToInventory, - inventoryW: outW, - inventoryH: outH, - }); - drawOverlayPlan(ctx, plan); - return await canvas.convertToBlob({ type: 'image/png' }); - } finally { - bmp.close(); - } -} diff --git a/frontend/src/png/overlayGeometry.ts b/frontend/src/png/overlayGeometry.ts new file mode 100644 index 0000000..29a6b86 --- /dev/null +++ b/frontend/src/png/overlayGeometry.ts @@ -0,0 +1,198 @@ +/** + * Mask-space helpers for the dimension overlay: axial tip stations, slab + * extent probes, and the inch-label formatter. No inventory mapping here — + * pure source-pixel geometry shared by `overlay.ts`. + */ +import type { BBox } from '../geometry/rect'; +import { axisEndpoints } from '../measure/measure'; + +/** + * Min across-width as a fraction of max bin width for an axial bin to count as a tip. + * #71 used 0.32 (excluded fringe); #73 lowers to ~0.15 so tapered live-edge tips remain. + */ +export const BODY_WIDTH_FRAC = 0.15; + +/** + * Axial tip stations from across-width profile (#71/#73). + * Thin fringe whiskers past the visible body survive 0.2%/99.8% percentiles; + * require each axial bin to have meaningful across-width before it counts as a tip. + */ +function widthAwareAxialRange( + bitset: Uint8Array, + w: number, + h: number, + ux: number, + uy: number, + cx: number, + cy: number, +): { minP: number; maxP: number } | null { + const rx = -uy; + const ry = ux; + const binMin = new Map(); + const binMax = new Map(); + for (let y = 0; y < h; y++) { + const row = y * w; + for (let x = 0; x < w; x++) { + if (!bitset[row + x]) continue; + const along = x * ux + y * uy; + const across = (x - cx) * rx + (y - cy) * ry; + const bin = Math.round(along); + const prevMin = binMin.get(bin); + if (prevMin === undefined || across < prevMin) binMin.set(bin, across); + const prevMax = binMax.get(bin); + if (prevMax === undefined || across > prevMax) binMax.set(bin, across); + } + } + if (binMin.size === 0) return null; + let maxWidth = 0; + const widths: { bin: number; width: number }[] = []; + for (const [bin, minA] of binMin) { + const maxA = binMax.get(bin)!; + const width = maxA - minA + 1; + widths.push({ bin, width }); + if (width > maxWidth) maxWidth = width; + } + // Body threshold: exclude ~10% fringe whiskers; keep 15–25% live-edge tapers (#73). + const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC); + widths.sort((a, b) => a.bin - b.bin); + let tipLo = widths[0].bin; + let tipHi = widths[widths.length - 1].bin; + for (const { bin, width } of widths) { + if (width >= thresh) { + tipLo = bin; + break; + } + } + for (let i = widths.length - 1; i >= 0; i--) { + if (widths[i].width >= thresh) { + tipHi = widths[i].bin; + break; + } + } + return { minP: tipLo, maxP: tipHi }; +} + +export interface AxisSegment { + start: [number, number]; + end: [number, number]; + /** Actual mask pixels nearest centerline at tip stations. */ + tipStart: [number, number]; + tipEnd: [number, number]; + ux: number; + uy: number; + span: number; +} + +/** + * Wood end→end along the approved bbox axis: width-aware tip stations with a + * robust-percentile fallback when the across-width profile is empty (#71). + */ +export function maskAxisSegment( + bitset: Uint8Array, + w: number, + h: number, + bbox: BBox, +): AxisSegment { + const { start: a, end: b } = axisEndpoints(bbox); + const ex = b[0] - a[0]; + const ey = b[1] - a[1]; + const elen = Math.hypot(ex, ey) || 1; + const ux = ex / elen; + const uy = ey / elen; + const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4; + const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4; + const cProj = cx * ux + cy * uy; + // Width-aware tips (#71). Fall back to robust percentiles if profile empty. + const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy); + let minP: number; + let maxP: number; + if (aware) { + minP = aware.minP; + maxP = aware.maxP; + } else { + const projs: number[] = []; + for (let y = 0; y < h; y++) { + const row = y * w; + for (let x = 0; x < w; x++) { + if (!bitset[row + x]) continue; + projs.push(x * ux + y * uy); + } + } + if (projs.length === 0) return { start: a, end: b, tipStart: a, tipEnd: b, ux, uy, span: 0 }; + projs.sort((p, q) => p - q); + const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1); + const hi = Math.max(Math.floor(projs.length * 0.998), lo); + minP = projs[lo]; + maxP = projs[hi]; + } + // Tip pixel at each station: closest to the axis centerline + let tipStart: [number, number] = a; + let tipEnd: [number, number] = b; + let bestStart = Infinity; + let bestEnd = Infinity; + const stationTol = Math.max(1.5, (maxP - minP) * 0.001); + for (let y = 0; y < h; y++) { + const row = y * w; + for (let x = 0; x < w; x++) { + if (!bitset[row + x]) continue; + const p = x * ux + y * uy; + const across = Math.abs((x - cx) * -uy + (y - cy) * ux); + if (Math.abs(p - minP) <= stationTol && across < bestStart) { + bestStart = across; + tipStart = [x, y]; + } + if (Math.abs(p - maxP) <= stationTol && across < bestEnd) { + bestEnd = across; + tipEnd = [x, y]; + } + } + } + return { + start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)], + end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)], + tipStart, + tipEnd, + ux, + uy, + span: maxP - minP, + }; +} + +/** Probe slab extent along unit (px, py) through origin (ox, oy). */ +export function probeExtent( + bitset: Uint8Array, + w: number, + h: number, + ox: number, + oy: number, + px: number, + py: number, +): { minP: number; maxP: number } | null { + const half = Math.max(2, Math.round((w + h) / 4)); + let minP = Infinity; + let maxP = -Infinity; + for (let p = -half; p <= half; p++) { + const x = Math.round(ox + px * p); + const y = Math.round(oy + py * p); + if (x < 0 || y < 0 || x >= w || y >= h) continue; + if (bitset[y * w + x]) { + if (p < minP) minP = p; + if (p > maxP) maxP = p; + } + } + if (maxP < minP) return null; + return { minP, maxP }; +} + +/** Format inches as a display label with eighths fractions, e.g. `47 3/4"`. */ +export function fmtInches(inches: number): string { + const whole = Math.floor(inches + 1e-9); + const rem = Math.round((inches - whole) * 8); + if (rem === 0) return `${whole}"`; + const frac: Record = { + 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '', + }; + if (rem === 8) return `${whole + 1}"`; + const part = frac[rem] ?? `${rem}/8`; + return whole > 0 ? `${whole} ${part}"` : `${part}"`; +} diff --git a/frontend/src/publish/PublishPanel.svelte b/frontend/src/publish/PublishPanel.svelte new file mode 100644 index 0000000..f335c6c --- /dev/null +++ b/frontend/src/publish/PublishPanel.svelte @@ -0,0 +1,81 @@ + + +
+

4. Publish (PUBLISH)

+

Ready set validated → SKU lookup → taxonomy refresh → PNG upload → Woo product. SLAB-UAT-* SKUs are always created as drafts (PUBLISH-10).

+ +
+

SKU: {slab.sku} | Price: ${slab.price} | Bdft: {slab.bdft}

+

Title: {slab.title}

+

Photos: {slab.photos.length}

+
+ + {#if status} +

{status}

+ {/if} + {#if result} +

Woo product id: {result.woo_product_id}{result.draft ? ' (draft)' : ''}

+ {/if} + {#if error} + + {/if} + +
+ + +
+
+ + diff --git a/frontend/src/review/ReviewForm.svelte b/frontend/src/review/ReviewForm.svelte new file mode 100644 index 0000000..fe256dc --- /dev/null +++ b/frontend/src/review/ReviewForm.svelte @@ -0,0 +1,387 @@ + + +
+

3. Review (METADATA/REVIEW)

+ {#if !taxonomy} +

Loading synced taxonomy…

+ {:else if !categories.length} +

Taxonomy is empty — run a Woo sync in Settings first.

+ {:else} +
+ + + + +
+ Wood categories — primary drives the title + {#each woodCatLeaves as c (c.id)} + + {/each} + {selectedSummary(woodCatIds, woodCatNames, 'None selected')} +
+ + + +
+ Figure (multi) + {#each figureTerms as t (t.id)} + + {/each} +
+ + + +
+ Thickness band (derived from {slab.thicknessIn}″) + {bandName || '— set thickness in Measure —'} +
+ + + + {#if figTags.length} +
+ Figure tags + {#each figTags as t (t.id)} + + {/each} +
+ {/if} + {#if featTags.length} +
+ Feature tags + {#each featTags as t (t.id)} + + {/each} +
+ {/if} + +
+ + {#if priceRec != null} + + {:else} + No species rate configured — set it in Settings. + {/if} + Source: {priceSource} +
+ +
+ + + +
+ + + {#if genMessage}

{genMessage}

{/if} +
+
+ +
+ Photo lineup — leftmost kept = primary (cover). Tap to keep/remove; use ‹ › to reorder. + {#each lineup as item, i (item.id)} + {#if item.kept} +
+ + {roleLabel(item.role)} +
+ + +
+ {roleLabel(item.role)} +
+ {/if} + {/each} + {#if lineupMissing.length} +

Minimum not met — missing {lineupMissing.map(roleLabel).join(' and ')}.

+ {/if} +
+
+ + {#if !ready} +

Still needed: {missing.join(', ')}

+ {/if} + +
+ + +
+ {/if} +
+ + diff --git a/frontend/src/review/copy.test.ts b/frontend/src/review/copy.test.ts new file mode 100644 index 0000000..b69240b --- /dev/null +++ b/frontend/src/review/copy.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { buildShortDescription, buildTitle, type CopyFacts } from './copy'; + +const FACTS: CopyFacts = { lengthIn: 48, widthAvgIn: 18, thicknessIn: 2, bdft: 12 }; + +test('copy: live-edge leaf title, no dims, figure drops when absent', () => { + assert.equal( + buildTitle(FACTS, 'Cathedral', 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }), + 'Cathedral Black Walnut Live Edge Slab', + ); + assert.equal( + buildTitle(FACTS, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }), + 'Black Walnut Live Edge Slab', + ); +}); + +test('copy: dimensional leaf gains "Live Edge" only from the synced edge term (METADATA-5)', () => { + assert.equal( + buildTitle({ ...FACTS, edgeName: 'Live' }, null, 'Red Oak', { slug: 'dimensional-wood', name: 'Dimensional wood' }), + 'Red Oak Live Edge Dimensional', + ); + assert.equal( + buildTitle({ ...FACTS, edgeName: 'Straight' }, null, 'Red Oak', { slug: 'dimensional-wood', name: 'Dimensional wood' }), + 'Red Oak Dimensional', + ); +}); + +test('copy: knife-blank leaf carries dims', () => { + assert.equal( + buildTitle(FACTS, null, 'Cherry', { slug: 'knife-blanks', name: 'Knife blanks' }), + 'Cherry Knife Blank (48 x 18 x 2 in)', + ); +}); + +test('copy: unknown leaf slug falls back to "Slab"', () => { + assert.equal( + buildTitle(FACTS, 'Spalted', 'Olive', { slug: 'mystery-leaf', name: 'Mystery' }), + 'Spalted Olive Slab', + ); +}); + +test('copy: title collapses double spaces', () => { + assert.equal( + buildTitle(FACTS, ' ', 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }), + 'Black Walnut Live Edge Slab', + ); +}); + +test('copy: short description = title + ft/in split + dims + rounded bdft', () => { + assert.equal( + buildShortDescription(FACTS, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }), + 'Black Walnut Live Edge Slab, 4ft 0in x 18in x 2in, 12 bdft', + ); +}); + +test('copy: short description keeps fractional bdft to 1 dp', () => { + assert.equal( + buildShortDescription({ ...FACTS, bdft: 11.46 }, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }), + 'Black Walnut Live Edge Slab, 4ft 0in x 18in x 2in, 11.5 bdft', + ); +}); + +test('copy: non-whole feet split into ft + rem inches', () => { + assert.equal( + buildShortDescription({ ...FACTS, lengthIn: 60 }, null, 'Cherry', { slug: 'live-edge-wood', name: 'Live edge wood' }), + 'Cherry Live Edge Slab, 5ft 0in x 18in x 2in, 12 bdft', + ); + assert.equal( + buildShortDescription({ ...FACTS, lengthIn: 41.5 }, null, 'Cherry', { slug: 'live-edge-wood', name: 'Live edge wood' }), + 'Cherry Live Edge Slab, 3ft 5.5in x 18in x 2in, 12 bdft', + ); +}); diff --git a/frontend/src/review/copy.ts b/frontend/src/review/copy.ts new file mode 100644 index 0000000..c59d498 --- /dev/null +++ b/frontend/src/review/copy.ts @@ -0,0 +1,91 @@ +/** + * Deterministic title / short description (METADATA-80/85). + * + * Mirrors the backend's `build_title` / `build_short_description` so the Review + * screen previews exactly what the server will assemble. Patterns interpolate + * {figure}, {species}, {length}, {width}, {thickness}, {bdft}; figure is a + * Title-Case adjective and an absent figure drops the slot. Leaf slugs are the + * synced Woo slugs — ids are never hardcoded (QUALITY-105 / METADATA-5). + */ + +export interface CopyFacts { + lengthIn: number; + widthAvgIn: number; + thicknessIn: number; + bdft: number; + /** Name of the selected edge term; a name containing "live" flags live edge (METADATA-5). */ + edgeName?: string | null; +} + +export interface CopyLeaf { + slug: string; + name: string; +} + +/** (pattern, noun, carriesDims) per primary wood-category leaf slug. */ +const LEAF_PATTERNS: Record = { + 'live-edge-wood': { pattern: '{figure}{species} Live Edge Slab', noun: 'Live Edge Slab', carriesDims: false }, + 'dimensional-wood': { pattern: '{figure}{species} {live_edge}Dimensional', noun: 'Dimensional', carriesDims: false }, + 'knife-blanks': { pattern: '{figure}{species} Knife Blank ({dims})', noun: 'Knife Blank', carriesDims: true }, + 'bowl-blanks': { pattern: '{figure}{species} {live_edge}Bowl Blank ({dims})', noun: 'Bowl Blank', carriesDims: true }, + 'logs-and-caps': { pattern: '{figure}{species} Log Cap', noun: 'Log Cap', carriesDims: false }, + 'pen-blanks': { pattern: '{figure}{species} Pen Blank ({dims})', noun: 'Pen Blank', carriesDims: true }, + 'pepper-mill-blanks': { pattern: '{figure}{species} Pepper Mill Blank ({dims})', noun: 'Pepper Mill Blank', carriesDims: true }, +}; + +const FALLBACK_PATTERN = { pattern: '{figure}{species} Slab', noun: 'Slab', carriesDims: false }; + +function fmt1(v: number): string { + const s = v.toFixed(1); + return s.endsWith('.0') ? s.slice(0, -2) : s; +} + +function fmt2(v: number): string { + return v.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +function dims(facts: CopyFacts): string { + return `${fmt1(facts.lengthIn)} x ${fmt1(facts.widthAvgIn)} x ${fmt2(facts.thicknessIn)} in`; +} + +function titleCase(s: string): string { + return s.replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function collapse(s: string): string { + return s.replace(/\s+/g, ' ').trim(); +} + +function splitLength(lengthIn: number): [number, number] { + const ft = Math.floor(lengthIn / 12); + const rem = Math.round((lengthIn - ft * 12) * 10) / 10; + return [ft, rem]; +} + +function edgeIsLive(edgeName: string | null | undefined): boolean { + return (edgeName ?? '').toLowerCase().includes('live'); +} + +export function buildTitle(facts: CopyFacts, figure: string | null, speciesName: string, primaryLeaf: CopyLeaf): string { + const { pattern, carriesDims } = LEAF_PATTERNS[primaryLeaf.slug] ?? FALLBACK_PATTERN; + const fig = (figure ?? '').trim(); + // The live-edge flag is driven by the *synced edge term* for dimensional/bowl + // forms — never by any non-null edge id (METADATA-5 / #98). + const liveEdge = + (primaryLeaf.slug === 'dimensional-wood' || primaryLeaf.slug === 'bowl-blanks') && edgeIsLive(facts.edgeName) + ? 'Live Edge ' + : ''; + const title = pattern + .replace('{figure}', fig ? `${titleCase(fig)} ` : '') + .replace('{species}', speciesName) + .replace('{live_edge}', liveEdge) + .replace('{dims}', carriesDims ? dims(facts) : ''); + return collapse(title); +} + +export function buildShortDescription(facts: CopyFacts, figure: string | null, speciesName: string, primaryLeaf: CopyLeaf): string { + const title = buildTitle(facts, figure, speciesName, primaryLeaf); + const [ft, rem] = splitLength(facts.lengthIn); + const line = `${title}, ${ft}ft ${fmt1(rem)}in x ${fmt1(facts.widthAvgIn)}in x ${fmt2(facts.thicknessIn)}in, ${fmt1(Math.round(facts.bdft * 10) / 10)} bdft`; + return collapse(line); +} diff --git a/frontend/src/create/review/lineup.test.ts b/frontend/src/review/lineup.test.ts similarity index 90% rename from frontend/src/create/review/lineup.test.ts rename to frontend/src/review/lineup.test.ts index 27be45d..8e7d0d3 100644 --- a/frontend/src/create/review/lineup.test.ts +++ b/frontend/src/review/lineup.test.ts @@ -60,10 +60,7 @@ test('lineup: batch-only is not valid; cal+overlay+batch is', () => { 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'], - ); + assert.deepEqual(missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]), ['calibration']); }); test('lineup: roleLabel is owner-facing', () => { @@ -71,9 +68,7 @@ test('lineup: roleLabel is owner-facing', () => { 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". +test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL', () => { 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'); diff --git a/frontend/src/create/review/lineup.ts b/frontend/src/review/lineup.ts similarity index 65% rename from frontend/src/create/review/lineup.ts rename to frontend/src/review/lineup.ts index bc931c8..a19420b 100644 --- a/frontend/src/create/review/lineup.ts +++ b/frontend/src/review/lineup.ts @@ -1,11 +1,12 @@ -// #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. +/** + * Review photo-strip lineup (REVIEW-5 / PUBLISH-45). Pure logic so keep/remove, + * reorder, and the submission minimum are unit-testable without a browser. + * + * The owner taps photos to keep/remove and drags to reorder; the LEFTMOST kept + * photo is the primary (Woo cover). Submission minimum: at least one clean + * calibration photo AND its dimension-overlay copy (REVIEW-5). Batch photos + * are optional. + */ export type PhotoRole = 'calibration' | 'overlay' | 'batch'; @@ -17,11 +18,7 @@ export interface LineupItem { 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. - */ +/** The kept photos in their current (left→right) order; the first is the primary. */ export function keptInOrder(items: LineupItem[]): LineupItem[] { return items.filter((it) => it.kept); } @@ -63,11 +60,9 @@ export function roleLabel(role: PhotoRole): string { } /** - * Resolve the 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. + * Resolve the src for a strip photo. A fresh in-memory blob must become + * a `blob:` object URL — assigning a raw Blob renders a broken image. With no + * usable blob, fall back to the stored endpoint. * * Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it * is unit-testable without a real DOM/Blob. diff --git a/frontend/src/review/thicknessBand.test.ts b/frontend/src/review/thicknessBand.test.ts new file mode 100644 index 0000000..d363800 --- /dev/null +++ b/frontend/src/review/thicknessBand.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { deriveThicknessBandId, parseInchToken, termBounds, type ThicknessTerm } from './thicknessBand'; + +test('parseInchToken: whole, decimal, fraction, mixed number, quoted', () => { + assert.equal(parseInchToken('2'), 2); + assert.equal(parseInchToken('1.5'), 1.5); + assert.equal(parseInchToken('3/4'), 0.75); + assert.equal(parseInchToken('1 1/2'), 1.5); + assert.equal(parseInchToken('2"'), 2); + assert.equal(parseInchToken(' 1 1/2 '), 1.5); + assert.equal(parseInchToken(''), null); + assert.equal(parseInchToken('N/A'), null); + assert.equal(parseInchToken(null), null); +}); + +test('termBounds: name first, slug fallback, malformed → null', () => { + assert.deepEqual(termBounds({ id: 1, name: '1"–1 1/2"' }), [1, 1.5]); + assert.deepEqual(termBounds({ id: 2, name: '1 1/2"–3"', slug: 'x' }), [1.5, 3]); + assert.deepEqual(termBounds({ id: 3, name: '3"+' }), [3, Infinity]); + assert.deepEqual(termBounds({ id: 4, name: 'under 1"', slug: 'x' }), [0, 1]); + assert.deepEqual(termBounds({ id: 5, name: 'over 6"', slug: 'x' }), [6, Infinity]); + assert.deepEqual(termBounds({ id: 6, name: 'x', slug: '4-6qtr' }), [1, 1.5]); + assert.deepEqual(termBounds({ id: 7, name: 'x', slug: '12qtr' }), [3, Infinity]); + assert.deepEqual(termBounds({ id: 8, name: 'x', slug: 'xthin' }), [0, 0.5]); + assert.equal(termBounds({ id: 9, name: 'N/A', slug: 'zzz' }), null); +}); + +test('deriveThicknessBandId: half-open [low, high) — boundary belongs to the starting band', () => { + const terms: ThicknessTerm[] = [ + { id: 10, name: '3/4"–1"' }, + { id: 11, name: '1"–1 1/2"' }, + { id: 12, name: '1 1/2"–3"' }, + ]; + assert.equal(deriveThicknessBandId(0.75, terms), 10); + assert.equal(deriveThicknessBandId(0.9, terms), 10); + // Exactly on the 1" shared boundary → the band that starts at 1". + assert.equal(deriveThicknessBandId(1, terms), 11); + assert.equal(deriveThicknessBandId(1.5, terms), 12); + assert.equal(deriveThicknessBandId(2.5, terms), 12); +}); + +test('deriveThicknessBandId: 1/8" steps land in the enclosing band', () => { + const terms: ThicknessTerm[] = [ + { id: 39, name: '2-4 in' }, + { id: 10, name: '1-2 in' }, + ]; + assert.equal(deriveThicknessBandId(1, terms), 10); + assert.equal(deriveThicknessBandId(1.5, terms), 10); + assert.equal(deriveThicknessBandId(2, terms), 39); // 2 is the upper bound of 1-2 → excluded, falls into 2-4 + assert.equal(deriveThicknessBandId(3.875, terms), 39); +}); + +test('deriveThicknessBandId: order-independent, gap fallback by nearest lower edge', () => { + const terms: ThicknessTerm[] = [ + { id: 12, name: '1 1/2"–3"' }, + { id: 11, name: '1"–1 1/2"' }, + ]; + assert.equal(deriveThicknessBandId(1.25, terms), 11); + // Gap below 1": nearest band by lower edge. + assert.equal(deriveThicknessBandId(0.5, terms), 11); +}); + +test('deriveThicknessBandId: unbounded "over" band catches large values', () => { + const terms: ThicknessTerm[] = [ + { id: 1, name: '3/4"–1"' }, + { id: 2, name: '1"+' }, + ]; + assert.equal(deriveThicknessBandId(12, terms), 2); +}); + +test('deriveThicknessBandId: null / invalid input → null (no invented band)', () => { + const terms: ThicknessTerm[] = [{ id: 1, name: '1-2 in' }]; + assert.equal(deriveThicknessBandId(null, terms), null); + assert.equal(deriveThicknessBandId(undefined, terms), null); + assert.equal(deriveThicknessBandId(-1, terms), null); + assert.equal(deriveThicknessBandId(Number.NaN, terms), null); + assert.equal(deriveThicknessBandId(1, []), null); + assert.equal(deriveThicknessBandId(1, [{ id: 1, name: 'N/A' }]), null); +}); diff --git a/frontend/src/review/thicknessBand.ts b/frontend/src/review/thicknessBand.ts new file mode 100644 index 0000000..9b097c7 --- /dev/null +++ b/frontend/src/review/thicknessBand.ts @@ -0,0 +1,122 @@ +/** + * Deterministic thickness-band derivation (METADATA-15 / QUALITY-95). + * + * Mirrors the backend's `resolve_thickness_band_id` so the Review screen can + * preview the band the server will store: the owner enters thickness in 1/8" + * steps and the band follows — the UI never exposes a band picker. Bands are + * parsed from the live synced term names/slugs, never hardcoded ids. + * + * Round-up semantics: bands are half-open on the upper bound, so a value on a + * shared boundary belongs to the band that *starts* there ("1"–1 1/2" wins over + * "3/4"–1"). + */ + +export interface ThicknessTerm { + id: number; + name: string; + slug?: string; +} + +/** Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → inches. */ +export function parseInchToken(text: string | undefined | null): number | null { + const t = (text ?? '').trim().replace(/["']\s*$/, '').trim(); + if (!t) return null; + const m = + t.match(/^(?:(\d+)\s+(\d+)\s*\/\s*(\d+)|(\d+)\s*\/\s*(\d+)|(\d+(?:\.\d+)?))$/i) ?? + t.match(/(?:(\d+)\s+(\d+)\s*\/\s*(\d+)|(\d+)\s*\/\s*(\d+)|(\d+(?:\.\d+)?))/i); + if (!m) return null; + if (m[1] != null) return Number(m[1]) + Number(m[2]) / Number(m[3]); + if (m[4] != null) return Number(m[4]) / Number(m[5]); + if (m[6] != null) return Number(m[6]); + return null; +} + +/** Half-open [low, high) inches from one synced term; name first, slug fallback. */ +export function termBounds(term: ThicknessTerm): [number, number] | null { + return boundsFromName(String(term.name ?? '')) ?? boundsFromSlug(String(term.slug ?? '')); +} + +function boundsFromName(raw: string): [number, number] | null { + const text = raw.trim(); + if (!text) return null; + const low = text.toLowerCase(); + + // Under / below / less-than / < X + let m = low.match(/^(?:under|below|less than|<)\s*(.+)$/); + if (m) { + const hi = parseInchToken(m[1]); + if (hi != null) return [0, hi]; + } + // Over / above / greater-than / > X / X+ + m = low.match(/^(?:over|above|greater than|>)\s*(.+)$/); + if (m) { + const lo = parseInchToken(m[1]); + if (lo != null) return [lo, Infinity]; + } + m = text.match(/^(.+?)\+\s*$/); + if (m) { + const lo = parseInchToken(m[1]); + if (lo != null) return [lo, Infinity]; + } + // Range: "A - B" (hyphen / en / em dash, quotes optional on either side) + const parts = text.split(/\s*[-–—]\s*/); + if (parts.length === 2) { + const lo = parseInchToken(parts[0]); + const hi = parseInchToken(parts[1]); + if (lo != null && hi != null && hi > lo) return [lo, hi]; + } + // Single value band, treated as [v, v+ε) — rare + const only = parseInchToken(text); + if (only != null) return [only, only + 1e-9]; + return null; +} + +function boundsFromSlug(slug: string): [number, number] | null { + const s = slug.trim().toLowerCase(); + if (!s) return null; + if (s === 'xthin' || s === 'under' || s === 'thin') return [0, 0.5]; + const range = s.match(/^(\d+)-(\d+)qtr$/); + if (range) { + const lo = Number(range[1]) * 0.25; + const hi = Number(range[2]) * 0.25; + if (hi > lo) return [lo, hi]; + } + const min = s.match(/^(\d+)qtr$/); + if (min) return [Number(min[1]) * 0.25, Infinity]; + return null; +} + +/** + * Pick the synced thickness term id for `thicknessIn`, or null when unknown + * (no thickness, no parseable bands, negative value). + */ +export function deriveThicknessBandId( + thicknessIn: number | null | undefined, + terms: ThicknessTerm[], +): number | null { + if (thicknessIn == null) return null; + const t = Number(thicknessIn); + if (!Number.isFinite(t) || t < 0) return null; + + const scored: [number, number, number][] = []; + for (const term of terms) { + const b = termBounds(term); + if (!b) continue; + if (term.id == null) continue; + scored.push([b[0], b[1], Number(term.id)]); + } + if (!scored.length) return null; + + // Prefer the tightest band that contains t under half-open [low, high). + const matches = scored.filter(([lo, hi]) => lo <= t && t < hi).map(([lo, hi, id]) => [hi - lo, lo, hi, id] as const); + if (matches.length) { + matches.sort((a, z) => a[0] - z[0]); + return Number(matches[0][3]); + } + // Gaps in the taxonomy: pick the nearest band by lower edge. + scored.sort((a, z) => a[0] - z[0]); + for (const [lo, hi, id] of scored) { + if (t < hi) return id; + } + return Number(scored[scored.length - 1][2]); +} diff --git a/frontend/src/create/review/woodPrimary.test.ts b/frontend/src/review/woodPrimary.test.ts similarity index 99% rename from frontend/src/create/review/woodPrimary.test.ts rename to frontend/src/review/woodPrimary.test.ts index 1ef5cd2..88ec396 100644 --- a/frontend/src/create/review/woodPrimary.test.ts +++ b/frontend/src/review/woodPrimary.test.ts @@ -39,5 +39,4 @@ test('selectedSummary: shows names, then +N overflow', () => { test('selectedSummary: falls back to count when names unknown', () => { assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected'); -} -); +}); diff --git a/frontend/src/create/review/woodPrimary.ts b/frontend/src/review/woodPrimary.ts similarity index 79% rename from frontend/src/create/review/woodPrimary.ts rename to frontend/src/review/woodPrimary.ts index 7353bb5..6c009db 100644 --- a/frontend/src/create/review/woodPrimary.ts +++ b/frontend/src/review/woodPrimary.ts @@ -1,7 +1,7 @@ /** - * 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. + * REVIEW-15: the 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[], diff --git a/frontend/src/settings/SettingsPanel.svelte b/frontend/src/settings/SettingsPanel.svelte new file mode 100644 index 0000000..5f4f135 --- /dev/null +++ b/frontend/src/settings/SettingsPanel.svelte @@ -0,0 +1,191 @@ + + +
+

Settings (SETTINGS)

+ {#if !settings} +

{saveError || 'Loading…'}

+ {:else} +
+
+ Woo (SETTINGS-10/25/75) +

Base URL (env-only, SETTINGS-85): {settings.woo_base_url || 'not set'}

+ + + +
+ +
+ Inference (SETTINGS-30/45/90) + + + + + +
+ +
+ Store copy & crop + + + +
+
+ +
+ Taxonomy: {settings.taxonomy_last_sync_at ?? 'never synced'}{settings.taxonomy_anchor ? ` (anchor ${String(settings.taxonomy_anchor).slice(0, 8)}…)` : ''} +
+ +
+ + + + +
+ + {#if saveMsg}

{saveMsg}

{/if} + {#if saveError}

{saveError}

{/if} + {#if testMsg}

{testMsg}

{/if} + {#if testErr}

{testErr}

{/if} + + Secrets are write-only: GET shows a configured flag only (SETTINGS-75). Re-enter a secret to replace it. + {/if} +
+ + diff --git a/frontend/src/worker/mask.worker.ts b/frontend/src/worker/mask.worker.ts deleted file mode 100644 index b0bebe8..0000000 --- a/frontend/src/worker/mask.worker.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { minAreaRect, slabBounds } from '../geometry/rect'; -import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types'; -import { decodeOrientedBitmap } from '../decodeOrientedBitmap'; -import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep'; - -let bitmap: ImageBitmap | null = null; -let data: Uint8ClampedArray | null = null; -let w = 0, h = 0; -let current = 0; // highest job id issued by main; older replies are dropped - -function runMask(params: MaskParams, id: number): WorkerOut { - if (!data) return { id, kind: 'failed', reason: 'no source decoded' }; - let sheet = params.sheet; - if (sheet === 'auto') { - const samples: { r: number; g: number; b: number }[] = []; - for (let y = 0; y < h; y += 16) - for (let x = 0; x < w; x += 16) { - const i = (y * w + x) * 4; - samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] }); - } - sheet = autoPickSheet(samples); - } - const keep = sheet === 'green' ? greenKeep : blackKeep; - const s = Math.max(0, Math.min(100, params.sensitivity)); - const bitset = new Uint8Array(w * h); - let slabPx = 0; - for (let i = 0, p = 0; i < data.length; i += 4, p++) { - if (keep(data[i], data[i + 1], data[i + 2], s)) { - bitset[p] = 1; - slabPx++; - } - } - if (slabPx === 0) { - return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } }; - } - const bbox = minAreaRect(bitset, w, h); - if (!bbox) { - return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } }; - } - return { id, kind: 'ready', slabPx, bitset, bbox, slabBounds: slabBounds(bitset, w, h) }; -} - -self.onmessage = (e: MessageEvent) => { - const msg = e.data; - if (msg.id < current) return; // superseded by a newer job - current = msg.id; - if (msg.kind === 'init') { - if (bitmap) { - bitmap.close(); - bitmap = null; - } - data = null; - w = h = 0; - decodeOrientedBitmap(msg.blob) - .then((bmp) => { - w = bmp.width; - h = bmp.height; - const canvas = new OffscreenCanvas(w, h); - const ctx = canvas.getContext('2d')!; - ctx.drawImage(bmp, 0, 0); - data = ctx.getImageData(0, 0, w, h).data; - bitmap = bmp; - self.postMessage({ id: msg.id, kind: 'decoded', w, h } as WorkerOut); - }) - .catch((err) => self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut)); - return; - } - if (msg.kind === 'invalidate') { - if (bitmap) { - bitmap.close(); - bitmap = null; - } - data = null; - w = h = 0; - return; - } - try { - const out = runMask({ sheet: msg.sheet, sensitivity: msg.sensitivity }, msg.id); - if (out.kind === 'ready') { - self.postMessage(out, [out.bitset.buffer]); - } else { - self.postMessage(out); - } - } catch (err) { - self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut); - } -}; - -export {};