2586fdb135
- live-mask.spec.ts: replace green-overlay avgAlpha assertions with
checkerboard + slab pixel detection; add green-overlay color rejection
check; fix hasColorVariance false-positive with monochrome test image
- mask-ux.spec.ts: rename test to match new rendering (no green overlay)
- playwright.config.ts: set baseURL when webServer is used so page.goto('/')
resolves correctly in local dev
Fixes: issue #55 mask preview
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
});
|
|
});
|