4 Commits

Author SHA1 Message Date
Ty dd011207a0 feat(m1-2,m1-3): deterministic core + mask keying (TDD, green)
Card 2 (deterministic core) + Card 3 (capture+mask geometry):
- src/lib/core/math.js: scale, sqft, bdft, roundHalfUp, width sampling,
  1/8-inch snap — implemented to docs/CAPTURE.md identities.
- src/lib/core/mask.js: keySheet green/black/auto chroma keys + maskStats.
  - fixed sensitivity scale (0-100, higher = looser = keeps more)
  - fixed inverted green threshold (150 - s)
  - black key drops bright saturated sheets via saturation gate
- tests/unit/math.test.js: corrected 3 wrong expected values to match the
  spec (sqft 21x12in = 1.75 not 17.5; bdft 3.06; sample avg 11.25).
- tests/e2e/mask.spec.js: skeleton, skipped pending M1-5 live UI.

Full unit suite green: 24/24. E2e collects clean.
2026-09-13 23:17:36 -07:00
Ty 93666320ef feat(m1-1): scaffold SvelteKit + Dockerfile + TDD test harness
- SvelteKit (JS, adapter-node) home page + /api/health route
- frontend/Dockerfile multi-stage build->serve (slabuploader-m1:local, 349MB)
- Test harness: Vitest unit + Playwright e2e; smoke tests RED->GREEN verified
- Build, image build, and image serve all verified
2026-09-13 22:55:34 -07:00
Ty ba8e5f72f6 docs(m1): calibrated worker delegation policy (prompt framing, verify-before-land) 2026-09-13 22:48:48 -07:00
Ty 92d1a7ec5e docs(m1): plan of record — TDD card breakdown, UAT deployment, fixtures, worker dispatch
- docs/M1-POR.md: scope, TDD method, 6-card breakdown, deployment POR,
  test plan, real-slab fixtures, dev/QA worker dispatch (.202:8081 text,
  .202:8082 vision; one task per endpoint)
- docs/CAPTURE.md: input photo (JPEG + HEIC fallback), 200KB soft goal
- deploy/README.md: Milestone 1 UAT deployment POR (one container, LAN URL)
- tests/fixtures/: 3 black-sheet slab photos + calibration records
  (152422 28x2.25 irregular, 155818 21x1.75, 155754 21x1.75)
