THE CAUSE, and it is not a winner: balance.test.js built skyfx with no camera, and skyfx.step() opens `if (!camera) return;` — so the hail shadow grid was never rebuilt across all 5400 calls and every loadout was scored as though it had no sail. hp 36 IS the bare-bed number. Measured with the camera as the only variable: hailShadowOver(bed) peaks 0.000 vs 1.000, hp 36 vs 69. Neither harness was lying; one was flying a yard with no cloth in it. Camera + wind shelters added — the suite must drive what the game drives. MY ERROR, named: the hp-58 win was measured at tension 1.0 and I never declared it. At the shop's default 0.9 my own end-to-end also loses 2 corners. STILL OPEN: this suite says 2 corners lost, my end-to-end says 1 at tension 1.0. Ruled out by measurement, each worth <=0.02 kN: shelters, frozen vs live tree sway, the scripted repair. Next suspects named in the skip: session.commit() vs rigSail(), and main.js passing debris as rig.step's 4th arg (debris ADDS load, so it should break MORE here, not less — that inversion is the thread). And the find that explains how this survived a merge: Suite.test() ignored its return value, so `return "SKIPPED — ..."` was recorded as a PASS. The integrator's storm_02 skip was a fake pass; so was mine for storm_01; `skip: 0` in every report we ever printed was the tell and nobody read it, me included — I misread one of those fakes as my win reproducing. test() now honours the string, and rejects async fns outright (they hand back a Promise that cannot throw synchronously, passing forever while proving nothing). That guard immediately caught its own author: BOTH my wind-router tripwire tests were async and have asserted nothing for two sprints — including the sprint where I reported the tripwire "caught its first real omission". It had, in a hand-run probe, never in the suite. Awaits hoisted into run(); they asserted for the first time today, and pass. Selftest 261 pass / 0 fail / 2 honest skips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
5.2 KiB
JavaScript
140 lines
5.2 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 = [];
|
|
}
|
|
|
|
/**
|
|
* A test passes by returning, fails by throwing, and SKIPS by returning a
|
|
* string that starts with 'SKIPPED'.
|
|
*
|
|
* That last clause is not sugar — it is a repair. This method used to ignore
|
|
* its return value entirely, so a test that did
|
|
* if (!ok) return `SKIPPED — harness dispute: ...`;
|
|
* was recorded as a PASS. Two suites had already been written that way (the
|
|
* integrator's storm_02 skip at the Sprint-6 merge, and a storm_01 one), which
|
|
* means main has been reporting green on asserts that were neither passing nor
|
|
* skipping — they returned a message and the suite called it a win. `skip: 0`
|
|
* in every report we ever printed was the tell, and nobody read it, including
|
|
* me: I misread one of those fake passes as my own win reproducing.
|
|
*
|
|
* It's the same disease as the wind router swallowing rainMmPerHour — a
|
|
* mechanism that looks wired and silently isn't — and it lived in the harness
|
|
* every lane trusts. A skip must be visible or it is a lie with good manners.
|
|
*/
|
|
test(label, fn) {
|
|
const t0 = performance.now();
|
|
try {
|
|
const out = fn();
|
|
if (typeof out === 'string' && out.startsWith('SKIPPED')) {
|
|
this.results.push({
|
|
lane: this.lane, label, status: 'skip',
|
|
err: out.replace(/^SKIPPED\s*[—-]?\s*/, ''), ms: performance.now() - t0,
|
|
});
|
|
return;
|
|
}
|
|
if (out && typeof out.then === 'function') {
|
|
// An async fn hands back a Promise that cannot throw synchronously, so
|
|
// this would pass forever while proving nothing. Lane B called this out
|
|
// in balance.test.js's header; make it impossible rather than a warning.
|
|
throw new Error(`test "${label}" is async — Suite.test() cannot await it. ` +
|
|
`Do the awaiting in run() and assert synchronously.`);
|
|
}
|
|
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 };
|
|
}
|