15 Commits

Author SHA1 Message Date
grokbot_ty 67775d6c8c fix: HTTP-only Caddy for Gate B LAN phone hang
Root cause: Caddy on :80 returned 308 → HTTPS, and TLS handshake failed
(Go/OpenSSL compat). Phones hung on HTTPS redirect loop.

Fix: Caddyfile serves all HTTP on :80 without TLS/redirect. Docker
compose drops port 443 and cert mount. Interim — post-POC restores
proper TLS.
2026-09-07 16:39:35 +00:00
grokbot_ty 6c62b506d4 docs: update INVENTORY smoke results with LAN bind fix 2026-09-07 16:30:46 +00:00
grokbot_ty b4a702318d fix: bind 0.0.0.0 for LAN reachability (Gate B) 2026-09-07 16:29:58 +00:00
grokbot_ty c62f0329f6 gate-b: smoke results, INVENTORY complete 2026-09-07 16:19:31 +00:00
grokbot_ty f966fbe690 gate-b: Caddy TLS fix, self-signed RSA cert, SLAB_WOO_BASE_URL, absolute DB path 2026-09-07 16:13:29 +00:00
grokbot_ty 68d236961a feat(#30): Gate B [10/10]: Deploy compose + WOO_BASE_URL (OPS-1, GC-5)
- deploy/.env.example: placeholders only (no real URLs, proper comments)
- frontend/Dockerfile: Node 20 stub server for compose build
- deploy/tests/test-compose-health.sh: OPS-1 compose health smoke test
- deploy/tests/test-woo-base-url.sh: GC-4/5/FUND-25 validation suite
- backend/app/api/settings.py: PUT rejects woo_base_url (422), consumer keys (422),
  text() wrapping for SQLAlchemy 2.x, key primary key fix
- backend/tests/contract/test_woo_base_url.py: 8 contract tests for GC-4/GC-5

Tests: 39/39 backend tests pass (8 new, 31 existing green)
Deploy: 5/5 shell tests pass (Docker unavailable for compose-up path)
2026-09-07 08:18:25 +00:00
grokbot_ty 5019b10584 Gate B [09/10]: Draft store stub D-1 — POST /api/v1/slabs (#29)
POST /api/v1/slabs accepts multipart/form-data with:
- data: JSON-encoded SlabCreate payload
- files[]: original image files (1-5 photos)
- meta[]: JSON-encoded photo metadata objects

Returns 201 with status: calibrated and server_rev: 1.

Server stores client-sent numbers only (sqft, bdft, widths) without
server-side recompute. Client is authoritative for deterministic math.

Includes:
- SlabCreateData Pydantic model mirroring OPENAPI.md SlabCreate schema
- PhotoMeta model for photo metadata validation
- SlabRead/PhotoRead response models
- File storage with configurable images directory (temp fallback for tests)
- Duplicate SKU detection (409)
- python-multipart added to requirements.txt

D-1 test coverage (8 tests, all green):
- 201 response on valid multipart upload
- status: calibrated in response
- server_rev: 1 in response
- Full response shape assertion
- Multiple photos handling
- Missing data field returns 400
- Duplicate SKU returns 409
- TestClient lifespan management (FUND-9)

Evidence: pytest tests/ -v → 31 passed, 0 failed
2026-09-07 08:08:39 +00:00
grokbot_ty 82219fe5ad feat(#28): Gate B [08/10]: AES-GCM settings round-trip (AES-1, AES-2)
- AES-GCM encrypt/decrypt module (app/security/aes.py):
  - get_aes_key: decode base64 AES master key to 32 bytes
  - encrypt_setting: AES-256-GCM with fresh 12-byte nonce per call, returns nonce||ciphertext
  - decrypt_setting: decrypts ciphertext, raises AuthenticationError on tamper/wrong key
- Settings endpoint (app/api/settings.py):
  - GET /api/v1/settings: returns SettingsView with decrypted values, secrets masked
    (woo_app_password never returned, only woo_credentials_configured boolean)
  - PUT /api/v1/settings: encrypts all setting values AES-GCM, stores in settings table
  - Rejects woo_consumer_key/woo_consumer_secret fields
  - Validates woo_create_status (draft|publish)
- App factory (app/main.py): stores settings on app.state for dependency injection
- Unit tests (tests/unit/test_aes.py): 18 tests covering AES-1 (round-trip), AES-2
  (missing key refusal), FUND-17 (fresh nonce, tamper rejection), FUND-19
  (no plaintext fallback)

Test results: 23/23 passed (18 new AES + 5 existing health tests)
2026-09-07 08:00:29 +00:00
grokbot_ty e5514fe3bf feat(#27): Gate B [07/10]: FastAPI health endpoint + app factory (H-1)
- App factory pattern (FUND-8): create_app() produces fresh app per test
- Logging config (structured, no secrets)
- Config validation: SLAB_AES_KEY required, WOO_BASE_URL HTTPS-only
- GET /api/health returns 200 {status: ok, version, woo_reachable: false}
- SQLite + alembic skeleton: full DATA-MODEL schema, initial migration
- API contract tests via httpx TestClient: 5 cases all green
  - test_returns_200, test_returns_ok_status, test_woo_reachable_false
  - test_response_shape, test_with_test_client_lifespan (FUND-9/10)
2026-09-07 07:53:36 +00:00
grokbot_ty d5d8c49e04 Gate B #26: shared validation module (V-1, V-2, V-3)
- V-1: SKU charset [A-Z0-9-]{3,24}, accepted/rejected with specific error codes
- V-2: title/short_title/description UTF-8 within WooCommerce maxLength,
  enforced at field-exit and submit (200/200/2500 chars, Unicode code-point counting)
- V-3: validation module version hash, client caches, submit carries hash,
  mismatch returns 412 with fresh module (not 409)

25 unit tests green, zero browser/DOM/network dependencies.
Re-exports through pipeline/index.ts for shared use.
2026-09-07 07:43:50 +00:00
grokbot_ty 34dbfa5d7d feat(#25): Gate B pricing and thickness band (TV-8, TV-9)
TV-8 pricing:
- computeRecommendedPrice(bdft, pricePerBdft) returns bdft × price/bdft rounded half-up to 2 decimals
  0.96 bdft × 12.50/bdft → 12.00
- computeSlabPrice handles three paths:
  1. Has rule, no override → recommended price, source 'recommendation'
  2. Has rule, override → override stored (150.00), source 'override'
  3. No rule, manual → manual stored (99.99), recommendedPrice null

TV-9 thickness band:
- findThicknessBand(thicknessIn, ranges) rounds to 1/8" then matches [min, max) half-open ranges
- 1" → 1"–1 1/2" (rounds up, label from synced Woo attribute, not hardcoded)

Test output (all 29 tests green, 6 new):
- tv8-pricing.test.ts: 6 passed (recommended, override, no-rule, rounding)
- tv9-thickness-band.test.ts: 6 passed (1"→1"–1 1/2", 0.75"→3/4"–1", 0.875"→3/4"–1", 1.5"→1 1/2"–2", null for out-of-range, rounding to 1/8")
2026-09-07 06:33:24 +00:00
grokbot_ty f7040f5947 feat(#24): Gate B 3:4 crop + no upscale (TV-6, TV-7)
TV-6: 3:4 portrait crop, 80% fill at extremes, transparent PNG, shorter side >= 1600px when source allows.
TV-7: undersized source warning, no upscaling, photo not publishable until retake.

Real fixture output:
- TV-6 large source (4000x3200, slab 1281x3000): crop target 1601x2135, shorter 1601 >= 1600, fill at limiting dim 0.80
- TV-7 small source (800x640, slab 400x300): crop target 281x375, shorter 281 < 1600, undersized
- No upscale property: output never exceeds source dimensions

New files:
- frontend/src/lib/pipeline/crop.ts: cropTo34(), findSlabBounds(), computeCropTarget(), checkSourceSize()
- frontend/src/__tests__/fixtures/crop.ts: tv6LargeSourceFixture, tv7SmallSourceFixture, simpleAxis
- frontend/src/__tests__/tv6-tv7-crop.test.ts: 7 tests (2 TV-6, 2 TV-7, 3 property)
2026-09-07 06:24:00 +00:00
grokbot_ty 08c463b8d8 feat(#23): Gate B length axis and 6" widths (TV-4, TV-5)
Pure client axis module: min-area length axis, rotate/confirm session,
scale, and perpendicular width samples every 6".
2026-09-07 06:07:07 +00:00
grokbot_ty 55d66b9644 feat(#22): Gate B BDFT identity and rounding (TV-3, TV-10)
Pure units module: bdft = sqft × thickness_in (no /12), half-up
rounding for sqft/bdft/price, length to nearest 1/8". Property
checks for monotonicity in thickness and sqft.

TDD: red evidence in test-results/tv3-tv10-red.txt, then minimum impl.

Results:
TV-3 | pass | test-results/tv3-tv10-green.txt
TV-10 | pass | test-results/tv3-tv10-green.txt
2026-09-07 06:02:08 +00:00
grokbot_ty 3c2f8871a8 feat(#21): Gate B green/black sheet mask (TV-1, TV-2)
Pure client modules for TECH-SPEC sheet detect, chroma-key / black
threshold with border flood-fill, four knobs (live re-run), and mask
confirm hooks. Synthetic fixtures. U2Net never invoked.

TDD: red evidence in test-results/tv1-tv2-red.txt, then minimum impl.

Results:
TV-1 | pass | test-results/tv1-tv2-green.txt
TV-2 | pass | test-results/tv1-tv2-green.txt
2026-09-07 05:53:43 +00:00
82 changed files with 7259 additions and 21 deletions
+1
View File
@@ -35,3 +35,4 @@ images/
# Logs
*.log
deploy/certs/
+21
View File
@@ -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"]
+45
View File
@@ -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
View File
+65
View File
@@ -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")
View File
View File
+23
View File
@@ -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,
}
+233
View File
@@ -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)
+432
View File
@@ -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()
+46
View File
@@ -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
View File
+84
View File
@@ -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()
+185
View File
@@ -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())
View File
+26
View File
@@ -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)
+56
View File
@@ -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
+9
View File
@@ -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.
+7
View File
@@ -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.
View File
+94
View File
@@ -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")
View File
+7
View File
@@ -0,0 +1,7 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
line-length = 100
target-version = "py313"
+3
View File
@@ -0,0 +1,3 @@
pytest>=8.3,<9
httpx>=0.28,<1
respx>=0.22,<1
+11
View File
@@ -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
View File
+45
View File
@@ -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()
View File
+113
View File
@@ -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()
+520
View File
@@ -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()
+140
View File
@@ -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()
View File
+196
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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) | | |
+16 -5
View File
@@ -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:
+84
View File
@@ -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
+178
View File
@@ -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
+17
View File
@@ -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')"]
+1362
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+85
View File
@@ -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,
};
}
+156
View File
@@ -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 },
];
}
+37
View File
@@ -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);
});
});
+106
View File
@@ -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,
}),
);
});
});
+210
View File
@@ -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);
});
});
+284
View File
@@ -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);
});
});
+316
View File
@@ -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 };
}
+21
View File
@@ -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;
+178
View File
@@ -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";
}
+9
View File
@@ -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";
+326
View File
@@ -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,
};
+91
View File
@@ -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;
}
+89
View File
@@ -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";
+39
View File
@@ -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);
}
+16
View File
@@ -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";
+115
View File
@@ -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;
}
+97
View File
@@ -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(),
};
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./constants.js";
export * from "./sku.js";
export * from "./content.js";
export * from "./hash.js";
+37
View File
@@ -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);
}
+13
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/__tests__/**/*.test.ts'],
},
});
+24
View File
@@ -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)
+10
View File
@@ -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)
+87
View File
@@ -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)
+16
View File
@@ -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)
+18
View File
@@ -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)
+16
View File
@@ -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)
+18
View File
@@ -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)