/** * LIVE mask verification — issue #55, Gate B. * * Tests three fundamentals against the deployed host: * 1. Cutout preview — slab pixels visible, checkerboard where background removed. * 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. Cutout preview — slab pixels visible on checkerboard, no green overlay', 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: slab pixels show actual photo RGB, // background shows checkerboard (224/192 gray, 255 alpha), no green. 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; let slabPixels = 0; let checkerPixels = 0; let greenTintPixels = 0; let hasColorVariance = false; const 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]; // Checkerboard pixel: gray (224 or 192), full alpha, no color. if (a === 255 && (r === 224 || r === 192) && r === g && g === b) { checkerPixels++; } // Slab pixel: not checkerboard, alpha from mask (may vary). else if (a > 0) { slabPixels++; // Check if any slab pixel has color variance (not monochrome gray). const maxC = Math.max(r, g, b); const minC = Math.min(r, g, b); if (maxC - minC > 5) hasColorVariance = true; } // Green-tint detection: green channel significantly > red and blue. if (g > 180 && r < 100 && b < 100) { greenTintPixels++; } } return { width: canvas.width, height: canvas.height, slabPixels, checkerPixels, greenTintPixels, hasColorVariance, totalPixels, }; }); console.log(`Canvas: ${stats.width}x${stats.height}, slab=${stats.slabPixels}, checker=${stats.checkerPixels}, greenTint=${stats.greenTintPixels}`); // a) Canvas has dimensions expect(stats.width).toBeGreaterThan(0); expect(stats.height).toBeGreaterThan(0); // b) Must have checkerboard pixels (removed background) expect(stats.checkerPixels).toBeGreaterThan(0); // c) Must have slab pixels (content showing through) expect(stats.slabPixels).toBeGreaterThan(0); // d) No green tint pixels — the green overlay is gone. // This is the critical check: any (g>180, r<100, b<100) pixel means // the old green-wash rendering is still active. expect(stats.greenTintPixels).toBe(0); // e) Slab pixels must not be the old green overlay color (60,220,60). // With the fix, slab pixels are the original photo pixels, not green paint. const greenOverlayPixels = await page.evaluate(() => { const canvas = document.querySelector('canvas.mask-canvas') as HTMLCanvasElement; if (!canvas) return 0; const ctx = canvas.getContext('2d'); if (!ctx) return 0; const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const { data } = imgData; let count = 0; for (let i = 0; i < data.length; i += 4) { // Old green overlay: R~60, G~220, B~60, alpha>0 if (data[i] < 80 && data[i + 1] > 200 && data[i + 2] < 80 && data[i + 3] > 0) { count++; } } return count; }); expect(greenOverlayPixels).toBe(0); }); 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. // Canvas now shows: slab pixels (actual photo RGB, mask alpha) + // checkerboard (224/192 gray, 255 alpha) where background was removed. // Slab pixels are any non-checkerboard pixels with alpha > 0. 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]; // Checkerboard: r==g==b and r is 224 or 192, alpha 255. const isChecker = a === 255 && r === g && g === b && (r === 224 || r === 192); if (!isChecker && a > 0) { 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 r = data[i]; const g = data[i + 1]; const b = data[i + 2]; const a = data[i + 3]; // Checkerboard: r==g==b and r is 224 or 192, alpha 255. const isChecker = a === 255 && r === g && g === b && (r === 224 || r === 192); if (!isChecker && 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); } }); });