M1-3: Capture + Mask state (TDD)
- Add maskState.js: sheet Auto/Green/Black, sensitivity, edge offset, feather - Overlay = slab photo + checkerboard on removed px - Debounced recompute 2.0s after last sensitivity change - Approve blocked when maskStats empty - HEIC shows retake notice - Refactored mask.test.js + extended e2e mask.spec.js - 43/43 unit tests green
This commit is contained in:
@@ -1,42 +1,102 @@
|
||||
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).
|
||||
// M1-3: Playwright tests for the Mask state (RED first — the live Mask UI
|
||||
// lands in M1-5; the pure state machine + geometry are unit-tested in
|
||||
// tests/unit/maskState.test.js).
|
||||
//
|
||||
// Spec ground truth (docs/CAPTURE.md):
|
||||
// - "Approve is blocked when the mask has no slab pixels. User retakes."
|
||||
// - "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."
|
||||
// - 4 knobs: sheet Auto/Green/Black, sensitivity 0–100, edge offset, feather.
|
||||
// - Sensitivity slider updates immediately; overlay recomputes 2.0 s after
|
||||
// the last change.
|
||||
|
||||
const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg';
|
||||
const SHEET_ONLY_FIXTURE = 'tests/fixtures/sheet-only-green.jpg';
|
||||
const HEIC_FIXTURE = 'tests/fixtures/slab.heic';
|
||||
|
||||
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).
|
||||
test('HEIC input that the browser cannot decode shows a one-tap retake notice', async ({ page }) => {
|
||||
// 1. Land on Add photo.
|
||||
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');
|
||||
const input = page.locator('input[type="file"]');
|
||||
await input.setInputFiles(HEIC_FIXTURE);
|
||||
// 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);
|
||||
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();
|
||||
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).
|
||||
test('Approve is blocked while the mask has no slab pixels', async ({ page }) => {
|
||||
// 1. Load a photo of the sheet only (no slab).
|
||||
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();
|
||||
const input = page.locator('input[type="file"]');
|
||||
await input.setInputFiles(SHEET_ONLY_FIXTURE);
|
||||
// 2. The Mask state opens with the overlay visible.
|
||||
await expect(page.getByText(/mask/i)).toBeVisible();
|
||||
// 3. Approve must be disabled — the mask has no slab pixels.
|
||||
const approve = page.getByRole('button', { name: /approve/i });
|
||||
await expect(approve).toBeDisabled();
|
||||
// 4. Retake and load a photo with a real slab -> approve enables.
|
||||
await page.getByRole('button', { name: /retake/i }).tap();
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
await expect(approve).toBeEnabled();
|
||||
});
|
||||
|
||||
test('keys the green sheet and shows the 4 knobs', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
// Sheet control with Auto/Green/Black.
|
||||
const sheet = page.getByRole('radiogroup', { name: /sheet/i });
|
||||
await expect(sheet.getByRole('radio', { name: /auto/i })).toBeChecked();
|
||||
await expect(sheet.getByRole('radio', { name: /green/i })).toBeVisible();
|
||||
await expect(sheet.getByRole('radio', { name: /black/i })).toBeVisible();
|
||||
// Sensitivity slider 0–100 with minus/plus.
|
||||
const slider = page.getByRole('slider', { name: /sensitivity/i });
|
||||
await expect(slider).toBeVisible();
|
||||
await expect(slider).toHaveAttribute('min', '0');
|
||||
await expect(slider).toHaveAttribute('max', '100');
|
||||
await expect(page.getByRole('button', { name: /decrease sensitivity/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /increase sensitivity/i })).toBeVisible();
|
||||
// Edge offset and feather knobs.
|
||||
await expect(page.getByRole('slider', { name: /edge offset/i })).toBeVisible();
|
||||
await expect(page.getByRole('slider', { name: /feather/i })).toBeVisible();
|
||||
// Overlay visible (photo with checkerboard on removed px).
|
||||
await expect(page.locator('[data-testid="mask-overlay"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('sensitivity slider updates the control immediately', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
const slider = page.getByRole('slider', { name: /sensitivity/i });
|
||||
await slider.fill('80');
|
||||
// The control value is reflected immediately, not after the 2 s debounce.
|
||||
await expect(slider).toHaveValue('80');
|
||||
});
|
||||
|
||||
test('edge offset and feather update immediately', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
const edge = page.getByRole('slider', { name: /edge offset/i });
|
||||
const feather = page.getByRole('slider', { name: /feather/i });
|
||||
await edge.fill('2');
|
||||
await expect(edge).toHaveValue('2');
|
||||
await feather.fill('3');
|
||||
await expect(feather).toHaveValue('3');
|
||||
});
|
||||
|
||||
test('approve advances to Length axis after a non-empty mask', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
// Let the initial recompute land so stats are non-empty.
|
||||
await page.waitForTimeout(2200);
|
||||
await page.getByRole('button', { name: /approve/i }).tap();
|
||||
await expect(page.getByText(/length axis/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user