← back to Quadrille Showroom

scripts/test-views.js

126 lines

#!/usr/bin/env node
/**
 * test-views.js — DEBUG-OURSELVES harness for the View-Mode + Theme engine.
 *
 * Cycles through ALL 10 view modes and (in Hero) all 6 themes, captures a
 * labelled screenshot of each into recordings/shots/ (view-*.png, theme-*.png),
 * samples FPS + drawCalls + triangles per mode, and records one continuous
 * .webm clip Steve can scrub to pick winners. Also runs guided Next within a
 * board-presentation mode to prove the senior nav still works per-view.
 *
 * Output:
 *   recordings/shots/view-<n>-<key>.png   (×10)
 *   recordings/shots/theme-<n>-<key>.png  (×6)
 *   recordings/test-views.webm            (scrub clip)
 *   recordings/test-views-report.json     (per-mode FPS/draws/tris, errors)
 *
 * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/test-views.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');
const SHOTS = path.join(OUT, 'shots');
fs.mkdirSync(SHOTS, { recursive: true });
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

(async () => {
  const browser = await chromium.launch({ args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'] });
  const ctx = await browser.newContext({
    viewport: { width: 1600, height: 900 },
    recordVideo: { dir: OUT, size: { width: 1600, height: 900 } },
  });
  const page = await ctx.newPage();
  const errors = [];
  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
  page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));

  // Clean slate — Hero boot, default theme.
  await page.addInitScript(() => { try { localStorage.clear(); } catch (e) {} });

  console.log('→ loading', URL);
  await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
  await page.waitForFunction(() => window._viewmode && window._wingBoards && window._wingBoards.length > 0, { timeout: 25000 }).catch(() => {});
  await sleep(4000); // loader fade + guided auto-focus

  const sample = async () => page.evaluate(() => {
    const s = window._getSceneStats();
    const fpsEl = document.getElementById('fps-counter');
    return { fps: fpsEl ? parseInt(fpsEl.textContent) : null, draws: s.drawCalls, tris: s.triangles, meshes: s.meshes };
  });

  const MODE_ORDER = await page.evaluate(() => window._viewmode.MODE_ORDER);
  const MODE_LABELS = await page.evaluate(() => Object.fromEntries(window._viewmode.MODE_ORDER.map(k => [k, window._viewmode.MODES[k].label])));
  const THEME_ORDER = await page.evaluate(() => window._viewmode.THEME_ORDER);
  const THEME_LABELS = await page.evaluate(() => Object.fromEntries(window._viewmode.THEME_ORDER.map(k => [k, window._viewmode.THEMES[k].label])));

  const report = { when: new Date().toISOString(), modes: [], themes: [], errors: [] };

  // ---- VIEW MODES ----
  console.log('→ cycling 10 view modes');
  for (let i = 0; i < MODE_ORDER.length; i++) {
    const key = MODE_ORDER[i];
    await page.evaluate(k => window._viewmode.set(k), key);
    // modes with motion (orbit/carousel/macro) get a longer settle so the shot is mid-motion
    await sleep(['orbit', 'carousel', 'macro'].includes(key) ? 2600 : 1700);
    const st = await sample();
    const shot = path.join(SHOTS, `view-${i + 1}-${key}.png`);
    await page.screenshot({ path: shot });
    report.modes.push({ n: i + 1, key, label: MODE_LABELS[key], ...st, shot: path.basename(shot) });
    console.log(`  ${i + 1}. ${MODE_LABELS[key].padEnd(14)} fps=${st.fps} draws=${st.draws} tris=${st.tris}`);
  }

  // ---- GUIDED NEXT within a board-presentation mode (Macro) — prove senior nav works ----
  console.log('→ guided Next within Macro (senior nav per-view)');
  await page.evaluate(() => window._viewmode.set('macro'));
  await sleep(1600);
  const macroBefore = await page.evaluate(() => document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : '');
  await page.click('#g-next'); await sleep(1800);
  const macroAfter = await page.evaluate(() => document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : '');
  report.guidedNextInMode = { before: macroBefore, after: macroAfter, advanced: macroBefore !== macroAfter };
  console.log(`  Macro Next: "${macroBefore}" → "${macroAfter}" advanced=${report.guidedNextInMode.advanced}`);

  // ---- THEMES (shot in Hero so the swatch + room read cleanly) ----
  console.log('→ cycling 6 themes (in Hero)');
  await page.evaluate(() => window._viewmode.set('hero'));
  await sleep(1500);
  await page.evaluate(() => window._viewmode.perf(true)); // show perf readout in theme shots
  for (let i = 0; i < THEME_ORDER.length; i++) {
    const key = THEME_ORDER[i];
    await page.evaluate(k => window._viewmode.theme(k), key);
    await sleep(1500);
    const st = await sample();
    const shot = path.join(SHOTS, `theme-${i + 1}-${key}.png`);
    await page.screenshot({ path: shot });
    report.themes.push({ n: i + 1, key, label: THEME_LABELS[key], ...st, shot: path.basename(shot) });
    console.log(`  ${i + 1}. ${THEME_LABELS[key].padEnd(16)} fps=${st.fps} draws=${st.draws}`);
  }

  // ---- spec-card integrity (China Seas) ----
  const specText = await page.evaluate(() => {
    const grid = document.getElementById('detail-spec-grid');
    if (!grid) return null;
    return [...grid.querySelectorAll('.wd-spec')].map(r => r.querySelector('dt').textContent + ': ' + r.querySelector('dd').textContent);
  });
  report.specText = specText;

  const fpsVals = [...report.modes, ...report.themes].map(m => m.fps).filter(n => n > 0);
  report.fpsMin = fpsVals.length ? Math.min(...fpsVals) : null;
  report.fpsAvg = fpsVals.length ? Math.round(fpsVals.reduce((a, b) => a + b, 0) / fpsVals.length) : null;
  report.errors = errors; report.errorCount = errors.length;

  fs.writeFileSync(path.join(OUT, 'test-views-report.json'), JSON.stringify(report, null, 2));
  console.log('\n=== VIEW-MODE REPORT ===');
  console.log('fps min/avg:', report.fpsMin, '/', report.fpsAvg, '| errors:', report.errorCount,
    '| specs:', JSON.stringify(specText));
  if (errors.length) console.log('ERRORS:', errors.slice(0, 8).join(' || '));

  await page.close();
  const vid = await page.video().path().catch(() => null);
  await ctx.close(); await browser.close();
  if (vid && fs.existsSync(vid)) { fs.renameSync(vid, path.join(OUT, 'test-views.webm')); console.log('✓ recordings/test-views.webm'); }
  console.log('✓ shots in recordings/shots/  (view-*.png ×10, theme-*.png ×6)');
})().catch(e => { console.error('VIEW TEST FAILED:', e.message); process.exit(1); });