Second harness on B's post-ratingHint audit: measures what the STORM delivers at every dressed anchor — peak speedAt, tPeak, dynamic pressure (downdraft folded back in), dose, and Pa/hint (the wind-side failure-order prediction under load > rating x ratingHint). No cloth, no rig, no tension: independent by construction. Browser front-end dresses the yard the way the game does (createWorld + dress) and wires venturi + tree shelters exactly as main.js:446-447 does. envelope.selftest.js wired into c.test.js, every assert written to fail if a wiring line is deleted (sweep.selftest's pattern). Selftest 320/0/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
101 lines
5.4 KiB
JavaScript
101 lines
5.4 KiB
JavaScript
/**
|
||
* envelope.selftest.js — the second harness proves it measures. [Lane C, SPRINT12]
|
||
*
|
||
* Same shape as sweep.selftest.js: [name, fn] pairs, run under Lane A's
|
||
* selftest.html via js/tests/c.test.js. sweep.selftest exists because site_audit
|
||
* flew the corner block with the funnel switched off for two sprints; this file
|
||
* exists so the harness built to CHECK that tool can't quietly do the same.
|
||
* Every assert here is written to fail if a wiring line is deleted:
|
||
* · drop setVenturi → the throat anchor's funnelled/unfunnelled peaks
|
||
* collapse to equal and the strict < goes red
|
||
* · drop setSheltersFromTrees → the sheltered anchor stops reading lower
|
||
* · reseed per call → the determinism byte-compare goes red
|
||
* · flip paPerHint to × → the weak-anchor-ranks-first assert goes red
|
||
*/
|
||
|
||
import { stormEnvelope } from './envelope.js';
|
||
|
||
const TESTS = [];
|
||
const test = (name, fn) => TESTS.push([name, fn]);
|
||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||
|
||
/**
|
||
* A synthetic storm, flat on purpose: constant 10 m/s, dir 0 (blowing +X), no
|
||
* gusts, no wander, no spatial noise — so every local effect is the ONLY thing
|
||
* moving a number, and the asserts can be strict instead of statistical.
|
||
*/
|
||
const FLAT = {
|
||
name: 'flat_test', seed: 7, duration: 20,
|
||
baseCurve: [[0, 10], [20, 10]],
|
||
dirCurve: [[0, 0], [20, 0]],
|
||
dirWander: { amp: 0, rate: 0 },
|
||
spatial: { amp: 0 },
|
||
gusts: { firstAt: 999, minGap: 6, maxGap: 12, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.35 },
|
||
};
|
||
|
||
const A = (id, x, z, o = {}) => ({ id, type: o.type ?? 'post', pos: { x, y: 4, z }, ...o });
|
||
|
||
test('envelope: deterministic — same def, same anchors, same numbers to the bit', () => {
|
||
const anchors = [A('a', 0, 0), A('b', 5, 3)];
|
||
const def = JSON.parse(JSON.stringify(FLAT));
|
||
const r1 = stormEnvelope({ anchors, stormDef: def });
|
||
const r2 = stormEnvelope({ anchors, stormDef: JSON.parse(JSON.stringify(FLAT)) });
|
||
for (let i = 0; i < r1.rows.length; i++) {
|
||
assert(r1.rows[i].peak === r2.rows[i].peak && r1.rows[i].dose === r2.rows[i].dose
|
||
&& r1.rows[i].tPeak === r2.rows[i].tPeak,
|
||
`run 2 disagrees with run 1 at ${r1.rows[i].id}: ${r1.rows[i].peak} vs ${r2.rows[i].peak}`);
|
||
}
|
||
});
|
||
|
||
test('envelope: the venturi is wired — throat anchor reads the funnel, a far one does not', () => {
|
||
const anchors = [A('throat', 0, 0), A('far', 100, 0)];
|
||
const venturi = [{ x: 0, z: 0, axis: 0, gain: 1.5, radius: 5, sharp: 3 }];
|
||
const off = stormEnvelope({ anchors, stormDef: FLAT });
|
||
const on = stormEnvelope({ anchors, stormDef: FLAT, venturi });
|
||
const g = (res, id) => res.rows.find((r) => r.id === id);
|
||
// strict: delete envelope.js's setVenturi call and this collapses to equal
|
||
assert(g(on, 'throat').peak > g(off, 'throat').peak,
|
||
`funnel did not raise the throat: ${g(on, 'throat').peak} vs ${g(off, 'throat').peak} — setVenturi is not wired`);
|
||
assert(Math.abs(g(on, 'throat').peak - 15) < 0.01,
|
||
`gain 1.5 on 10 m/s dead-aligned should read 15 at the throat, got ${g(on, 'throat').peak}`);
|
||
assert(g(on, 'far').peak === g(off, 'far').peak,
|
||
'the funnel reached an anchor 100 m away — radial falloff is broken');
|
||
});
|
||
|
||
test('envelope: tree shelters are wired — a downwind anchor reads the shadow', () => {
|
||
// tree at origin, wind blowing +X: 'lee' sits 4 m downwind inside the shadow
|
||
const withTree = [A('tree', 0, 0, { type: 'tree' }), A('lee', 4, 0)];
|
||
const without = [A('lee', 4, 0)];
|
||
const sheltered = stormEnvelope({ anchors: withTree, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
|
||
const open = stormEnvelope({ anchors: without, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
|
||
// strict: delete envelope.js's setSheltersFromTrees call and these read equal
|
||
assert(sheltered.peak < open.peak,
|
||
`the tree cast no shadow: lee reads ${sheltered.peak} with it, ${open.peak} without — setSheltersFromTrees is not wired`);
|
||
});
|
||
|
||
test('envelope: paPerHint ranks the weak steel first at equal wind', () => {
|
||
// identical position, identical wind — only the hint differs (E's beam 0.22
|
||
// vs a clean post at 1.0). The wind-side prediction must rank the beam first.
|
||
const anchors = [A('beam', 2, 2, { ratingHint: 0.22 }), A('clean', 2, 2, { ratingHint: 1 })];
|
||
const { rows } = stormEnvelope({ anchors, stormDef: FLAT });
|
||
assert(rows[0].id === 'beam',
|
||
`equal wind, hint 0.22 vs 1.0 — 'beam' must rank first, got ${rows.map((r) => r.id).join(',')}`);
|
||
assert(Math.abs(rows[0].paPerHint - rows[1].paPerHint / 0.22) < 1e-9,
|
||
'paPerHint is not peakPa/hint — the ranking formula drifted');
|
||
assert(Math.abs(rows[0].effN[0] - 1200 * 0.22) < 1e-9,
|
||
`effN must be rating×hint: carabiner on the beam should read ${1200 * 0.22} N, got ${rows[0].effN[0]}`);
|
||
});
|
||
|
||
test('envelope: peaks land inside the storm and doses are finite and positive', () => {
|
||
const anchors = [A('a', -3, 1), A('b', 6, -2)];
|
||
const { rows, duration } = stormEnvelope({ anchors, stormDef: FLAT });
|
||
for (const r of rows) {
|
||
assert(r.tPeak >= 0 && r.tPeak <= duration, `${r.id} peaked at t=${r.tPeak}, outside 0..${duration}`);
|
||
assert(Number.isFinite(r.dose) && r.dose > 0, `${r.id} dose is ${r.dose}`);
|
||
// flat 10 m/s for 20 s → dose ≈ 100·20; a wildly-off dose means the loop drifted
|
||
assert(Math.abs(r.dose - 2000) < 20, `${r.id} dose ${r.dose.toFixed(0)} — flat 10 m/s × 20 s should be ~2000`);
|
||
}
|
||
});
|
||
|
||
export const ENVELOPE_TESTS = TESTS;
|