Comment-text only, zero behavior change. 32/32 tests green before and after. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
304 lines
14 KiB
JavaScript
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);
|
|
}
|