← back to Quadrille Showroom

scripts/verify-slice2-spec-legal.mjs

193 lines

// ============================================================
// SPEC-LEGAL Slice-2 proof — regenerates the 4 contrarian screenshots using ONLY
// the real fixed-centre yaw-spin interaction. NO setMode('room'), NO park(), NO
// view-mode change, NO camera translation. Every "spin" is a real ArrowLeft/
// ArrowRight/ArrowUp keydown dispatched on window, eased by the app's own
// updateWASD() at the ±72° clamp. Click-to-select goes through window._qh.focusOnWing
// (identical payload to onCanvasClick).
//
// Asserts:
//   • boot: no selection, no detail panel (Slice-1 intact)
//   • selected: hero board dead-front (focusedIdx == clicked), flip >= 0.9 (dramatic
//     open), hero on-screen width >= 3x its closed sliver neighbour, detail card
//     COLLAPSED, all 4 wall SIDES clad, clad front-wall pattern visible around the bank
//   • detail-expanded: chevron toggle reveals 27" China Seas specs
//   • spin-to-clad-side-wall: real ArrowRight yaw to the clamp shows a clad SIDE wall
//     (raycast confirms a large block of clad-wall pixels on screen) — REAL yaw, no mode
//   • HOLE-3 determinism: with localStorage qh_detail_collapsed='0' (stored "expanded"),
//     a FRESH selection STILL starts collapsed
//   • camera posDelta 0 across the whole spin; yaw clamp ±72°; errorCount 0
// ============================================================
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/slice2-spec-legal';
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'] });
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));

// HOLE-3 hostile setup: pre-seed localStorage with a stored EXPANDED flag so we prove a
// fresh selection ignores it and still starts collapsed.
await page.addInitScript(() => { try { localStorage.setItem('qh_detail_collapsed','0'); } catch(e){} });

await page.goto('http://localhost:7690/', { waitUntil: 'networkidle' });
await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length>0, { timeout:20000 });
await page.waitForTimeout(2500);

// raycast sampler for clad-wall on-screen pixels (40x24 grid). Counts screen cells whose
// camera ray first hits a large room-wall plane carrying a clad texture map.
await page.addScriptTag({ content: `
window.__measureClad = function() {
  const qh=window._qh, T=qh.THREE, cam=qh.camera, scene=qh.scene;
  const rc=new T.Raycaster(); const cladMeshes=[]; const targets=[];
  scene.traverse(o=>{ if(o.isMesh){ targets.push(o);
    if(o.material&&o.material.map&&o.geometry&&o.geometry.type==='PlaneGeometry'){
      const g=o.geometry.parameters||{}; if(g.width>=1.0&&g.height>=2.0) cladMeshes.push(o); } } });
  let cladHits=0,wingHits=0,other=0,total=0;
  for(let yi=0;yi<24;yi++)for(let xi=0;xi<40;xi++){
    const nx=(xi+0.5)/40*2-1, ny=-((yi+0.5)/24*2-1);
    rc.setFromCamera({x:nx,y:ny},cam); const h=rc.intersectObjects(targets,false)[0];
    total++; if(!h)continue;
    if(cladMeshes.includes(h.object))cladHits++;
    else if(h.object.userData&&h.object.userData.isWingFace)wingHits++; else other++;
  }
  return { cladHits,wingHits,other,total,yawDeg:+(qh.spinYaw*180/Math.PI).toFixed(1) };
};
window.__heroWidth = function() {
  const qh=window._qh,T=qh.THREE,cam=qh.camera,f=qh.focusedWing; if(!f)return null;
  const wb=qh.wingBoards;
  function wpx(p){ const ud=p.userData,face=ud.face; if(!face)return 0;
    const g=face.geometry.parameters,hw=g.width/2,hh=g.height/2;
    let mn=1e9,mx=-1e9;
    for(const[cx,cy]of[[-hw,-hh],[hw,-hh],[hw,hh],[-hw,hh]]){
      const v=new T.Vector3(cx,cy,0); face.localToWorld(v); v.project(cam);
      const px=(v.x*0.5+0.5)*window.innerWidth; mn=Math.min(mn,px);mx=Math.max(mx,px);} return mx-mn; }
  const hi=f.userData.index; const nb=wb[hi+3]||wb[hi-3]||wb[hi+1]||wb[hi-1];
  const heroW=wpx(f), nbW=nb?wpx(nb):0;
  return { heroW:+heroW.toFixed(1), nbW:+nbW.toFixed(1), ratio:+(heroW/(nbW||1)).toFixed(2), flip:+f.userData.flip.toFixed(3), idx:hi };
};
`});

// helper: real held-key yaw (dispatch keydown, wait, keyup). updateWASD eases each frame.
async function holdKey(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);
  await page.waitForTimeout(450);
}

// ---------- 1. BOOT ----------
const boot = await page.evaluate(() => ({
  focused: !!window._qh.focusedWing,
  resting: !!window._qh.restingOpenWing,
  detailHidden: document.getElementById('wing-detail').classList.contains('hidden'),
  camPos: [window._cam.position.x, window._cam.position.y, window._cam.position.z],
}));
result.boot = boot;
const bootCam = boot.camPos;
await page.screenshot({ path: OUT + '/01-boot.png' });

