findCandidates/priceCandidate/judgeSweep extracted so the sync auditSweep and the new auditSweepAsync are ONE copy of the math; scoreSite drives the async path with onProgress ticks (sweep/fly/separation phases). Yield is a MessageChannel task, not setTimeout — Chrome's intensive timer throttling turned an occluded run into one flight per minute, measured. Three new asserts, mutation-checked red-then-green in one sitting (impure chunk + a swallowed tick = all three red with the intended diagnostics). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
413 lines
23 KiB
JavaScript
413 lines
23 KiB
JavaScript
/**
|
|
* scorecard.selftest.js — GATE 2.3: the editor scores the wind the game plays.
|
|
* [Lane B, SPRINT14; co-owned with Lane C]
|
|
*
|
|
* SPRINT13's whole lesson, made into an assert. Three harnesses independently
|
|
* rebuilt site wind by hand and each got it wrong in a different way (funnel
|
|
* off twice, frozen tree sway once); the fix was `windForSite()`, one door, and
|
|
* the rule that no tool builds site wind again. Sprint 14 adds a fourth place
|
|
* that scores a yard — A's editor — so the rule needs a tripwire rather than a
|
|
* promise. This is it:
|
|
*
|
|
* the wind the EDITOR scores == the wind the GAME plays,
|
|
* at one probe point, at one storm second, EXACTLY.
|
|
*
|
|
* Exactly, not nearly. A tolerance is precisely where the funnel-off bug hid:
|
|
* it moved the Sprint-13 headline from 91.5 FULL to 39.8 TATTERED, so any
|
|
* epsilon loose enough to feel "safe" is loose enough to hide the bug this
|
|
* assert exists to catch. Two chains that agree agree to the last bit.
|
|
*
|
|
* ── The two chains, built independently on purpose ─────────────────────────
|
|
*
|
|
* GAME loadSite(name) → createWorld → dress() → **main.js's own
|
|
* `createWindRouter`**, wired by main.js's own two lines
|
|
* (loadSiteInto: setVenturi then setSheltersFromTrees). Importing the
|
|
* router rather than retyping those lines is C's correction to my
|
|
* first draft, and it matters: my version built BOTH sides with
|
|
* `windForSite`, so it could only ever have caught a bad INPUT, never
|
|
* a router that routed wrong.
|
|
*
|
|
* EDITOR siteClone-shaped object → buildScoringWorld() → dress() →
|
|
* windForSite(storm, clone, scoringWorld.anchors). The editor's yard
|
|
* is an object that was never a file, and its scoring world is built
|
|
* fresh (A's page renders on a calm stub whose sway closures must
|
|
* never reach a score — C's landmine 2 wearing a new hat). This is
|
|
* the path SCORE IT actually runs, which is what my half adds over
|
|
* C's: C pins the BUILDER against the game, this pins the thing the
|
|
* BUTTON runs against the game. Three chains, one number.
|
|
*
|
|
* The clone goes through a JSON round-trip because that is what `siteClone()`
|
|
* and the export actually produce: if canonicalisation ever dropped or
|
|
* reshaped something wind-relevant, the editor would score a yard whose
|
|
* weather nobody could play. This file does not import `editor.js` — the pin
|
|
* is about the two WIND CHAINS, and importing A's page would make lane/b's
|
|
* selftest unable to run on lane/b.
|
|
*
|
|
* ── WHERE the probe goes: C's knobs, and the three wrong answers before them ──
|
|
*
|
|
* **This pin uses Lane C's knobs, not the ones I proposed.** C co-owns gate
|
|
* 2.3, measured my proposal, and moved all three; converging beats landing a
|
|
* second pin that disagrees, so the numbers below are theirs.
|
|
*
|
|
* site site_02_corner_block the only yard with a shipped venturi
|
|
* storm storm_02_wildnight (mine, kept)
|
|
* probe (-6, y, 0) the authored throat centre — site geometry,
|
|
* not a magic number; speedAt ignores y
|
|
* second t = 60.0 the alignment plateau, where the funnel is
|
|
* worth about a third of the answer
|
|
* match exact `===`
|
|
*
|
|
* I proposed backyard_01 / bed corner / t=30. All three were wrong, and it is
|
|
* worth writing down because they are the SAME failure three times:
|
|
*
|
|
* 1. backyard_01 declares `venturi: []`. There, "setVenturi called with an
|
|
* empty list" and "setVenturi never called" are the same number — the
|
|
* regression the pin exists to catch is invisible. (Found while measuring;
|
|
* it is why the pin moved to site_02 at all.)
|
|
* 2. site_02's BED cannot see the funnel either — the throat's disc doesn't
|
|
* reach it. Measured Δ 0.0000 m/s, independently by both of us.
|
|
* 3. t = 30 is a second where the wildnight's direction does not line up with
|
|
* the gap: C measured the funnel worth 0.4% at the throat there. **This one
|
|
* I missed.** My throat measurement read a healthy Δ +4.948 m/s and I
|
|
* trusted it — but that was on `storm_03b_earlybuster`, not on the
|
|
* wildnight this pin flies. A sensitivity measured on one storm says
|
|
* nothing about another, and I generalised across exactly that gap. The
|
|
* mutation check I was proud of would have passed on the wrong storm.
|
|
*
|
|
* A pin at 0.4% passes with the funnel wired backwards. Hence the vacuity
|
|
* guard below: it re-measures what the funnel is WORTH at the pin and fails
|
|
* under 25% — the check that would have caught my proposal on its own.
|
|
*
|
|
* ── The bed is kept, as a SECOND probe, on C's offer ───────────────────────
|
|
*
|
|
* C offered to carry the garden bed as a second probe rather than the one, and
|
|
* I took it: what the sail SHADES is what the audit is ultimately about, and
|
|
* equality there is a real claim even though the funnel is not what decides it.
|
|
* It is labelled honestly and carries no funnel tripwire, because it cannot. A
|
|
* second probe that measures a different thing is worth having; a first probe
|
|
* that measures nothing is not.
|
|
*
|
|
* A finding worth more than the pin, which C reached independently: the corner
|
|
* block's funnel does not reach its garden bed at all. It bites the RIGGING
|
|
* ZONE — what the site JSON's own comment says ("q1 is 3.5 m away, inside the
|
|
* radius"). The funnel on that yard is a HARDWARE-LOAD story, not a
|
|
* garden-exposure story, and the score card should be read that way.
|
|
*
|
|
* ── Vacuous-pass guard ─────────────────────────────────────────────────────
|
|
*
|
|
* `a === b` also passes when both are 0, or both are garbage from a chain that
|
|
* silently did nothing. Every equality assert here is paired with a liveness
|
|
* assert (finite, non-trivial) and every claim of sensitivity is paired with a
|
|
* mutation assert. An assert that cannot fail is documentation, and
|
|
* documentation cannot fail.
|
|
*/
|
|
|
|
import * as THREE from '../../web/world/vendor/three.module.js';
|
|
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
|
import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.js';
|
|
// main.js's OWN router, not a retyped copy of its two wiring lines — C's
|
|
// correction, and the right call: a pin that retypes the thing it pins agrees
|
|
// with itself by construction.
|
|
import { createWindRouter } from '../../web/world/js/main.js';
|
|
import { buildScoringWorld } from './scorecard.js';
|
|
import { auditSweep, auditSweepAsync } from './sweep.js';
|
|
|
|
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
|
|
|
/**
|
|
* GATE 2.3's pinned knobs — Lane C's, adopted. One copy, both lanes, no drift.
|
|
* If either half moves these, it moves them here and says so in THREADS.
|
|
*/
|
|
export const PIN = {
|
|
site: 'site_02_corner_block',
|
|
storm: 'storm_02_wildnight',
|
|
t: 60.0,
|
|
probeY: 0,
|
|
/** The funnel must be worth at least this much AT THE PIN or the pin is
|
|
* decoration. C's threshold, shared rather than re-chosen. */
|
|
MIN_FUNNEL_SHARE: 0.25,
|
|
};
|
|
|
|
/** Probe sets per yard. Each probe says what it is ALLOWED to claim. */
|
|
const PROBES = {
|
|
site_02_corner_block: {
|
|
// THE pin: the authored throat centre, where the funnel decides the answer
|
|
throat: { x: -6, z: 0 },
|
|
// C's offered SECOND probe — what the sail shades. Funnel-blind by
|
|
// geometry (Δ 0.0000), so it carries equality only, and says so.
|
|
bed: 'gardenBed',
|
|
tree: { x: 7, z: -1 }, // tr1 — the shelter half
|
|
},
|
|
backyard_01: {
|
|
bed: 'gardenBed',
|
|
tree: { x: -9, z: 2 }, // t1, the gum whose sway is dynamic load
|
|
},
|
|
};
|
|
|
|
const vec = (p) => new THREE.Vector3(p.x, PIN.probeY, p.z);
|
|
const resolveProbes = (name, world) => Object.fromEntries(
|
|
Object.entries(PROBES[name]).map(([k, v]) =>
|
|
[k, vec(v === 'gardenBed' ? { x: world.gardenBed.x, z: world.gardenBed.z } : v)]));
|
|
|
|
/**
|
|
* The GAME chain: main.js's OWN router, wired by main.js's own two lines
|
|
* (loadSiteInto — `setVenturi(siteDef.wind.venturi)` then
|
|
* `setSheltersFromTrees(anchors.filter(type === 'tree'))`).
|
|
*/
|
|
async function gameChain(siteName, stormDef) {
|
|
const site = await loadSite(siteName);
|
|
const world = createWorld(new THREE.Scene(), { wind: stubProxy(), site });
|
|
await world.dress();
|
|
const inner = createWind(stormDef);
|
|
const router = createWindRouter([inner]);
|
|
router.use(inner);
|
|
router.setVenturi(site.wind?.venturi ?? []);
|
|
router.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
|
return { site, world, wind: router };
|
|
}
|
|
|
|
/** The EDITOR chain: a siteClone-shaped object through the scoring world. */
|
|
async function editorChain(siteName, stormDef) {
|
|
// What EDITOR.siteClone() hands over: a deep clone that has been through the
|
|
// canonical export shape. JSON round-trip stands in for the key ordering —
|
|
// the pin is that this survives as the SAME WEATHER.
|
|
const clone = JSON.parse(JSON.stringify(await loadSite(siteName)));
|
|
const built = await buildScoringWorld(clone);
|
|
assert(built.dressed, `gate 2.3: scoring world for ${siteName} did not dress (${built.dressError}) — `
|
|
+ 'an undressed yard has no fascia hints and is a different game');
|
|
return { site: clone, built, wind: windForSite(stormDef, clone, built.anchors) };
|
|
}
|
|
|
|
function stubProxy() {
|
|
return {
|
|
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
|
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
|
gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {}, setVenturi() {},
|
|
};
|
|
}
|
|
|
|
export async function buildScorecardTests() {
|
|
const tests = [];
|
|
|
|
// ── the two shipped yards ────────────────────────────────────────────────
|
|
// The pinned case first (C's knobs). backyard_01 rides along because
|
|
// equality on a funnel-less yard is still a real claim about the clone and
|
|
// the dress — it just carries no funnel tripwire, and says so.
|
|
const cases = [
|
|
{ site: PIN.site, storm: PIN.storm },
|
|
{ site: 'backyard_01', storm: 'storm_02_wildnight' },
|
|
];
|
|
|
|
const measured = {};
|
|
for (const c of cases) {
|
|
const stormDef = await loadStorm(c.storm);
|
|
const game = await gameChain(c.site, stormDef);
|
|
const editor = await editorChain(c.site, stormDef);
|
|
|
|
const gProbes = resolveProbes(c.site, game.world);
|
|
const eProbes = resolveProbes(c.site, editor.built.world);
|
|
measured[c.site] = { stormDef, editor, probes: eProbes };
|
|
|
|
tests.push([`gate 2.3: ${c.site} — both chains agree where the yard's probes ARE`, () => {
|
|
for (const k of Object.keys(gProbes)) {
|
|
assert(gProbes[k].equals(eProbes[k]),
|
|
`gate 2.3: the chains disagree about the '${k}' probe on ${c.site} — `
|
|
+ `game (${gProbes[k].x},${gProbes[k].z}) vs editor (${eProbes[k].x},${eProbes[k].z}). `
|
|
+ 'A pin on two different points is not a pin.');
|
|
}
|
|
}]);
|
|
|
|
for (const k of Object.keys(gProbes)) {
|
|
const gSpeed = game.wind.speedAt(gProbes[k], PIN.t);
|
|
const eSpeed = editor.wind.speedAt(eProbes[k], PIN.t);
|
|
const gVec = game.wind.sample(gProbes[k], PIN.t, new THREE.Vector3());
|
|
const eVec = editor.wind.sample(eProbes[k], PIN.t, new THREE.Vector3());
|
|
|
|
tests.push([`gate 2.3: ${c.site} @${k} — editor-scored wind === game-played wind (t=${PIN.t})`, () => {
|
|
// liveness first: `a === b` on two zeroes is not agreement, it is silence
|
|
assert(Number.isFinite(gSpeed) && gSpeed > 1,
|
|
`gate 2.3: the GAME chain read ${gSpeed} m/s at '${k}' — that is not a storm, it is a `
|
|
+ 'broken chain, and an equality assert against it would pass vacuously');
|
|
assert(gSpeed === eSpeed,
|
|
`gate 2.3 FAILED on ${c.site} @${k}: the editor would score a wind the game never plays.\n`
|
|
+ ` game ${gSpeed}\n editor ${eSpeed}\n delta ${eSpeed - gSpeed}\n`
|
|
+ 'Both sides go through windForSite(); if they differ, the INPUTS differ — check the '
|
|
+ 'venturi list survived the clone, and that the scoring world dressed.');
|
|
assert(gVec.x === eVec.x && gVec.y === eVec.y && gVec.z === eVec.z,
|
|
`gate 2.3: speeds matched but DIRECTIONS did not on ${c.site} @${k} — `
|
|
+ `game (${gVec.x},${gVec.y},${gVec.z}) vs editor (${eVec.x},${eVec.y},${eVec.z}). `
|
|
+ 'A sail cares which way the wind blows, so scalar agreement alone is not the pin.');
|
|
}]);
|
|
}
|
|
}
|
|
|
|
// backyard_01 carries no funnel, and the pin must SAY so rather than let a
|
|
// future reader assume the funnel-off tripwire covers both yards.
|
|
tests.push(['gate 2.3: backyard_01 declares no venturi (so site_02 carries the funnel tripwire)', () => {
|
|
const v = measured['backyard_01'].editor.site.wind?.venturi ?? [];
|
|
assert(v.length === 0,
|
|
`backyard_01 now declares ${v.length} venturi. That is not a failure — but the funnel `
|
|
+ 'mutation check below lives on site_02 BECAUSE this yard had none, and if this yard grew '
|
|
+ 'weather the pin should cover it too. Update the probe table.');
|
|
}]);
|
|
|
|
// ── the mutation checks, on the yard that HAS weather ────────────────────
|
|
// These are what stop the pin above from being decoration. Each one breaks an
|
|
// input that a real harness has actually broken, and demands the number move.
|
|
const fun = measured['site_02_corner_block'];
|
|
const baseWind = () => windForSite(fun.stormDef, fun.editor.site, fun.editor.built.anchors);
|
|
|
|
/** Minimum move a mutation must produce to count as "this probe can feel it".
|
|
* Not `!== 0`: a floating-point hair would satisfy that while the probe sat
|
|
* effectively blind. Measured deltas here are ~4.9 m/s, so 1.0 is a floor
|
|
* with two orders of headroom, not a threshold anyone tuned to pass. */
|
|
const MIN_MOVE = 1.0;
|
|
|
|
// ── C's vacuity guard, adopted — threshold and all ───────────────────────
|
|
// The strongest of the three checks, and the one that would have caught my
|
|
// proposal on its own. A mutation check asks "did the number MOVE"; this asks
|
|
// "is the funnel worth ENOUGH here that a wiring bug couldn't hide in the
|
|
// rounding". At t=30 on this storm the answer was 0.4%.
|
|
tests.push([`GATE 2.3 guard: the funnel is worth ≥${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% at the pinned probe/second`, () => {
|
|
const probe = fun.probes.throat;
|
|
const on = baseWind().speedAt(probe, PIN.t);
|
|
const off = windForSite(fun.stormDef, { ...fun.editor.site, wind: { venturi: [] } },
|
|
fun.editor.built.anchors).speedAt(probe, PIN.t);
|
|
const share = (on - off) / on;
|
|
assert(Number.isFinite(share) && share >= PIN.MIN_FUNNEL_SHARE,
|
|
`gate 2.3 GUARD FAILED: at (${probe.x},${probe.z}) t=${PIN.t} on ${PIN.storm} the funnel is `
|
|
+ `worth ${(share * 100).toFixed(2)}% (${off.toFixed(2)} → ${on.toFixed(2)} m/s), under the `
|
|
+ `${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% floor. The equality pin is now decoration — it `
|
|
+ 'would pass with setVenturi deleted. Do NOT lower this floor: re-measure and move the '
|
|
+ 'second, the way C moved it off t=30 (0.4%) onto the alignment plateau.');
|
|
}]);
|
|
|
|
tests.push(['gate 2.3 mutation: dropping the venturi MOVES the throat (funnel-off tripwire)', () => {
|
|
const probe = fun.probes.throat;
|
|
const before = baseWind().speedAt(probe, PIN.t);
|
|
const starved = { ...fun.editor.site, wind: { venturi: [] } };
|
|
const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t);
|
|
assert(Math.abs(before - after) >= MIN_MOVE,
|
|
`gate 2.3 mutation FAILED: starving site_02 of its venturi moved the THROAT probe by `
|
|
+ `${(before - after).toFixed(4)} m/s (${before} → ${after}). This probe cannot SEE the `
|
|
+ 'funnel, so the equality pin above would stay green through exactly the funnel-off bug it '
|
|
+ 'exists to catch — the one that moved the S13 headline 91.5 → 39.8. This already happened '
|
|
+ 'THREE times on this pin (backyard_01 has no funnel; site_02\'s BED is 6.08 m from a '
|
|
+ 'radius-5 throat; t=30 on the wildnight is worth 0.4%). Re-measure and move the probe; '
|
|
+ 'do not relax the assert.');
|
|
}]);
|
|
|
|
tests.push(['gate 2.3 mutation: hiding the trees MOVES the tree probe (shelter tripwire)', () => {
|
|
// setSheltersFromTrees filters on type; anchors that no longer say "tree"
|
|
// register no shelter. The frozen-tree landmine's cousin — the wind arrives
|
|
// unsheltered and the yard is easier than the one that ships.
|
|
const probe = fun.probes.tree;
|
|
const before = baseWind().speedAt(probe, PIN.t);
|
|
const noTrees = fun.editor.built.anchors.map((a) => ({ ...a, type: a.type === 'tree' ? 'post' : a.type }));
|
|
const after = windForSite(fun.stormDef, fun.editor.site, noTrees).speedAt(probe, PIN.t);
|
|
assert(Math.abs(before - after) >= MIN_MOVE,
|
|
`gate 2.3 mutation FAILED: hiding every tree moved the tree probe by `
|
|
+ `${(before - after).toFixed(4)} m/s (${before} → ${after}) — the ANCHOR half of `
|
|
+ 'windForSite is not reaching this probe, so a harness that forgot tree shelters would '
|
|
+ 'pin green.');
|
|
}]);
|
|
|
|
tests.push(['gate 2.3 (2nd probe): the corner block\'s funnel does NOT reach its garden bed — recorded, not a bug', () => {
|
|
// Not a defect — it is what the site JSON's own comment describes ("q1 is
|
|
// 3.5 m away, inside the radius"): the funnel is aimed at the RIGGING
|
|
// ZONE, so it is a hardware-load story rather than a garden-exposure one.
|
|
// Pinned because it is load-bearing for anyone reading the score card: if
|
|
// this ever changes, the bed probe silently becomes funnel-sensitive and
|
|
// every garden number on that yard moves with it.
|
|
const probe = fun.probes.bed;
|
|
const before = baseWind().speedAt(probe, PIN.t);
|
|
const starved = { ...fun.editor.site, wind: { venturi: [] } };
|
|
const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t);
|
|
assert(before === after,
|
|
`The funnel now reaches site_02's garden bed: ${before} → ${after} with the venturi removed. `
|
|
+ 'That is a real design change (the bed sat 6.08 m from a radius-5 throat and felt nothing). '
|
|
+ 'Every garden number on this yard just moved — re-baseline the audit and tell A/D.');
|
|
}]);
|
|
|
|
// ── SPRINT15 gate 2.1: the chunked sweep is the SAME sweep ───────────────
|
|
// scoreSite runs auditSweepAsync now — same flights, but the candidate loop
|
|
// yields so the editor page paints progress instead of freezing for 75 s.
|
|
// The contract is that yielding is PURE: any yieldEvery produces numbers
|
|
// byte-identical to the sync sweep. These asserts are the tripwire for
|
|
// anyone who later threads state across candidates (a reused rig, a shared
|
|
// accumulator) — the exact class of bug a chunk boundary would expose.
|
|
//
|
|
// Synthetic five-anchor yard (multiple candidates, so chunk boundaries land
|
|
// MID-list), 8 s storm — cheap on purpose; the real-yard reproduction lives
|
|
// in THREADS (site_02 + site_03 cards pinned before and after the refactor).
|
|
const YARD5 = [
|
|
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
|
|
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
|
|
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
|
|
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
|
|
{ id: 'a5', type: 'post', pos: { x: 0, y: 3.9, z: 4 } },
|
|
].map((a) => ({ ...a, sway: () => a.pos }));
|
|
const YARD5_BED = { x: 0, z: 0, w: 4, d: 4 };
|
|
const YARD5_STORM = {
|
|
id: 'chunk_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
|
|
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
|
|
};
|
|
// Strip nothing, hide nothing: the whole result must survive comparison.
|
|
const sweepJSON = (r) => JSON.stringify({
|
|
cands: r.cands, rows: r.rows, winners: r.winners,
|
|
marginalWinners: r.marginalWinners, verdict: r.verdict,
|
|
});
|
|
|
|
const syncResult = auditSweep({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] });
|
|
const ticks = [];
|
|
const chunk1 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
|
|
yieldEvery: 1, onProgress: (p) => ticks.push({ ...p }) });
|
|
const chunk3 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
|
|
yieldEvery: 3 });
|
|
|
|
tests.push(['gate 2.1: the yielding sweep === the sync sweep, byte for byte', () => {
|
|
// vacuity guard first — a yard whose sweep found nothing would make the
|
|
// equality below a comparison of two empty objects
|
|
assert(syncResult.cands.length >= 3,
|
|
`gate 2.1: the synthetic yard swept only ${syncResult.cands.length} candidate(s) — `
|
|
+ 'not enough list for a chunk boundary to land mid-way; the purity assert is decoration. '
|
|
+ 'Fix the fixture, not the assert.');
|
|
assert(sweepJSON(chunk1) === sweepJSON(syncResult),
|
|
'gate 2.1 FAILED: auditSweepAsync(yieldEvery 1) returned different numbers than auditSweep '
|
|
+ 'on the same yard and storm. Yielding must be WHEN the page breathes, never WHAT gets '
|
|
+ 'computed — some state is leaking across candidates.');
|
|
}]);
|
|
|
|
tests.push(['gate 2.1: perturbing the chunk boundary moves NOTHING (yieldEvery 3 === yieldEvery 1)', () => {
|
|
assert(sweepJSON(chunk3) === sweepJSON(chunk1),
|
|
'gate 2.1 FAILED: changing yieldEvery (1 → 3) changed the sweep\'s numbers. The chunk '
|
|
+ 'boundary is a paint schedule, not an input — if moving it moves a number, a candidate '
|
|
+ 'flight is reading something a previous chunk wrote.');
|
|
}]);
|
|
|
|
tests.push(['gate 2.1: progress ticks fire, count monotonically, and reach the total', () => {
|
|
const sweepTicks = ticks.filter((p) => p.phase === 'sweep');
|
|
assert(sweepTicks.length === syncResult.cands.length,
|
|
`gate 2.1: expected one 'sweep' tick per candidate (${syncResult.cands.length}), got `
|
|
+ `${sweepTicks.length} — a progress line that undercounts is the looks-wired-isn't `
|
|
+ 'disease on the UI layer.');
|
|
sweepTicks.forEach((p, i) => {
|
|
assert(p.done === i + 1 && p.total === syncResult.cands.length,
|
|
`gate 2.1: tick ${i} read done=${p.done}/total=${p.total}, expected ${i + 1}/${syncResult.cands.length}`);
|
|
});
|
|
}]);
|
|
|
|
// ── the clone is the same weather as the file ────────────────────────────
|
|
tests.push(['gate 2.3: the export clone carries the funnel (site_02 venturi survives the round-trip)', () => {
|
|
const v = fun.editor.site.wind?.venturi ?? [];
|
|
assert(v.length === 1,
|
|
`gate 2.3: the cloned site_02 has ${v.length} venturi, not 1 — the editor would score, and `
|
|
+ 'export, a corner block with no weather. The funnel lives in the SITE def; if the clone '
|
|
+ 'loses it the whole yard silently becomes an easier one.');
|
|
assert(v[0].gain === 1.5 && v[0].axis === 2.1,
|
|
`gate 2.3: the cloned venturi reads gain ${v[0].gain} axis ${v[0].axis}, not gain 1.5 axis 2.1.`);
|
|
}]);
|
|
|
|
return tests;
|
|
}
|