← back to Quadrille Showroom

scripts/test-senior.js

176 lines

#!/usr/bin/env node
/**
 * test-senior.js — SENIOR-STYLE walkthrough. Drives ONLY the big on-screen guided
 * buttons (Previous / Next / Auto Tour / All Designs) with the MOUSE. NO keyboard,
 * NO 3D walking — proves a 65-year-old can browse the whole collection hands-on-mouse.
 *
 * Verifies: guided mode boots focused on a board; Next/Previous fly board-to-board;
 * Auto Tour runs hands-free and Pause stops it; All Designs grid opens + a card jumps
 * to a board; the big buttons are >=56px tall; the China Seas spec card stays intact;
 * FPS stays healthy; zero console errors.
 *
 * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/test-senior.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 = [], fpsLog = [];
  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
  page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));

  // Ensure a clean GUIDED default (no persisted Explore from a prior run).
  await page.addInitScript(() => { try { localStorage.removeItem('qh_explore'); } catch(e){} });

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

  const sampleFps = async (tag) => {
    const fps = await page.evaluate(() => { const el = document.getElementById('fps-counter'); return el ? parseInt(el.textContent) : null; });
    if (fps > 0) fpsLog.push({ tag, fps });
    return fps;
  };

  // --- Assert guided mode booted on a focused board (big title visible) ---
  const guided = await page.evaluate(() => {
    const t = document.getElementById('guided-title');
    const bar = document.getElementById('guided-bar');
    const exploreOff = !document.body.classList.contains('explore-mode');
    return {
      titleVisible: t && !t.classList.contains('hidden'),
      barVisible: !!bar && getComputedStyle(bar).display !== 'none',
      exploreOff,
      titleText: document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : null,
      pos: document.getElementById('gt-pos') ? document.getElementById('gt-pos').textContent : null,
    };
  });
  console.log('  guided boot:', JSON.stringify(guided));
  await page.screenshot({ path: path.join(SHOTS, 'sr-01-guided-boot.png') });
  await sampleFps('boot');

  // --- Measure the big-button tap-target heights (must be >= 56px) ---
  const btnSizes = await page.evaluate(() => {
    const ids = ['g-prev', 'g-tour', 'g-next', 'g-grid'];
    const out = {};
    ids.forEach(id => { const el = document.getElementById(id); out[id] = el ? Math.round(el.getBoundingClientRect().height) : 0; });
    return out;
  });
  console.log('  button heights:', JSON.stringify(btnSizes));

  // --- Browse FORWARD with the big "Next ▶" button only (mouse clicks) ---
  console.log('→ NEXT × 6 (mouse only, no keyboard)');
  const seen = [];
  for (let i = 0; i < 6; i++) {
    await page.click('#g-next');
    await sleep(1500);
    const name = await page.evaluate(() => document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : '');
    const pos = await page.evaluate(() => document.getElementById('gt-pos') ? document.getElementById('gt-pos').textContent : '');
    seen.push(pos);
    if (i === 2) await page.screenshot({ path: path.join(SHOTS, 'sr-02-after-next.png') });
    await sampleFps('next');
  }
  console.log('  positions seen via Next:', JSON.stringify(seen));

  // --- Browse BACKWARD with "◀ Previous" ---
  console.log('→ PREVIOUS × 3 (mouse only)');
  for (let i = 0; i < 3; i++) { await page.click('#g-prev'); await sleep(1400); await sampleFps('prev'); }
  await page.screenshot({ path: path.join(SHOTS, 'sr-03-after-prev.png') });

  // --- AUTO TOUR (hands-free) then PAUSE — all via the one big brass button ---
  console.log('→ AUTO TOUR (hands-free) for ~14s, then PAUSE');
  await page.click('#g-tour');                      // start
  await sleep(2000);
  const touring = await page.evaluate(() => document.getElementById('g-tour').classList.contains('playing'));
  await sleep(12000);
  await page.screenshot({ path: path.join(SHOTS, 'sr-04-auto-tour.png') });
  await sampleFps('tour');
  await page.click('#g-tour');                      // pause (same button)
  await sleep(1200);
  const paused = await page.evaluate(() => !document.getElementById('g-tour').classList.contains('playing'));
  console.log('  auto-tour started:', touring, '| paused via same button:', paused);

  // --- ALL DESIGNS grid — open, then tap a big picture to jump to it ---
  console.log('→ ALL DESIGNS grid: open + tap a card');
  await page.click('#g-grid');
  await sleep(1200);
  const cardCount = await page.evaluate(() => document.querySelectorAll('#grid-items .go-card').length);
  await page.screenshot({ path: path.join(SHOTS, 'sr-05-all-designs.png') });
  console.log('  grid cards:', cardCount);
  // Tap the 4th card
  await page.click('#grid-items .go-card:nth-child(4)').catch(() => {});
  await sleep(1800);
  const gridClosed = await page.evaluate(() => !document.getElementById('grid-overlay').classList.contains('open'));
  const afterCardName = await page.evaluate(() => document.getElementById('gt-name') ? document.getElementById('gt-name').textContent : '');
  await page.screenshot({ path: path.join(SHOTS, 'sr-06-after-grid-pick.png') });
  console.log('  grid closed after pick:', gridClosed, '| now showing:', afterCardName);

  // --- Page across the WHOLE collection with Next, confirming the window advances ---
  console.log('→ paging the full collection with Next (window should advance past 8)');
  let maxPos = 0;
  for (let i = 0; i < 12; i++) {
    await page.click('#g-next');
    await sleep(1100);
    const idx = await page.evaluate(() => {
      const el = document.getElementById('gt-pos'); if (!el) return 0;
      const m = el.textContent.match(/Design\s+(\d+)/); return m ? parseInt(m[1]) : 0;
    });
    if (idx > maxPos) maxPos = idx;
  }
  console.log('  highest design index reached via Next:', maxPos);
  await sampleFps('paging');

  // --- 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);
  });
  console.log('  SPEC SHEET:', JSON.stringify(specText));

  const stats = await page.evaluate(() => (window._getSceneStats ? window._getSceneStats() : null));
  const fpsVals = fpsLog.map(f => f.fps).filter(n => n > 0);
  const report = {
    when: new Date().toISOString(),
    mode: 'senior-buttons-only',
    guidedBoot: guided,
    buttonHeights: btnSizes,
    buttonsMeetTarget: Object.values(btnSizes).every(h => h >= 56),
    autoTourWorked: !!touring && !!paused,
    gridCards: cardCount,
    gridPickJumped: gridClosed,
    highestIndexViaNext: maxPos,
    specText,
    errors, errorCount: errors.length,
    fpsMin: fpsVals.length ? Math.min(...fpsVals) : null,
    fpsAvg: fpsVals.length ? Math.round(fpsVals.reduce((a, b) => a + b, 0) / fpsVals.length) : null,
    fpsSamples: fpsLog,
    sceneStats: stats,
  };
  fs.writeFileSync(path.join(OUT, 'test-senior-report.json'), JSON.stringify(report, null, 2));
  console.log('\n=== SENIOR REPORT ===');
  console.log('errors:', report.errorCount, '| fps min/avg:', report.fpsMin, '/', report.fpsAvg,
    '| buttons>=56:', report.buttonsMeetTarget, '| autoTour:', report.autoTourWorked,
    '| gridPick:', report.gridPickJumped, '| reachedIndex:', report.highestIndexViaNext);
  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-senior.webm')); console.log('✓ test-senior.webm'); }
})().catch(e => { console.error('SENIOR TEST FAILED:', e.message); process.exit(1); });