// ---------- 2. SELECTED (real focus path) ----------
const pickIdx = await page.evaluate(() => {
  const wb = window._qh.wingBoards;
  const idx = Math.floor(wb.length/2) + 4;     // a clearly off-middle board
  window.__pick = idx; window._qh.focusOnWing(wb[idx]); return idx;
});
await page.waitForTimeout(4800);   // carousel settle + flip + clad-image onload
const selected = await page.evaluate(() => {
  const panel = document.getElementById('wing-detail');
  const cladSides = window._qh.cladSides, wallSides = window._qh.wallSides;
  return {
    focusedIdx: window._qh.focusedWing ? window._qh.focusedWing.userData.index : null,
    pick: window.__pick,
    deadFront: window._qh.focusedWing && window._qh.focusedWing.userData.index === window.__pick,
    detailHidden: panel.classList.contains('hidden'),
    detailCollapsed: panel.classList.contains('collapsed'),
    cladSides, wallSides,
    allFourClad: wallSides.length===4 && wallSides.every(s=>cladSides.includes(s)),
    carouselActive: window._qh.carouselActive,
  };
});
selected.hero = await page.evaluate(() => window.__heroWidth());
selected.cladFrontView = await page.evaluate(() => window.__measureClad());
result.selected = selected;
await page.screenshot({ path: OUT + '/02-selected-front.png' });

// ---------- 3. DETAIL EXPANDED (real chevron handler) ----------
await page.evaluate(() => document.getElementById('detail-collapse').click());
await page.waitForTimeout(600);
const expanded = await page.evaluate(() => {
  const panel = document.getElementById('wing-detail');
  const grid = document.getElementById('detail-spec-grid');
  const specText = grid ? grid.textContent.replace(/\s+/g,' ').trim() : '';
  return {
    detailCollapsed: panel.classList.contains('collapsed'),
    pattern: (document.getElementById('detail-pattern')||{}).textContent||'',
    vendor: (document.getElementById('detail-vendor')||{}).textContent||'',
    specText, has27: specText.includes('27'),
  };
});
result.detailExpanded = expanded;
await page.screenshot({ path: OUT + '/03-detail-expanded.png' });
// re-collapse so the spin screenshot is clean
await page.evaluate(() => { const p=document.getElementById('wing-detail'); if(!p.classList.contains('collapsed')) document.getElementById('detail-collapse').click(); });
await page.waitForTimeout(300);

// ---------- 4. SPIN-TO-CLAD-SIDE-WALL (REAL ArrowRight yaw to clamp) ----------
// Hold ArrowRight long enough to ride the ease to the +72 clamp. Measure clad wall pixels
// + assert the camera position did NOT move (posDelta 0 vs boot). NO mode switch.
await holdKey('ArrowRight', 1700);
await holdKey('ArrowRight', 1200);   // top up to clamp
const spinRight = await page.evaluate(() => window.__measureClad());
const camAfterSpin = await page.evaluate(() => [window._cam.position.x, window._cam.position.y, window._cam.position.z]);
result.spinRight = spinRight;
await page.screenshot({ path: OUT + '/04-spin-to-clad-side-wall.png' });

// also sample the left clamp for the record (no extra screenshot needed)
await holdKey('ArrowLeft', 1700);
await holdKey('ArrowLeft', 1700);
await holdKey('ArrowLeft', 1200);
const spinLeft = await page.evaluate(() => window.__measureClad());
result.spinLeft = spinLeft;

// ---------- CAMERA + YAW-CLAMP ASSERTIONS ----------
const dx = camAfterSpin[0]-bootCam[0], dy = camAfterSpin[1]-bootCam[1], dz = camAfterSpin[2]-bootCam[2];
result.posDelta = +Math.sqrt(dx*dx+dy*dy+dz*dz).toFixed(5);
const clamp = await page.evaluate(() => {
  const t = window._qh.testYawClamp();
  return { rightDeg:+(t.right*180/Math.PI).toFixed(2), leftDeg:+(t.left*180/Math.PI).toFixed(2), clampDeg:+(t.clamp*180/Math.PI).toFixed(2) };
});
result.yawClamp = clamp;
result.errorCount = errors.length;
result.errors = errors;

// ---------- VERDICT ----------
const A = [];
A.push(['boot: no selection', boot.focused===false]);
A.push(['boot: no detail panel', boot.detailHidden===true]);
A.push(['selected: dead-front == clicked', selected.deadFront===true]);
A.push(['selected: hero flip >= 0.9', selected.hero && selected.hero.flip>=0.9]);
A.push(['selected: hero >= 3x neighbour width', selected.hero && selected.hero.ratio>=3]);
A.push(['selected: detail COLLAPSED (HOLE-3, stored=expanded ignored)', selected.detailCollapsed===true]);
A.push(['selected: all 4 walls clad', selected.allFourClad===true]);
A.push(['selected: clad front-wall visible around bank (>=80 cells)', selected.cladFrontView.cladHits>=80]);
A.push(['expanded: shows 27" specs', expanded.has27===true]);
A.push(['expanded: card now open', expanded.detailCollapsed===false]);
A.push(['spin-right: real yaw reached side (>=40 deg)', Math.abs(spinRight.yawDeg)>=40]);
A.push(['spin-right: clad SIDE wall visible (>=150 cells)', spinRight.cladHits>=150]);
A.push(['spin-left: clad SIDE wall visible (>=150 cells)', spinLeft.cladHits>=150]);
A.push(['camera posDelta ~0', result.posDelta<=0.01]);
A.push(['yaw clamp == +/-72', clamp.clampDeg===72 && clamp.rightDeg===72 && clamp.leftDeg===-72]);
A.push(['errorCount 0', errors.length===0]);
result.assertions = A.map(([n,p])=>({ name:n, pass:!!p }));
result.allPass = A.every(([,p])=>p);

console.log(JSON.stringify(result, null, 2));
await browser.close();
process.exit(result.allPass ? 0 : 1);