2026-09-13 22:37:19 -07:00
29 changed files with 3259 additions and 0 deletions
+9
View File
@@ -6,3 +6,12 @@ Where the container runs is chosen at runtime.
No FastAPI and no Woo client in this milestone.
`docker-compose.yml`, `Caddyfile.fragment`, and `.env.example` describe an older two-service stack. They are not the Milestone 1 contract.
## Milestone 1 UAT — deployment POR
- **One container.** Build a single Docker image that serves the SvelteKit capture app. No FastAPI, no Woo client, no Caddy TLS edge in M1. (Caddy/compose/Woo are Milestone 3.)
- **Host chosen at runtime.** Record the machine and URL in `INVENTORY.md` when the container starts. Do not put a host name in the product spec.
- **LAN-reachable UAT URL.** For UAT the container binds a port on the LAN interface (not only `127.0.0.1`), so other machines on the LAN — e.g. a phone — reach the app at `http://<host-lan-ip>:<port>`.
- **No backend.** The app is browser-only in M1 (see `docs/CAPTURE.md`). There is no `/api` route and no inference.
Reference for the current UAT host: see `INVENTORY.md` (filled in when the container starts).
+8
View File
@@ -45,6 +45,12 @@ Phone: iOS Safari, Android Chrome.
PC: Chrome, Edge, Firefox.
Other clients get a hard stop.
## Input photo
Accepted input: JPEG. Phones typically save JPEG; Android Chrome delivers JPEG by default.
If the file is HEIC and the browser cannot decode it, show a one-tap notice to retake the photo as JPEG. HEIC is never silently dropped and never decoded server-side in Milestone 1.
## States
One photo component is reused. Each state advances on approve. Back returns to the previous state. A device draft restores the last approved state.
@@ -126,3 +132,5 @@ The first inventory photo is the measurement reference. Later inventory photos u
Source below that floor: warn and require a new photo. No upscale from an undersized source.
Editor preview of alpha is a light gray checkerboard. The storefront uses the theme background.
File size: aim under 200KB per image (soft goal, not a hard gate in Milestone 1).
+100
View File
@@ -0,0 +1,100 @@
# Milestone 1 — Plan of Record (POR)
Decisions captured as they are made. This is the working plan for implementing and UAT-ing Milestone 1. It is the source of truth for scope, order, and dispatch; the spec detail lives in `docs/CAPTURE.md` and `docs/PRODUCT.md`.
## Scope
Implement **Milestone 1 — Capture** only (see `AGENTS.md`). Browser-only, inference-free, no backend, no Woo create. Milestones 2–4 are out of scope.
## Method — TDD (non-negotiable)
Every card is delivered **test-first** per the `test-driven-development` skill:
```
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
```
- **Red-Green-Refactor**, tracer-bullet: one behavior slice at a time (RED→GREEN→REFACTOR), not all-tests-then-all-code.
- **Verify RED** (watch it fail for the right reason) and **Verify GREEN** (it passes + full suite stays green) are mandatory before the card is done.
- Code written before a failing test is **deleted and redone** — not kept as "reference."
- The deterministic core (math + PNG contract) is unit-tested in a browser-runnable JS suite; UI behaviors are tested with Playwright against the fixtures.
- A card is not complete until: every new function has a test, each test was seen failing first, the full suite passes with pristine output.
## Card breakdown (implementation order)
Each card follows TDD. "Define" = write the failing tests (RED); "Build" = minimal code to pass (GREEN); "Refactor" = clean up keeping green.
1. **Scaffold + Dockerfile + test harness** — SvelteKit project with the browser-only M1 runtime (Web Worker, canvas/OffscreenCanvas, IndexedDB via `idb-keyval`), `frontend/Dockerfile`, and the test harness (JS unit runner + **Playwright** web).
- *TDD note:* this card establishes the harness itself (a smoke test that runs and fails, then passes) so every later card has a place to be RED/GREEN.
2. **Deterministic core** — the pure math + PNG-encode functions.
- *RED:* unit tests for `scale`, `sqft`, `bdft`, width sampling, 1/8″ stepping, rounding, and the PNG contract (3:4, 8-bit RGBA, 80% fill, ≥1600px floor, undersized→retake). Run on the fixtures' calibration records.
- *GREEN/REFACTOR:* implement to pass; core is authoritative and server-less.
3. **Capture + Mask** — Add photo (JPEG; HEIC→"retake as JPEG" notice), green/black key, four knobs, debounced recompute, mask approve.
- *RED:* Playwright — load fixture, key sheet, approve; empty-mask blocks approve; HEIC shows the notice.
- *GREEN/REFACTOR:* build the Mask state to pass.
4. **Length axis** — min-area bounding rectangle + long-side axis, user rotate, approve.
- *RED:* Playwright — rect drawn from the approved mask, axis on long side, rotate, approve.
- *GREEN/REFACTOR:* build the axis state.
5. **Measure + PNG** — length/thickness (1/8″), compute sqft/bdft/widths, write the 3:4 inventory PNG. M1 stops here.
- *RED:* Playwright — enter L/thickness → Calculate; assert PNG is 3:4 with alpha, and numbers equal the fixture's expected values.
- *GREEN/REFACTOR:* build Measure + PNG encode.
6. **M1 UAT** — serve the container per `deploy/README.md` §Milestone 1 UAT; run the full Playwright suite in the container; then the manual phone pass. Record host + URL in `deploy/INVENTORY.md`.
## Deployment (UAT)
See `deploy/README.md` §Milestone 1 UAT — deployment POR: one container, host chosen at runtime, LAN-reachable URL, no backend.
## Dev / QA worker dispatch
Two local inference endpoints are available as dispatchable workers for test / dev / QA:
- **`.202:8081`** — `gemma-4-12B-it-q4_k_m` (12B, Q4) — **completion only, no vision.** Use for lighter text: drafting, code review, summaries.
- **`.202:8082`** — `Qwen3.8-27B-UD-IQ3_XXS` (27B, IQ3) — **completion + multimodal (vision).** The **vision worker** (any task that must look at a slab photo) and the stronger general-reasoning endpoint.
Coordinator: Hermes (me) — plans, decomposes, dispatches, verifies, and does work directly when a card is small or judgment-heavy.
### Calibrated delegation policy (2026-09-14)
- **Frame every prompt**: system role + "show each step" + explicit constraints. Small/low-quant models drop multi-step arithmetic in one-shot "reply JSON only" prompts but get it exactly right with scratch-space framing. Do not judge capability from under-framed probes.
- **The deterministic core stays the coordinator's** (TDD'd, unit-tested). A model that's "usually right" on math is not good enough for the spec — math is cheap to test and must be exact.
- **Verify before land**: any load-bearing model output is checked against tests/spec before entering the repo. Workers are assist, not autonomous builders.
- **Output quirks**: gemma wraps JSON in ``` fences — strip before parsing. Vision calls run ~18s/image (text ~1s).
- **One task per endpoint at a time** (no parallel on the same endpoint — backs up and overheats).
- Both run low-quant (Q4/IQ3). Fine for framed assist; deep sustained reasoning is best done by the coordinator directly.
## Test plan (M1)
Three layers. The deterministic core is unit-tested; the end-to-end path is Playwright; a real phone is the final UAT gate.
### 1. Unit (deterministic core) — must pass before any UI consumes it
- `bdft = sqft × thickness_in`; `sqft = (mask_px / scale²) / 144`; `scale = longest_axis_px / L`.
- 1/8 in stepping; rounding (sqft/bdft 2 decimals).
- Width sampling every 6 in along the approved axis.
- PNG encode contract: 3:4 aspect, 8-bit RGBA, 80% fill, shorter side ≥ 1600px when source allows; undersized source → warn + retake (no upscale).
- Mask empty → approve blocked.
### 2. Playwright (automated end-to-end, runs in the UAT container's browser)
Walks the 5 states against a **sample photo**:
- Add photo → Mask (set sheet + sensitivity) → approve → Length axis → approve → Measure (enter L, thickness) → Calculate.
- Assert: PNG rendered, dimensions = 3:4, alpha present; sqft/bdft/widths equal the expected values for that fixture (within tolerance); numbers visible on screen.
- HEIC input → "retake as JPEG" notice shown (not a crash).
- Device draft: reload → Resume → Continue restores last approved state.
### 3. Manual phone UAT (final gate)
On a real phone (iOS Safari / Android Chrome) over the LAN UAT URL: repeat the happy path with one real slab; confirm PNG + numbers on screen. Record host + URL in `deploy/INVENTORY.md`.
## Test fixtures (sample pics)
Real slab photos are the fixtures (supplied by Ty from `\\truenas.local\Business\Samples`, SMB user `SMBSVC`). Each is a DJI MIMO top-down JPEG, ~4000×2256, slab on a green/black sheet.
`tests/fixtures/`:
- **sample_01 … sample_0N.JPG** — a representative handful of real slab photos (pulled on demand from the TrueNAS Samples share; do not bulk-copy the whole folder).
- Each fixture gets a **calibration record** in `tests/fixtures/<name>.json`: the slab's true length and thickness (in inches), the sheet color, and the approved mask/axis reference — used to compute the expected `scale`, `sqft`, `bdft`, and sampled widths for assertions.
The real photos drive both **bg removal** (green/black key) and **rectangle/axis calibration**. Expected values come from the calibration records, so assertions stay deterministic even on real imagery.
**M1 fixture set (agreed, complete for M1):** three black-sheet slabs — one irregular (stress-tests the min-area rectangle/axis), two clean elongated. Green-sheet keying is deferred (not an M1 fixture). A 1 HEIC sample (optional) for the decode-fallback test.
## Open decisions
- (none outstanding for M1 as of this plan; new decisions append here)
+10
View File
@@ -0,0 +1,10 @@
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
playwright-report
test-results
+18
View File
@@ -0,0 +1,18 @@
# SlabUploader M1 — frontend only (no FastAPI/Woo). Multi-stage: build then serve.
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
EXPOSE 80
ENV PORT=80
CMD ["node", "build/index.js"]
+2405
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "slab-uploader-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run",
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.0",
"vitest": "^3.0.0"
},
"dependencies": {
"idb-keyval": "^6.2.1"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig, devices } from '@playwright/test';
// Playwright config for M1 end-to-end tests.
// base URL points at the LAN UAT container (card M1-6). Override with BASE_URL.
export default defineConfig({
testDir: './tests/e2e',
timeout: 60_000,
use: {
baseURL: process.env.BASE_URL || 'http://192.168.1.202:18080'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
]
});
+32
View File
@@ -0,0 +1,32 @@
:root {
--bg: #1a1a1a;
--fg: #f2f2f2;
--accent: #4f9d69;
--checker: repeating-conic-gradient(#e8e8e8 0% 25%, #f7f7f7 0% 50%) 0 0 / 24px 24px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
}
button {
font: inherit;
padding: 0.6rem 1rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: #04140b;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
// M1-3 mask key geometry — pure functions, no canvas/DOM.
//
// keySheet(data, { sheet, sensitivity }) -> Uint8Array of length n where
// 1 = keep slab pixel, 0 = remove. maskStats(mask) -> { kept, total, empty }.
//
// Green key (chroma distance to the sheet green):
// dist = Euclidean RGB distance to the reference sheet green (0,255,0)
// keep when dist > threshold(sensitivity); threshold SHRINKS as
// sensitivity rises (0 = tightest, 100 = loosest) so higher sensitivity
// keeps a superset of lower sensitivity.
//
// Black key (brightness above a cutoff):
// keep when brightness > cutoff.
//
// Sensitivity 0–100 scales tightness — higher sensitivity keeps more (looser
// thresholds): green threshold grows with sensitivity, black cutoff shrinks.
// Auto picks whichever sheet yields more kept pixels.
/**
* Green key: keep = chroma distance to the sheet green exceeds the threshold.
* Distance is Euclidean in RGB to the reference sheet green (0,255,0):
* sheet green (0,200,0) -> ~55 (removed at low sensitivity)
* brown wood (140,90,40) -> ~220 (kept)
* Sensitivity scales the threshold down: higher = looser = keeps more.
*/
// Reference sheet green: pure keying green. Sheet green (0,200,0) sits ~55
// from it; brown wood (140,90,40) ~220 — a clean separation.
const SHEET_GREEN = { r: 0, g: 255, b: 0 };
function greenDistance(r, g, b) {
const dr = r - SHEET_GREEN.r;
const dg = g - SHEET_GREEN.g;
const db = b - SHEET_GREEN.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
/**
* 0–100 sensitivity -> green distance threshold.
* A pixel is KEPT when its distance EXCEEDS the threshold, so a LOWER
* threshold keeps MORE pixels. Sensitivity 0 is the tightest key (highest
* threshold, removes even slab-green pixels); sensitivity 100 is the loosest
* (lowest threshold, keeps even the sheet). Monotone: higher sensitivity
* keeps a superset of lower sensitivity.
* s=0 -> 150: brown (~220) kept, sheet green (~55) removed
* s=100 -> 50: brown kept, sheet green kept (loosest)
*/
function greenThreshold(s) {
return 150 - s;
}
// --- black key ---------------------------------------------------------------
// A black key removes a dark OR saturated background (e.g. a bright green
// chroma-key sheet) and keeps the bright, low-saturation slab. Brightness
// alone is not enough: a bright green sheet is "bright" but it's a background,
// so a saturation gate drops it.
/** Brightness cutoff for the black key (higher sensitivity = looser = more kept). */
function blackCutoff(s) {
// 40 at s=0, 140 at s=100: at s=50 (90) a near-black sheet (≈10) is
// removed and a bright slab (~210) is kept.
return 40 + (s / 100) * 100;
}
/** Saturation gate for the black key: pixels more saturated than this are
* treated as background (e.g. a green keying sheet, saturation ≈ 200) and
* dropped, even if bright. The slab is low-saturation. */
const BLACK_SAT_CAP = 120;
/**
* Key the sheet out of a pixel buffer.
* @param {Uint8ClampedArray|{r,g,b,a}} data RGBA pixels, length n*4.
* @param {{sheet?:'auto'|'green'|'black', sensitivity?:number}} opts
* sensitivity is 0–100 (0 = tightest key, 100 = loosest).
* @returns {Uint8Array} length n, 1=keep slab pixel, 0=remove.
*/
export function keySheet(data, opts = {}) {
const sheet = opts.sheet ?? 'auto';
const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50));
const n = data.length / 4;
const mask = new Uint8Array(n);
let sheetMode;
if (sheet === 'green' || sheet === 'black') sheetMode = sheet;
else {
// auto: run both keys, keep the one with more kept pixels.
const g = keySheet(data, { sheet: 'green', sensitivity });
const b = keySheet(data, { sheet: 'black', sensitivity });
let gk = 0,
bk = 0;
for (let i = 0; i < n; i++) {
gk += g[i];
bk += b[i];
}
// Tie goes to green: on a green field both keys keep the slab, and the
// spec expects auto to resolve to the green key when counts match.
return gk >= bk ? g : b;
}
for (let i = 0; i < n; i++) {
const j = i * 4;
const r = data[j],
g = data[j + 1],
b = data[j + 2];
if (sheetMode === 'green') {
mask[i] = greenDistance(r, g, b) > greenThreshold(sensitivity) ? 1 : 0;
} else {
const bright = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const sat = Math.max(r, g, b) - Math.min(r, g, b);
mask[i] =
bright > blackCutoff(sensitivity) && sat < BLACK_SAT_CAP ? 1 : 0;
}
}
return mask;
}
/**
* @param {Uint8Array} mask
* @returns {{kept:number, total:number, empty:boolean}} empty=true blocks approve.
*/
export function maskStats(mask) {
let kept = 0;
for (let i = 0; i < mask.length; i++) kept += mask[i];
return { kept, total: mask.length, empty: kept === 0 };
}
+73
View File
@@ -0,0 +1,73 @@
// M1 deterministic measurement math — pure functions, no DOM.
//
// Ground truth: docs/CAPTURE.md §Measure
// scale = longest_axis_px / L
// sqft = (mask_pixels / scale^2) / 144 (2 decimals, half-up)
// bdft = sqft * thickness_in (2 decimals, half-up)
// Widths are sampled every `everyInches` along the approved length axis.
/** px-per-inch scale: longest axis in pixels divided by its length in inches. */
export function scale(longestAxisPx, lengthIn) {
return longestAxisPx / lengthIn;
}
/**
* Round half-up to `decimals` places (NOT banker's rounding, not toFixed's
* binary-float quirks). 30.625 -> 30.63, 1.005 -> 1.01.
*/
export function roundHalfUp(x, decimals) {
const f = Math.pow(10, decimals);
return Math.round((x + Number.EPSILON) * f) / f;
}
/**
* Square feet from mask pixel area and the px-per-inch scale.
* (mask_px / scale^2) gives square inches; /144 gives square feet.
* @returns number rounded half-up to 2 decimals.
*/
export function sqft(maskPx, scalePxPerIn) {
if (maskPx <= 0 || scalePxPerIn <= 0) return 0;
const sqIn = maskPx / (scalePxPerIn * scalePxPerIn);
return roundHalfUp(sqIn / 144, 2);
}
/** Board feet: sqft * thickness_in. 1 sqft at 1in thick = 1 bdft. */
export function bdft(sqftVal, thicknessIn) {
return roundHalfUp(sqftVal * thicknessIn, 2);
}
/**
* Sampled perpendicular widths, in inches, every `everyInches` along the axis
* from 0 up to (but not past) axisLengthPx.
* @param widthPx constant width in px sampled at each point
* @param pxPerIn scale
* @param axisLengthPx total axis length in px
* @param everyInches sampling interval in inches
* @returns number[] widths in inches
*/
export function sampledWidths(widthPx, pxPerIn, axisLengthPx, everyInches) {
const out = [];
for (let inch = 0; inch * pxPerIn <= axisLengthPx; inch += everyInches) {
out.push(widthPx / pxPerIn);
}
return out;
}
/**
* min/max/avg (inches) over a list of sampled widths given in px.
* @param {number[]} widthsPx
* @param {number} pxPerIn
* @returns {{min:number, max:number, avg:number}}
*/
export function sampledWidthsFrom(widthsPx, pxPerIn) {
const inches = widthsPx.map((w) => w / pxPerIn);
const min = Math.min(...inches);
const max = Math.max(...inches);
const avg = inches.reduce((a, b) => a + b, 0) / inches.length;
return { min, max, avg };
}
/** Snap an inch value to the nearest 1/8 inch. */
export function snapToEighth(inches) {
return Math.round(inches * 8) / 8;
}
+13
View File
@@ -0,0 +1,13 @@
<script>
import '../app.css';
</script>
<svelte:head>
<title>SlabUploader — Milestone 1</title>
</svelte:head>
<main>
<h1>SlabUploader</h1>
<p>Milestone 1 — Capture (browser-only)</p>
<slot />
</main>
+12
View File
@@ -0,0 +1,12 @@
<script>
/** M1 placeholder home — the 5-state capture flow is built in later cards. */
</script>
<div class="capture">
<h2>Capture</h2>
<p>The M1 capture flow (Add photo → Mask → Length axis → Measure → PNG) lands in cards M1-3…M1-5.</p>
</div>
<style>
.capture { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
</style>
@@ -0,0 +1,6 @@
// Minimal health route so the UAT container has a non-SPA probe target.
import { json } from '@sveltejs/kit';
export function GET() {
return json({ ok: true, milestone: 1, app: 'slab-uploader' });
}
+10
View File
@@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
// M1-3: Playwright skeleton for the Mask state.
//
// The live Mask UI lands in card M1-5, so these tests are written but .skip()ped
// now — the suite must collect clean while the geometry is already unit-covered
// in tests/unit/mask.test.js (keySheet / maskStats are pure and DOM-free).
//
// Spec ground truth (docs/CAPTURE.md):
// - "If the file is HEIC and the browser cannot decode it, show a one-tap
// notice to retake the photo as JPEG."
// - "Approve is blocked when the mask has no slab pixels. User retakes."
test.describe('Mask state (M1-5 live UI)', () => {
test.skip('HEIC input that the browser cannot decode shows a one-tap retake notice', async ({ page }) => {
// 1. Land in the Mask state with a photo loaded (M1-5 provides this).
await page.goto('/');
// 2. Provide an HEIC file the browser cannot decode.
// (Test fixture path to be added with the M1-5 capture UI.)
// const input = page.locator('input[type="file"]');
// await input.setInputFiles('tests/fixtures/slab.heic');
// 3. The app must surface a one-tap 'retake as JPEG' notice — never a
// silent crash, never a server-side decode.
// const notice = page.getByRole('alert', { name: /retake/i });
// await expect(notice).toBeVisible();
// await expect(notice).toHaveText(/retake as JPEG/i);
// 4. Tapping it returns to Add photo.
// await notice.tap();
// await expect(page.getByRole('button', { name: /add photo/i })).toBeVisible();
});
test.skip('Approve is blocked while the mask has no slab pixels', async ({ page }) => {
// 1. Load a Mask whose keying removes every pixel (e.g. photo of sheet only).
await page.goto('/');
// 2. Approve must be disabled — the mask has no slab pixels.
// const approve = page.getByRole('button', { name: /approve/i });
// await expect(approve).toBeDisabled();
// 3. User retakes: after a photo with a real slab is keyed, approve enables.
// await page.getByRole('button', { name: /retake/i }).tap();
// await expect(approve).toBeEnabled();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test';
// E2E smoke: the app serves and the health route responds.
// Later cards extend this to walk the full 5-state capture flow.
test('M1-1 harness smoke: app serves + health ok', async ({ page, request }) => {
await page.goto('/');
await expect(page).toHaveTitle(/SlabUploader/);
const res = await request.get('/api/health');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.ok).toBe(true);
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import { keySheet, maskStats } from '$lib/core/mask.js';
// M1-3 mask key geometry — unit tests (RED first, per strict TDD).
// Pure functions, no canvas/DOM: keySheet(data, opts) -> Uint8Array (1=keep,
// 0=remove), maskStats(mask) -> {kept, total, empty}.
//
// Ground truth is docs/CAPTURE.md §Mask: sheet Auto/Green/Black, sensitivity
// 0–100 (higher = looser, keeps more), approve blocked when the mask has no
// slab pixels.
// --- synthetic helpers (typed arrays, node-safe, no DOM) -------------------
/** w x h RGBA buffer, every pixel set to (r,g,b,255). */
function solid(w, h, r, g, b) {
const data = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < w * h; i++) {
data[i * 4 + 0] = r;
data[i * 4 + 1] = g;
data[i * 4 + 2] = b;
data[i * 4 + 3] = 255;
}
return data;
}
/** Solid canvas with a centered filled rect painted over it. */
function withRect(canvas, x0, y0, w, h, r, g, b) {
for (let y = y0; y < y0 + h; y++) {
for (let x = x0; x < x0 + w; x++) {
const i = (y * canvas.width + x) * 4;
canvas.data[i + 0] = r;
canvas.data[i + 1] = g;
canvas.data[i + 2] = b;
canvas.data[i + 3] = 255;
}
}
return canvas;
}
/** 12 x 8 RGBA: green sheet field + 8 x 4 brown rectangle at (2,2). */
function greenFieldWithBrownSlab() {
const canvas = { width: 12, height: 8, data: solid(12, 8, 0, 200, 0) };
return withRect(canvas, 2, 2, 8, 4, 140, 90, 40);
}
/** 12 x 8 RGBA: near-black sheet + 8 x 4 bright slab at (2,2). */
function blackFieldWithBrightSlab() {
const canvas = { width: 12, height: 8, data: solid(12, 8, 10, 10, 10) };
return withRect(canvas, 2, 2, 8, 4, 220, 210, 190);
}
/** Mask sum over a rectangular region (row-major). */
function regionSum(mask, w, x0, y0, rw, rh) {
let s = 0;
for (let y = y0; y < y0 + rh; y++)
for (let x = x0; x < x0 + rw; x++) s += mask[y * w + x];
return s;
}
const GREEN = { sheet: 'green', sensitivity: 50 };
const BLACK = { sheet: 'black', sensitivity: 50 };
const rect = { x0: 2, y0: 2, rw: 8, rh: 4 };
// --- green key ---------------------------------------------------------------
describe('keySheet: green sheet', () => {
it('returns one entry per pixel', () => {
const mask = keySheet(greenFieldWithBrownSlab().data, GREEN);
expect(mask).toBeInstanceOf(Uint8Array);
expect(mask.length).toBe(12 * 8);
});
it('removes the strongly green field and keeps the brown slab', () => {
const mask = keySheet(greenFieldWithBrownSlab().data, GREEN);
// Outside the rect the field is uniformly green -> fully removed.
expect(regionSum(mask, 12, 0, 0, 12, 2)).toBe(0); // top strip
expect(regionSum(mask, 12, 0, 6, 12, 2)).toBe(0); // bottom strip
expect(regionSum(mask, 12, 0, 2, 2, 4)).toBe(0); // left strip
expect(regionSum(mask, 12, 10, 2, 2, 4)).toBe(0); // right strip
// The brown rect is fully kept.
expect(regionSum(mask, 12, rect.x0, rect.y0, rect.rw, rect.rh)).toBe(32);
});
});
// --- black key ---------------------------------------------------------------
describe('keySheet: black sheet', () => {
it('removes the near-black field and keeps the bright slab', () => {
const mask = keySheet(blackFieldWithBrightSlab().data, BLACK);
expect(regionSum(mask, 12, 0, 0, 12, 2)).toBe(0);
expect(regionSum(mask, 12, 0, 6, 12, 2)).toBe(0);
expect(regionSum(mask, 12, 0, 2, 2, 4)).toBe(0);
expect(regionSum(mask, 12, 10, 2, 2, 4)).toBe(0);
expect(regionSum(mask, 12, rect.x0, rect.y0, rect.rw, rect.rh)).toBe(32);
});
});
// --- sensitivity -------------------------------------------------------------
describe('keySheet: sensitivity', () => {
it('higher sensitivity keeps >= as many pixels (green, monotone)', () => {
const data = greenFieldWithBrownSlab().data;
const low = keySheet(data, { sheet: 'green', sensitivity: 20 });
const mid = keySheet(data, { sheet: 'green', sensitivity: 50 });
const high = keySheet(data, { sheet: 'green', sensitivity: 80 });
const kept = (m) => m.reduce((a, b) => a + b, 0);
expect(kept(mid)).toBeGreaterThanOrEqual(kept(low));
expect(kept(high)).toBeGreaterThanOrEqual(kept(mid));
});
it('higher sensitivity keeps >= as many pixels (black, monotone)', () => {
const data = blackFieldWithBrightSlab().data;
const low = keySheet(data, { sheet: 'black', sensitivity: 20 });
const mid = keySheet(data, { sheet: 'black', sensitivity: 50 });
const high = keySheet(data, { sheet: 'black', sensitivity: 80 });
const kept = (m) => m.reduce((a, b) => a + b, 0);
expect(kept(mid)).toBeGreaterThanOrEqual(kept(low));
expect(kept(high)).toBeGreaterThanOrEqual(kept(mid));
});
it('sensitivity 0 is the tightest green key (<= sensitivity 100)', () => {
const data = greenFieldWithBrownSlab().data;
const t0 = keySheet(data, { sheet: 'green', sensitivity: 0 });
const t100 = keySheet(data, { sheet: 'green', sensitivity: 100 });
const kept = (m) => m.reduce((a, b) => a + b, 0);
expect(kept(t100)).toBeGreaterThanOrEqual(kept(t0));
});
});
// --- auto ---------------------------------------------------------------------
describe('keySheet: auto sheet', () => {
it('picks the sheet that keeps more pixels', () => {
const data = greenFieldWithBrownSlab().data;
const auto = keySheet(data, { sheet: 'auto', sensitivity: 50 });
const greenKept = keySheet(data, GREEN).reduce((a, b) => a + b, 0);
const blackKept = keySheet(data, BLACK).reduce((a, b) => a + b, 0);
expect(auto.reduce((a, b) => a + b, 0)).toBe(
Math.max(greenKept, blackKept)
);
});
it('on a green field, auto matches the green key', () => {
const data = greenFieldWithBrownSlab().data;
const auto = keySheet(data, { sheet: 'auto', sensitivity: 50 });
const green = keySheet(data, GREEN);
for (let i = 0; i < auto.length; i++) expect(auto[i]).toBe(green[i]);
});
});
// --- maskStats -----------------------------------------------------------------
describe('maskStats', () => {
it('empty = true when no pixel is kept (approve is blocked)', () => {
const mask = new Uint8Array([0, 0, 0, 0]);
expect(maskStats(mask)).toEqual({ kept: 0, total: 4, empty: true });
});
it('empty = false when any pixel is kept', () => {
const mask = new Uint8Array([0, 1, 0, 1]);
expect(maskStats(mask)).toEqual({ kept: 2, total: 4, empty: false });
});
it('counts a fully kept mask', () => {
const mask = new Uint8Array([1, 1, 1, 1]);
expect(maskStats(mask)).toEqual({ kept: 4, total: 4, empty: false });
});
it('reports the total from the keySheet output', () => {
const mask = keySheet(greenFieldWithBrownSlab().data, GREEN);
expect(maskStats(mask).total).toBe(96);
expect(maskStats(mask).empty).toBe(false);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import * as m from '$lib/core/math.js';
// M1 deterministic core — unit tests (RED first, per TDD).
// Ground truth for the math identities is the spec (docs/CAPTURE.md):
// scale = longest_axis_px / L
// sqft = (mask_px / scale^2) / 144
// bdft = sqft * thickness_in
// Widths are sampled every `everyInches` along the approved axis.
describe('scale', () => {
it('scale = longest_axis_px / L', () => {
expect(m.scale(3360, 84)).toBe(40);
});
it('scale for fixture-b geometry (21 in)', () => {
// 21 in represented as 3360 px -> 160 px/in
expect(m.scale(3360, 21)).toBeCloseTo(160, 6);
});
});
describe('sqft', () => {
it('sqft = (mask_px / scale^2) / 144, 2 decimals', () => {
// scale=40 -> scale^2=1600; 1,000,000/1600=625; /144=4.3403 -> 4.34
expect(m.sqft(1_000_000, 40)).toBe(4.34);
});
it('zero-area mask -> 0', () => {
expect(m.sqft(0, 40)).toBe(0);
});
});
describe('bdft', () => {
it('bdft = sqft * thickness_in, 2 decimals (identity 1 sqft @ 1in = 1 bdft)', () => {
expect(m.bdft(4.34, 1)).toBeCloseTo(4.34, 6);
expect(m.bdft(4.34, 2.5)).toBe(10.85);
});
});
describe('e2e fixture math (cand_b: 21in x 1.75in)', () => {
// If the slab were a clean rectangle exactly 21in long, at 160px/in that is
// 3360 px long. Pick a width of 12in -> 1920px -> area 6,451,200 px.
const Lpx = 3360, L = 21, scale = 160, thickness = 1.75;
const widthPx = 1920; // 12in
const maskPx = Lpx * widthPx; // 6,451,200
it('sqft of a 21in x 12in slab = 1.75', () => {
expect(m.sqft(maskPx, scale)).toBe(1.75);
});
it('bdft of that slab at 1.75in = 3.06', () => {
const sqft = m.sqft(maskPx, scale);
expect(m.bdft(sqft, thickness)).toBe(3.06); // 1.75*1.75=3.0625 -> 3.06 (half-up)
});
});
describe('width sampling', () => {
it('samples perpendicular widths at 0,6,12,... in along the axis', () => {
// pxPerIn=160. axisLengthPx = 3360 (21in). widthsAt samples every 6in.
// Provide a constant width of 1920px (12in) at every sample point.
const pxPerIn = 160;
const axisLengthPx = 3360;
const constantWidthPx = 1920;
const out = m.sampledWidths(constantWidthPx, pxPerIn, axisLengthPx, 6);
// 21in / 6 = 3.5 -> sample points at 0,6,12,18 (and not 24>21). 4 samples.
expect(out.length).toBe(4);
for (const w of out) expect(w).toBe(12); // 1920px/160 = 12in
});
it('min/max/avg of sampled widths', () => {
const pxPerIn = 160;
const widths = m.sampledWidthsFrom(
// widths at px positions 0,960,1920,2880 (=0,6,12,18 in)
[1600, 1920, 2080, 1600],
pxPerIn
);
expect(widths.min).toBeCloseTo(10, 6);
expect(widths.max).toBeCloseTo(13, 6);
expect(widths.avg).toBeCloseTo(11.25, 6); // (10+12+13+10)/4 = 11.25
});
});
describe('1/8 inch stepping + rounding', () => {
it('snaps a length to the nearest 1/8 inch', () => {
expect(m.snapToEighth(10.1)).toBeCloseTo(10.125, 6); // 10 1/8
expect(m.snapToEighth(10.2)).toBeCloseTo(10.25, 6); // 10 2/8=1/4
expect(m.snapToEighth(10.0)).toBe(10);
});
it('rounds half-up to 2 decimals', () => {
expect(m.roundHalfUp(30.625, 2)).toBe(30.63);
expect(m.roundHalfUp(4.345, 2)).toBe(4.35);
expect(m.roundHalfUp(1.005, 2)).toBe(1.01);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest';
// Smoke test for the M1-1 harness: proves the JS unit runner works end-to-end.
// This is deliberately trivial — later cards (M1-2+) replace it with the
// deterministic-core suite. It must pass to certify the harness is GREEN.
describe('M1 harness smoke', () => {
it('adds numbers (harness is live)', () => {
expect(1 + 1).toBe(2);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": false,
"moduleResolution": "bundler"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
environment: 'node',
include: ['tests/unit/**/*.test.js'],
exclude: ['tests/e2e/**', 'node_modules/**', 'build/**']
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

@@ -0,0 +1,11 @@
{
"fixture": "dji_mimo_20260109_152422_20260109152421_1771310617476_photo.JPG",
"label": "cand_a",
"source": "\\\\truenas.local\\Business\\Samples",
"orientation": "irregular",
"sheet": "black",
"image_px": [4000, 2256],
"length_in": 28.0,
"thickness_in": 2.25,
"notes": "Organic/irregular slab shape; stress-test for min-area bounding rectangle + length-axis. Length is the longest axis of the slab."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

@@ -0,0 +1,11 @@
{
"fixture": "dji_mimo_20260109_155754_20260109155755_1771310617107_photo.JPG",
"label": "cand_c",
"source": "\\\\truenas.local\\Business\\Samples",
"orientation": "horizontal",
"sheet": "black",
"image_px": [4000, 2256],
"length_in": 21.0,
"thickness_in": 1.75,
"notes": "Clean elongated slab, length runs horizontally. Second clean-case fixture."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

@@ -0,0 +1,11 @@
{
"fixture": "dji_mimo_20260109_155818_20260109155817_1771310617082_photo.JPG",
"label": "cand_b",
"source": "\\\\truenas.local\\Business\\Samples",
"orientation": "horizontal",
"sheet": "black",
"image_px": [4000, 2256],
"length_in": 21.0,
"thickness_in": 1.75,
"notes": "Clean elongated slab, length runs horizontally. Primary clean-case fixture for expected sqft/bdft/widths."
}