HardYards/web/world/js/testkit.js
m3ultra 1049dece24 Add selftest harness with one suite per lane
selftest.html only imports; each lane owns js/tests/<letter>.test.js. Five lanes
sharing one selftest.html would be the single guaranteed merge conflict in the
repo, so the stubs are pre-created with each lane's PLAN3D asserts written into
the header.

No rAF anywhere: it's throttled in a hidden tab, so a rAF-driven selftest would
pass only while you watch it. fixedLoop() drives time instead.

Lane A's suite covers the §5-A acceptance criteria, including "camera never
clips through house" — stated as the underlying invariant (no solid between the
player's head and the camera) so it survives Lane E swapping the house GLB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:33:42 +10:00

108 lines
3.4 KiB
JavaScript

/**
* SHADES — tiny test harness for selftest.html. Lane A owns this file.
*
* Deliberately not a framework. The only rules that matter:
* - No rAF. requestAnimationFrame is throttled to a crawl in a hidden tab,
* and a selftest that only passes while you watch it is not a selftest.
* Drive time with fixedLoop() below.
* - No Date.now(), no Math.random() in anything under test.
*
* Each lane owns js/tests/<lane>.test.js and nobody edits selftest.html.
*/
export function assert(cond, msg = 'assertion failed') {
if (!cond) throw new Error(msg);
}
export function assertEq(actual, expected, msg = '') {
if (!Object.is(actual, expected)) {
throw new Error(`${msg || 'not equal'}: got ${format(actual)}, want ${format(expected)}`);
}
}
export function assertClose(actual, expected, eps = 1e-6, msg = '') {
if (!(Math.abs(actual - expected) <= eps)) {
throw new Error(`${msg || 'not close'}: got ${actual}, want ${expected} ±${eps}`);
}
}
export function assertLess(a, b, msg = '') {
if (!(a < b)) throw new Error(`${msg || 'not less'}: ${a} is not < ${b}`);
}
function format(v) {
if (typeof v === 'number') return String(v);
try { return JSON.stringify(v); } catch { return String(v); }
}
/**
* Drive a sim forward in fixed steps. This is how you fast-forward a storm.
*
* @param {number} seconds Sim seconds to advance.
* @param {number} dt Step size — pass contracts.FIXED_DT.
* @param {(dt:number, t:number) => void} fn Called with the time at step start.
*/
export function fixedLoop(seconds, dt, fn) {
const steps = Math.round(seconds / dt);
let t = 0;
for (let i = 0; i < steps; i++) {
fn(dt, t);
t += dt;
}
return t;
}
/** Collects results for one lane. */
export class Suite {
constructor(lane) {
this.lane = lane;
/** @type {{lane:string, label:string, status:'pass'|'fail'|'skip', err?:string, ms:number}[]} */
this.results = [];
}
test(label, fn) {
const t0 = performance.now();
try {
fn();
this.results.push({ lane: this.lane, label, status: 'pass', ms: performance.now() - t0 });
} catch (err) {
this.results.push({
lane: this.lane, label, status: 'fail',
err: err?.message ?? String(err), ms: performance.now() - t0,
});
}
}
/** For a lane that hasn't landed yet. Shows up in the report, doesn't fail it. */
skip(label) {
this.results.push({ lane: this.lane, label, status: 'skip', ms: 0 });
}
}
/**
* @param {[string, (t: Suite) => void|Promise<void>][]} lanes
* @returns {Promise<{pass:number, fail:number, skip:number, ok:boolean, results:object[]}>}
*/
export async function runAll(lanes) {
const results = [];
for (const [lane, run] of lanes) {
const suite = new Suite(lane);
try {
await run(suite);
} catch (err) {
// A lane whose module fails to import or throws at setup must not take
// the whole report down with it — that's how you lose the signal from
// every other lane during a merge.
suite.results.push({
lane, label: '(suite threw during setup)', status: 'fail',
err: err?.message ?? String(err), ms: 0,
});
}
results.push(...suite.results);
}
const pass = results.filter((r) => r.status === 'pass').length;
const fail = results.filter((r) => r.status === 'fail').length;
const skip = results.filter((r) => r.status === 'skip').length;
return { pass, fail, skip, ok: fail === 0, results };
}