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>
This commit is contained in:
m3ultra 2026-07-16 21:33:42 +10:00
parent a41328a219
commit 1049dece24
7 changed files with 537 additions and 0 deletions

107
web/world/js/testkit.js Normal file
View File

@ -0,0 +1,107 @@
/**
* 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 };
}

View File

@ -0,0 +1,228 @@
/**
* Lane A selftests contracts, yard layout, anchors, phase machine.
* Lane A owns this file. Other lanes: yours is js/tests/<letter>.test.js.
*/
import * as THREE from '../../vendor/three.module.js';
import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
import { createWorld, heightAt } from '../world.js';
import { createCameraRig } from '../camera.js';
import { createGame } from '../main.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
const scene = new THREE.Scene();
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
// --- contract conformance ------------------------------------------------
// These are the merge tripwires: if a lane's module drifts from contracts.js,
// this is where we find out, not three lanes later.
t.test('contract: stub wind conforms', () => {
assertEq(checkContract('wind', createStubWind()).join('; '), '');
});
t.test('contract: world conforms', () => {
assertEq(checkContract('world', world).join('; '), '');
});
t.test('contract: camera rig conforms', () => {
const rig = createCameraRig(document.createElement('div'));
assertEq(checkContract('camera', rig).join('; '), '');
});
t.test('contract: game conforms', () => {
assertEq(checkContract('game', createGame()).join('; '), '');
});
// --- camera --------------------------------------------------------------
t.test('camera keeps a clear line to the player from every angle', () => {
// PLAN3D §5-A acceptance: "camera never clips through house". Stated here
// as the underlying invariant — no solid between the player's head and the
// camera — so it still means something after Lane E swaps the graybox house
// for house_yardside.glb.
const rig = createCameraRig(document.createElement('div'));
rig.setSolids(world.solids);
rig.setGround(heightAt);
const ray = new THREE.Raycaster();
const head = new THREE.Vector3();
const spots = [
[0, -9.4], // backed against the house — the case that caught it
[-5, -9.2], // under a fascia anchor
[-9, 2.6], // hard against a tree trunk
[-6, 7], // hard against a post
[0, 9.2], // against the south fence
];
for (const [x, z] of spots) {
const spot = new THREE.Vector3(x, heightAt(x, z), z);
for (let k = 0; k < 6; k++) {
rig.yaw = (k / 6) * Math.PI * 2;
// 60 steps = 1 s of smoothing at lambda 9, i.e. 99.99% settled. Every
// step is a raycast against the whole yard, so this bound is the
// difference between a 1 s selftest and a 4 s one — and every lane runs
// it after every merge.
for (let i = 0; i < 60; i++) rig.update(1 / 60, spot);
head.set(spot.x, spot.y + 1.55, spot.z);
const cam = rig.object.position;
const d = head.distanceTo(cam);
assert(d > 0.1, `camera collapsed onto the player (${d.toFixed(3)}m) at (${x},${z})`);
ray.set(head, cam.clone().sub(head).divideScalar(d));
ray.far = d - 0.02;
const blocked = ray.intersectObjects(world.solids, true)[0];
assert(
!blocked,
`'${blocked?.object.name || '?'}' sits between the player at (${x},${z}) ` +
`and the camera at yaw ${rig.yaw.toFixed(2)}`,
);
// The ground isn't a solid (heightAt handles it), so the raycast above
// can't speak for it — assert it separately.
assert(
cam.y > heightAt(cam.x, cam.z),
`camera is underground at (${cam.x.toFixed(1)},${cam.z.toFixed(1)}), ` +
`y=${cam.y.toFixed(2)} vs terrain ${heightAt(cam.x, cam.z).toFixed(2)}`,
);
}
}
});
// --- terrain -------------------------------------------------------------
t.test('heightAt is pure and gentle across the whole yard', () => {
for (let x = -15; x <= 15; x += 1.5) {
for (let z = -10; z <= 10; z += 1.5) {
const h = heightAt(x, z);
assertEq(h, heightAt(x, z), `heightAt(${x},${z}) not pure`);
assert(Math.abs(h) < 0.5, `heightAt(${x},${z}) = ${h}: terrain should stay gentle`);
}
}
});
t.test('garden bed sits inside the yard', () => {
const b = world.gardenBed;
assert(Math.abs(b.x) + b.w / 2 < YARD.width / 2, 'bed overhangs east/west');
assert(Math.abs(b.z) + b.d / 2 < YARD.depth / 2, 'bed overhangs north/south');
});
// --- anchors -------------------------------------------------------------
t.test('yard offers 7 anchors: 3 house, 2 tree, 2 post', () => {
const by = (type) => world.anchors.filter((a) => a.type === type).length;
assertEq(by('house'), 3, 'house anchors');
assertEq(by('tree'), 2, 'tree anchors');
assertEq(by('post'), 2, 'post anchors');
const ids = world.anchors.map((a) => a.id);
assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`);
});
t.test('sway() returns an absolute position, not an offset', () => {
// If sway ever regresses to returning an offset, the returned point lands
// near the origin instead of near the anchor, and Lane B's cloth corners
// all snap to the middle of the yard. Catch it here.
for (const a of world.anchors) {
const p = a.sway(3.1);
assertLess(p.distanceTo(a.pos), 0.6, `${a.id}.sway() is ${p.length().toFixed(2)}m from origin but should be near pos`);
}
});
t.test('house and post anchors are rigid; tree anchors move', () => {
for (const a of world.anchors.filter((x) => x.type !== 'tree')) {
assertEq(a.sway(0).distanceTo(a.sway(12.5)), 0, `${a.id} should not move`);
}
for (const a of world.anchors.filter((x) => x.type === 'tree')) {
// Sampled at two times a half-period apart; a static tree would score 0.
const spread = a.sway(0).clone().distanceTo(a.sway(0.83));
assert(spread > 1e-4, `${a.id} should sway with the wind, moved ${spread}m`);
}
});
t.test('anchors do not alias each other scratch vectors', () => {
// Two tree anchors handing out the same scratch Vector3 would silently
// corrupt whichever corner was read first.
const t1 = world.anchor('t1'), t2 = world.anchor('t2');
const p1 = t1.sway(2);
const x1 = p1.x;
const p2 = t2.sway(2);
assert(p1 !== p2, 't1 and t2 handed out the same vector object');
assertEq(p1.x, x1, 'reading t2 mutated the vector t1 returned');
});
// --- wind stub -----------------------------------------------------------
t.test('gust telegraph always gives at least 1.2 s of warning', () => {
// The promise the whole storm rests on: the player can always react.
// Lane C must keep this true for the real weather.js.
const wind = createStubWind({ seed: 7 });
let had = false, edges = 0;
fixedLoop(STORM_LEN, FIXED_DT, (dt, time) => {
const tel = wind.gustTelegraph(time);
if (tel && !had) {
edges++;
assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`);
}
had = !!tel;
});
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${STORM_LEN}s storm — too quiet to test`);
});
t.test('wind stub is deterministic: same seed, same storm', () => {
const a = createStubWind({ seed: 42 });
const b = createStubWind({ seed: 42 });
const p = new THREE.Vector3(1, 1, 1);
for (let time = 0; time < STORM_LEN; time += 0.37) {
const va = a.sample(p, time).clone();
const vb = b.sample(p, time);
assertEq(va.x, vb.x, `x diverged at t=${time}`);
assertEq(va.z, vb.z, `z diverged at t=${time}`);
}
});
t.test('wind stub gets angrier as the storm runs', () => {
const wind = createStubWind({ seed: 3 });
const p = new THREE.Vector3();
assertLess(wind.sample(p, 1).length(), wind.sample(p, STORM_LEN - 1).length());
});
// --- phase machine -------------------------------------------------------
t.test('phases cycle forecast → prep → storm → aftermath → forecast', () => {
const game = createGame();
assertEq(game.phase, 'forecast');
game.advance(); assertEq(game.phase, 'prep');
game.advance(); assertEq(game.phase, 'storm');
game.advance(); assertEq(game.phase, 'aftermath');
game.advance(); assertEq(game.phase, 'forecast');
});
t.test('phaseChange fires once, with from and to', () => {
const game = createGame();
const seen = [];
game.on('phaseChange', (p) => seen.push(p));
game.setPhase('prep');
game.setPhase('prep'); // no-op: already there
assertEq(seen.length, 1, 'phaseChange fired for a no-op transition');
assertEq(seen[0].from, 'forecast');
assertEq(seen[0].to, 'prep');
});
t.test('a 90 s storm ends itself (fast-forwarded, no rAF)', () => {
const game = createGame();
game.setPhase('storm');
fixedLoop(STORM_LEN - 1, FIXED_DT, () => game.tick(FIXED_DT));
assertEq(game.phase, 'storm', 'storm ended early');
fixedLoop(2, FIXED_DT, () => game.tick(FIXED_DT));
assertEq(game.phase, 'aftermath', 'storm never ended');
});
t.test('unknown phase is rejected', () => {
const game = createGame();
let threw = false;
try { game.setPhase('apocalypse'); } catch { threw = true; }
assert(threw, 'setPhase accepted a phase that does not exist');
});
}

View File

@ -0,0 +1,26 @@
/**
* Lane B selftests cloth, corner loads, failure cascade.
*
* Lane B owns this file. Lane A pre-created it so that adding your suite never
* means editing selftest.html if all five lanes shared that file it would be
* the one guaranteed merge conflict in the repo.
*
* The asserts PLAN3D §5-B asks for, once sail.js lands:
* 1. hypar sheds load twisted rig's peak corner load < flat rig's peak,
* same storm, same hardware. This is the thesis of the whole game; if it
* doesn't hold, the wind force is being applied per-node instead of
* per-face.
* 2. cascade break one corner at a fixed t, a neighbour's load spikes 2×.
* 3. determinism two runs, same inputs, byte-equal load traces.
*
* Useful imports when you get there:
* import { FIXED_DT, STORM_LEN, HARDWARE, createStubWind } from '../contracts.js';
* import { assert, assertLess, fixedLoop } from '../testkit.js';
* Drive time with fixedLoop(), never rAF. Use createStubWind({seed}) until
* Lane C's weather.js lands — but don't tune against it, it's uniform in space.
*/
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('sail.js not landed yet — Lane B');
}

View File

@ -0,0 +1,25 @@
/**
* Lane C selftests wind field, storm timelines, rain, debris.
*
* Lane C owns this file. Lane A pre-created it so adding your suite never means
* editing selftest.html.
*
* The asserts PLAN3D §5-C asks for, once weather.js lands:
* 1. gust telegraph lead 1.2 s the promise the storm rests on. Lane A's
* suite already asserts this against the stub wind (see a.test.js,
* 'gust telegraph always gives at least 1.2 s of warning'); lift that test
* onto the real wind.sample and delete the stub version's claim to it.
* 2. wind.sample continuity no frame-to-frame jump beyond a sane bound, or
* the cloth explodes and it looks like Lane B's bug.
* 3. storm JSON schema validation for everything in data/storms/.
*
* Useful imports:
* import { FIXED_DT, STORM_LEN } from '../contracts.js';
* import { assert, assertLess, fixedLoop } from '../testkit.js';
* wind.sample(pos, t) must be pure in (pos, t): selftest samples out of order.
*/
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('weather.js not landed yet — Lane C');
}

View File

@ -0,0 +1,26 @@
/**
* Lane D selftests player state machine and interactions.
*
* Lane D owns this file. Lane A pre-created it so adding your suite never means
* editing selftest.html.
*
* The asserts PLAN3D §5-D asks for, once player.js lands:
* 1. anim state machine table test every state reachable, none stuck
* (idle/walk/run + one-shot interact + knockdown get-up).
* 2. interact radius respects the busy and carrying flags.
*
* Note for integration: main.js currently drives a placeholder capsule that
* satisfies the Player contract ({pos, carrying, busy, update}). When player.js
* lands, Lane A swaps the factory call in boot() and deletes the placeholder
* you shouldn't need to touch main.js yourself. Ping THREADS.md when you're
* ready and Lane A will do the swap.
*
* Useful imports:
* import { FIXED_DT, createStubWind } from '../contracts.js';
* import { assert, assertEq, fixedLoop } from '../testkit.js';
*/
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('player.js not landed yet — Lane D');
}

View File

@ -0,0 +1,28 @@
/**
* Lane E selftests asset sanity.
*
* Lane E owns this file. Lane A pre-created it so adding your suite never means
* editing selftest.html.
*
* Most of Lane E's verification is the Blender-side contact sheet against the
* 1.7 m ref capsule (PLAN3D §5-E), which this harness can't see. What IS worth
* asserting here, once the GLBs land, is the stuff that silently breaks the
* other lanes:
* 1. every GLB loads without error through the vendored GLTFLoader.
* 2. scale sanity a loaded tree's bounding box is 49 m tall, the fence
* panel is ~1.6 m, the shackle is ~0.1 m. A model exported in centimetres
* looks fine alone and absurd next to a person.
* 3. the named nodes the other lanes query actually exist:
* tree_gum_01 `trunk`, `canopy_*`, `branch_anchor_*`
* house_yardside `fascia_anchor_*`
* Lane A sways the canopies by name; Lane B reads branch anchors.
*
* Loading is async `export default async function run(t)` is supported.
* Useful import:
* import { GLTFLoader } from '../../vendor/addons/loaders/GLTFLoader.js';
*/
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('yard asset GLBs not landed yet — Lane E');
}

97
web/world/selftest.html Normal file
View File

@ -0,0 +1,97 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SHADES — selftest</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; padding: 22px 20px 60px;
background: #10161b; color: #dde5ea;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
}
h1 { font-size: 15px; letter-spacing: .16em; margin: 0 0 4px; color: #fff; }
.sub { color: #7f8f9b; margin-bottom: 18px; }
#summary { font-size: 15px; font-weight: 700; padding: 10px 14px; border-radius: 6px; margin-bottom: 18px; }
.ok { background: #12321c; color: #7fce6a; border: 1px solid #2c6b3c; }
.bad { background: #3a1618; color: #ff8f86; border: 1px solid #7d2b2b; }
.lane { margin: 16px 0 6px; color: #7ee0ff; letter-spacing: .1em; }
table { border-collapse: collapse; width: 100%; max-width: 1000px; }
td { padding: 3px 10px 3px 0; vertical-align: top; }
td.s { width: 58px; font-weight: 700; }
td.ms { width: 66px; color: #66757f; text-align: right; }
.pass { color: #7fce6a; } .fail { color: #ff6b6b; } .skip { color: #7f8f9b; }
.err { color: #ffb1ab; padding-left: 68px; display: block; }
tr.f td { background: #2a1214; }
</style>
</head>
<body>
<h1>SHADES SELFTEST</h1>
<div class="sub">
Fixed-dt only — no requestAnimationFrame, so this stays honest in a background tab.
Full report is also on the console as JSON.
</div>
<div id="summary">running…</div>
<div id="out"></div>
<script type="module">
import { runAll } from './js/testkit.js';
// One import per lane. Each lane owns js/tests/<letter>.test.js and nobody has
// to touch this file — that's the whole point, it keeps selftest.html out of
// the merge path. (Lane A addition to PLAN3D §3; logged in THREADS.md.)
const lanes = [];
for (const [letter, path] of [
['A', './js/tests/a.test.js'],
['B', './js/tests/b.test.js'],
['C', './js/tests/c.test.js'],
['D', './js/tests/d.test.js'],
['E', './js/tests/e.test.js'],
]) {
try {
const mod = await import(path);
lanes.push([letter, mod.default]);
} catch (err) {
// A lane whose module doesn't parse or whose dependency isn't landed yet
// must not blank the report for everyone else.
lanes.push([letter, () => { throw new Error(`import failed: ${err.message}`); }]);
}
}
const report = await runAll(lanes);
const summary = document.getElementById('summary');
summary.className = report.ok ? 'ok' : 'bad';
summary.textContent = report.ok
? `PASS — ${report.pass} passed, ${report.skip} skipped`
: `FAIL — ${report.fail} failed, ${report.pass} passed, ${report.skip} skipped`;
const out = document.getElementById('out');
for (const letter of ['A', 'B', 'C', 'D', 'E']) {
const rows = report.results.filter((r) => r.lane === letter);
if (!rows.length) continue;
const h = document.createElement('div');
h.className = 'lane';
h.textContent = `LANE ${letter}`;
out.append(h);
const table = document.createElement('table');
for (const r of rows) {
const tr = table.insertRow();
if (r.status === 'fail') tr.className = 'f';
const s = tr.insertCell(); s.className = `s ${r.status}`; s.textContent = r.status.toUpperCase();
const l = tr.insertCell(); l.textContent = r.label;
if (r.err) { const e = document.createElement('span'); e.className = 'err'; e.textContent = `↳ ${r.err}`; l.append(e); }
const m = tr.insertCell(); m.className = 'ms'; m.textContent = r.ms >= 0.05 ? `${r.ms.toFixed(1)}ms` : '';
}
out.append(table);
}
console.log(JSON.stringify(report, null, 2));
// Scrapeable by anything that wants a verdict without parsing the DOM.
globalThis.SHADES_SELFTEST = report;
document.title = `SHADES selftest — ${report.ok ? 'PASS' : 'FAIL'}`;
</script>
</body>
</html>