diff --git a/public/tide-pool.js b/public/tide-pool.js index acf2e48..7fd5340 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -2824,5 +2824,5 @@ async function initialize() { for (const type of ['pointermove', 'pointerdown', 'wheel', 'keydown', 'focusin']) root.addEventListener(type, stir, { passive: true }); stir(true); // Test and debugging hook; read-only by convention. - window.__tidePool = { scene, draw: () => { easeCamera(1); render(); }, watch: seconds => { for (let t = 0; t < seconds; t += 1 / 30) { advanceWorld(world, 1 / 30); aim(1 / 30); easeCamera(1 / 30); } render(); }, steer: seconds => { for (let t = 0; t < seconds; t += 1 / 30) { drive(1 / 30); aim(1 / 30); easeCamera(1 / 30); } render(); }, director, advance: seconds => { for (let i = 0; i < seconds * 20; i++) advanceWorld(world, .05); readout(); }, get world() { return world; }, lantern, probe: (x, y) => floorAt(x, y), select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal }; + window.__tidePool = { scene, draw: () => { easeCamera(1); render(); }, watch: seconds => { for (let t = 0; t < seconds; t += 1 / 30) { advanceWorld(world, 1 / 30); aim(1 / 30); easeCamera(1 / 30); } render(); }, steer: seconds => { for (let t = 0; t < seconds; t += 1 / 30) { drive(1 / 30); aim(1 / 30); easeCamera(1 / 30); } render(); }, director, advance: seconds => { for (let i = 0; i < seconds * 20; i++) advanceWorld(world, .05); readout(); }, get world() { return world; }, lantern, probe: (x, y) => floorAt(x, y), floor: (x, z) => Math.max(heightAt(world, x, z), surfaceAt(world, x, z)), select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal }; } diff --git a/scripts/test-tide-pool-browser.mjs b/scripts/test-tide-pool-browser.mjs index 6d08941..c6bfe7f 100644 --- a/scripts/test-tide-pool-browser.mjs +++ b/scripts/test-tide-pool-browser.mjs @@ -1,5 +1,5 @@ // Optional Playwright tooling; never shipped as a runtime dependency. -// TIDE_PLAYWRIGHT=/path/to/playwright/index.mjs TIDE_WATCH=1 node scripts/test-tide-pool-browser.mjs [base] [receipts] +// TIDE_PLAYWRIGHT=/path/to/playwright/index.mjs TIDE_CHANNEL=chrome TIDE_WATCH=1 node scripts/test-tide-pool-browser.mjs [base] [receipts] import assert from 'node:assert/strict'; import { mkdir, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; @@ -7,7 +7,7 @@ const { chromium } = await import(process.env.TIDE_PLAYWRIGHT ? pathToFileURL(pr const base = process.argv[2] || 'http://localhost:3097'; const out = process.argv[3] || '/tmp/tide-pool-immersive-review'; await mkdir(out, { recursive: true }); -const browser = await chromium.launch({ headless: true, args: ['--enable-unsafe-swiftshader'] }); +const browser = await chromium.launch({ channel: process.env.TIDE_CHANNEL, headless: true, args: ['--enable-unsafe-swiftshader'] }); const errors = [], sizes = []; try { const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); @@ -15,6 +15,14 @@ try { window.__audio = []; const Native = window.AudioContext; window.AudioContext = class extends Native { constructor(...args) { super(...args); window.__audio.push(this); } }; + // How many times the page's frame loop runs in each animation frame: never more than once. + window.__loops = { frame: 0, most: 0 }; + const raf = window.requestAnimationFrame.bind(window); + let counted = -1; + window.requestAnimationFrame = cb => raf(ts => { + if (cb.name === 'loop') { if (ts !== counted) { counted = ts; window.__loops.frame = 0; } window.__loops.most = Math.max(window.__loops.most, ++window.__loops.frame); } + cb(ts); + }); }); const page = await context.newPage(); page.on('pageerror', e => errors.push(String(e))); @@ -81,6 +89,31 @@ try { await page.locator('#tide-sound').click(); await page.waitForFunction(() => window.__audio[0]?.state === 'suspended'); await page.locator('#tide-reset').click(); + // Driving the view with held keys, the pointer over the pool, as a real keyboard repeats them: the view moves, the + // page keeps answering, and the frame loop never runs more than once a frame. + await page.evaluate(() => document.activeElement?.blur()); + await page.mouse.move(720, 450); + await page.evaluate(() => { window.__loops.most = 0; }); + const start = await page.evaluate(() => ({ ...window.__tidePool.view })); + const hold = (async () => { + await page.keyboard.down('KeyW'); + for (let i = 0; i < 30; i++) { await page.keyboard.down('KeyW'); await page.waitForTimeout(33); } + await page.keyboard.up('KeyW'); + })(); + const kept = await Promise.race([hold.then(() => true), new Promise(done => setTimeout(() => done(false), 8000))]); + assert.ok(kept, 'the page kept up with held keys'); + const moved = await page.evaluate(() => ({ ...window.__tidePool.view })); + assert.ok(Math.hypot(moved.x - start.x, moved.z - start.z) > .3, 'held W moved the view'); + assert.equal(await page.evaluate(() => window.__loops.most), 1); + // Night: the light floats under the pointer, clear of the floor, and lights up. + await page.evaluate(() => { const P = window.__tidePool; let n = 0; while (document.querySelector('#tide-time').textContent.slice(0, 2) !== '00' && n++ < 20) P.advance(20); }); + await page.mouse.move(700, 440); await page.mouse.move(720, 450); + await page.waitForFunction(() => window.__tidePool.lantern.glow > .5, null, { timeout: 20000 }); + const light = await page.evaluate(() => { const { lantern, floor } = window.__tidePool; return { lit: lantern.lit, clear: lantern.y - floor(lantern.x, lantern.z) }; }); + assert.ok(light.lit); + assert.ok(light.clear > .3, `the light floats clear of the floor (${light.clear})`); + await page.screenshot({ path: `${out}/night-light.png` }); + await page.mouse.move(2, 2); if (process.env.TIDE_WATCH === '1') { await page.locator('.tide-notes summary').click(); const watch = await page.evaluate(async () => { @@ -142,6 +175,6 @@ try { assert.equal(await f.locator('noscript .tide-veil-note').textContent(), 'The pool needs JavaScript to fill'); await f.screenshot({ path: `${out}/no-javascript.png` }); await fallback.close(); - await writeFile(`${out}/receipt.json`, JSON.stringify({ base, at: new Date().toISOString(), sizes, errors, passed: ['fullscreen geometry', 'desktop and mobile touch', 'keyboard', 'material-specific instructions', 'instant offering response', 'follow and release', 'zoom and pool navigation', 'pause pixel equality', 'opt-in audio', 'reduced motion', 'context loss and return', 'no-JS veil'] }, null, 2)); + await writeFile(`${out}/receipt.json`, JSON.stringify({ base, at: new Date().toISOString(), sizes, errors, passed: ['fullscreen geometry', 'desktop and mobile touch', 'keyboard', 'material-specific instructions', 'instant offering response', 'follow and release', 'zoom and pool navigation', 'pause pixel equality', 'opt-in audio', 'reduced motion', 'context loss and return', 'no-JS veil', 'held keys with the pointer over the pool', 'the night light'] }, null, 2)); console.log(JSON.stringify({ passed: true, base, out, sizes })); } finally { await browser.close(); } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index aaa0f4d..81c8356 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -189,7 +189,7 @@ export function TidePoolContent() {
Rendered with Three.js. Software license.
- + ); }