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:
parent
a41328a219
commit
1049dece24
107
web/world/js/testkit.js
Normal file
107
web/world/js/testkit.js
Normal 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 };
|
||||
}
|
||||
228
web/world/js/tests/a.test.js
Normal file
228
web/world/js/tests/a.test.js
Normal 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');
|
||||
});
|
||||
}
|
||||
26
web/world/js/tests/b.test.js
Normal file
26
web/world/js/tests/b.test.js
Normal 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');
|
||||
}
|
||||
25
web/world/js/tests/c.test.js
Normal file
25
web/world/js/tests/c.test.js
Normal 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');
|
||||
}
|
||||
26
web/world/js/tests/d.test.js
Normal file
26
web/world/js/tests/d.test.js
Normal 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');
|
||||
}
|
||||
28
web/world/js/tests/e.test.js
Normal file
28
web/world/js/tests/e.test.js
Normal 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 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');
|
||||
}
|
||||
97
web/world/selftest.html
Normal file
97
web/world/selftest.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user