88b9c47200
Spec CAPTURE.md §Mask lists exactly two controls: Sheet and Sensitivity. Edge offset and feather nudge buttons were extra. Removed from the UI; core params (edgeOffset, feather) remain in maskState.js, still unit-tested.
94 lines
4.5 KiB
JavaScript
94 lines
4.5 KiB
JavaScript
import { test, expect } from '@playwright/test';
|
||
|
||
// 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."
|
||
// - 2 knobs: sheet Auto/Green/Black, sensitivity 0–100 (slider + minus/plus).
|
||
// Edge offset and feather are NOT user-exposed in M1 (core params only).
|
||
// - 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('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.
|
||
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);
|
||
// 4. Tapping it returns to Add photo.
|
||
await notice.tap();
|
||
await expect(page.getByRole('button', { name: /add photo/i })).toBeVisible();
|
||
});
|
||
|
||
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('/');
|
||
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 2 spec 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 are NOT in the spec — they must not be
|
||
// user-exposed in the Mask UI.
|
||
await expect(page.getByRole('slider', { name: /edge offset/i })).not.toBeVisible();
|
||
await expect(page.getByRole('slider', { name: /feather/i })).not.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('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();
|
||
});
|
||
});
|