/** * LIVE mask verification — issue #55, Gate B. * * Tests three fundamentals against the deployed host: * 1. Overlay translucent — photo visible, not solid green washout. * 2. Knobs respond fast — debounce working, no multi-second lag. * 3. Auto/green/black sheet does not mark entire frame as slab. * * Run with: PLAYWRIGHT_BASE_URL=https://100.64.0.9 npx playwright test test/bvt/live-mask.spec.ts * Also works with: PLAYWRIGHT_BASE_URL=https://192.168.1.202 (LAN, if reachable). * * Uses ignoreHTTPSErrors for self-signed certs. */ import { test, expect } from '@playwright/test'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as fs from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEST_IMAGE = resolve(__dirname, 'test-slab.png'); const evidenceDir = resolve(__dirname, '../../test-results/live-mask-ux'); function ensureDir(dir: string) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } /** Helper: upload test image and wait for mask step. */ async function loadToMaskStep(page: import('@playwright/test').Page) { const fileInput = page.locator('#photo-file-input'); await fileInput.setInputFiles(TEST_IMAGE); await expect(page.locator('h2')).toContainText('Mask & Knobs', { timeout: 15000 }); await page.locator('.mask-canvas').waitFor({ state: 'visible', timeout: 15000 }); } test.describe('LIVE mask fundamentals (issue #55)', () => { test('1. Overlay translucent — photo visible under green mask, not solid green washout', async ({ page }) => { await page.goto('/'); await loadToMaskStep(page); // Take screenshot of the mask canvas area for evidence. ensureDir(evidenceDir); await page.locator('.mask-canvas').screenshot({ path: resolve(evidenceDir, 'mask-overlay-live.png'), }); // Read canvas pixel data to assert overlay is translucent. const stats = await page.evaluate(() => { const canvas = document.querySelector('canvas.mask-canvas') as HTMLCanvasElement; if (!canvas) return { width: 0, height: 0 }; const ctx = canvas.getContext('2d'); if (!ctx) return { width: 0, height: 0 }; const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const { data } = imgData; const len = data.length; let totalAlpha = 0; let greenPixelCount = 0; let maxAlpha = 0; for (let i = 0; i < len; i += 4) { const a = data[i + 3]; totalAlpha += a; if (a > 0) greenPixelCount++; if (a > maxAlpha) maxAlpha = a; } const avgAlpha = totalAlpha / (canvas.width * canvas.height); return { width: canvas.width, height: canvas.height, avgAlpha, maxAlpha, greenPixelCount, totalPixels: canvas.width * canvas.height, }; }); console.log(`Canvas: ${stats.width}x${stats.height}, avgAlpha=${stats.avgAlpha.toFixed(1)}, maxAlpha=${stats.maxAlpha}, greenPixels=${stats.greenPixelCount}/${stats.totalPixels} (${(stats.greenPixelCount / stats.totalPixels * 100).toFixed(1)}%)`); // Check 1 assertions: // a) Canvas has dimensions (not blank) expect(stats.width).toBeGreaterThan(0); expect(stats.height).toBeGreaterThan(0); // b) Average alpha must be well below 255 — the overlay is translucent. // Max alpha is 80 (~31% of 255), so average should be < 128 (50% opacity). expect(stats.avgAlpha).toBeLessThan(128); expect(stats.avgAlpha).toBeGreaterThan(0); // Must have some overlay at all. // c) Max alpha must be 80 or close (the fix set max alpha to 80). expect(stats.maxAlpha).toBeLessThan(170); // Well below full opacity. // d) Green pixel fraction should not be ~100% — the overlay should NOT cover // the entire canvas with green washout. There should be visible photo areas. const greenFraction = stats.greenPixelCount / stats.totalPixels; expect(greenFraction).toBeLessThan(0.95); console.log(`Green overlay fraction: ${greenFraction.toFixed(2)} (must be <0.95)`); }); test('2. Knobs respond fast on real photo — debounce working, no multi-second lag', async ({ page }) => { await page.goto('/'); await loadToMaskStep(page); // Get the sensitivity slider (first range input). const sensitivitySlider = page.locator('input[type="range"]').first(); await sensitivitySlider.waitFor({ state: 'visible', timeout: 10000 }); const initialValue = await sensitivitySlider.inputValue(); // Measure programmatic dispatch time. const startTime = Date.now(); await sensitivitySlider.evaluate((el: HTMLInputElement) => { el.value = '0.75'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }); const elapsed = Date.now() - startTime; console.log(`Sensitivity knob programmatic response: ${elapsed}ms`); // Must respond within 500ms. The debounce should cause the overlay to // redraw immediately (cheap path) and full remask after 150ms. expect(elapsed).toBeLessThan(500); const newValue = await sensitivitySlider.inputValue(); expect(newValue).not.toBe(initialValue); // Now measure the edge offset knob too. const edgeOffsetSlider = page.locator('input[type="range"]').nth(1); await edgeOffsetSlider.waitFor({ state: 'visible', timeout: 10000 }); const edgeStart = Date.now(); await edgeOffsetSlider.evaluate((el: HTMLInputElement) => { el.value = '5'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }); const edgeElapsed = Date.now() - edgeStart; console.log(`Edge offset knob programmatic response: ${edgeElapsed}ms`); expect(edgeElapsed).toBeLessThan(500); }); test('3. Auto sheet detection does not mark entire frame as slab on normal test image', async ({ page }) => { await page.goto('/'); await loadToMaskStep(page); // Read the canvas pixel data and compute slab fraction. // The canvas shows: photo + green overlay on slab pixels. // Green overlay = (60, 220, 60) with alpha proportional to mask value. // Sheet pixels have alpha=0 (fully transparent). // So: count pixels where the green channel is > 150 AND red channel < 100 // to detect the green overlay, which indicates slab pixels. const slabStats = await page.evaluate(() => { const canvas = document.querySelector('canvas.mask-canvas') as HTMLCanvasElement; if (!canvas) return { width: 0, height: 0 }; const ctx = canvas.getContext('2d'); if (!ctx) return { width: 0, height: 0 }; const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const { data } = imgData; let slabPixels = 0; let totalPixels = canvas.width * canvas.height; for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; const a = data[i + 3]; // Green overlay is (60, 220, 60). Slab pixels have alpha > 0. // At max alpha (80), the blended pixel on white (240,240,240) would be: // R: 240*(1-0.31) + 60*0.31 ≈ 184 // G: 240*(1-0.31) + 220*0.31 ≈ 234 // So slab pixels have R < 240, G > 200, B < 240, a > 0 if (a > 0 && g > 150 && r < 230 && b < 230) { slabPixels++; } } return { width: canvas.width, height: canvas.height, slabPixels, totalPixels, slabFraction: slabPixels / totalPixels, }; }); console.log(`Slab fraction: ${slabStats.slabFraction.toFixed(3)} (${slabStats.slabPixels}/${slabStats.totalPixels})`); // Check 3 assertions: // The slab fraction must be in a sane band — not ~0% and not ~100%. // The test image has green border + gray-white interior. // The auto-detection should identify the green border as sheet, // leaving the gray-white interior as slab. // So slab fraction should be roughly 20% (the interior is ~20% gray-white). // We allow a wide band: 0.05 to 0.95. expect(slabStats.slabFraction).toBeGreaterThan(0.05); expect(slabStats.slabFraction).toBeLessThan(0.95); // Also verify that green sheet detection works correctly. const greenSheetStats = await page.evaluate(() => { const canvas = document.querySelector('canvas.mask-canvas') as HTMLCanvasElement; if (!canvas) return null; // Switch to green sheet mode, wait for remask, read canvas. const select = document.querySelector('.knob-group select'); if (!select) return null; const originalVal = (select as HTMLSelectElement).value; (select as HTMLSelectElement).value = 'green'; select.dispatchEvent(new Event('change', { bubbles: true })); // Wait a moment for the remask to complete. return new Promise((resolve) => { setTimeout(() => { const ctx = canvas.getContext('2d'); if (!ctx) { resolve(null); return; } const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const { data } = imgData; let slabPixels = 0; for (let i = 0; i < data.length; i += 4) { const a = data[i + 3]; if (a > 0) slabPixels++; } resolve({ width: canvas.width, height: canvas.height, slabPixels, totalPixels: canvas.width * canvas.height, slabFraction: slabPixels / (canvas.width * canvas.height), }); // Restore original value. (select as HTMLSelectElement).value = originalVal; select.dispatchEvent(new Event('change', { bubbles: true })); }, 500); }); }); if (greenSheetStats) { console.log(`Green sheet slab fraction: ${greenSheetStats.slabFraction.toFixed(3)}`); expect(greenSheetStats.slabFraction).toBeGreaterThan(0.05); expect(greenSheetStats.slabFraction).toBeLessThan(0.95); } }); });