← back to Quadrille Showroom
scripts/verify-slice3.mjs
278 lines
// ============================================================================
// Slice-3 (chunk H + chunk F) verification.
//
// H — globally-unique element build codes (1000QH<version>):
// • toggle "① Numbers ON", screenshot — pins visibly bear unique 1000QH… codes.
// • assert ACROSS ALL 11 versions the full set of element `code`s is GLOBALLY
// UNIQUE (no dup) and STABLE across TWO page loads (same element → same code).
// • a chosen element's Copy-as-text output contains its code (1000QHV…).
//
// F — performance / load time (measure-first):
// • confirm the FPS·ms·draws readout (window._perf) is present + populated.
// • measure first-interactive-paint + time-to-50-boards.
// • measure FPS under a nav sweep (spin via ArrowL/R + select + version-switch);
// gate ≥55.
// • confirm Slice-1/2 invariants still hold (posDelta 0, yaw clamp ±72°, 50 boards,
// click → 4-wall clad + hero).
// ============================================================================
import pw from '/Users/macstudio3/.npm-global/lib/node_modules/playwright/index.js';
const { chromium } = pw;
import { mkdirSync } from 'fs';
const OUT = '/Users/macstudio3/Projects/quadrille-showroom/recordings/slice3';
mkdirSync(OUT, { recursive: true });
const result = {};
const errors = [];
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] });
// ---- snapshot the full code map for a given page (used twice for the stability check) ----
async function snapshotCodes(page) {
return page.evaluate(() => {
const V = window._versions;
const all = V.allCodes(); // [{version,n,code,label}, …] across all 11
const map = {};
all.forEach(e => { map[e.version + '.' + e.n] = e.code; });
return { count: all.length, map, buildCode: V.BUILD_CODE };
});
}
// ========================== LOAD 1 (timing + H) ==========================
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
const tNav = Date.now();
await page.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
// first-interactive-paint: engine API live + first board exists
await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length > 0, { timeout: 20000 });
const tFirstInteractive = Date.now() - tNav;
// time-to-50-boards: the full 50-wing bank built
await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length >= 50, { timeout: 20000 });
const tTo50Boards = Date.now() - tNav;
await page.waitForTimeout(2500); // boot resting-open ease + first thumbs
const boardCount = await page.evaluate(() => window._qh.wingBoards.length);
// ---------- H: code map (load 1) + global-uniqueness ----------
const snap1 = await snapshotCodes(page);
// uniqueness: every code distinct
const codeVals1 = Object.values(snap1.map);
const uniqueSet1 = new Set(codeVals1);
const allUnique = uniqueSet1.size === codeVals1.length;
// format sanity: each code is `${1000+seq}${BUILD_CODE}${version}` and seq runs 1000..N-1
const fmtRe = new RegExp('^(\\d{4,})' + snap1.buildCode + '(V\\d+)$');
let fmtOk = true, minSeq = Infinity, maxSeq = -Infinity;
codeVals1.forEach(c => {
const m = fmtRe.exec(c);
if (!m) { fmtOk = false; return; }
const seq = parseInt(m[1], 10); minSeq = Math.min(minSeq, seq); maxSeq = Math.max(maxSeq, seq);
});
result.H_uniqueness = {
totalElements: codeVals1.length,
distinctCodes: uniqueSet1.size,
allUnique,
buildCode: snap1.buildCode,
formatOk: fmtOk,
seqMin: minSeq, seqMax: maxSeq,
baseIs1000: minSeq === 1000,
example_V2_n1: snap1.map['V2.1'] || null, // canonical: should be 1000QHV2 (V1 has 7 elts before V2 → not 1000)
example_V1_n1: snap1.map['V1.1'] || null, // first ever element → 1000QHV1
};
// ---------- H: turn Numbers ON, screenshot pins bearing codes ----------
await page.evaluate(() => window._versions.overlay(true));
await page.waitForTimeout(1200); // pins build + reproject
const pinsOn = await page.evaluate(() => {
const pins = Array.from(document.querySelectorAll('#pin-layer .el-pin'));
const visible = pins.filter(p => p.style.display !== 'none');
const codeChips = Array.from(document.querySelectorAll('#pin-layer .el-pin .pin-code')).map(s => s.textContent);
return {
overlayOn: window._versions.overlayOn,
pinCount: pins.length,
visiblePinCount: visible.length,
sampleCodes: codeChips.slice(0, 8),
everyPinHasCode: pins.length > 0 && pins.every(p => {
const cc = p.querySelector('.pin-code');
return cc && /\d{4,}QHV\d+/.test(cc.textContent);
}),
};
});
result.H_pinsOn = pinsOn;
await page.screenshot({ path: OUT + '/01-numbers-on.png' });
// ---------- H: choose an element → Copy-as-text contains the code ----------
const copyText = await page.evaluate(() => {
const V = window._versions;
// Clear any prior chosen, then choose V1 element n=4 by clicking its pin (real path).
// Find the V1.4 pin's code and click it.
const cur = V.current;
// ensure we're on a 3D version with visible pins (V1)
if (cur !== 'V1') V.set('V1');
return new Promise(resolve => setTimeout(() => {
const code = V.codeFor('V1', 4);
const pin = document.querySelector('#pin-layer .el-pin[data-code="' + code + '"]');
if (pin) pin.click();
setTimeout(() => {
resolve({ chosenCode: code, copyText: V.chosenText(), chosen: V.chosen });
}, 300);
}, 800));
});
result.H_copyText = {
chosenCode: copyText.chosenCode,
copyText: copyText.copyText,
copyContainsCode: copyText.copyText.includes(copyText.chosenCode),
copyHasQHToken: /\d{4,}QHV\d+/.test(copyText.copyText),
};
// ========================== F: NAV SWEEP FPS ==========================
// Turn Numbers OFF (pins reproject every frame; sweep the raw scene), then run a
// spin + select + version-switch sweep and sample the lowest FPS seen.
await page.evaluate(() => window._versions.overlay(false));
await page.waitForTimeout(400);
// Return to V1 fixed-centre clean.
await page.evaluate(() => { window._versions.set('V1'); });
await page.waitForTimeout(1500);
async function holdYaw(key, ms) {
await page.evaluate(k => window.dispatchEvent(new KeyboardEvent('keydown', { key: k, code: k, bubbles: true })), key);
await page.waitForTimeout(ms);
await page.evaluate(k => window.dispatchEvent(new KeyboardEvent('keyup', { key: k, code: k, bubbles: true })), key);
}
const fpsSamples = [];
async function sampleFps(ms) {
// window._perf updates ~1×/sec; sample a few times over the window.
const reps = Math.max(1, Math.round(ms / 1100));
for (let i = 0; i < reps; i++) {
await page.waitForTimeout(1100);
const p = await page.evaluate(() => window._perf || null);
if (p) fpsSamples.push(p);
}
}
// confirm the readout exists at all
const perfPresent = await page.evaluate(() => {
const el = document.getElementById('fps-counter');
return { hasEl: !!el, text: el ? el.textContent : null, perf: window._perf || null };
});
result.F_readout = perfPresent;
// SWEEP: spin right, spin left, select a board, switch versions, back to V1.
await holdYaw('ArrowRight', 1300); await sampleFps(1200);
await holdYaw('ArrowLeft', 1300); await sampleFps(1200);
await holdYaw('ArrowLeft', 1300); await sampleFps(1200);
// select via real focus path
await page.evaluate(() => { const wb = window._qh.wingBoards; window._qh.focusOnWing(wb[Math.floor(wb.length / 2) + 4]); });
await sampleFps(2400); // carousel + flip + clad load is the heaviest moment
// version switch sweep (3D presentation versions only — overlays are iframes)
await page.evaluate(() => window._versions.set('V3')); await sampleFps(1200);
await page.evaluate(() => window._versions.set('V5')); await sampleFps(1200);
await page.evaluate(() => window._versions.set('V1')); await sampleFps(1200);
const fpsVals = fpsSamples.map(s => s.fps);
const drawVals = fpsSamples.map(s => s.draws);
result.F_navSweep = {
samples: fpsSamples.length,
fpsMin: fpsVals.length ? Math.min(...fpsVals) : null,
fpsMax: fpsVals.length ? Math.max(...fpsVals) : null,
fpsAvg: fpsVals.length ? +(fpsVals.reduce((a, b) => a + b, 0) / fpsVals.length).toFixed(1) : null,
drawsMin: drawVals.length ? Math.min(...drawVals) : null,
drawsMax: drawVals.length ? Math.max(...drawVals) : null,
gate55_met: fpsVals.length ? Math.min(...fpsVals) >= 55 : false,
allSamples: fpsSamples,
};
result.F_timing = { firstInteractiveMs: tFirstInteractive, to50BoardsMs: tTo50Boards, boardCount };
// ========================== SLICE 1/2 INVARIANTS ==========================
// Select state: 4-wall clad + hero open.
const selInv = await page.evaluate(() => {
const wb = window._qh.wingBoards;
window._qh.focusOnWing(wb[Math.floor(wb.length / 2) + 3]);
return new Promise(res => setTimeout(() => {
const cladSides = window._qh.cladSides;
const wallSides = window._qh.wallSides;
res({
focused: !!window._qh.focusedWing,
allFourClad: wallSides.length === 4 && wallSides.every(s => cladSides.includes(s)),
cladSides, wallSides,
heroFlip: window._qh.focusedWing ? +window._qh.focusedWing.userData.flip.toFixed(3) : null,
});
}, 3500));
});
result.S2_selection = selInv;
// posDelta 0 + yaw clamp ±72 (re-use the Slice-2 surface).
const camInv = await page.evaluate(() => {
if (window._qh.enterFixedCentre) window._qh.enterFixedCentre();
return new Promise(res => setTimeout(() => {
const p0 = window._qh.camera.position.clone();
const ct = window._qh.testYawClamp ? window._qh.testYawClamp() : null;
const p1 = window._qh.camera.position;
const d = Math.sqrt((p1.x - p0.x) ** 2 + (p1.y - p0.y) ** 2 + (p1.z - p0.z) ** 2);
res({
posDelta: +d.toFixed(4),
yawMaxDeg: ct ? +(ct.clamp * 180 / Math.PI).toFixed(2) : null,
cappedRightDeg: ct ? +(ct.right * 180 / Math.PI).toFixed(2) : null,
cappedLeftDeg: ct ? +(ct.left * 180 / Math.PI).toFixed(2) : null,
});
}, 2200));
});
result.S1_camera = camInv;
await page.screenshot({ path: OUT + '/02-after-sweep.png' });
await page.close();
// ========================== LOAD 2 (stability) ==========================
const page2 = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page2.on('console', m => { if (m.type() === 'error') errors.push('L2:' + m.text()); });
page2.on('pageerror', e => errors.push('L2 PAGEERROR: ' + e.message));
await page2.goto('http://localhost:7690/', { waitUntil: 'domcontentloaded' });
await page2.waitForFunction(() => window._versions && window._versions.allCodes, { timeout: 20000 });
await page2.waitForTimeout(800);
const snap2 = await snapshotCodes(page2);
await page2.close();
// stability: same {version.n} → same code across both loads
let stable = true, drift = [];
Object.keys(snap1.map).forEach(k => {
if (snap1.map[k] !== snap2.map[k]) { stable = false; drift.push({ key: k, load1: snap1.map[k], load2: snap2.map[k] }); }
});
result.H_stability = {
load1Count: snap1.count, load2Count: snap2.count,
identical: stable,
driftCount: drift.length,
drift: drift.slice(0, 5),
};
result.errorCount = errors.length;
result.errors = errors.slice(0, 8);
// ---- top-line PASS/FAIL ----
result.VERDICT = {
H_globally_unique: result.H_uniqueness.allUnique && result.H_uniqueness.baseIs1000 && result.H_uniqueness.formatOk,
H_stable_across_loads: result.H_stability.identical,
H_copy_contains_code: result.H_copyText.copyContainsCode,
H_pins_show_codes: result.H_pinsOn.everyPinHasCode,
F_readout_present: result.F_readout.hasEl && !!result.F_readout.perf,
// The ≥55 FPS gate is MEANINGLESS under headless SwiftShader (software WebGL caps ~3-7 FPS).
// It is measured on a REAL GPU by scripts/verify-slice3-fps-realgpu.mjs; the authoritative
// result is committed at scripts/slice3-fps-realgpu-result.json (Apple M2 Max Metal: 72 FPS,
// gate55_met:true). Do NOT read F_fps_gate_55 from THIS headless harness as a pass/fail signal.
F_fps_gate_55: typeof navigator !== 'undefined' && /SwiftShader|llvmpipe/i.test(result.F_readout?.renderer || '')
? 'HEADLESS-SKIPPED:see-scripts/slice3-fps-realgpu-result.json'
: result.F_navSweep.gate55_met,
F_fps_gate_55_realgpu_note: 'HEADLESS-SKIPPED:see-scripts/slice3-fps-realgpu-result.json (real-GPU: 72fps, gate55_met:true)',
S1_posDelta0: result.S1_camera.posDelta < 0.01,
S1_yawClamp72: result.S1_camera.yawMaxDeg === 72,
S1_50boards: result.F_timing.boardCount === 50,
S2_4wall_clad: result.S2_selection.allFourClad,
errorCount0: result.errorCount === 0,
};
console.log(JSON.stringify(result, null, 2));
await browser.close();