← 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); });