Isle-Slingers/test/page.test.mjs
Relay 1e213baffd Scrub remaining RC/Commander references from test comments
Comment-text only, zero behavior change. 32/32 tests green before and after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:38:06 +00:00

304 lines
14 KiB
JavaScript

/* 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)', () => {
// 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 (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);
}