← back to Quadrille Showroom
scripts/verify-slice1.js
203 lines
#!/usr/bin/env node
/**
* verify-slice1.js — Slice-1 (chunks A+B+C) acceptance check for the Quadrille showroom.
* Confirms:
* 1. camera POSITION is constant across an arrow-spin sweep (yaw, not walk)
* 2. yaw is CLAMPED (cannot face behind the wings)
* 3. ~50 wings load with visible slivers (all visible)
* 4. the MIDDLE board is open-at-angle on boot with NO detail panel shown
* 5. errorCount === 0 in console
* Captures a verification screenshot of the fixed-centre boot view.
*
* Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/verify-slice1.js
*/
const path = require('path');
const fs = require('fs');
const { chromium } = require('playwright');
const URL = process.env.SHOWROOM_URL || 'http://127.0.0.1:7690/';
const OUT = path.join(__dirname, '..', 'recordings', 'slice1');
fs.mkdirSync(OUT, { recursive: true });
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const round3 = v => Math.round(v * 1000) / 1000;
async function holdKey(page, key, ms) {
await page.keyboard.down(key); await sleep(ms); await page.keyboard.up(key);
}
function camState(page) {
return page.evaluate(() => {
const c = window._qh.camera, t = window._qh.controls.target;
return { px: c.position.x, py: c.position.y, pz: c.position.z,
tx: t.x, ty: t.y, tz: t.z };
});
}
(async () => {
const errors = [];
const browser = await chromium.launch({ args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'] });
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const page = await ctx.newPage();
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
console.log('→ loading', URL);
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length > 0, { timeout: 25000 });
await sleep(3500); // loader fade + guided boot (~700ms) + flip ease + first textures
const results = {};
// ---- 3. wing count ----
const wingCount = await page.evaluate(() => window._qh.wingBoards.length);
results.wingCount = wingCount;
results.wingCountPass = (wingCount === 50);
// ---- (3b) all wings visible (slivers not hidden) ----
const visibleCount = await page.evaluate(() => window._qh.wingBoards.filter(b => b.visible).length);
results.visibleCount = visibleCount;
results.allVisiblePass = (visibleCount === wingCount);
// ---- 4. middle open-at-angle, NO detail panel, NOTHING selected ----
const boot = await page.evaluate(() => {
const wb = window._qh.wingBoards;
const mid = Math.floor(wb.length / 2);
const ro = window._qh.restingOpenWing;
const detail = document.getElementById('wing-detail');
return {
restingIsMiddle: !!ro && ro === wb[mid],
midFlip: wb[mid] ? wb[mid].userData.flip : -1,
midPanelClosed: wb[mid] ? !!wb[mid].userData.panelClosed : null,
focusedWing: !!window._qh.focusedWing,
detailHidden: detail ? detail.classList.contains('hidden') : true,
};
});
results.boot = boot;
// open-at-angle: flip between ~0.2 and ~0.95 (partial), middle is resting-open, nothing focused, detail hidden
results.middleOpenPass = boot.restingIsMiddle && boot.midFlip > 0.2 && boot.midFlip < 0.97
&& !boot.focusedWing && boot.detailHidden && !boot.midPanelClosed;
// screenshot of the boot view (fixed-centre, middle open amid slivers, no panel)
await page.screenshot({ path: path.join(OUT, 'boot-fixed-centre.png') });
// ---- 1. camera POSITION constant across an arrow-spin sweep ----
const before = await camState(page);
await holdKey(page, 'ArrowRight', 900); // yaw right
await sleep(400);
const afterRight = await camState(page);
await holdKey(page, 'ArrowLeft', 1700); // yaw back across to the left
await sleep(400);
const afterLeft = await camState(page);
const posDelta = Math.max(
Math.abs(before.px - afterRight.px), Math.abs(before.py - afterRight.py), Math.abs(before.pz - afterRight.pz),
Math.abs(before.px - afterLeft.px), Math.abs(before.py - afterLeft.py), Math.abs(before.pz - afterLeft.pz)
);
// target DID rotate (proves the spin worked, not a no-op)
const tgtDelta = Math.max(Math.abs(before.tx - afterRight.tx), Math.abs(before.tz - afterRight.tz));
results.camPos = { before: { px: round3(before.px), py: round3(before.py), pz: round3(before.pz) },
afterRight: { px: round3(afterRight.px), pz: round3(afterRight.pz) },
afterLeft: { px: round3(afterLeft.px), pz: round3(afterLeft.pz) } };
results.posDelta = round3(posDelta);
results.tgtDelta = round3(tgtDelta);
results.fixedCentrePass = (posDelta < 0.001) && (tgtDelta > 0.05);
await page.screenshot({ path: path.join(OUT, 'after-spin-right-then-left.png') });
// ---- 2. yaw CLAMPED — hold right a long time, then read spin yaw + verify never faces behind ----
await holdKey(page, 'ArrowRight', 4000); // try to over-spin
await sleep(300);
// At the clamp, count how many wing boards are still inside the camera frustum — the
// FIX-1 acceptance: the clamp LIMITS the view, it must NOT empty it.
// Count boards whose body is in frame. We sample BOTH the hinge (getWorldPosition) AND
// the board-centre offset (+halfWidth along its facing) and take the min |x|, because at
// the clamp the fanned boards fill half the frame even when a hinge point projects to the
// screen edge. A board counts if its nearest sampled point is within an extended NDC band.
const boardsOnScreen = (label) => page.evaluate(() => {
const THREE = window._qh.THREE; const cam = window._qh.camera;
const halfW = (window._qh.CONFIG.wing.boardWidthM || 0.762) / 2;
cam.updateMatrixWorld(); cam.updateProjectionMatrix();
const hinge = new THREE.Vector3(), ctr = new THREE.Vector3(); let n = 0;
window._qh.wingBoards.forEach(b => {
b.getWorldPosition(hinge);
// board centre ≈ hinge + halfW along the board's local +X (rotated by pivot yaw)
const yaw = b.rotation.y;
ctr.set(hinge.x + Math.cos(yaw) * halfW, hinge.y, hinge.z - Math.sin(yaw) * halfW);
const ph = hinge.clone().project(cam), pc = ctr.clone().project(cam);
const inFront = ph.z < 1 || pc.z < 1;
const minX = Math.min(Math.abs(ph.x), Math.abs(pc.x));
const minY = Math.min(Math.abs(ph.y), Math.abs(pc.y));
if (inFront && minX <= 1.0 && minY <= 1.2) n++;
});
return n;
});
const clampR = await page.evaluate(() => {
// facing direction: camera→target, projected to XZ. Behind-the-wings = +z forward.
const c = window._qh.camera, t = window._qh.controls.target;
const dx = t.x - c.position.x, dz = t.z - c.position.z;
// angle off the wing-facing (-z) axis, in degrees, signed
const yawDeg = Math.atan2(dx, -dz) * 180 / Math.PI;
// "behind the wings" = yaw turned past the side walls toward the room's rear. The
// tightened ±72° clamp keeps the leftmost/rightmost boards + side wall in frame and
// never empties the room; only |yaw|>80° would count as over-spun past the side walls.
return { yawDeg, dz, facesBehind: Math.abs(yawDeg) > 80 };
});
results.boardsOnScreenRight = await boardsOnScreen('right');
await page.screenshot({ path: path.join(OUT, 'clamp-right.png') });
await holdKey(page, 'ArrowLeft', 8000); // over-spin the other way
await sleep(300);
const clampL = await page.evaluate(() => {
const c = window._qh.camera, t = window._qh.controls.target;
const dx = t.x - c.position.x, dz = t.z - c.position.z;
const yawDeg = Math.atan2(dx, -dz) * 180 / Math.PI;
return { yawDeg, dz, facesBehind: Math.abs(yawDeg) > 80 };
});
results.boardsOnScreenLeft = await boardsOnScreen('left');
await page.screenshot({ path: path.join(OUT, 'clamp-left.png') });
results.clamp = { right: { yawDeg: round3(clampR.yawDeg), facesBehind: clampR.facesBehind },
left: { yawDeg: round3(clampL.yawDeg), facesBehind: clampL.facesBehind } };
// clamp pass: never faces behind, |yaw| stays within ~73° (72° clamp + tolerance), AND
// boards remain in frame at BOTH clamps (the FIX-1 non-empty-room gate).
results.clampPass = !clampR.facesBehind && !clampL.facesBehind
&& Math.abs(clampR.yawDeg) <= 73 && Math.abs(clampL.yawDeg) <= 73
&& results.boardsOnScreenRight >= 3 && results.boardsOnScreenLeft >= 3;
// ---- (2c) SPIN-TO-TABLE: yaw-right + pitch-down frames the consultation/sample table ----
// Reset to centre by over-spinning left was last; now yaw partway right + look down so the
// sample nook at ~(1.25, -0.2, -0.55) [yaw +66°, below eye] is framed. Proves the tightened
// clamp still REACHES the table (FIX-1's second half) — not just that it avoids the void.
await holdKey(page, 'ArrowRight', 1900); // partial yaw right toward the nook (~+66°)
await holdKey(page, 'ArrowDown', 1400); // pitch down toward the table top
await sleep(400);
const tableView = await page.evaluate(() => {
const c = window._qh.camera, t = window._qh.controls.target;
const dx = t.x - c.position.x, dz = t.z - c.position.z;
const yawDeg = Math.atan2(dx, -dz) * 180 / Math.PI;
const pitchDeg = Math.atan2(t.y - c.position.y, Math.hypot(dx, dz)) * 180 / Math.PI;
return { yawDeg, pitchDeg };
});
results.tableView = { yawDeg: round3(tableView.yawDeg), pitchDeg: round3(tableView.pitchDeg) };
await page.screenshot({ path: path.join(OUT, 'spin-to-table.png') });
// ---- 5. errorCount ----
results.errorCount = errors.length;
results.errors = errors.slice(0, 10);
results.errorPass = (errors.length === 0);
// ---- scene stats / FPS ----
results.sceneStats = await page.evaluate(() => window._getSceneStats());
// rough FPS sample
const fps = await page.evaluate(() => new Promise(res => {
let n = 0; const t0 = performance.now();
function tick() { n++; if (performance.now() - t0 < 1000) requestAnimationFrame(tick); else res(n); }
requestAnimationFrame(tick);
}));
results.fps = fps;
const pass = results.wingCountPass && results.allVisiblePass && results.middleOpenPass
&& results.fixedCentrePass && results.clampPass && results.errorPass;
results.OVERALL = pass ? 'PASS' : 'FAIL';
console.log(JSON.stringify(results, null, 2));
fs.writeFileSync(path.join(OUT, 'verify-slice1.json'), JSON.stringify(results, null, 2));
await browser.close();
process.exit(pass ? 0 : 1);
})().catch(e => { console.error('FATAL', e); process.exit(2); });