/* ISLE SLINGERS — page boot test (node, zero dependencies). * Run: node test/page.test.mjs * * Loads index.html + all js/ files exactly as a browser would (same order, * same globals), against a minimal DOM stub built from the REAL ids in * index.html, and drives a full click session: PLAY -> level tile -> aim & * launch -> play out the level. Any null-element wiring bug (the class * that shipped a dead page) throws here. The physics suite cannot see this. */ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..'); let passed = 0, failed = 0; const failures = []; function test(name, fn) { try { fn(); passed++; console.log(' PASS ' + name); } catch (e) { failed++; failures.push({ name, err: e }); console.log(' FAIL ' + name + ' :: ' + e.message); } } function ok(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); } // ------------------------------------------------------------ DOM stub const html = readFileSync(join(ROOT, 'index.html'), 'utf8'); function makeEl(id) { const el = { id, children: [], listeners: {}, style: {}, dataset: {}, disabled: false, className: '', textContent: '', _innerHTML: '', get innerHTML() { return this._innerHTML; }, set innerHTML(v) { this._innerHTML = v; if (v === '') this.children = []; }, appendChild(c) { this.children.push(c); return c; }, addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); }, removeEventListener() {}, dispatch(type, ev) { const e = Object.assign({ stopPropagation() {}, preventDefault() {} }, ev || {}); for (const fn of this.listeners[type] || []) fn(e); }, querySelector(sel) { const m = /^#([A-Za-z0-9_-]+)$/.exec(sel); if (!m) return null; return collectAll().find((x) => x.id === m[1]) || null; }, querySelectorAll() { return []; }, getBoundingClientRect() { return { left: 0, top: 0, width: 1280, height: 720 }; }, getContext(kind) { return makeCtx(this); }, get context() { return makeCtx(this); }, }; return el; } function makeCtx(el) { const noop = () => {}; const grad = { addColorStop: noop }; // the game canvas records lineTo() coordinates so tests can assert what // the sling actually draws (e.g. that the band stops tracking the bird // once it launches — bug #14's arm) const isGame = el.id === 'game'; if (isGame) el._lineTos = el._lineTos || []; return new Proxy({}, { get(t, k) { if (k === 'createLinearGradient' || k === 'createRadialGradient' || k === 'createPattern') return () => grad; if (k === 'measureText') return () => ({ width: 10 }); if (isGame && k === 'lineTo') return (x, y) => { el._lineTos.push([x, y]); }; if (k in t) return t[k]; return noop; }, set(t, k, v) { t[k] = v; return true; }, }); } // parse every id out of the real index.html const ids = [...html.matchAll(/id="([^"]+)"/g)].map((m) => m[1]); const els = new Map(); for (const id of ids) els.set(id, makeEl(id)); const collectAll = () => [...els.values()]; // the level-grid tiles are created dynamically by main.js const dynamicEls = new Map(); const documentStub = { getElementById(id) { return els.get(id) || dynamicEls.get(id) || null; }, createElement(tag) { const el = makeEl('__dyn_' + tag + '_' + dynamicEls.size); dynamicEls.set(el.id, el); return el; }, addEventListener() {}, body: makeEl('__body'), }; const rafQueue = []; const windowStub = { innerWidth: 1280, innerHeight: 720, addEventListener(type, fn) { (this._l = this._l || {})[type] = fn; }, dispatch(type, ev) { const e = Object.assign({ stopPropagation() {}, preventDefault() {} }, ev || {}); if (this._l && this._l[type]) this._l[type](e); }, requestAnimationFrame(fn) { rafQueue.push(fn); return rafQueue.length; }, localStorage: { _s: {}, getItem(k) { return this._s[k] === undefined ? null : this._s[k]; }, setItem(k, v) { this._s[k] = String(v); }, }, }; // canvas sizing side effects touch window.devicePixelRatio & element styles windowStub.devicePixelRatio = 1; // ------------------------------------------------------------ load modules as a browser would const sandbox = { window: windowStub, document: documentStub, localStorage: windowStub.localStorage, requestAnimationFrame: (fn) => windowStub.requestAnimationFrame(fn), setTimeout: (fn) => { fn(); return 0; }, clearTimeout: () => {}, console, performance: { now: () => Date.now() }, navigator: { userAgent: 'page-test' }, AudioContext: undefined, // audio.js must degrade without it HTMLAudioElement: undefined, }; sandbox.window.AudioContext = undefined; function load(file) { const src = readFileSync(join(ROOT, file), 'utf8'); // 'globalThis' is a shadowing parameter: UMD wrappers do // `typeof globalThis !== 'undefined' ? globalThis : this`, so without this // the modules would attach to Node's real global, not the browser stub. const fn = new Function('window', 'document', 'localStorage', 'requestAnimationFrame', 'setTimeout', 'clearTimeout', 'console', 'performance', 'navigator', 'AudioContext', 'HTMLAudioElement', 'globalThis', src); fn(windowStub, documentStub, windowStub.localStorage, windowStub.requestAnimationFrame, sandbox.setTimeout, sandbox.clearTimeout, console, sandbox.performance, sandbox.navigator, undefined, undefined, windowStub); } console.log('\n== ISLE SLINGERS page boot (DOM stub) =='); // ------------------------------------------------------------------- tests test('page loads: all five modules execute without throwing (browser order)', () => { // RC-375D84 S-2: modularized tree -- same browser order as index.html const FILES = [ 'js/physics/constants/index.js', 'js/physics/state/index.js', 'js/physics/aim/index.js', 'js/physics/abilities/index.js', 'js/physics/damage/index.js', 'js/physics/step/index.js', 'js/physics/collision/index.js', 'js/physics/topple/index.js', 'js/physics/rules/index.js', 'js/physics/camera/index.js', 'js/physics/index.js', 'js/levels/helpers/index.js', 'js/levels/data/level-1.js', 'js/levels/data/level-2.js', 'js/levels/data/level-3.js', 'js/levels/data/level-4.js', 'js/levels/data/level-5.js', 'js/levels/index.js', 'js/audio/index.js', 'js/render/index.js', 'js/app/index.js' ]; for (const f of FILES) { try { load(f); } catch (e) { throw new Error(f + ' threw during load: ' + e.message + '\n' + (e.stack || '').split('\n').slice(0, 4).join('\n')); } } ok(windowStub.ISLE_CORE && windowStub.ISLE_LEVELS && windowStub.ISLE_AUDIO && windowStub.ISLE_RENDERER, 'browser globals missing after load'); }); test('boot: requestAnimationFrame loop scheduled and title screen shown', () => { ok(rafQueue.length >= 1, 'no RAF scheduled — boot died'); const start = els.get('screen-start'); ok(start.style.display !== 'none', 'start screen not shown on boot'); }); let lastPumpT = 0; // rAF timestamps must keep advancing across pumps — // an undefined t0 would NaN the clock and silently freeze // the fixed-timestep accumulator (the game would never step) function pumpFrames(n, t0) { let t = (t0 === undefined) ? lastPumpT : t0; for (let i = 0; i < n; i++) { t += 1000 / 60; const q = rafQueue.splice(0); for (const fn of q) fn(t); } lastPumpT = t; return t; } test('click session: PLAY -> level select -> start level 1 -> play screen', () => { els.get('btn-play').dispatch('click'); ok(els.get('screen-select').style.display !== 'none', 'level select did not open'); ok(els.get('level-grid').children.length >= 3, 'level tiles not rendered'); const tile1 = els.get('level-grid').children[0]; ok(!tile1.disabled, 'first level unexpectedly locked'); tile1.dispatch('click'); ok(els.get('hud').style.display !== 'none', 'HUD not shown in play screen'); ok(els.get('hud-level').textContent.indexOf('Level 1') === 0, 'HUD level label wrong: ' + els.get('hud-level').textContent); }); test('play session: aim, launch, run the sim to resolution without throwing', () => { const canvas = els.get('game'); // grab the bird near the sling and launch it full-power left-to-right canvas.dispatch('mousedown', { clientX: 200, clientY: 560, preventDefault() {} }); windowStub.dispatch('mousemove', { clientX: 120, clientY: 600, preventDefault() {} }); windowStub.dispatch('mouseup', {}); let t = pumpFrames(60 * 8, 0); // 8s of gameplay at 60fps ok(true, 'sim ran 8s without throwing (status: n/a headless)'); }); test('pause / mute / keyboard wiring respond without throwing', () => { windowStub.dispatch('keydown', { key: 'p', repeat: false, preventDefault() {} }); els.get('btn-pause-resume').dispatch('click'); els.get('btn-mute').dispatch('click'); els.get('btn-mute').dispatch('click'); ok(els.get('btn-mute').textContent.length > 0, 'mute button label empty'); }); // ------------------------------ bugs #14-15 (Commander's playtest, round 2) // Both bugs were invisible to the physics suite because they live in the // input->world mapping and the renderer, not the core. These arms drive // the page's OWN pointer handlers (the exact calls a player makes) and // assert on the resulting state and drawing. // Bug #15: "Sling shot only shoots backwards and not in the direction of // the target." The player grabs the bird at the place it is VISIBLE, // drags it back (away from the fort, i.e. left), and releases. The bird // must launch toward the fort (right). RED on 2d9c8f47: toWorld() ignored // the camera zoom, so the visible bird mapped to a world point ~210px to // the RIGHT of the sling — every pull-back produced a leftward launch // (measured: bird landed x=230, behind the sling at x=270, fort at 1500+). test('direction: pulling the bird back launches it TOWARD the fort (bug #15)', () => { ok(windowStub.ISLE_PAGE, 'ISLE_PAGE state handle missing — pre-fix build (expected RED on 2d9c8f47)'); const st = windowStub.ISLE_PAGE.getState(); const ab = st.birds[st.activeBird]; ok(ab && ab.state === 'aiming', 'no bird waiting on the sling'); // where the bird is actually drawn: inverse of the renderer transform const cam = st.camera; const sx = (ab.x - cam.x) * cam.zoom + 640; const sy = (ab.y - cam.y) * cam.zoom + 360; const canvas = els.get('game'); // press on the visible bird, pull it back (left + slightly down), release canvas.dispatch('mousedown', { clientX: sx, clientY: sy, preventDefault() {} }); windowStub.dispatch('mousemove', { clientX: sx - 110, clientY: sy + 50, preventDefault() {} }); windowStub.dispatch('mouseup', {}); const st2 = windowStub.ISLE_PAGE.getState(); const fired = st2.birds[st2.activeBird]; ok(fired && fired.state === 'flying', 'release did not launch the bird (grab mapping off?)'); ok(fired.vx > 100, `bird launched the wrong way: vx=${fired.vx.toFixed(0)} (the fort is to the RIGHT of the sling at x>${st2.level.sling.x})`); }); // Bug #14: "sling never actually lets go of bird when the bird is shot // out." With the bird in flight, the sling's bands must hang relaxed — // they must not keep ending at the flying bird's position. RED on // 2d9c8f47: drawSling drew the taut band to bird.x/bird.y for ANY active // bird, so the rubber banded tracked the projectile for its whole flight. test('release: sling bands do not track the flying bird (bug #14)', () => { ok(windowStub.ISLE_PAGE, 'ISLE_PAGE state handle missing — pre-fix build (expected RED on 2d9c8f47)'); let st = windowStub.ISLE_PAGE.getState(); let b = st.birds[st.activeBird]; // wait (up to 10s) until a bird is loaded on the sling — an earlier // arm's shot may still be in flight / settling let guard = 0; while (b && b.state !== 'aiming' && guard++ < 600) { pumpFrames(1); st = windowStub.ISLE_PAGE.getState(); b = st.birds[st.activeBird]; } ok(b && b.state === 'aiming', 'no bird waiting on the sling to fire'); // fire it with the standard pull-back gesture const cam = st.camera; const sx = (b.x - cam.x) * cam.zoom + 640; const sy = (b.y - cam.y) * cam.zoom + 360; const canvas = els.get('game'); canvas.dispatch('mousedown', { clientX: sx, clientY: sy, preventDefault() {} }); windowStub.dispatch('mousemove', { clientX: sx - 110, clientY: sy + 50, preventDefault() {} }); windowStub.dispatch('mouseup', {}); pumpFrames(30); // 0.5 s: mid-flight and well clear of the sling st = windowStub.ISLE_PAGE.getState(); b = st.birds[st.activeBird]; ok(b && b.state === 'flying', 'no bird in flight to check the release'); const sling = st.level.sling; ok(Math.hypot(b.x - sling.x, b.y - sling.y) > 250, 'bird still within 250px of the sling — check would be ambiguous'); canvas._lineTos.length = 0; pumpFrames(1); // one drawn frame with the bird mid-flight // draw() renders the state AFTER that frame's physics step — re-read st = windowStub.ISLE_PAGE.getState(); const fb = st.birds[st.activeBird]; const attached = canvas._lineTos.some( ([x, y]) => Math.hypot(x - (fb.x - 6), y - fb.y) < 12 || Math.hypot(x - (fb.x + 6), y - fb.y) < 12); ok(!attached, `a sling band was still drawn to the flying bird at (${fb.x.toFixed(0)},${fb.y.toFixed(0)}) — the sling never let go`); }); // ------------------------------------------------------------------ report console.log('\n== ' + passed + ' passed, ' + failed + ' failed =='); if (failed) { for (const f of failures) console.log(' - ' + f.name + ': ' + f.err.message.split('\n')[0]); process.exit(1); }