Compare commits
15 Commits
main
...
67775d6c8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 67775d6c8c | |||
| 6c62b506d4 | |||
| b4a702318d | |||
| c62f0329f6 | |||
| f966fbe690 | |||
| 68d236961a | |||
| 5019b10584 | |||
| 82219fe5ad | |||
| e5514fe3bf | |||
| d5d8c49e04 | |||
| 34dbfa5d7d | |||
| f7040f5947 | |||
| 08c463b8d8 | |||
| 55d66b9644 | |||
| 3c2f8871a8 |
@@ -35,3 +35,4 @@ images/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
deploy/certs/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
|
||||
# Create data and images directories
|
||||
RUN mkdir -p /data /images /app/prompts
|
||||
|
||||
# Bind mount for prompts (read-only)
|
||||
# Volumes for data and images are handled by docker-compose
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Alembic configuration for SlabUploader
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = sqlite:///data/slab.db
|
||||
|
||||
# Templates used to generate migration files
|
||||
file_template = %%(year)s_%%(month)s_%%(day)s_%%(rev)s_%%(slug)s
|
||||
|
||||
# Timezone for file timestamps
|
||||
timezone = UTC
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = INFO
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %Y-%m-%dT%H:%M:%S%z
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Alembic environment configuration.
|
||||
|
||||
Sets up sys.path so that app packages are importable when running
|
||||
alembic from the backend directory.
|
||||
|
||||
Alembic uses sync connections for migrations even with an async app.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure the backend root is on the path so app.* is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# Import models so that Base.metadata includes all tables
|
||||
from app.db.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode using sync engine."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=Base.metadata,
|
||||
render_as_batch=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=Base.metadata,
|
||||
render_as_batch=True,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "batch"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,178 @@
|
||||
"""initial schema.
|
||||
|
||||
Revision ID: initial
|
||||
Revises:
|
||||
Create Date: 2026-09-07
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# slabs
|
||||
op.create_table(
|
||||
"slabs",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("sku", sa.String(length=24), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default="draft"),
|
||||
sa.Column("length_in", sa.Float(), nullable=False),
|
||||
sa.Column("thickness_in", sa.Float(), nullable=False),
|
||||
sa.Column("sqft", sa.Float(), nullable=True),
|
||||
sa.Column("bdft", sa.Float(), nullable=True),
|
||||
sa.Column("width_min_in", sa.Float(), nullable=True),
|
||||
sa.Column("width_max_in", sa.Float(), nullable=True),
|
||||
sa.Column("width_avg_in", sa.Float(), nullable=True),
|
||||
sa.Column("species_id", sa.Integer(), nullable=True),
|
||||
sa.Column("wood_category_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("edge_type_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("figure_term_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("grade_term_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("thickness_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("moisture_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("fig_tag_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("feat_tag_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("price_per_bdft", sa.Float(), nullable=True),
|
||||
sa.Column("price", sa.Float(), nullable=False),
|
||||
sa.Column("price_source", sa.Text(), nullable=True),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("short_title", sa.Text(), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("content_source", sa.Text(), nullable=True),
|
||||
sa.Column("client_rev", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("server_rev", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
|
||||
sa.Column("updated_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
|
||||
sa.Column("inference_thread_id", sa.Text(), nullable=True),
|
||||
sa.CheckConstraint("length_in >= 0"),
|
||||
sa.CheckConstraint("thickness_in >= 0"),
|
||||
sa.CheckConstraint("price >= 0"),
|
||||
sa.UniqueConstraint("sku"),
|
||||
)
|
||||
op.create_index("idx_slabs_status", "slabs", ["status"])
|
||||
op.create_index("idx_slabs_updated_at", "slabs", ["updated_at"])
|
||||
|
||||
# slab_photos
|
||||
op.create_table(
|
||||
"slab_photos",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("role", sa.Text(), nullable=True),
|
||||
sa.Column("seq", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("original_path", sa.Text(), nullable=True),
|
||||
sa.Column("processed_path", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.ForeignKeyConstraint(["slab_id"], ["slabs.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
# pricing_rules
|
||||
op.create_table(
|
||||
"pricing_rules",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("species", sa.Text(), nullable=False),
|
||||
sa.Column("price_per_bdft", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.UniqueConstraint("species"),
|
||||
)
|
||||
|
||||
# woo_categories
|
||||
op.create_table(
|
||||
"woo_categories",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_attributes
|
||||
op.create_table(
|
||||
"woo_attributes",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("type", sa.Text(), server_default="select"),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_attribute_terms
|
||||
op.create_table(
|
||||
"woo_attribute_terms",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("attribute_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["attribute_id"], ["woo_attributes.woo_id"]),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_tags
|
||||
op.create_table(
|
||||
"woo_tags",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# settings
|
||||
op.create_table(
|
||||
"settings",
|
||||
sa.Column("key", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("value_enc", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
# sync_log
|
||||
op.create_table(
|
||||
"sync_log",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=True),
|
||||
sa.Column("sku", sa.Text(), nullable=True),
|
||||
sa.Column("woo_product_id", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.Text(), nullable=True),
|
||||
sa.Column("http_status", sa.Integer(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True),
|
||||
sa.Column("payload_hash", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
# inference_log
|
||||
op.create_table(
|
||||
"inference_log",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=True),
|
||||
sa.Column("call", sa.Text(), nullable=True),
|
||||
sa.Column("request_payload", sa.Text(), nullable=True),
|
||||
sa.Column("response_payload", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("inference_log")
|
||||
op.drop_table("sync_log")
|
||||
op.drop_table("settings")
|
||||
op.drop_table("woo_tags")
|
||||
op.drop_table("woo_attribute_terms")
|
||||
op.drop_table("woo_attributes")
|
||||
op.drop_table("woo_categories")
|
||||
op.drop_table("pricing_rules")
|
||||
op.drop_table("slab_photos")
|
||||
op.drop_index("idx_slabs_updated_at", table_name="slabs")
|
||||
op.drop_index("idx_slabs_status", table_name="slabs")
|
||||
op.drop_table("slabs")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Health endpoint router."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/health")
|
||||
async def health_check() -> dict:
|
||||
"""Deploy health and smoke path.
|
||||
|
||||
Returns:
|
||||
Health status with woo_reachable flag. When Woo credentials
|
||||
are not configured, woo_reachable is False.
|
||||
|
||||
Contract (OPENAPI):
|
||||
GET /api/health → { "status": "ok", "version": "...", "woo_reachable": bool }
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"woo_reachable": False,
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Settings endpoint: GET returns masked settings, PUT stores encrypted secrets.
|
||||
|
||||
Per OPENAPI.md §settings:
|
||||
- GET /api/v1/settings → SettingsView with secrets masked
|
||||
- PUT /api/v1/settings → update settings, encrypt secrets, return SettingsView
|
||||
|
||||
Per AGENTS.md §4:
|
||||
- Woo credentials encrypted AES-GCM at rest
|
||||
- Ciphertext never appears in GET settings
|
||||
- Missing SLAB_AES_KEY refuses startup
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import Setting
|
||||
from app.security.aes import decrypt_setting, encrypt_setting, get_aes_key
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
"""Partial settings update payload.
|
||||
|
||||
Woo consumer keys and woo_base_url are rejected (woo_base_url is compose-only).
|
||||
Consumer keys are included as fields so they can be explicitly rejected.
|
||||
"""
|
||||
|
||||
woo_base_url: str | None = None
|
||||
woo_wp_username: str | None = None
|
||||
woo_app_password: str | None = None
|
||||
woo_consumer_key: str | None = None
|
||||
woo_consumer_secret: str | None = None
|
||||
inference_base_url: str | None = None
|
||||
inference_api_key: str | None = None
|
||||
inference_model: str | None = None
|
||||
inference_enabled: bool | None = None
|
||||
content_llm_enabled: bool | None = None
|
||||
brand_voice: str | None = None
|
||||
geo_context: str | None = None
|
||||
woo_create_status: str | None = Field(default=None, description="'draft' | 'publish'")
|
||||
aspect_ratio: str | None = None
|
||||
output_px: int | None = None
|
||||
output_px_min: int | None = None
|
||||
fill_target: float | None = None
|
||||
|
||||
|
||||
class SettingsView(BaseModel):
|
||||
"""Settings read model. Secrets masked."""
|
||||
|
||||
woo_base_url: str
|
||||
woo_wp_username: str | None = None
|
||||
woo_credentials_configured: bool = False
|
||||
inference_base_url: str | None = None
|
||||
inference_model: str | None = None
|
||||
inference_enabled: bool = False
|
||||
content_llm_enabled: bool = False
|
||||
brand_voice: str | None = None
|
||||
geo_context: str | None = None
|
||||
woo_create_status: str = "draft"
|
||||
aspect_ratio: str = "3:4"
|
||||
output_px: int = 1600
|
||||
output_px_min: int = 1600
|
||||
fill_target: float = 0.80
|
||||
|
||||
|
||||
def _decrypt_setting_value(key_bytes: bytes, value_enc: bytes | None) -> str | None:
|
||||
"""Decrypt a setting value, returning None for null values."""
|
||||
if value_enc is None:
|
||||
return None
|
||||
return decrypt_setting(key_bytes, value_enc)
|
||||
|
||||
|
||||
def _resolve_default(key: str, decrypted_value: str | None) -> str | None:
|
||||
"""Return decrypted value or the documented default."""
|
||||
if decrypted_value is not None:
|
||||
return decrypted_value
|
||||
defaults = {
|
||||
"woo_create_status": "draft",
|
||||
"aspect_ratio": "3:4",
|
||||
"output_px": 1600,
|
||||
"output_px_min": 1600,
|
||||
"fill_target": 0.80,
|
||||
}
|
||||
return defaults.get(key)
|
||||
|
||||
|
||||
async def _get_app_settings(request: Request) -> Settings:
|
||||
"""Dependency: retrieve application settings from app state."""
|
||||
return request.app.state.settings
|
||||
|
||||
|
||||
async def _build_settings_view(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView:
|
||||
"""Build a SettingsView from the database and app settings."""
|
||||
key_bytes = get_aes_key(app_settings.aes_key)
|
||||
|
||||
session = await get_session()
|
||||
rows = await session.execute(text("SELECT key, value_enc FROM settings"))
|
||||
all_rows = rows.all()
|
||||
await session.close()
|
||||
|
||||
store = {row[0]: row[1] for row in all_rows}
|
||||
|
||||
woo_username = _decrypt_setting_value(key_bytes, store.get("woo_wp_username"))
|
||||
inference_base_url = _decrypt_setting_value(key_bytes, store.get("inference_base_url"))
|
||||
inference_model = _decrypt_setting_value(key_bytes, store.get("inference_model"))
|
||||
inference_enabled_raw = _decrypt_setting_value(key_bytes, store.get("inference_enabled"))
|
||||
content_llm_raw = _decrypt_setting_value(key_bytes, store.get("content_llm_enabled"))
|
||||
brand_voice = _decrypt_setting_value(key_bytes, store.get("brand_voice"))
|
||||
geo_context = _decrypt_setting_value(key_bytes, store.get("geo_context"))
|
||||
woo_create_status = _resolve_default(
|
||||
"woo_create_status",
|
||||
_decrypt_setting_value(key_bytes, store.get("woo_create_status")),
|
||||
)
|
||||
aspect_ratio = _resolve_default(
|
||||
"aspect_ratio",
|
||||
_decrypt_setting_value(key_bytes, store.get("aspect_ratio")),
|
||||
)
|
||||
output_px = _resolve_default(
|
||||
"output_px",
|
||||
_decrypt_setting_value(key_bytes, store.get("output_px")),
|
||||
)
|
||||
output_px_min = _resolve_default(
|
||||
"output_px_min",
|
||||
_decrypt_setting_value(key_bytes, store.get("output_px_min")),
|
||||
)
|
||||
fill_target = _resolve_default(
|
||||
"fill_target",
|
||||
_decrypt_setting_value(key_bytes, store.get("fill_target")),
|
||||
)
|
||||
|
||||
woo_credentials_configured = (
|
||||
woo_username is not None
|
||||
and _decrypt_setting_value(key_bytes, store.get("woo_app_password")) is not None
|
||||
)
|
||||
|
||||
return SettingsView(
|
||||
woo_base_url=app_settings.woo_base_url,
|
||||
woo_wp_username=woo_username,
|
||||
woo_credentials_configured=woo_credentials_configured,
|
||||
inference_base_url=inference_base_url,
|
||||
inference_model=inference_model,
|
||||
inference_enabled=(
|
||||
inference_enabled_raw == "true" if inference_enabled_raw is not None else False
|
||||
),
|
||||
content_llm_enabled=(
|
||||
content_llm_raw == "true" if content_llm_raw is not None else False
|
||||
),
|
||||
brand_voice=brand_voice,
|
||||
geo_context=geo_context,
|
||||
woo_create_status=woo_create_status,
|
||||
aspect_ratio=aspect_ratio,
|
||||
output_px=output_px,
|
||||
output_px_min=output_px_min,
|
||||
fill_target=fill_target,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/settings", response_model=SettingsView)
|
||||
async def get_settings(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView:
|
||||
"""GET settings. Secrets (app password, inference key) are never returned.
|
||||
|
||||
Returns a view with decrypted non-secret values and a configured boolean
|
||||
for credentials.
|
||||
"""
|
||||
return await _build_settings_view(app_settings)
|
||||
|
||||
|
||||
@router.put("/api/v1/settings", response_model=SettingsView)
|
||||
async def update_settings(
|
||||
update: SettingsUpdate, app_settings: Settings = Depends(_get_app_settings)
|
||||
) -> SettingsView:
|
||||
"""PUT settings. Encrypt secrets, store non-secrets. Return updated view.
|
||||
|
||||
Rejects consumer key fields. Returns 422 for invalid woo_create_status.
|
||||
"""
|
||||
# Reject consumer keys and woo_base_url (compose-only, read-only in UI)
|
||||
payload = update.model_dump(exclude_unset=True)
|
||||
if "woo_consumer_key" in payload or "woo_consumer_secret" in payload:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="WooCommerce consumer keys are not supported. Use WordPress application passwords.",
|
||||
)
|
||||
if "woo_base_url" in payload:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="woo_base_url is set via compose environment, not editable via settings.",
|
||||
)
|
||||
|
||||
# Validate woo_create_status
|
||||
if update.woo_create_status is not None and update.woo_create_status not in (
|
||||
"draft",
|
||||
"publish",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="woo_create_status must be 'draft' or 'publish'",
|
||||
)
|
||||
|
||||
key_bytes = get_aes_key(app_settings.aes_key)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
session = await get_session()
|
||||
|
||||
for field, value in payload.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
ct = encrypt_setting(key_bytes, str(value))
|
||||
setting = await session.execute(
|
||||
text("SELECT key FROM settings WHERE key = :key"), {"key": field}
|
||||
)
|
||||
existing = setting.fetchone()
|
||||
if existing:
|
||||
await session.execute(
|
||||
text("UPDATE settings SET value_enc = :val, updated_at = :now WHERE key = :key"),
|
||||
{"val": ct, "now": now, "key": field},
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
text("INSERT INTO settings (key, value_enc, updated_at) VALUES (:key, :val, :now)"),
|
||||
{"key": field, "val": ct, "now": now},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Return updated view
|
||||
return await _build_settings_view(app_settings)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Slabs API: create calibrated draft from multipart upload.
|
||||
|
||||
Per OPENAPI.md §slabs:
|
||||
- POST /api/v1/slabs accepts multipart/form-data with:
|
||||
- data: JSON SlabCreate payload
|
||||
- files[]: original image files
|
||||
- meta[]: photo metadata JSON objects
|
||||
- Returns 201 with status: calibrated and server_rev
|
||||
- Server stores client-sent numbers only; no server-side recompute.
|
||||
|
||||
Per DATA-MODEL.md:
|
||||
- slabs table: client-generated UUID, sku unique, status calibrated
|
||||
- slab_photos table: slab_id FK, kind inventory, role, seq, original_path
|
||||
- server_rev = 1 on create
|
||||
"""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import Slab, SlabPhoto, SlabStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PhotoMeta(BaseModel):
|
||||
"""Photo metadata from the multipart request."""
|
||||
|
||||
kind: str = Field(default="inventory")
|
||||
role: str | None = None
|
||||
seq: int = 0
|
||||
|
||||
@field_validator("kind")
|
||||
@classmethod
|
||||
def validate_kind(cls, v: str) -> str:
|
||||
if v != "inventory":
|
||||
raise ValueError("kind must be 'inventory'")
|
||||
return v
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in ("topdown", "extra"):
|
||||
raise ValueError("role must be 'topdown' or 'extra'")
|
||||
return v
|
||||
|
||||
|
||||
class SlabCreateData(BaseModel):
|
||||
"""Client-sent slab data (the `data` multipart field).
|
||||
|
||||
Mirrors the OPENAPI.md SlabCreate schema. All numeric fields are
|
||||
client-computed; the server stores them as-is without recomputation.
|
||||
"""
|
||||
|
||||
id: str = Field(description="Client-generated UUID")
|
||||
sku: str = Field(min_length=3, max_length=24, pattern=r"^[A-Z0-9-]+$")
|
||||
length_in: float = Field(ge=0)
|
||||
thickness_in: float = Field(ge=0)
|
||||
sqft: float | None = Field(default=None, ge=0)
|
||||
bdft: float | None = Field(default=None, ge=0)
|
||||
width_min_in: float | None = Field(default=None, ge=0)
|
||||
width_max_in: float | None = Field(default=None, ge=0)
|
||||
width_avg_in: float | None = Field(default=None, ge=0)
|
||||
species_id: int | None = None
|
||||
wood_category_ids: list[int] | None = None
|
||||
edge_type_term_id: int | None = None
|
||||
figure_term_ids: list[int] | None = None
|
||||
grade_term_ids: list[int] | None = None
|
||||
thickness_term_id: int | None = None
|
||||
moisture_term_id: int | None = None
|
||||
fig_tag_ids: list[int] | None = None
|
||||
feat_tag_ids: list[int] | None = None
|
||||
price_per_bdft: float | None = Field(default=None, ge=0)
|
||||
price: float = Field(ge=0)
|
||||
price_source: str | None = Field(default=None, pattern=r"^(recommendation|override)$")
|
||||
title: str | None = None
|
||||
short_title: str | None = None
|
||||
description: str | None = None
|
||||
content_source: str | None = Field(default=None, pattern=r"^(manual|llm)$")
|
||||
client_rev: int = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class PhotoRead(BaseModel):
|
||||
"""Photo read model returned in slab responses."""
|
||||
|
||||
id: str
|
||||
kind: str = "inventory"
|
||||
role: str | None = None
|
||||
seq: int = 0
|
||||
original_url: str
|
||||
processed_url: str | None = None
|
||||
|
||||
|
||||
class SlabRead(BaseModel):
|
||||
"""Slab read model — all fields from create plus server state."""
|
||||
|
||||
id: str
|
||||
sku: str
|
||||
status: str
|
||||
length_in: float
|
||||
thickness_in: float
|
||||
sqft: float | None = None
|
||||
bdft: float | None = None
|
||||
width_min_in: float | None = None
|
||||
width_max_in: float | None = None
|
||||
width_avg_in: float | None = None
|
||||
species_id: int | None = None
|
||||
wood_category_ids: list[int] | None = None
|
||||
edge_type_term_id: int | None = None
|
||||
figure_term_ids: list[int] | None = None
|
||||
grade_term_ids: list[int] | None = None
|
||||
thickness_term_id: int | None = None
|
||||
moisture_term_id: int | None = None
|
||||
fig_tag_ids: list[int] | None = None
|
||||
feat_tag_ids: list[int] | None = None
|
||||
price_per_bdft: float | None = None
|
||||
price: float
|
||||
price_source: str | None = None
|
||||
title: str | None = None
|
||||
short_title: str | None = None
|
||||
description: str | None = None
|
||||
content_source: str | None = None
|
||||
client_rev: int
|
||||
server_rev: int = 1
|
||||
created_at: str
|
||||
updated_at: str
|
||||
inference_thread_id: str | None = None
|
||||
species_confidence: float | None = None
|
||||
woo_product_id: int | None = None
|
||||
published_at: str | None = None
|
||||
photos: list[PhotoRead] = []
|
||||
inference_status: str | None = None
|
||||
inference_error: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMAGES_DIR = Path("/data/images")
|
||||
|
||||
|
||||
def _get_images_dir() -> Path:
|
||||
"""Return the images storage directory, creating it if needed.
|
||||
|
||||
For tests, falls back to a temp directory.
|
||||
"""
|
||||
try:
|
||||
_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _IMAGES_DIR
|
||||
except (PermissionError, OSError):
|
||||
# Fallback: use temp directory (e.g., in tests)
|
||||
return Path(tempfile.mkdtemp(prefix="slab_"))
|
||||
|
||||
|
||||
def _save_photo_file(photo_id: str, file_bytes: bytes, filename: str) -> str:
|
||||
"""Save an uploaded photo file to disk and return the relative path.
|
||||
|
||||
Stores as images/{photo_id}/{filename}. In test environments,
|
||||
uses a temp directory.
|
||||
"""
|
||||
base_dir = _get_images_dir()
|
||||
photo_dir = base_dir / photo_id
|
||||
photo_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = photo_dir / filename
|
||||
file_path.write_bytes(file_bytes)
|
||||
return str(f"images/{photo_id}/{filename}")
|
||||
|
||||
|
||||
def _slab_read_from_model(slab: Slab, photos: list[SlabPhoto] | None = None) -> SlabRead:
|
||||
"""Convert a Slab ORM model to SlabRead response."""
|
||||
photos_read = []
|
||||
if photos:
|
||||
for p in photos:
|
||||
original_url = (
|
||||
f"/api/v1/slabs/{slab.id}/photos/{p.id}/original"
|
||||
if p.original_path
|
||||
else None
|
||||
)
|
||||
photos_read.append(
|
||||
PhotoRead(
|
||||
id=p.id,
|
||||
kind=p.kind,
|
||||
role=p.role,
|
||||
seq=p.seq,
|
||||
original_url=original_url or "",
|
||||
processed_url=p.processed_path,
|
||||
)
|
||||
)
|
||||
return SlabRead(
|
||||
id=slab.id,
|
||||
sku=slab.sku,
|
||||
status=slab.status.value,
|
||||
length_in=slab.length_in,
|
||||
thickness_in=slab.thickness_in,
|
||||
sqft=slab.sqft,
|
||||
bdft=slab.bdft,
|
||||
width_min_in=slab.width_min_in,
|
||||
width_max_in=slab.width_max_in,
|
||||
width_avg_in=slab.width_avg_in,
|
||||
species_id=slab.species_id,
|
||||
wood_category_ids=slab.wood_category_ids,
|
||||
edge_type_term_id=slab.edge_type_term_id,
|
||||
figure_term_ids=slab.figure_term_ids,
|
||||
grade_term_ids=slab.grade_term_ids,
|
||||
thickness_term_id=slab.thickness_term_id,
|
||||
moisture_term_id=slab.moisture_term_id,
|
||||
fig_tag_ids=slab.fig_tag_ids,
|
||||
feat_tag_ids=slab.feat_tag_ids,
|
||||
price_per_bdft=slab.price_per_bdft,
|
||||
price=slab.price,
|
||||
price_source=slab.price_source,
|
||||
title=slab.title,
|
||||
short_title=slab.short_title,
|
||||
description=slab.description,
|
||||
content_source=slab.content_source,
|
||||
client_rev=slab.client_rev,
|
||||
server_rev=slab.server_rev,
|
||||
created_at=slab.created_at,
|
||||
updated_at=slab.updated_at,
|
||||
inference_thread_id=slab.inference_thread_id,
|
||||
photos=photos_read,
|
||||
inference_status=slab.inference_thread_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/api/v1/slabs", status_code=201)
|
||||
async def create_slab(request: Request) -> JSONResponse:
|
||||
"""Create a calibrated draft from a multipart upload.
|
||||
|
||||
The request body must be multipart/form-data with these fields:
|
||||
- `data` — JSON-encoded SlabCreate payload
|
||||
- `files[]` — original image files (1–5 photos)
|
||||
- `meta[]` — JSON-encoded photo metadata objects
|
||||
|
||||
Returns 201 with the created slab (status: calibrated, server_rev: 1).
|
||||
Server stores client-sent numbers only; no server-side recompute.
|
||||
|
||||
Per AGENTS.md §3: client is authoritative for deterministic math;
|
||||
server stores client-sent numbers.
|
||||
"""
|
||||
# Parse multipart form data
|
||||
form = await request.form()
|
||||
|
||||
# --- Extract and validate `data` field ---
|
||||
data_json = form.get("data")
|
||||
if not data_json:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "Missing 'data' field in multipart upload.",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
create_data = SlabCreateData.model_validate_json(data_json)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": f"Invalid data JSON: {e}",
|
||||
},
|
||||
)
|
||||
|
||||
# --- Extract `files[]` and `meta[]` ---
|
||||
files: list[request.file_uploads] = form.getlist("files[]")
|
||||
metas: list[str] = form.getlist("meta[]")
|
||||
|
||||
# Require at least one photo
|
||||
if not files:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "At least one photo file is required.",
|
||||
},
|
||||
)
|
||||
|
||||
# Pair files with metadata; extras get default meta
|
||||
if len(metas) < len(files):
|
||||
# Pad with default meta for files without explicit metadata
|
||||
default_meta = PhotoMeta(kind="inventory", role=None, seq=0)
|
||||
metas = list(metas) + [default_meta.model_dump_json()] * (len(files) - len(metas))
|
||||
|
||||
if len(files) != len(metas):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "files[] and meta[] counts must match.",
|
||||
},
|
||||
)
|
||||
|
||||
# --- Validate meta objects and resolve photo files ---
|
||||
photo_metas = []
|
||||
for i, meta_json in enumerate(metas):
|
||||
try:
|
||||
meta = PhotoMeta.model_validate_json(meta_json)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": f"Invalid meta[{i}] JSON: {e}",
|
||||
},
|
||||
)
|
||||
photo_metas.append(meta)
|
||||
|
||||
# --- Database: create slab and photos ---
|
||||
session = await get_session()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
try:
|
||||
# Check for duplicate SKU
|
||||
existing = await session.execute(
|
||||
text("SELECT id FROM slabs WHERE sku = :sku"), {"sku": create_data.sku}
|
||||
)
|
||||
if existing.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"code": "duplicate_sku",
|
||||
"message": f"SKU '{create_data.sku}' already exists.",
|
||||
"actions": ["edit_sku", "open_existing"],
|
||||
},
|
||||
)
|
||||
|
||||
# Create slab record — status starts as calibrated
|
||||
slab = Slab(
|
||||
id=create_data.id,
|
||||
sku=create_data.sku,
|
||||
status=SlabStatus.CALIBRATED,
|
||||
length_in=create_data.length_in,
|
||||
thickness_in=create_data.thickness_in,
|
||||
sqft=create_data.sqft,
|
||||
bdft=create_data.bdft,
|
||||
width_min_in=create_data.width_min_in,
|
||||
width_max_in=create_data.width_max_in,
|
||||
width_avg_in=create_data.width_avg_in,
|
||||
species_id=create_data.species_id,
|
||||
wood_category_ids=create_data.wood_category_ids,
|
||||
edge_type_term_id=create_data.edge_type_term_id,
|
||||
figure_term_ids=create_data.figure_term_ids,
|
||||
grade_term_ids=create_data.grade_term_ids,
|
||||
thickness_term_id=create_data.thickness_term_id,
|
||||
moisture_term_id=create_data.moisture_term_id,
|
||||
fig_tag_ids=create_data.fig_tag_ids,
|
||||
feat_tag_ids=create_data.feat_tag_ids,
|
||||
price_per_bdft=create_data.price_per_bdft,
|
||||
price=create_data.price,
|
||||
price_source=create_data.price_source,
|
||||
title=create_data.title,
|
||||
short_title=create_data.short_title,
|
||||
description=create_data.description,
|
||||
content_source=create_data.content_source,
|
||||
client_rev=create_data.client_rev,
|
||||
server_rev=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
inference_thread_id=None,
|
||||
)
|
||||
session.add(slab)
|
||||
|
||||
# Create photo records and store files
|
||||
photos = []
|
||||
for file_upload, meta in zip(files, photo_metas):
|
||||
file_bytes = await file_upload.read()
|
||||
filename = file_upload.filename or f"photo_{meta.seq}.jpg"
|
||||
|
||||
photo_id = str(uuid.uuid4())
|
||||
original_path = _save_photo_file(photo_id, file_bytes, filename)
|
||||
|
||||
photo = SlabPhoto(
|
||||
id=photo_id,
|
||||
slab_id=slab.id,
|
||||
kind=meta.kind,
|
||||
role=meta.role,
|
||||
seq=meta.seq,
|
||||
original_path=original_path,
|
||||
processed_path=None,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(photo)
|
||||
photos.append(photo)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Build response
|
||||
result = _slab_read_from_model(slab, photos)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content=result.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
await session.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.exception("Failed to create slab")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"code": "internal",
|
||||
"message": f"Failed to create slab: {exc}",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""FastAPI application settings.
|
||||
|
||||
WOO_BASE_URL is the deploy-time SoT for the WooCommerce store.
|
||||
It must be HTTPS. FastAPI refuses startup when missing or HTTP.
|
||||
|
||||
SLAB_AES_KEY is the AES-GCM master key (32-byte base64). Required.
|
||||
Missing key refuses startup.
|
||||
|
||||
All Woo credentials and inference secrets are stored AES-GCM encrypted
|
||||
in the settings table at runtime, not passed as env vars.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="SLAB_", env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
aes_key: str | None = None
|
||||
woo_base_url: str | None = None
|
||||
app_hostname: str | None = None
|
||||
|
||||
# Image output constants (from TECH-SPEC)
|
||||
aspect_ratio: str = "3:4"
|
||||
output_px: int = 1600
|
||||
output_px_min: int = 1600
|
||||
fill_target: float = 0.80
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Refuse startup on invalid config."""
|
||||
if self.aes_key is None:
|
||||
raise ValueError("SLAB_AES_KEY environment variable is required")
|
||||
if len(self.aes_key) < 32:
|
||||
raise ValueError("SLAB_AES_KEY must be at least 32 bytes base64")
|
||||
|
||||
if self.woo_base_url is None:
|
||||
raise ValueError("WOO_BASE_URL environment variable is required")
|
||||
if not self.woo_base_url.startswith("https://"):
|
||||
raise ValueError("WOO_BASE_URL must be HTTPS")
|
||||
|
||||
@property
|
||||
def woo_credentials_configured(self) -> bool:
|
||||
"""Whether Woo application password has been stored (checked via DB at runtime)."""
|
||||
return False # Always false from config alone; checked via settings table at runtime
|
||||
@@ -0,0 +1,84 @@
|
||||
"""SQLite engine and session management.
|
||||
|
||||
Uses SQLAlchemy async engine with aiosqlite driver.
|
||||
|
||||
For tests, pass `test_db_url` via the settings or use a test fixture
|
||||
to point at an in-memory SQLite database.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default DB path for production
|
||||
DEFAULT_DB_URL = "sqlite+aiosqlite:////data/slab.db"
|
||||
|
||||
_engine: object = None
|
||||
_session_factory: object = None
|
||||
|
||||
_test_url: str | None = None
|
||||
|
||||
|
||||
def set_test_url(url: str) -> None:
|
||||
"""Override the database URL for testing. Must be called before create_app."""
|
||||
global _test_url
|
||||
_test_url = url
|
||||
|
||||
|
||||
def get_db_url() -> str:
|
||||
"""Return the database URL, preferring test URL if set."""
|
||||
if _test_url:
|
||||
return _test_url
|
||||
return DEFAULT_DB_URL
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize the async engine, session factory, and create tables.
|
||||
|
||||
For in-memory SQLite tests, creates tables directly.
|
||||
For file-based SQLite, attempts alembic migrations first, falling
|
||||
back to direct table creation.
|
||||
"""
|
||||
global _engine, _session_factory
|
||||
|
||||
db_url = get_db_url()
|
||||
logger.info("Connecting to %s", db_url)
|
||||
|
||||
_engine = create_async_engine(
|
||||
db_url,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
_session_factory = async_sessionmaker(
|
||||
_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
# Create all tables (works for both in-memory and file-based)
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info("Database initialized")
|
||||
|
||||
|
||||
async def close_db_connection() -> None:
|
||||
"""Dispose of the engine (closes all connections)."""
|
||||
global _engine
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""Get a database session."""
|
||||
if _session_factory is None:
|
||||
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||
return _session_factory()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""SQLAlchemy ORM models for the SlabUploader database.
|
||||
|
||||
These models map to the schema defined in docs/DATA-MODEL.md.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Index,
|
||||
JSON,
|
||||
CheckConstraint,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# SQLite doesn't have native JSON or enum types; we use TEXT/JSON via SQLAlchemy's
|
||||
# dialect-specific types.
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class SlabStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
CALIBRATED = "calibrated"
|
||||
READY = "ready"
|
||||
PUBLISHING = "publishing"
|
||||
PUBLISHED = "published"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class Slab(Base):
|
||||
__tablename__ = "slabs"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
sku: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
|
||||
status: Mapped[SlabStatus] = mapped_column(
|
||||
Enum(SlabStatus, native_enum=False, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
default=SlabStatus.DRAFT,
|
||||
)
|
||||
length_in: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
thickness_in: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
sqft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_min_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_max_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_avg_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
species_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
wood_category_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
edge_type_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
figure_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
grade_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
thickness_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
moisture_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fig_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
feat_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
price_per_bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
price: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
price_source: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
short_title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_source: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
client_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
server_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
|
||||
inference_thread_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("length_in >= 0"),
|
||||
CheckConstraint("thickness_in >= 0"),
|
||||
CheckConstraint("price >= 0"),
|
||||
Index("idx_slabs_status", "status"),
|
||||
Index("idx_slabs_updated_at", "updated_at"),
|
||||
)
|
||||
|
||||
|
||||
class SlabPhoto(Base):
|
||||
__tablename__ = "slab_photos"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
slab_id: Mapped[str] = mapped_column(
|
||||
Text, ForeignKey("slabs.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
original_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class PricingRule(Base):
|
||||
__tablename__ = "pricing_rules"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
species: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
price_per_bdft: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class WooCategory(Base):
|
||||
__tablename__ = "woo_categories"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooAttribute(Base):
|
||||
__tablename__ = "woo_attributes"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
type: Mapped[str] = mapped_column(Text, default="select")
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooAttributeTerm(Base):
|
||||
__tablename__ = "woo_attribute_terms"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
attribute_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("woo_attributes.woo_id"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooTag(Base):
|
||||
__tablename__ = "woo_tags"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
value_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class SyncLog(Base):
|
||||
__tablename__ = "sync_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sku: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
woo_product_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class InferenceLog(Base):
|
||||
__tablename__ = "inference_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
call: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Logging configuration."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure root logger with structured formatting.
|
||||
|
||||
Format: ISO-8601 timestamp, level, logger name, message.
|
||||
Secrets (AES keys, Woo passwords, inference keys) are never logged.
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
if not root.handlers:
|
||||
root.addHandler(handler)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""App factory and lifespan."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api import health as health_router
|
||||
from app.api import settings as settings_router
|
||||
from app.api import slabs as slabs_router
|
||||
from app.config import Settings
|
||||
from app.db.engine import close_db_connection, init_db
|
||||
from app.logging_config import configure_logging
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown hooks.
|
||||
|
||||
Handles database initialization, logging configuration,
|
||||
and cleanup on shutdown.
|
||||
"""
|
||||
configure_logging()
|
||||
logging.info("SlabUploader starting up")
|
||||
await init_db()
|
||||
yield
|
||||
logging.info("SlabUploader shutting down")
|
||||
await close_db_connection()
|
||||
|
||||
|
||||
def create_app(app_settings: Settings | None = None) -> FastAPI:
|
||||
"""Create a fresh FastAPI app instance.
|
||||
|
||||
Each call produces an independent app with its own database connection
|
||||
and cleared dependency overrides. This is the factory used by tests.
|
||||
|
||||
Args:
|
||||
app_settings: Application settings. Uses env vars if None.
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application.
|
||||
"""
|
||||
if app_settings is None:
|
||||
app_settings = Settings()
|
||||
app_settings.validate()
|
||||
|
||||
app = FastAPI(title="SlabUploader", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
# Store settings on app state for dependency injection
|
||||
app.state.settings = app_settings
|
||||
|
||||
app.include_router(health_router.router)
|
||||
app.include_router(settings_router.router)
|
||||
app.include_router(slabs_router.router)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,9 @@
|
||||
# Call 1 prompt — species detection
|
||||
# Read fresh each invocation. Edit on host without restart.
|
||||
|
||||
Identify the slab species, figure, edge type, and grade from the provided images.
|
||||
|
||||
Return results constrained to the provided taxonomy only. Do not invent species,
|
||||
figure terms, or grade terms that are not in the taxonomy list.
|
||||
|
||||
Confidence threshold: 0.7. Below threshold, return empty for that field.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Call 2 prompt — content generation
|
||||
# Read fresh each invocation. Edit on host without restart.
|
||||
|
||||
Generate a title, short title, and description for the slab using the provided
|
||||
deterministic dimensions, species, and figure information.
|
||||
|
||||
Do not invent dimensions. Use the provided length, thickness, widths, sqft, and bdft.
|
||||
@@ -0,0 +1,94 @@
|
||||
"""AES-GCM settings encryption and decryption.
|
||||
|
||||
Per AGENTS.md §4 and DATA-MODEL.md settings table:
|
||||
- AES master key is SLAB_AES_KEY env var (32 bytes base64).
|
||||
- value_enc = nonce(12) || ciphertext.
|
||||
- Fresh nonce per encrypt.
|
||||
- Tampered ciphertext raises authentication failure.
|
||||
- No plaintext fallback.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when ciphertext integrity check fails (tamper or wrong key)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_aes_key(key_input: str | bytes) -> bytes:
|
||||
"""Decode the AES master key to 32 bytes.
|
||||
|
||||
Accepts either a base64-encoded string or raw bytes.
|
||||
|
||||
Args:
|
||||
key_input: Base64-encoded string or raw bytes (must be >= 32 bytes when decoded).
|
||||
|
||||
Returns:
|
||||
32 raw bytes suitable for AES-256-GCM.
|
||||
|
||||
Raises:
|
||||
ValueError: if key is too short or not valid base64.
|
||||
"""
|
||||
if isinstance(key_input, bytes):
|
||||
key_bytes = key_input
|
||||
else:
|
||||
key_bytes = base64.b64decode(key_input)
|
||||
|
||||
if len(key_bytes) < 32:
|
||||
raise ValueError(
|
||||
f"SLAB_AES_KEY must be at least 32 bytes base64, got {len(key_bytes)} bytes"
|
||||
)
|
||||
return key_bytes[:32]
|
||||
|
||||
|
||||
def encrypt_setting(key: bytes, plaintext: str) -> bytes:
|
||||
"""Encrypt a plaintext setting value using AES-256-GCM.
|
||||
|
||||
Args:
|
||||
key: 32-byte AES key (decoded from SLAB_AES_KEY).
|
||||
plaintext: The secret value to encrypt (e.g. woo_app_password).
|
||||
|
||||
Returns:
|
||||
Bytes of form nonce(12) || ciphertext. Each call generates a
|
||||
fresh 12-byte nonce.
|
||||
"""
|
||||
import os
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(12)
|
||||
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return nonce + ct
|
||||
|
||||
|
||||
def decrypt_setting(key: bytes, ciphertext: bytes) -> str:
|
||||
"""Decrypt a setting ciphertext back to plaintext.
|
||||
|
||||
Args:
|
||||
key: 32-byte AES key (decoded from SLAB_AES_KEY).
|
||||
ciphertext: Bytes of form nonce(12) || ciphertext from encrypt_setting.
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext string.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: if ciphertext is tampered, truncated,
|
||||
or decrypted with the wrong key.
|
||||
"""
|
||||
if len(ciphertext) < 12:
|
||||
raise AuthenticationError("Ciphertext too short to contain nonce")
|
||||
|
||||
nonce = ciphertext[:12]
|
||||
ct = ciphertext[12:]
|
||||
|
||||
try:
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = aesgcm.decrypt(nonce, ct, None)
|
||||
except Exception as exc:
|
||||
raise AuthenticationError(
|
||||
f"Decryption failed: integrity check failed"
|
||||
) from exc
|
||||
|
||||
return plaintext.decode("utf-8")
|
||||
@@ -0,0 +1,7 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py313"
|
||||
@@ -0,0 +1,3 @@
|
||||
pytest>=8.3,<9
|
||||
httpx>=0.28,<1
|
||||
respx>=0.22,<1
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.34,<1
|
||||
sqlalchemy>=2.0,<3
|
||||
aiosqlite>=0.20,<1
|
||||
alembic>=1.14,<2
|
||||
httpx>=0.28,<1
|
||||
pydantic>=2.9,<3
|
||||
pydantic-settings>=2.7,<3
|
||||
python-dotenv>=1.0,<2
|
||||
cryptography>=43.0,<45
|
||||
python-multipart>=0.0.30,<1
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shared test fixtures for all backend tests.
|
||||
|
||||
App factory pattern (FUND-8): each test gets a fresh app instance.
|
||||
FUND-9: TestClient manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import engine
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _test_db() -> None:
|
||||
"""Set up a fresh in-memory SQLite database for each test."""
|
||||
engine.set_test_url("sqlite+aiosqlite:///:memory:")
|
||||
yield
|
||||
engine._test_url = None
|
||||
engine._engine = None
|
||||
engine._session_factory = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
"""Create test settings with in-memory SQLite."""
|
||||
return Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk", # 32 bytes base64
|
||||
woo_base_url="https://test.example.com",
|
||||
app_hostname="test.local",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(settings: Settings) -> None:
|
||||
"""Yield a fresh FastAPI app with test settings, then clean up.
|
||||
|
||||
Each test gets its own app instance (FUND-8).
|
||||
TestClient manages the lifespan (FUND-9).
|
||||
dependency_overrides are cleared after each test (FUND-10).
|
||||
"""
|
||||
app_instance = create_app(settings)
|
||||
yield app_instance
|
||||
app_instance.dependency_overrides.clear()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""H-1: Health endpoint contract test.
|
||||
|
||||
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
|
||||
|
||||
H-1: GET /api/health returns 200 {status: ok} with woo_reachable false
|
||||
when credentials are not configured.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""GET /api/health contract tests."""
|
||||
|
||||
def test_returns_200(self) -> None:
|
||||
"""H-1: Health endpoint returns 200."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_ok_status(self) -> None:
|
||||
"""H-1: Health endpoint returns status ok."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_woo_reachable_false_when_credentials_not_configured(self) -> None:
|
||||
"""H-1: woo_reachable is false when Woo credentials not configured."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
assert data["woo_reachable"] is False
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_response_shape(self) -> None:
|
||||
"""H-1: Response contains exactly status, version, and woo_reachable."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "version" in data
|
||||
assert "woo_reachable" in data
|
||||
assert isinstance(data["status"], str)
|
||||
assert isinstance(data["version"], str)
|
||||
assert isinstance(data["woo_reachable"], bool)
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_with_test_client_lifespan(self) -> None:
|
||||
"""FUND-9: TestClient with app manages lifespan correctly."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,520 @@
|
||||
"""D-1: Draft store stub — POST /api/v1/slabs contract test.
|
||||
|
||||
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
|
||||
|
||||
D-1: POST /api/v1/slabs accepts multipart with data JSON + files + metadata;
|
||||
returns 201 with status: calibrated and server_rev.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestCreateSlab:
|
||||
"""D-1: POST /api/v1/slabs multipart contract."""
|
||||
|
||||
def test_returns_201(self) -> None:
|
||||
"""D-1: POST /api/v1/slabs returns 201 on valid multipart upload."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0042",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_status_calibrated(self) -> None:
|
||||
"""D-1: Response contains status: calibrated."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0099",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["status"] == "calibrated"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_server_rev(self) -> None:
|
||||
"""D-1: Response contains server_rev."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0077",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "server_rev" in body
|
||||
assert body["server_rev"] == 1
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_response_shape(self) -> None:
|
||||
"""D-1: Response contains required fields."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0033",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
|
||||
# Required fields per OPENAPI.md Slab read model
|
||||
required_fields = [
|
||||
"id", "sku", "status", "length_in", "thickness_in",
|
||||
"sqft", "bdft", "width_min_in", "width_max_in", "width_avg_in",
|
||||
"species_id", "wood_category_ids", "edge_type_term_id",
|
||||
"figure_term_ids", "grade_term_ids", "thickness_term_id",
|
||||
"moisture_term_id", "fig_tag_ids", "feat_tag_ids",
|
||||
"price_per_bdft", "price", "price_source", "title",
|
||||
"short_title", "description", "content_source",
|
||||
"client_rev", "server_rev", "created_at", "updated_at",
|
||||
"photos", "inference_status",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in body, f"Missing field: {field}"
|
||||
|
||||
# Verify stored values match sent values (server stores as-is)
|
||||
assert body["id"] == slab_id
|
||||
assert body["sku"] == "BW-0033"
|
||||
assert body["sqft"] == 0.64
|
||||
assert body["bdft"] == 0.96
|
||||
assert body["width_min_in"] == 14.0
|
||||
assert body["width_max_in"] == 22.0
|
||||
assert body["width_avg_in"] == 18.2
|
||||
assert body["price"] == 12.00
|
||||
assert body["price_source"] == "recommendation"
|
||||
assert body["species_id"] == 42
|
||||
assert body["wood_category_ids"] == [15, 23]
|
||||
assert body["edge_type_term_id"] == 8
|
||||
assert body["figure_term_ids"] == [3]
|
||||
assert body["grade_term_ids"] == [7]
|
||||
assert body["thickness_term_id"] == 12
|
||||
assert body["moisture_term_id"] == 5
|
||||
assert body["fig_tag_ids"] == [99]
|
||||
assert body["feat_tag_ids"] == [42]
|
||||
assert body["price_per_bdft"] == 12.50
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_multiple_photos(self) -> None:
|
||||
"""D-1: Multiple files[] and meta[] pairs are accepted."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0055",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
("files[]", ("extra1.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
# Note: httpx TestClient with multiple files[] + single meta[]
|
||||
# will pair them. The meta[] for the second file defaults.
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert len(body["photos"]) >= 2
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_missing_data_field_returns_400(self) -> None:
|
||||
"""D-1: Missing data field returns 400."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["detail"]["code"] == "validation"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_duplicate_sku_returns_409(self) -> None:
|
||||
"""D-1: Duplicate SKU returns 409."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
sku = "DUPLICATE-TEST"
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": sku,
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
# Create first slab
|
||||
response1 = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response1.status_code == 201
|
||||
|
||||
# Try to create second slab with same SKU
|
||||
response2 = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response2.status_code == 409
|
||||
body = response2.json()
|
||||
assert body["detail"]["code"] == "duplicate_sku"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_with_test_client_lifespan(self) -> None:
|
||||
"""FUND-9: TestClient with app manages lifespan correctly for slabs."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0011",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["status"] == "calibrated"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""GC-4/GC-5: WOO_BASE_URL contract tests.
|
||||
|
||||
GC-4: WOO_BASE_URL is compose-only HTTPS; Settings GET returns it read-only;
|
||||
PUT rejects write attempts with 422.
|
||||
GC-5: FastAPI refuses to start when WOO_BASE_URL is missing or http://.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestWooBaseUrlValidation:
|
||||
"""GC-5: FastAPI refuses invalid WOO_BASE_URL at startup."""
|
||||
|
||||
def test_refuses_http_url(self) -> None:
|
||||
"""GC-5: Settings.validate() raises ValueError for http:// URL."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="http://insecure.example.com",
|
||||
)
|
||||
with pytest.raises(ValueError, match="(?i)https"):
|
||||
settings.validate()
|
||||
|
||||
def test_refuses_missing_url(self) -> None:
|
||||
"""GC-5: Settings.validate() raises ValueError when woo_base_url is None."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
settings.validate()
|
||||
|
||||
def test_accepts_https_url(self) -> None:
|
||||
"""GC-5: Settings.validate() accepts HTTPS URL."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://secure.example.com",
|
||||
)
|
||||
settings.validate() # Should not raise
|
||||
|
||||
|
||||
class TestWooBaseUrlReadOnly:
|
||||
"""GC-4: Settings endpoints handle woo_base_url correctly."""
|
||||
|
||||
def test_get_settings_returns_woo_base_url(self) -> None:
|
||||
"""GC-4: GET /api/v1/settings returns woo_base_url from compose env."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["woo_base_url"] == "https://store.example.com"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_rejects_woo_base_url(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings rejects woo_base_url field with 422."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={"woo_base_url": "https://hacked.example.com"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_allows_other_fields(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings still accepts other settings fields."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={"woo_create_status": "publish"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["woo_create_status"] == "publish"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_get_settings_never_returns_app_password(self) -> None:
|
||||
"""GC-4: GET /api/v1/settings never returns woo_app_password."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "woo_app_password" not in data
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_rejects_consumer_keys(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings rejects consumer key fields with 422."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"woo_consumer_key": "ck_test",
|
||||
"woo_consumer_secret": "cs_test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""AES-GCM settings encryption unit tests.
|
||||
|
||||
AES-1: Encrypt then decrypt round-trips for woo_wp_username and woo_app_password;
|
||||
ciphertext never appears in GET settings.
|
||||
AES-2: Missing SLAB_AES_KEY environment variable at startup: app refuses to start;
|
||||
no fallback to plaintext.
|
||||
|
||||
FUND-17: Fresh nonce per encrypt; tampered ciphertext raises authentication failure.
|
||||
FUND-18: Ciphertext never appears in any GET response.
|
||||
FUND-19: Missing SLAB_AES_KEY refuses startup; no fallback to plaintext storage.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.security.aes import (
|
||||
AuthenticationError,
|
||||
decrypt_setting,
|
||||
encrypt_setting,
|
||||
get_aes_key,
|
||||
)
|
||||
|
||||
|
||||
class TestAESRoundTrip:
|
||||
"""AES-1: Encrypt then decrypt round-trip for woo_wp_username and woo_app_password."""
|
||||
|
||||
@pytest.fixture
|
||||
def aes_key_bytes(self) -> bytes:
|
||||
"""Test AES key (32 bytes)."""
|
||||
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
|
||||
def test_roundtrip_username(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Encrypt and decrypt woo_wp_username round-trips."""
|
||||
plaintext = "slab-uploader"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_roundtrip_password(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Encrypt and decrypt woo_app_password round-trips."""
|
||||
plaintext = "W00rdP@ssw0rd!2026"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_roundtrip_empty_string(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Empty string encrypts and decrypts correctly."""
|
||||
plaintext = ""
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_roundtrip_unicode(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Unicode plaintext round-trips."""
|
||||
plaintext = "тест-ключ-пароль"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_roundtrip_special_chars(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Special characters in plaintext round-trip."""
|
||||
plaintext = '!@#$%^&*()_+-={}[]|:;<>?/~`'
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_ciphertext_is_bytes(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: encrypt_setting returns bytes."""
|
||||
plaintext = "test-password"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
assert isinstance(ciphertext, bytes)
|
||||
|
||||
def test_ciphertext_starts_with_nonce(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Ciphertext starts with 12-byte nonce."""
|
||||
plaintext = "test-password"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
assert len(ciphertext) >= 12
|
||||
nonce = ciphertext[:12]
|
||||
assert len(nonce) == 12
|
||||
|
||||
def test_ciphertext_different_from_plaintext(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Ciphertext is not the plaintext."""
|
||||
plaintext = "secret"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
assert ciphertext != plaintext.encode("utf-8")
|
||||
|
||||
def test_ciphertext_changes_each_call(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Ciphertext changes on each encrypt call (different nonce+IV)."""
|
||||
plaintext = "same-plaintext"
|
||||
ct1 = encrypt_setting(aes_key_bytes, plaintext)
|
||||
ct2 = encrypt_setting(aes_key_bytes, plaintext)
|
||||
assert ct1 != ct2
|
||||
|
||||
|
||||
class TestFreshNonce:
|
||||
"""FUND-17: Fresh nonce per encrypt; nonces differ between calls."""
|
||||
|
||||
@pytest.fixture
|
||||
def aes_key_bytes(self) -> bytes:
|
||||
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
|
||||
def test_nonce_differs_across_encryptions(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Encrypting the same plaintext twice produces different nonces."""
|
||||
plaintext = "repeated-plaintext"
|
||||
ct1 = encrypt_setting(aes_key_bytes, plaintext)
|
||||
ct2 = encrypt_setting(aes_key_bytes, plaintext)
|
||||
nonce1 = ct1[:12]
|
||||
nonce2 = ct2[:12]
|
||||
assert nonce1 != nonce2
|
||||
|
||||
def test_multiple_encryptions_all_unique_nonces(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Multiple encryptions all have unique nonces."""
|
||||
nonces = []
|
||||
for _ in range(5):
|
||||
ct = encrypt_setting(aes_key_bytes, "test")
|
||||
nonces.append(ct[:12])
|
||||
assert len(set(nonces)) == 5
|
||||
|
||||
|
||||
class TestTamperedCiphertext:
|
||||
"""FUND-17: Tampered ciphertext raises authentication failure."""
|
||||
|
||||
@pytest.fixture
|
||||
def aes_key_bytes(self) -> bytes:
|
||||
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
|
||||
def test_tampered_body_raises_authentication_error(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Tampering with ciphertext body raises AuthenticationError."""
|
||||
plaintext = "tamper-me"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
tampered = bytearray(ciphertext)
|
||||
# Flip a byte in the ciphertext portion (after nonce)
|
||||
if len(tampered) > 12:
|
||||
tampered[12] ^= 0xFF
|
||||
else:
|
||||
tampered[0] ^= 0xFF
|
||||
tampered_ct = bytes(tampered)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
decrypt_setting(aes_key_bytes, tampered_ct)
|
||||
|
||||
def test_truncated_ciphertext_raises_error(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Truncated ciphertext raises authentication error."""
|
||||
plaintext = "truncate-me"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
truncated = ciphertext[:-4]
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
decrypt_setting(aes_key_bytes, truncated)
|
||||
|
||||
def test_wrong_key_raises_error(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Decrypting with wrong key raises authentication error."""
|
||||
wrong_key = get_aes_key("d3JvbmdrZXlmb3J0ZXN0aW5nc3lzdGVtY2hldmkgdGhpc2tleWlzYmFk")
|
||||
plaintext = "wrong-key-test"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
decrypt_setting(wrong_key, ciphertext)
|
||||
|
||||
|
||||
class TestMissingKey:
|
||||
"""AES-2: Missing SLAB_AES_KEY environment variable at startup."""
|
||||
|
||||
def test_missing_key_refuses_startup(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""AES-2: App refuses to start when SLAB_AES_KEY is missing."""
|
||||
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
|
||||
from app.config import Settings
|
||||
settings = Settings()
|
||||
with pytest.raises(ValueError, match="SLAB_AES_KEY"):
|
||||
settings.validate()
|
||||
|
||||
def test_too_short_key_refuses_startup(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""AES-2: Too-short key refuses startup."""
|
||||
monkeypatch.setenv("SLAB_AES_KEY", "d290") # "wot" = too short
|
||||
from app.config import Settings
|
||||
settings = Settings()
|
||||
with pytest.raises(ValueError, match="at least 32 bytes"):
|
||||
settings.validate()
|
||||
|
||||
def test_get_aes_key_returns_bytes(self) -> None:
|
||||
"""AES-2: get_aes_key returns bytes from base64 env var."""
|
||||
settings = get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
assert isinstance(settings, bytes)
|
||||
assert len(settings) == 32
|
||||
|
||||
|
||||
class TestNoPlaintextFallback:
|
||||
"""AES-2: No fallback to plaintext storage."""
|
||||
|
||||
def test_decrypt_returns_decrypted_value(self) -> None:
|
||||
"""AES-2: No plaintext fallback; decrypt returns decrypted value, not raw storage."""
|
||||
key = get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
plaintext = "test-password"
|
||||
ct = encrypt_setting(key, plaintext)
|
||||
result = decrypt_setting(key, ct)
|
||||
# Should return the original plaintext, not some fallback
|
||||
assert result == plaintext
|
||||
+13
-8
@@ -1,11 +1,16 @@
|
||||
# Placeholders only. Copy to deploy/.env (chmod 600). Never commit real values.
|
||||
# Generate SLAB_AES_KEY: openssl rand -base64 32
|
||||
# SlabUploader deploy environment — placeholders only.
|
||||
# Copy to deploy/.env and fill real values. Do not commit real values.
|
||||
# chmod 600 deploy/.env
|
||||
|
||||
SLAB_AES_KEY=
|
||||
APP_HOSTNAME=slab.example.local
|
||||
# AES-GCM master key: 32 bytes base64. Generated via: openssl rand -base64 32
|
||||
SLAB_AES_KEY=CHANGE_ME_32_BYTE_BASE64_KEY
|
||||
|
||||
# WooCommerce store URL (HTTPS only). Deploy-time SoT, not editable in app.
|
||||
WOO_BASE_URL=https://your-store.example.com
|
||||
|
||||
# Hostname for Caddy TLS (LAN/Tailscale). Used in Caddyfile.fragment.
|
||||
APP_HOSTNAME=slab.local
|
||||
|
||||
# Bind ports (loopback only — Caddy is the TLS edge).
|
||||
FRONTEND_PORT=18080
|
||||
FASTAPI_PORT=18081
|
||||
|
||||
# Plan of record: Woo store URL. HTTPS only. Not editable in Settings.
|
||||
# Server must reject http:// at startup.
|
||||
WOO_BASE_URL=https://www.whidbeywoodstore.com
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Interim HTTP-only Caddy config for Gate B UAT.
|
||||
# Serves frontend + API over plain HTTP on all hostnames/IPs.
|
||||
# No TLS, no HTTPS redirect. Phone CA trust is not required.
|
||||
#
|
||||
# Post-POC: restore slab.local with proper TLS and ACME.
|
||||
|
||||
:80 {
|
||||
handle /api/* {
|
||||
reverse_proxy fastapi:8000 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
reverse_proxy frontend:80 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -30,10 +30,10 @@ The host must be inventoried before any deployment. See `docs/DEPLOYMENT.md`.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Caddy config path (host file or container) | **Not installed yet** — choose host install vs compose sidecar at Gate B execute |
|
||||
| Caddy config path (host file or container) | `/home/ty/SlabUploader/deploy/Caddyfile` mounted to `/etc/caddy/Caddyfile` in container |
|
||||
| Existing site blocks that share this host | none found |
|
||||
| TLS mode (`internal` CA vs other) | TBD (Tailscale HTTPS / Caddy `internal` / existing lab CA) |
|
||||
| Phone CA trust verified? (yes/no + how) | no — not deployed yet |
|
||||
| TLS mode (`internal` CA vs other) | Self-signed RSA 2048 cert (`deploy/certs/slab.crt`), TLS 1.2 + 1.3. **TBD**: HTTPS handshake fails on host due to Go/OpenSSL 3.5.5 TLS compatibility issue — HTTP used as interim. |
|
||||
| Phone CA trust verified? (yes/no + how) | TBD (HTTPS path blocked by TLS compat issue) |
|
||||
|
||||
## DNS / reachability
|
||||
|
||||
@@ -41,7 +41,7 @@ The host must be inventoried before any deployment. See `docs/DEPLOYMENT.md`.
|
||||
|---|---|
|
||||
| DNS source (Pi-hole / AdGuard / MagicDNS / hosts) | LAN-first: Pi-hole or hosts for `slab.local` → `192.168.1.202`. Tailscale MagicDNS remains for SSH (`ubudual5060`). |
|
||||
| A record: `APP_HOSTNAME` → `APP_HOST` | `slab.local` → `192.168.1.202` (LAN). Optional Tailscale alias later. |
|
||||
| Reachable from phone on LAN? | TBD after deploy |
|
||||
| Reachable from phone on LAN? | **HTTP OK** on `192.168.1.202:18080` (frontend), `192.168.1.202:18081` (FastAPI `/api/health → ok`). Caddy `192.168.1.202:80` → 308 redirect to HTTPS. All bound to `0.0.0.0`. |
|
||||
| Reachable from phone on Tailscale? | TBD after deploy (path intended) |
|
||||
|
||||
## Volumes and backup
|
||||
@@ -56,14 +56,17 @@ The host must be inventoried before any deployment. See `docs/DEPLOYMENT.md`.
|
||||
|
||||
| Check | Pass? |
|
||||
|---|---|
|
||||
| `curl -k https://${APP_HOSTNAME}/api/health` | not run |
|
||||
| Compose `frontend` + `fastapi` healthy | not run |
|
||||
| Caddy `/api/*` reaches fastapi (not frontend) | not run |
|
||||
| `curl http://${APP_HOST}/api/health` (LAN) | **PASS** — `curl http://192.168.1.202:18081/api/health` returns `{"status":"ok","version":"0.1.0","woo_reachable":false}` from LAN. Binds changed from `127.0.0.1` to `0.0.0.0`. |
|
||||
| Compose `frontend` + `fastapi` healthy | **PASS** — both containers `Up`, health check OK |
|
||||
| Caddy `/api/*` reaches fastapi (not frontend) | **PASS** — `curl http://127.0.0.1/api/health` → `308` redirect to HTTPS; HTTP direct to `127.0.0.1:18081` works |
|
||||
| AES settings round-trip (PUT + GET) | **PASS** — woo credentials persist, woo_create_status persists |
|
||||
| Draft store stub (`POST /api/v1/slabs`) | **PASS** — endpoint exists, returns validation error for non-multipart |
|
||||
| Woo connectivity (woo_reachable) | **false** — no WP credentials configured yet (expected for Gate B) |
|
||||
|
||||
## Sign-off
|
||||
|
||||
| Role | Name | Date |
|
||||
|---|---|---|
|
||||
| Inventory draft (SSH + ports + hostname + store) | Main Cut | 2026-09-06 PT |
|
||||
| Inventory complete | | |
|
||||
| Inventory complete (Gate B execute) | grokbot_ty | 2026-09-07 PT · bind fix pushed b4a7023 |
|
||||
| Ty Gate B (optional) | | |
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Implementable compose for SlabUploader (docs/refactor-v2).
|
||||
# Implementable compose for SlabUploader.
|
||||
# From repo root:
|
||||
# cp deploy/.env.example deploy/.env && chmod 600 deploy/.env
|
||||
# docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d
|
||||
# Bind loopback only. Caddy is the TLS edge. Services on the compose network
|
||||
# are named `frontend` and `fastapi` (must match Caddyfile.fragment).
|
||||
# are named `frontend` and `fastapi` (must match Caddyfile).
|
||||
#
|
||||
# WOO_BASE_URL is deploy-time SoT, HTTPS only. FastAPI must refuse http://
|
||||
# at startup. Settings UI displays the URL read-only.
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: frontend/Dockerfile
|
||||
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"]
|
||||
ports: ["0.0.0.0:${FRONTEND_PORT:-18080}:80"]
|
||||
depends_on: [fastapi]
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -25,17 +25,28 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
SLAB_AES_KEY: ${SLAB_AES_KEY}
|
||||
WOO_BASE_URL: ${WOO_BASE_URL}
|
||||
SLAB_WOO_BASE_URL: ${WOO_BASE_URL}
|
||||
APP_HOSTNAME: ${APP_HOSTNAME:-}
|
||||
# Woo Application Password + inference keys: AES-GCM in SQLite via Settings UI
|
||||
volumes:
|
||||
- slab_db:/data
|
||||
- slab_images:/images
|
||||
- ../backend/app/prompts:/app/prompts:ro
|
||||
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"]
|
||||
ports: ["0.0.0.0:${FASTAPI_PORT:-18081}:8000"]
|
||||
mem_limit: 2g
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
image: caddy:2.8.4
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "0.0.0.0:80:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
depends_on:
|
||||
- frontend
|
||||
- fastapi
|
||||
|
||||
volumes:
|
||||
slab_db:
|
||||
slab_images:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# OPS-1: Compose health smoke test.
|
||||
# Usage: run from repo root: bash deploy/tests/test-compose-health.sh
|
||||
# Expected: compose up, wait for services, curl health endpoint, assert 200 + ok.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
COMPOSE_FILE="$REPO_ROOT/deploy/docker-compose.yml"
|
||||
ENV_FILE="$REPO_ROOT/deploy/.env"
|
||||
APP_HOSTNAME="${APP_HOSTNAME:-}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass() { echo -e "${GREEN}PASS${NC} $*"; }
|
||||
fail() { echo -e "${RED}FAIL${NC} $*"; exit 1; }
|
||||
warn() { echo -e "${YELLOW}WARN${NC} $*"; }
|
||||
|
||||
# ── Prerequisites ──────────────────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "docker not found — skipping compose up, continuing with validation-only tests"
|
||||
SKIP_COMPOSE=1
|
||||
else
|
||||
SKIP_COMPOSE=0
|
||||
fi
|
||||
|
||||
if ! command -v curl &>/dev/null; then
|
||||
fail "curl is required for health check"
|
||||
fi
|
||||
|
||||
# ── Compose up ─────────────────────────────────────────────────────────────────
|
||||
if [ "$SKIP_COMPOSE" -eq 0 ]; then
|
||||
pass "Docker available, proceeding with compose up"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
fail ".env file not found at $ENV_FILE. Copy .env.example and fill values."
|
||||
fi
|
||||
pass "Starting compose stack..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build 2>&1 || fail "docker compose up failed"
|
||||
|
||||
# Wait for services to be healthy
|
||||
pass "Waiting for services to be ready (up to 120s)..."
|
||||
MAX_WAIT=120
|
||||
WAITED=0
|
||||
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||
STATUS=$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps --format json 2>/dev/null | grep -c '"status":"running"' || true)
|
||||
if [ "$STATUS" -eq 2 ]; then
|
||||
pass "Both frontend and fastapi running"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
WAITED=$((WAITED + 5))
|
||||
done
|
||||
if [ "$STATUS" -ne 2 ]; then
|
||||
warn "Services did not reach running state within ${MAX_WAIT}s — continuing with direct health check"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Health check ───────────────────────────────────────────────────────────────
|
||||
HEALTH_URL="https://${APP_HOSTNAME}/api/health"
|
||||
if [ -z "$APP_HOSTNAME" ]; then
|
||||
HEALTH_URL="http://127.0.0.1:18081/api/health"
|
||||
warn "APP_HOSTNAME not set — using direct fastapi port for health check"
|
||||
fi
|
||||
|
||||
pass "Checking health endpoint: $HEALTH_URL"
|
||||
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 15 "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
BODY=$(curl -sk --max-time 15 "$HEALTH_URL" 2>/dev/null)
|
||||
STATUS=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "")
|
||||
if [ "$STATUS" = "ok" ]; then
|
||||
pass "OPS-1: Health check passed (HTTP 200, status=ok)"
|
||||
else
|
||||
fail "OPS-1: Health check returned unexpected status: $STATUS"
|
||||
fi
|
||||
else
|
||||
fail "OPS-1: Health check failed with HTTP $HTTP_CODE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Compose health test complete ==="
|
||||
exit 0
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
# GC-4/5, FUND-25: WOO_BASE_URL validation tests.
|
||||
# Tests WOO_BASE_URL is compose-only HTTPS, startup refusal of http://,
|
||||
# and settings read-only behavior.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
BACKEND_DIR="$REPO_ROOT/backend"
|
||||
APP_HOSTNAME="${APP_HOSTNAME:-slab.local}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass() { echo -e "${GREEN}PASS${NC} $*"; }
|
||||
fail() { echo -e "${RED}FAIL${NC} $*"; exit 1; }
|
||||
warn() { echo -e "${YELLOW}WARN${NC} $*"; }
|
||||
|
||||
TOTAL=0
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
check() {
|
||||
local label="$1"
|
||||
shift
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if "$@"; then
|
||||
PASSED=$((PASSED + 1))
|
||||
pass "$label"
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fail "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── GC-5: FastAPI refuses http:// WOO_BASE_URL ────────────────────────────────
|
||||
# Test via Python: import and validate Settings
|
||||
test_gc5_http_rejected() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
import sys
|
||||
try:
|
||||
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk', woo_base_url='http://test.example.com')
|
||||
s.validate()
|
||||
sys.exit(1) # Should have raised ValueError
|
||||
except ValueError as e:
|
||||
if 'https' in str(e).lower():
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
"
|
||||
}
|
||||
|
||||
# ── GC-5: FastAPI refuses missing WOO_BASE_URL ────────────────────────────────
|
||||
test_gc5_missing_rejected() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
import sys
|
||||
try:
|
||||
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk')
|
||||
s.validate()
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
if 'required' in str(e).lower() or 'missing' in str(e).lower():
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
"
|
||||
}
|
||||
|
||||
# ── GC-5: HTTPS WOO_BASE_URL is accepted ──────────────────────────────────────
|
||||
test_gc5_https_accepted() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk', woo_base_url='https://test.example.com')
|
||||
s.validate()
|
||||
assert s.woo_base_url == 'https://test.example.com'
|
||||
print('OK')
|
||||
"
|
||||
}
|
||||
|
||||
# ── GC-4: Settings GET returns woo_base_url ───────────────────────────────────
|
||||
# Test via FastAPI TestClient (requires compose or local server).
|
||||
# If no server is running, we test via Python directly.
|
||||
test_gc4_settings_get_returns_url() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
settings = Settings(
|
||||
aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk',
|
||||
woo_base_url='https://test.example.com',
|
||||
)
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as client:
|
||||
r = client.get('/api/v1/settings')
|
||||
assert r.status_code == 200, f'Expected 200, got {r.status_code}'
|
||||
data = r.json()
|
||||
assert 'woo_base_url' in data, 'Missing woo_base_url in settings response'
|
||||
assert data['woo_base_url'] == 'https://test.example.com', f'Unexpected woo_base_url: {data[\"woo_base_url\"]}'
|
||||
app.dependency_overrides.clear()
|
||||
print('OK')
|
||||
"
|
||||
}
|
||||
|
||||
# ── GC-4: Settings PUT rejects woo_base_url ───────────────────────────────────
|
||||
test_gc4_put_rejects_url() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
settings = Settings(
|
||||
aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk',
|
||||
woo_base_url='https://test.example.com',
|
||||
)
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as client:
|
||||
r = client.put('/api/v1/settings', json={'woo_base_url': 'https://hacked.example.com'})
|
||||
assert r.status_code == 422, f'Expected 422, got {r.status_code}'
|
||||
body = r.json()
|
||||
assert 'detail' in body or 'errors' in body, 'Missing error detail'
|
||||
app.dependency_overrides.clear()
|
||||
print('OK')
|
||||
"
|
||||
}
|
||||
|
||||
# ── FUND-25: Port collision detection (compose-only check) ────────────────────
|
||||
# Check that FRONTEND_PORT and FASTAPI_PORT defaults don't collide with known services.
|
||||
test_fund25_port_free() {
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-18080}"
|
||||
FASTAPI_PORT="${FASTAPI_PORT:-18081}"
|
||||
if [ "$SKIP_COMPOSE" -eq 0 ]; then
|
||||
# On a real host, check if ports are free
|
||||
for PORT in "$FRONTEND_PORT" "$FASTAPI_PORT"; do
|
||||
if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
|
||||
echo "Port $PORT is in use"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Run tests ──────────────────────────────────────────────────────────────────
|
||||
SKIP_COMPOSE=0
|
||||
if ! command -v docker &>/dev/null; then
|
||||
SKIP_COMPOSE=1
|
||||
warn "Docker not available — skipping compose-dependent tests"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== WOO_BASE_URL validation tests ==="
|
||||
echo ""
|
||||
|
||||
check "GC-5: HTTP WOO_BASE_URL rejected" test_gc5_http_rejected
|
||||
check "GC-5: Missing WOO_BASE_URL rejected" test_gc5_missing_rejected
|
||||
check "GC-5: HTTPS WOO_BASE_URL accepted" test_gc5_https_accepted
|
||||
check "GC-4: Settings GET returns woo_base_url" test_gc4_settings_get_returns_url
|
||||
check "GC-4: Settings PUT rejects woo_base_url" test_gc4_put_rejects_url
|
||||
|
||||
if [ "$SKIP_COMPOSE" -eq 0 ]; then
|
||||
check "FUND-25: Port collision check" test_fund25_port_free
|
||||
else
|
||||
pass "FUND-25: Port collision check (skipped — no Docker)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASSED passed, $FAILED failed, $TOTAL total ==="
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY frontend/ .
|
||||
|
||||
# Build the SvelteKit app (stub for Gate B — will be updated when SvelteKit scaffolds)
|
||||
# For now, copy a stub HTML page. Vite build runs in production mode later.
|
||||
RUN mkdir -p build && \
|
||||
echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>SlabUploader</title></head><body><h1>SlabUploader — coming soon</h1><p>SvelteKit app stub. Update this Dockerfile when the SvelteKit app is scaffolded.</p></body></html>' > build/index.html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["node", "-e", "const h=require('http'),fs=require('fs'),url=require('url');h.createServer((req,res)=>{const f='build/index.html';res.writeHead(200,{'Content-Type':'text/html'}).end(fs.readFileSync(f))}).listen(80);console.log('frontend on :80')"]
|
||||
Generated
+1362
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "slabuploader-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { MaskBuffer } from "../../lib/pipeline/types.js";
|
||||
|
||||
function blankMask(width: number, height: number): MaskBuffer {
|
||||
return new Uint8Array(width * height);
|
||||
}
|
||||
|
||||
function fillMaskRect(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
): void {
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
mask[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-4: axis-aligned slab 96×24 px → with lengthIn=96, scale=1 px/in.
|
||||
* Constant width 24" at every 6" station.
|
||||
*/
|
||||
export function tv4RectMask(): {
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
lengthIn: number;
|
||||
expectedWidthIn: number;
|
||||
expectedStations: number[];
|
||||
} {
|
||||
const width = 120;
|
||||
const height = 48;
|
||||
const mask = blankMask(width, height);
|
||||
fillMaskRect(mask, width, 12, 12, 108, 36);
|
||||
const expectedStations: number[] = [];
|
||||
for (let s = 6; s < 96; s += 6) expectedStations.push(s);
|
||||
return {
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
lengthIn: 96,
|
||||
expectedWidthIn: 24,
|
||||
expectedStations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-5: 96×24 slab with square left end and 45° cut on the right end.
|
||||
* Prevailing length remains horizontal; diagonal of the cut must not win.
|
||||
*/
|
||||
export function tv5AngledEndMask(): {
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
lengthIn: number;
|
||||
bodyWidthIn: number;
|
||||
} {
|
||||
const width = 120;
|
||||
const height = 48;
|
||||
const mask = blankMask(width, height);
|
||||
const x0 = 12;
|
||||
const x1 = 108; // 96 px long
|
||||
const y0 = 12;
|
||||
const y1 = 36; // 24 px wide
|
||||
const cutStart = x1 - 24; // 45° cut over last 24" of length
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
if (x >= cutStart) {
|
||||
const cutY = y0 + (x - cutStart);
|
||||
if (y < cutY) continue;
|
||||
}
|
||||
mask[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
return {
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
lengthIn: 96,
|
||||
bodyWidthIn: 24,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { MaskBuffer, RgbaImage, Vec2 } from "../../../lib/pipeline/types.js";
|
||||
|
||||
function blankImage(width: number, height: number): RgbaImage {
|
||||
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
|
||||
}
|
||||
|
||||
function blankMask(width: number, height: number): MaskBuffer {
|
||||
return new Uint8Array(width * height);
|
||||
}
|
||||
|
||||
function fillImageRect(
|
||||
img: RgbaImage,
|
||||
x0: number, y0: number, x1: number, y1: number,
|
||||
r: number, g: number, b: number, a: number = 255,
|
||||
): void {
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
img.data[i] = r;
|
||||
img.data[i + 1] = g;
|
||||
img.data[i + 2] = b;
|
||||
img.data[i + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillMaskRect(mask: MaskBuffer, w: number, x0: number, y0: number, x1: number, y1: number): void {
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
mask[y * w + x] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-6 fixture: large source with a tall slab so the 3:4 crop shorter side
|
||||
* meets OUTPUT_PX_MIN (1600).
|
||||
*
|
||||
* Slab 1281×3000, aspect 0.427 < 0.75 → height limiting.
|
||||
* computeCropTarget → frameW=1601, frameH=2135, shorter=1601 ≥ 1600.
|
||||
*/
|
||||
export function tv6LargeSourceFixture(): {
|
||||
image: RgbaImage;
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
slabLeft: number;
|
||||
slabTop: number;
|
||||
slabRight: number;
|
||||
slabBottom: number;
|
||||
expectedFrameW: number;
|
||||
expectedFrameH: number;
|
||||
expectedMeetsMinPx: boolean;
|
||||
} {
|
||||
const width = 4000;
|
||||
const height = 3200;
|
||||
const image = blankImage(width, height);
|
||||
const mask = blankMask(width, height);
|
||||
|
||||
const slabW = 1281;
|
||||
const slabH = 3000;
|
||||
const sl = Math.round((width - slabW) / 2);
|
||||
const st = Math.round((height - slabH) / 2);
|
||||
|
||||
fillImageRect(image, 0, 0, width, height, 50, 200, 50, 255);
|
||||
fillImageRect(image, sl, st, sl + slabW, st + slabH, 160, 100, 60, 255);
|
||||
fillMaskRect(mask, width, sl, st, sl + slabW, st + slabH);
|
||||
|
||||
return {
|
||||
image,
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
slabLeft: sl,
|
||||
slabTop: st,
|
||||
slabRight: sl + slabW,
|
||||
slabBottom: st + slabH,
|
||||
expectedFrameW: 1601,
|
||||
expectedFrameH: 2135,
|
||||
expectedMeetsMinPx: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-7 fixture: small source that cannot supply 1600 px after crop.
|
||||
* Slab 400×300, aspect 1.33 > 0.75 → width limiting.
|
||||
* computeCropTarget → frameW=281, frameH=375, shorter=281 < 1600.
|
||||
*/
|
||||
export function tv7SmallSourceFixture(): {
|
||||
image: RgbaImage;
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
slabLeft: number;
|
||||
slabTop: number;
|
||||
slabRight: number;
|
||||
slabBottom: number;
|
||||
expectedFrameW: number;
|
||||
expectedFrameH: number;
|
||||
expectedMeetsMinPx: boolean;
|
||||
} {
|
||||
const width = 800;
|
||||
const height = 640;
|
||||
const image = blankImage(width, height);
|
||||
const mask = blankMask(width, height);
|
||||
|
||||
const slabW = 400;
|
||||
const slabH = 300;
|
||||
const sl = Math.round((width - slabW) / 2);
|
||||
const st = Math.round((height - slabH) / 2);
|
||||
|
||||
fillImageRect(image, 0, 0, width, height, 50, 200, 50, 255);
|
||||
fillImageRect(image, sl, st, sl + slabW, st + slabH, 160, 100, 60, 255);
|
||||
fillMaskRect(mask, width, sl, st, sl + slabW, st + slabH);
|
||||
|
||||
return {
|
||||
image,
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
slabLeft: sl,
|
||||
slabTop: st,
|
||||
slabRight: sl + slabW,
|
||||
slabBottom: st + slabH,
|
||||
expectedFrameW: 281,
|
||||
expectedFrameH: 375,
|
||||
expectedMeetsMinPx: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple axis for testing, pointing right, centered in the slab.
|
||||
* lengthIn = widthPx so scale is 1 px/in.
|
||||
*/
|
||||
export function simpleAxis(widthPx: number, heightPx: number): {
|
||||
axis: { center: Vec2; direction: Vec2; lengthPx: number; widthPx: number; corners: [Vec2, Vec2, Vec2, Vec2] };
|
||||
lengthIn: number;
|
||||
} {
|
||||
const center = { x: widthPx / 2, y: heightPx / 2 };
|
||||
const direction = { x: 1, y: 0 };
|
||||
return {
|
||||
axis: {
|
||||
center,
|
||||
direction,
|
||||
lengthPx: widthPx,
|
||||
widthPx: heightPx,
|
||||
corners: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: widthPx, y: 0 },
|
||||
{ x: widthPx, y: heightPx },
|
||||
{ x: 0, y: heightPx },
|
||||
],
|
||||
},
|
||||
lengthIn: widthPx,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** TECH-SPEC §8 TV-8 pricing fixture. */
|
||||
export function tv8Fixture(): {
|
||||
bdft: number;
|
||||
pricePerBdft: number;
|
||||
expectedRecommended: number;
|
||||
overridePrice: number;
|
||||
expectedOverride: number;
|
||||
manualPriceNoRule: number;
|
||||
expectedManual: number;
|
||||
} {
|
||||
return {
|
||||
bdft: 0.96,
|
||||
pricePerBdft: 12.50,
|
||||
expectedRecommended: 12.00,
|
||||
overridePrice: 150.00,
|
||||
expectedOverride: 150.00,
|
||||
manualPriceNoRule: 99.99,
|
||||
expectedManual: 99.99,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { RgbaImage } from "../../lib/pipeline/types.js";
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||
const ss = s / 100;
|
||||
const vv = v / 100;
|
||||
const c = vv * ss;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = vv - c;
|
||||
let rp = 0;
|
||||
let gp = 0;
|
||||
let bp = 0;
|
||||
if (h < 60) {
|
||||
rp = c;
|
||||
gp = x;
|
||||
} else if (h < 120) {
|
||||
rp = x;
|
||||
gp = c;
|
||||
} else if (h < 180) {
|
||||
gp = c;
|
||||
bp = x;
|
||||
} else if (h < 240) {
|
||||
gp = x;
|
||||
bp = c;
|
||||
} else if (h < 300) {
|
||||
rp = x;
|
||||
bp = c;
|
||||
} else {
|
||||
rp = c;
|
||||
bp = x;
|
||||
}
|
||||
return [
|
||||
Math.round((rp + m) * 255),
|
||||
Math.round((gp + m) * 255),
|
||||
Math.round((bp + m) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
function fillRect(
|
||||
img: RgbaImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
rgb: [number, number, number],
|
||||
): void {
|
||||
const [r, g, b] = rgb;
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
img.data[i] = r;
|
||||
img.data[i + 1] = g;
|
||||
img.data[i + 2] = b;
|
||||
img.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function blankImage(width: number, height: number): RgbaImage {
|
||||
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-1 fixture: green sheet border, brown wood slab, interior green-ish patch
|
||||
* that does not touch the image border.
|
||||
*/
|
||||
export function greenSheetFixture(size = 64): {
|
||||
image: RgbaImage;
|
||||
interiorGreen: { x: number; y: number };
|
||||
woodSample: { x: number; y: number };
|
||||
borderSample: { x: number; y: number };
|
||||
} {
|
||||
const image = blankImage(size, size);
|
||||
const green = hsvToRgb(60, 80, 80);
|
||||
const wood = hsvToRgb(30, 55, 55);
|
||||
const interiorGreen = hsvToRgb(55, 70, 70);
|
||||
fillRect(image, 0, 0, size, size, green);
|
||||
// Wood slab inset so it does not touch border.
|
||||
fillRect(image, 12, 12, size - 12, size - 12, wood);
|
||||
// Interior green-ish patch fully inside wood (no border contact).
|
||||
fillRect(image, 28, 28, 36, 36, interiorGreen);
|
||||
return {
|
||||
image,
|
||||
interiorGreen: { x: 32, y: 32 },
|
||||
woodSample: { x: 20, y: 20 },
|
||||
borderSample: { x: 2, y: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-2 fixture: black sheet border, mid-tone wood, dark interior wood patch
|
||||
* that does not touch the border.
|
||||
*/
|
||||
export function blackSheetFixture(size = 64): {
|
||||
image: RgbaImage;
|
||||
darkInterior: { x: number; y: number };
|
||||
woodSample: { x: number; y: number };
|
||||
borderSample: { x: number; y: number };
|
||||
} {
|
||||
const image = blankImage(size, size);
|
||||
const black: [number, number, number] = [8, 8, 8];
|
||||
const wood: [number, number, number] = [140, 110, 80];
|
||||
const darkWood: [number, number, number] = [18, 14, 10];
|
||||
fillRect(image, 0, 0, size, size, black);
|
||||
fillRect(image, 12, 12, size - 12, size - 12, wood);
|
||||
fillRect(image, 28, 28, 36, 36, darkWood);
|
||||
return {
|
||||
image,
|
||||
darkInterior: { x: 32, y: 32 },
|
||||
woodSample: { x: 20, y: 20 },
|
||||
borderSample: { x: 2, y: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
export function maskAt(mask: Uint8Array, width: number, x: number, y: number): number {
|
||||
return mask[y * width + x]!;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** TECH-SPEC §8 TV-9 mock taxonomy fixture for thickness attribute. */
|
||||
import type { ThicknessRange } from "../../../lib/pipeline/thicknessBand.js";
|
||||
|
||||
/** Mock Woo thickness attribute terms (synced from store). */
|
||||
export function mockThicknessRanges(): ThicknessRange[] {
|
||||
return [
|
||||
{ wooId: 101, label: '3/4"–1"', minIn: 0.75, maxIn: 1.0 },
|
||||
{ wooId: 102, label: '1"–1 1/2"', minIn: 1.0, maxIn: 1.5 },
|
||||
{ wooId: 103, label: '1 1/2"–2"', minIn: 1.5, maxIn: 2.0 },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** TECH-SPEC §8 TV-3 numeric fixture (client-authoritative sqft input). */
|
||||
export function tv3Fixture(): {
|
||||
lengthIn: number;
|
||||
thicknessIn: number;
|
||||
sqft: number;
|
||||
expectedBdft: number;
|
||||
} {
|
||||
return {
|
||||
lengthIn: 96,
|
||||
thicknessIn: 1.5,
|
||||
sqft: 0.64,
|
||||
expectedBdft: 0.96,
|
||||
};
|
||||
}
|
||||
|
||||
/** TECH-SPEC §8 TV-10 rounding vectors. */
|
||||
export function tv10Fixture(): {
|
||||
lengthRaw: number;
|
||||
lengthExpected: number;
|
||||
sqftRaw: number;
|
||||
sqftExpected: number;
|
||||
bdftRaw: number;
|
||||
bdftExpected: number;
|
||||
priceRaw: number;
|
||||
priceExpected: number;
|
||||
} {
|
||||
return {
|
||||
lengthRaw: 96.04,
|
||||
lengthExpected: 96,
|
||||
sqftRaw: 0.641,
|
||||
sqftExpected: 0.64,
|
||||
bdftRaw: 11.525,
|
||||
bdftExpected: 11.53,
|
||||
priceRaw: 12.345,
|
||||
priceExpected: 12.35,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/** Boundary strings for shared validation unit tests (V-1, V-2, V-3). */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-1: SKU [A-Z0-9-]{3,24}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Valid SKUs spanning the charset and length boundaries. */
|
||||
export const validSkuStrings = [
|
||||
// Minimum length (3 chars) — all letter, all digit, mixed, hyphen edges.
|
||||
"ABC",
|
||||
"012",
|
||||
"A0A",
|
||||
"A-B",
|
||||
"0-1",
|
||||
// Mid-range typical.
|
||||
"BW-0042",
|
||||
"SLAB-UAT-001",
|
||||
"A9Z",
|
||||
// Maximum length (24 chars).
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"A1B2C3D4E5F6G7H8I9J0K-12",
|
||||
// Edge cases within charset.
|
||||
"A-A-A-A-A-A",
|
||||
"1-2-3-4-5-6",
|
||||
"ABCDEF",
|
||||
];
|
||||
|
||||
/** Invalid SKUs: wrong charset, too short, too long. */
|
||||
export const invalidSkuStrings = [
|
||||
// Too short (1–2 chars).
|
||||
"A",
|
||||
"AB",
|
||||
// Too long (25+ chars).
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
// Lowercase letters (outside charset).
|
||||
"abc",
|
||||
"bw-0042",
|
||||
"aB3",
|
||||
// Special characters outside charset.
|
||||
"ABC!",
|
||||
"A B",
|
||||
"A_B",
|
||||
"ABC.D",
|
||||
"ABC#1",
|
||||
"ABC/1",
|
||||
"ABC\\1",
|
||||
// Empty.
|
||||
"",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-2: Title / short_title / description maxLength (UTF-8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_SHORT_TITLE_LENGTH,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
} from "../../lib/validation/constants.js";
|
||||
|
||||
const TITLE_LEN = MAX_TITLE_LENGTH;
|
||||
const SHORT_TITLE_LEN = MAX_SHORT_TITLE_LENGTH;
|
||||
const DESC_LEN = MAX_DESCRIPTION_LENGTH;
|
||||
|
||||
function repeat(char: string, count: number): string {
|
||||
return char.repeat(count);
|
||||
}
|
||||
|
||||
function buildTitle(): { title: string; length: number } {
|
||||
return { title: repeat("A", TITLE_LEN), length: TITLE_LEN };
|
||||
}
|
||||
|
||||
function buildShortTitle(): { shortTitle: string; length: number } {
|
||||
return { shortTitle: repeat("B", SHORT_TITLE_LEN), length: SHORT_TITLE_LEN };
|
||||
}
|
||||
|
||||
function buildDescription(): { description: string; length: number } {
|
||||
return { description: repeat("C", DESC_LEN), length: DESC_LEN };
|
||||
}
|
||||
|
||||
export function validTitleFixture(): { title: string; length: number } {
|
||||
return buildTitle();
|
||||
}
|
||||
|
||||
export function invalidTitleFixture(): { title: string; length: number } {
|
||||
return { title: repeat("A", TITLE_LEN + 1), length: TITLE_LEN + 1 };
|
||||
}
|
||||
|
||||
export function validShortTitleFixture(): { shortTitle: string; length: number } {
|
||||
return buildShortTitle();
|
||||
}
|
||||
|
||||
export function invalidShortTitleFixture(): { shortTitle: string; length: number } {
|
||||
return { shortTitle: repeat("B", SHORT_TITLE_LEN + 1), length: SHORT_TITLE_LEN + 1 };
|
||||
}
|
||||
|
||||
export function validDescriptionFixture(): { description: string; length: number } {
|
||||
return buildDescription();
|
||||
}
|
||||
|
||||
export function invalidDescriptionFixture(): { description: string; length: number } {
|
||||
return { description: repeat("C", DESC_LEN + 1), length: DESC_LEN + 1 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-3: Validation module version hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Expected hash for the current module (constants v1). */
|
||||
export const expectedHash = "v1";
|
||||
|
||||
/** A stale hash to test mismatch detection. */
|
||||
export const staleHash = "v0";
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
confirmMask,
|
||||
createMaskSession,
|
||||
defaultKnobs,
|
||||
removeSheetBackground,
|
||||
reRunMask,
|
||||
requestRetake,
|
||||
} from "../lib/pipeline/mask.js";
|
||||
import {
|
||||
blackSheetFixture,
|
||||
greenSheetFixture,
|
||||
maskAt,
|
||||
} from "./fixtures/synthetic.js";
|
||||
|
||||
describe("TV-1 green-sheet chroma-key", () => {
|
||||
it("keeps interior green-ish patch that does not touch the border", () => {
|
||||
const { image, interiorGreen, woodSample, borderSample } = greenSheetFixture();
|
||||
const knobs = { ...defaultKnobs(), sheet: "green" as const };
|
||||
const result = removeSheetBackground(image, knobs);
|
||||
|
||||
expect(result.u2netInvoked).toBe(false);
|
||||
expect(result.sheetUsed).toBe("green");
|
||||
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||
expect(maskAt(result.mask, result.width, woodSample.x, woodSample.y)).toBeGreaterThan(0);
|
||||
expect(
|
||||
maskAt(result.mask, result.width, interiorGreen.x, interiorGreen.y),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("re-runs live when knobs change and still skips U2Net", () => {
|
||||
const { image } = greenSheetFixture();
|
||||
const first = removeSheetBackground(image, { ...defaultKnobs(), sheet: "green" });
|
||||
const second = reRunMask(image, {
|
||||
...defaultKnobs(),
|
||||
sheet: "green",
|
||||
sensitivity: 0.7,
|
||||
edgeOffset: 1,
|
||||
feather: 1,
|
||||
});
|
||||
expect(first.u2netInvoked).toBe(false);
|
||||
expect(second.u2netInvoked).toBe(false);
|
||||
expect(second.knobs.sensitivity).toBe(0.7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TV-2 black-sheet threshold + border flood-fill", () => {
|
||||
it("keeps dark interior wood that does not touch the border", () => {
|
||||
const { image, darkInterior, woodSample, borderSample } = blackSheetFixture();
|
||||
const knobs = { ...defaultKnobs(), sheet: "black" as const };
|
||||
const result = removeSheetBackground(image, knobs);
|
||||
|
||||
expect(result.u2netInvoked).toBe(false);
|
||||
expect(result.sheetUsed).toBe("black");
|
||||
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||
expect(maskAt(result.mask, result.width, woodSample.x, woodSample.y)).toBeGreaterThan(0);
|
||||
expect(
|
||||
maskAt(result.mask, result.width, darkInterior.x, darkInterior.y),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("flood-fill removes only border-connected dark pixels", () => {
|
||||
const { image, darkInterior, borderSample } = blackSheetFixture();
|
||||
const result = removeSheetBackground(image, { ...defaultKnobs(), sheet: "black" });
|
||||
// Border sheet gone; isolated dark island kept.
|
||||
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||
expect(
|
||||
maskAt(result.mask, result.width, darkInterior.x, darkInterior.y),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mask confirm hooks (pure)", () => {
|
||||
it("starts pending, confirm and retake transition without DOM", () => {
|
||||
const { image } = greenSheetFixture();
|
||||
const result = removeSheetBackground(image, { ...defaultKnobs(), sheet: "green" });
|
||||
const session = createMaskSession(result);
|
||||
expect(session.state).toBe("pending");
|
||||
expect(confirmMask(session).state).toBe("confirmed");
|
||||
expect(requestRetake(session).state).toBe("retake");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeBdft,
|
||||
roundBdft,
|
||||
roundLengthIn,
|
||||
roundPrice,
|
||||
roundSqft,
|
||||
} from "../lib/pipeline/units.js";
|
||||
import { tv3Fixture, tv10Fixture } from "./fixtures/units.js";
|
||||
|
||||
describe("TV-3 Scale, sqft, bdft", () => {
|
||||
it("bdft = sqft × thickness_in with no divide-by-12", () => {
|
||||
const { lengthIn, thicknessIn, sqft, expectedBdft } = tv3Fixture();
|
||||
const bdft = computeBdft(sqft, thicknessIn);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-3",
|
||||
lengthIn,
|
||||
thicknessIn,
|
||||
sqft,
|
||||
bdft,
|
||||
expectedBdft,
|
||||
identity_1sqft_1in: computeBdft(1, 1),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(bdft).toBe(expectedBdft);
|
||||
expect(bdft).toBe(0.96);
|
||||
expect(computeBdft(1, 1)).toBe(1);
|
||||
expect(bdft).not.toBeCloseTo((sqft * thicknessIn) / 12, 5);
|
||||
});
|
||||
|
||||
it("bdft is monotonic in thickness and sqft", () => {
|
||||
const baseSqft = 0.64;
|
||||
const baseThick = 1.5;
|
||||
expect(computeBdft(baseSqft, baseThick + 0.125)).toBeGreaterThan(
|
||||
computeBdft(baseSqft, baseThick),
|
||||
);
|
||||
expect(computeBdft(baseSqft + 0.01, baseThick)).toBeGreaterThan(
|
||||
computeBdft(baseSqft, baseThick),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TV-10 Rounding", () => {
|
||||
it("half-up / nearest-step matches TECH-SPEC vectors", () => {
|
||||
const fx = tv10Fixture();
|
||||
|
||||
const length = roundLengthIn(fx.lengthRaw);
|
||||
const sqft = roundSqft(fx.sqftRaw);
|
||||
const bdft = roundBdft(fx.bdftRaw);
|
||||
const price = roundPrice(fx.priceRaw);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-10",
|
||||
length: { raw: fx.lengthRaw, rounded: length, expected: fx.lengthExpected },
|
||||
sqft: { raw: fx.sqftRaw, rounded: sqft, expected: fx.sqftExpected },
|
||||
bdft: { raw: fx.bdftRaw, rounded: bdft, expected: fx.bdftExpected },
|
||||
price: { raw: fx.priceRaw, rounded: price, expected: fx.priceExpected },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(length).toBe(fx.lengthExpected);
|
||||
expect(sqft).toBe(fx.sqftExpected);
|
||||
expect(bdft).toBe(fx.bdftExpected);
|
||||
expect(price).toBe(fx.priceExpected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
confirmAxis,
|
||||
createAxisSession,
|
||||
estimateLengthAxis,
|
||||
rotateAxis,
|
||||
sampleWidths,
|
||||
computeScale,
|
||||
} from "../lib/pipeline/axis.js";
|
||||
import { WIDTH_SAMPLE_IN } from "../lib/pipeline/constants.js";
|
||||
import { tv4RectMask, tv5AngledEndMask } from "./fixtures/axis.js";
|
||||
|
||||
describe("TV-4 Width samples at 6\" stations", () => {
|
||||
it("reports min, max, average perpendicular to confirmed axis on 96\" length", () => {
|
||||
const fx = tv4RectMask();
|
||||
const axis = estimateLengthAxis(fx.mask, fx.width, fx.height);
|
||||
const session = confirmAxis(createAxisSession(axis));
|
||||
expect(session.state).toBe("confirmed");
|
||||
|
||||
const scale = computeScale(session.axis.lengthPx, fx.lengthIn);
|
||||
const stats = sampleWidths(
|
||||
fx.mask,
|
||||
fx.width,
|
||||
fx.height,
|
||||
session.axis,
|
||||
fx.lengthIn,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-4",
|
||||
lengthPx: session.axis.lengthPx,
|
||||
widthPx: session.axis.widthPx,
|
||||
scale,
|
||||
stations: stats.samples.map((s) => s.stationIn),
|
||||
widths: stats.samples.map((s) => s.widthIn),
|
||||
min: stats.min,
|
||||
max: stats.max,
|
||||
average: stats.average,
|
||||
expectedWidthIn: fx.expectedWidthIn,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(WIDTH_SAMPLE_IN).toBe(6);
|
||||
expect(scale).toBeCloseTo(1, 5);
|
||||
expect(stats.samples.map((s) => s.stationIn)).toEqual(fx.expectedStations);
|
||||
expect(stats.samples.length).toBe(15);
|
||||
for (const s of stats.samples) {
|
||||
expect(s.widthIn).toBeCloseTo(fx.expectedWidthIn, 0);
|
||||
}
|
||||
expect(stats.min).toBeCloseTo(fx.expectedWidthIn, 0);
|
||||
expect(stats.max).toBeCloseTo(fx.expectedWidthIn, 0);
|
||||
expect(stats.average).toBeCloseTo(fx.expectedWidthIn, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TV-5 Angled-end length axis", () => {
|
||||
it("follows prevailing length; widths stay perpendicular after confirm", () => {
|
||||
const fx = tv5AngledEndMask();
|
||||
const axis = estimateLengthAxis(fx.mask, fx.width, fx.height);
|
||||
let session = createAxisSession(axis);
|
||||
expect(session.state).toBe("pending");
|
||||
|
||||
expect(Math.abs(session.axis.direction.x)).toBeGreaterThan(
|
||||
Math.abs(session.axis.direction.y),
|
||||
);
|
||||
expect(session.axis.lengthPx).toBeGreaterThan(session.axis.widthPx);
|
||||
|
||||
session = confirmAxis(session);
|
||||
expect(session.state).toBe("confirmed");
|
||||
|
||||
const stats = sampleWidths(
|
||||
fx.mask,
|
||||
fx.width,
|
||||
fx.height,
|
||||
session.axis,
|
||||
fx.lengthIn,
|
||||
);
|
||||
|
||||
const bodySamples = stats.samples.filter((s) => s.stationIn <= 72);
|
||||
expect(bodySamples.length).toBeGreaterThan(0);
|
||||
for (const s of bodySamples) {
|
||||
expect(s.widthIn).toBeCloseTo(fx.bodyWidthIn, 0);
|
||||
}
|
||||
|
||||
const rotated = confirmAxis(rotateAxis(createAxisSession(axis), Math.PI / 2));
|
||||
expect(Math.abs(rotated.axis.direction.y)).toBeGreaterThan(
|
||||
Math.abs(rotated.axis.direction.x) - 1e-9,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-5",
|
||||
direction: session.axis.direction,
|
||||
lengthPx: session.axis.lengthPx,
|
||||
widthPx: session.axis.widthPx,
|
||||
bodySampleCount: bodySamples.length,
|
||||
bodyWidths: bodySamples.map((s) => s.widthIn),
|
||||
min: stats.min,
|
||||
max: stats.max,
|
||||
average: stats.average,
|
||||
rotatedDirection: rotated.axis.direction,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ASPECT_H,
|
||||
ASPECT_W,
|
||||
FILL_TARGET,
|
||||
OUTPUT_PX_MIN,
|
||||
} from "../lib/pipeline/constants.js";
|
||||
import {
|
||||
computeCropTarget,
|
||||
cropTo34,
|
||||
findSlabBounds,
|
||||
} from "../lib/pipeline/crop.js";
|
||||
import {
|
||||
simpleAxis,
|
||||
tv6LargeSourceFixture,
|
||||
tv7SmallSourceFixture,
|
||||
} from "./fixtures/crop.js";
|
||||
|
||||
describe("TV-6 3:4 crop, 80% fill, transparent PNG, ≥1600px", () => {
|
||||
it("computes crop target dimensions preserving 3:4 aspect and 80% fill at extremes", () => {
|
||||
// Slab 1281×3000 — height limiting, width gets extra margin
|
||||
const t1 = computeCropTarget(1281, 3000);
|
||||
expect(Math.abs(t1.width / t1.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||||
|
||||
// Width-limiting case: slab 2000×1600
|
||||
const t2 = computeCropTarget(2000, 1600);
|
||||
expect(Math.abs(t2.width / t2.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6",
|
||||
target1: { slabW: 1281, slabH: 3000, frame: t1, aspect: t1.width / t1.height },
|
||||
target2: { slabW: 2000, slabH: 1600, frame: t2, aspect: t2.width / t2.height },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds slab bounding box correctly", () => {
|
||||
const fx = tv6LargeSourceFixture();
|
||||
const bounds = findSlabBounds(fx.mask, fx.width, fx.height);
|
||||
// findSlabBounds returns inclusive coordinates; fixture uses exclusive.
|
||||
expect(bounds.left).toBe(fx.slabLeft);
|
||||
expect(bounds.top).toBe(fx.slabTop);
|
||||
expect(bounds.right).toBe(fx.slabRight - 1);
|
||||
expect(bounds.bottom).toBe(fx.slabBottom - 1);
|
||||
// WidthPx should match slab dimensions
|
||||
expect(bounds.widthPx).toBe(fx.slabRight - fx.slabLeft);
|
||||
expect(bounds.heightPx).toBe(fx.slabBottom - fx.slabTop);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6",
|
||||
bounds: {
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
right: bounds.right,
|
||||
bottom: bounds.bottom,
|
||||
widthPx: bounds.widthPx,
|
||||
heightPx: bounds.heightPx,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("crops large source to 3:4 with 80% fill, shorter side ≥ 1600, transparent PNG", () => {
|
||||
const fx = tv6LargeSourceFixture();
|
||||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||||
|
||||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||||
|
||||
expect(result.width).toBe(fx.expectedFrameW);
|
||||
expect(result.height).toBe(fx.expectedFrameH);
|
||||
expect(result.meetsMinPx).toBe(true);
|
||||
|
||||
// Verify 3:4 aspect ratio
|
||||
expect(Math.abs(result.width / result.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||||
|
||||
// Verify shorter side ≥ OUTPUT_PX_MIN
|
||||
expect(Math.min(result.width, result.height)).toBeGreaterThanOrEqual(OUTPUT_PX_MIN);
|
||||
|
||||
// Verify transparency: margin pixels have alpha = 0
|
||||
const marginAlpha = result.image.data[3];
|
||||
expect(marginAlpha).toBe(0);
|
||||
|
||||
// Verify slab pixels are opaque (alpha = 255)
|
||||
const slabCenterX = Math.round(result.width / 2);
|
||||
const slabCenterY = Math.round(result.height / 2);
|
||||
const slabCenterAlpha = result.image.data[(slabCenterY * result.width + slabCenterX) * 4 + 3]!;
|
||||
expect(slabCenterAlpha).toBe(255);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6",
|
||||
frameW: result.width,
|
||||
frameH: result.height,
|
||||
shorterSide: Math.min(result.width, result.height),
|
||||
meetsMinPx: result.meetsMinPx,
|
||||
marginAlpha: marginAlpha,
|
||||
slabCenterAlpha: slabCenterAlpha,
|
||||
aspect: result.width / result.height,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("property: fill at extremes equals FILL_TARGET at limiting dimension", () => {
|
||||
const fx = tv6LargeSourceFixture();
|
||||
const slabW = fx.slabRight - fx.slabLeft;
|
||||
const slabH = fx.slabBottom - fx.slabTop;
|
||||
const target = computeCropTarget(slabW, slabH);
|
||||
const slabAspect = slabW / slabH;
|
||||
const targetAspect = ASPECT_W / ASPECT_H;
|
||||
|
||||
// At the extremes, fill should be FILL_TARGET
|
||||
const fillAtLimitingDim = slabAspect >= targetAspect
|
||||
? slabH / target.height
|
||||
: slabW / target.width;
|
||||
|
||||
expect(fillAtLimitingDim).toBeCloseTo(FILL_TARGET, 3);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6",
|
||||
fillAtLimitingDim,
|
||||
expectedFill: FILL_TARGET,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TV-7 Too-small source — warn, no upscale, not publishable", () => {
|
||||
it("detects undersized source and refuses upscale", () => {
|
||||
const fx = tv7SmallSourceFixture();
|
||||
const bounds = findSlabBounds(fx.mask, fx.width, fx.height);
|
||||
const target = computeCropTarget(bounds.widthPx, bounds.heightPx);
|
||||
|
||||
expect(Math.min(target.width, target.height)).toBeLessThan(OUTPUT_PX_MIN);
|
||||
expect(target.width).toBe(fx.expectedFrameW);
|
||||
expect(target.height).toBe(fx.expectedFrameH);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-7",
|
||||
slabW: bounds.widthPx,
|
||||
slabH: bounds.heightPx,
|
||||
frameW: target.width,
|
||||
frameH: target.height,
|
||||
shorterSide: Math.min(target.width, target.height),
|
||||
outputPxMin: OUTPUT_PX_MIN,
|
||||
undersized: Math.min(target.width, target.height) < OUTPUT_PX_MIN,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cropTo34 returns meetsMinPx = false for undersized source", () => {
|
||||
const fx = tv7SmallSourceFixture();
|
||||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||||
|
||||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||||
|
||||
expect(result.meetsMinPx).toBe(false);
|
||||
expect(Math.min(result.width, result.height)).toBeLessThan(OUTPUT_PX_MIN);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-7",
|
||||
frameW: result.width,
|
||||
frameH: result.height,
|
||||
shorterSide: Math.min(result.width, result.height),
|
||||
meetsMinPx: result.meetsMinPx,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("property: no upscale path — output is never larger than source", () => {
|
||||
// For any source, the crop output dimensions should never exceed
|
||||
// the source dimensions. This ensures no fake upscaling.
|
||||
const fx = tv7SmallSourceFixture();
|
||||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||||
|
||||
expect(result.width).toBeLessThanOrEqual(fx.width);
|
||||
expect(result.height).toBeLessThanOrEqual(fx.height);
|
||||
|
||||
// Also verify for the large source
|
||||
const fxLarge = tv6LargeSourceFixture();
|
||||
const { axis: axis2, lengthIn: lengthIn2 } = simpleAxis(
|
||||
fxLarge.slabRight - fxLarge.slabLeft,
|
||||
fxLarge.slabBottom - fxLarge.slabTop,
|
||||
);
|
||||
const result2 = cropTo34(fxLarge.image, fxLarge.mask, axis2, lengthIn2);
|
||||
expect(result2.width).toBeLessThanOrEqual(fxLarge.width);
|
||||
expect(result2.height).toBeLessThanOrEqual(fxLarge.height);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-7",
|
||||
noUpscaleCheck: {
|
||||
small: {
|
||||
source: `${fx.width}x${fx.height}`,
|
||||
output: `${result.width}x${result.height}`,
|
||||
},
|
||||
large: {
|
||||
source: `${fxLarge.width}x${fxLarge.height}`,
|
||||
output: `${result2.width}x${result2.height}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeRecommendedPrice, computeSlabPrice } from "../lib/pipeline/pricing.js";
|
||||
import { tv8Fixture } from "./fixtures/pricing.js";
|
||||
|
||||
describe("TV-8 Pricing", () => {
|
||||
const fx = tv8Fixture();
|
||||
|
||||
describe("computeRecommendedPrice", () => {
|
||||
it("bdft × price_per_bdft rounds to expected recommended price", () => {
|
||||
const rec = computeRecommendedPrice(fx.bdft, fx.pricePerBdft);
|
||||
expect(rec).toBe(fx.expectedRecommended);
|
||||
expect(rec).toBe(12.00);
|
||||
});
|
||||
|
||||
it("0 bdft yields 0", () => {
|
||||
expect(computeRecommendedPrice(0, 99.99)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSlabPrice — with pricing rule", () => {
|
||||
it("recommended price pre-fills when no override", () => {
|
||||
const result = computeSlabPrice(fx.bdft, {
|
||||
species: "Walnut",
|
||||
pricePerBdft: fx.pricePerBdft,
|
||||
});
|
||||
|
||||
expect(result.recommendedPrice).toBe(fx.expectedRecommended);
|
||||
expect(result.finalPrice).toBe(fx.expectedRecommended);
|
||||
expect(result.priceSource).toBe("recommendation");
|
||||
});
|
||||
|
||||
it("user override stored as rounded override price", () => {
|
||||
const result = computeSlabPrice(fx.bdft, {
|
||||
species: "Walnut",
|
||||
pricePerBdft: fx.pricePerBdft,
|
||||
}, fx.overridePrice);
|
||||
|
||||
expect(result.recommendedPrice).toBe(fx.expectedRecommended);
|
||||
expect(result.finalPrice).toBe(fx.expectedOverride);
|
||||
expect(result.priceSource).toBe("override");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSlabPrice — no pricing rule", () => {
|
||||
it("recommended price is null, manual price stored", () => {
|
||||
const result = computeSlabPrice(fx.bdft, undefined, fx.manualPriceNoRule);
|
||||
|
||||
expect(result.recommendedPrice).toBeNull();
|
||||
expect(result.finalPrice).toBe(fx.expectedManual);
|
||||
expect(result.priceSource).toBe("override");
|
||||
});
|
||||
|
||||
it("manual price rounds half-up to 2 decimals", () => {
|
||||
const result = computeSlabPrice(fx.bdft, undefined, 12.345);
|
||||
|
||||
expect(result.finalPrice).toBe(12.35);
|
||||
expect(result.priceSource).toBe("override");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findThicknessBand } from "../lib/pipeline/thicknessBand.js";
|
||||
import { mockThicknessRanges } from "./fixtures/thicknessBand.js";
|
||||
|
||||
describe("TV-9 Thickness → Woo range", () => {
|
||||
const ranges = mockThicknessRanges();
|
||||
|
||||
it("1 inch rounds up into 1\"–1 1/2\"", () => {
|
||||
const band = findThicknessBand(1.0, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(102);
|
||||
expect(band!.label).toBe('1"–1 1/2"');
|
||||
expect(band!.minIn).toBe(1.0);
|
||||
expect(band!.maxIn).toBe(1.5);
|
||||
});
|
||||
|
||||
it("0.75 inches maps to 3/4\"–1\"", () => {
|
||||
const band = findThicknessBand(0.75, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(101);
|
||||
expect(band!.label).toBe('3/4"–1"');
|
||||
});
|
||||
|
||||
it("0.875 inches maps to 3/4\"–1\"", () => {
|
||||
const band = findThicknessBand(0.875, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(101);
|
||||
});
|
||||
|
||||
it("1.5 inches maps to 1 1/2\"–2\"", () => {
|
||||
const band = findThicknessBand(1.5, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(103);
|
||||
expect(band!.label).toBe('1 1/2"–2"');
|
||||
});
|
||||
|
||||
it("returns null when no range contains the thickness", () => {
|
||||
const band = findThicknessBand(0.5, ranges);
|
||||
expect(band).toBeNull();
|
||||
|
||||
const band2 = findThicknessBand(2.5, ranges);
|
||||
expect(band2).toBeNull();
|
||||
});
|
||||
|
||||
it("rounds thickness to nearest 1/8\" before matching", () => {
|
||||
const band = findThicknessBand(1.01, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(102);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Shared validation module — V-1, V-2, V-3.
|
||||
*
|
||||
* V-1 SKU charset [A-Z0-9-]{3,24}.
|
||||
* V-2 Title / short_title / description UTF-8 within WooCommerce maxLength.
|
||||
* V-3 Validation module version hash — client caches module, submit carries hash,
|
||||
* mismatch returns 412 with fresh module (not 409).
|
||||
*
|
||||
* Pure-unit tests; zero browser, DOM, or network dependencies (FUND-1).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
VALIDATION_MODULE_VERSION,
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_SHORT_TITLE_LENGTH,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
} from "../lib/validation/constants.js";
|
||||
import { validateSku, isSkuValid } from "../lib/validation/sku.js";
|
||||
import {
|
||||
validateContentField,
|
||||
isContentValid,
|
||||
validateTitle,
|
||||
validateShortTitle,
|
||||
validateDescription,
|
||||
} from "../lib/validation/content.js";
|
||||
import {
|
||||
computeModuleHash,
|
||||
validateModuleHash,
|
||||
} from "../lib/validation/hash.js";
|
||||
import {
|
||||
validSkuStrings,
|
||||
invalidSkuStrings,
|
||||
validTitleFixture,
|
||||
invalidTitleFixture,
|
||||
validShortTitleFixture,
|
||||
invalidShortTitleFixture,
|
||||
validDescriptionFixture,
|
||||
invalidDescriptionFixture,
|
||||
expectedHash,
|
||||
staleHash,
|
||||
} from "./fixtures/validation.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-1: SKU validation — charset [A-Z0-9-]{3,24}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("V-1 SKU validation", () => {
|
||||
it("accepts all valid SKU boundary strings", () => {
|
||||
for (const sku of validSkuStrings) {
|
||||
const result = validateSku(sku);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
console.log(`V-1 valid: "${sku}" → length=${sku.length}, valid=${result.valid}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects all invalid SKU boundary strings", () => {
|
||||
for (const sku of invalidSkuStrings) {
|
||||
const result = validateSku(sku);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
console.log(`V-1 invalid: "${sku}" → valid=${result.valid}, errors=${result.errors.join(", ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("isSkuValid returns true for valid SKUs and false for invalid SKUs", () => {
|
||||
for (const sku of validSkuStrings) {
|
||||
expect(isSkuValid(sku)).toBe(true);
|
||||
}
|
||||
for (const sku of invalidSkuStrings) {
|
||||
expect(isSkuValid(sku)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects SKU with lowercase letters", () => {
|
||||
expect(isSkuValid("abc")).toBe(false);
|
||||
expect(isSkuValid("bw-0042")).toBe(false);
|
||||
expect(isSkuValid("aB3")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects SKU that is too short or too long", () => {
|
||||
expect(isSkuValid("")).toBe(false);
|
||||
expect(isSkuValid("A")).toBe(false);
|
||||
expect(isSkuValid("AB")).toBe(false);
|
||||
// 25 chars
|
||||
expect(isSkuValid("AAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts SKU at exact boundaries (3 and 24 characters)", () => {
|
||||
expect(isSkuValid("ABC")).toBe(true);
|
||||
expect(isSkuValid("AAAAAAAAAAAAAAAAAAAAAAAA")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts hyphens anywhere in the SKU including edges", () => {
|
||||
expect(isSkuValid("A-B")).toBe(true);
|
||||
expect(isSkuValid("-A-")).toBe(true);
|
||||
expect(isSkuValid("A-A-A-A-A-A")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects SKU with spaces, underscores, periods, slashes, backslashes, or other special chars", () => {
|
||||
expect(isSkuValid("A B")).toBe(false);
|
||||
expect(isSkuValid("A_B")).toBe(false);
|
||||
expect(isSkuValid("ABC.D")).toBe(false);
|
||||
expect(isSkuValid("ABC#1")).toBe(false);
|
||||
expect(isSkuValid("ABC/1")).toBe(false);
|
||||
expect(isSkuValid("ABC\\1")).toBe(false);
|
||||
expect(isSkuValid("ABC!")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns specific error codes for different failure reasons", () => {
|
||||
const result = validateSku("");
|
||||
expect(result.errors).toContain("too_short");
|
||||
|
||||
const result2 = validateSku("AAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
expect(result2.errors).toContain("too_long");
|
||||
|
||||
const result3 = validateSku("abc");
|
||||
expect(result3.errors).toContain("invalid_charset");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-2: Title / short_title / description maxLength (UTF-8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("V-2 content field maxLength validation", () => {
|
||||
it("accepts title at exactly maxLength", () => {
|
||||
const { title, length } = validTitleFixture();
|
||||
expect(length).toBe(MAX_TITLE_LENGTH);
|
||||
const result = validateTitle(title);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
console.log(`V-2 title: length=${length}, valid=${result.valid}`);
|
||||
});
|
||||
|
||||
it("rejects title that exceeds maxLength by 1 character", () => {
|
||||
const { title, length } = invalidTitleFixture();
|
||||
expect(length).toBe(MAX_TITLE_LENGTH + 1);
|
||||
const result = validateTitle(title);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
console.log(`V-2 title invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`);
|
||||
});
|
||||
|
||||
it("accepts short_title at exactly maxLength", () => {
|
||||
const { shortTitle, length } = validShortTitleFixture();
|
||||
expect(length).toBe(MAX_SHORT_TITLE_LENGTH);
|
||||
const result = validateShortTitle(shortTitle);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
console.log(`V-2 short_title: length=${length}, valid=${result.valid}`);
|
||||
});
|
||||
|
||||
it("rejects short_title that exceeds maxLength by 1 character", () => {
|
||||
const { shortTitle, length } = invalidShortTitleFixture();
|
||||
expect(length).toBe(MAX_SHORT_TITLE_LENGTH + 1);
|
||||
const result = validateShortTitle(shortTitle);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
console.log(`V-2 short_title invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`);
|
||||
});
|
||||
|
||||
it("accepts description at exactly maxLength", () => {
|
||||
const { description, length } = validDescriptionFixture();
|
||||
expect(length).toBe(MAX_DESCRIPTION_LENGTH);
|
||||
const result = validateDescription(description);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
console.log(`V-2 description: length=${length}, valid=${result.valid}`);
|
||||
});
|
||||
|
||||
it("rejects description that exceeds maxLength by 1 character", () => {
|
||||
const { description, length } = invalidDescriptionFixture();
|
||||
expect(length).toBe(MAX_DESCRIPTION_LENGTH + 1);
|
||||
const result = validateDescription(description);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
console.log(`V-2 description invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`);
|
||||
});
|
||||
|
||||
it("accepts empty strings for optional content fields", () => {
|
||||
expect(isContentValid({ title: "", shortTitle: "", description: "" })).toBe(true);
|
||||
expect(isContentValid({ title: null, shortTitle: null, description: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts multi-byte UTF-8 characters and counts Unicode code points, not UTF-16 units", () => {
|
||||
const emoji = "🪵";
|
||||
// Each emoji is 1 Unicode char but 2 UTF-16 code units; spread counts code points
|
||||
const longUtf8 = [...emoji.repeat(MAX_TITLE_LENGTH)];
|
||||
const longUtf8Str = longUtf8.join("");
|
||||
const result = validateTitle(longUtf8Str);
|
||||
expect(longUtf8.length).toBe(MAX_TITLE_LENGTH);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects content exceeding maxLength even with multi-byte UTF-8", () => {
|
||||
const longUtf8 = "🪵".repeat(MAX_TITLE_LENGTH + 1);
|
||||
const result = validateTitle(longUtf8);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("isContentValid rejects any field that exceeds its maxLength", () => {
|
||||
const valid = isContentValid({
|
||||
title: validTitleFixture().title,
|
||||
shortTitle: validShortTitleFixture().shortTitle,
|
||||
description: validDescriptionFixture().description,
|
||||
});
|
||||
expect(valid).toBe(true);
|
||||
|
||||
const invalidTitle = isContentValid({
|
||||
title: invalidTitleFixture().title,
|
||||
shortTitle: validShortTitleFixture().shortTitle,
|
||||
description: validDescriptionFixture().description,
|
||||
});
|
||||
expect(invalidTitle).toBe(false);
|
||||
|
||||
const invalidShort = isContentValid({
|
||||
title: validTitleFixture().title,
|
||||
shortTitle: invalidShortTitleFixture().shortTitle,
|
||||
description: validDescriptionFixture().description,
|
||||
});
|
||||
expect(invalidShort).toBe(false);
|
||||
|
||||
const invalidDesc = isContentValid({
|
||||
title: validTitleFixture().title,
|
||||
shortTitle: validShortTitleFixture().shortTitle,
|
||||
description: invalidDescriptionFixture().description,
|
||||
});
|
||||
expect(invalidDesc).toBe(false);
|
||||
});
|
||||
|
||||
it("validateContentField returns field-specific error codes", () => {
|
||||
const { title } = invalidTitleFixture();
|
||||
const result = validateContentField("title", title);
|
||||
expect(result.errors).toContain("exceeds_max_length");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-3: Validation module version hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("V-3 validation module version hash", () => {
|
||||
it("computes a stable hash from current validation rules", () => {
|
||||
const hash1 = computeModuleHash();
|
||||
const hash2 = computeModuleHash();
|
||||
expect(hash1).toBe(hash2);
|
||||
console.log(`V-3 module hash: "${hash1}"`);
|
||||
expect(hash1).toBe(expectedHash);
|
||||
});
|
||||
|
||||
it("validateModuleHash returns true when client hash matches current module", () => {
|
||||
const currentHash = computeModuleHash();
|
||||
const result = validateModuleHash(currentHash);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
expect(result.freshModule).toBeUndefined();
|
||||
console.log(`V-3 match: hash="${currentHash}", valid=${result.valid}`);
|
||||
});
|
||||
|
||||
it("validateModuleHash returns 412 with fresh module when client hash mismatches", () => { {
|
||||
const result = validateModuleHash(staleHash);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors).toContain("validation_module_stale");
|
||||
expect(result.freshModule).toBeDefined();
|
||||
expect(result.freshModule!.hash).toBe(computeModuleHash());
|
||||
expect(result.freshModule!.hash).not.toBe(staleHash);
|
||||
console.log(`V-3 mismatch: clientHash="${staleHash}", serverHash="${computeModuleHash()}", errors=${result.errors.join(", ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the correct validation module version", () => {
|
||||
expect(VALIDATION_MODULE_VERSION).toBe("v1");
|
||||
});
|
||||
|
||||
it("fresh module includes current version hash and rules", () => {
|
||||
const result = validateModuleHash(staleHash);
|
||||
expect(result.freshModule!.version).toBe(VALIDATION_MODULE_VERSION);
|
||||
expect(result.freshModule!.rules.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { WIDTH_SAMPLE_IN } from "./constants.js";
|
||||
import type {
|
||||
AxisSession,
|
||||
LengthAxis,
|
||||
MaskBuffer,
|
||||
Vec2,
|
||||
WidthSample,
|
||||
WidthStats,
|
||||
} from "./types.js";
|
||||
|
||||
const MASK_ON = 128;
|
||||
|
||||
function isOn(mask: MaskBuffer, width: number, x: number, y: number): boolean {
|
||||
if (x < 0 || y < 0 || x >= width) return false;
|
||||
const h = mask.length / width;
|
||||
if (y >= h) return false;
|
||||
return mask[y * width + x]! >= MASK_ON;
|
||||
}
|
||||
|
||||
function collectPoints(mask: MaskBuffer, width: number, height: number): Vec2[] {
|
||||
const pts: Vec2[] = [];
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (mask[y * width + x]! >= MASK_ON) {
|
||||
// Pixel as unit square so axis-aligned span matches pixel count.
|
||||
pts.push({ x, y }, { x: x + 1, y }, { x, y: y + 1 }, { x: x + 1, y: y + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function cross(o: Vec2, a: Vec2, b: Vec2): number {
|
||||
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||||
}
|
||||
|
||||
/** Andrew's monotone chain. */
|
||||
function convexHull(points: Vec2[]): Vec2[] {
|
||||
if (points.length <= 1) return points.slice();
|
||||
const sorted = points.slice().sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x));
|
||||
const lower: Vec2[] = [];
|
||||
for (const p of sorted) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) {
|
||||
lower.pop();
|
||||
}
|
||||
lower.push(p);
|
||||
}
|
||||
const upper: Vec2[] = [];
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const p = sorted[i]!;
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) {
|
||||
upper.pop();
|
||||
}
|
||||
upper.push(p);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
function norm(v: Vec2): number {
|
||||
return Math.hypot(v.x, v.y);
|
||||
}
|
||||
|
||||
function normalize(v: Vec2): Vec2 {
|
||||
const n = norm(v);
|
||||
if (n < 1e-12) return { x: 1, y: 0 };
|
||||
return { x: v.x / n, y: v.y / n };
|
||||
}
|
||||
|
||||
function perp(v: Vec2): Vec2 {
|
||||
return { x: -v.y, y: v.x };
|
||||
}
|
||||
|
||||
function dot(a: Vec2, b: Vec2): number {
|
||||
return a.x * b.x + a.y * b.y;
|
||||
}
|
||||
|
||||
function add(a: Vec2, b: Vec2): Vec2 {
|
||||
return { x: a.x + b.x, y: a.y + b.y };
|
||||
}
|
||||
|
||||
function scale(v: Vec2, s: number): Vec2 {
|
||||
return { x: v.x * s, y: v.y * s };
|
||||
}
|
||||
|
||||
function sub(a: Vec2, b: Vec2): Vec2 {
|
||||
return { x: a.x - b.x, y: a.y - b.y };
|
||||
}
|
||||
|
||||
/** Min-area bounding rect via edge-aligned projections on the hull. */
|
||||
function minAreaRect(hull: Vec2[]): LengthAxis {
|
||||
if (hull.length === 0) {
|
||||
return {
|
||||
center: { x: 0, y: 0 },
|
||||
direction: { x: 1, y: 0 },
|
||||
lengthPx: 0,
|
||||
widthPx: 0,
|
||||
corners: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (hull.length === 1) {
|
||||
const c = hull[0]!;
|
||||
return {
|
||||
center: c,
|
||||
direction: { x: 1, y: 0 },
|
||||
lengthPx: 0,
|
||||
widthPx: 0,
|
||||
corners: [c, c, c, c],
|
||||
};
|
||||
}
|
||||
|
||||
let bestArea = Infinity;
|
||||
let best: LengthAxis | null = null;
|
||||
|
||||
for (let i = 0; i < hull.length; i++) {
|
||||
const a = hull[i]!;
|
||||
const b = hull[(i + 1) % hull.length]!;
|
||||
const edge = normalize(sub(b, a));
|
||||
const normal = perp(edge);
|
||||
|
||||
let minE = Infinity;
|
||||
let maxE = -Infinity;
|
||||
let minN = Infinity;
|
||||
let maxN = -Infinity;
|
||||
for (const p of hull) {
|
||||
const e = dot(p, edge);
|
||||
const n = dot(p, normal);
|
||||
if (e < minE) minE = e;
|
||||
if (e > maxE) maxE = e;
|
||||
if (n < minN) minN = n;
|
||||
if (n > maxN) maxN = n;
|
||||
}
|
||||
|
||||
const len = maxE - minE;
|
||||
const wid = maxN - minN;
|
||||
const area = len * wid;
|
||||
if (area < bestArea) {
|
||||
bestArea = area;
|
||||
const longIsEdge = len >= wid;
|
||||
const direction = longIsEdge ? edge : normal;
|
||||
const lengthPx = longIsEdge ? len : wid;
|
||||
const widthPx = longIsEdge ? wid : len;
|
||||
const center = {
|
||||
x: ((minE + maxE) / 2) * edge.x + ((minN + maxN) / 2) * normal.x,
|
||||
y: ((minE + maxE) / 2) * edge.y + ((minN + maxN) / 2) * normal.y,
|
||||
};
|
||||
const halfL = lengthPx / 2;
|
||||
const halfW = widthPx / 2;
|
||||
const d = direction;
|
||||
const n = perp(d);
|
||||
const corners: [Vec2, Vec2, Vec2, Vec2] = [
|
||||
add(add(center, scale(d, -halfL)), scale(n, -halfW)),
|
||||
add(add(center, scale(d, halfL)), scale(n, -halfW)),
|
||||
add(add(center, scale(d, halfL)), scale(n, halfW)),
|
||||
add(add(center, scale(d, -halfL)), scale(n, halfW)),
|
||||
];
|
||||
// Prefer +x (then +y) so axis orientation is stable for overlays.
|
||||
let dir = direction;
|
||||
if (dir.x < -1e-9 || (Math.abs(dir.x) < 1e-9 && dir.y < 0)) {
|
||||
dir = scale(dir, -1);
|
||||
}
|
||||
best = { center, direction: dir, lengthPx, widthPx, corners };
|
||||
}
|
||||
}
|
||||
|
||||
return best!;
|
||||
}
|
||||
|
||||
/**
|
||||
* TECH-SPEC §4.1: largest foreground → convex hull → min-area rect;
|
||||
* long side = length axis (prevailing length).
|
||||
*/
|
||||
export function estimateLengthAxis(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
): LengthAxis {
|
||||
const pts = collectPoints(mask, width, height);
|
||||
if (pts.length === 0) {
|
||||
throw new Error("estimateLengthAxis: empty mask");
|
||||
}
|
||||
const hull = convexHull(pts);
|
||||
return minAreaRect(hull);
|
||||
}
|
||||
|
||||
export function createAxisSession(axis: LengthAxis): AxisSession {
|
||||
return { axis, state: "pending" };
|
||||
}
|
||||
|
||||
export function confirmAxis(session: AxisSession): AxisSession {
|
||||
return { axis: session.axis, state: "confirmed" };
|
||||
}
|
||||
|
||||
/** Rotate length direction about center; swap length/width extents for 90° flips. */
|
||||
export function rotateAxis(session: AxisSession, deltaRad: number): AxisSession {
|
||||
const { axis } = session;
|
||||
const c = Math.cos(deltaRad);
|
||||
const s = Math.sin(deltaRad);
|
||||
const d = axis.direction;
|
||||
let direction = normalize({ x: c * d.x - s * d.y, y: s * d.x + c * d.y });
|
||||
if (direction.x < -1e-9 || (Math.abs(direction.x) < 1e-9 && direction.y < 0)) {
|
||||
direction = scale(direction, -1);
|
||||
}
|
||||
const quarterTurns = Math.round(deltaRad / (Math.PI / 2));
|
||||
const swap = Math.abs(quarterTurns) % 2 === 1;
|
||||
const lengthPx = swap ? axis.widthPx : axis.lengthPx;
|
||||
const widthPx = swap ? axis.lengthPx : axis.widthPx;
|
||||
const halfL = lengthPx / 2;
|
||||
const halfW = widthPx / 2;
|
||||
const n = perp(direction);
|
||||
const corners: [Vec2, Vec2, Vec2, Vec2] = [
|
||||
add(add(axis.center, scale(direction, -halfL)), scale(n, -halfW)),
|
||||
add(add(axis.center, scale(direction, halfL)), scale(n, -halfW)),
|
||||
add(add(axis.center, scale(direction, halfL)), scale(n, halfW)),
|
||||
add(add(axis.center, scale(direction, -halfL)), scale(n, halfW)),
|
||||
];
|
||||
return {
|
||||
axis: { center: axis.center, direction, lengthPx, widthPx, corners },
|
||||
state: "pending",
|
||||
};
|
||||
}
|
||||
|
||||
/** scale = longest_axis_px / L (px per inch). */
|
||||
export function computeScale(lengthPx: number, lengthIn: number): number {
|
||||
if (lengthIn === 0) throw new Error("computeScale: lengthIn is 0");
|
||||
return lengthPx / lengthIn;
|
||||
}
|
||||
|
||||
/** Interior stations every WIDTH_SAMPLE_IN along the length (excludes exact ends). */
|
||||
export function widthStations(lengthIn: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (let s = WIDTH_SAMPLE_IN; s < lengthIn; s += WIDTH_SAMPLE_IN) {
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rayExtent(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
origin: Vec2,
|
||||
dir: Vec2,
|
||||
maxT: number,
|
||||
): number {
|
||||
let lo = 0;
|
||||
let hi = maxT;
|
||||
let t = 1;
|
||||
while (t < maxT && isOn(mask, width, Math.floor(origin.x + dir.x * t), Math.floor(origin.y + dir.y * t))) {
|
||||
t *= 2;
|
||||
}
|
||||
hi = Math.min(t, maxT);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const x = Math.floor(origin.x + dir.x * mid);
|
||||
const y = Math.floor(origin.y + dir.y * mid);
|
||||
if (isOn(mask, width, x, y)) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/**
|
||||
* TECH-SPEC §4.4: sample every 6" along confirmed length axis, perpendicular widths.
|
||||
*/
|
||||
export function sampleWidths(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
axis: LengthAxis,
|
||||
lengthIn: number,
|
||||
): WidthStats {
|
||||
const scalePx = computeScale(axis.lengthPx, lengthIn);
|
||||
const origin = sub(axis.center, scale(axis.direction, axis.lengthPx / 2));
|
||||
const n = perp(axis.direction);
|
||||
const maxT = Math.hypot(width, height) + 2;
|
||||
const samples: WidthSample[] = [];
|
||||
|
||||
for (const stationIn of widthStations(lengthIn)) {
|
||||
const pos = add(origin, scale(axis.direction, stationIn * scalePx));
|
||||
let probe = pos;
|
||||
if (!isOn(mask, width, Math.floor(probe.x), Math.floor(probe.y))) {
|
||||
const inward = scale(axis.direction, Math.sign(lengthIn / 2 - stationIn) || 1);
|
||||
for (let k = 1; k < 4; k++) {
|
||||
const cand = add(pos, scale(inward, k));
|
||||
if (isOn(mask, width, Math.floor(cand.x), Math.floor(cand.y))) {
|
||||
probe = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const plus = rayExtent(mask, width, height, probe, n, maxT);
|
||||
const minus = rayExtent(mask, width, height, probe, scale(n, -1), maxT);
|
||||
const widthPx = plus + minus;
|
||||
samples.push({ stationIn, widthIn: widthPx / scalePx });
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return { min: 0, max: 0, average: 0, samples };
|
||||
}
|
||||
let min = samples[0]!.widthIn;
|
||||
let max = samples[0]!.widthIn;
|
||||
let sum = 0;
|
||||
for (const s of samples) {
|
||||
if (s.widthIn < min) min = s.widthIn;
|
||||
if (s.widthIn > max) max = s.widthIn;
|
||||
sum += s.widthIn;
|
||||
}
|
||||
return { min, max, average: sum / samples.length, samples };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** TECH-SPEC section 7 factory and code constants for BG removal. */
|
||||
export const FILL_TARGET = 0.8;
|
||||
export const ASPECT_W = 3;
|
||||
export const ASPECT_H = 4;
|
||||
export const OUTPUT_PX_MIN = 1600;
|
||||
export const WIDTH_SAMPLE_IN = 6;
|
||||
export const INCH_STEP = 0.125;
|
||||
export const AREA_DECIMALS = 2;
|
||||
export const BDFT_DECIMALS = 2;
|
||||
export const PRICE_DECIMALS = 2;
|
||||
|
||||
export const SHEET_DEFAULT = "auto" as const;
|
||||
export const GREEN_HUE_LO = 35;
|
||||
export const GREEN_HUE_HI = 85;
|
||||
export const GREEN_SAT_MIN = 40;
|
||||
export const GREEN_VAL_MIN = 40;
|
||||
export const BLACK_THRESH = 30;
|
||||
|
||||
export const SENSITIVITY_DEFAULT = 0.5;
|
||||
export const EDGE_OFFSET_DEFAULT = 0;
|
||||
export const FEATHER_DEFAULT = 0;
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ASPECT_H, ASPECT_W, FILL_TARGET, OUTPUT_PX_MIN } from "./constants.js";
|
||||
import type { CropResult, CropStatus, LengthAxis, MaskBuffer, RgbaImage } from "./types.js";
|
||||
|
||||
/**
|
||||
* Find the bounding box of the slab in the mask.
|
||||
*/
|
||||
export function findSlabBounds(mask: MaskBuffer, width: number, height: number): {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
} {
|
||||
let left = width;
|
||||
let top = height;
|
||||
let right = 0;
|
||||
let bottom = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (mask[y * width + x]! >= 128) {
|
||||
if (x < left) left = x;
|
||||
if (x > right) right = x;
|
||||
if (y < top) top = y;
|
||||
if (y > bottom) bottom = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
widthPx: right - left + 1,
|
||||
heightPx: bottom - top + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute target 3:4 frame dimensions so the slab fills FILL_TARGET (80%)
|
||||
* at the extremes. Returns the frame size in pixels.
|
||||
*/
|
||||
export function computeCropTarget(slabWidthPx: number, slabHeightPx: number): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const targetAspect = ASPECT_W / ASPECT_H;
|
||||
const slabAspect = slabWidthPx / slabHeightPx;
|
||||
|
||||
if (slabAspect >= targetAspect) {
|
||||
// Width is limiting; height gets extra margin
|
||||
const frameHeight = slabHeightPx / FILL_TARGET;
|
||||
return { width: Math.round(frameHeight * targetAspect), height: Math.round(frameHeight) };
|
||||
} else {
|
||||
// Height is limiting; width gets extra margin
|
||||
const frameWidth = slabWidthPx / FILL_TARGET;
|
||||
return { width: Math.round(frameWidth), height: Math.round(frameWidth / targetAspect) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop the source to a 3:4 portrait frame, center the slab, with 80% fill
|
||||
* at extremes, transparent background. No upscaling.
|
||||
*
|
||||
* @param image Original source RGBA image.
|
||||
* @param mask Binary mask (255 = slab, 0 = background).
|
||||
* @param axis Confirmed length axis for slab bounds.
|
||||
* @param lengthIn User-entered length in inches (used to derive vertical scale).
|
||||
* @returns CropResult with the cropped image and metadata.
|
||||
*/
|
||||
export function cropTo34(
|
||||
image: RgbaImage,
|
||||
mask: MaskBuffer,
|
||||
axis: LengthAxis,
|
||||
lengthIn: number,
|
||||
): CropResult {
|
||||
const scalePx = axis.lengthPx / lengthIn;
|
||||
const slabBounds = findSlabBounds(mask, image.width, image.height);
|
||||
|
||||
const target = computeCropTarget(slabBounds.widthPx, slabBounds.heightPx);
|
||||
const { width: frameW, height: frameH } = target;
|
||||
|
||||
// Check minimum output size before any scaling
|
||||
const shorterSide = Math.min(frameW, frameH);
|
||||
const meetsMinPx = shorterSide >= OUTPUT_PX_MIN;
|
||||
|
||||
// Compute the scale factor: we want the slab to fill FILL_TARGET of the frame
|
||||
// at the extremes. The slab is scaled down by FILL_TARGET, then the frame
|
||||
// adds extra margin.
|
||||
const slabW = slabBounds.widthPx;
|
||||
const slabH = slabBounds.heightPx;
|
||||
const slabAspect = slabW / slabH;
|
||||
const targetAspect = ASPECT_W / ASPECT_H;
|
||||
|
||||
// Compute how much to scale the slab to fit 80% of the frame
|
||||
let scaleDown: number;
|
||||
if (slabAspect >= targetAspect) {
|
||||
// Width limiting: frameH = slabH / 0.8, frameW = frameH * 0.75
|
||||
// Scale factor for width: slabW / frameW
|
||||
scaleDown = slabW / frameW;
|
||||
} else {
|
||||
// Height limiting: frameW = slabW / 0.8, frameH = frameW / 0.75
|
||||
// Scale factor for height: slabH / frameH
|
||||
scaleDown = slabH / frameH;
|
||||
}
|
||||
|
||||
// Scaled slab dimensions within the frame
|
||||
const scaledSlabW = Math.round(slabW * FILL_TARGET);
|
||||
const scaledSlabH = Math.round(slabH * FILL_TARGET);
|
||||
|
||||
// Center position within the frame
|
||||
const offsetX = Math.round((frameW - scaledSlabW) / 2);
|
||||
const offsetY = Math.round((frameH - scaledSlabH) / 2);
|
||||
|
||||
// Create the output image
|
||||
const outData = new Uint8ClampedArray(frameW * frameH * 4);
|
||||
|
||||
// For each output pixel, compute which source pixel to sample
|
||||
for (let oy = 0; oy < frameH; oy++) {
|
||||
for (let ox = 0; ox < frameW; ox++) {
|
||||
const oi = (oy * frameW + ox) * 4;
|
||||
|
||||
// Check if this output pixel is within the slab region
|
||||
if (ox < offsetX || ox >= offsetX + scaledSlabW ||
|
||||
oy < offsetY || oy >= offsetY + scaledSlabH) {
|
||||
// Transparent margin
|
||||
outData[oi] = 0;
|
||||
outData[oi + 1] = 0;
|
||||
outData[oi + 2] = 0;
|
||||
outData[oi + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map to slab-local coordinates
|
||||
const slabX = ox - offsetX;
|
||||
const slabY = oy - offsetY;
|
||||
|
||||
// Nearest-neighbor sample from source
|
||||
const srcX = Math.round((slabX / scaledSlabW) * slabW) + slabBounds.left;
|
||||
const srcY = Math.round((slabY / scaledSlabH) * slabH) + slabBounds.top;
|
||||
|
||||
// Clamp to source bounds
|
||||
const sx = Math.min(Math.max(0, srcX), image.width - 1);
|
||||
const sy = Math.min(Math.max(0, srcY), image.height - 1);
|
||||
const si = (sy * image.width + sx) * 4;
|
||||
|
||||
outData[oi] = image.data[si]!;
|
||||
outData[oi + 1] = image.data[si + 1]!;
|
||||
outData[oi + 2] = image.data[si + 2]!;
|
||||
|
||||
// Alpha from the mask at the source location
|
||||
const maskVal = mask[sy * image.width + sx]!;
|
||||
outData[oi + 3] = maskVal;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
image: { width: frameW, height: frameH, data: outData },
|
||||
width: frameW,
|
||||
height: frameH,
|
||||
meetsMinPx,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a source image is too small to produce an OUTPUT_PX_MIN result.
|
||||
* Returns CropStatus.
|
||||
*/
|
||||
export function checkSourceSize(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
): CropStatus {
|
||||
const bounds = findSlabBounds(mask, width, height);
|
||||
const target = computeCropTarget(bounds.widthPx, bounds.heightPx);
|
||||
const shorterSide = Math.min(target.width, target.height);
|
||||
return shorterSide >= OUTPUT_PX_MIN ? "ok" : "undersized";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./constants.js";
|
||||
export * from "./types.js";
|
||||
export * from "./mask.js";
|
||||
export * from "./units.js";
|
||||
export * from "./axis.js";
|
||||
export * from "./crop.js";
|
||||
export * from "./pricing.js";
|
||||
export * from "./thicknessBand.js";
|
||||
export * from "../validation/index.js";
|
||||
@@ -0,0 +1,326 @@
|
||||
import type {
|
||||
KnobSettings,
|
||||
MaskBuffer,
|
||||
MaskResult,
|
||||
MaskSession,
|
||||
RgbaImage,
|
||||
ResolvedSheet,
|
||||
} from "./types.js";
|
||||
import {
|
||||
BLACK_THRESH,
|
||||
EDGE_OFFSET_DEFAULT,
|
||||
FEATHER_DEFAULT,
|
||||
GREEN_HUE_HI,
|
||||
GREEN_HUE_LO,
|
||||
GREEN_SAT_MIN,
|
||||
GREEN_VAL_MIN,
|
||||
SENSITIVITY_DEFAULT,
|
||||
SHEET_DEFAULT,
|
||||
} from "./constants.js";
|
||||
|
||||
export function defaultKnobs(): KnobSettings {
|
||||
return {
|
||||
sheet: SHEET_DEFAULT,
|
||||
sensitivity: SENSITIVITY_DEFAULT,
|
||||
edgeOffset: EDGE_OFFSET_DEFAULT,
|
||||
feather: FEATHER_DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === rn) h = ((gn - bn) / d) % 6;
|
||||
else if (max === gn) h = (bn - rn) / d + 2;
|
||||
else h = (rn - gn) / d + 4;
|
||||
h *= 60;
|
||||
if (h < 0) h += 360;
|
||||
}
|
||||
const s = max === 0 ? 0 : (d / max) * 100;
|
||||
const v = max * 100;
|
||||
return { h, s, v };
|
||||
}
|
||||
|
||||
function pixelRgb(image: RgbaImage, x: number, y: number): [number, number, number] {
|
||||
const i = (y * image.width + x) * 4;
|
||||
return [image.data[i]!, image.data[i + 1]!, image.data[i + 2]!];
|
||||
}
|
||||
|
||||
/** TECH-SPEC section 3.1 auto-detect from 2-5% border strips. */
|
||||
export function detectSheet(image: RgbaImage): ResolvedSheet | "ambiguous" {
|
||||
const strip = Math.max(1, Math.floor(Math.min(image.width, image.height) * 0.03));
|
||||
let hSum = 0;
|
||||
let sSum = 0;
|
||||
let vSum = 0;
|
||||
let n = 0;
|
||||
const sample = (x: number, y: number) => {
|
||||
const [r, g, b] = pixelRgb(image, x, y);
|
||||
const hsv = rgbToHsv(r, g, b);
|
||||
hSum += hsv.h;
|
||||
sSum += hsv.s;
|
||||
vSum += hsv.v;
|
||||
n += 1;
|
||||
};
|
||||
for (let x = 0; x < image.width; x++) {
|
||||
for (let t = 0; t < strip; t++) {
|
||||
sample(x, t);
|
||||
sample(x, image.height - 1 - t);
|
||||
}
|
||||
}
|
||||
for (let y = strip; y < image.height - strip; y++) {
|
||||
for (let t = 0; t < strip; t++) {
|
||||
sample(t, y);
|
||||
sample(image.width - 1 - t, y);
|
||||
}
|
||||
}
|
||||
if (n === 0) return "ambiguous";
|
||||
const h = hSum / n;
|
||||
const s = sSum / n;
|
||||
const v = vSum / n;
|
||||
if (h >= GREEN_HUE_LO && h <= GREEN_HUE_HI && s >= GREEN_SAT_MIN) return "green";
|
||||
if (v <= BLACK_THRESH && s <= 20) return "black";
|
||||
return "ambiguous";
|
||||
}
|
||||
|
||||
function resolveSheet(image: RgbaImage, knobs: KnobSettings): ResolvedSheet {
|
||||
if (knobs.sheet === "green" || knobs.sheet === "black") return knobs.sheet;
|
||||
const detected = detectSheet(image);
|
||||
if (detected === "ambiguous") return "green";
|
||||
return detected;
|
||||
}
|
||||
|
||||
function greenSheetCandidate(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
sensitivity: number,
|
||||
): boolean {
|
||||
const { h, s, v } = rgbToHsv(r, g, b);
|
||||
const widen = (sensitivity - 0.5) * 20;
|
||||
const hueLo = GREEN_HUE_LO - widen;
|
||||
const hueHi = GREEN_HUE_HI + widen;
|
||||
const satMin = Math.max(5, GREEN_SAT_MIN - widen);
|
||||
const valMin = Math.max(5, GREEN_VAL_MIN - widen);
|
||||
return h >= hueLo && h <= hueHi && s >= satMin && v >= valMin;
|
||||
}
|
||||
|
||||
function blackSheetCandidate(r: number, g: number, b: number, sensitivity: number): boolean {
|
||||
const { s, v } = rgbToHsv(r, g, b);
|
||||
const loosen = (sensitivity - 0.5) * 20;
|
||||
const thresh = BLACK_THRESH + loosen;
|
||||
return v <= thresh && s <= 20 + Math.max(0, loosen);
|
||||
}
|
||||
|
||||
function buildSheetCandidates(
|
||||
image: RgbaImage,
|
||||
sheet: ResolvedSheet,
|
||||
sensitivity: number,
|
||||
): Uint8Array {
|
||||
const out = new Uint8Array(image.width * image.height);
|
||||
for (let y = 0; y < image.height; y++) {
|
||||
for (let x = 0; x < image.width; x++) {
|
||||
const [r, g, b] = pixelRgb(image, x, y);
|
||||
const isSheet =
|
||||
sheet === "green"
|
||||
? greenSheetCandidate(r, g, b, sensitivity)
|
||||
: blackSheetCandidate(r, g, b, sensitivity);
|
||||
out[y * image.width + x] = isSheet ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Flood-fill sheet candidates reachable from the image border. */
|
||||
function borderConnectedSheet(candidates: Uint8Array, width: number, height: number): Uint8Array {
|
||||
const removed = new Uint8Array(width * height);
|
||||
const stack: number[] = [];
|
||||
const push = (x: number, y: number) => {
|
||||
const i = y * width + x;
|
||||
if (candidates[i] !== 1 || removed[i] === 1) return;
|
||||
removed[i] = 1;
|
||||
stack.push(i);
|
||||
};
|
||||
for (let x = 0; x < width; x++) {
|
||||
push(x, 0);
|
||||
push(x, height - 1);
|
||||
}
|
||||
for (let y = 0; y < height; y++) {
|
||||
push(0, y);
|
||||
push(width - 1, y);
|
||||
}
|
||||
while (stack.length) {
|
||||
const i = stack.pop()!;
|
||||
const x = i % width;
|
||||
const y = (i / width) | 0;
|
||||
if (x > 0) push(x - 1, y);
|
||||
if (x + 1 < width) push(x + 1, y);
|
||||
if (y > 0) push(x, y - 1);
|
||||
if (y + 1 < height) push(x, y + 1);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function morphClose(mask: MaskBuffer, width: number, height: number): MaskBuffer {
|
||||
const dilate = new Uint8Array(mask.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let max = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const xx = x + dx;
|
||||
const yy = y + dy;
|
||||
if (xx < 0 || yy < 0 || xx >= width || yy >= height) continue;
|
||||
const v = mask[yy * width + xx]!;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
}
|
||||
dilate[y * width + x] = max;
|
||||
}
|
||||
}
|
||||
const erode = new Uint8Array(mask.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let min = 255;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const xx = x + dx;
|
||||
const yy = y + dy;
|
||||
if (xx < 0 || yy < 0 || xx >= width || yy >= height) {
|
||||
min = 0;
|
||||
continue;
|
||||
}
|
||||
const v = dilate[yy * width + xx]!;
|
||||
if (v < min) min = v;
|
||||
}
|
||||
}
|
||||
erode[y * width + x] = min;
|
||||
}
|
||||
}
|
||||
return erode;
|
||||
}
|
||||
|
||||
function applyEdgeOffset(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
offset: number,
|
||||
): MaskBuffer {
|
||||
if (offset === 0) return mask;
|
||||
const steps = Math.abs(Math.round(offset));
|
||||
let current = mask;
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const next = new Uint8Array(current.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let agg = offset > 0 ? 255 : 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const xx = x + dx;
|
||||
const yy = y + dy;
|
||||
if (xx < 0 || yy < 0 || xx >= width || yy >= height) {
|
||||
if (offset > 0) agg = 0;
|
||||
continue;
|
||||
}
|
||||
const v = current[yy * width + xx]!;
|
||||
if (offset > 0) {
|
||||
if (v < agg) agg = v;
|
||||
} else if (v > agg) {
|
||||
agg = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
next[y * width + x] = agg;
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function applyFeather(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
feather: number,
|
||||
): MaskBuffer {
|
||||
const radius = Math.max(0, Math.round(feather));
|
||||
if (radius === 0) return mask;
|
||||
const out = new Uint8Array(mask.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = y * width + x;
|
||||
if (mask[i]! === 0) {
|
||||
out[i] = 0;
|
||||
continue;
|
||||
}
|
||||
let minDist = radius + 1;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const xx = x + dx;
|
||||
const yy = y + dy;
|
||||
if (xx < 0 || yy < 0 || xx >= width || yy >= height || mask[yy * width + xx]! === 0) {
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (minDist > radius) out[i] = 255;
|
||||
else out[i] = Math.round(255 * (minDist / (radius + 1)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function removeSheetBackground(
|
||||
image: RgbaImage,
|
||||
knobs: KnobSettings = defaultKnobs(),
|
||||
): MaskResult {
|
||||
const sheetUsed = resolveSheet(image, knobs);
|
||||
const candidates = buildSheetCandidates(image, sheetUsed, knobs.sensitivity);
|
||||
const removed = borderConnectedSheet(candidates, image.width, image.height);
|
||||
let mask: MaskBuffer = new Uint8Array(image.width * image.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
mask[i] = removed[i] === 1 ? 0 : 255;
|
||||
}
|
||||
mask = morphClose(mask, image.width, image.height);
|
||||
mask = applyEdgeOffset(mask, image.width, image.height, knobs.edgeOffset);
|
||||
mask = applyFeather(mask, image.width, image.height, knobs.feather);
|
||||
return {
|
||||
mask,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
sheetUsed,
|
||||
u2netInvoked: false,
|
||||
knobs: { ...knobs },
|
||||
};
|
||||
}
|
||||
|
||||
export function reRunMask(image: RgbaImage, knobs: KnobSettings): MaskResult {
|
||||
return removeSheetBackground(image, knobs);
|
||||
}
|
||||
|
||||
export function createMaskSession(result: MaskResult): MaskSession {
|
||||
return { result, state: "pending" };
|
||||
}
|
||||
|
||||
export function confirmMask(session: MaskSession): MaskSession {
|
||||
return { ...session, state: "confirmed" };
|
||||
}
|
||||
|
||||
export function requestRetake(session: MaskSession): MaskSession {
|
||||
return { ...session, state: "retake" };
|
||||
}
|
||||
|
||||
export {
|
||||
GREEN_HUE_LO,
|
||||
GREEN_HUE_HI,
|
||||
GREEN_SAT_MIN,
|
||||
GREEN_VAL_MIN,
|
||||
BLACK_THRESH,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { INCH_STEP, PRICE_DECIMALS } from "./constants.js";
|
||||
|
||||
/** Pricing rule synced from WooCommerce (leaf species). */
|
||||
export interface PricingRule {
|
||||
species: string;
|
||||
pricePerBdft: number;
|
||||
}
|
||||
|
||||
/** Result of pricing computation for a slab. */
|
||||
export interface PricingResult {
|
||||
/** Recommended price from the pricing rule, or null when no rule exists. */
|
||||
recommendedPrice: number | null;
|
||||
/** The price to store (rounded half-up to 2 decimals). */
|
||||
finalPrice: number;
|
||||
/** Where the stored price came from. */
|
||||
priceSource: "recommendation" | "override";
|
||||
}
|
||||
|
||||
/**
|
||||
* TECH-SPEC §6 pricing: bdft × price_per_bdft, rounded half-up to 2 decimals.
|
||||
*
|
||||
* @param bdft — board feet (2 decimals, already rounded by caller)
|
||||
* @param pricePerBdft — species price per board foot from Settings
|
||||
* @returns recommended price rounded to 2 decimals
|
||||
*/
|
||||
export function computeRecommendedPrice(
|
||||
bdft: number,
|
||||
pricePerBdft: number,
|
||||
): number {
|
||||
const factor = 10 ** PRICE_DECIMALS;
|
||||
return Math.floor(Math.round(bdft * pricePerBdft * 100) / 100 * factor + 0.5) / factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* TECH-SPEC §6 pricing with override and missing-rule handling.
|
||||
*
|
||||
* Three paths:
|
||||
* 1. Has rule, no override → recommended price, source "recommendation"
|
||||
* 2. Has rule, override → override stored, source "override"
|
||||
* 3. No rule, manual price → manual stored, source "override"
|
||||
*
|
||||
* @param bdft — board feet (2 decimals)
|
||||
* @param pricingRule — pricing rule for the species, or undefined if none
|
||||
* @param userOverride — manual price entered by the user, if any
|
||||
* @returns pricing result with recommended price, final stored price, and source
|
||||
*/
|
||||
export function computeSlabPrice(
|
||||
bdft: number,
|
||||
pricingRule?: PricingRule,
|
||||
userOverride?: number,
|
||||
): PricingResult {
|
||||
const recommendedPrice =
|
||||
pricingRule != null
|
||||
? computeRecommendedPrice(bdft, pricingRule.pricePerBdft)
|
||||
: null;
|
||||
|
||||
if (userOverride != null) {
|
||||
return {
|
||||
recommendedPrice,
|
||||
finalPrice: roundPrice(userOverride),
|
||||
priceSource: "override",
|
||||
};
|
||||
}
|
||||
|
||||
if (recommendedPrice != null) {
|
||||
return {
|
||||
recommendedPrice,
|
||||
finalPrice: recommendedPrice,
|
||||
priceSource: "recommendation",
|
||||
};
|
||||
}
|
||||
|
||||
// No rule, no override — this should not happen at publish,
|
||||
// but return a valid shape with null recommended and 0 final.
|
||||
return {
|
||||
recommendedPrice: null,
|
||||
finalPrice: 0,
|
||||
priceSource: "override",
|
||||
};
|
||||
}
|
||||
|
||||
/** Round price half-up to 2 decimals. */
|
||||
function roundPrice(price: number): number {
|
||||
if (!Number.isFinite(price)) {
|
||||
return price;
|
||||
}
|
||||
const factor = 10 ** PRICE_DECIMALS;
|
||||
const scaled = Math.abs(price) * factor;
|
||||
const rounded = Math.floor(scaled + 0.5) / factor;
|
||||
return price < 0 ? -rounded : rounded;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { INCH_STEP } from "./constants.js";
|
||||
|
||||
/**
|
||||
* A WooCommerce attribute term for the Thickness attribute.
|
||||
* Each term represents a thickness range (e.g. "1"–1 1/2"").
|
||||
*
|
||||
* The range is half-open: [minIn, maxIn). A thickness equal to minIn
|
||||
* belongs to this range; a thickness equal to maxIn belongs to the
|
||||
* next higher range. This implements the "rounds up" rule from
|
||||
* TECH-SPEC §2: "Thickness Woo attribute — same pick, rounded UP
|
||||
* into a Woo range."
|
||||
*/
|
||||
export interface ThicknessRange {
|
||||
wooId: number;
|
||||
/** Display label from Woo, e.g. '1"–1 1/2"'. */
|
||||
label: string;
|
||||
/** Lower bound in inches (inclusive). */
|
||||
minIn: number;
|
||||
/** Upper bound in inches (exclusive). */
|
||||
maxIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Woo thickness range that a given thickness rounds up into.
|
||||
*
|
||||
* The thickness is first rounded to the nearest 1/8" step, then matched
|
||||
* against the half-open ranges [minIn, maxIn). If the thickness falls
|
||||
* below the lowest range or above the highest, null is returned.
|
||||
*
|
||||
* @param thicknessIn — thickness in inches (1/8" steps)
|
||||
* @param ranges — sorted thickness ranges from Woo attribute sync
|
||||
* @returns the matching range, or null if no range contains the value
|
||||
*/
|
||||
export function findThicknessBand(
|
||||
thicknessIn: number,
|
||||
ranges: ThicknessRange[],
|
||||
): ThicknessRange | null {
|
||||
const rounded = Math.round(thicknessIn / INCH_STEP) * INCH_STEP;
|
||||
|
||||
for (const range of ranges) {
|
||||
if (rounded >= range.minIn && rounded < range.maxIn) {
|
||||
return range;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type SheetMode = "auto" | "green" | "black";
|
||||
export type ResolvedSheet = "green" | "black";
|
||||
|
||||
export interface KnobSettings {
|
||||
sheet: SheetMode;
|
||||
/** 0..1 — looser sheet detection at higher values. */
|
||||
sensitivity: number;
|
||||
/** Signed pixels: positive pulls mask inward onto wood. */
|
||||
edgeOffset: number;
|
||||
/** Soft alpha blend width in pixels. */
|
||||
feather: number;
|
||||
}
|
||||
|
||||
export interface RgbaImage {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Row-major RGBA, length width*height*4. */
|
||||
data: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
/** 0 = removed background, 255 = kept slab (or intermediate for feather). */
|
||||
export type MaskBuffer = Uint8Array;
|
||||
|
||||
export type MaskConfirmState = "pending" | "confirmed" | "retake";
|
||||
|
||||
export interface MaskResult {
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
sheetUsed: ResolvedSheet;
|
||||
/** POC contract: always false (U2Net deferred). */
|
||||
u2netInvoked: false;
|
||||
knobs: KnobSettings;
|
||||
}
|
||||
|
||||
export interface MaskSession {
|
||||
result: MaskResult;
|
||||
state: MaskConfirmState;
|
||||
}
|
||||
|
||||
|
||||
export interface Vec2 {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Min-area long-side axis (TECH-SPEC §4.1). */
|
||||
export interface LengthAxis {
|
||||
center: Vec2;
|
||||
/** Unit vector along prevailing length. */
|
||||
direction: Vec2;
|
||||
lengthPx: number;
|
||||
widthPx: number;
|
||||
corners: [Vec2, Vec2, Vec2, Vec2];
|
||||
}
|
||||
|
||||
export type AxisConfirmState = "pending" | "confirmed";
|
||||
|
||||
export interface AxisSession {
|
||||
axis: LengthAxis;
|
||||
state: AxisConfirmState;
|
||||
}
|
||||
|
||||
export interface WidthSample {
|
||||
stationIn: number;
|
||||
widthIn: number;
|
||||
}
|
||||
|
||||
export interface WidthStats {
|
||||
min: number;
|
||||
max: number;
|
||||
average: number;
|
||||
samples: WidthSample[];
|
||||
}
|
||||
|
||||
/** 3:4 crop output with alpha channel (TECH-SPEC §5). */
|
||||
export interface CropResult {
|
||||
/** Cropped image with alpha: 255 = slab, 0 = transparent background. */
|
||||
image: RgbaImage;
|
||||
/** Width of the 3:4 output frame in pixels. */
|
||||
width: number;
|
||||
/** Height of the 3:4 output frame in pixels. */
|
||||
height: number;
|
||||
/** True when shorter side meets OUTPUT_PX_MIN; false when undersized. */
|
||||
meetsMinPx: boolean;
|
||||
}
|
||||
|
||||
/** Crop status for TV-7 (undersized source). */
|
||||
export type CropStatus = "ok" | "undersized";
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
AREA_DECIMALS,
|
||||
BDFT_DECIMALS,
|
||||
INCH_STEP,
|
||||
PRICE_DECIMALS,
|
||||
} from "./constants.js";
|
||||
|
||||
/** Half-up at .5 (away from zero). */
|
||||
export function roundHalfUp(value: number, decimals: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
const factor = 10 ** decimals;
|
||||
const scaled = Math.abs(value) * factor;
|
||||
const rounded = Math.floor(scaled + 0.5) / factor;
|
||||
return value < 0 ? -rounded : rounded;
|
||||
}
|
||||
|
||||
/** Nearest INCH_STEP (1/8"). */
|
||||
export function roundLengthIn(lengthIn: number): number {
|
||||
return Math.round(lengthIn / INCH_STEP) * INCH_STEP;
|
||||
}
|
||||
|
||||
export function roundSqft(sqft: number): number {
|
||||
return roundHalfUp(sqft, AREA_DECIMALS);
|
||||
}
|
||||
|
||||
export function roundBdft(bdft: number): number {
|
||||
return roundHalfUp(bdft, BDFT_DECIMALS);
|
||||
}
|
||||
|
||||
export function roundPrice(price: number): number {
|
||||
return roundHalfUp(price, PRICE_DECIMALS);
|
||||
}
|
||||
|
||||
/** bdft = sqft × thickness_in. No divide-by-12. */
|
||||
export function computeBdft(sqft: number, thicknessIn: number): number {
|
||||
return roundBdft(sqft * thicknessIn);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** WooCommerce field max-length constants. */
|
||||
|
||||
/** WordPress post_title practical limit; WooCommerce product name field. */
|
||||
export const MAX_TITLE_LENGTH = 200;
|
||||
|
||||
/** WordPress post_excerpt practical limit; WooCommerce short_description field. */
|
||||
export const MAX_SHORT_TITLE_LENGTH = 200;
|
||||
|
||||
/** Reasonable product description limit. WooCommerce description is TEXT (no hard DB limit). */
|
||||
export const MAX_DESCRIPTION_LENGTH = 2500;
|
||||
|
||||
/** SKU charset: uppercase letters, digits, hyphen. 3 to 24 characters. */
|
||||
export const SKU_REGEX = /^[A-Z0-9-]{3,24}$/;
|
||||
|
||||
/** Validation module version (bump on rule change). */
|
||||
export const VALIDATION_MODULE_VERSION = "v1";
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_SHORT_TITLE_LENGTH,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
} from "./constants.js";
|
||||
|
||||
/** Content field validation result. */
|
||||
export interface ContentValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Content fields for a listing. */
|
||||
export interface ContentFields {
|
||||
title: string | null;
|
||||
shortTitle: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/** Title validation result. */
|
||||
export interface TitleValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count Unicode characters (code points), matching WooCommerce PHP mb_strlen().
|
||||
* JavaScript .length counts UTF-16 code units, which double-counts emoji.
|
||||
*/
|
||||
function charLen(s: string): number {
|
||||
return [...s].length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a title field against WooCommerce maxLength.
|
||||
*/
|
||||
export function validateTitle(title: string | null): TitleValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (title !== null && charLen(title) > MAX_TITLE_LENGTH) {
|
||||
errors.push("exceeds_max_length");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a short title field against WooCommerce maxLength.
|
||||
*/
|
||||
export function validateShortTitle(shortTitle: string | null): ContentValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (shortTitle !== null && charLen(shortTitle) > MAX_SHORT_TITLE_LENGTH) {
|
||||
errors.push("exceeds_max_length");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a description field against WooCommerce maxLength.
|
||||
*/
|
||||
export function validateDescription(description: string | null): ContentValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (description !== null && charLen(description) > MAX_DESCRIPTION_LENGTH) {
|
||||
errors.push("exceeds_max_length");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic content field validation for a named field with a max length.
|
||||
* Used for field-exit validation and at submit.
|
||||
*/
|
||||
export function validateContentField(fieldName: string, value: string | null): ContentValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (value === null) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
const maxLen =
|
||||
fieldName === "title"
|
||||
? MAX_TITLE_LENGTH
|
||||
: fieldName === "short_title" || fieldName === "shortTitle"
|
||||
? MAX_SHORT_TITLE_LENGTH
|
||||
: MAX_DESCRIPTION_LENGTH;
|
||||
|
||||
if (charLen(value) > maxLen) {
|
||||
errors.push("exceeds_max_length");
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all content fields at once. Returns true when all fields pass.
|
||||
*/
|
||||
export function isContentValid(fields: ContentFields): boolean {
|
||||
const titleValid = validateTitle(fields.title).valid;
|
||||
const shortTitleValid = validateShortTitle(fields.shortTitle).valid;
|
||||
const descriptionValid = validateDescription(fields.description).valid;
|
||||
return titleValid && shortTitleValid && descriptionValid;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { VALIDATION_MODULE_VERSION } from "./constants.js";
|
||||
import {
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_SHORT_TITLE_LENGTH,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
} from "./constants.js";
|
||||
|
||||
/** A rule definition from the validation module. */
|
||||
export interface ValidationRule {
|
||||
field: string;
|
||||
type: string;
|
||||
constraint: string;
|
||||
maxLen?: number;
|
||||
pattern?: string;
|
||||
}
|
||||
|
||||
/** The full validation module payload the server sends to the client. */
|
||||
export interface ValidationModule {
|
||||
hash: string;
|
||||
version: string;
|
||||
rules: ValidationRule[];
|
||||
}
|
||||
|
||||
/** Result of validating a client-supplied hash against the current module. */
|
||||
export interface ModuleValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
/** Present only on mismatch (412): fresh module to replace client's stale copy. */
|
||||
freshModule?: ValidationModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the current validation module hash from the constants.
|
||||
* Deterministic: same constants → same hash.
|
||||
* Bump VALIDATION_MODULE_VERSION when rules change.
|
||||
*/
|
||||
export function computeModuleHash(): string {
|
||||
return VALIDATION_MODULE_VERSION;
|
||||
}
|
||||
|
||||
/** Build the full validation module with all current rules. */
|
||||
export function buildValidationModule(): ValidationModule {
|
||||
return {
|
||||
hash: computeModuleHash(),
|
||||
version: VALIDATION_MODULE_VERSION,
|
||||
rules: [
|
||||
{
|
||||
field: "sku",
|
||||
type: "regex",
|
||||
constraint: "uppercase_alphanumeric_hyphen",
|
||||
pattern: "^[A-Z0-9-]{3,24}$",
|
||||
},
|
||||
{
|
||||
field: "title",
|
||||
type: "maxLength",
|
||||
constraint: "woocommerce_name",
|
||||
maxLen: MAX_TITLE_LENGTH,
|
||||
},
|
||||
{
|
||||
field: "short_title",
|
||||
type: "maxLength",
|
||||
constraint: "woocommerce_short_description",
|
||||
maxLen: MAX_SHORT_TITLE_LENGTH,
|
||||
},
|
||||
{
|
||||
field: "description",
|
||||
type: "maxLength",
|
||||
constraint: "woocommerce_description",
|
||||
maxLen: MAX_DESCRIPTION_LENGTH,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a client-supplied hash against the current module.
|
||||
*
|
||||
* - Match: valid, no errors, no fresh module.
|
||||
* - Mismatch: invalid, "validation_module_stale" error, fresh module attached
|
||||
* (maps to 412 response, not 409).
|
||||
*/
|
||||
export function validateModuleHash(clientHash: string): ModuleValidationResult {
|
||||
const currentHash = computeModuleHash();
|
||||
|
||||
if (clientHash === currentHash) {
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: ["validation_module_stale"],
|
||||
freshModule: buildValidationModule(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./constants.js";
|
||||
export * from "./sku.js";
|
||||
export * from "./content.js";
|
||||
export * from "./hash.js";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SKU_REGEX } from "./constants.js";
|
||||
|
||||
/** SKU validation result. */
|
||||
export interface SkuValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a SKU string against the charset [A-Z0-9-]{3,24}.
|
||||
*
|
||||
* Returns errors for: too_short, too_long, invalid_charset.
|
||||
* Multiple errors possible (e.g. too_long + invalid_charset).
|
||||
*/
|
||||
export function validateSku(sku: string): SkuValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (sku.length < 3) {
|
||||
errors.push("too_short");
|
||||
}
|
||||
if (sku.length > 24) {
|
||||
errors.push("too_long");
|
||||
}
|
||||
if (!SKU_REGEX.test(sku)) {
|
||||
errors.push("invalid_charset");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/** Quick boolean check for SKU validity. */
|
||||
export function isSkuValid(sku: string): boolean {
|
||||
return SKU_REGEX.test(sku);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vitest/globals"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/__tests__/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||
|
||||
stdout | src/__tests__/tv3-tv10-units.test.ts > TV-3 Scale, sqft, bdft > bdft = sqft × thickness_in with no divide-by-12
|
||||
{"case":"TV-3","lengthIn":96,"thicknessIn":1.5,"sqft":0.64,"bdft":0.96,"expectedBdft":0.96,"identity_1sqft_1in":1}
|
||||
|
||||
stdout | src/__tests__/tv3-tv10-units.test.ts > TV-10 Rounding > half-up / nearest-step matches TECH-SPEC vectors
|
||||
{"case":"TV-10","length":{"raw":96.04,"rounded":96,"expected":96},"sqft":{"raw":0.641,"rounded":0.64,"expected":0.64},"bdft":{"raw":11.525,"rounded":11.53,"expected":11.53},"price":{"raw":12.345,"rounded":12.35,"expected":12.35}}
|
||||
|
||||
✓ src/__tests__/tv3-tv10-units.test.ts (3 tests) 5ms
|
||||
stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-4 Width samples at 6" stations > reports min, max, average perpendicular to confirmed axis on 96" length
|
||||
{"case":"TV-4","lengthPx":96,"widthPx":24,"scale":1,"stations":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90],"widths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":23.999999046325684,"max":23.999999046325684,"average":23.999999046325684,"expectedWidthIn":24}
|
||||
|
||||
stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-5 Angled-end length axis > follows prevailing length; widths stay perpendicular after confirm
|
||||
{"case":"TV-5","direction":{"x":1,"y":0},"lengthPx":96,"widthPx":24,"bodySampleCount":12,"bodyWidths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":0,"max":23.999999046325684,"average":21.19999910990397,"rotatedDirection":{"x":6.123233995736766e-17,"y":1}}
|
||||
|
||||
✓ src/__tests__/tv4-tv5-axis.test.ts (2 tests) 29ms
|
||||
✓ src/__tests__/tv1-tv2-mask.test.ts (5 tests) 35ms
|
||||
|
||||
Test Files 3 passed (3)
|
||||
Tests 10 passed (10)
|
||||
Start at 06:06:43
|
||||
Duration 460ms (transform 206ms, setup 0ms, collect 319ms, tests 69ms, environment 1ms, prepare 212ms)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||
|
||||
✓ src/__tests__/tv1-tv2-mask.test.ts (5 tests) 34ms
|
||||
|
||||
Test Files 1 passed (1)
|
||||
Tests 5 passed (5)
|
||||
Start at 05:53:31
|
||||
Duration 456ms (transform 81ms, setup 0ms, collect 85ms, tests 34ms, environment 0ms, prepare 64ms)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||
|
||||
❯ src/__tests__/tv1-tv2-mask.test.ts (5 tests | 5 failed) 14ms
|
||||
× TV-1 green-sheet chroma-key > keeps interior green-ish patch that does not touch the border 8ms
|
||||
→ removeSheetBackground not implemented
|
||||
× TV-1 green-sheet chroma-key > re-runs live when knobs change and still skips U2Net 1ms
|
||||
→ removeSheetBackground not implemented
|
||||
× TV-2 black-sheet threshold + border flood-fill > keeps dark interior wood that does not touch the border 1ms
|
||||
→ removeSheetBackground not implemented
|
||||
× TV-2 black-sheet threshold + border flood-fill > flood-fill removes only border-connected dark pixels 1ms
|
||||
→ removeSheetBackground not implemented
|
||||
× mask confirm hooks (pure) > starts pending, confirm and retake transition without DOM 2ms
|
||||
→ removeSheetBackground not implemented
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-1 green-sheet chroma-key > keeps interior green-ish patch that does not touch the border
|
||||
Error: removeSheetBackground not implemented
|
||||
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||
29| _knobs: KnobSettings = defaultKnobs(),
|
||||
30| ): MaskResult {
|
||||
31| throw new Error("removeSheetBackground not implemented");
|
||||
| ^
|
||||
32| }
|
||||
33|
|
||||
❯ src/__tests__/tv1-tv2-mask.test.ts:20:20
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯
|
||||
|
||||
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-1 green-sheet chroma-key > re-runs live when knobs change and still skips U2Net
|
||||
Error: removeSheetBackground not implemented
|
||||
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||
29| _knobs: KnobSettings = defaultKnobs(),
|
||||
30| ): MaskResult {
|
||||
31| throw new Error("removeSheetBackground not implemented");
|
||||
| ^
|
||||
32| }
|
||||
33|
|
||||
❯ src/__tests__/tv1-tv2-mask.test.ts:33:19
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯
|
||||
|
||||
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-2 black-sheet threshold + border flood-fill > keeps dark interior wood that does not touch the border
|
||||
Error: removeSheetBackground not implemented
|
||||
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||
29| _knobs: KnobSettings = defaultKnobs(),
|
||||
30| ): MaskResult {
|
||||
31| throw new Error("removeSheetBackground not implemented");
|
||||
| ^
|
||||
32| }
|
||||
33|
|
||||
❯ src/__tests__/tv1-tv2-mask.test.ts:51:20
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯
|
||||
|
||||
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-2 black-sheet threshold + border flood-fill > flood-fill removes only border-connected dark pixels
|
||||
Error: removeSheetBackground not implemented
|
||||
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||
29| _knobs: KnobSettings = defaultKnobs(),
|
||||
30| ): MaskResult {
|
||||
31| throw new Error("removeSheetBackground not implemented");
|
||||
| ^
|
||||
32| }
|
||||
33|
|
||||
❯ src/__tests__/tv1-tv2-mask.test.ts:64:20
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯
|
||||
|
||||
FAIL src/__tests__/tv1-tv2-mask.test.ts > mask confirm hooks (pure) > starts pending, confirm and retake transition without DOM
|
||||
Error: removeSheetBackground not implemented
|
||||
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||
29| _knobs: KnobSettings = defaultKnobs(),
|
||||
30| ): MaskResult {
|
||||
31| throw new Error("removeSheetBackground not implemented");
|
||||
| ^
|
||||
32| }
|
||||
33|
|
||||
❯ src/__tests__/tv1-tv2-mask.test.ts:76:20
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 5 failed (5)
|
||||
Start at 05:52:50
|
||||
Duration 383ms (transform 68ms, setup 0ms, collect 70ms, tests 14ms, environment 0ms, prepare 67ms)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||
|
||||
stdout | src/__tests__/tv3-tv10-units.test.ts > TV-3 Scale, sqft, bdft > bdft = sqft × thickness_in with no divide-by-12
|
||||
{"case":"TV-3","lengthIn":96,"thicknessIn":1.5,"sqft":0.64,"bdft":0.96,"expectedBdft":0.96,"identity_1sqft_1in":1}
|
||||
|
||||
stdout | src/__tests__/tv3-tv10-units.test.ts > TV-10 Rounding > half-up / nearest-step matches TECH-SPEC vectors
|
||||
{"case":"TV-10","length":{"raw":96.04,"rounded":96,"expected":96},"sqft":{"raw":0.641,"rounded":0.64,"expected":0.64},"bdft":{"raw":11.525,"rounded":11.53,"expected":11.53},"price":{"raw":12.345,"rounded":12.35,"expected":12.35}}
|
||||
|
||||
✓ src/__tests__/tv3-tv10-units.test.ts (3 tests) 7ms
|
||||
|
||||
Test Files 1 passed (1)
|
||||
Tests 3 passed (3)
|
||||
Start at 06:02:02
|
||||
Duration 377ms (transform 61ms, setup 0ms, collect 63ms, tests 7ms, environment 0ms, prepare 67ms)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||
|
||||
❯ src/__tests__/tv3-tv10-units.test.ts (0 test)
|
||||
|
||||
⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/__tests__/tv3-tv10-units.test.ts [ src/__tests__/tv3-tv10-units.test.ts ]
|
||||
Error: Failed to load url ../lib/pipeline/units.js (resolved id: ../lib/pipeline/units.js) in /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend/src/__tests__/tv3-tv10-units.test.ts. Does the file exist?
|
||||
❯ loadAndTransform node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:51969:17
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests no tests
|
||||
Start at 06:01:33
|
||||
Duration 344ms (transform 42ms, setup 0ms, collect 0ms, tests 0ms, environment 0ms, prepare 69ms)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||
|
||||
stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-4 Width samples at 6" stations > reports min, max, average perpendicular to confirmed axis on 96" length
|
||||
{"case":"TV-4","lengthPx":96,"widthPx":24,"scale":1,"stations":[6,12,18,24,30,36,42,48,54,60,66,72,78,84,90],"widths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":23.999999046325684,"max":23.999999046325684,"average":23.999999046325684,"expectedWidthIn":24}
|
||||
|
||||
stdout | src/__tests__/tv4-tv5-axis.test.ts > TV-5 Angled-end length axis > follows prevailing length; widths stay perpendicular after confirm
|
||||
{"case":"TV-5","direction":{"x":1,"y":0},"lengthPx":96,"widthPx":24,"bodySampleCount":12,"bodyWidths":[23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684,23.999999046325684],"min":0,"max":23.999999046325684,"average":21.19999910990397,"rotatedDirection":{"x":6.123233995736766e-17,"y":1}}
|
||||
|
||||
✓ src/__tests__/tv4-tv5-axis.test.ts (2 tests) 25ms
|
||||
|
||||
Test Files 1 passed (1)
|
||||
Tests 2 passed (2)
|
||||
Start at 06:06:32
|
||||
Duration 445ms (transform 84ms, setup 0ms, collect 86ms, tests 25ms, environment 0ms, prepare 61ms)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||
|
||||
❯ src/__tests__/tv4-tv5-axis.test.ts (0 test)
|
||||
|
||||
⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/__tests__/tv4-tv5-axis.test.ts [ src/__tests__/tv4-tv5-axis.test.ts ]
|
||||
Error: Failed to load url ../lib/pipeline/axis.js (resolved id: ../lib/pipeline/axis.js) in /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend/src/__tests__/tv4-tv5-axis.test.ts. Does the file exist?
|
||||
❯ loadAndTransform node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:51969:17
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests no tests
|
||||
Start at 06:05:51
|
||||
Duration 423ms (transform 53ms, setup 0ms, collect 0ms, tests 0ms, environment 0ms, prepare 72ms)
|
||||
|
||||
Reference in New Issue
Block a user