From 1049dece24fc6350937fb790d721a66c8425c0f6 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:33:42 +1000 Subject: [PATCH] Add selftest harness with one suite per lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selftest.html only imports; each lane owns js/tests/.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 --- web/world/js/testkit.js | 107 ++++++++++++++++ web/world/js/tests/a.test.js | 228 +++++++++++++++++++++++++++++++++++ web/world/js/tests/b.test.js | 26 ++++ web/world/js/tests/c.test.js | 25 ++++ web/world/js/tests/d.test.js | 26 ++++ web/world/js/tests/e.test.js | 28 +++++ web/world/selftest.html | 97 +++++++++++++++ 7 files changed, 537 insertions(+) create mode 100644 web/world/js/testkit.js create mode 100644 web/world/js/tests/a.test.js create mode 100644 web/world/js/tests/b.test.js create mode 100644 web/world/js/tests/c.test.js create mode 100644 web/world/js/tests/d.test.js create mode 100644 web/world/js/tests/e.test.js create mode 100644 web/world/selftest.html diff --git a/web/world/js/testkit.js b/web/world/js/testkit.js new file mode 100644 index 0000000..76b33bd --- /dev/null +++ b/web/world/js/testkit.js @@ -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/.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][]} 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 }; +} diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js new file mode 100644 index 0000000..e70c747 --- /dev/null +++ b/web/world/js/tests/a.test.js @@ -0,0 +1,228 @@ +/** + * Lane A selftests — contracts, yard layout, anchors, phase machine. + * Lane A owns this file. Other lanes: yours is js/tests/.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'); + }); +} diff --git a/web/world/js/tests/b.test.js b/web/world/js/tests/b.test.js new file mode 100644 index 0000000..497c817 --- /dev/null +++ b/web/world/js/tests/b.test.js @@ -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'); +} diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js new file mode 100644 index 0000000..872bf59 --- /dev/null +++ b/web/world/js/tests/c.test.js @@ -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'); +} diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js new file mode 100644 index 0000000..c4719a9 --- /dev/null +++ b/web/world/js/tests/d.test.js @@ -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'); +} diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js new file mode 100644 index 0000000..2a73b3f --- /dev/null +++ b/web/world/js/tests/e.test.js @@ -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 4–9 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'); +} diff --git a/web/world/selftest.html b/web/world/selftest.html new file mode 100644 index 0000000..57f8dcf --- /dev/null +++ b/web/world/selftest.html @@ -0,0 +1,97 @@ + + + + + +SHADES — selftest + + + +

SHADES SELFTEST

+
+ Fixed-dt only — no requestAnimationFrame, so this stays honest in a background tab. + Full report is also on the console as JSON. +
+
running…
+
+ + + +