29 Commits

Author SHA1 Message Date
Ty f725e04ae6 docs(m4): correct stale 80%-fill crop comment (#99 §7) 2026-09-25 09:21:07 -07:00
Ty 8673ae0ecd docs(m4): record #99 as-built and sweep stale draggable-tick refs (#99) 2026-09-25 09:17:23 -07:00
Ty a756cf58bb fix(m4): drop stale tick-nudge hint from tape page (#99) 2026-09-25 08:56:57 -07:00
Ty d50d55aea9 fix(m4): tape line is a fixed fixture, not draggable (#99) 2026-09-25 08:53:38 -07:00
Ty 1882fa3842 fix(m4): apply tape tilt to calibration output (#99) 2026-09-25 07:31:13 -07:00
Ty 7908a99ec3 fix(m4): keep tape calibration on one page (#99) 2026-09-24 22:33:23 -07:00
Ty 6000ac2b0f feat(m4): fixed tape line for length calibration (#99)
The photo pans, zooms, and tilts under a line that stays in the middle
of the frame. Scale is the source-pixel distance between the ticks
divided by the declared length, not the mask span.
2026-09-24 17:56:21 -07:00
Ty d49ff2fab5 fix(capture): align masked tape calibration with #99 2026-09-24 16:49:17 -07:00
Ty 4ca2ef57a7 feat(capture): ship tape-line calibration (#99) 2026-09-24 14:47:13 -07:00
Ty 6fb92362a4 docs(m4): M4 goals + task breakdown
Goals: polish M3 + #99 tape-line, human-driven taxonomy review
(thumb-first UI, Call 1 deferred), Call 2 inference refinement,
real SLAB-UAT-* Woo draft exit.

Tasks: 9-task / 5-phase breakdown with detail bar (skills, starting
point, verification, sequence, stop condition, evidence boundary per
task). Kickoff decisions folded in: full polish wave (#91-#100 minus
#96), tape-line confined to calibration step, single-slab multi-photo
exit walk.
2026-09-24 13:44:53 -07:00
Ty 818874e65c feat(m4): crop_fill setting + tape-line prototype (#99)
Add global crop_fill percent (default 90, 1-100) as a settings key with
FastAPI 4xx validation. Wires the value through to the inventory PNG
encode (non-retro: stored PNGs untouched). Adds a throwaway
TapePrototype.svelte behind ?tape-prototype for the #99 design sandbox.

Gates: check_requirements PASS, check_coverage PASS, settings tests 8/8.
2026-09-24 12:01:18 -07:00
Ty 0c00d25cc7 fix(m3): Option A must-fix #79 #82 #83 #84 #89 #98
- #79 publish SKU/create via WooClient (User-Agent + typed 403 unrecoverable)
- #82 stale_field current_options from live taxonomy + owner labels
- #83 create allowlist/extra-key safe; taxonomy+enum 4xx on create
- #84 health woo_reachable real probe (non-failing); version 0.5.0-m3
- #89 review persists assembled title/short after taxonomy lock; UI applies
- #98 live-edge from synced term; attrs resolved by slug then name
2026-09-21 12:55:17 -07:00
Ty 96a697a50a fix(review): restore lineup/woodPrimary imports after merge
Morning merge kept Review call sites (nextPrimary, keptInOrder, roleLabel, …)
and dropped the imports, so hydrate threw ReferenceError and the UI showed
"Could not load the slab." Live UAT redeployed; sample data preserved.
2026-09-21 12:36:24 -07:00
Ty 67aa3d6b0d fix(review): black screen on Capture→Review — speciesId/gradeId refs
The merge auto-kept the branch's canCreateTitle reactive (speciesId) which is
undeclared in the merged file (main's canonical var is speciesSel) → ReferenceError
on Review mount → black screen. Also dropped the branch's duplicate gradeId (main
uses gradeSel) and repointed hydrate/patch to gradeSel. Live UAT verified: Review
now mounts and renders 'Review to Ready' with photo strip + Create buttons.
2026-09-21 12:17:24 -07:00
Ty 8ee3da9dcf merge work/merge-89-90 into main (Create-string #89/#90 + PR #97 fixes)
Reconciles two independent lines of work that both touched Review.svelte,
MultiSelect.svelte, and the thickness band:

- main's PR #97 (client-side thicknessBand.ts + MultiSelect names/commit +
  syncSelects DOM race fix + box-sizing CSS)
- work/merge-89-90 (server-side thickness band in thickness_band.py,
  Create-string #89, metadata_locked dropped, photo strip, wood primary)

Canonical resolution: the band is SERVER-side (hidden from UI, sent to Woo
on publish), so the client-side thicknessBand.ts + read-only band display
are removed from Review.svelte; PR #97's syncSelects() and box-sizing CSS
are preserved (orthogonal fixes). MultiSelect takes selectedSummary from
woodPrimary.ts (names in closed state) + the ms-count testid + onDestroy
timer cleanup; duplicate close()/summary definitions dropped.

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip).
2026-09-21 11:59:36 -07:00
Ty 1a50fad0a5 merge issue-89-metadata-lock → main (Create-string model, #89/#90)
Drop the metadata_locked column + "Save and lock" UI end-to-end. The
review endpoint now builds the deterministic title/short_description on
every call; the owner's Create title / Create short desc buttons fire
the build and write the result (force-overwrite contract). commitReview
auto-fills only empty fields (no owner-clobber) and folds the
deterministic copy into its single PUT (kills the client_rev 409
cascade).

Taken wholesale from the branch: photo strip, wood primary/secondary,
server-side thickness band (hidden from UI, passed to Woo on publish),
and fixes #80/#82/#83/#84.

- backend: remove metadata_locked from schema.sql, db sentinels,
  slabs.py client fields/exposure, review.py lock action; review
  always computes title/short_desc
- frontend: slabs.ts + Review.svelte drop the lock model, add
  Create-string workflow + wood primary + photo strip
- e2e: m3-create + review-photo-strip rewritten for the Create-string
  contract; thickness-band test asserts absence (per #90)
- deploy: recreate LAN binds (override-m3.yml was absent); verify
  metadata_locked gone, review builds title, PUT persists

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip). Live UAT verified at 192.168.1.202:10881.
2026-09-21 11:54:48 -07:00
Ty e5acef4c9b chore(deploy): record final #90 UAT state (band omitted) 2026-09-21 02:50:06 -07:00
Ty 204bc5e960 fix(review): #90 thickness band omitted entirely (not read-only) 2026-09-21 02:49:47 -07:00
Ty 191b507934 chore(deploy): record #90 UAT deploy marker 2026-09-21 02:43:26 -07:00
Ty 0329052d37 feat(review): #90 wood primary category + derived thickness band 2026-09-21 02:41:20 -07:00
Ty e1b928ccbd fix(review): #90 clearer mandatory-photo indicator (padlock + REQUIRED tag)
The mandatory clean-cal and dimension-overlay photos now show a padlock glyph
in their keep box and an amber REQUIRED tag at the top of the card, so the
owner can see at a glance which photos cannot be removed (vs. optional batch
photos, which keep a plain checkmark).
2026-09-21 02:18:01 -07:00
Ty 76fcb36a2b fix(review): #90 clean cal + dimension-overlay are mandatory (checked + disabled)
REVIEW-5 requires both the clean calibration photo and its dimension-overlay
copy to ship. Their keep checkbox is now checked AND disabled (aria-disabled,
a lock glyph, cursor:default, tap is a no-op) so the owner can't remove them.
Only batch photos are removable. The submission minimum can no longer be
broken by the owner, so publish is never lineup-blocked by a missing required
photo.
2026-09-21 02:15:00 -07:00
Ty 5a1800df91 fix(review): #90 persist the dimension-overlay copy with its role
The Capture→Review upload filtered out the overlay (role !== 'overlay') and
sent no role field, so the dimension-overlay copy was never stored and every
photo defaulted to role=batch. Review then fell back to a stored /processed
URL that had no dimensions drawn — the owner saw the overlay thumbnail
without its measurements. Now upload the full lineup with the correct role,
keeping the overlay's dimension-drawn blob as both original and processed.
2026-09-21 01:59:27 -07:00
Ty cd326d1ec9 test(review): #90 assert Primary photo src is valid + checkerboard on strip
Locks in the two live-bug fixes: the Primary (leftmost kept) photo's <img src>
must be a real URL (blob: for an in-memory capture blob) and never empty/
"object Blob"; and each strip card must carry the gray checkerboard
background matching the Capture lineup.
2026-09-21 01:54:21 -07:00
Ty 0612ce66a6 fix(review): #90 photo strip — blob: URL for Primary photo, checkerboard thumbs
Bug #1: the first/Primary (calibration) photo rendered broken because its
in-memory capture Blob was assigned to <img src> directly (String(Blob)=
"object Blob"). resolvePhotoUrl() now mints a blob: object URL (revoked on
unload) and falls back to the stored photo endpoint when there is no blob.
Bug #2: strip thumbnails used a flat dark bg; now carry the gray checkerboard
from the Capture lineup (MeasurePanel) so transparent inventory PNGs read the
same on both screens.
Also harden missingMinimums to filter kept internally (pre-existing test 7
failed on the clean base because it passed un-kept items).
2026-09-21 01:48:43 -07:00
Ty 7b7adbdb34 fix(deploy): nginx client_max_body_size 25m for photo uploads
Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
multipart to /api/v1/slabs/{id}/photos. nginx's default 1MB
client_max_body_size 413s on real multi-MB photos, blocking the
Capture→Review transition. Allow 25m.
2026-09-21 01:41:31 -07:00
Ty d9af8420bc feat(review): #90 photo strip — keep/remove, drag-reorder, leftmost=primary, persist
REVIEW-5 / PUBLISH-45: owner edits the stored photo lineup in Review — tap a
card to keep/remove, drag to reorder, leftmost kept photo = primary (Woo
cover). The kept set must contain a clean calibration + its dimension-overlay
copy; publish is blocked until it does.

- backend: role column on slab_photos (schema + idempotent _migrate ALTER),
  role in photo CRUD + new DELETE endpoint, Woo image order by role-rank
  (clean→overlay→batch) per PUBLISH-45
- frontend: Review photo strip (render from listPhotos, keep/remove,
  HTML5 drag-reorder, dynamic PRIMARY badge from .primary class),
  persistLineup() re-seq kept + drop removed before entering Publish
- tests: lineup.ts unit, e2e review-photo-strip.spec, backend role + order

Refs: #90, REVIEW-5, PUBLISH-45
2026-09-21 01:34:22 -07:00
Ty fe82133ca4 fix(backend): #84 real woo_reachable, #83 reject unknown fields, #82 live taxonomy in stale_field, #80 persist review overrides
#84: /api/health woo_reachable is now a real check (GET /wp-json/ with a
3s timeout) instead of a hardcoded False stub. Version bumped to 0.5.0-m3-s1.

#83: POST /slabs rejects unknown (non-ALLOWED_FIELDS) keys with a 400
'unknown fields' error, instead of silently dropping them. The INSERT
binding count is now provably correct (no more assert needed).

#82: stale_field 422 error now returns the LIVE taxonomy ids
(SELECT id FROM table) as current_options, plus the field name and
submitted value, instead of echoing the submitted value back.

#80: regular review (no lock) persists owner overrides (price/sku/title/
short_description) from the payload to the DB via an UPDATE, so the
review endpoint is self-contained. server_rev is incremented on persist.

Tests: +3 (test_create_rejects_unknown_fields,
test_update_stale_field_returns_live_options,
test_review_persists_owner_overrides). 87/87 passing.
2026-09-20 23:38:29 -07:00
Ty 693719e85f fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review
The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.

- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
  stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
  ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
  inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
  ready→publish→done); mock respects optimistic concurrency (client_rev) so
  stale PUTs 409 like the real backend
2026-09-20 23:36:31 -07:00
58 changed files with 4402 additions and 666 deletions
+192
View File
@@ -0,0 +1,192 @@
# M4 — Goals
Role: goals for Milestone 4. Cites requirement IDs and Gitea issue numbers; defines none
new until accepted. Source of truth for scope will be `docs/MILESTONES.md` (M4 ID subset)
and `docs/REQUIREMENTS.md` (rows) after acceptance.
**Base:** builds on branch `m4` (commit `818874e`, `feat(m4): crop_fill setting + tape-line
prototype (#99)`), which is 1 ahead of `origin/main`. #99 is in scope.
**Objective:** Ship a polished, stable M3 experience with the tape-line calibration model,
a **human-driven taxonomy review** (Call 1 deferred; owner picks everything on a thumb-first
UI), **refined Call 2 long descriptions**, and a verified real WooCommerce draft submission.
**Exit criterion:** A real `SLAB-UAT-*` Woo **draft** matching the reviewed ready set exists
in the store, created end-to-end through the M4 UI (tape-line calibration → human taxonomy
review → Call 2 long description → publish), with **no Call 1** in the path (inference
settings need not be vision-enabled). Call 2 is exercised on a live call at least once.
Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
## Goals (accepted direction)
1. **Stabilize + polish the M3 experience (A) — includes the #99 tape-line calibration (B).**
- **Tape-line calibration (#99) — SHIPPED (as-built 2026-09-25, UAT signed off):** the
axis-rectangle calibration is replaced by a **fixed** black centre line with two end
ticks. The line is a stage fixture and is **never an owner input** (not draggable, not
focusable, not keyboard-nudgeable). The **source photo/slab is the only thing that
moves** — pan/zoom/tilt it until it lines up with the fixed line at the place the tape
measurement was taken. Declared Length = the real inches read off the tape, and the
fixed line represents that same length; scale = fixed-line pixel span / declared
Length, no `maskSpanAlong` check. The crop slider alone sets the line ends
(`crop_fill=90` ⇒ each end 5% from its edge). The earlier `?tape-prototype` sandbox,
`TapePrototype.svelte`, `frontend/src/png/crop.ts` and the `sveltedrag` dependency are
all superseded/removed. Requirement rows CAPTURE-170/175/75 carry the as-built; see
`tasks.md` T2 for detail.
- **UX polish:** photo-picker look/feel (#91), photo filenames on submit (#92), capture
gated on a tested Woo connection (#93), review UI is always the human-pick flow (#94),
debug mode (#95), required-photo lock glyph (#100). (#96 Call 1/2 prompt viewer drops
with Call 1.)
- Post-launch issue work + the tape-line feature; no other new feature surface.
2. **Human-driven taxonomy review + thumb-first UI (Call 1 deferred; Call 2 kept).**
- **Call 1 (vision) → deferred to future work.** Not in the MVP: no "Suggest with AI"
button, no auto-pre-fill, no confidence pre-fill, no AI markers on fields. The
inference settings need not be vision-enabled.
- **Owner picks everything Call 1 used to propose**, via a thumb-first, on-phone usable
UI: species (1 leaf), wood categories (primary + optional secondaries), Edge, Figure
(multi), Grade (owner judgment), Thickness band (derived, no pick), Moisture, and
`feat-*` / `fig-*` tags. All from the synced Woo taxonomy (METADATA-5/10/15/30).
- **Call 2 (text) stays** — writes the long description from the human-picked values +
dimension fact block; brand/geo context, primary + secondary categories. (Refining its
output quality is its own goal, #3 below.)
- **Title + short description remain deterministic** from the primary wood category +
dimension fact block (METADATA-80/85) — they depend only on the human's primary pick
+ fact block, not on Call 1.
- **All METADATA requirements remain in scope** — the human collects the same data
Call 1 previously suggested, just by hand.
- **Wireframe first (a sprint task in this milestone):** before any review-UI coding,
produce a wireframe (2-3 HTML variants) to align on the overall flow — how the
thumb-first taxonomy collection, primary/secondary selection, and Call 2 generate
control compose on a phone. This is the design gate for the review work.
**Starting point (saved, recent):** `docs/design/issue-99-tape-line-overlay.html`
(approved #99 tape-line overlay wireframe, committed 2026-09-24 on
`issue-99-tape-line-overlay`). Do **not** build on the older `pre-simplification`
wireframes or the 2026-09-18 `ux-design.md` coverage matrix — those are stale.
- *To be scoped:* the exact thumb-first interaction model (wizard vs single-scroll,
chips vs pickers) — settled by the wireframe.
3. **Refine Call 2 so it delivers better long descriptions.**
- Call 2 (text) stays in the MVP and is the inference work of this milestone: improve
how it turns the human-picked taxonomy + dimension fact block + brand/geo context into
a long description that reads well for the store.
- Levers (task-level, settled during the sprint): prompt revision (METADATA-105/106),
the dimension fact block as the only numbers it may print (METADATA-65/90/95),
primary + secondary category weighting, brand voice / geo context settings
(SETTINGS-30), and recoverable/unrecoverable failure (METADATA-115).
- Success is judged on the exit-walk description quality (Ty reads it), not on a proxy
metric. Raw evidence recorded.
4. **Exit: real Woo draft submission.**
- PUBLISH-5 sequence, PUBLISH-10 (`SLAB-UAT-*` force draft), PUBLISH-25 (woo_product_id,
purge photos, discard local row, clear device draft). QUALITY-110 exit evidence.
## Non-goals (excluded from M4)
- **Call 1 (vision) inference** — deferred to future work. No auto-suggest, no confidence
pre-fill, no AI markers. METADATA-35/40/45/100 rows are out of the M4 active set.
- TLS on LAN transports (#78) — production-readiness, deferred.
- Backup/restore (OPS-30/35) — deferred ("Later").
- Instrumentation: Prometheus metrics, alerting, log retention (#7/#16) — deferred.
- Store-admin species-leaf creation (#13), shared validation module (#17), 409/422
field-level recovery (#18) — post-launch designs, deferred.
- Offline capture (CAPTURE-15), U2Net server matting (CAPTURE-165) — "Later".
- Browser e2e on all five clients (QUALITY-115) — "Later".
- Prompt-revision auth beyond trusted network (METADATA-107) — "Later".
- App login (relies on trusted LAN/Tailscale + HTTPS, OPS-55) — out of scope.
## Assumptions
- M4 does **not** add an app-level login; the trusted-network model (OPS-55) holds.
- The `m4` branch is the working base; `main` is the merge target once M4 is accepted and green.
- Inference endpoint is a remote, configurable endpoint (OPS-45); M4 refines prompts/calls,
not the endpoint transport.
- Real Woo for the exit walk; Woo mock is a dishonest proxy (QUALITY-105/110).
- Existing M3 requirement rows that the tape-line model supersedes (CAPTURE-60, CAPTURE-75)
are updated in place; new rows are added for the tape-line behavior. No row is deleted.
## Requirements delta (draft — rows written at execution, after acceptance)
- **New rows:** tape-line calibration behavior (overlay, draggable ticks,
owner-declared length, tick-distance scale). ~1–3 rows under CAPTURE.
- **New rows (draft):** the **manual taxonomy selection UI** — owner picks species / wood
categories (primary + secondary) / edge / figure / grade / moisture / tags by hand,
thumb-first, with no inference pre-fill. ~1 row under METADATA or REVIEW.
- **Superseded/updated rows (draft):** CAPTURE-60 (length axis), CAPTURE-75 (scale model) —
rewritten to the tape-line model.
- **Deferred rows (out of M4 active set):** METADATA-35/40/45/100 (Call 1 vision
suggestions + confidence gate) — moved to "Later"/future work. The rows stay in
REQUIREMENTS.md; only their M4 scope drops.
- **Refined rows (draft):** METADATA-55/60/65 (Call 2) — if the human-picked flow changes
normative Call 2 inputs (it shouldn't materially; Call 2 already receives the metadata
confirmed in review).
- **Refined rows (draft):** REVIEW-15/20/25/40 (review-workflow usability) — field layout,
nudge behavior, primary/secondary category interaction, generate-text control placement,
if the usability changes alter normative review behavior. #94 (conditional review UI)
now means: the review screen is **always** the human-pick flow (no inference on/off
branch for Call 1).
- **Unchanged (in M4 ID subset):** all PUBLISH, REVIEW, SETTINGS, APP, OPS-40, QUALITY rows
that M3 already shipped and M4 re-uses for the exit; METADATA-5/10/15/30/85 (the data the
human collects + the deterministic title/short-desc).
## Acceptance criteria (draft)
1. **Tape-line calibration + M3 polish ship together (Goal 1):** the tape-line calibration
ships as a **fixed** centre line the owner pans/zooms/tilts the slab onto (line is never
an owner input; crop slider sets its ends; declared length = the real tape inches, and
the fixed line represents that same length; no `maskSpanAlong` check), and the
M3 polish issues (#91–#100, excluding #96 which drops with Call 1) are closed with
passing tests and raw runner evidence.
2. **The review flow is human-driven, thumb-first, and Call 1-free:** the owner can pick
species, wood categories (primary + secondary), edge, figure, grade, moisture, and tags
by hand on a phone, with no "Suggest with AI" surface and no AI pre-fill. The wireframe
was accepted before coding (design gate).
3. **Call 2 (text) is exercised on a live call** during the exit walk and delivers a
description that reads well (Ty judges it); content-gen control visibility (METADATA-110),
recoverable/unrecoverable classification (METADATA-115), and prompt-revision API
(METADATA-105/106) all pass their tests.
4. **A real `SLAB-UAT-*` Woo draft** exists in the store matching the reviewed ready set
(QUALITY-110), with `woo_product_id` set, photos purged, local row discarded, device
draft cleared (PUBLISH-25).
5. **Gates pass:** `python tools/check_requirements.py` and `python tools/check_coverage.py`
exit 0; full suite passes with inference **and** off (QUALITY-20) — now including the
human-pick flow with Call 1 removed; raw evidence recorded.
## Verification strategy (smallest that proves the claim)
- **Deterministic core:** unit tests for tape-line geometry + `crop_fill` (existing
`frontend/src/png/*` test surface).
- **Integration:** Woo mock for the PUBLISH-5 sequence (success, duplicate SKU, stale field,
taxonomy anchor) — QUALITY-35/50.
- **Exit walk:** `deploy/uat_walk.sh` on the real store for the `SLAB-UAT-*` draft
(QUALITY-105/110) — the only proof that counts for the submission claim.
- **Ty owns gate exit** (QUALITY-30); agents record raw evidence, never declare green.
## Task-level decisions (settled during the sprint, not at planning)
The only plan-level open item is the exact thumb-first interaction model
(wizard vs single-scroll, chips vs pickers) — settled by the wireframe.
Settled at kickoff (not reopened here): full #91–#100 polish wave (only #96 drops
with Call 1); #99 confined to the calibration step (CAPTURE-130 lineup untouched);
exit walk is a single slab carrying multiple photos.
## Task breakdown detail bar
The task breakdown is not a list of goal names. Every task carries, at minimum:
- **Goal it serves** (G1–G4) and the requirement IDs / issue numbers it touches.
- **Skills to load** for that task — named and ordered (e.g. UI task: `sketch` +
`wireframe-review-iteration` → `omh-frontend` + `omh-design-quality-gate` →
`omh-visual-qa` + `omh-accessibility-audit`).
- **Starting point / references** — the specific saved artifact, file, or branch the task
builds from (e.g. `docs/design/issue-99-tape-line-overlay.html`, the live
`Review.svelte`, the M3 `MultiSelect.svelte`).
- **Verification** — the smallest check that proves the task's claim (unit test paths,
`tsc --noEmit`, Woo mock, `deploy/uat_walk.sh`, 375/768/1280 visual captures, keyboard /
contrast audit). Ty owns gate exit.
- **Sequence / dependencies** — what must land first (wireframe accepted before review-UI
coding; requirements rows before the code that implements them; Call 2 refinement after
the human-pick flow feeds it real data).
- **Stop condition** — when the task is done (e.g. "wireframe accepted by Ty," "gates
green + raw evidence recorded," "real `SLAB-UAT-*` draft matches the ready set").
- **Evidence boundary** — what is prepared vs observed; no task claims done without the
raw runner / capture / store evidence named above.
+205
View File
@@ -0,0 +1,205 @@
# M4 Task Breakdown
Companion to `goals.md` (accepted direction). Detail bar per task: goal, skills,
starting point, verification, sequence, stop condition, evidence boundary.
Phase order: P0 (requirements first) → P1 (G1 polish + #99, parallel-safe) → P2 (G2
review-UI, wireframe-gated) → P3 (G3 Call 2) → P4 (G4 exit walk). Ty owns every gate
exit (QUALITY-30); agents record raw evidence, never declare green.
---
## P0 — Requirements baseline (must land before code it enables)
### T1. Requirements delta for M4
- **Goal:** G2/G4 (unblocks G3). **IDs:** REQS (whole doc), REQS-30. **Issues:** —.
- **Skills:** `document-to-action-items` (extract obligations), `ocr-and-documents`
(read current REQUIREMENTS.md), OMH `omh-routing` (evidence boundaries).
- **Starting point:** `docs/REQUIREMENTS.md` (current), `goals.md` requirements-delta
section.
- **Work:** add rows for the human-pick flow (REVIEW-*, CAPTURE-*, METADATA-*), the
Call 2 taxonomy-input row, and the `SLAB-UAT-*` exit-walk row; note the Call 1
deferral (METADATA-40/45/55) and drop #96; keep the "draft only" PUBLISH-40 invariant;
confirm #99 is tracked. Every new/changed row is the gate for the code that implements it.
- **Verification:** `python tools/check_requirements.py` exit 0; coverage matrix
consistent.
- **Sequence:** first task of the sprint.
- **Stop:** rows merged + gate green. **Evidence:** runner output.
### T2. #99 tape-line calibration (folded into G1) — AS-BUILT, shipped
- **Goal:** G1. **IDs:** CAPTURE-170, CAPTURE-175, CAPTURE-75. **Issues:** #99 (issue
body still describes the superseded draggable-tick model — this entry is the as-built).
- **Skills:** `systematic-debugging`, `svelte-core-bestpractices`,
`docker-uat-deploy` (isolate-and-verify the review preview).
- **As-built:** a **fixed** black centre line with two end ticks, drawn on the stage
fixture. The line is **never an owner input** — not draggable, not focusable, not
keyboard-nudgeable, and not part of the published pose. The **source photo/slab is the
only thing that moves**: drag to pan, wheel/pinch to zoom, the ↻ handle or arrow keys
(Shift = fine step) to tilt. The owner aligns the *slab* to the fixed line at the place
the tape measurement was taken. Typed Length is the real inches read off the tape, and
the fixed line represents that same length. Scale = fixed-line pixel span / declared
Length; `maskSpanAlong` is not the scale source. The crop slider is the **sole** control
of the line ends: `crop_fill=90` puts each end 5% from its corresponding edge.
Calibration rotation applies to the calibration clean PNG + overlay only; batch
siblings stay unrotated.
- **Superseded (do not restore):** draggable ticks; `TapePrototype.svelte` and the
`?tape-prototype` sandbox (both deleted); `frontend/src/png/crop.ts` (removed in
6000ac2 — crop geometry now lives in `frontend/src/capture/tapeLine.ts`
`cropEnds(fillPercent)`); the `sveltedrag` dependency (404s on npm, never added).
The wireframe `docs/design/issue-99-tape-line-overlay.html` lives on branch
`issue-99-tape-line-overlay` (63b8879), not on `m4`.
- **Verification:** unit tests in `frontend/src/capture/tapeLine.test.ts` (ends fixed by
the crop slider, never an owner input) + `frontend/src/png/{inventory,overlay}.test.ts`
(calibration rotation reaches the calibration PNG/overlay only); `tsc --noEmit`;
`npm run build`; e2e `frontend/tests/e2e/tape-fixed-line.spec.ts` (no tick drag; a tick
drag behaves identically to a pan; ends follow the crop slider).
- **Sequence:** parallel with T3–T5; no dependency on T1.
- **Stop:** SHIPPED. UAT-signed off by Ty 2026-09-25 on the M4 review preview
(`http://192.168.1.202:10883/?noload=1`, build `a756cf5`, branch `m4`).
- **Evidence:** `npm test` 102/102; requirements + coverage gates green; live e2e on the
deployed preview (0°/20° rotation produces differing clean PNG + overlay bytes); render
gate clean at 1440/768/375 with no overflow or clipped text; Ty's UAT sign-off.
**Not covered:** `svelte-check` still reports its known 42-error baseline.
---
## P1 — M3 polish (G1), parallel-safe once T1 lands
### T3. Polish wave (full set #91–#100)
- **Goal:** G1. **IDs:** UX (various). **Issues:** all of #91–#100 — #91 (photo-picker
look/feel), #92 (photo filenames on submit), #93 (capture gated on tested Woo
connection), #94 (review layout), #97 (settings), #98 (publish), #100 (UX general).
**Excluded:** #96 (drops with Call 1). Ty confirmed: full polish wave, no triage.
- **Skills:** `svelte-core-bestpractices`, `omh-frontend` (component states),
`omh-visual-qa` (rendered evidence per screen).
- **Starting point:** the live screens named in each issue; `frontend/src/create/*`.
- **Work:** per-issue fixes. Each is a small, individually-verifiable unit; sequence
by dependency (e.g. #93 gate before #98 publish flow touches it).
- **Verification:** per-issue acceptance (capture at 375/768/1280, or the named
behavior), full suite green.
- **Sequence:** after T1; independent of T2.
- **Stop:** each issue's acceptance captured. **Evidence:** captures + test run.
---
## P2 — Human-driven taxonomy review + thumb-first UI (G2)
### T4. Wireframe (design gate)
- **Goal:** G2. **IDs:** REVIEW-*, UX (phone). **Issues:** #100 (UX).
- **Skills:** `sketch` (variants) + `wireframe-review-iteration` (paradigms, serve on
LAN IP, iterate on Ty's feedback) → `omh-frontend` + `omh-design-quality-gate`
(lock the chosen variant).
- **Starting point:** `docs/design/issue-99-tape-line-overlay.html` (approved overlay
wireframe, 2026-09-24) for the visual language + the interaction model.
- **Work:** 2–3 HTML variants of the thumb-first review flow. Standing paradigms:
*no tap-bubble chip clouds for long lists → collapsible searchable checklist*;
leftmost-kept-is-primary for wood (primary + optional secondaries);
no AI-assist surface (Call 1 decommissioned); every metadata requirement present (primary
wood-category required, secondaries optional).
- **Verification:** Ty reviews the served variants and accepts one (375/768/1280).
- **Sequence:** before T5/T6. **Stop:** Ty accepts a variant.
**Evidence:** the accepted `docs/design/*` file + Ty's acceptance.
### T5. Review-UI implementation
- **Goal:** G2. **IDs:** REVIEW-*, CAPTURE-*. **Issues:** #100.
- **Skills:** `svelte-core-bestpractices`, `svelte-code-writer` (Svelte 5 lookups),
`omh-frontend`, `omh-visual-qa`, `omh-accessibility-audit` (keyboard, 44px targets,
contrast, 320px reflow).
- **Starting point:** accepted T4 wireframe; `frontend/src/create/review/Review.svelte`
(current single-column form); the M3 `MultiSelect.svelte` pattern (reused, not the
tap-bubble pattern).
- **Work:** replace the review form with the accepted thumb-first flow; primary
wood-category + optional secondaries; remove the "Suggest with AI" bar (#96 drops
with Call 1); keep all metadata requirements present and editable.
- **Verification:** `tsc --noEmit`; `omh-visual-qa` at 375/768/1280 (PASS);
`omh-accessibility-audit` (keyboard-reachable, 44px, contrast, 320px reflow);
full suite green with inference on **and** off (QUALITY-20).
- **Sequence:** after T4 accepted. **Stop:** flow renders, all requirements present,
audits pass. **Evidence:** captures + audit output.
### T6. Taxonomy → title/shortdesc (server-side)
- **Goal:** G2. **IDs:** METADATA-*. **Issues:** —.
- **Skills:** `systematic-debugging` (verify the title/shortdesc builders),
`evidence-based-code-review`.
- **Starting point:** the title/short description builders in `backend/` (review.py /
publish.py path); the new human-pick fields from T5.
- **Work:** ensure title + short description are built from the human-picked primary
wood + form (not from the removed Call 1). Confirm the server_description
(inference-off) path stays intact.
- **Verification:** unit tests for title/shortdesc from human picks; inference-off
path covered. **Sequence:** with/after T5. **Stop:** tests green.
**Evidence:** test output.
---
## P3 — Refine Call 2 long description (G3)
### T7. Wire human-picked taxonomy into the Call 2 prompt
- **Goal:** G3. **IDs:** METADATA-*. **Issues:** —.
- **Skills:** `systematic-debugging`, `evidence-based-code-review`,
`local-llm-benchmarking` (endpoint behavior), `omh-ask` (prompt-engineering consult
if the owner wants an external pass).
- **Starting point:** `backend/inference.py` `call2` (line 467) — the prompt today is
the **dimension fact block + brand voice + geo only; taxonomy is NOT in the prompt**.
`CALL2_SEED` (line 112), `_active_prompt`, `brand_voice`/`geo_context` settings.
- **Work:** add the human-picked primary/secondary wood + form to the Call 2 fact
block so the long description is grounded in what the owner picked; keep the
"invent no numbers" constraint; keep the dimension block exact.
- **Verification:** `test_inference.py` updated for the new input; a recorded call2
with a known taxonomy set shows the wood/form in the output. **Sequence:** after T5
(needs the human-pick data shape). **Stop:** test passes + sample output grounded.
**Evidence:** test output + a sample Call 2 response.
### T8. Exit-walk quality gate
- **Goal:** G3. **IDs:** QUALITY-105/110. **Issues:** —.
- **Skills:** `omh-verification-gate` (record the gate), `omh-visual-qa`.
- **Starting point:** `deploy/uat_walk.sh`; the refined Call 2 from T7.
- **Work:** Ty reads the Call 2 long description on the exit walk for 2–3 real slabs;
pass = "sounds like a good product description." This is the only proof that counts
for the Call 2 quality claim.
- **Verification:** Ty's judgment on 2–3 outputs, recorded. **Sequence:** after T7.
**Stop:** Ty passes the gate. **Evidence:** Ty's recorded judgment.
---
## P4 — Exit: real Woo draft (G4)
### T9. `SLAB-UAT-*` draft submission + walk
- **Goal:** G4. **IDs:** PUBLISH-*, QUALITY-105/110. **Issues:** —.
- **Skills:** `omh-deploy-and-monitor` (release checklist + monitor),
`omh-verification-gate`, `docker-uat-deploy` (if the UAT host needs staging).
- **Starting point:** `backend/publish.py` (draft create path), the Woo mock
(`test_integration_woo_mock.py`) for the PUBLISH-5 sequence, `deploy/uat_walk.sh`.
- **Work:** run the full create → review (human pick) → Call 2 → publish path on the
real store, producing **one** `SLAB-UAT-*` draft; walk it (draft only, PUBLISH-40);
confirm the draft matches the ready set. **Shape (Ty confirmed):** single slab, and
the draft carries **multiple photos** of that slab — not a QUALITY-110 pair.
- **Verification:** `deploy/uat_walk.sh` on the real store; draft present in Woo with
the correct fields — including **multiple photos** on the single `SLAB-UAT-*` draft;
full suite green inference on **and** off (QUALITY-20).
- **Sequence:** last; after T3–T8. **Stop:** real draft matches the ready set + Ty
passes the walk. **Evidence:** store screenshot + walk transcript + suite output.
---
## Dependency map
```
T1 ──┬─ T3 (polish)
├─ T4 ─ T5 ─┬─ T6
│ └─ T7 ─ T8 ─ T9
└─ T2 (tape-line, parallel)
T9 is the milestone exit.
```
- T2 is independent of T1 (prototype already on `m4`).
- T4 must be accepted before T5.
- T7 needs T5's data shape; T8/T9 need T7.
- Nothing in P2/P3 blocks T2/T3 (polish + tape-line ship independently).
## Resolved at kickoff (deep-interview, 2026-09-24)
- **Polish scope:** full wave — all of #91–#100, no triage (only #96 drops with Call 1).
- **Tape-line scope:** confined to the calibration step; photos-ready lineup
(CAPTURE-130) untouched.
- **Exit-walk shape:** single slab; the `SLAB-UAT-*` draft carries multiple photos of
that slab (not a QUALITY-110 pair).
+24 -3
View File
@@ -25,7 +25,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable
from . import settings as settings_mod
__version__ = "0.5.0-m3-s0"
__version__ = "0.5.0-m3"
# --- Structured JSON logging (stdlib only) ---
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
@@ -155,7 +155,11 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
@app.get("/api/health")
async def health() -> dict[str, Any]:
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
#84: woo_reachable is a real authenticated probe (signal only — store outage
does not fail health).
"""
db_ok = probe_writable()
img_ok = probe_images_writable()
if not (db_ok and img_ok):
@@ -164,10 +168,27 @@ async def health() -> dict[str, Any]:
return {
"status": "ok",
"version": __version__,
"woo_reachable": False, # S0 stub; S4 will implement real check
"woo_reachable": _probe_woo_reachable(),
}
def _probe_woo_reachable() -> bool:
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
Storage health never depends on this result. Missing creds → False.
"""
try:
base, username, enc_password = _woo_credentials()
if not base or not username or not enc_password:
return False
client = WooClient.from_settings(base, username, enc_password)
# One lightweight authenticated call (categories page 1).
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
return code == 200
except Exception:
return False
@app.get("/api/v1/meta")
async def meta() -> dict[str, Any]:
"""Per API.md §3."""
+19
View File
@@ -79,6 +79,21 @@ _M3_TABLES = [
]
def _migrate(conn: sqlite3.Connection) -> None:
"""Idempotent column additions for volumes created before a column existed.
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
so new columns must be added here. Each guard is a no-op once the column is
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
"""
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
if photo_cols and "role" not in photo_cols:
conn.execute(
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
"CHECK(role IN ('calibration','overlay','batch'))"
)
def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn = get_connection(db_path)
schema_path = _get_schema_path()
@@ -98,6 +113,10 @@ def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn.executescript(schema)
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
conn.commit()
# Safe to run every boot: no-op once the columns exist.
with _lock:
_migrate(conn)
conn.commit()
return conn
+3
View File
@@ -54,6 +54,9 @@ CREATE TABLE IF NOT EXISTS slab_photos (
slab_id TEXT NOT NULL,
seq INTEGER NOT NULL,
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
-- #90: lineup kind (clean cal / dimension-overlay copy / batch sibling).
-- Drives the Review photo strip labels and the PUBLISH-45 Woo image order.
role TEXT NOT NULL DEFAULT 'batch' CHECK(role IN ('calibration','overlay','batch')),
original_blob BLOB,
processed_blob BLOB,
width_px INTEGER,
+25 -6
View File
@@ -26,7 +26,7 @@ def _get_slab(conn, slab_id: str):
def _get_photo(conn, slab_id: str, photo_id: str):
row = conn.execute(
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq "
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq, role "
"FROM slab_photos WHERE id=? AND slab_id=?",
(photo_id, slab_id),
).fetchone()
@@ -44,6 +44,7 @@ async def upload_photo(
photo_id: str = Form(...),
seq: int | None = Form(None),
is_calibration: int = Form(0),
role: str = Form("batch"),
original: UploadFile = File(...),
processed: UploadFile | None = File(None),
) -> dict:
@@ -52,6 +53,8 @@ async def upload_photo(
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
if is_calibration not in (0, 1):
return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1"))
if role not in ("calibration", "overlay", "batch"):
return JSONResponse(status_code=400, content=_error("validation", "role must be calibration, overlay, or batch"))
orig_bytes = await original.read()
proc_bytes = await processed.read() if processed is not None else None
if not orig_bytes:
@@ -60,14 +63,14 @@ async def upload_photo(
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
conn.execute(
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, original_blob, processed_blob, created_at) "
"VALUES (?,?,?,?,?,?,?)",
(photo_id, slab_id, seq_val, is_calibration, orig_bytes, proc_bytes, _now()),
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()),
)
conn.commit()
row = _get_photo(conn, slab_id, photo_id)
return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"],
"original_retained": True, "processed_retained": row["processed_blob"] is not None}
"role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None}
def _now() -> str:
@@ -108,9 +111,25 @@ async def list_photos(slab_id: str) -> list[dict]:
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
rows = conn.execute(
"SELECT id, seq, is_calibration, width_px, height_px, "
"SELECT id, seq, is_calibration, role, width_px, height_px, "
"(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed "
"FROM slab_photos WHERE slab_id=? ORDER BY seq",
(slab_id,),
).fetchall()
return [dict(r) for r in rows]
@router.delete("/{slab_id}/photos/{photo_id}", status_code=204)
async def delete_photo(slab_id: str, photo_id: str):
"""#90: drop a photo the owner removed in Review (keep/remove edit). The
remaining photos are re-numbered by the client via re-upload."""
conn = get_connection()
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
cur = conn.execute(
"DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)
)
conn.commit()
if cur.rowcount == 0:
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
return Response(status_code=204)
+50 -30
View File
@@ -16,7 +16,14 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from .db import get_connection
from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
from .woo import (
WooAuthError,
WooClient,
WooConnectionError,
WooCredentialsMissing,
WooError,
WooForbiddenError,
)
from .review import compute_ready, _slab_dict
router = APIRouter(prefix="/api/v1/slabs")
@@ -72,21 +79,24 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
if tags:
payload["tags"] = [{"id": t} for t in tags]
# Five locked attributes by synced term name.
# Five locked attributes: resolve the attribute row from the synced cache by
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
# then fall back to case-insensitive name. Term *options* are always the
# names of the selected synced terms.
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
built_attrs = []
for field, attr_name, pos in (
("edge_type_term_id", "Edge Type", 0),
("figure_term_ids", "Figure", 1),
("grade_term_id", "Grade", 2),
("thickness_term_id", "Thickness", 3),
("moisture_term_id", "Moisture", 4),
for field, attr_slug, attr_name, pos in (
("edge_type_term_id", "edge-type", "Edge Type", 0),
("figure_term_ids", "pa_figure", "Figure", 1),
("grade_term_id", "pa_grade", "Grade", 2),
("thickness_term_id", "pa_thickness", "Thickness", 3),
("moisture_term_id", "pa_moisture", "Moisture", 4),
):
raw = slab.get(field)
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
options = [terms[t]["name"] for t in term_ids if t in terms]
attr_id = _attr_id_by_name(attrs, attr_name)
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
if attr_id and options:
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
if built_attrs:
@@ -94,19 +104,35 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
return payload
def _attr_id_by_name(attrs: dict, name: str) -> int | None:
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
"""Match a locked attribute against the synced Woo set by slug, then name."""
slug_l = slug.lower().lstrip()
# Accept both "figure" and "pa_figure" style slugs from Woo.
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
for a in attrs.values():
if a.get("name", "").lower() == name.lower():
s = (a.get("slug") or "").lower()
if s in slug_alts:
return a["id"]
name_l = name.lower()
for a in attrs.values():
if (a.get("name") or "").lower() == name_l:
return a["id"]
return None
def _classify(e: Exception) -> tuple[str, str, bool]:
"""Return (code, plain message, recoverable)."""
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
if isinstance(e, WooCredentialsMissing):
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
if isinstance(e, WooAuthError):
return "woo_auth", "The store rejected the credentials. Check the app password.", False
if isinstance(e, WooForbiddenError):
# WAF / security plugin block — retrying the same request will not help.
return (
"woo_forbidden",
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
False,
)
if isinstance(e, WooConnectionError):
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
return "woo_failed", "The store create failed. You can retry.", True
@@ -150,9 +176,16 @@ async def publish(slab_id: str) -> dict:
code, msg, rec = _classify(e)
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
# 4/5. Upload processed PNGs, then POST product.
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
# its dimension-overlay copy, then the remaining batch photos. The Review
# photo strip stores the owner's keep order (leftmost = primary), so the
# overlay (which can sit after a moved batch photo) is re-ordered here by
# role rather than by stored seq. Ties fall back to stored seq.
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
photos = conn.execute(
"SELECT id, processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq",
"SELECT id, processed_blob, role FROM slab_photos "
"WHERE slab_id=? AND processed_blob IS NOT NULL "
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
(slab_id,),
).fetchall()
uploaded_ids: list[int] = []
@@ -202,26 +235,13 @@ def _create_status(conn) -> str:
def _sku_exists(client: WooClient, sku: str) -> bool:
code, _h, body = client._request(
f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
"GET", None,
{"Authorization": f"Basic {client._auth}", "Accept": "application/json"},
)
if code != 200:
raise WooError(f"sku lookup returned {code}")
return len(json.loads(body)) > 0
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
return len(client.get_products_by_sku(sku)) > 0
def _create_product(client: WooClient, payload: dict) -> int:
body = json.dumps(payload).encode("utf-8")
code, _h, resp = client._request(
f"{client.base_url}/wp-json/wc/v3/products",
"POST", body,
{"Authorization": f"Basic {client._auth}", "Content-Type": "application/json", "Accept": "application/json"},
)
if code not in (200, 201):
raise WooError(f"product create returned {code}")
return int(json.loads(resp)["id"])
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
return client.create_product(payload)
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
+82 -37
View File
@@ -5,6 +5,7 @@ METADATA-80/85/90, SETTINGS-35.
import json
import re
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter
@@ -44,6 +45,10 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}}
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _split_length(length_in: float) -> tuple[int, float]:
ft = int(length_in // 12)
rem = round(length_in - ft * 12, 1)
@@ -66,14 +71,16 @@ def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
)
figure = figure.strip() if figure else ""
live_edge = ""
if primary_leaf["slug"] in ("dimensional-wood", "bowl-blanks"):
live_edge = "Live Edge " if slab.get("edge_type_term_id") else ""
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
# "Live Edge" is a product-form flag driven by the *synced* edge term
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
title = pattern.format(
figure=figure + " " if figure else "",
species=species_name,
@@ -84,8 +91,17 @@ def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf:
return re.sub(r"\s+", " ", title).strip()
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
title = build_title(slab, figure, species_name, primary_leaf)
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
"""True when the synced edge term name/slug indicates live edge."""
if not edge_term_id:
return False
term = terms.get(edge_term_id) or {}
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
return "live" in blob
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
title = build_title(slab, figure, species_name, primary_leaf, terms)
ft, rem = _split_length(slab["length_in"])
length_str = f"{ft}ft {_fmt1(rem)}in"
bdft = round(slab["bdft"], 1)
@@ -142,6 +158,13 @@ def price_suggestion(conn, slab: dict) -> float | None:
@router.post("/{slab_id}/review")
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
win (METADATA-50). Assembled values are written back to the slab so the lock
sticks for publish.
"""
payload = payload or {}
conn = get_connection()
row = conn.execute(
@@ -150,46 +173,68 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
slab = _slab_dict(row)
# Apply any review edits (primary category declaration + prose).
editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"}
for k in editable:
if k in payload and payload[k] is not None:
if k == "primary_wood_category_id":
continue # not stored as a column; derived
slab[k] = payload[k]
# --- Deterministic review: report ready/missing + the deterministic
# title/short_description built from the current metadata. The client's
# "Create title" / "Create short desc" buttons call this endpoint and write the
# returned strings into the fields (#89: no separate lock action).
# #80: persist owner overrides (price/sku) from the payload so the review
# endpoint is self-contained. title/short_description are NOT overridden here —
# they are produced by the deterministic build below, not persisted from the
# payload (so the review POST can't clobber the owner's Create workflow).
cats, terms = _taxonomy_lookup(conn)
# Primary wood category: declared, else first wood_category_ids, else first category.
primary_id = payload.get("primary_wood_category_id")
wood_cats = slab.get("wood_category_ids") or []
if not primary_id:
primary_id = wood_cats[0] if wood_cats else None
primary_leaf = cats.get(primary_id, {})
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") else ""
figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
# Deterministic copy (only if not already overridden by owner).
if not payload.get("title"):
if primary_leaf and species_name:
slab["title"] = build_title(slab, figure, species_name, primary_leaf)
if not payload.get("short_description"):
if primary_leaf and species_name:
slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf)
# Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks.
suggested = price_suggestion(conn, slab)
if payload.get("price") is not None:
price = payload["price"]
elif slab.get("price") not in (None, 0):
price = slab["price"]
else:
price = suggested
ready, missing = compute_ready({**slab, "price": price})
# #80: persist owner overrides from the payload (price/sku only).
overrides: dict[str, Any] = {}
for k in ("price", "sku"):
if k in payload and payload[k] is not None:
overrides[k] = payload[k]
if overrides:
set_clause = ", ".join(f"{k} = ?" for k in overrides)
conn.execute(
f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
[*overrides.values(), _now(), slab_id],
)
conn.commit()
slab.update(overrides)
# Deterministic title / short_description, re-derived from the current
# metadata on every review (the Create button's source of truth).
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
title = (
build_title(slab, figure, species_name, primary_leaf, terms)
if (primary_leaf and species_name)
else slab.get("title") or None
)
short_desc = (
build_short_description(slab, figure, species_name, primary_leaf, terms)
if (primary_leaf and species_name)
else slab.get("short_description") or None
)
price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
return {
"ready": ready,
"missing": missing,
"title": slab.get("title"),
"short_description": slab.get("short_description"),
"price": price,
"price_suggestion": suggested,
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
"price": price, "price_suggestion": price_suggestion(conn, slab),
"primary_wood_category_id": primary_id,
}
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
else the stored price, else the suggestion (None if no rate)."""
suggested = price_suggestion(conn, slab)
if payload.get("price") is not None:
return payload["price"]
if slab.get("price") not in (None, 0):
return slab["price"]
return suggested
+30
View File
@@ -28,13 +28,18 @@ PLAIN_KEYS: set[str] = {
"woo_create_status",
"taxonomy_last_sync_at",
"taxonomy_anchor",
"crop_fill",
}
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
NUMERIC_KEYS: set[str] = {"crop_fill"}
DEFAULTS: dict[str, Any] = {
"inference_enabled": False,
"content_llm_enabled": False,
"woo_create_status": "draft",
# #99: global crop fill %, one source of truth for the ref line + PNG crop.
"crop_fill": 90,
}
@@ -91,6 +96,12 @@ def get_settings() -> dict[str, Any]:
result[k] = val if val is not None else DEFAULTS.get(k)
else:
result[k] = DEFAULTS.get(k)
if k in NUMERIC_KEYS:
try:
f = float(result[k])
result[k] = int(f) if f.is_integer() else f
except (TypeError, ValueError):
result[k] = DEFAULTS.get(k)
return result
@@ -108,6 +119,25 @@ def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
},
)
# #99: rules live here in the 4xx layer, not in the DB (structural only).
if "crop_fill" in payload:
raw = payload["crop_fill"]
try:
pct = float(raw)
except (TypeError, ValueError):
pct = None
if pct is None or not (1 <= pct <= 100):
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "validation",
"message": "crop_fill must be a number between 1 and 100 (percent)",
"detail": {"field": "crop_fill"},
}
},
)
conn = get_connection()
for k, v in payload.items():
if k not in ALL_KEYS:
+116 -16
View File
@@ -13,6 +13,7 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response
from .db import get_connection
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
router = APIRouter(prefix="/api/v1/slabs")
@@ -42,6 +43,20 @@ TAXONOMY_TABLES = {
"fig_tag_ids": "woo_tags",
"feat_tag_ids": "woo_tags",
}
# Owner-facing labels for stale_field messages (QUALITY-120 — never raw column names).
FIELD_LABELS = {
"species_id": "Species",
"wood_category_ids": "Wood category",
"edge_type_term_id": "Edge type",
"figure_term_ids": "Figure",
"grade_term_id": "Grade",
"thickness_term_id": "Thickness",
"moisture_term_id": "Moisture",
"fig_tag_ids": "Figure tags",
"feat_tag_ids": "Feature tags",
}
PRICE_SOURCES = {"recommendation", "override", "manual"}
INFERENCE_STATUSES = {"idle", "inferring", "done", "failed", "skipped"}
def _now_iso() -> str:
@@ -64,6 +79,12 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
return out
# #89: server-managed fields that a plain client PUT must not set directly.
# (metadata_locked was dropped — the deterministic title/short-desc are built by
# the review endpoint and written back by the client's Create buttons.)
CLIENT_FORBIDDEN_FIELDS = set()
def _row_to_slab(row) -> Dict[str, Any]:
d = dict(row)
for f in JSON_ARRAY_FIELDS:
@@ -95,6 +116,58 @@ def _check_taxonomy(conn, table: str, val: Any) -> bool:
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
def _current_options(conn, table: str) -> list[dict[str, Any]]:
"""Live taxonomy options for a stale_field response (PUBLISH-20 / #82)."""
rows = conn.execute(f"SELECT id, name FROM {table} ORDER BY name, id").fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
def _normalize_tax_val(val: Any) -> Any:
if isinstance(val, str):
try:
return json.loads(val)
except (ValueError, TypeError):
return []
return val
def _validate_taxonomy_fields(conn, payload: Dict[str, Any]) -> JSONResponse | None:
"""Return a 422 stale_field response, or None if all present taxonomy ids are live."""
for field, table in TAXONOMY_TABLES.items():
if field not in payload or payload[field] is None:
continue
val = _normalize_tax_val(payload[field])
if not _check_taxonomy(conn, table, val):
label = FIELD_LABELS.get(field, field)
return _err(
"stale_field",
f"{label} is no longer in the store taxonomy",
{"field": field, "current_options": _current_options(conn, table)},
status=422,
)
return None
def _validate_enums(payload: Dict[str, Any]) -> JSONResponse | None:
if "price_source" in payload and payload["price_source"] is not None:
if payload["price_source"] not in PRICE_SOURCES:
return _err(
"validation",
"price_source is not a known value",
{"field": "price_source", "allowed": sorted(PRICE_SOURCES)},
status=400,
)
if "inference_status" in payload and payload["inference_status"] is not None:
if payload["inference_status"] not in INFERENCE_STATUSES:
return _err(
"validation",
"inference_status is not a known value",
{"field": "inference_status", "allowed": sorted(INFERENCE_STATUSES)},
status=400,
)
return None
# Capture-only fields required to create a slab. The Review screen fills the
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
# editable until create"), so create must accept a capture-math payload.
@@ -110,15 +183,34 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
if missing:
return _err("validation", "missing fields", {"fields": missing}, status=400)
# #83: reject unknown keys (client sent a field that is not client-settable).
# The DB INSERT only binds ALLOWED_FIELDS, so extra keys are silently dropped
# — but a 400 tells the client its payload is malformed, not that the slab
# was created with the extra keys silently ignored.
unknown = [k for k in payload if k not in ALLOWED_FIELDS and k != "client_rev"]
if unknown:
return _err("validation", "unknown fields", {"fields": unknown}, status=400)
# Business rule (middle tier, not the DB): an empty/blank SKU at create means
# "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only
# governs present SKUs, and NULL is the honest "absent" representation.
sku = payload["sku"]
sku = payload.get("sku")
if isinstance(sku, str) and sku.strip() == "":
payload = {**payload, "sku": None}
enum_err = _validate_enums(payload)
if enum_err is not None:
return enum_err
conn = get_connection()
if payload["sku"] is not None and _sku_taken(conn, payload["sku"]):
tax_err = _validate_taxonomy_fields(conn, payload)
if tax_err is not None:
return tax_err
if payload.get("sku") is not None and _sku_taken(conn, payload["sku"]):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
# Resolve it from the measured thickness against the live synced Woo bands and
# overwrite whatever the client sent (the UI has no band control).
band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn))
if band is not None:
payload = {**payload, "thickness_term_id": band}
now = _now_iso()
data = _encode(payload)
allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
@@ -129,8 +221,12 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
vals = [data[f] for f in allowed_cols] + server_vals
ph = ",".join(["?"] * len(cols))
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
try:
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
except Exception:
# QUALITY-120: never surface a driver stack to the owner.
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
return _row_to_slab(row)
@@ -155,6 +251,13 @@ async def get_slab(id: str) -> Dict[str, Any]:
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if "client_rev" not in payload:
return _err("validation", "client_rev required", status=400)
# #89: server-managed fields a plain PUT must not set.
for field in CLIENT_FORBIDDEN_FIELDS:
if field in payload:
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
enum_err = _validate_enums(payload)
if enum_err is not None:
return enum_err
conn = get_connection()
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if current is None:
@@ -164,19 +267,16 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
return _err("revision_conflict", "client_rev mismatch",
{"current_server_rev": current["server_rev"]}, status=409)
if "sku" in payload and payload["sku"] != current["sku"]:
if _sku_taken(conn, payload["sku"], exclude_id=id):
if payload["sku"] is not None and _sku_taken(conn, payload["sku"], exclude_id=id):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
for field, table in TAXONOMY_TABLES.items():
if field in payload and payload[field] is not None:
val = payload[field]
if isinstance(val, str):
try:
val = json.loads(val)
except (ValueError, TypeError):
val = []
if not _check_taxonomy(conn, table, val):
return _err("stale_field", f"{field} not in current taxonomy",
{"current_options": val}, status=422)
tax_err = _validate_taxonomy_fields(conn, payload)
if tax_err is not None:
return tax_err
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
eff_thickness = payload.get("thickness_in", current["thickness_in"])
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
if band is not None:
payload = {**payload, "thickness_term_id": band}
data = _encode(payload)
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
if not cols:
+41 -4
View File
@@ -34,13 +34,16 @@ PNG_ORIG = b"\x89PNG-orig-bytes"
PNG_PROC = b"\x89PNG-proc-bytes"
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0):
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0, role="batch", seq=None):
files = {"original": ("orig.png", original, "image/png")}
if processed is not None:
files["processed"] = ("proc.png", processed, "image/png")
data = {"photo_id": photo_id, "is_calibration": str(is_calibration), "role": role}
if seq is not None:
data["seq"] = str(seq)
return client.post(
f"/api/v1/slabs/{slab_id}/photos",
data={"photo_id": photo_id, "is_calibration": str(is_calibration)},
data=data,
files=files,
)
@@ -90,9 +93,43 @@ def test_reupload_replaces(photos_env):
def test_list_photos(photos_env):
client, _ = photos_env
_multipart(client, "s1", "pa", is_calibration=1)
_multipart(client, "s1", "pb")
_multipart(client, "s1", "pa", is_calibration=1, role="calibration")
_multipart(client, "s1", "pb", role="overlay")
r = client.get("/api/v1/slabs/s1/photos")
assert r.status_code == 200
ids = [row["id"] for row in r.json()]
assert "pa" in ids and "pb" in ids
def test_upload_stores_and_returns_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r1", role="overlay")
assert r.status_code == 201
assert r.json()["role"] == "overlay"
r = client.get("/api/v1/slabs/s1/photos")
row = {row_["id"]: row_ for row_ in r.json()}["r1"]
assert row["role"] == "overlay"
def test_upload_rejects_bad_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r2", role="not-a-role")
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
def test_delete_photo(photos_env):
client, _ = photos_env
_multipart(client, "s1", "d1")
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 200
r = client.delete("/api/v1/slabs/s1/photos/d1")
assert r.status_code == 204
# Gone from the list and blob no longer served.
ids = [row["id"] for row in client.get("/api/v1/slabs/s1/photos").json()]
assert "d1" not in ids
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 404
def test_delete_photo_not_found(photos_env):
client, _ = photos_env
assert client.delete("/api/v1/slabs/s1/photos/ghost").status_code == 404
+178
View File
@@ -45,6 +45,21 @@ def env(monkeypatch):
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
# Thickness bands, names as synced (the derivation parses them) — QUALITY-95.
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)")
for tid, name, slug in [
(347, 'Under 1/2"', 'xthin'), (348, '1/2" - 3/4"', '2-3qtr'),
(349, '3/4" - 1"', '3-4qtr'), (350, '1"- 1 1/2"', '4-6qtr'),
(351, '1 1/2" - 2"', '6-8qtr'), (352, '2" - 3"', '8-12qtr'),
(353, 'Over 3"', '12qtr'),
]:
conn.execute(
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)",
(tid, name, slug, tid),
)
# Moisture keeps its own attribute so the five locked attributes stay distinct.
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)")
# creds
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
@@ -119,6 +134,109 @@ def test_publish_success(env, monkeypatch):
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
def test_publish_sends_derived_thickness_band(env, monkeypatch):
"""#90 / QUALITY-95: the band is off the Review screen but must still reach Woo.
The owner has no thickness-band control, so nothing client-side carries it: the
server derives it from the measurement on create/update, and publish must send the
live synced term name on the product payload. This pins that chain end to end.
"""
client, conn = env
create = client.post("/api/v1/slabs", json={
"id": "s1", "sku": "SLAB-BAND", "length_in": 114.0, "thickness_in": 1.75,
"sqft": 30.0, "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
})
assert create.status_code == 201, create.text
# 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client.
assert create.json()["thickness_term_id"] == 351
put = client.put("/api/v1/slabs/s1", json={
"client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2],
"edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326,
"price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S",
"description": "D", "content_source": "manual",
})
assert put.status_code == 200, put.text
conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
conn.commit()
products = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
products.append(json.loads(data))
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(products) == 1, "expected exactly one product create"
attrs = {a["id"]: a for a in products[0].get("attributes", [])}
assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}"
assert attrs[9]["options"] == ['1 1/2" - 2"']
def test_publish_woo_image_order_by_role(env, monkeypatch):
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
# role, not by stored seq (the Review strip stores the owner's keep order).
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
# overlay(3). The publish query must reorder to cal, overlay, batch.
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
conn.commit()
media_calls = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
media_calls.append(data)
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(media_calls) == 3
# The photo id is embedded in the multipart filename (slab.py publish
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
# batch (PUBLISH-45), not stored seq.
def photo_id(body: bytes) -> str:
import re
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
return m.group(1).decode() if m else ""
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
def test_uat_draft(env, monkeypatch):
client, conn = env
s = _full_slab(sku="SLAB-UAT-1")
@@ -182,3 +300,63 @@ def test_partial_media_failure_502(env, monkeypatch):
# sync_log written, status failed
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.commit()
seen = []
def fake_request(url, method, data, headers):
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 42}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient, _USER_AGENT
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
assert sku_hops and create_hops
for hop in sku_hops + create_hops:
assert hop["headers"].get("User-Agent") == _USER_AGENT
def test_publish_403_is_unrecoverable(env, monkeypatch):
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.commit()
from backend.woo import WooClient, WooForbiddenError
def fake_request(url, method, data, headers):
raise WooForbiddenError("request forbidden (403)")
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 502
body = r.json()
assert body["error"]["code"] == "woo_forbidden"
assert body["error"]["detail"]["recoverable"] is False
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
+58
View File
@@ -55,6 +55,8 @@ def test_title_live_edge_no_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# #89: the review always returns the deterministic title/short_description
# built from the current metadata (the Create button's source of truth).
r = env[0].post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 200, r.text
body = r.json()
@@ -134,5 +136,61 @@ def test_knife_blank_carries_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# #89: the deterministic build runs on every review.
r = client.post("/api/v1/slabs/s1/review", json={})
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
def test_review_always_reports_deterministic_title(env):
"""#89: the review always returns the deterministic title/short_description
built from the current metadata. The client's Create button writes these into
the fields; the server does not persist them from the review payload."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
# The review does NOT persist title/short_description from the payload.
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] is None
assert got["short_description"] is None
def test_review_no_species_reports_stored_title(env):
"""With no species, there is no deterministic pattern; the review reports the
stored title (None here) and lists title as missing."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] is None
assert "title" in body["missing"]
def test_review_persists_owner_overrides(env):
"""#80: regular review persists price/sku overrides from the payload to the DB,
so the client doesn't need a separate PUT for each. (title/short_description
are NOT persisted here — they come from the deterministic build, not the payload.)"""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# Review with owner overrides for price + sku.
r = client.post("/api/v1/slabs/s1/review", json={
"price": 250.0,
"sku": "SLAB-99",
})
assert r.status_code == 200
body = r.json()
assert body["price"] == 250.0
# The overrides are persisted: GET reflects them.
got = client.get("/api/v1/slabs/s1").json()
assert got["price"] == 250.0
assert got["sku"] == "SLAB-99"
# server_rev was incremented (the UPDATE fired).
assert got["server_rev"] == 2
+48
View File
@@ -138,3 +138,51 @@ def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
body = resp.json()
assert body["inference_api_key"] == {"configured": False}
db_mod._reset_for_tests()
def test_settings_crop_fill_defaults_to_90(monkeypatch):
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
resp = client.get("/api/v1/settings")
assert resp.status_code == 200
assert resp.json()["crop_fill"] == 90
db_mod._reset_for_tests()
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
assert resp.status_code == 200
assert resp.json()["crop_fill"] == 75
conn = db_mod.get_connection()
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
assert row["value"] == "75" and row["value_enc"] is None
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
db_mod._reset_for_tests()
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
for bad in (0, 101, -5, "abc", None):
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
assert resp.status_code == 400, f"{bad!r} should be rejected"
assert resp.json()["error"]["code"] == "validation"
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
# boundaries are valid
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
db_mod._reset_for_tests()
+122
View File
@@ -33,6 +33,23 @@ def _seed_taxonomy(conn):
# tags
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
# live Woo attribute, because the derivation parses them.
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
for tid, name, slug in [
(347, 'Under 1/2"', 'xthin'),
(348, '1/2" - 3/4"', '2-3qtr'),
(349, '3/4" - 1"', '3-4qtr'),
(350, '1"- 1 1/2"', '4-6qtr'),
(351, '1 1/2" - 2"', '6-8qtr'),
(352, '2" - 3"', '8-12qtr'),
(353, 'Over 3"', '12qtr'),
]:
conn.execute(
"INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
"VALUES (?, 2, ?, ?, ?)",
(tid, name, slug, tid),
)
conn.commit()
@pytest.fixture
@@ -103,6 +120,55 @@ def test_create_and_fetch(client, db):
assert body[key] == payload[key]
def test_create_derives_thickness_band(client, db):
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
the UI has no band control, so the server's derivation is authoritative.
"""
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"thickness_term_id": 1,
}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
def test_update_rebands_when_thickness_changes(client, db):
"""A thickness edit moves the slab to the band the new measurement rounds into."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
def test_update_ignores_client_supplied_band(client, db):
"""A stale client band cannot pin the slab to the wrong band."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
def test_duplicate_sku(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
@@ -260,3 +326,59 @@ def test_delete_published_conflict(client, db):
conn.commit()
resp = client.delete(f"/api/v1/slabs/{slab_id}")
assert resp.status_code == 409
def test_create_rejects_unknown_fields(client, db):
# #83: POST /slabs with extra (non-ALLOWED_FIELDS) keys → 400, not a silent drop.
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5, "bogus_field": 42}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 400
body = resp.json()
assert body["error"]["code"] == "validation"
assert "bogus_field" in body["error"]["detail"]["fields"]
def test_update_stale_field_returns_live_options(client, db):
# #82: stale_field returns LIVE taxonomy options (id+name), not the submitted id.
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
}
assert client.post("/api/v1/slabs", json=payload).status_code == 201
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "edge_type_term_id": 999})
assert resp.status_code == 422
body = resp.json()
assert body["error"]["code"] == "stale_field"
opts = body["error"]["detail"]["current_options"]
assert isinstance(opts, list) and opts
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
assert 999 not in [o["id"] for o in opts]
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
def test_create_extra_key_and_bad_taxonomy_are_4xx(client, db):
"""#83: server-managed/extra keys and bad taxonomy/enums are 4xx, not 500."""
base = {
"id": str(uuid.uuid4()), "sku": "SKU-XTRA", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"status": "published", # server-managed → unknown field 400
}
r = client.post("/api/v1/slabs", json=base)
assert r.status_code == 400, r.text
assert "status" in r.json()["error"]["detail"]["fields"]
# Bad taxonomy on create
r2 = client.post("/api/v1/slabs", json={
"id": str(uuid.uuid4()), "sku": "SKU-BADTAX", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "species_id": 999,
})
assert r2.status_code == 422
assert r2.json()["error"]["code"] == "stale_field"
# Bad enum
r3 = client.post("/api/v1/slabs", json={
"id": str(uuid.uuid4()), "sku": "SKU-BADENUM", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "price_source": "not-a-source",
})
assert r3.status_code == 400
assert r3.json()["error"]["code"] == "validation"
+94
View File
@@ -0,0 +1,94 @@
"""Thickness band resolution (METADATA-15 / QUALITY-95).
The owner never picks the Woo thickness band in the UI — the server derives it
from the measured thickness_in against the live synced bands. These tests pin the
derivation on the real live band names (from the UAT taxonomy) and on the
quarter-inch slug fallback.
"""
import pytest
from backend.thickness_band import (
parse_inch_token,
resolve_thickness_band_id,
term_bounds,
)
# The live UAT Thickness attribute (pa_thk) as synced: name, slug.
LIVE_TERMS = [
{"id": 347, "name": "Under 1/2\"", "slug": "xthin"},
{"id": 348, "name": "1/2\" - 3/4\"", "slug": "2-3qtr"},
{"id": 349, "name": "3/4\" - 1\"", "slug": "3-4qtr"},
{"id": 350, "name": "1\"- 1 1/2\"", "slug": "4-6qtr"},
{"id": 351, "name": "1 1/2\" - 2\"", "slug": "6-8qtr"},
{"id": 352, "name": "2\" - 3\"", "slug": "8-12qtr"},
{"id": 353, "name": "Over 3\"", "slug": "12qtr"},
]
@pytest.mark.parametrize(
"text,expected",
[
("1 1/2", 1.5),
("3/4", 0.75),
("2", 2.0),
("1.75", 1.75),
("2\"", 2.0),
("1 1/2\"", 1.5),
],
)
def test_parse_inch_token(text, expected):
assert parse_inch_token(text) == pytest.approx(expected)
def test_parse_inch_token_rejects_junk():
assert parse_inch_token("") is None
assert parse_inch_token(" ") is None
def test_term_bounds_from_live_names():
assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5)
assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75)
assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5)
assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf"))
def test_term_bounds_falls_back_to_qtr_slug():
assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5)
assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf"))
assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5)
@pytest.mark.parametrize(
"thickness,expected_id",
[
(0.25, 347), # Under 1/2"
(0.5, 348), # boundary rounds UP into 1/2"-3/4", not down
(0.625, 348),
(0.75, 349), # boundary rounds UP
(0.875, 349),
(1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band
(1.25, 350),
(1.5, 351),
(1.75, 351), # the UAT sample thickness
(2.0, 352),
(2.75, 352),
(3.0, 353),
(4.0, 353),
],
)
def test_resolve_rounds_up_on_boundary(thickness, expected_id):
assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id
def test_resolve_handles_missing_inputs():
assert resolve_thickness_band_id(None, LIVE_TERMS) is None
assert resolve_thickness_band_id(-1, LIVE_TERMS) is None
assert resolve_thickness_band_id(1.0, []) is None
# A term with no parseable name/slug is skipped, never guessed at.
assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None
def test_resolve_is_id_order_independent():
"""The result must not depend on the sync order of the terms."""
assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351
+177
View File
@@ -0,0 +1,177 @@
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
synced band term. We map thickness_in into the first half-open band [low, high)
that contains it, derived only from the live synced term names/slugs — never a
hardcoded Woo term id.
Round-up semantics: bands are half-open on the upper bound, so a value that sits
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
value (1"–1 1/2"), not the band that ends there (3/4"–1").
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Optional
# Mixed number / fraction / decimal inch token, optional trailing quote.
_INCH_RE = re.compile(
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
re.IGNORECASE,
)
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
def parse_inch_token(text: str) -> Optional[float]:
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
t = (text or "").strip().rstrip("\"'").strip()
if not t:
return None
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
if not m:
return None
if m.group("a") is not None:
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
if m.group("n2") is not None:
return float(m.group("n2")) / float(m.group("d2"))
if m.group("dec") is not None:
return float(m.group("dec"))
return None
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
"""Return half-open [low, high) inches from a Woo thickness term name."""
raw = (name or "").strip()
low_s = raw.lower()
if not raw:
return None
# Under X / less than X / < X
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
if m:
hi = parse_inch_token(m.group(1))
if hi is not None:
return (0.0, hi)
# Over X / above X / > X / X+
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
if m:
lo = parse_inch_token(m.group(1))
if lo is not None:
return (lo, float("inf"))
m = re.match(r"^(.+?)\+\s*$", raw)
if m:
lo = parse_inch_token(m.group(1))
if lo is not None:
return (lo, float("inf"))
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
if len(parts) == 2:
lo = parse_inch_token(parts[0])
hi = parse_inch_token(parts[1])
if lo is not None and hi is not None and hi > lo:
return (lo, hi)
# Single value band treated as [v, v+epsilon) — rare
only = parse_inch_token(raw)
if only is not None:
return (only, only + 1e-9)
return None
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
s = (slug or "").strip().lower()
if not s:
return None
if s in {"xthin", "under", "thin"}:
return (0.0, 0.5)
m = _QTR_RANGE_RE.match(s)
if m:
lo = int(m.group(1)) * 0.25
hi = int(m.group(2)) * 0.25
if hi > lo:
return (lo, hi)
m = _QTR_MIN_RE.match(s)
if m:
lo = int(m.group(1)) * 0.25
return (lo, float("inf"))
return None
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
"""Bounds for one synced thickness term; name first, slug fallback."""
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
str(term.get("slug") or "")
)
def resolve_thickness_band_id(
thickness_in: float | int | None,
terms: Iterable[dict[str, Any]],
) -> Optional[int]:
"""Pick the synced thickness term id for thickness_in, or None if unknown.
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
"""
if thickness_in is None:
return None
try:
t = float(thickness_in)
except (TypeError, ValueError):
return None
if t < 0:
return None
scored: list[tuple[float, float, int]] = [] # (low, high, id)
for term in terms:
b = term_bounds(term)
if b is None:
continue
tid = term.get("id")
if tid is None:
continue
lo, hi = b
scored.append((lo, hi, int(tid)))
if not scored:
return None
# Prefer the tightest band that contains t under half-open [low, high).
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
if matches:
matches.sort() # tightest span first
return matches[0][3]
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
scored.sort(key=lambda x: x[0])
for lo, hi, tid in scored:
if t < hi:
return tid
return scored[-1][2]
def load_thickness_terms(conn) -> list[dict[str, Any]]:
"""Live Thickness attribute terms from the synced Woo cache."""
row = conn.execute(
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
("thickness", "thickness"),
).fetchone()
if row is None:
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
row = conn.execute(
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
).fetchone()
if row is None:
return []
attr_id = row[0]
rows = conn.execute(
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
(attr_id,),
).fetchall()
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]
+38 -13
View File
@@ -98,14 +98,7 @@ class WooClient:
def _get(self, path: str) -> tuple[int, dict, bytes]:
url = f"{self.base_url}/wp-json/wc/v3{path}"
return self._request(
url, "GET", None,
{
"Authorization": f"Basic {self._auth}",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
},
)
return self._request(url, "GET", None, self._auth_headers())
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
page = 1
@@ -132,6 +125,42 @@ class WooClient:
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
"""Shared headers for every outbound Woo/WP request (User-Agent + auth)."""
h = {
"Authorization": f"Basic {self._auth}",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
}
if extra:
h.update(extra)
return h
def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]:
"""GET products filtered by SKU. Raises typed Woo* errors on non-200."""
code, _h, body = self._request(
f"{self.base_url}/wp-json/wc/v3/products?sku={sku}",
"GET",
None,
self._auth_headers(),
)
if code != 200:
raise WooServerError(f"sku lookup returned {code}")
return list(json.loads(body))
def create_product(self, payload: dict[str, Any]) -> int:
"""POST /products. Raises typed Woo* errors on failure; returns product id."""
body = json.dumps(payload).encode("utf-8")
code, _h, resp = self._request(
f"{self.base_url}/wp-json/wc/v3/products",
"POST",
body,
self._auth_headers({"Content-Type": "application/json"}),
)
if code not in (200, 201):
raise WooServerError(f"product create returned {code}")
return int(json.loads(resp)["id"])
def upload_media(self, png_bytes: bytes, filename: str) -> int:
"""POST a PNG to WordPress core media; return the attachment id."""
boundary = "----slabuploader-media-boundary"
@@ -143,11 +172,7 @@ class WooClient:
url = f"{self.base_url}/wp-json/wp/v2/media"
code, _headers, resp = self._request(
url, "POST", body,
{
"Authorization": f"Basic {self._auth}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"User-Agent": _USER_AGENT,
},
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
)
if code not in (200, 201):
raise WooServerError(f"media upload returned {code}")
+123
View File
@@ -24,3 +24,126 @@ Check to see if you are running on ubudual5060 (192.168.1.202) before attempting
Future deploys use a **separate** container name and a free port in `10880`–`10889`
(other than `10880`), bound `0.0.0.0`.
Do not put a host name in the product spec.
## Deploy records
### 2026-09-21 02:41 PDT — #90 wood primary category + derived thickness band
- Source ref: `0329052` on `issue-89-metadata-lock` (pushed to Gitea; pulled into `~/slab-deploy`).
- Images (built on `ubudual5060`): `deploy-frontend:latest` `1c169c29f131`,
`deploy-fastapi:latest` `32671659639b`.
- Compose: `deploy/docker-compose.yml` + host-local (untracked) `deploy/override-m3.yml`.
- URL: `http://192.168.1.202:10881` (LAN) · `http://100.64.0.9:10881` (Tailscale).
- `matches HEAD`: **yes**.
- Sample data preserved: **yes** — 24 slabs before and after `up -d` (no `-v`, no volume removal).
- Verified on the live stack: served asset `assets/index-tlFib3RM.js` (sha256 `3b665746ec81f28e…`)
contains the new markers and no `#thickness` select; `POST /api/v1/slabs` at `thickness_in 1.0`
stored band `350` (`1"- 1 1/2"`); `PUT` at `2.75` re-banded to `352`; a client-sent stale band id
was ignored; `POST /review` no longer lists thickness as missing; the Review screen in a real
browser (LAN URL) shows the wood-category MultiSelect closed summary with names + count, the
`Primary wood category` radio set switching on click, and the band as read-only text.
- Not exercised: the Woo publish path. Rows created **before** this deploy keep `thickness_term_id`
NULL until their next save (the band is derived on write, so any Review commit fills it in).
### 2026-09-21 03:12 PDT — follow-up: thickness band omitted entirely from Review
- Commit: `204bc5e` (fix #90: band removed from UI, not merely read-only; publish test added).
- Rebuilt `deploy-frontend:latest` and recreated the container on 10881.
- Live verification: served HTML and bundle contain zero occurrences of "Thickness band" or "set from the measurement".
- Sample data unchanged (still 25 slabs post-recreate).
- All gates (unit, build, requirements, coverage, m3-create e2e) pass.
### 2026-09-21 11:52 PDT — merge `issue-89-metadata-lock` → main (Create-string model)
- Source: branch `work/merge-89-90` (base `origin/issue-89-metadata-lock` + Create-string re-applied).
`matches HEAD`: **no** — staged from the uncommitted working tree (local sync; Gitea push not
attempted this round). 9 files changed: Review.svelte, slabs.ts, review.py, slabs.py, schema.sql,
db/__init__.py, test_review.py, m3-create.spec.ts, review-photo-strip.spec.ts.
- Model change: `metadata_locked` DB column + "Save and lock" UI **dropped end-to-end**. The
deterministic title/short_description are now built by the review endpoint on every call; the
owner's **Create title / Create short desc** buttons fire the build and write the result
(overwrite contract). `commitReview` auto-fills only empty fields (no owner-clobber); `createTitle`
is the explicit force-overwrite. Photo strip, wood primary/secondary, and server-side thickness
band (item: not shown in UI, passed to Woo on publish) all taken from the branch.
- LAN binds: `deploy/override-m3.yml` was absent from disk; the live stack's 10881/10882 LAN binds
were re-created by editing `deploy/docker-compose.yml` ports in-place (loopback 18082/18083 from
`.env` + `0.0.0.0:10881`/`0.0.0.0:10882`), then `down --remove-orphans` + `up -d` (no `-v`).
- Bundle: `assets/index-YPAqx609.js` (new; contains `create-title`). API `/api/health`
`woo_reachable:true`.
- Sample data preserved: **yes** — 34 slabs before/after.
- Live verification (LAN URL, real DB): `slabs` table has **no** `metadata_locked` column;
`slab_photos.role` present. `POST /review` on slab `ec4088da…` → `title:'Curl Black Walnut Slab'`,
`ready:True`. `PUT` title persisted (rev 1→2) then restored (original sample value intact, rev 3).
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
not exercised this round.
### 2026-09-21 12:10 PDT — redeploy: `main` after merge `8ee3da9` (reconciled PR #97 + branch)
- Source: `main` @ `8ee3da9` (merge of `work/merge-89-90` into main, which itself reconciled
main's PR #97 client-side `thicknessBand.ts` + `syncSelects`/`box-sizing` against the branch's
server-side `thickness_band.py` + Create-string #89). `matches HEAD`: **yes**.
- Resolution outcome in the deployed bundle: band is **server-side** — `Review.svelte` no longer
imports `thicknessBand` and the read-only band display is gone; PR #97's `syncSelects()` (3× in
source) + `box-sizing` CSS preserved; `metadata_locked` still dropped; Create-string + photo
strip + wood primary live.
- Rebuilt both images from `main`; recreated the stack (`down --remove-orphans` + `up -d`, no `-v`).
LAN binds 10881/10882 intact.
- Bundle: `assets/index-BScrgd8o.js` (new; differs from pre-merge `YPAqx609`; contains
`create-title`, **zero** `thicknessBand` references). API `/api/health` `woo_reachable:true`.
- Sample data preserved: **yes** — 35 slabs (one added upstream since last deploy).
- Live verification (LAN URL, real DB): `POST /review` on slab `ec4088da…` →
`title:'Curl Black Walnut Slab'`, `ready:True`.
- Gates at merge time: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip).
- Branches `work/merge-89-90` and `issue-89-metadata-lock` deleted (local + remote); remote has
only `main`.
### 2026-09-21 12:34 PDT — Review mount: restore dropped lineup/woodPrimary imports
- Source: uncommitted working tree on `main` (parent `67aa3d6`). `matches HEAD`: **no**.
Diff: `frontend/src/create/review/Review.svelte` +5 lines restoring imports of
`keptInOrder`/`moveItem`/`missingMinimums`/`roleLabel`/`resolvePhotoUrl`/`LineupItem`
from `lineup.ts` and `nextPrimary` from `woodPrimary.ts`. The morning merge kept the
call sites and dropped the imports → `ReferenceError` on Review mount → "Could not load the slab."
- Rebuilt `deploy-frontend` only (`compose build frontend` + `up -d frontend`, no `-v`).
Image `56b35f6cdcdf`. FastAPI container left running.
- URL: http://192.168.1.202:10881 (LAN) · loopback 127.0.0.1:18082
- Bundle: `assets/index-BVCyM130.js` (replaced `index-ck41xD02.js`). Served JS has **0**
`nextPrimary`/`keptInOrder` (minified locals) vs 3 unresolved `nextPrimary` in the broken bundle.
- Sample data preserved: **yes** — 47 slabs before rebuild; 48 after live Capture→Review probe
(created `ef1f4101-b1d2-459d-b9a0-842e7650ea3c`, sku NULL, status calibrated, 48×2 in; not published).
- Live verification (loopback, real API): Continue to Review → `review-screen` visible,
heading "Review to Ready", no loadError, no pageerror. POST /slabs 201, photos 201×2,
GET slab/taxonomy/settings 200, review 200.
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
### 2026-09-25 08:57 PDT — M4 review preview: #99 fixed tape line + calibration rotation
- Source: branch `m4`, commit **`a756cf5`** (`fix(m4): drop stale tick-nudge hint from tape page (#99)`),
pushed and verified on `origin/m4`. `matches HEAD`: **yes**. Preceding M4 commits on the same
branch: `1882fa3` (calibration tilt applied to the calibration PNG/overlay only) and
`7908a99` (tape calibration collapsed onto one page).
- Image `deploy-frontend:m4-a756cf5` (`sha256:26402a77…`) built on the 5060 from a clean release
archive, **not** from the `~/slab-deploy` greenfield checkout. Container `slabuploader-m4-review`
recreated on network `deploy_default`, host port `10883:80`, **zero mounts**.
- URL: http://192.168.1.202:10883/?noload=1 (M4 review only). **Not** `10881` — that is the old
`main` stack and has no Length page. `10882` is the API, not a UI.
- Bundle: `assets/index-BI2cLnOA.js`. Served bundle has **zero** `Drag the ticks` /
`nudge the last tick` strings and **does** contain the fixed-line hint.
- Main stack untouched: `:10881` and `:10882/api/health` both 200 throughout.
- Sample data preserved: **yes** — the review container is volume-free; the real DB was never
rebuilt or re-seeded.
- Live verification (deployed URL, real backend): the fixed line renders with both end ticks;
a drag starting on a tick behaves identically to a pan elsewhere (line does not move); the
crop slider moves the line ends; Length/Thickness → Calculate reaches Photos ready. A drag is
still allowed (that is the intended pan).
- Gates: `npm test` 102/102; `tsc --noEmit` clean; requirements + coverage gates green;
render gate clean at 1440/768/375 (no overflow, no clipped text, no page/console/HTTP errors).
- Regression guard proven to fail against pre-fix code, not just pass post-fix.
- Ty UAT sign-off: **length e2e signed off 2026-09-25**; minor bugs to be filed separately.
- Not done: `svelte-check` still reports its known 42-error baseline (no new error from this work);
`Calculate` (42px) and `Fit` (41px) are marginally under the 44px touch reference — pre-existing,
deliberately left for a separate decision; Gitea #99's **body** still describes the superseded
draggable-tick model (the as-built is recorded in `tasks.md` T2 and requirements rows
CAPTURE-170/175/75).
+6 -2
View File
@@ -16,7 +16,9 @@ services:
build:
context: ..
dockerfile: frontend/Dockerfile
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"]
ports:
- "127.0.0.1:${FRONTEND_PORT:-18080}:80"
- "0.0.0.0:10881:80"
depends_on: [fastapi]
restart: unless-stopped
@@ -34,7 +36,9 @@ services:
volumes:
- slab_db:/data
- slab_images:/images
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"]
ports:
- "127.0.0.1:${FASTAPI_PORT:-18081}:8000"
- "0.0.0.0:10882:8000"
mem_limit: 2g
restart: unless-stopped
+5
View File
@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html;
index index.html;
# Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
# multipart to /api/v1/slabs/{id}/photos. nginx's default client_max_body_size
# is 1MB, which 413s on real multi-MB photos. Allow large photo bodies.
client_max_body_size 25m;
# Docker embedded DNS for service name resolution (fastapi)
resolver 127.0.0.11 valid=10s;
+29 -1
View File
@@ -41,8 +41,36 @@ OPS-40,
QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105, QUALITY-110,
QUALITY-120, QUALITY-125
## M4 — Tape-line, manual taxonomy, Call 2, and UAT exit
IDs:
CAPTURE-25, CAPTURE-30, CAPTURE-35, CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50,
CAPTURE-55, CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-75, CAPTURE-80, CAPTURE-85,
CAPTURE-90, CAPTURE-95, CAPTURE-100, CAPTURE-105, CAPTURE-110, CAPTURE-115,
CAPTURE-120, CAPTURE-125, CAPTURE-130, CAPTURE-135, CAPTURE-140, CAPTURE-141,
CAPTURE-145, CAPTURE-150, CAPTURE-155, CAPTURE-160, CAPTURE-170, CAPTURE-175,
METADATA-5, METADATA-10, METADATA-15, METADATA-20, METADATA-25, METADATA-30,
METADATA-50, METADATA-55, METADATA-60, METADATA-65, METADATA-66, METADATA-70,
METADATA-75, METADATA-80, METADATA-85, METADATA-90, METADATA-95, METADATA-105,
METADATA-106, METADATA-110, METADATA-115, METADATA-120,
REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-35,
REVIEW-40, REVIEW-45, REVIEW-50, REVIEW-55,
PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-31,
PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-40, PUBLISH-45,
PUBLISH-50, PUBLISH-55, PUBLISH-60,
SETTINGS-5, SETTINGS-10, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30,
SETTINGS-35, SETTINGS-40, SETTINGS-45, SETTINGS-50, SETTINGS-51, SETTINGS-55,
SETTINGS-60, SETTINGS-65, SETTINGS-70, SETTINGS-75, SETTINGS-80, SETTINGS-85,
SETTINGS-90,
APP-15, APP-20, APP-30, APP-35, APP-45, APP-50, APP-55, APP-60, APP-65,
OPS-40,
QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105,
QUALITY-110, QUALITY-120, QUALITY-125, QUALITY-130
## Later
IDs:
CAPTURE-15, CAPTURE-165, METADATA-107, OPS-30, OPS-35, QUALITY-115
CAPTURE-15, CAPTURE-165, METADATA-35, METADATA-40, METADATA-45, METADATA-100,
METADATA-107, OPS-30, OPS-35, QUALITY-115
+10 -5
View File
@@ -26,10 +26,12 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif
| CAPTURE-45 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.calibrate.test.ts |
| CAPTURE-50 | The mask overlay renders on the source photo and the owner approves it; approval is blocked when the mask has no slab pixels. | shipped | — |
| CAPTURE-55 | The mask must be a single contiguous slab contour; otherwise the resolution is a retake. | shipped | — |
| CAPTURE-60 | The length axis is the long side of the minimum-area bounding rectangle of the mask; the owner rotates it to the prevailing length and confirms. | shipped | frontend/src/geometry/rect.test.ts |
| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/capture/tapeLine.test.ts |
| CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts |
| CAPTURE-75 | scale equals longest_axis_px divided by measured length, where longest_axis_px is the mask span end to end along the approved axis, not the rectangle edge. | shipped | — |
| CAPTURE-75 | Scale equals the pixel distance between the two ends of the fixed tape line divided by the owner-declared length; mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts |
| CAPTURE-170 | The calibration overlay draws one black tape-line fixed in the middle of the 3:4 frame, with a tick at each end. The line is a stage fixture: it is not editable, and its ends are set by the crop-fill setting alone (at 90 percent each end sits 5 percent in from the frame edge). The owner pans, zooms, and rotates the slab until its long axis lies along the line. | M4 | frontend/src/capture/tapeLine.test.ts |
| CAPTURE-175 | The owner-declared length is the tape reading taken where the slab was aligned to the line; scale equals the fixed line's pixel distance between its two ends, in source-photo coordinates, divided by that declared length. Mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts |
| CAPTURE-80 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts |
| CAPTURE-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts |
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
@@ -71,7 +73,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| METADATA-80 | The server assembles the name and short description deterministically from the primary wood category's patterns and the dimension fact block. The patterns use only synced taxonomy terms and capture facts. | M3 | — |
| METADATA-90 | The dimension fact block supplies the exact strings the patterns interpolate and the only numbers Call 2 may print; there is no L by W by T rectangle assumption. | M3 | — |
| METADATA-55 | Call 2 runs on demand and produces the long description. | M3 | — |
| METADATA-60 | Call 2 receives the processed photos, the metadata confirmed or changed in review, the primary and secondary wood categories, the parent-category path, the dimension fact block, and brand/geo context. | M3 | — |
| METADATA-60 | Call 2 receives the processed photos, the metadata the owner picked or changed in review, including primary and secondary wood categories and the human-selected taxonomy attributes and tags, the parent-category path, the dimension fact block, and brand/geo context. | M4 | — |
| METADATA-65 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — |
| METADATA-95 | The dimension-overlay image is never sent to the model. | M3 | — |
| METADATA-70 | With inference off, the server produces the long description. | M3 | — |
@@ -82,6 +84,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| METADATA-107 | Before the prompt revision interface is exposed beyond the trusted network it requires authentication. | later | — |
| METADATA-110 | The Generate text control appears only when content generation is enabled and the inference endpoint is reachable, and is disabled when the text service is down. | M3 | — |
| METADATA-115 | A runtime inference failure is classified recoverable or unrecoverable for the owner: recoverable offers Retry, unrecoverable gives a plain message explaining it. | M3 | — |
| METADATA-120 | Call 2 is grounded in the owner's human-picked primary and secondary wood categories and taxonomy values from review; it does not receive or depend on Call 1 suggestions. | M4 | — |
## REVIEW — confirm, tweak, ready
@@ -97,6 +100,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| REVIEW-40 | The owner can edit any field, including generated title and description, until create, except the calculated measurement fields (widths, sqft, bdft), which are not user mutable. | M3 | — |
| REVIEW-45 | SKU is 3 to 24 characters, alphanumeric and hyphen only. | M3 | — |
| REVIEW-50 | Title and short_description warn past 60 and 160 characters and never exceed the store's hard limits; description has no maximum. | M3 | docs/contracts/WOO-API.md |
| REVIEW-55 | Review always presents a thumb-first manual taxonomy flow: the owner selects species, primary and optional secondary wood categories, Edge, Figure, Grade, Moisture, and optional synced tags; no Call 1 suggestion, inference pre-fill, or AI marker is shown. | M4 | — |
## PUBLISH — store submission
@@ -113,7 +117,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| PUBLISH-36 | Every publish failure is classified for the owner as recoverable (he can act) or unrecoverable (outside his control), and the plain message states which it is and the next step. | M3 | — |
| PUBLISH-37 | A recoverable failure points at the field to fix, or offers Retry while the draft and photos are retained. | M3 | — |
| PUBLISH-38 | An unrecoverable failure says it is outside the owner's control and offers the available next step. | M3 | — |
| PUBLISH-40 | Images upload as processed PNGs with alpha; the clean primary, the primary dimensioned copy, and every remaining batch photo the owner selected are uploaded, with no upper cap on the number of images. | M3 | — |
| PUBLISH-40 | Images upload as processed PNGs with alpha; the clean primary, the primary dimensioned copy, and every remaining batch photo the owner selected are uploaded, with no upper cap on the number of images. | M4 | — |
| PUBLISH-45 | The Woo image order is the photos-ready lineup: clean calibration, dimension-overlay copy, then the rest in capture order. | M3 | — |
| PUBLISH-50 | Published means the store create succeeded, not that the product is live when status is draft. | M3 | — |
| PUBLISH-55 | Only non-null fields are sent; the payload carries name, type simple, status, sku, slug, regular_price, description, short_description, categories, tags, images, attributes, virtual false, downloadable false, catalog_visibility visible, sold_individually true. The slug is the sanitized product name plus the lowercased SKU. | M3 | docs/contracts/WOO-API.md |
@@ -199,10 +203,11 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
| QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
| QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — |
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | — |
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | backend/tests/test_thickness_band.py |
| QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
| QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
| QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
| QUALITY-115 | Browser end-to-end capture through publish is verified on iOS Safari, Android Chrome, and desktop Chrome, Edge, and Firefox. | later | — |
| QUALITY-120 | No raw status code, stack trace, or internal code reaches the owner in any tested path. | M3 | — |
| QUALITY-125 | An encrypted credential stored by Settings decrypts back to the value it was given. | M3 | — |
| QUALITY-130 | The M4 exit walk records raw runner evidence for a real `SLAB-UAT-*` WooCommerce draft created through the UI; the draft matches the reviewed ready set and is not published live. | M4 | — |
+3 -1
View File
@@ -42,6 +42,8 @@ remain the point of record, and `.planning/` files cite requirement IDs without
| `.planning/MILESTONES.md` | history |
| `.planning/milestones/v0.5/M3/roadmap.md` | roadmap |
| `.planning/milestones/v0.5/M3/task_breakdown.md` | breakdown |
| `.planning/milestones/v0.5/M4/goals.md` | roadmap |
| `.planning/milestones/v0.5/M4/tasks.md` | breakdown |
Roles:
@@ -87,7 +89,7 @@ Defined only in `docs/REQUIREMENTS.md`, as table rows:
| CAPTURE-5 | Statement of one testable requirement | shipped | frontend/src/measure/measure.test.ts |
```
- `Scope` is `shipped`, `M3`, or `later`.
- `Scope` is `shipped`, a milestone scope (`M3`, `M4`, ...), or `later`.
- `Test` is a repo-relative test path, a requirement ID, or `—`. A path given for a `shipped` requirement must exist; a given ID must resolve to a defined requirement.
- IDs advance by 5 within each namespace (`5, 10, 15, ...`), resetting for each prefix. The gaps are intentional room for later insertions; never renumber to close a gap.
+4 -4
View File
@@ -121,10 +121,10 @@ hard stop. Switch to a supported browser.
3. **Wood categories.** Pick at least one from the synced list.
4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
exactly one grade.
5. **Thickness band.** Not a control — it is derived automatically from the measured
thickness (the thickness-in rounds up into the synced Woo band, `TV-9` / `QUALITY-95`).
It displays read-only on the Review screen; fix it upstream by correcting the thickness
on capture/measure if the band looks wrong.
5. **Thickness band.** Not on the screen and not picked: the server bands the measured thickness
up into the synced Woo band, stores it, and publish sends it to the store. Verify after publish
that the Woo product's Thickness attribute is the band the captured thickness rounds up into
(`QUALITY-95`).
6. **Moisture.** Keep the default (kiln-dried) or change it.
7. **fig-* tags.** Optional; pick any from the synced list.
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
+4 -1
View File
@@ -12,7 +12,7 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
| CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
| METADATA-10, METADATA-15, METADATA-30, QUALITY-95 | required taxonomy fields and cardinality; the thickness band is derived from the measurement, not picked |
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
| PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
@@ -62,6 +62,9 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
(`REVIEW-5`) via that derived value.
- **Primary category.** The owner declares one primary wood category and any secondary ones; the
default is the first selected.
- **Thickness band is derived and not shown.** The measured thickness bands up into the synced Woo
band server-side (`METADATA-15`, `QUALITY-95`); Review carries no thickness-band control and no
read-only echo of it. The derived band is stored on the slab and sent to the store at publish.
- **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is
no single submit-time gap list.
- **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable.
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --import tsx --test src/**/*.test.ts",
"test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
+27 -16
View File
@@ -18,7 +18,7 @@
import {
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
} from './create/flow';
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
import { createSlab, uploadPhoto, getSettings, updateSettings, ApiError } from './api/slabs';
import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
@@ -26,7 +26,6 @@
let cap: Capture = { k: 'batch', photos: [] };
let busy = false;
let pngUrl: string | null = null;
let showSettings = false;
let showSlabs = false;
@@ -37,16 +36,20 @@
const unsubCap = session.capture.subscribe((c) => {
cap = c;
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
});
const unsubBusy = session.busy.subscribe((b) => (busy = b));
let cropFill = 90;
onMount(() => {
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
// device draft into IndexedDB after the origin exists, then reload without
// ?noload to trigger the Resume prompt deterministically.
const boot = new URLSearchParams(window.location.search);
if (!boot.get('noload')) void session.resumeProbe();
void getSettings().then((s) => {
if (typeof s.crop_fill === 'number') {
cropFill = s.crop_fill;
session.setCropFill(s.crop_fill);
}
}).catch(() => {});
});
onDestroy(() => {
unsubCap();
@@ -105,16 +108,22 @@
content_source: 'manual',
client_rev: 0,
});
const pngItems = capture.lineup.filter(
(it) => it.kind === 'png' && it.role !== 'overlay',
);
// #90 / REVIEW-5: persist the full lineup with correct roles so the Review
// strip matches in-memory blobs by role. The overlay is the dimension-drawn
// copy — it must be persisted (its blob already has the dimensions) and is
// the photo the owner sees with the measurement overlay. Excluding it (the
// old `role !== 'overlay'` filter) dropped the dimension copy from Review.
const pngItems = capture.lineup.filter((it) => it.kind === 'png');
for (let i = 0; i < pngItems.length; i++) {
const it = pngItems[i];
await uploadPhoto(id, {
photoId: uuid(),
seq: i + 1,
isCalibration: it.role === 'calibration',
role: it.role,
original: it.blob,
// The calibration/batch PNGs have no separate processed variant; the
// overlay blob already carries the drawn dimensions.
processed: it.blob,
});
}
@@ -210,18 +219,20 @@
{:else if cap.k === 'axis'}
<AxisPanel
state={cap}
setAxisAngle={session.setAxisAngle}
approveAxis={session.approveAxis}
{cropFill}
setTapePose={session.setTapePose}
setCropFill={(pct) => {
cropFill = pct;
session.setCropFill(pct);
void updateSettings({ crop_fill: pct }).catch(() => {});
}}
calculateFromAxis={session.calculateFromAxis}
back={session.back}
/>
{:else}
{:else if cap.k === 'measure'}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
calculate={session.calculate}
back={session.back}
{pngUrl}
onContinue={saving ? undefined : () => void continueToReview()}
/>
{/if}
+51
View File
@@ -157,6 +157,8 @@ export interface PhotoRow {
id: string;
seq: number;
is_calibration: number;
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
role: 'calibration' | 'overlay' | 'batch';
width_px: number | null;
height_px: number | null;
has_original: number;
@@ -167,6 +169,8 @@ export interface UploadPhotoInput {
photoId: string; // client-generated UUIDv4
seq?: number;
isCalibration: boolean;
/** #90: lineup kind; defaults to batch. */
role?: 'calibration' | 'overlay' | 'batch';
original: Blob;
processed?: Blob | null;
}
@@ -176,6 +180,7 @@ export interface UploadPhotoResult {
slab_id: string;
seq: number;
is_calibration: number;
role: 'calibration' | 'overlay' | 'batch';
original_retained: boolean;
processed_retained: boolean;
}
@@ -191,6 +196,7 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
form.append('photo_id', input.photoId);
if (input.seq != null) form.append('seq', String(input.seq));
form.append('is_calibration', input.isCalibration ? '1' : '0');
form.append('role', input.role ?? 'batch');
const origName = input.original instanceof File ? input.original.name : 'original.png';
form.append('original', input.original, origName);
if (input.processed) form.append('processed', input.processed, 'processed.png');
@@ -200,6 +206,49 @@ export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<Up
}).then((r) => parse<UploadPhotoResult>(r));
}
// #90: drop a photo the owner removed in Review (keep/remove edit).
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
method: 'DELETE',
}).then((r) => parse<void>(r));
}
/**
* #90: persist an edited Review photo lineup. `kept` is the owner's final
* order (leftmost = primary); each entry re-uploads with a fresh seq so the
* stored `seq` reflects the keep order. `removed` photos are dropped. Every
* kept photo's blob is re-sent (original + processed) because the server
* stores only the BLOB — there is no separate re-seq endpoint.
*/
export interface LineupPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
original: Blob;
processed: Blob | null;
}
export async function persistLineup(
slabId: string,
kept: LineupPhoto[],
removed: string[],
): Promise<void> {
for (const id of removed) {
await deletePhoto(slabId, id);
}
for (let i = 0; i < kept.length; i++) {
const p = kept[i];
await uploadPhoto(slabId, {
photoId: p.id,
seq: i + 1,
isCalibration: p.isCalibration,
role: p.role,
original: p.original,
processed: p.processed,
});
}
}
export function photoUrl(
slabId: string,
photoId: string,
@@ -405,6 +454,8 @@ export interface SettingsView {
woo_create_status: string;
taxonomy_last_sync_at: string | null;
taxonomy_anchor: string | null;
/** #99: global crop fill %, default 90. Drives the ref line + the PNG crop. */
crop_fill: number;
}
export function getSettings(): Promise<SettingsView> {
+360 -33
View File
@@ -1,58 +1,385 @@
<script lang="ts">
import type { Capture } from './capture.types';
import PhotoStage from './PhotoStage.svelte';
import { onDestroy, onMount } from 'svelte';
import type { Capture, PhotoTransform } from './capture.types';
import { eighthsToIn, inToEighths } from './capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import {
CALIBRATION_FRAME,
IDENTITY_TRANSFORM,
cropEnds,
fitScale,
} from './tapeLine';
export let state: Extract<Capture, { k: 'axis' }>;
export let setAxisAngle: (deg: number) => void;
export let approveAxis: () => void;
export let cropFill = 90;
export let setTapePose: (pose: {
transform: PhotoTransform;
sourceW: number;
sourceH: number;
}) => void;
export let setCropFill: (pct: number) => void = () => {};
export let calculateFromAxis: (lengthEighths: number, thicknessEighths: number) => Promise<void>;
export let back: () => void;
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
const frame = CALIBRATION_FRAME;
let stageEl: HTMLDivElement | undefined;
let canvas: HTMLCanvasElement | undefined;
let bitmap: ImageBitmap | null = null;
let maskLayer: HTMLCanvasElement | null = null;
let sourceW = state.source.widthPx;
let sourceH = state.source.heightPx;
let renderedW = 320;
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? IDENTITY_TRANSFORM) };
// The line is a stage fixture. Its two ends are the crop insets, so they are
// a pure function of crop_fill and are never an owner input (#99).
let fill = cropFill;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let calcBusy = false;
let drag: 'tilt' | 'pan' | 'pinch' | null = null;
let pointers = new Map<number, [number, number]>();
let pinch0 = 0;
let zoom0 = 1;
let panGrab: [number, number] = [0, 0];
let tilt0 = 0;
let tiltX0 = 0;
let moved = false;
let downAt: [number, number] = [0, 0];
let lastTap: { t: number; x: number; y: number } | null = null;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
function publish() {
setTapePose({
transform: { ...transform },
sourceW,
sourceH,
});
}
function toFrame(event: { clientX: number; clientY: number }): [number, number] {
const rect = canvas!.getBoundingClientRect();
renderedW = rect.width;
return [
((event.clientX - rect.left) / rect.width) * frame.width,
((event.clientY - rect.top) / rect.height) * frame.height,
];
}
function draw() {
if (!canvas || !stageEl) return;
const rect = stageEl.getBoundingClientRect();
renderedW = rect.width || renderedW;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform((dpr * rect.width) / frame.width, 0, 0, (dpr * rect.height) / frame.height, 0, 0);
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, frame.width, frame.height);
if (bitmap && sourceW > 0 && sourceH > 0) {
const scale = fitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
ctx.save();
ctx.translate(frame.width / 2 + transform.panX, frame.height / 2 + transform.panY);
ctx.rotate((transform.tiltDeg * Math.PI) / 180);
ctx.scale(scale, scale);
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
if (maskLayer) {
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(maskLayer, -sourceW / 2, -sourceH / 2);
}
ctx.restore();
}
const x = frame.width / 2;
const ends = cropEnds(fill);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, ends.startY);
ctx.lineTo(x, ends.endY);
ctx.stroke();
for (const y of [ends.startY, ends.endY]) {
ctx.beginPath();
ctx.moveTo(x - 13, y);
ctx.lineTo(x + 13, y);
ctx.stroke();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#eee';
ctx.beginPath();
ctx.arc(x, frame.height - 28, 16, 0, Math.PI * 2);
ctx.fill();
}
function reseat() {
// The line's ends follow crop_fill; nothing about them is owner-draggable.
publish();
draw();
}
function zoomAt(point: [number, number], factor: number) {
const z1 = clamp(transform.zoom * factor, 0.5, 3);
if (z1 === transform.zoom) return;
const cx = frame.width / 2;
const cy = frame.height / 2;
const qx = cx + (point[0] - cx - transform.panX) / transform.zoom;
const qy = cy + (point[1] - cy - transform.panY) / transform.zoom;
transform = {
...transform,
zoom: z1,
panX: point[0] - cx - z1 * (qx - cx),
panY: point[1] - cy - z1 * (qy - cy),
};
publish();
draw();
}
function fit() {
transform = { ...IDENTITY_TRANSFORM, tiltDeg: transform.tiltDeg };
publish();
draw();
}
function onPointerDown(event: PointerEvent) {
const p = toFrame(event);
pointers.set(event.pointerId, p);
canvas!.setPointerCapture(event.pointerId);
moved = false;
downAt = p;
if (pointers.size >= 2) {
const pts = [...pointers.values()];
pinch0 = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) || 1;
zoom0 = transform.zoom;
drag = 'pinch';
event.preventDefault();
return;
}
const x = frame.width / 2;
if (Math.hypot(p[0] - x, p[1] - (frame.height - 28)) <= 22) {
drag = 'tilt';
tilt0 = transform.tiltDeg;
tiltX0 = p[0];
} else {
drag = 'pan';
panGrab = [p[0] - transform.panX, p[1] - transform.panY];
}
event.preventDefault();
draw();
}
function onPointerMove(event: PointerEvent) {
const p = toFrame(event);
pointers.set(event.pointerId, p);
if (!drag) return;
if (Math.hypot(p[0] - downAt[0], p[1] - downAt[1]) > 3) moved = true;
if (drag === 'pinch' && pointers.size >= 2) {
const pts = [...pointers.values()];
const dist = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) || 1;
const mid: [number, number] = [(pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2];
transform = { ...transform, zoom: zoom0 };
zoomAt(mid, clamp(dist / pinch0, 0.8, 1.25));
return;
}
if (drag === 'tilt') {
transform = { ...transform, tiltDeg: tilt0 + (p[0] - tiltX0) * 0.6 };
} else if (drag === 'pan') {
transform = {
...transform,
panX: clamp(p[0] - panGrab[0], -400, 400),
panY: clamp(p[1] - panGrab[1], -400, 400),
};
}
publish();
draw();
}
function onPointerUp(event: PointerEvent) {
const p = toFrame(event);
pointers.delete(event.pointerId);
const wasPan = drag === 'pan';
drag = pointers.size ? drag : null;
if (wasPan && !moved) {
const now = performance.now();
if (lastTap && now - lastTap.t < 350 && Math.hypot(p[0] - lastTap.x, p[1] - lastTap.y) < 12) {
lastTap = null;
fit();
return;
}
lastTap = { t: now, x: p[0], y: p[1] };
}
draw();
}
function onWheel(event: WheelEvent) {
event.preventDefault();
const dy = clamp(event.deltaY, -200, 200);
zoomAt(toFrame(event), clamp(Math.exp(-dy * 0.0015), 0.8, 1.25));
}
function onKey(event: KeyboardEvent) {
const tag = (event.target as HTMLElement | null)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (event.key === '+' || event.key === '=') {
zoomAt([frame.width / 2, frame.height / 2], 1.1);
event.preventDefault();
} else if (event.key === '-' || event.key === '_') {
zoomAt([frame.width / 2, frame.height / 2], 1 / 1.1);
event.preventDefault();
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const step = event.shiftKey ? 0.1 : 1;
transform = { ...transform, tiltDeg: transform.tiltDeg + (event.key === 'ArrowLeft' ? -step : step) };
publish();
draw();
event.preventDefault();
}
}
function onFill(event: Event) {
fill = Number((event.currentTarget as HTMLInputElement).value);
setCropFill(fill);
reseat();
}
const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null;
};
const commitInches = (raw: string): number => {
const inches = positiveInches(raw);
return inches === null ? 0 : inToEighths(inches);
};
async function onCalculate() {
const length = commitInches(String(lengthIn));
const thickness = commitInches(String(thicknessIn));
lengthIn = length === 0 ? '' : String(eighthsToIn(length));
thicknessIn = thickness === 0 ? '' : String(eighthsToIn(thickness));
if (length === 0 || thickness === 0) return;
calcBusy = true;
try {
publish();
await calculateFromAxis(length, thickness);
} finally {
calcBusy = false;
}
}
onMount(async () => {
const bmp = await decodeOrientedBitmap(state.source.blob);
bitmap = bmp;
sourceW = bmp.width;
sourceH = bmp.height;
const bitset = state.approvedMask.bitset;
if (bitset.length === sourceW * sourceH) {
maskLayer = document.createElement('canvas');
maskLayer.width = sourceW;
maskLayer.height = sourceH;
const mctx = maskLayer.getContext('2d');
if (mctx) {
const data = mctx.createImageData(sourceW, sourceH);
for (let i = 0; i < bitset.length; i++) {
if (bitset[i]) data.data[i * 4 + 3] = 255;
}
mctx.putImageData(data, 0, 0);
}
}
publish();
draw();
});
onDestroy(() => bitmap?.close());
</script>
<svelte:window on:keydown={onKey} />
<section class="panel">
<h1>Length axis</h1>
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
<PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
{maskW}
{maskH}
/>
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
<input
type="range" min="0" max="179" step="1"
value={angleDeg}
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
<span class="val">{angleDeg}°</span>
<h1>Length</h1>
<p class="hint">Move the slab until its long axis sits on the line, the way it lines up with the tape. The line is fixed in the middle; its ends are set by the crop slider and do not move.</p>
<div class="stage" bind:this={stageEl} data-testid="tape-line">
<canvas
bind:this={canvas}
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={onPointerUp}
on:wheel={onWheel}
></canvas>
</div>
<p class="ctl">Tilt</p>
<div class="row">
<button type="button" class="thumb" aria-label="Tilt counter-clockwise" on:click={() => { transform = { ...transform, tiltDeg: transform.tiltDeg - 1 }; publish(); draw(); }}>−</button>
<input type="range" min="-180" max="180" step="0.1" aria-label="Tilt" value={transform.tiltDeg} on:input={(e) => { transform = { ...transform, tiltDeg: Number(e.currentTarget.value) }; publish(); draw(); }} />
<button type="button" class="thumb" aria-label="Tilt clockwise" on:click={() => { transform = { ...transform, tiltDeg: transform.tiltDeg + 1 }; publish(); draw(); }}>+</button>
<span class="val">{transform.tiltDeg.toFixed(1)}°</span>
</div>
<p class="ctl">Zoom</p>
<div class="row">
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => zoomAt([frame.width / 2, frame.height / 2], 1 / 1.1)}>−</button>
<input type="range" min="50" max="300" step="1" aria-label="Zoom" value={Math.round(transform.zoom * 100)} on:input={(e) => { const z = Number(e.currentTarget.value) / 100; zoomAt([frame.width / 2, frame.height / 2], z / Math.max(transform.zoom, 0.01)); }} />
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => zoomAt([frame.width / 2, frame.height / 2], 1.1)}>+</button>
<span class="val">{Math.round(transform.zoom * 100)}%</span>
</div>
<button type="button" class="ghost fit" on:click={fit}>Fit</button>
<p class="nudge">Wheel or pinch zooms the slab, drag pans it, and the handle tilts it. Arrow keys tilt; Shift is a fine step. Double-tap the slab to fit.</p>
<p class="ctl">Crop fill</p>
<div class="row">
<input type="range" min="1" max="100" step="1" aria-label="Crop fill percent" value={fill} on:input={onFill} />
<span class="val">{fill}%</span>
</div>
<div class="fields">
<label class="field">
<span class="ctl">Length L (tape reading)</span>
<input type="number" aria-label="Length L" inputmode="decimal" step="0.125" min="0" placeholder="0" bind:value={lengthIn} />
</label>
<label class="field">
<span class="ctl">Thickness</span>
<input type="number" aria-label="Thickness" inputmode="decimal" step="0.125" min="0" placeholder="0" bind:value={thicknessIn} />
</label>
</div>
<div class="actions">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
<button type="button" class="primary" disabled={calcBusy || positiveInches(String(lengthIn)) === null || positiveInches(String(thicknessIn)) === null} on:click={onCalculate}>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.hint, .nudge { color: #aaa; font-size: 0.85rem; line-height: 1.4; margin: 0 0 12px; }
.ctl { color: #aaa; font-size: 0.78rem; margin: 14px 0 6px; }
.stage { width: 100%; aspect-ratio: 3 / 4; background: #111; border: 1px solid #333; border-radius: 3px; overflow: hidden; touch-action: none; }
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.row { display: flex; align-items: center; gap: 10px; }
.thumb {
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
width: 44px; height: 44px; border-radius: 3px; font-size: 1.3rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
.fit { margin-top: 8px; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
.fields { display: flex; gap: 12px; margin-top: 8px; }
.field { flex: 1; }
.field input {
box-sizing: border-box; width: 100%; min-height: 44px; padding: 10px; font-size: 1rem;
border-radius: 3px; border: 1px solid #444; background: #1d1d1d; color: #eee;
}
.primary:disabled { background: #333; color: #777; }
.primary {
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
flex: 2; padding: 12px; font-size: 1rem; border-radius: 3px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.ghost { padding: 12px 16px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
</style>
+4 -106
View File
@@ -1,9 +1,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session';
import PhotoStage from './PhotoStage.svelte';
import {
buildPhotosReadyLineup,
photosReadyPngAlt,
@@ -11,22 +9,13 @@
} from './photosReadyLineup';
export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void;
export let setThickness: (e: number) => void;
export let calculate: () => Promise<void>;
export let back: () => void;
export let pngUrl: string | null;
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
export let onContinue: (() => void) | undefined = undefined;
let calcBusy = false;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let lineupUrls: string[] = [];
let lineupItems: PhotosReadyLineupItem[] = [];
$: photosReady = !!state.results;
$: {
for (const u of lineupUrls) URL.revokeObjectURL(u);
lineupUrls = [];
@@ -48,92 +37,17 @@
onDestroy(() => {
for (const u of lineupUrls) URL.revokeObjectURL(u);
});
const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null;
};
const commitLength = () => {
const inches = positiveInches(String(lengthIn));
const eighths = inches === null ? 0 : inToEighths(inches);
setLength(eighths);
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
const commitThickness = () => {
const inches = positiveInches(String(thicknessIn));
const eighths = inches === null ? 0 : inToEighths(inches);
setThickness(eighths);
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
const doCalc = async () => {
calcBusy = true;
try {
commitLength();
commitThickness();
await calculate();
} finally { calcBusy = false; }
};
</script>
<section class="panel">
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
{#if !photosReady}
<PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
/>
<div class="fields">
<div class="field">
<span class="label">Length L</span>
<input
type="number"
aria-label="Length L"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={lengthIn}
on:blur={commitLength}
on:change={commitLength}
/>
</div>
<div class="field">
<span class="label">Thickness</span>
<input
type="number"
aria-label="Thickness"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={thicknessIn}
on:blur={commitThickness}
on:change={commitThickness}
/>
</div>
</div>
<h1>Photos ready</h1>
{#if !state.results}
<p class="hint">Calculating…</p>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button
type="button"
class="primary"
disabled={calcBusy || !canCalc}
on:click={doCalc}
>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
</div>
{:else if state.results}
{:else}
<div class="results" aria-label="Photos ready">
<div class="nums">
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
@@ -169,12 +83,6 @@
{/each}
</ol>
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
<div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" />
</div>
{/if}
</div>
<div class="row">
@@ -191,14 +99,6 @@
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
.fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.field input {
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
font-variant-numeric: tabular-nums;
}
.row { display: flex; gap: 10px; margin-top: 20px; }
.primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
@@ -229,6 +129,4 @@
}
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
.png-wrap img { max-width: 100%; border-radius: 4px; }
</style>
+23 -4
View File
@@ -43,13 +43,25 @@ export interface Mask {
slabPx: number; // 0 ⇒ approve blocked, user retakes
bitset: Uint8Array; // source-grid mask, 1 byte/px
bbox: BBox;
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
slabBounds: ABox; // axis-aligned extent of slab px; the PNG crop bounds (#99)
}
// Direction only: an oriented rectangle over the slab. The arrow reads
// corners[3] → corners[0] (the long side).
export interface PhotoTransform {
tiltDeg: number;
zoom: number;
panX: number;
panY: number;
}
// The tape line is a stage fixture. bbox is kept for the dimension overlay.
// Scale is the source-pixel distance between tapeStart and tapeEnd.
export interface Axis {
bbox: BBox;
photoTransform: PhotoTransform;
tickStartY: number;
tickEndY: number;
tapeStart: [number, number];
tapeEnd: [number, number];
}
export interface Widths {
@@ -96,7 +108,9 @@ export type Capture =
k: 'axis';
source: Source;
approvedMask: Mask; // frozen footprint
axis: Axis; // live axis.bbox being nudged
axis: Axis; // live tape line
lengthEighths: Eighths;
thicknessEighths: Eighths;
}
| {
k: 'measure';
@@ -138,6 +152,11 @@ export interface Draft {
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
photoTransform?: PhotoTransform;
tickStartY?: number;
tickEndY?: number;
tapeStart?: [number, number];
tapeEnd?: [number, number];
lengthEighths: Eighths;
thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
+10 -1
View File
@@ -6,11 +6,15 @@ import { test } from 'node:test';
import { inToEighths } from './capture.types';
const panelSrc = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'AxisPanel.svelte'),
'utf8',
);
const measureSrc = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
'utf8',
);
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
test('tape page exposes typed number inputs for Length L and Thickness', () => {
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
assert.ok(
@@ -31,6 +35,11 @@ test('MeasurePanel exposes typed number inputs for Length L and Thickness', () =
false,
'Length must not be a read-only fmtEighths span',
);
assert.equal(
/aria-label="Length L"/.test(measureSrc),
false,
'the separate measure page must not collect length',
);
});
test('typed inches snap to nearest 1/8 via inToEighths', () => {
@@ -252,6 +252,33 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
s.dispose();
});
test('#62: calibration tape tilt is not copied to sibling photos (#99)', async () => {
installFakes();
const rotations: (number | undefined)[] = [];
const s = createCaptureSession({
draft: store,
encodeInventory: async (input) => {
rotations.push(input.rotationDeg);
return { blob: new Blob(['ok-png'], { type: 'image/png' }), sourceBelowFloor: false };
},
} as Parameters<typeof createCaptureSession>[0]);
await driveToMeasure(s, [tinyJpeg('sibling-a'), tinyJpeg('calibration'), tinyJpeg('sibling-b')], 1, { sheet: 'black' });
s.back();
s.setTapePose({
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
sourceW: 1000,
sourceH: 900,
});
await s.calculateFromAxis(320, 16);
assert.equal(rotations.length, 3, 'one encoder call for calibration and each sibling');
assert.equal(rotations[0], 20, 'calibration receives the selected tape tilt');
assert.equal(rotations[1], undefined, 'sibling photos remain unrotated');
assert.equal(rotations[2], undefined, 'sibling photos remain unrotated');
s.dispose();
});
test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => {
installFakes();
const a = tinyJpeg('a'); // will empty-mask
@@ -358,6 +358,87 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl
s.dispose();
});
test('calculateFromAxis stays on the tape page until the calibrated result exists', async () => {
installFakeMaskWorker();
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
async () => ({ width: MASK_W, height: MASK_H, close() {} }) as ImageBitmap;
let releaseCalibration!: () => void;
const calibrationGate = new Promise<void>((resolve) => { releaseCalibration = resolve; });
const s = createCaptureSession({
draft: store,
encodeInventory: async () => {
await calibrationGate;
return { blob: new Blob(['clean'], { type: 'image/png' }), sourceBelowFloor: false };
},
encodeOverlay: async () => new Blob(['overlay'], { type: 'image/png' }),
});
const a = tinyJpeg('a');
await s.addToBatch(a);
s.continueBatch();
s.pickCalibration(0);
s.setSheet('black');
await settleOverlay(s);
s.approveMask();
assert.equal(get(s.capture).k, 'axis');
const calculating = s.calculateFromAxis(320, 16);
await new Promise((r) => setTimeout(r, 0));
const during = get(s.capture);
assert.equal(during.k, 'axis', 'tape page stays mounted while Calculate runs');
if (during.k === 'axis') {
assert.equal(during.lengthEighths, 320);
assert.equal(during.thicknessEighths, 16);
}
releaseCalibration();
await calculating;
const after = get(s.capture);
assert.equal(after.k, 'measure');
if (after.k === 'measure') {
assert.ok(after.results, 'Photos ready opens only with a completed result');
assert.equal(after.lengthEighths, 320);
assert.equal(after.thicknessEighths, 16);
}
s.dispose();
});
test('calculateFromAxis passes the tape tilt to the calibration PNG and overlay only', async () => {
installFakeMaskWorker();
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
async () => ({ width: MASK_W, height: MASK_H, close() {} }) as ImageBitmap;
const inventoryTilts: (number | undefined)[] = [];
const overlayTilts: (number | undefined)[] = [];
const s = createCaptureSession({
draft: store,
encodeInventory: async (input) => {
inventoryTilts.push((input as typeof input & { rotationDeg?: number }).rotationDeg);
return { blob: new Blob(['clean'], { type: 'image/png' }), sourceBelowFloor: false };
},
encodeOverlay: async (input) => {
overlayTilts.push((input as typeof input & { rotationDeg?: number }).rotationDeg);
return new Blob(['overlay'], { type: 'image/png' });
},
});
await s.addToBatch(tinyJpeg('cal'));
s.continueBatch();
s.pickCalibration(0);
s.setSheet('black');
await settleOverlay(s);
s.approveMask();
s.setTapePose({
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
sourceW: MASK_W,
sourceH: MASK_H,
});
await s.calculateFromAxis(320, 16);
assert.deepEqual(inventoryTilts, [20], 'calibration inventory must receive tape tilt');
assert.deepEqual(overlayTilts, [20], 'dimension overlay must receive tape tilt');
s.dispose();
});
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
installFakeMaskWorker();
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
@@ -520,6 +601,53 @@ test('#61: draft resume through measure restores calibration source (not photos[
s2.dispose();
});
test('legacy measure draft without results returns to the tape calibration page', async () => {
installFakeMaskWorker();
const source = tinyJpeg('legacy');
store.saveDraft({
version: 1,
stage: 'measure',
batch: [source],
calibrationIndex: 0,
source,
maskParams: { sheet: 'black', sensitivity: 50 },
mask: {
slabPx: MASK_W * MASK_H,
bbox: {
corners: [[MASK_W, 0], [MASK_W, MASK_H], [0, MASK_H], [0, 0]],
longSidePx: MASK_W,
angleDeg: 0,
},
slabBounds: { x: 0, y: 0, w: MASK_W, h: MASK_H },
},
axisBbox: {
corners: [[MASK_W, 0], [MASK_W, MASK_H], [0, MASK_H], [0, 0]],
longSidePx: MASK_W,
angleDeg: 0,
},
lengthEighths: 320,
thicknessEighths: 16,
results: null,
inventoryPng: null,
});
const s = createCaptureSession({ draft: store });
await s.resumeProbe();
s.continueDraft();
const start = Date.now();
let cap = get(s.capture);
while (cap.k !== 'axis' && Date.now() - start < 1500) {
await new Promise((r) => setTimeout(r, 10));
cap = get(s.capture);
}
assert.equal(cap.k, 'axis', 'legacy resultless measure draft must not open a dead result screen');
if (cap.k === 'axis') {
assert.equal(cap.lengthEighths, 320);
assert.equal(cap.thicknessEighths, 16);
}
s.dispose();
});
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
assert.match(app, /MaskPanel/);
+226 -121
View File
@@ -1,16 +1,16 @@
import { writable } from 'svelte/store';
import type {
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
Axis, BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, PhotoTransform, Sheet, Source, WorkerIn, WorkerOut,
} from './capture.types';
import {
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
} from './capture.types';
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { IDENTITY_TRANSFORM, cropEnds, tapeSegmentSource } from './tapeLine';
const DEBOUNCE_MS = 2000;
const BUSY_MS = 300;
@@ -40,13 +40,20 @@ export interface CaptureSession {
setSensitivity(n: number): void;
setSheet(s: Sheet): void;
approveMask(): void;
setAxisAngle(deg: number): void;
setTapePose(pose: {
transform: PhotoTransform;
sourceW: number;
sourceH: number;
}): void;
setCropFill(pct: number): void;
/** Legacy resultless transition retained for old callers/tests; the shipped UI uses calculateFromAxis. */
approveAxis(): void;
/** Length and thickness are entered on the tape page. Calculate leaves that page for Photos ready. */
calculateFromAxis(lengthEighths: Eighths, thicknessEighths: Eighths): Promise<void>;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
calculate(): Promise<void>;
back(): void;
getInventoryUrl(): string | null;
/** The current `measure` branch, or null while not on it (C2 handoff). */
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
dispose(): void;
@@ -65,6 +72,19 @@ const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
const batchPhotosOf = (d: Draft): Source[] =>
d.batch && d.batch.length > 0 ? d.batch : [d.source];
type CalibrationInput = {
source: Source;
approvedMask: Mask;
axis: Axis;
lengthEighths: Eighths;
thicknessEighths: Eighths;
};
type CalibrationOutput = {
results: Measures;
batchApply: BatchPhotoResult[];
};
// The one writer. Owns the worker port, the single debounce timer, and the draft.
// Every transition to an approved branch persists; no explicit "save".
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
@@ -81,8 +101,29 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
let timer: ReturnType<typeof setTimeout> | null = null;
let passStarted = 0;
let busyTimer: ReturnType<typeof setTimeout> | null = null;
let pngUrl: string | null = null;
let resumed = false;
let cropFill = 90;
let poseTimer: ReturnType<typeof setTimeout> | null = null;
// The tape line is a stage fixture: the owner only moves the slab. Its ends are
// the crop insets, so a pose carries the photo transform and nothing else (#99).
const axisFrom = (
bbox: Axis['bbox'],
sourceW: number,
sourceH: number,
pose?: { transform?: PhotoTransform; tapeStart?: [number, number]; tapeEnd?: [number, number] },
): Axis => {
const ends = cropEnds(cropFill);
const transform = pose?.transform ?? { ...IDENTITY_TRANSFORM };
const tickStartY = ends.startY;
const tickEndY = ends.endY;
const w = sourceW > 0 ? sourceW : 1;
const h = sourceH > 0 ? sourceH : 1;
const seg = pose?.tapeStart && pose?.tapeEnd
? { start: pose.tapeStart, end: pose.tapeEnd }
: tapeSegmentSource(tickStartY, tickEndY, w, h, transform);
return { bbox, photoTransform: transform, tickStartY, tickEndY, tapeStart: seg.start, tapeEnd: seg.end };
};
const set = (next: Capture) => {
state = next;
@@ -103,10 +144,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}
worker = null;
}
if (pngUrl) {
URL.revokeObjectURL(pngUrl);
pngUrl = null;
}
};
const makeWorker = (blob: Blob) => {
@@ -207,7 +244,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
photoTransform: state.axis.photoTransform,
tickStartY: state.axis.tickStartY,
tickEndY: state.axis.tickEndY,
tapeStart: state.axis.tapeStart,
tapeEnd: state.axis.tapeEnd,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: null, inventoryPng: null,
};
}
if (state.k === 'measure') {
@@ -217,6 +259,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
photoTransform: state.axis.photoTransform,
tickStartY: state.axis.tickStartY,
tickEndY: state.axis.tickEndY,
tapeStart: state.axis.tapeStart,
tapeEnd: state.axis.tapeEnd,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
@@ -308,6 +355,92 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
stub: Omit<Mask, 'bitset' | 'params'>,
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
const runCalibration = async (input: CalibrationInput): Promise<CalibrationOutput> => {
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = input;
if (lengthEighths === 0 || thicknessEighths === 0) {
throw new Error('length and thickness are required');
}
const bitmap = await decodeOrientedBitmap(source.blob);
let pngBlob: Blob;
let belowFloor = false;
try {
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
{ start: axis.tapeStart, end: axis.tapeEnd },
);
try {
const encoded = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill, rotationDeg: axis.photoTransform.tiltDeg });
pngBlob = encoded.blob;
belowFloor = encoded.sourceBelowFloor;
} catch {
pngBlob = new Blob([], { type: 'image/png' });
}
const overlayBlob = await (async () => {
try {
return await encodeOverlay({
inventoryPng: pngBlob,
bitset: approvedMask.bitset,
widthPx: source.widthPx,
heightPx: source.heightPx,
bbox: axis.bbox,
scale: base.scale,
lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds,
source,
cropFill,
rotationDeg: axis.photoTransform.tiltDeg,
});
} catch {
return new Blob(['overlay'], { type: 'image/png' });
}
})();
const results: Measures = {
...base,
inventoryPng: pngBlob,
overlayPng: overlayBlob,
sourceBelowFloor: belowFloor,
};
const calIndex = calibrationIndex != null && calibrationIndex >= 0
? calibrationIndex
: 0;
const batch = batchPhotos.length ? batchPhotos : [source];
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
status: 'error',
message: 'Batch photo was not processed',
}));
for (let index = 0; index < batch.length; index++) {
if (index === calIndex) continue;
const photo = batch[index];
try {
const derived = await rederiveMaskDetailed(photo, approvedMask.params, {
slabPx: 0,
bbox: EMPTY_BBOX,
slabBounds: EMPTY_ABOX,
});
if (derived.mask.slabPx === 0) throw new Error('empty mask: no slab pixels detected');
const siblingSource: Source = { ...photo, widthPx: derived.widthPx, heightPx: derived.heightPx };
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean };
try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, cropFill });
} finally {
siblingBitmap.close();
}
if (encoded.sourceBelowFloor) throw new Error('PNG size floor: shorter side is below 800 px');
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
} catch (error) {
const message = error instanceof Error ? error.message : 'unable to process photo';
batchApply[index] = { status: 'error', message };
}
}
return { results, batchApply };
} finally {
bitmap.close();
}
};
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
// fall back to the mask's own bbox.
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
@@ -338,16 +471,18 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
return;
}
if (d.inventoryPng) {
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(d.inventoryPng);
// A resultless legacy measure draft predates the merged tape page. Do not
// open the result-only screen; return to tape calibration with its inputs.
if (d.stage === 'measure' && !d.results) {
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
return;
}
set({
k: 'measure', source, approvedMask: mask,
axis: { bbox: draftAxisBbox(d) },
axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d),
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results
? {
@@ -377,7 +512,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
void persist();
};
return {
const session: CaptureSession = {
capture,
busy,
@@ -476,22 +611,39 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
const overlay = state.overlay;
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
const bbox = { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg };
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx), lengthEighths: 0, thicknessEighths: 0 });
void persist();
},
setAxisAngle(deg: number) {
setTapePose(pose) {
if (state.k !== 'axis') return;
const a = ((Math.round(deg) % 180) + 180) % 180;
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
void persist();
const sourceW = pose.sourceW > 0 ? pose.sourceW : state.source.widthPx;
const sourceH = pose.sourceH > 0 ? pose.sourceH : state.source.heightPx;
const source = sourceW > 0 && sourceH > 0
? { ...state.source, widthPx: sourceW, heightPx: sourceH }
: state.source;
set({
...state,
source,
axis: axisFrom(state.axis.bbox, sourceW, sourceH, pose),
});
if (poseTimer) clearTimeout(poseTimer);
poseTimer = setTimeout(() => {
poseTimer = null;
void persist();
}, 250);
},
setCropFill(pct: number) {
cropFill = Math.max(1, Math.min(100, Math.round(pct)));
},
approveAxis() {
if (state.k !== 'axis') return;
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis: { bbox: state.axis.bbox },
axis: state.axis,
lengthEighths: 0, thicknessEighths: 0, results: null,
});
void persist();
@@ -509,105 +661,54 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
void persist();
},
// #62: Calculate the calibration photo, then apply its frozen mask params
// to siblings. This stays in Measure; siblings never enter Mask controls.
// Legacy compatibility path; the shipped tape page calls calculateFromAxis.
async calculate() {
if (state.k !== 'measure') return;
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return;
const bitmap = await decodeOrientedBitmap(source.blob);
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
);
let pngBlob: Blob;
let belowFloor = false;
try {
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
pngBlob = r.blob;
belowFloor = r.sourceBelowFloor;
} catch {
// CAPTURE.md: inventory is image/png even when encode is unavailable.
pngBlob = new Blob([], { type: 'image/png' });
}
bitmap.close();
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(pngBlob);
// #63: second copy of the calibration inventory PNG with dimension overlays.
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
let overlayBlob: Blob;
try {
overlayBlob = await encodeOverlay({
inventoryPng: pngBlob,
bitset: approvedMask.bitset,
widthPx: source.widthPx,
heightPx: source.heightPx,
bbox: axis.bbox,
scale: base.scale,
lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds,
source,
});
} catch {
// Deterministic tests may inject a non-decodable inventory blob; still
// return a distinct image/png so callers never confuse it with clean.
overlayBlob = new Blob(['overlay'], { type: 'image/png' });
}
const results: Measures = {
...base,
inventoryPng: pngBlob,
overlayPng: overlayBlob,
sourceBelowFloor: belowFloor,
const input: CalibrationInput = {
source: state.source,
approvedMask: state.approvedMask,
axis: state.axis,
lengthEighths: state.lengthEighths,
thicknessEighths: state.thicknessEighths,
};
const params = approvedMask.params;
const calIndex = calibrationIndex != null && calibrationIndex >= 0
? calibrationIndex
: 0;
const batch = batchPhotos.length ? batchPhotos : [source];
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
status: 'error',
message: 'Batch photo was not processed',
}));
const output = await runCalibration(input);
if (state.k !== 'measure') return;
set({ ...state, ...output });
void persist();
},
// Process serially so each worker owns exactly one sibling photo and a
// failing decode/mask/encode cannot prevent later siblings from running.
for (let index = 0; index < batch.length; index++) {
if (index === calIndex) continue;
const photo = batch[index];
try {
const derived = await rederiveMaskDetailed(photo, params, {
slabPx: 0,
bbox: EMPTY_BBOX,
slabBounds: EMPTY_ABOX,
});
if (derived.mask.slabPx === 0) {
throw new Error('empty mask: no slab pixels detected');
}
const siblingSource: Source = {
...photo,
widthPx: derived.widthPx,
heightPx: derived.heightPx,
};
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean };
try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
} finally {
siblingBitmap.close();
}
if (encoded.sourceBelowFloor) {
throw new Error('PNG size floor: shorter side is below 800 px');
}
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
} catch (error) {
const message = error instanceof Error ? error.message : 'unable to process photo';
batchApply[index] = { status: 'error', message };
}
async calculateFromAxis(lengthEighths, thicknessEighths) {
if (state.k !== 'axis') return;
const length = Math.max(0, Math.round(lengthEighths));
const thickness = Math.max(0, Math.round(thicknessEighths));
if (length === 0 || thickness === 0) return;
if (poseTimer) {
clearTimeout(poseTimer);
poseTimer = null;
}
set({ ...state, results, batchApply });
const input: CalibrationInput = {
source: state.source,
approvedMask: state.approvedMask,
axis: state.axis,
lengthEighths: length,
thicknessEighths: thickness,
};
// Keep the tape page mounted while the PNG, overlay, and batch apply run.
// The old measure form was a real screen; the result state is not entered
// until there is something complete to show.
set({ ...state, lengthEighths: length, thicknessEighths: thickness });
const output = await runCalibration(input);
if (state.k !== 'axis') return;
set({
k: 'measure',
source: input.source,
approvedMask: input.approvedMask,
axis: input.axis,
lengthEighths: input.lengthEighths,
thicknessEighths: input.thicknessEighths,
results: output.results,
batchApply: output.batchApply,
});
void persist();
},
@@ -626,14 +727,17 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') {
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
set({
k: 'axis',
source: state.source,
approvedMask: state.approvedMask,
axis: state.axis,
lengthEighths: state.lengthEighths,
thicknessEighths: state.thicknessEighths,
});
}
},
getInventoryUrl() {
return pngUrl;
},
currentMeasure() {
return state.k === 'measure' ? state : null;
},
@@ -642,6 +746,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
teardownWorker();
},
};
return session;
}
// Display helper: format eighths as inches with fractions.
+85
View File
@@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CALIBRATION_FRAME,
IDENTITY_TRANSFORM,
calibrationScale,
cropEnds,
hitRadiusStage,
lineX,
sourceTickDistance,
stageToPhoto,
} from './tapeLine.ts';
const moved = { tiltDeg: 17, zoom: 1.8, panX: 40, panY: -25 };
test('the line stays in the middle of the frame when the photo moves', () => {
assert.equal(lineX(), CALIBRATION_FRAME.width / 2);
assert.equal(lineX(), 240);
const a = stageToPhoto([lineX(), 32], 1000, 1500, IDENTITY_TRANSFORM);
const b = stageToPhoto([lineX(), 32], 1000, 1500, moved);
assert.equal(a[0] === b[0] && a[1] === b[1], false);
assert.equal(lineX(), CALIBRATION_FRAME.width / 2);
});
test('crop fill moves only the ends and never the 3:4 frame', () => {
const at90 = cropEnds(90);
assert.equal(at90.startY, 32);
assert.equal(at90.endY, 608);
const at50 = cropEnds(50);
const at100 = cropEnds(100);
assert.equal(CALIBRATION_FRAME.width / CALIBRATION_FRAME.height, 3 / 4);
assert.equal(at50.startY, 160);
assert.equal(at100.startY, 0);
assert.equal(at100.endY, 640);
assert.equal(lineX(), 240);
});
test('tick stage positions are inputs, not a function of the photo transform', () => {
const ends = cropEnds(90);
const still = sourceTickDistance(ends.startY, ends.endY, 800, 1200, IDENTITY_TRANSFORM);
const after = sourceTickDistance(ends.startY, ends.endY, 800, 1200, moved);
assert.ok(still > 0);
assert.notEqual(still, after);
assert.equal(ends.startY, 32);
assert.equal(ends.endY, 608);
});
test('scale is source-pixel tick distance divided by the declared length', () => {
const scale = calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 72);
const distance = sourceTickDistance(32, 608, 480, 640, IDENTITY_TRANSFORM);
assert.equal(scale, distance / 72);
assert.equal(calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 0), 0);
});
test('tick ends are fixed by the crop slider and are never an owner input (#99)', () => {
// The line is a stage fixture: its two ends are the crop insets, set by
// crop_fill alone. There is no owner drag that can move them.
for (const fill of [1, 25, 50, 75, 90, 100]) {
const ends = cropEnds(fill);
assert.equal(ends.startY, cropEnds(fill).startY, 'ends are a pure function of fill');
assert.equal(ends.endY, cropEnds(fill).endY, 'ends are a pure function of fill');
assert.ok(ends.endY > ends.startY, 'line keeps a positive span at any fill');
}
// Ends stay symmetric about the frame centre, so the line is fixed in the middle.
for (const fill of [1, 50, 90, 100]) {
const ends = cropEnds(fill);
const top = ends.startY;
const bottom = CALIBRATION_FRAME.height - ends.endY;
assert.equal(top, bottom, `fill ${fill}: top and bottom insets match`);
}
});
test('the scale span is the fixed line span, not a mask span', () => {
const ends = cropEnds(90);
const distance = sourceTickDistance(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM);
const scale = calibrationScale(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM, 72);
assert.ok(distance > 0, 'line has a real source-pixel span');
assert.equal(scale, distance / 72, 'scale is the line span over the tape reading');
});
test('the tick hit target is at least 44px on a 320px-wide stage', () => {
const radius = hitRadiusStage(320);
const screen = radius * (320 / CALIBRATION_FRAME.width);
assert.ok(screen >= 44, `screen hit ${screen}`);
});
+104
View File
@@ -0,0 +1,104 @@
// Fixed tape line for calibration (#99).
// The line is a stage fixture. The photo is the only transformed entity.
// Tick Y values are stage coordinates and are never derived from the photo.
export const CALIBRATION_FRAME = { width: 480, height: 640 } as const;
export interface PhotoTransform {
tiltDeg: number;
zoom: number;
panX: number;
panY: number;
}
export const IDENTITY_TRANSFORM: PhotoTransform = {
tiltDeg: 0,
zoom: 1,
panX: 0,
panY: 0,
};
export function lineX(): number {
return CALIBRATION_FRAME.width / 2;
}
/** (1 - fill) / 2 of the frame's long axis, from the top and the bottom. */
export function cropEnds(fillPercent: number): { startY: number; endY: number } {
const fill = Math.max(1, Math.min(100, Number.isFinite(fillPercent) ? fillPercent : 90));
const inset = (CALIBRATION_FRAME.height * (100 - fill)) / 200;
return { startY: inset, endY: CALIBRATION_FRAME.height - inset };
}
export function fitScale(sourceW: number, sourceH: number, tiltDeg: number): number {
const radians = (tiltDeg * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const rotatedW = cos * sourceW + sin * sourceH;
const rotatedH = sin * sourceW + cos * sourceH;
return Math.min(
CALIBRATION_FRAME.width / Math.max(rotatedW, 1),
CALIBRATION_FRAME.height / Math.max(rotatedH, 1),
);
}
/** Inverse of the photo transform. Stage points on the fixed line become source pixels. */
export function stageToPhoto(
point: [number, number],
sourceW: number,
sourceH: number,
transform: PhotoTransform,
): [number, number] {
const scale = fitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN];
const radians = (-transform.tiltDeg * Math.PI) / 180;
const dx = point[0] - lineX() - transform.panX;
const dy = point[1] - CALIBRATION_FRAME.height / 2 - transform.panY;
return [
sourceW / 2 + (dx * Math.cos(radians) - dy * Math.sin(radians)) / scale,
sourceH / 2 + (dx * Math.sin(radians) + dy * Math.cos(radians)) / scale,
];
}
export function sourceTickDistance(
startY: number,
endY: number,
sourceW: number,
sourceH: number,
transform: PhotoTransform,
): number {
const a = stageToPhoto([lineX(), startY], sourceW, sourceH, transform);
const b = stageToPhoto([lineX(), endY], sourceW, sourceH, transform);
return Math.hypot(b[0] - a[0], b[1] - a[1]);
}
export function calibrationScale(
startY: number,
endY: number,
sourceW: number,
sourceH: number,
transform: PhotoTransform,
lengthIn: number,
): number {
if (!(lengthIn > 0)) return 0;
const distance = sourceTickDistance(startY, endY, sourceW, sourceH, transform);
if (!(distance > 0) || !Number.isFinite(distance)) return 0;
return distance / lengthIn;
}
/** Stage-unit radius whose on-screen size is at least 44px at the given stage width. */
export function hitRadiusStage(renderedWidthPx: number): number {
return (44 * CALIBRATION_FRAME.width) / Math.max(renderedWidthPx, 1);
}
export function tapeSegmentSource(
startY: number,
endY: number,
sourceW: number,
sourceH: number,
transform: PhotoTransform,
): { start: [number, number]; end: [number, number] } {
return {
start: stageToPhoto([lineX(), startY], sourceW, sourceH, transform),
end: stageToPhoto([lineX(), endY], sourceW, sourceH, transform),
};
}
+17 -12
View File
@@ -9,7 +9,10 @@
// (METADATA-10: "parents group only and are never assigned"); only the
// leaf rows are selectable. Filtering hides a parent group when none of
// its leaves match.
// #90 closed state shows selected NAMES (not only a count). Done dispatches
// `commit` so the parent can persist + re-review.
import { createEventDispatcher, onDestroy } from 'svelte';
import { selectedSummary } from './woodPrimary';
export interface Opt { id: number; name: string; ai: boolean }
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
@@ -38,15 +41,7 @@
// Effective selectable set (leaves when grouped, options when flat).
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
// #90: the closed state must show WHAT is selected (names), not just a count.
// Truncate long lists so the button stays one line.
$: selectedNames = allOpts.filter((o) => selected.includes(o.id)).map((o) => o.name);
$: summary =
selectedNames.length === 0
? ''
: selectedNames.length <= 2
? selectedNames.join(', ')
: `${selectedNames.slice(0, 2).join(', ')} +${selectedNames.length - 2}`;
$: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
$: q = filter.trim().toLowerCase();
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
// Grouped: keep a parent only if at least one of its leaves survives the filter.
@@ -56,6 +51,7 @@
.filter((g) => g.leaves.length > 0)
: [];
$: count = selected.length;
$: summary = selectedSummary(selected, nameById, placeholder);
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
function scheduleCommit() {
@@ -86,9 +82,13 @@
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
function destroy() { if (commitTimer) clearTimeout(commitTimer); }
onDestroy(destroy);
// Rows are focusable options (role=option): Enter/Space toggles, matching click.
function onRowKey(e: KeyboardEvent, id: number) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
}
</script>
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
<div class="ms" class:open role="group" aria-label={label} data-testid={testid} on:keydown={onKey}>
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>
{open
@@ -97,7 +97,7 @@
? summary
: placeholder}
</span>
<span class="ms-c">{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</span>
<span class="ms-c" data-testid={testid ? `${testid}-count` : 'ms-count'}>{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</span>
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
</button>
@@ -121,9 +121,11 @@
class="ms-row leaf"
class:on={selected.includes(o.id)}
role="option"
tabindex="0"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
on:keydown={(e) => onRowKey(e, o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
@@ -140,9 +142,11 @@
class="ms-row"
class:on={selected.includes(o.id)}
role="option"
tabindex="0"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
on:keydown={(e) => onRowKey(e, o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
@@ -163,7 +167,7 @@
<style>
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
.ms-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
.ms-btn { box-sizing: border-box; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
.ms-btn .ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eee; }
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
@@ -185,6 +189,7 @@
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
.ms-row:focus-visible { outline: 2px solid #00e5ff; outline-offset: -2px; }
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
+510 -88
View File
@@ -2,12 +2,17 @@
import { onMount, onDestroy, tick } from 'svelte';
import {
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
listPhotos, persistLineup, photoUrl, ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
} from '../../api/slabs';
import type { CalibratedCapture } from '../flow';
import MultiSelect from './MultiSelect.svelte';
import { deriveThicknessTermId } from './thicknessBand';
import {
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
type LineupItem,
} from './lineup';
import { nextPrimary } from './woodPrimary';
// Props handed off by App (one-column flow, UX.md §1).
export let slabId: string;
@@ -35,7 +40,6 @@
let edgeOpts: Opt[] = [];
let figureOpts: Opt[] = [];
let gradeOpts: Opt[] = [];
let thicknessOpts: Opt[] = [];
let moistureOpts: Opt[] = [];
let figTagOpts: Opt[] = [];
let featTagOpts: Opt[] = [];
@@ -78,7 +82,8 @@
edgeOpts = termsOf('edge type');
figureOpts = termsOf('figure');
gradeOpts = termsOf('grade');
thicknessOpts = termsOf('thickness');
// No thickness options: the band is derived server-side and never shown here
// (METADATA-15 / QUALITY-95).
moistureOpts = termsOf('moisture');
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
// filtered by the family the backend keeps. Never invent a prefix — if the
@@ -99,23 +104,6 @@
let gradeSel: string = '';
let woodCatIds: number[] = [];
let figureIds: number[] = [];
// #88: thickness band is NOT an owner control — it is derived deterministically
// from thickness_in (TV-9 / QUALITY-95) against the synced Woo thickness terms.
// The Review screen shows it read-only; the owner changes thickness upstream, and
// the band follows.
$: thicknessId = deriveThicknessTermId(
slab?.thickness_in ?? null,
thicknessOpts.map((o) => ({ id: o.id, name: o.name })),
);
$: thicknessName = thicknessOpts.find((o) => o.id === thicknessId)?.name ?? '';
// #88 display text: the derived band name, or an explicit "no band" note when a
// thickness is set but nothing in the synced set encloses it.
$: thicknessDisplay =
thicknessId !== ''
? thicknessName
: (slab?.thickness_in ?? 0) > 0
? `— no band for ${fmt1(slab!.thickness_in!)}″`
: '—';
let moistureId: number | '' = '';
let figTagIds: number[] = [];
let featTagIds: number[] = [];
@@ -137,6 +125,16 @@
let review: ReviewOut | null = null;
let reviewBusy = false;
let reviewError = '';
// #89: explicit "Create" actions. `createBusy` covers the deterministic
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
// category). `canCreateDesc` gates the AI long-description on title+short-desc
// being populated AND the content LLM being enabled.
let createBusy = false;
let createError = '';
$: canCreateTitle =
speciesSel !== '' && woodCatIds.length > 0;
$: canCreateDesc =
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
// Monotonic token: each commit captures the current value and ignores its
// result if a newer commit has since started. Without this, two concurrent
// commits (e.g. a select change racing the SKU blur) resolve out of order and
@@ -156,14 +154,177 @@
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
const fmt2 = (v: number) => v.toFixed(2);
// --- #90 Photo strip (REVIEW-5 / PUBLISH-45) ---
// The owner edits which photos stay/go and their order. Leftmost kept photo =
// primary (Woo cover). Kept in-memory; persisted via persistLineup() on
// "Continue to Publish". Sources are the STORED photos (listPhotos) so the
// strip survives re-entry into Review; in-memory lineup blobs (from capture)
// are preferred when present.
interface StripPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
kept: boolean;
/** True for the mandatory photos (clean calibration + dimension-overlay
copy). Their keep checkbox is checked and disabled — the owner cannot
remove them (REVIEW-5 requires both). */
required: boolean;
original: Blob;
processed: Blob | null;
url: string;
}
let photos: StripPhoto[] = [];
let photosLoaded = false;
let lineupDirty = false;
let persistBusy = false;
let persistError = '';
// In-memory blobs from the capture lineup, keyed by lineup order. The stored
// photo id matches the capture's upload order only on the first entry; after
// an edit the STORED row (listPhotos) is authoritative and we re-fetch blobs.
const inMemByRole = (): Map<'calibration' | 'overlay' | 'batch', Blob[]> => {
const m = new Map<'calibration' | 'overlay' | 'batch', Blob[]>();
for (const it of capture?.lineup ?? []) {
if (it.kind !== 'png') continue;
const arr = m.get(it.role) ?? [];
arr.push(it.blob);
m.set(it.role, arr);
}
return m;
};
// Object URLs minted from in-memory blobs; revoked on unmount/reload so the
// <img> src stays valid. A raw Blob assigned to src renders broken — it must
// be a blob: URL (bug: the first/Primary photo was the one carrying a blob).
let photoUrls: string[] = [];
async function loadPhotos() {
// Revoke any previous object URLs before rebuilding the strip.
for (const u of photoUrls) URL.revokeObjectURL(u);
photoUrls = [];
try {
const rows: PhotoRow[] = await listPhotos(slabId);
const byRole = inMemByRole();
const seqByRole: Record<string, number> = { calibration: 0, overlay: 0, batch: 0 };
photos = rows.map((r) => {
const role = r.role ?? 'batch';
const n = seqByRole[role]++;
const blob = (byRole.get(role)?.[n]) ?? null;
// Prefer the in-memory blob (fresh capture), as an object URL. Otherwise
// fall back to the stored photo endpoint (a plain string URL).
const endpoint = photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed');
const url = resolvePhotoUrl(blob, endpoint);
if (blob && blob.size > 0) photoUrls.push(url);
return {
id: r.id,
role,
isCalibration: r.is_calibration === 1,
// Mandatory: the clean calibration photo and the dimension-overlay
// copy. REVIEW-5 requires both to ship, so their keep control is
// checked + disabled (the owner can't remove them).
required: role === 'calibration' || role === 'overlay',
kept: true,
original: blob ?? new Blob(),
processed: blob ?? null,
url,
};
});
photosLoaded = true;
lineupDirty = false;
} catch {
photos = [];
photosLoaded = false;
}
}
onDestroy(() => {
for (const u of photoUrls) URL.revokeObjectURL(u);
photoUrls = [];
});
// Whole-card tap = keep/remove toggle. A real drag (>6px) reorders and does NOT
// toggle keep/remove — the threshold lives in the pointer handlers below.
function toggleKeep(id: string) {
photos = photos.map((p) => {
if (p.id !== id) return p;
// Mandatory photos (clean cal + dimension-overlay) can't be removed.
if (p.required) return p;
return { ...p, kept: !p.kept };
});
lineupDirty = true;
}
function reorder(from: number, to: number) {
photos = moveItem(photos, from, to);
lineupDirty = true;
}
let dragFrom = -1;
function onDragStart(e: DragEvent, index: number) {
dragFrom = index;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}
}
function onDragOver(e: DragEvent) {
if (e.preventDefault) e.preventDefault();
}
function onDrop(e: DragEvent, index: number) {
if (e.preventDefault) e.preventDefault();
if (dragFrom >= 0 && dragFrom !== index) reorder(dragFrom, index);
dragFrom = -1;
}
// REVIEW-5: the kept set must contain a clean photo and its overlay copy. Only
// enforced once photos have loaded (photosLoaded); a failed/absent photo list
// (e.g. legacy mock) leaves the lineup minimum neutral rather than blocking.
let lineupMissing: ('calibration' | 'overlay')[] = [];
$: lineupMissing = photosLoaded
? missingMinimums(keptInOrder(photos as LineupItem[]) as LineupItem[])
: [];
$: lineupValid = lineupMissing.length === 0;
// Leftmost kept photo id = primary (Woo cover). '' when nothing is kept.
let primaryId = '';
$: primaryId = photosLoaded
? ((keptInOrder(photos as LineupItem[]) as StripPhoto[])[0]?.id ?? '')
: '';
// Persist the edited lineup (re-seq kept + drop removed), then hand to Publish.
async function goPublish() {
if (!lineupValid) return;
persistBusy = true;
persistError = '';
try {
const kept = keptInOrder(photos as LineupItem[]) as StripPhoto[];
const removed = photos.filter((p) => !p.kept).map((p) => p.id);
await persistLineup(
slabId,
kept.map((p) => ({
id: p.id,
role: p.role,
isCalibration: p.isCalibration,
original: p.original,
processed: p.processed,
})),
removed,
);
onPublish();
} catch (e) {
persistError = e instanceof ApiError ? e.message : 'Could not save the photos. Try again.';
} finally {
persistBusy = false;
}
}
function hydrate(s: Slab, t: Taxonomy | null) {
speciesSel = s.species_id != null ? String(s.species_id) : '';
woodCatIds = [...(s.wood_category_ids ?? [])];
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
figureIds = [...(s.figure_term_ids ?? [])];
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
// thicknessId is derived reactively from slab.thickness_in (#88) — not hydrated
// from the stored term, so a stale stored band can't shadow the derived value.
moistureId = s.moisture_term_id ?? '';
figTagIds = [...(s.fig_tag_ids ?? [])];
featTagIds = [...(s.feat_tag_ids ?? [])];
@@ -174,7 +335,8 @@
price = s.price != null ? String(s.price) : '';
priceSource = s.price_source ?? 'recommendation';
sku = s.sku ?? '';
primaryCatId = woodCatIds[0] ?? '';
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
// its weight behind the value the owner kept).
aiRecommendations = [];
@@ -184,28 +346,63 @@
aiRecommendations.push(...(s.figure_term_ids ?? []));
}
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// AI-marked option views (badge on rows after Call 1 / stored confidences).
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: woodCatGroupsView = woodCatGroups.map((g) => ({
parent: g.parent,
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
}));
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
// Flat leaf name lookup for the primary radio set (REVIEW-15).
$: woodLeafById = new Map(
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
);
$: primaryOpts = woodCatIds
.map((id) => woodLeafById.get(id))
.filter((o): o is Opt => !!o)
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
// measured thickness against the live synced Woo bands. It is deliberately absent
// from this screen — no owner control, no read-only echo. The only thickness the
// owner sees here is the measured value in the summary above.
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
// #89: the deterministic title/short_description are written here — the server's
// review returns the build from the current metadata, and if the field is empty
// we fold the deterministic value into THIS same PUT (no extra request, no rev
// race). Owner-edited text is never clobbered (only an empty field is filled).
async function commitReview(): Promise<void> {
if (!slab) return;
const myToken = ++commitToken;
reviewBusy = true;
reviewError = '';
try {
// Run the deterministic review FIRST (reads the current metadata) to get the
// title/short_description build before we persist.
const probe = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
// so this PUT persists it (the Create buttons do the same, on demand).
if (probe.title && title === '') title = probe.title;
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
const patch = {
species_id: speciesSel === '' ? null : Number(speciesSel),
wood_category_ids: woodCatIds,
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
figure_term_ids: figureIds,
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
thickness_term_id: thicknessId === '' ? null : Number(thicknessId),
moisture_term_id: moistureId === '' ? null : Number(moistureId),
fig_tag_ids: figTagIds,
feat_tag_ids: featTagIds,
title: title || null,
short_description: shortDescription || null,
description: description || null,
content_source: contentSource || null,
price: price === '' ? null : Number(price),
price_source: priceSource,
@@ -215,21 +412,79 @@
const fresh = await updateSlab(slabId, patch);
if (myToken !== commitToken) return; // superseded; don't clobber the latest
slab = fresh;
if (primaryCatId !== '') {
const res = await reviewSlab(slabId, {
primary_wood_category_id: Number(primaryCatId),
title: title || undefined,
short_description: shortDescription || undefined,
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
// Re-run the review against the just-persisted row (authoritative ready/missing).
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
} catch (e) {
if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
review = null;
} finally {
if (myToken === commitToken) reviewBusy = false;
}
}
// #89: explicit "Create title" / "Create short desc" — fire the deterministic
// build from the current metadata and write the result into the fields,
// overwriting whatever was there (empty, owner-typed, or previously generated).
// That is the whole contract: the button fires the build. commitReview()
// auto-fills only empty fields; this is the explicit "force overwrite" action.
async function createTitle(): Promise<void> {
if (!slab || createBusy) return;
createBusy = true;
createError = '';
try {
// 1. Run the deterministic review to get title / short_description.
const res = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
// 2. Write the deterministic values into the bound fields — always overwrite.
if (res.title) title = res.title;
if (res.short_description) shortDescription = res.short_description;
// 3. Persist the generated prose (single PUT, fresh client_rev).
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
const prosePatch = {
title: title || null,
short_description: shortDescription || null,
client_rev: (slab as Slab).server_rev,
};
const fresh = await updateSlab(slabId, prosePatch);
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
if (myToken !== commitToken) return;
review = res;
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
} else {
review = await reviewSlab(slabId);
if (myToken !== commitToken) return;
}
} catch (e) {
createError = e instanceof ApiError ? e.message : 'Could not build the title. Try again.';
} finally {
createBusy = false;
}
}
// #89: the description textarea commits on blur. commitReview() no longer sends
// description (see #89), so this persists the owner-typed description explicitly.
async function commitDescription(): Promise<void> {
if (!slab) return;
const myToken = ++commitToken;
reviewBusy = true;
reviewError = '';
try {
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
} catch (e) {
if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
@@ -250,7 +505,7 @@
if (s.species_id) { speciesSel = String(s.species_id); recs.push(s.species_id); }
if (s.wood_category_ids) {
woodCatIds = [...s.wood_category_ids];
if (!primaryCatId) primaryCatId = woodCatIds[0];
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
recs.push(...s.wood_category_ids);
}
if (s.edge_type_term_id) edgeSel = String(s.edge_type_term_id);
@@ -274,14 +529,22 @@
}
async function generateContent() {
if (!slab) return;
if (!slab || !canCreateDesc) return;
genBusy = true;
genError = '';
try {
const r = await inferContent(slabId);
description = r.description;
contentSource = 'llm';
await commitReview();
// commitReview() no longer sends description (see #89), so persist it explicitly.
const myToken = ++commitToken;
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
} catch (e) {
genError = e instanceof ApiError ? e.message : 'Could not generate text.';
} finally {
@@ -319,6 +582,17 @@
let speciesEl: HTMLSelectElement | undefined;
let edgeEl: HTMLSelectElement | undefined;
let gradeEl: HTMLSelectElement | undefined;
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
function toggleWoodCat(id: number) {
woodCatIds = toggleMulti(woodCatIds, id);
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
}
function pickPrimary(id: number) {
if (!woodCatIds.includes(id)) return;
primaryCatId = id;
void commitReview();
}
onMount(async () => {
try {
@@ -329,6 +603,7 @@
canGenerateContent = !!set.content_llm_enabled;
// #90: sync the <select> DOM to the hydrated values (same race as AI prefill).
syncSelects();
await loadPhotos();
await commitReview();
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
@@ -337,17 +612,9 @@
}
});
function goPublish() {
onPublish();
}
function goBack() {
onBack();
}
function publish() {
goPublish();
}
</script>
{#if loading}
@@ -371,6 +638,55 @@
<div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
</dl>
<!-- #90 Photo strip: keep/remove + drag-reorder; leftmost kept = primary. -->
{#if photosLoaded && photos.length}
<div class="photos" data-testid="photo-strip">
<p class="ph-hint">Tap a photo to keep or remove it · drag to reorder · leftmost photo = primary</p>
<div class="ph-grid">
{#each photos as p, i (p.id)}
<div
class="pcard"
class:kept={p.kept}
class:primary={p.id === primaryId}
class:required={p.required}
data-testid="photo-card"
data-primary={p.id === primaryId ? 'true' : ''}
data-required={p.required ? 'true' : ''}
role="button"
tabindex="0"
aria-pressed={p.required ? true : p.kept}
aria-disabled={p.required ? 'true' : 'false'}
aria-label="{p.required ? 'Required: ' : ''}{roleLabel(p.role)}{p.required ? ' (cannot be removed)' : (p.kept ? ', kept' : ', removed')}"
draggable="true"
on:click={() => toggleKeep(p.id)}
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleKeep(p.id); } }}
on:dragstart={(e) => onDragStart(e, i)}
on:dragover={onDragOver}
on:drop={(e) => onDrop(e, i)}
>
<img class="ph-thumb" src={p.url} alt={roleLabel(p.role)} draggable="false" />
{#if p.id === primaryId}
<span class="ph-pri">PRIMARY</span>
{/if}
{#if p.required}
<span class="ph-req">REQUIRED</span>
{/if}
<span class="ph-seq" aria-hidden="true">{i + 1}</span>
<!-- Keep checkbox: always checked for required photos; a disabled
glyph (no check) when a batch photo is removed. -->
<span class="ph-keep" class:required={p.required} aria-hidden="true"></span>
</div>
{/each}
</div>
{#if lineupMissing.length}
<p class="ph-nudge" data-testid="lineup-nudge" role="alert">
Keep at least {lineupMissing.map(roleLabel).join(' and ')} — required to publish.
</p>
{/if}
{#if persistError}<p class="nudge" role="alert">{persistError}</p>{/if}
</div>
{/if}
{#if settings?.inference_enabled}
<div class="aibar">
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
@@ -391,16 +707,43 @@
</select>
</div>
<!-- Wood categories (multi, searchable list, AI-assist) -->
<MultiSelect
label="Wood categories"
groups={woodCatGroups}
selected={woodCatIds}
placeholder="Choose wood categories"
testid="wood-cat"
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))}
onCommit={commitReview}
/>
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
<div class="field" data-testid="wood-cat-field">
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
<MultiSelect
label="Wood categories"
groups={woodCatGroupsView}
selected={woodCatIds}
placeholder="Choose wood categories"
testid="wood-cat"
onToggle={toggleWoodCat}
on:commit={commitReview}
/>
{#if primaryOpts.length}
<div class="primary-set" data-testid="primary-wood-set" role="radiogroup" aria-label="Primary wood category">
<div class="ps-lbl">Primary wood category <span class="ps-req">drives title</span></div>
{#each primaryOpts as o (o.id)}
<button
type="button"
class="ps-opt"
class:on={primaryCatId === o.id}
role="radio"
aria-checked={primaryCatId === o.id}
data-testid={`primary-wood-${o.id}`}
on:click={() => pickPrimary(o.id)}
>
<span class="ps-radio" aria-hidden="true"></span>
<span class="ps-name">{o.name}</span>
{#if o.ai}<span class="ps-tag">AI</span>{/if}
</button>
{/each}
<p class="ps-note">
Primary selects the title &amp; short-description pattern (METADATA-85).
Secondary categories feed the description. Default = first selected.
</p>
</div>
{/if}
</div>
<!-- Edge type (single, AI-assist) -->
<div class="field">
@@ -418,12 +761,12 @@
<label class="lbl">Figure <span class="ai">AI assist</span></label>
<MultiSelect
label="Figure"
options={figureOpts}
options={figureOptsView}
selected={figureIds}
placeholder="Choose figure"
testid="figure"
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
onCommit={commitReview}
on:commit={commitReview}
/>
</div>
@@ -446,7 +789,7 @@
placeholder="Choose fig-* tags"
testid="fig-tag"
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
onCommit={commitReview}
on:commit={commitReview}
/>
</div>
@@ -455,21 +798,18 @@
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
<MultiSelect
label="feat-* tags"
options={featTagOpts}
options={featTagOptsView}
selected={featTagIds}
placeholder="Choose feat-* tags"
testid="feat-tag"
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
onCommit={commitReview}
on:commit={commitReview}
/>
</div>
<!-- #88: thickness band is deterministic from thickness_in — display-only, no owner pick. -->
<div class="field">
<label class="lbl" for="thickness">Thickness band <span class="sug">from thickness</span></label>
<div id="thickness" class="ro" data-testid="thickness-band">{thicknessDisplay}</div>
</div>
<div class="field">
<label class="lbl" for="moisture">Moisture</label>
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
<option value="">— select —</option>
@@ -477,26 +817,39 @@
</select>
</div>
<!-- Title / Description -->
<!-- Title / Short description + explicit "Create" build buttons (#89) -->
<div class="field">
<label class="lbl" for="title">Title</label>
<input id="title" class="inp" data-testid="title" bind:value={title} />
<div class="create-row">
<input id="title" class="inp" data-testid="title" bind:value={title} />
<button type="button" class="create-btn" data-testid="create-title"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create title'}
</button>
</div>
</div>
<div class="field">
<label class="lbl" for="short-desc">Short description</label>
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
<div class="create-row">
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
<button type="button" class="create-btn" data-testid="create-short-desc"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create short desc'}
</button>
</div>
</div>
<div class="field">
<label class="lbl" for="desc">Description</label>
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitReview}></textarea>
{#if canGenerateContent}
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
{#if canCreateDesc}
<div class="gen">
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}>
{genBusy ? 'Writing…' : 'Generate text'}
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
{genBusy ? 'Writing…' : 'Create desc'}
</button>
</div>
{/if}
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
</div>
<!-- Price / SKU (SKU last) -->
@@ -518,13 +871,16 @@
{#if reviewBusy}
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
{:else if review}
<div class="status" class:ok={ready()} data-testid="ready-status">
{ready()
<div class="status" class:ok={ready() && lineupValid} data-testid="ready-status">
{ready() && lineupValid
? 'Ready to publish'
: `Not ready — ${review.missing.length} field${review.missing.length === 1 ? '' : 's'} remaining`}
: `Not ready — ${[
...review.missing,
...lineupMissing.map(roleLabel),
].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`}
</div>
{#if !ready()}
<p class="missing" data-testid="missing-list">Missing: {review.missing.join(', ')}</p>
{#if !(ready() && lineupValid)}
<p class="missing" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p>
{/if}
{/if}
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
@@ -535,10 +891,10 @@
type="button"
class="primary"
data-testid="continue-publish"
disabled={publishDisabled}
on:click={publish}
disabled={publishDisabled || !lineupValid || persistBusy}
on:click={goPublish}
>
Continue to Publish
{persistBusy ? 'Saving photos…' : 'Continue to Publish'}
</button>
</div>
</section>
@@ -555,15 +911,40 @@
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
.inp, .sel { width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
/* #88 read-only thickness band: same chrome as .sel but non-interactive. */
.ro { width: 100%; padding: 9px; background: #141414; border: 1px solid #333; border-radius: 3px; color: #9ad0d8; font-size: .9rem; text-align: left; }
/* box-sizing: the app has no global reset, so a width:100% control with padding
and a border pokes out of its container by that padding+border (20px here) —
which becomes real page overflow whenever the column sits at the viewport edge
(measured: 375px mobile scrolled 20px). Border-box keeps the box inside. */
.inp, .sel { box-sizing: border-box; width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
.ta { resize: vertical; }
.field { margin-bottom: 12px; }
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
/* #89: explicit "Create" build buttons beside the title / short-desc fields. */
.create-row { display: flex; gap: 8px; align-items: stretch; }
.create-row .inp { flex: 1; }
.create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
background: #1d1d1d; color: #eee; font-size: .82rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
.create-btn:hover { background: #242424; }
.create-btn:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
.primary-set .ps-lbl { font-size: .74rem; color: #9a9a9a; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
.primary-set .ps-req { font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #2a2a2a; color: #888; }
.ps-opt { box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 9px; padding: 8px 6px; margin: 0; border: 0; border-radius: 3px; background: transparent; color: #ddd; font-size: .88rem; cursor: pointer; text-align: left; }
.ps-opt:hover { background: #1c1c1c; }
.ps-opt.on { background: #12262b; color: #fff; }
.ps-opt:focus-visible { outline: 2px solid #00e5ff; outline-offset: 1px; }
.ps-radio { width: 14px; height: 14px; border: 2px solid #555; border-radius: 50%; flex: none; position: relative; }
.ps-opt.on .ps-radio { border-color: #00e5ff; }
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
.ps-name { flex: 1; }
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
.status.ok { background: #122a12; color: #7fd37f; }
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
@@ -572,4 +953,45 @@
.primary:disabled { background: #333; color: #777; }
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
.err { color: #e88; } .busy { color: #aaa; }
/* #90 Photo strip */
.photos { margin: 14px 0 4px; }
.ph-hint { color: #8a8a8a; font-size: .72rem; margin: 0 0 8px; }
.ph-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
@media (max-width: 420px) { .ph-grid { grid-template-columns: repeat(2, 1fr); } }
.pcard { position: relative; aspect-ratio: 1; border: 2px solid #2a2a2a; border-radius: 3px; overflow: hidden; cursor: grab;
/* Gray checkerboard, matching the Capture lineup (MeasurePanel .lineup-item.png)
so transparent inventory PNGs read the same on both screens (bug2). */
background-color: #eee;
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; }
.pcard:active { cursor: grabbing; }
.pcard:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
.pcard.kept { border-color: #444; }
.pcard.primary { border-color: #00e5ff; }
.pcard:not(.kept) { opacity: .4; }
.ph-thumb { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.ph-pri { position: absolute; top: 6px; right: 6px; background: #00e5ff; color: #002; font-size: .56rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .03em; pointer-events: none; }
.ph-req { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); background: #ffb020; color: #201500; font-size: .52rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .04em; pointer-events: none; }
.ph-seq { position: absolute; bottom: 6px; left: 6px; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,.7); color: #fff; font-size: .66rem; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.ph-keep { position: absolute; top: 6px; left: 6px; width: 16px; height: 16px; border: 2px solid #fff; border-radius: 3px; background: rgba(0,0,0,.5); pointer-events: none; }
.pcard.kept .ph-keep { background: #00e5ff; border-color: #00e5ff; }
.pcard.kept .ph-keep::after { content: ''; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #002; border-width: 0 2px 2px 0; transform: rotate(45deg); }
/* Mandatory photos (clean cal + dimension-overlay): the keep checkbox is
checked AND disabled. A bold padlock glyph in the box signals "required —
cannot be removed", and the card carries a thin required outline so the
owner can tell mandatory photos apart from optional batch photos. */
.pcard.required { border-color: #00e5ff55; }
.pcard.required .ph-keep { background: #00e5ff; border-color: #00e5ff; }
/* Padlock: shackle (arc) + body, drawn large enough to read on a 16px box. */
.pcard.required .ph-keep::after {
content: ''; position: absolute; left: 3px; top: 2px; width: 8px; height: 8px;
border: 2px solid #002; border-bottom: none; border-radius: 4px 4px 0 0;
}
.pcard.required .ph-keep::before {
content: ''; position: absolute; left: 2.5px; top: 6px; width: 8px; height: 6px;
background: #002; border-radius: 1px;
}
.pcard.required { cursor: default; }
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; }
</style>
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
keptInOrder,
moveItem,
missingMinimums,
lineupValid,
roleLabel,
resolvePhotoUrl,
type LineupItem,
} from './lineup';
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
id,
role,
isCalibration: role === 'calibration',
kept,
});
test('lineup: keptInOrder returns kept photos in their current order', () => {
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
});
test('lineup: leftmost kept is the primary', () => {
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
// After a drag the owner re-ordered; primary follows position, not role.
assert.equal(keptInOrder(items)[0].id, 'b1');
});
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
assert.equal(keptInOrder(items)[0].id, 'ov');
});
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
const a = ['x', 'y', 'z'];
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
// Out-of-range is a no-op, never throws.
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
// Returns a new array, does not mutate the input.
const before = a.slice();
moveItem(a, 0, 2);
assert.deepEqual(a, before);
});
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
});
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
assert.equal(lineupValid([p('b1', 'batch')]), false);
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
});
test('lineup: missingMinimums only counts KEPT photos', () => {
// Both roles present in the array, but the cal is not kept.
assert.deepEqual(
missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]),
['calibration'],
);
});
test('lineup: roleLabel is owner-facing', () => {
assert.match(roleLabel('calibration'), /clean/);
assert.match(roleLabel('overlay'), /overlay/);
});
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => {
// The first/Primary (calibration) photo carries a fresh capture blob. It must
// become a valid object URL — never a raw Blob, never String(blob)="object Blob".
const blob = new Blob(['png-bytes'], { type: 'image/png' });
const fake = (b: Blob) => `blob:fake-${b.size}`;
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
// Empty/absent blob falls back to the stored endpoint.
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
});
+82
View File
@@ -0,0 +1,82 @@
// #90 Review photo strip (REVIEW-5 / PUBLISH-45). Pure logic split out so the
// keep/remove + reorder + minimum enforcement is unit-testable without a browser.
//
// Model (approved in the wireframe): the owner taps a photo to keep/remove it,
// drags to reorder, and the LEFTMOST kept photo is the primary (Woo cover). The
// submission-ready minimum is at least one clean (calibration) photo AND one
// dimension-overlay copy (REVIEW-5 "clean first pic plus its dimension-overlay
// copy"). Batch photos are optional.
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
export interface LineupItem {
id: string;
role: PhotoRole;
isCalibration: boolean;
/** True = the owner keeps this photo in the submission. */
kept: boolean;
}
/**
* The kept photos in their current (left→right) order. The first kept photo is
* the primary (Woo cover). Reordering is a plain array move; this helper just
* exposes the kept subset so the caller can re-seq on persist.
*/
export function keptInOrder(items: LineupItem[]): LineupItem[] {
return items.filter((it) => it.kept);
}
/** Move an item from one index to another (drag-reorder). Returns a new array. */
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
return arr;
}
const next = arr.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/**
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
* clean calibration photo and one dimension-overlay copy. Returns the missing
* role(s) so the UI can name them in a nudge.
*/
export function missingMinimums(items: LineupItem[]): PhotoRole[] {
const kept = items.filter((it) => it.kept);
const missing: PhotoRole[] = [];
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
return missing;
}
/** True when the kept lineup is publishable from a photo standpoint. */
export function lineupValid(kept: LineupItem[]): boolean {
return missingMinimums(kept).length === 0;
}
/** A short owner-facing label for a role (used in the minimum nudge). */
export function roleLabel(role: PhotoRole): string {
if (role === 'calibration') return 'a clean photo';
if (role === 'overlay') return 'its dimension-overlay copy';
return 'a batch photo';
}
/**
* Resolve the <img> src for a strip photo. The in-memory capture blob (fresh
* photo) must become a `blob:` object URL — assigning a raw Blob (or
* `String(blob)` = "object Blob") to `src` renders a broken image. That was
* bug #1: the first/Primary (calibration) photo is the one carrying a blob.
* When there is no usable blob, fall back to the stored photo endpoint.
*
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
* is unit-testable without a real DOM/Blob.
*/
export function resolvePhotoUrl(
blob: Blob | null,
endpoint: string,
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
): string {
if (blob && blob.size > 0) return createObjectUrl(blob);
return endpoint;
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { nextPrimary, selectedSummary } from './woodPrimary';
test('nextPrimary: empty selection clears primary', () => {
assert.equal(nextPrimary([], 21), '');
assert.equal(nextPrimary([], ''), '');
});
test('nextPrimary: default is first selected (REVIEW-15)', () => {
assert.equal(nextPrimary([21, 22], ''), 21);
assert.equal(nextPrimary([21, 22], null), 21);
});
test('nextPrimary: keeps current when still selected', () => {
assert.equal(nextPrimary([21, 22], 22), 22);
assert.equal(nextPrimary([22], 22), 22);
});
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
assert.equal(nextPrimary([22, 23], 21), 22);
assert.equal(nextPrimary([23], 21), 23);
});
test('selectedSummary: placeholder when empty', () => {
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
});
test('selectedSummary: shows names, then +N overflow', () => {
const m = new Map([
[1, 'Live Edge'],
[2, 'Book Match'],
[3, 'Cookie'],
]);
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
});
test('selectedSummary: falls back to count when names unknown', () => {
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
}
);
+32
View File
@@ -0,0 +1,32 @@
/**
* REVIEW-15: owner declares one primary wood category among the selected set.
* Default primary = first selected. Deselecting the current primary promotes
* the first remaining selected id. Empty selection clears primary.
*/
export function nextPrimary(
selected: number[],
current: number | '' | null | undefined,
): number | '' {
if (!selected.length) return '';
const cur = typeof current === 'number' ? current : '';
if (cur !== '' && selected.includes(cur)) return cur;
return selected[0];
}
/** Closed MultiSelect summary: names when known, else a count. */
export function selectedSummary(
selected: number[],
nameById: Map<number, string>,
placeholder: string,
maxNames = 2,
): string {
if (!selected.length) return placeholder;
const names = selected
.map((id) => nameById.get(id))
.filter((n): n is string => !!n && n.length > 0);
if (!names.length) {
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
}
if (names.length <= maxNames) return names.join(', ');
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
}
+16 -1
View File
@@ -17,8 +17,11 @@
brand_voice: string | null;
geo_context: string | null;
woo_create_status: string | null;
crop_fill: number;
}
export let onCropFillChange: (pct: number) => void = () => {};
interface PriceRule {
id: number;
species_id: number;
@@ -205,6 +208,7 @@
brand_voice: settings.brand_voice || '',
geo_context: settings.geo_context || '',
woo_create_status: settings.woo_create_status || 'draft',
crop_fill: settings.crop_fill ?? 90,
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
woo_app_password: secretValue(settings.woo_app_password),
inference_base_url: secretValue(settings.inference_base_url),
@@ -230,6 +234,7 @@
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
if (Number(form.crop_fill) !== (settings?.crop_fill ?? 90)) payload.crop_fill = Number(form.crop_fill);
// secrets only if provided (write-only)
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
@@ -243,6 +248,7 @@
});
if (res.ok) {
settings = await res.json();
onCropFillChange(settings.crop_fill ?? 90);
message = 'Saved';
// refresh secret buffers from the (possibly unmasked) server state
form.woo_app_password = secretValue(settings.woo_app_password);
@@ -351,6 +357,12 @@
</select>
</div>
<div>
<label for="crop-fill">Crop Fill % (default 90) — inventory PNG + reference line</label>
<input id="crop-fill" type="range" min="1" max="100" step="1" bind:value={form.crop_fill} />
<span>{form.crop_fill}%</span>
</div>
<button on:click={save} disabled={saving}>Save Changes</button>
{#if message}<p class="msg">{message}</p>{/if}
@@ -415,7 +427,10 @@
<style>
.settings { max-width: 600px; color: #eee; }
label { display: block; margin: 8px 0 4px; }
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; }
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; box-sizing: border-box; }
/* width:100% + the UA default input margins overflowed narrow viewports
(mobile Settings scrolled 16px sideways). Zero the horizontal margins. */
.settings input, .settings select { margin-left: 0; margin-right: 0; }
option { color: #eee; background: #1c1c1c; }
button { margin-top: 16px; }
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
+7
View File
@@ -51,6 +51,13 @@ test('short slab L=12in: no interior stations, widths are empty/zero', () => {
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
});
test('scale follows the tape ticks when they disagree with the mask span', () => {
const { bitset, box, slabPx } = uniform(480, 100);
const tape = { start: [0, 50] as [number, number], end: [240, 50] as [number, number] };
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, tape);
assert.equal(m.scale, 240 / 48);
});
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
const { bitset, box, slabPx } = uniform(100, 100);
const m = measure(bitset, 100, 100, box, 80, 8, slabPx);
+11 -2
View File
@@ -76,11 +76,20 @@ export function measure(
bbox: BBox,
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
tape?: { start: [number, number]; end: [number, number] },
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8;
const seg = maskAxisSegment(bitset, w, h, bbox);
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
const tapeSpan = tape
? Math.hypot(tape.end[0] - tape.start[0], tape.end[1] - tape.start[1])
: 0;
const scale = tapeSpan > 0
? tapeSpan / lengthIn
: (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
const axisStart = tapeSpan > 0 ? tape!.start : seg.start;
const axisUx = tapeSpan > 0 ? (tape!.end[0] - tape!.start[0]) / tapeSpan : seg.ux;
const axisUy = tapeSpan > 0 ? (tape!.end[1] - tape!.start[1]) / tapeSpan : seg.uy;
const every6In = sampleWidths(bitset, w, h, axisStart, axisUx, axisUy, scale, lengthIn);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);
+31
View File
@@ -107,6 +107,37 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
});
test('calibration rotation turns a landscape slab into an upright crop extent (#99)', () => {
const width = 6;
const height = 4;
const bitset = new Uint8Array(width * height);
for (let y = 1; y <= 2; y++) {
for (let x = 1; x <= 4; x++) bitset[y * width + x] = 1;
}
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: width, heightPx: height };
const mask = maskFor(bitset, { x: 1, y: 1, w: 4, h: 2 });
const photo = makePhoto(width, height, (x, y) =>
bitset[y * width + x] ? [200, 40, 20, 255] : [0, 255, 0, 255],
);
const extent = (rgba: Uint8Array, outW: number, outH: number) => {
let minX = outW, maxX = -1, minY = outH, maxY = -1;
for (let y = 0; y < outH; y++) {
for (let x = 0; x < outW; x++) {
if (rgba[(y * outW + x) * 4 + 3] === 0) continue;
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
}
}
return { width: maxX - minX + 1, height: maxY - minY + 1 };
};
const unrotated = composeInventoryFrame(photo, mask, source, 1, 0);
const rotated = composeInventoryFrame(photo, mask, source, 1, 90);
assert.ok(extent(unrotated.rgba, unrotated.width, unrotated.height).width >
extent(unrotated.rgba, unrotated.width, unrotated.height).height);
assert.ok(extent(rotated.rgba, rotated.width, rotated.height).height >
extent(rotated.rgba, rotated.width, rotated.height).width);
});
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
+134 -3
View File
@@ -1,7 +1,6 @@
import type { Mask, Source } from '../capture/capture.types';
const FLOOR = 800; // shorter side, px
const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
const ASPECT_W = 3;
const ASPECT_H = 4;
@@ -12,6 +11,10 @@ export interface InventoryInput {
bitmap: ImageBitmap;
mask: MaskPick;
source: SourcePick;
/** Global crop fill percent. Default 90. */
cropFill?: number;
/** Calibration-photo rotation in degrees; batch siblings remain unrotated. */
rotationDeg?: number;
}
export interface Frame {
@@ -25,7 +28,12 @@ export function composeInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick,
source: SourcePick,
fill = 0.9,
rotationDeg = 0,
): Frame {
if (Number.isFinite(rotationDeg) && Math.abs(rotationDeg % 360) > 1e-8) {
return composeRotatedInventoryFrame(src, mask, source, fill, rotationDeg);
}
const b = mask.slabBounds;
const x0 = Math.max(0, Math.round(b.x));
const y0 = Math.max(0, Math.round(b.y));
@@ -36,7 +44,7 @@ export function composeInventoryFrame(
const belowFloor = Math.min(sw, sh) < FLOOR;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
const dw = sw * scale;
const dh = sh * scale;
const ox = (outW - dw) / 2;
@@ -62,10 +70,131 @@ export function composeInventoryFrame(
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
}
interface PixelBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
function maskPixelBounds(bitset: Uint8Array, w: number, h: number): PixelBounds | null {
let minX = w, minY = h, maxX = -1, maxY = -1;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (!bitset[y * w + x]) continue;
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
}
}
return maxX < 0 ? null : { minX, minY, maxX, maxY };
}
function rotatePoint(
x: number, y: number, bounds: PixelBounds, radians: number,
): [number, number] {
const cx = (bounds.minX + bounds.maxX) / 2;
const cy = (bounds.minY + bounds.maxY) / 2;
const dx = x - cx, dy = y - cy;
const cos = Math.cos(radians), sin = Math.sin(radians);
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
}
export interface MaskRotation {
bounds: { x: number; y: number; w: number; h: number };
toRotated: (x: number, y: number) => [number, number];
toSource: (x: number, y: number) => [number, number];
}
export function createMaskRotation(
bitset: Uint8Array,
w: number,
h: number,
rotationDeg: number,
): MaskRotation {
const pixels = maskPixelBounds(bitset, w, h);
if (!pixels) {
return {
bounds: { x: 0, y: 0, w: 0, h: 0 },
toRotated: (x, y) => [x, y],
toSource: (x, y) => [x, y],
};
}
const radians = (rotationDeg * Math.PI) / 180;
const toRotated = (x: number, y: number) => rotatePoint(x, y, pixels, radians);
const toSource = (x: number, y: number) => rotatePoint(x, y, pixels, -radians);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (!bitset[y * w + x]) continue;
const [rx, ry] = toRotated(x, y);
minX = Math.min(minX, rx); minY = Math.min(minY, ry);
maxX = Math.max(maxX, rx); maxY = Math.max(maxY, ry);
}
}
return {
bounds: { x: minX - 0.5, y: minY - 0.5, w: maxX - minX + 1, h: maxY - minY + 1 },
toRotated,
toSource,
};
}
export function rotatedMaskBounds(
bitset: Uint8Array,
w: number,
h: number,
rotationDeg: number,
) {
return createMaskRotation(bitset, w, h, rotationDeg).bounds;
}
function composeRotatedInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick,
source: SourcePick,
fill: number,
rotationDeg: number,
): Frame {
const rotation = createMaskRotation(mask.bitset, source.widthPx, source.heightPx, rotationDeg);
if (!(rotation.bounds.w > 0 && rotation.bounds.h > 0)) {
throw new RangeError('Cannot rotate an empty approved mask');
}
const rotated = rotation.bounds;
const belowFloor = Math.min(rotated.w, rotated.h) < FLOOR;
const outW = belowFloor ? Math.max(1, Math.min(rotated.w, FLOOR)) : FLOOR;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
const scale = Math.min((outW * fill) / Math.max(rotated.w, 1), (outH * fill) / Math.max(rotated.h, 1));
const dw = rotated.w * scale;
const dh = rotated.h * scale;
const ox = (outW - dw) / 2;
const oy = (outH - dh) / 2;
const rgba = new Uint8Array(outW * outH * 4);
for (let y = 0; y < outH; y++) {
for (let x = 0; x < outW; x++) {
if (x + 0.5 < ox || x + 0.5 >= ox + dw || y + 0.5 < oy || y + 0.5 >= oy + dh) continue;
const rx = rotated.x + (x + 0.5 - ox) / scale;
const ry = rotated.y + (y + 0.5 - oy) / scale;
const [sourceX, sourceY] = rotation.toSource(rx, ry);
const sx = Math.round(sourceX);
const sy = Math.round(sourceY);
if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue;
const siPixel = sy * source.widthPx + sx;
if (mask.bitset[siPixel] !== 1) continue;
const si = siPixel * 4;
const di = (y * outW + x) * 4;
rgba[di] = src.rgba[si];
rgba[di + 1] = src.rgba[si + 1];
rgba[di + 2] = src.rgba[si + 2];
rgba[di + 3] = 255;
}
}
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
}
export async function encodeInventoryPng(
input: InventoryInput,
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
const { bitmap, mask, source } = input;
const { bitmap, mask, source, cropFill, rotationDeg } = input;
const fill = Math.max(1, Math.min(100, cropFill ?? 90)) / 100;
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
const srcCtx = srcCanvas.getContext('2d')!;
srcCtx.drawImage(bitmap, 0, 0);
@@ -74,6 +203,8 @@ export async function encodeInventoryPng(
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
mask,
source,
fill,
rotationDeg,
);
const out = new OffscreenCanvas(frame.width, frame.height);
const ctx = out.getContext('2d')!;
+31 -1
View File
@@ -299,7 +299,37 @@ test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#
});
// --- Length dim must not overshoot wood footprint (#69) ---
test('inventorySourceMap follows calibration rotation for overlay geometry (#99)', () => {
const W = 400, H = 400;
const bitset = new Uint8Array(W * H).fill(1);
const slabBounds = { x: 0, y: 0, w: W, h: H };
const unrotated = inventorySourceMap(
slabBounds,
{ widthPx: W, heightPx: H },
800,
1067,
0.9,
);
const rotated = inventorySourceMap(
slabBounds,
{ widthPx: W, heightPx: H },
800,
1067,
0.9,
bitset,
90,
);
const horizontal = (map: ReturnType<typeof inventorySourceMap>) => {
const a = map.mapToInventory(100, 200);
const b = map.mapToInventory(300, 200);
return [b[0] - a[0], b[1] - a[1]] as const;
};
const [unrotatedDx, unrotatedDy] = horizontal(unrotated);
const [rotatedDx, rotatedDy] = horizontal(rotated);
assert.ok(Math.abs(unrotatedDx) > Math.abs(unrotatedDy), 'unrotated source axis stays horizontal');
assert.ok(Math.abs(rotatedDy) > Math.abs(rotatedDx), '90° calibration rotation maps it vertically');
});
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
function tiltedSlabFixture() {
+31 -3
View File
@@ -1,5 +1,6 @@
import type { BBox } from '../capture/capture.types';
import { axisEndpoints } from '../measure/measure';
import { createMaskRotation } from './inventory';
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
export function overlayWidthStations(lengthIn: number): number[] {
@@ -485,15 +486,31 @@ export function inventorySourceMap(
source: { widthPx: number; heightPx: number },
outW: number,
outH: number,
fill = 0.9,
bitset?: Uint8Array,
rotationDeg = 0,
): InventoryMap {
const FILL = 0.9;
if (bitset && Math.abs(rotationDeg % 360) > 1e-8) {
const rotation = createMaskRotation(bitset, source.widthPx, source.heightPx, rotationDeg);
const scale = Math.min((outW * fill) / Math.max(rotation.bounds.w, 1), (outH * fill) / Math.max(rotation.bounds.h, 1));
const ox = (outW - rotation.bounds.w * scale) / 2;
const oy = (outH - rotation.bounds.h * scale) / 2;
return {
outW,
outH,
mapToInventory: (sx, sy) => {
const [rx, ry] = rotation.toRotated(sx, sy);
return [ox + (rx - rotation.bounds.x) * scale, oy + (ry - rotation.bounds.y) * scale];
},
};
}
const x0 = Math.max(0, Math.round(slabBounds.x));
const y0 = Math.max(0, Math.round(slabBounds.y));
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
const sw = x1 - x0;
const sh = y1 - y0;
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
const dw = sw * scale;
const dh = sh * scale;
const ox = (outW - dw) / 2;
@@ -517,6 +534,8 @@ export interface EncodeOverlayInput {
lengthLabel: string;
slabBounds: { x: number; y: number; w: number; h: number };
source: { widthPx: number; heightPx: number };
cropFill?: number;
rotationDeg?: number;
}
/**
@@ -531,7 +550,16 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0);
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
const fill = Math.max(1, Math.min(100, input.cropFill ?? 90)) / 100;
const { mapToInventory } = inventorySourceMap(
input.slabBounds,
input.source,
outW,
outH,
fill,
input.bitset,
input.rotationDeg,
);
const plan = buildOverlayPlan({
bitset: input.bitset,
widthPx: input.widthPx,
+8 -2
View File
@@ -28,9 +28,15 @@ Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=
| `01-batch.png` | Batch with fixtures loaded |
| `02-pick-calibration.png` | Pick calibration |
| `03-mask.png` | Mask (Black sheet) ready to approve |
| `04-axis.png` | Length axis |
| `05-measure.png` | Measure with L / thickness filled |
| `04-axis.png` | Tape calibration page |
| `05-tape-calibration.png` | Length and thickness entered on the tape page |
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
| `tape-fixed-line.png` | #99 fixed tape line: no tick-drag copy, ticks follow the crop slider |
| `tape-fixed-line-crop50.png` | #99 fixed tape line at 50% crop fill (line ends pulled in) |
`tape-fixed-line.spec.ts` asserts the #99 contract: the line is a stage fixture,
its ends are a pure function of `crop_fill`, a drag starting on a tick behaves
exactly like a pan elsewhere, and arrow keys cannot move the ends.
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
+1 -1
View File
@@ -1,5 +1,5 @@
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
// the Resume path restores straight to the Photos-ready Measure step — the
// the Resume path restores straight to the Photos-ready result step — the
// deterministic driver for the create→review→publish flow (no live camera/canvas
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
+178 -135
View File
@@ -53,7 +53,16 @@ const TAXONOMY = {
},
{
id: 38, name: 'Thickness', slug: 'thickness',
terms: [{ id: 39, name: '2-4 in', slug: '2-4' }],
// Real live band names: the Review screen only *displays* the derived band.
terms: [
{ id: 347, name: 'Under 1/2"', slug: 'xthin' },
{ id: 348, name: '1/2" - 3/4"', slug: '2-3qtr' },
{ id: 349, name: '3/4" - 1"', slug: '3-4qtr' },
{ id: 350, name: '1"- 1 1/2"', slug: '4-6qtr' },
{ id: 351, name: '1 1/2" - 2"', slug: '6-8qtr' },
{ id: 352, name: '2" - 3"', slug: '8-12qtr' },
{ id: 353, name: 'Over 3"', slug: '12qtr' },
],
},
{
id: 40, name: 'Moisture', slug: 'moisture',
@@ -109,7 +118,7 @@ function baseSlab(): Record<string, unknown> {
edge_type_term_id: null,
figure_term_ids: [],
grade_term_id: null,
thickness_term_id: null,
thickness_term_id: 352,
moisture_term_id: null,
fig_tag_ids: [],
feat_tag_ids: [],
@@ -148,8 +157,13 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
const calls = hooks.calls ?? { call1: 0, call2: 0 };
const slab = baseSlab();
let primary = null as number | null;
const reviewPrimaries: number[] = [];
const merge = (payload: Record<string, unknown>) => {
// Respect optimistic concurrency: reject stale client_rev (like the real backend).
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) {
return false; // stale — do NOT merge
}
for (const k of [
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
@@ -160,10 +174,21 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
};
const reviewResult = (payload: Record<string, unknown>) => {
const reviewResult = (payload: Record<string, unknown>) => {
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
// #89: the review ALWAYS returns the deterministic title/short_description built
// from the current metadata (the Create button's source of truth). The server
// does NOT persist title/short_description from the review payload.
const hasSpecies = !!slab.species_id;
const hasWoodCat = Array.isArray(slab.wood_category_ids) && slab.wood_category_ids.length > 0;
const deterministicTitle =
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab' : null;
const deterministicShort =
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab, 4ft 0in x 11in x 2in, 12.5 bdft' : null;
const missing: string[] = [];
if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species');
@@ -173,8 +198,8 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
return {
ready: missing.length === 0,
missing,
title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null,
title: deterministicTitle,
short_description: deterministicShort,
price: (slab.price as number) ?? null,
price_suggestion: 120,
primary_wood_category_id: primary,
@@ -207,7 +232,10 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
return;
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
merge(payload);
if (merge(payload) === false) {
await json(route, { error: { code: 'revision_conflict', message: 'client_rev mismatch', detail: { current_server_rev: slab.server_rev } } }, 409);
return;
}
await json(route, slab);
return;
}
@@ -229,7 +257,11 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
return;
}
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') {
await json(route, reviewResult(payload));
const result = reviewResult(payload);
if (typeof result.primary_wood_category_id === 'number') {
reviewPrimaries.push(result.primary_wood_category_id);
}
await json(route, result);
return;
}
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
@@ -251,7 +283,7 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
}
await json(route, { error: { code: 'not_found', message: path } }, 404);
});
return { calls, slab };
return { calls, slab, reviewPrimaries };
}
// Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and
@@ -284,6 +316,12 @@ async function completeForReady(page: Page) {
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
// #89: fire the deterministic build so the title/short_description populate.
// Do this BEFORE the SKU blur so a subsequent plain review doesn't race.
await expect(page.getByTestId('create-title')).toBeEnabled();
await page.getByTestId('create-title').click();
// Wait for the build to complete (title field populated = deterministic copy).
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001');
// SKU commits on blur — press Tab to move focus off it (deterministic, single
// commit; a body click can re-focus the SKU and re-fire blur).
@@ -292,12 +330,16 @@ async function completeForReady(page: Page) {
}
test.describe('M3 Review→Publish flow (#77)', () => {
test('golden path: capture→review→ready→publish→done', async ({ page }) => {
await mockApi(page);
test('golden path: capture→review→create→ready→publish→done (#77, #89)', async ({ page }) => {
const { slab } = await mockApi(page);
await reachReview(page);
// Not ready until fields are set.
// Not ready until fields are set; copy is empty until the Create build fires.
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
await expect(page.getByTestId('title')).toHaveValue('');
await completeForReady(page);
// #89: the Create build populated the deterministic copy on the slab row.
expect(slab.title).toBeTruthy();
expect(slab.short_description).toBeTruthy();
// Continue to Publish is enabled and hands off.
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
@@ -309,135 +351,136 @@ test.describe('M3 Review→Publish flow (#77)', () => {
await expect(page.getByTestId('new-slab')).toBeVisible();
});
test('inference off: no inference calls are made', async ({ page }) => {
const { calls } = await mockApi(page, { settings: { inference_enabled: false } });
test('wood categories: closed summary shows names; primary radio among selected (#90, REVIEW-15)', async ({ page }) => {
const { reviewPrimaries } = await mockApi(page);
await reachReview(page);
await completeForReady(page);
// No "Suggest with AI" control when inference is disabled, and zero hits.
await expect(page.getByTestId('run-inference')).toHaveCount(0);
await expect(page.getByTestId('generate-text')).toHaveCount(0);
expect(calls.call1).toBe(0);
expect(calls.call2).toBe(0);
// Open wood MultiSelect, pick Live edge + Bowl blanks, Done.
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
await page.getByTestId('wood-cat-done').click();
// Closed summary shows selected names (not only a count).
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Live edge/i);
await expect(page.getByTestId('wood-cat-summary')).toContainText(/Bowl blanks/i);
// Primary set appears with both selected; default primary = first selected.
const primarySet = page.getByTestId('primary-wood-set');
await expect(primarySet).toBeVisible();
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'true');
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'false');
// Owner switches primary to Bowl blanks; review payload carries it.
await page.getByTestId(`primary-wood-${BOWL_ID}`).click();
await expect(page.getByTestId(`primary-wood-${BOWL_ID}`)).toHaveAttribute('aria-checked', 'true');
await expect(page.getByTestId(`primary-wood-${LIVEEDGE_ID}`)).toHaveAttribute('aria-checked', 'false');
await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID);
});
test('inference on: Suggest with AI prefills + AI badges; Generate text writes', async ({ page }) => {
await mockApi(page, {
settings: { inference_enabled: true, content_llm_enabled: true },
onCall1: async (route) => {
await json(route, {
ok: true,
suggestions: {
species_id: SPECIES_ID,
species_confidence: 0.9,
wood_category_ids: [LIVEEDGE_ID],
edge_type_term_id: 31,
figure_term_ids: [34, 35],
grade_term_id: 37,
},
test('thickness band is server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
await mockApi(page);
await reachReview(page);
// METADATA-15 / QUALITY-95: the band is derived server-side from the measured
// thickness against the live synced Woo bands. It is deliberately ABSENT from
// this screen — no owner control, no read-only echo. The only thickness the
// owner sees is the measured value in the summary. (Commit 204bc5e dropped the
// read-only echo; this test asserts the omission.)
await expect(page.getByTestId('thickness-band')).toHaveCount(0);
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
});
/**
* #90 served-surface render check: the changed Review surface (wood-category
* MultiSelect + primary-radio set + read-only thickness band) is rendered at
* desktop/tablet/mobile widths, measured, and captured to
* tests/e2e/artifacts/ so layout and interaction can be inspected as evidence.
*/
test('Review surface renders at 1440/768/375 with no overflow (#90)', async ({ page }) => {
const widths = [
{ w: 1440, h: 900, name: 'desktop' },
{ w: 768, h: 1024, name: 'tablet' },
{ w: 375, h: 812, name: 'mobile' },
];
const report: string[] = [];
for (const vp of widths) {
await page.setViewportSize({ width: vp.w, height: vp.h });
await mockApi(page);
await reachReview(page);
// Put the surface in its real state: two wood categories selected, primary set.
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId(`wood-cat-opt-${BOWL_ID}`).click();
await page.getByTestId('wood-cat-done').click();
await expect(page.getByTestId('primary-wood-set')).toBeVisible();
const metrics = await page.evaluate(() => {
const de = document.documentElement;
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
const box = (sel: string) => {
const el = q(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
w: Math.round(r.width), h: Math.round(r.height),
fontSize: cs.fontSize,
overflowX: el.scrollWidth > el.clientWidth + 1,
text: (el.textContent || '').trim().slice(0, 80),
};
};
const primaryRow = q('[data-testid^="primary-wood-"]');
const primaryRowBox = primaryRow ? primaryRow.getBoundingClientRect() : null;
// Anything whose box pokes outside the viewport, with enough identity to fix it.
const offenders: { tag: string; cls: string; id: string; left: number; right: number }[] = [];
document.querySelectorAll('body *').forEach((el) => {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return;
if (r.right > de.clientWidth + 1 || r.left < -1) {
offenders.push({
tag: el.tagName.toLowerCase(),
cls: String(el.className || '').slice(0, 48),
id: el.id,
left: Math.round(r.left),
right: Math.round(r.right),
});
}
});
},
onCall2: async (route) => {
await json(route, { ok: true, description: 'AI-generated slab story.' });
},
});
await reachReview(page);
await page.getByTestId('run-inference').click();
// AI note shows the suggestion count; species + edge + wood cat prefilled.
await expect(page.getByTestId('ai-note')).toContainText(/AI suggested/i, { timeout: 15000 });
await expect(page.getByTestId('species')).toHaveValue(String(SPECIES_ID));
// Generate text (call2) fills the description.
await page.getByTestId('generate-text').click();
await expect(page.getByTestId('desc')).toHaveValue('AI-generated slab story.', { timeout: 15000 });
});
return {
docScrollW: de.scrollWidth,
docClientW: de.clientWidth,
pageHasHScroll: de.scrollWidth > de.clientWidth + 1,
offenders: offenders.slice(0, 8),
summary: box('[data-testid="wood-cat-summary"]'),
count: box('[data-testid="wood-cat-count"]'),
band: box('[data-testid="thickness-band"]'),
primarySet: box('[data-testid="primary-wood-set"]'),
primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null,
createBtn: box('[data-testid="create-title"]'),
continueBtn: box('[data-testid="continue-publish"]'),
};
});
test('fig-*/feat-* tag pickers render (optional) and persist to the slab', async ({ page }) => {
const { slab } = await mockApi(page);
await reachReview(page);
// Both pickers render from the synced tag families (METADATA-30 / METADATA-40).
await expect(page.getByTestId('fig-tag-btn')).toBeVisible();
await expect(page.getByTestId('feat-tag-btn')).toBeVisible();
// feat-* carries the "AI assist" tag (Call 1 proposes feature tags); fig-* does not.
// Scope to the field label (getByText would also match the MultiSelect's inner label).
await expect(page.locator('label', { hasText: 'feat-* tags' })).toContainText('AI assist');
await expect(page.locator('label', { hasText: 'fig-* tags' })).not.toContainText('AI assist');
// Select one fig tag and one feat tag.
await page.getByTestId('fig-tag-btn').click();
await page.getByTestId(`fig-tag-opt-${FIG_TAG_ID}`).click();
await page.getByTestId('fig-tag-done').click();
await expect(page.getByTestId('fig-tag-btn')).toContainText('1 selected');
await page.getByTestId('feat-tag-btn').click();
await page.getByTestId(`feat-tag-opt-${FEAT_TAG_ID}`).click();
await page.getByTestId('feat-tag-done').click();
await expect(page.getByTestId('feat-tag-btn')).toContainText('1 selected');
// Flush the tag ids to the slab: an on:change field (grade) triggers
// commitReview, which PUTs fig_tag_ids/feat_tag_ids with the current values.
await page.getByTestId('grade').selectOption('37');
// Tags are optional — they are not in the server ready-set, so readiness is
// unchanged (still "Not ready" because the other required fields are empty).
await expect(page.getByTestId('ready-status')).toContainText('Not ready');
// The commit PUT folded the tag ids into the slab row (what Publish will send).
expect(slab.fig_tag_ids).toEqual([FIG_TAG_ID]);
expect(slab.feat_tag_ids).toEqual([FEAT_TAG_ID]);
});
const shot = `tests/e2e/artifacts/review-90-${vp.name}-${vp.w}.png`;
await page.screenshot({ path: shot, fullPage: true });
report.push(`${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
// Print before asserting so the measurement survives a failing gate.
console.log(`RENDER ${vp.name} ${vp.w}x${vp.h}: ${JSON.stringify(metrics)}`);
test('publish failure: duplicate SKU offers edit + open existing', async ({ page }) => {
await mockApi(page, {
onPublish: async (route) => {
await json(route, {
error: { code: 'duplicate_sku', message: 'SKU already exists', detail: {} },
}, 409);
},
});
await reachReview(page);
await completeForReady(page);
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
await page.getByTestId('publish-btn').click();
await expect(page.getByTestId('dup-sku')).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('edit-sku')).toBeVisible();
await expect(page.getByTestId('open-existing')).toBeVisible();
});
test('publish failure: recoverable Woo error offers retry', async ({ page }) => {
await mockApi(page, {
onPublish: async (route) => {
await json(route, {
error: {
code: 'woo_unreachable',
message: 'Store is unreachable',
detail: { recoverable: true, partial_media: false },
},
}, 502);
},
});
await reachReview(page);
await completeForReady(page);
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
await page.getByTestId('publish-btn').click();
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('store-failure')).toContainText('Store is unreachable');
await expect(page.getByTestId('retry-publish')).toBeVisible();
});
test('publish failure: partial media is flagged for manual cleanup', async ({ page }) => {
await mockApi(page, {
onPublish: async (route) => {
await json(route, {
error: {
code: 'woo_partial_media',
message: 'Product created, photos failed',
detail: { recoverable: true, partial_media: true },
},
}, 502);
},
});
await reachReview(page);
await completeForReady(page);
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
await page.getByTestId('publish-btn').click();
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId('store-failure')).toContainText(/orphan media/i);
// Layout gates: nothing spills sideways, the closed summary names both picks
// (CSS ellipsis may shorten the paint, but the text must be there), and the
// primary rows stay a comfortable tap target on mobile.
expect(metrics.pageHasHScroll, `${vp.name} horizontal overflow: ${metrics.docScrollW} > ${metrics.docClientW}`).toBe(false);
expect(metrics.summary?.text).toMatch(/Live edge/i);
expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
expect(metrics.count?.text).toContain('2 selected');
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
// Band is deliberately absent from Review (METADATA-15) — no element to measure.
expect(metrics.band).toBeNull();
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
}
console.log('RENDER REPORT\n' + report.join('\n'));
});
});
+8 -12
View File
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
};
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
test('batch → pick → mask → tape calibration → photos ready + screenshots', async ({ page }) => {
test.setTimeout(180_000);
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
@@ -87,20 +87,16 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
await shot(page, '03-mask.png');
await page.getByRole('button', { name: 'Approve mask' }).click();
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Measure' })).toHaveCount(0);
await shot(page, '04-axis.png');
await page.getByRole('button', { name: 'Approve axis' }).click();
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
await page.getByLabel('Length L').fill('48');
await page.getByLabel('Thickness').fill('2');
// blur/change commits eighths into session before Calculate.
await page.getByLabel('Length L').blur();
await page.getByLabel('Thickness').blur();
await shot(page, '05-measure.png');
const calc = page.getByRole('button', { name: 'Calculate' });
await expect(calc).toBeDisabled();
await page.getByLabel('Length L').fill('48');
await page.getByLabel('Thickness').fill('2');
await expect(calc).toBeEnabled();
await shot(page, '05-tape-calibration.png');
await calc.click();
// Calculate encodes calibration PNG + overlay + sibling batch apply.
@@ -117,7 +113,7 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
// Write a short run log for parent AI visual review (not a substitute for eyes).
const logPath = path.join(ARTIFACTS, 'run-log.txt');
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-tape-calibration.png', '06-photos-ready.png'];
const lines = [
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
@@ -0,0 +1,305 @@
import { test, expect, type Page, type Route } from '@playwright/test';
import { seedMeasureDraft } from './helpers/draft';
/**
* M3 Review photo strip E2E (issue #90, REVIEW-5 / PUBLISH-45). Drives the
* capture→review flow (mocked API, no live backend) and exercises the photo
* strip: render from stored photos, keep/remove (whole-card tap), drag-reorder,
* leftmost-kept = primary, the clean+overlay minimum, and persistLineup on
* "Continue to Publish". All ids are synthetic fixtures (QUALITY-105).
*/
const SLAB_ID = '90e00000-0000-4000-8000-000000000090';
const SPECIES_ID = 11;
const WCAT_ID = 20;
const LIVEEDGE_ID = 21;
const TAXONOMY = {
categories: [
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
{ id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: 10 },
{ id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null },
{ id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID },
],
attributes: [
{ id: 30, name: 'Edge Type', slug: 'edge_type', terms: [{ id: 31, name: 'Live edge', slug: 'live-edge' }] },
],
tags: [],
taxonomy_anchor: 'fixture',
taxonomy_last_sync_at: '2026-01-01T00:00:00',
};
// The stored lineup: clean cal (seq 1), dimension-overlay (seq 2), batch (seq 3).
const PHOTO_ROWS = [
{ id: 'ph-cal', seq: 1, is_calibration: 1, role: 'calibration', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
{ id: 'ph-ov', seq: 2, is_calibration: 0, role: 'overlay', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
{ id: 'ph-b1', seq: 3, is_calibration: 0, role: 'batch', width_px: null, height_px: null, has_original: 1, has_processed: 1 },
];
function baseSlab(): Record<string, unknown> {
return {
id: SLAB_ID, sku: '', status: 'draft', price: null, price_source: 'recommendation',
length_in: 48, thickness_in: 2, sqft: 6.25, bdft: 12.5, width_min_in: 10, width_max_in: 12, width_avg_in: 11,
species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null,
thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
title: null, short_description: null, description: null, content_source: 'manual',
species_confidence: null, figure_confidence: null, grade_confidence: null,
inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
};
}
async function json(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
}
interface PersistRecord {
seq: number; role: string; is_calibration: string;
}
async function mockApi(page: Page) {
const slab = baseSlab();
const photoStore = new Map<string, typeof PHOTO_ROWS[number]>();
for (const r of PHOTO_ROWS) photoStore.set(r.id, { ...r });
const persistCalls: { photoId: string; seq: number; role: string }[] = [];
const deletes: string[] = [];
const merge = (payload: Record<string, unknown>) => {
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false;
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'edge_type_term_id']) {
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
}
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
};
const reviewResult = (payload: Record<string, unknown>) => {
if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
const missing: string[] = [];
if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species');
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
if (!slab.edge_type_term_id) missing.push('edge type');
if (!slab.title) missing.push('title');
if (!slab.short_description) missing.push('short description');
if (slab.price == null) missing.push('price');
// Deterministic copy from the server on lock (REVIEW-15) — set before ready.
if (!slab.title) slab.title = 'Cedar · 48″ Live Edge';
if (!slab.short_description) slab.short_description = 'Hand-cut slab, ready to ship.';
return {
ready: missing.length === 0, missing,
title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null,
price: (slab.price as number) ?? null, price_suggestion: 120,
primary_wood_category_id: null,
};
};
await page.route('**/api/v1/**', async (route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const method = req.method();
const payload = (() => { try { return req.postDataJSON() as Record<string, unknown>; } catch { return {}; } })();
if (path === '/api/v1/settings' && method === 'GET') {
return json(route, { woo_base_url: 'https://example.invalid', woo_wp_username: 'wp', woo_app_password: { configured: true }, inference_base_url: { configured: false }, inference_api_key: { configured: false }, inference_model: null, inference_enabled: false, content_llm_enabled: false, brand_voice: null, geo_context: null, woo_create_status: 'draft', taxonomy_last_sync_at: '2026-01-01T00:00:00', taxonomy_anchor: 'fixture' });
}
if (path === '/api/v1/admin/taxonomy' && method === 'GET') return json(route, TAXONOMY);
if (path === '/api/v1/slabs' && method === 'POST') {
slab.id = String(payload.id ?? SLAB_ID);
merge(payload);
return json(route, slab, 201);
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
if (merge(payload) === false) return json(route, { error: { code: 'revision_conflict', message: 'rev', detail: {} } }, 409);
return json(route, slab);
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') return json(route, slab);
// --- Photos (the strip) ---
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
return json(route, [...photoStore.values()].sort((a, b) => a.seq - b.seq));
}
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
// Multipart; pull the photo_id / seq / role form fields out of the body.
const raw = req.postData() ?? '';
const field = (name: string): string => {
const re = new RegExp(`name="${name}"\\r\\n\\r\\n([\\s\\S]*?)(?=$|--)`);
const m = raw.match(re);
return m ? m[1].trim() : '';
};
const photoId = field('photo_id');
const seq = Number(field('seq'));
const role = field('role') || 'batch';
// Capture-time uploads (photo_id not in the fixture store, e.g. "photo-1")
// are accepted but not added to the lineup — the strip is driven by the
// stored rows only. Review-strip re-uploads reference a known fixture id.
if (!photoStore.has(photoId)) {
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
}
persistCalls.push({ photoId, seq, role });
const existing = photoStore.get(photoId);
if (existing) {
existing.seq = seq;
existing.role = role as typeof existing.role;
}
return json(route, { id: photoId, slab_id: slab.id, seq, is_calibration: 0, role, original_retained: true, processed_retained: true }, 201);
}
const delMatch = path.match(new RegExp(`^/api/v1/slabs/${slab.id}/photos/([^/]+)$`));
if (delMatch && method === 'DELETE') {
const id = delMatch[1];
if (photoStore.delete(id)) { deletes.push(id); return route.fulfill({ status: 204, body: '' }); }
return json(route, { error: { code: 'not_found', message: 'Photo not found', detail: null } }, 404);
}
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') return json(route, reviewResult(payload));
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
return json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
}
return json(route, { error: { code: 'not_found', message: path } }, 404);
});
return { persistCalls, deletes, photoStore };
}
async function reachReview(page: Page) {
await page.goto('/?noload');
await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11 });
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 });
await page.getByTestId('continue-to-review').click();
await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 });
}
async function completeForReady(page: Page) {
await page.getByTestId('species').selectOption(String(SPECIES_ID));
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
await expect(page.getByTestId('create-title')).toBeEnabled();
await page.getByTestId('create-title').click();
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001');
await page.getByTestId('sku').press('Tab');
}
test.describe('M3 Review photo strip (#90)', () => {
test('renders stored photos; leftmost kept = primary; tap keep/remove; drag reorder; minimum; persist', async ({ page }) => {
const mock = await mockApi(page);
await reachReview(page);
// Strip renders the 3 stored photos in stored seq order.
const cards = page.getByTestId('photo-card');
await expect(cards).toHaveCount(3);
// Leftmost (cal, seq 1) is the primary.
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
await expect(page.getByText('PRIMARY', { exact: true })).toHaveCount(1);
// Bug #1 regression: the primary photo's <img src> must be a valid URL
// (blob: for an in-memory capture blob), never empty or "object Blob".
const primaryImg = cards.nth(0).locator('img');
await expect(primaryImg).not.toHaveAttribute('src', /object Blob|^$/);
const src = await primaryImg.getAttribute('src');
expect(src && src.length > 4).toBeTruthy();
// Bug #2 regression: the card carries the gray checkerboard background
// (matching the Capture lineup), not a flat color.
const bgImage = await cards.nth(0).evaluate((el) => getComputedStyle(el).backgroundImage);
expect(bgImage).toContain('45deg');
// Mandatory photos: the clean calibration (seq 1) and the dimension-overlay
// copy (seq 2) are required — their keep checkbox is checked AND disabled,
// and a REQUIRED tag marks them.
await expect(cards.nth(0)).toHaveAttribute('data-required', 'true');
await expect(cards.nth(1)).toHaveAttribute('data-required', 'true');
await expect(cards.nth(2)).toHaveAttribute('data-required', '');
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
await expect(cards.nth(1)).toHaveAttribute('aria-pressed', 'true');
await expect(cards.nth(0)).toHaveAttribute('aria-disabled', 'true');
await expect(cards.nth(1)).toHaveAttribute('aria-disabled', 'true');
await expect(cards.nth(2)).toHaveAttribute('aria-disabled', 'false');
// Two REQUIRED tags (cal + overlay), none on the batch photo.
await expect(page.getByText('REQUIRED', { exact: true })).toHaveCount(2);
// Tapping a mandatory photo is a no-op: it stays checked + primary (can't remove).
// The card is aria-disabled, so force the click to exercise the handler directly.
await cards.nth(0).click({ force: true });
await expect(cards.nth(0)).toHaveAttribute('aria-pressed', 'true');
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
// No minimum nudge — both required photos are kept by construction.
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Remove the OPTIONAL batch photo (index 2) → it un-keeps; required stay kept.
await cards.nth(2).click();
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
// Primary is still the leftmost required (cal, index 0).
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
// Re-add the batch photo.
await cards.nth(2).click();
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Drag the batch photo (index 2) to the front → it becomes the primary.
await cards.nth(2).scrollIntoViewIfNeeded();
await page.waitForTimeout(120);
const first = await cards.nth(0).boundingBox();
const third = await cards.nth(2).boundingBox();
if (first && third) {
const sx = third.x + third.width / 2, sy = third.y + third.height / 2;
const dx = first.x + first.width / 2, dy = first.y + first.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// Several small steps so the HTML5 DnD picks up the drag.
for (let i = 1; i <= 14; i++) {
await page.mouse.move(sx + (dx - sx) * (i / 14), sy + (dy - sy) * (i / 14));
}
await page.waitForTimeout(60);
await page.mouse.up();
}
// After reorder the batch is leftmost = primary; seq badges renumber.
await expect(cards.nth(0)).toHaveAttribute('data-primary', 'true');
// Fill fields to ready, then Continue → persistLineup runs (re-seq kept, drop none).
await completeForReady(page);
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
// Persisted: 3 re-uploads (re-seq 1..3), no deletes (nothing was removed in final state).
expect(mock.persistCalls.length).toBe(3);
const seqs = mock.persistCalls.map((c) => c.seq).sort((a, b) => a - b);
expect(seqs).toEqual([1, 2, 3]);
});
test('required photos guarantee the minimum; removing an OPTIONAL batch persists a DELETE', async ({ page }) => {
const mock = await mockApi(page);
await reachReview(page);
const cards = page.getByTestId('photo-card');
// The clean calibration + dimension-overlay are mandatory and always kept,
// so the submission minimum can never be broken — publish is never lineup-blocked.
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Removing an OPTIONAL batch photo is allowed and persists a DELETE.
await cards.nth(2).click();
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'false');
// Still no nudge: both required photos remain kept.
await expect(page.getByTestId('lineup-nudge')).toHaveCount(0);
// Complete the rest of the form; the lineup is valid (required photos kept).
await completeForReady(page);
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
// The removed batch photo persisted as a DELETE; the required photos re-uploaded.
expect(mock.deletes).toEqual(['ph-b1']);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
const here = path.dirname(fileURLToPath(import.meta.url));
const FACE_A = path.resolve(here, '../fixtures/set1-48x2/face-a.jpeg');
const ARTIFACTS = path.resolve(here, 'artifacts');
const shot = async (page: Page, name: string) => {
fs.mkdirSync(ARTIFACTS, { recursive: true });
await page.screenshot({ path: path.join(ARTIFACTS, name), fullPage: true });
};
const ensureFreshBatch = async (page: Page) => {
await page.goto('/');
const resumeHeading = page.getByRole('heading', { name: /resume/i });
const batchHeading = page.getByRole('heading', { name: 'Batch photos' });
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
if (await resumeHeading.isVisible().catch(() => false)) {
await page.getByRole('button', { name: /start fresh/i }).click();
}
await expect(batchHeading).toBeVisible();
};
const prepareMaskForApprove = async (page: Page) => {
await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible();
await page.getByRole('button', { name: 'Black', exact: true }).click();
const approve = page.getByRole('button', { name: 'Approve mask' });
try {
await expect(approve).toBeEnabled({ timeout: 45_000 });
return;
} catch {
/* sensitivity ladder */
}
for (const target of [60, 70, 40, 30, 80]) {
await page.locator('input[type="range"]').fill(String(target));
await page.waitForTimeout(2800);
if (await approve.isEnabled()) return;
}
await expect(approve).toBeEnabled({ timeout: 15_000 });
};
const toLengthPage = async (page: Page) => {
await ensureFreshBatch(page);
await page.locator('input[type="file"][multiple]').setInputFiles([FACE_A]);
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click();
await prepareMaskForApprove(page);
await page.getByRole('button', { name: 'Approve mask' }).click();
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
};
test.describe('#99 fixed tape line', () => {
test('the line is not editable: no tick-drag copy and no tick drag', async ({ page }) => {
test.setTimeout(180_000);
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
await toLengthPage(page);
// The hint must never tell the owner to drag or nudge the ticks.
await expect(page.getByText('Drag the ticks')).toHaveCount(0);
await expect(page.getByText('nudge the last tick')).toHaveCount(0);
await expect(page.getByText('The line is fixed in the middle')).toBeVisible();
const stage = page.getByTestId('tape-line');
await expect(stage).toBeVisible();
const box = (await stage.boundingBox())!;
const canvas = stage.locator('canvas').first();
// A pan delta produces the same result wherever you grab the stage, because
// pan is measured as a delta from the grab point. So: drag from a tick
// position, then repeat the same delta from a plain part of the stage. If
// the tick drag were still live it would move the line too, and the two
// results would differ. Identical output ⇒ the line is fixed.
const centerX = box.x + box.width / 2;
const tickY = box.y + box.height * 0.05;
const plainY = box.y + box.height * 0.62;
const delta = box.height * 0.18;
const dragBy = async (from: number) => {
await page.mouse.move(centerX, from);
await page.mouse.down();
await page.mouse.move(centerX, from + delta, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(300);
};
await dragBy(tickY);
const fromTick = await canvas.screenshot();
// Reset the transform, then repeat the identical delta from a non-tick point.
await toLengthPage(page);
await dragBy(plainY);
const fromPlain = await canvas.screenshot();
expect(
Buffer.compare(fromTick, fromPlain),
'a tick drag must behave exactly like a pan elsewhere (line stays fixed)',
).toBe(0);
// Length and thickness live on this same screen; Calculate stays usable.
await page.getByLabel('Length L').fill('48');
await page.getByLabel('Thickness').fill('2');
await expect(page.getByRole('button', { name: 'Calculate' })).toBeEnabled();
await shot(page, 'tape-fixed-line.png');
});
test('the crop slider sets the line ends and nothing else does', async ({ page }) => {
test.setTimeout(180_000);
await toLengthPage(page);
const canvas = page.getByTestId('tape-line').locator('canvas').first();
const fill = page.getByLabel('Crop fill percent');
const before = await canvas.screenshot();
// Move the crop-fill slider and confirm the control really took the value,
// so a no-op fill() can never masquerade as a passing assertion.
await fill.fill('50');
await expect(fill).toHaveValue('50');
await page.waitForTimeout(1200);
const after = await canvas.screenshot();
expect(Buffer.compare(before, after), 'crop fill must move the line ends').not.toBe(0);
// Arrow-key nudge on the line must not change the geometry either.
// Blur the slider first: arrow keys on a focused range input legitimately
// change that input, which would move the line ends for the wrong reason.
await page.locator('h1').click();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(500);
const afterKeys = await canvas.screenshot();
expect(
Buffer.compare(after, afterKeys),
'arrow keys must not move the fixed line ends',
).toBe(0);
await shot(page, 'tape-fixed-line-crop50.png');
});
});
+1 -1
View File
@@ -50,7 +50,7 @@ CITING_ROLES = {"capability", "contract"}
CITATION_EXEMPT = {"docs/contracts/TEST-SPEC.md"}
HEADING = re.compile(r"^(#{2,4})\s+(.*)$")
SCOPES = {"shipped", "M3", "later"}
SCOPES = {"shipped", "M3", "M4", "later"}
PLACEHOLDER = {"", "—", "-", "*(none yet)*"}