Files
RingBRP/tools/check-behaviour.mjs
T
slaguru666andClaude Opus 5 d0db7954bc check-figures: the guard's body belongs behind the entry-point check
The fold exported scanDocument for the string tests, and importing it also ran
the guard. check-behaviour imports that module, so a figure defect called
process.exit(1) inside check-behaviour: it reported check-figures' failure under
its own name having run zero of its 108 tests.

The build went red, which is why this was survivable, but it went red in the
wrong place and every behavioural test was silently not running while appearing
to. A guard that stops another guard from running, and cannot say so, is the
worst version of the fault this file exists to catch.

Body now sits behind import.meta.main, the idiom step5-split.mjs already uses.
Importing yields the four readers and nothing else. Tested by spawning a fresh
process, because the property is "importing has no effect" and a source
assertion would pass on a file that grew a second side effect elsewhere.

Reverting the check fails exactly one test. With a defect planted,
check-behaviour runs 108 green and check-figures fails in its own slot.

Mine, introduced by R-307. R-309.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:39:46 +01:00

1031 lines
56 KiB
JavaScript

/**
* BEHAVIOURAL REGRESSION TESTS.
*
* The other three guards are static: they prove that the tables agree with each other
* and that no rule is written twice. They cannot prove that a rule is READ, and every
* defect this project has shipped was of that kind — a value computed and never used,
* found by playing rather than by reading.
*
* Playing found them; this file is what stops them coming back. Every numbered defect
* that can be reproduced without a browser gets a deterministic test here, named for
* the finding, so the log stops being the only evidence that any of it works.
*
* Deterministic on purpose: R-246 was originally confirmed by rolling a hundred and
* twenty times waiting for a 99, which is not a test, it is a hope.
*
* WHAT THIS FILE CANNOT REACH, and where those things are checked instead:
* - anything inside generateCharacter, resolveContact or the sheets, because they
* need `game`, Documents and compendia. Those are verified in a running Foundry
* and the results recorded in docs/REVIEW_LOG.md.
* - bonus-point eligibility and random species, for the same reason. Both were
* confirmed against a live world: an officer's points now land only on trained
* skills, and twelve rolls of "random" produced baseline/vesh/cadence rather
* than twelve baselines.
* Pretending otherwise would make this file look more complete than it is.
*/
import { strict as assert } from "node:assert";
import * as RULES from "../rules.mjs";
import { readFileSync } from "node:fs";
import { LOCATION_TABLES as TABLES } from "../anatomy.mjs";
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE }
from "../postings.mjs";
import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM }
from "./scenario-starter.mjs";
import { castMarkersIn, castLikeIn } from "./declared-cast.mjs";
import { scanDocument } from "./check-figures.mjs";
import { spawnSync } from "node:child_process";
import { tagsIn, classifiedFiles, playableFiles } from "./check-scenarios.mjs";
import { beatsIn, beatLikeIn } from "./outcome-coverage.mjs";
import { step5Split, split as step5Of, THING_RATING } from "./step5-split.mjs";
let passed = 0, failed = 0;
// Async tests are awaited in order rather than fired off — a failure that lands after
// the summary has printed is a failure nobody sees.
const pending = [];
const test = (name, fn) => {
pending.push(async () => {
try { await fn(); passed++; }
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
});
};
const runAll = async () => { for (const t of pending) await t(); };
/* ---------------------------------------------------------------- *
* Contact — R-246: a fumbled greeting used to cost nothing
* ---------------------------------------------------------------- */
// The engine and this file both call impressionDeltaFor. Re-implementing it here
// would have been the exact duplication check-rules exists to forbid — and the
// first draft of this file did precisely that.
const impressionAfter = (stage, level, before) =>
before + RULES.impressionDeltaFor(stage, level);
test("R-246 a fumbled SIGNAL lowers impression", () =>
assert.equal(impressionAfter("signal", "fumble", 0), -1));
test("R-246 a fumbled OFFER lowers impression by two", () =>
assert.equal(impressionAfter("offer", "fumble", 0), -2));
test("a successful SIGNAL does not raise impression", () =>
assert.equal(impressionAfter("signal", "success", 0), 0));
// READ's number counts FACTS LEARNED. Paying impression for it would reward research
// as though it were diplomacy, which is the bug the narrow fix avoids.
test("a successful READ does not raise impression", () =>
assert.equal(impressionAfter("read", "success", 0), 0));
test("a critical READ does not raise impression", () =>
assert.equal(impressionAfter("read", "critical", 0), 0));
test("an OFFER is the only thing that raises impression", () =>
assert.equal(impressionAfter("offer", "success", 0), 2));
/* ---------------------------------------------------------------- *
* Trades — prior career does not scale with agency rank
* ---------------------------------------------------------------- */
test("trade bands are the same at every rank", () => {
for (const tier of Object.keys(TIERS)) {
assert.deepEqual(TRADE_BANDS.core, [50, 65], `core moved at ${tier}`);
assert.deepEqual(TRADE_BANDS.support, [30, 50], `support moved at ${tier}`);
}
});
test("a trade is not rated on the agency tier bands", () => {
for (const [id, T] of Object.entries(TIERS)) {
assert.notDeepEqual(TRADE_BANDS.core, T.core,
`TRADE_BANDS.core is identical to tier ${id} — the coupling is back`);
}
});
test("induction improves with service", () => {
const order = ["probationary", "officer", "senior", "veteran"];
let prev = -1;
for (const t of order) {
const band = INDUCTION.bandByTier[t];
assert.ok(band, `no induction band for ${t}`);
assert.ok(band[0] > prev, `induction does not rise at ${t}`);
assert.ok(band[1] > band[0], `induction band inverted at ${t}`);
prev = band[0];
}
});
test("induction stays below a trade's own expertise at every rank", () => {
for (const [t, band] of Object.entries(INDUCTION.bandByTier)) {
assert.ok(band[0] <= TRADE_BANDS.core[0],
`induction at ${t} starts above prior career — the halves have inverted`);
}
});
/* ---------------------------------------------------------------- *
* Registers — every posting and trade is well formed
* ---------------------------------------------------------------- */
const CH = new Set(Object.keys(CHARACTERISTIC_DICE));
test("every posting and trade names a real characteristic", () => {
for (const [id, r] of Object.entries({ ...ROLES, ...TRADES })) {
assert.ok(CH.has(r.stat), `${id}: stat "${r.stat}" is not a characteristic key`);
}
});
// "mas" and "app" are the DISPLAY names of siz and cha. A posting declaring one would
// have passed the old hand-written guard and produced an undefined characteristic.
test("display names are not accepted as characteristic keys", () => {
assert.ok(!CH.has("mas") && !CH.has("app"),
"mas/app are labels, not keys — they must never be valid stats");
});
test("every trade has the same shape as a posting", () => {
for (const [id, t] of Object.entries(TRADES)) {
for (const f of ["label", "blurb", "stat", "core", "support", "talents", "kit"]) {
assert.ok(t[f] !== undefined, `trade ${id} has no ${f}`);
}
assert.equal(t.core.length, 5, `trade ${id} does not train five core skills`);
assert.equal(t.support.length, 5, `trade ${id} does not train five support skills`);
}
});
test("induction issues nothing it does not also teach the use of", () => {
// The catalogue check lives in check-kits; this asserts the pairing exists at all.
assert.ok(INDUCTION.skills.includes("firearm:pistol"),
"induction issues a sidearm and must teach it");
});
test("the characteristic dice cover exactly the eight the sheet shows", () => {
assert.equal(Object.keys(CHARACTERISTIC_DICE).length, 8);
for (const f of Object.values(CHARACTERISTIC_DICE)) {
assert.match(f, /^\d+d\d+(\+\d+)?$/, `"${f}" is not a dice formula`);
}
});
/* ---------------------------------------------------------------- *
* Rules that a play-through found, held down here instead
* ---------------------------------------------------------------- */
// R-231/232: the fire mode gates the burst rules.
test("R-231 a slow weapon cannot fire a burst", () =>
assert.deepEqual(RULES.burstSizesFor("slow"), []));
test("R-232 `fast` is a real fire mode", () =>
assert.equal(RULES.fireModeFor("fast").id, "fast"));
test("an unknown fire mode falls back to single, not to undefined", () =>
assert.equal(RULES.fireModeFor("nonsense").id, "single"));
// R-243: a helmet does not protect your legs.
test("R-243 a head piece does not armour a leg", () =>
assert.equal(RULES.locationArmourFor(0, [{ points: 5, coverage: "head" }], "leg"), 0));
test("R-243 unstated coverage still protects everything", () =>
assert.equal(RULES.locationArmourFor(0, [{ points: 5 }], "leg"), 5));
// The lamp rule: an unmeasured distance is not "in the light".
test("an unknown distance is not lit", () =>
assert.equal(RULES.lampCovers(null, 10), false));
test("a lamp lights someone standing inside its reach", () =>
assert.equal(RULES.lampCovers(4, 6), true));
// Shields: armour with a switch, and wear that is actually reachable.
test("a lowered shield is worth nothing", () =>
assert.equal(RULES.shieldArmourFor(4, { raised: false }), 0));
test("a broken shield is worth nothing even raised", () =>
assert.equal(RULES.shieldArmourFor(4, { raised: true, broken: true }), 0));
// R-245: wear must be reachable. A rule that only fires on a clean stop never fires.
test("R-245 a special costs a shield wear", () =>
assert.ok(RULES.shieldWearFor("special") > 0));
test("R-245 an ordinary blow costs a shield nothing", () =>
assert.equal(RULES.shieldWearFor("success"), 0));
// Graded defences: only a critical stops a critical.
test("a success against a critical still lands, one step down", () => {
const o = RULES.defenceOutcomeFor("critical", "success");
assert.equal(o.turnedAside, false);
assert.equal(o.landsAt, "special");
});
test("a critical against a critical turns it aside", () =>
assert.equal(RULES.defenceOutcomeFor("critical", "critical").turnedAside, true));
/* ---------------------------------------------------------------- *
* Borrowed Authority — a bad scope fails silently, which is the worst way
* ---------------------------------------------------------------- */
test("an in-scope action is in scope", () =>
assert.equal(RULES.scopeStanceFor("routine", "routine"), "in-scope"));
test("one tier beyond is a stretch", () =>
assert.equal(RULES.scopeStanceFor("routine", "privileged"), "stretch"));
test("two tiers beyond is impossible at any skill", () =>
assert.equal(RULES.scopeStanceFor("routine", "administrative"), "impossible"));
// An unrecognised scope ranks 0, so EVERYTHING becomes impossible and nothing says so.
test("an unrecognised scope makes everything impossible", () =>
assert.equal(RULES.scopeStanceFor("operate", "routine"), "impossible"));
// A scenario may carry no credential — but one that does must name a real tier, or
// every action through it comes back "impossible" and nothing says why.
test("the starter's credential, if it has one, names a real scope tier", () => {
if (!STARTER_AUTHORITY) return;
assert.ok(RULES.SCOPE_TIERS.includes(STARTER_AUTHORITY.scope),
`scope "${STARTER_AUTHORITY.scope}" is not a tier`);
});
/* ---------------------------------------------------------------- *
* Shipped case files — a standing with no group is an unreachable subsystem
* ---------------------------------------------------------------- */
// resolveContact looks up a GROUP ITEM and returns null without one, so a case file
// carrying a standing with no matching group has READ, SIGNAL and OFFER silently
// switched off. That is how PAPER WINDOW first shipped, with the contact scene as its
// centrepiece and no error anywhere. Asserted against the scenario DATA rather than the
// built pack, because the pack is a LevelDB that Foundry holds open while it runs — and
// a test that cannot read its subject must fail loudly, not skip quietly.
test("every standing on the starter case file has a group behind it", () => {
const groups = new Set(STARTER_GROUPS.map(g => g.groupId));
for (const id of STARTER_CASE.standings ?? []) {
assert.ok(groups.has(id),
`case file has a standing for "${id}" with no group — READ/SIGNAL/OFFER cannot run`);
}
});
// resolveLegDialog reads `leg.kind`. A case file that writes `kindId` falls through to
// a regex on the terrain prose, which works right up until somebody renames a phase.
test("starter phases name a kind the engine will read", () => {
const kinds = ["approach", "cordon", "containment", "extraction"];
for (const l of STARTER_CASE.legs) {
assert.ok(kinds.includes(l.kindId), `phase "${l.label}" has kind "${l.kindId}"`);
}
assert.equal(STARTER_CASE.legs.at(-1).kindId, "extraction",
"the last phase must be the extraction, or the return clock never moves");
});
// A floor naming a skill the catalogue does not have would silently create a skill
// nobody can roll — the sheet would show it and no rule would ever reach it.
test("every combat floor names a real skill", async () => {
const { SKILL_CATALOGUE } = await import("./content.mjs");
const known = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0])));
for (const t of STARTER_TEAM) {
for (const id of Object.keys(t.floors ?? {})) {
assert.ok(known.has(id), `${t.name}: floor "${id}" is not a skill`);
}
}
});
// The scenario promises every agent can fight. Assert the promise rather than trust it.
test("every starter agent can fight", () => {
for (const t of STARTER_TEAM) {
const f = t.floors ?? {};
const gun = Object.entries(f).find(([k]) => k.startsWith("firearm:"));
const melee = Object.entries(f).find(([k]) => k.startsWith("melee_weapon:"));
assert.ok(gun && gun[1] >= 40, `${t.name} has no usable firearm floor`);
assert.ok(melee && melee[1] >= 40, `${t.name} has no usable melee floor`);
assert.ok((f.brawl ?? 0) >= 40, `${t.name} has no usable Brawl floor`);
assert.ok((f.dodge ?? 0) >= 40, `${t.name} has no usable Dodge floor`);
}
});
test("every starter group is usable by the contact engine", () => {
for (const g of STARTER_GROUPS) {
assert.ok(g.groupId && g.name, "a group needs an id and a name");
assert.ok(g.desires?.length, `${g.groupId}: a group with no desire cannot be offered anything`);
assert.ok(g.correctOfferForm, `${g.groupId}: no correct offer form`);
for (const t of g.taboos ?? []) {
assert.ok(t.id && t.text, `${g.groupId}: a taboo needs an id and text`);
}
}
});
/* ---------------------------------------------------------------- *
* R-250 — every design token used must be declared, and on :root
* ---------------------------------------------------------------- */
// An undefined custom property is not a CSS error. `var(--rb-nothing)` resolves to
// "unset", so a rule half-applies: paddings land, colours vanish, and nothing anywhere
// says so. That is exactly how the journals shipped unstyled. Read the stylesheet.
test("every --rb- token used in the stylesheet is declared", async () => {
const { readFile } = await import("node:fs/promises");
const css = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
const declared = new Set([...css.matchAll(/^\s*(--rb-[\w-]+)\s*:/gm)].map(m => m[1]));
const used = new Set([...css.matchAll(/var\(\s*(--rb-[\w-]+)/g)].map(m => m[1]));
const missing = [...used].filter(t => !declared.has(t));
assert.equal(missing.length, 0,
`used but never declared: ${missing.join(", ")}`);
});
test("R-250 the tokens are declared on :root, not on a component class", async () => {
const { readFile } = await import("node:fs/promises");
const css = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
// The block that declares the palette must be selected by :root, so any new
// component inherits it without having to be added to a list.
const block = css.match(/([^{}]*)\{[^{}]*--rb-void\s*:/);
assert.ok(block, "could not find the block declaring --rb-void");
assert.ok(/(^|\s|,):root\s*$/.test(block[1].trim()) || block[1].trim() === ":root",
`the palette is declared on "${block[1].trim().slice(0, 60)}" rather than :root`);
});
// The duty roster is ten pregens handed to players who did not build them, so a
// wrong posting key or a missing face is not a cosmetic problem — it is a sheet
// somebody is asked to play from. The build throws on both; this says which one
// and why before the build gets that far.
test("every roster agent names a real posting or trade and has a portrait on disk", async () => {
const { ROSTER } = await import("./roster.mjs");
const { ROLES, TRADES } = await import("../postings.mjs");
const { existsSync } = await import("node:fs");
assert.ok(ROSTER.length >= 10, "the roster has shrunk");
for (const a of ROSTER) {
const register = a.trade ? TRADES : ROLES;
const key = a.trade ?? a.role;
assert.ok(register[key], `${a.name}: no ${a.trade ? "trade" : "posting"} "${key}"`);
assert.ok(existsSync(new URL(`../art/portraits/${a.portrait}.webp`, import.meta.url)),
`${a.name}: no portrait at art/portraits/${a.portrait}.webp`);
}
// Both halves of character creation must be on the sheet, and neither may dwarf the
// other, so a group choosing from it meets both without anyone having to explain them.
// Not an exact 50/50: that was an accident of the roster being exactly ten, and
// enforcing it would mean picking characters to satisfy a ratio.
const trades = ROSTER.filter(a => a.trade).length;
const postings = ROSTER.length - trades;
assert.ok(trades > 0 && postings > 0, "the roster shows only one half of character creation");
assert.ok(Math.max(trades, postings) <= Math.min(trades, postings) * 2,
`${trades} trades against ${postings} postings — one half is dwarfing the other`);
// Two agents with the same posting are two players who thought they had picked
// different characters and did not find out until the second scene.
const jobs = ROSTER.map(a => a.trade ?? a.role);
assert.equal(new Set(jobs).size, jobs.length,
`duplicate job on the roster: ${jobs.find((j, i) => jobs.indexOf(j) !== i)}`);
});
// The "before" set is only worth having if it is the SAME ten people, so it is keyed
// off the roster rather than written out again. The build proves the harder property —
// that a civilian carries nothing the department issued — because that is where the
// expanded objects exist; this proves the table they are built from.
test("every roster agent has a before-the-department entry, and the set covers every trade", async () => {
const { ROSTER, BEFORE } = await import("./roster.mjs");
const { TRADES } = await import("../postings.mjs");
const keys = new Set(ROSTER.map(a => a.key));
for (const a of ROSTER) {
const b = BEFORE[a.key];
assert.ok(b, `${a.name} has no "before" entry`);
assert.ok(TRADES[b.trade], `${a.name} was a "${b.trade}", which is not a trade`);
assert.ok(b.job && b.bio, `${a.name}: a before entry needs a job and a biography`);
assert.ok(b.age < a.age.chronological, `${a.name} is not younger before than after`);
}
for (const k of Object.keys(BEFORE)) assert.ok(keys.has(k), `${k} is a before with nobody after it`);
// Covering every trade is what makes this set a demonstration of the trade half of
// character creation. It was "exactly once" while the roster was exactly ten; the
// property that actually matters is that no trade is missing.
const seen = new Set(Object.values(BEFORE).map(b => b.trade));
const missing = Object.keys(TRADES).filter(t => !seen.has(t));
assert.equal(missing.length, 0, `no roster agent came out of: ${missing.join(", ")}`);
});
// A stat line in prose that disagrees with the actor the build generates is this
// project's oldest failure mode wearing a different hat: the number is written down and
// nothing reads it. LAST ADMISSION shipped with "HP 16, armour 1, shears 1d8+1" on the
// page and a 15-hit-point man carrying a 1d4+2 utility knife in the pack, so a GM running
// off the token had a different fight from the one written. Parse the page and check it.
test("LAST ADMISSION's fight numbers match the actor the build will generate", async () => {
const { LA_PAGES, LA_CAST } = await import("./scenario-lastadmission.mjs");
const { WEAPONS, hitPoints } = await import("./content.mjs");
const html = LA_PAGES.map(p => p.content).join("");
const m = html.match(/HP (\d+), armour (\d+), shears (\d+d\d+(?:\+\d+)?)/);
assert.ok(m, "the page no longer states Old Marrow's HP, armour and shear damage");
const [, hp, armour, dmg] = m;
const marrow = LA_CAST.find(c => c.name === "Old Marrow");
assert.equal(Number(hp), hitPoints(marrow.ch.con, marrow.ch.siz),
"the page's HP is not what CON and SIZ will actually generate");
assert.equal(Number(armour), marrow.naturalArmour,
"the page's armour is not the actor's natural armour");
// The shears are whichever real catalogue weapon he is issued — prose does not get to
// invent damage for an item the actor is holding.
const carried = marrow.weapons.filter(k => k !== "punch");
assert.equal(carried.length, 1, "Old Marrow should carry exactly one weapon besides his fists");
const w = WEAPONS.find(x => x.key === carried[0]);
assert.ok(w, `Old Marrow carries "${carried[0]}", which is not in the catalogue`);
assert.equal(dmg, w.dmg,
`the page says the shears do ${dmg}; the ${w.name} on his sheet does ${w.dmg}`);
});
// packFolders was written when there were ten packs and never revisited, so seven of
// seventeen sat loose in the sidebar — the same defect as always, a declaration nothing
// re-reads. Foundry does not warn: a pack missing from the declaration is simply not
// filed, and a folder naming a pack that does not exist is silently skipped.
test("every compendium pack is declared in exactly one packFolders folder", async () => {
const { readFile } = await import("node:fs/promises");
const sys = JSON.parse(await readFile(new URL("../system.json", import.meta.url), "utf8"));
const declared = [];
const walk = f => { declared.push(...(f.packs ?? [])); (f.folders ?? []).forEach(walk); };
(sys.packFolders ?? []).forEach(walk);
const packs = sys.packs.map(p => p.name);
const missing = packs.filter(n => !declared.includes(n));
assert.equal(missing.length, 0, `not in any folder, so loose in the sidebar: ${missing.join(", ")}`);
const phantom = declared.filter(n => !packs.includes(n));
assert.equal(phantom.length, 0, `folder names a pack that does not exist: ${phantom.join(", ")}`);
const dupes = declared.filter((n, i) => declared.indexOf(n) !== i);
assert.equal(dupes.length, 0, `declared in more than one folder: ${dupes.join(", ")}`);
});
// Talent and Art were added to template.json and then forgotten twice over: no
// TYPES.Item.* label, so the sheet printed the raw key under the name, and no branch in
// item-sheet.hbs, so the Detail tab was blank over a document full of real data. Eleven
// of thirteen types were handled, which is exactly the shape a human eye skips.
test("every declared document type has a label and a sheet section", async () => {
const { readFile } = await import("node:fs/promises");
const here = u => new URL(u, import.meta.url);
const tmpl = JSON.parse(await readFile(here("../template.json"), "utf8"));
const lang = JSON.parse(await readFile(here("../lang/en.json"), "utf8"));
const itemSheet = await readFile(here("../templates/item-sheet.hbs"), "utf8");
const system = await readFile(here("../ringbrp.mjs"), "utf8");
for (const doc of ["Actor", "Item"]) {
for (const t of tmpl[doc].types) {
assert.ok(lang[`TYPES.${doc}.${t}`],
`no TYPES.${doc}.${t} in en.json — the sheet prints the raw key`);
}
}
// Item types share one sheet, so each needs its own branch and the flag that drives it.
for (const t of tmpl.Item.types) {
const flag = `is${t[0].toUpperCase()}${t.slice(1)}`;
assert.ok(system.includes(`ctx.${flag} =`),
`the item sheet never sets ${flag}, so its section can never render`);
assert.ok(itemSheet.includes(`{{#if ${flag}}}`),
`item-sheet.hbs has no {{#if ${flag}}} section — Detail will be blank for ${t}`);
}
});
// Core hides the pause overlay with `#pause { display: none }` and shows it with
// `#pause.paused { display: flex }`. A system rule on the BARE #pause that sets display
// wins on specificity and the overlay never goes away — the game unpauses, play carries
// on, and GAME PAUSED sits over the table for the rest of the session. Shipped exactly
// that in 1.0.4. Anything targeting #pause must be scoped to .paused.
test("no stylesheet rule sets display on a bare #pause", async () => {
const { readFile } = await import("node:fs/promises");
const raw = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
// Comments first, or this guard reports the comment that explains the guard — which
// is precisely what the template guard did the last time round.
const css = raw.replace(/\/\*[\s\S]*?\*\//g, "");
// every rule whose selector mentions #pause, with its declaration block
for (const m of css.matchAll(/([^{}]*#pause[^{}]*)\{([^{}]*)\}/g)) {
const selector = m[1].trim().replace(/\s+/g, " ");
const body = m[2];
const scoped = selector.split(",").every(sel => !sel.includes("#pause") || /#pause[^,]*\.paused/.test(sel));
if (scoped) continue;
assert.ok(!/(^|;)\s*display\s*:/.test(body),
`"${selector}" sets display on an unscoped #pause — the overlay will not hide on unpause`);
}
});
/* ---------------------------------------------------------------- *
* Panic — the short track, added after the first game asked for it
* ---------------------------------------------------------------- */
// The first point is free ON PURPOSE: the opening shock of a scene should be colour,
// not arithmetic. If this ever starts costing, the whole track has been re-tuned.
test("panic 1 costs nothing to roll at", () =>
assert.equal(RULES.panicPenaltyFor(1), 0));
test("panic bites from 2 upward and never rewards", () => {
let prev = 1;
for (const v of [2, 3, 4, 5]) {
const pen = RULES.panicPenaltyFor(v);
assert.ok(pen <= 0, `panic ${v} gives a bonus`);
assert.ok(pen <= prev, `panic ${v} is kinder than ${v - 1}`);
prev = pen;
}
});
test("only 5 breaks you", () => {
for (const v of [0, 1, 2, 3, 4]) assert.equal(RULES.panicBreaksAt(v), false, `${v} breaks`);
assert.equal(RULES.panicBreaksAt(5), true);
});
// A track that can be pushed past its own ends is a track that will be, by a GM
// clicking + on a bad night.
test("panic cannot leave 0..5", () => {
assert.equal(RULES.panicAfter(5, 3), RULES.PANIC_MAX);
assert.equal(RULES.panicAfter(0, -3), 0);
assert.equal(RULES.panicAfter(2, 2), 4);
});
// Breaking spends it. Coming back at 0 would make breaking the cheapest way to clear
// the track, which is exactly backwards.
test("breaking does not reset you to calm", () => {
assert.ok(RULES.PANIC_AFTER_BREAK > 0, "breaking wipes the track clean");
assert.ok(RULES.PANIC_AFTER_BREAK < RULES.PANIC_MAX, "breaking leaves you still broken");
});
test("a special takes two points, a failure takes none, a fumble ADDS one", () => {
assert.equal(RULES.panicReliefFor("special"), 2);
assert.equal(RULES.panicReliefFor("critical"), 2);
assert.equal(RULES.panicReliefFor("success"), 1);
assert.equal(RULES.panicReliefFor("failure"), 0);
// Negative relief. Trying to steady somebody and making it worse is the most familiar
// thing on the list and it used to be the only outcome that cost nothing.
assert.equal(RULES.panicReliefFor("fumble"), -1);
});
// The sign has to survive the arithmetic easePanic does, or a fumble quietly heals.
test("a fumbled relief puts a point ON, through the same subtraction", () => {
const before = 2;
const after = RULES.panicAfter(before, -RULES.panicReliefFor("fumble"));
assert.equal(after, 3, "a fumbled relief did not raise panic");
assert.equal(RULES.panicAfter(before, -RULES.panicReliefFor("success")), 1);
});
// And it must still not be able to push past the top.
test("a fumbled relief at 5 stays at 5", () =>
assert.equal(RULES.panicAfter(5, -RULES.panicReliefFor("fumble")), RULES.PANIC_MAX));
// Every band and every trigger and relief must be sayable, or the chat card prints a key.
test("every panic band, trigger and relief has a label", async () => {
const { readFile } = await import("node:fs/promises");
const lang = JSON.parse(await readFile(new URL("../lang/en.json", import.meta.url), "utf8"));
for (const b of RULES.PANIC_BANDS) {
assert.ok(lang[b.label], `no label for panic band ${b.id}`);
assert.ok(lang[b.note], `no note for panic band ${b.id}`);
}
for (const [k, t] of Object.entries(RULES.PANIC_TRIGGERS))
assert.ok(lang[t.label], `no label for panic trigger ${k}`);
for (const [k, r] of Object.entries(RULES.PANIC_RELIEF))
assert.ok(lang[r.label], `no label for panic relief ${k}`);
});
// The reliefs name skills; a skill that is not in the catalogue is a relief nobody can
// roll. This is the same defect as a kit key with no item behind it.
test("every panic relief names a skill the catalogue has", async () => {
const { SKILL_CATALOGUE } = await import("./content.mjs");
const known = new Set(SKILL_CATALOGUE.map(r => r[0]));
for (const [k, r] of Object.entries(RULES.PANIC_RELIEF)) {
if (!r.skill) continue;
assert.ok(known.has(r.skill), `panic relief "${k}" rolls ${r.skill}, which is not a skill`);
}
});
// The three the first game asked for.
test("occult, religion and psychology exist and can be learned", async () => {
const { SKILL_CATALOGUE } = await import("./content.mjs");
for (const fam of ["occult", "religion", "psychology"]) {
const row = SKILL_CATALOGUE.find(r => r[0] === fam);
assert.ok(row, `${fam} is not in the catalogue`);
assert.ok(row[3] > 0, `${fam} has no printed base, so nobody can roll it untrained`);
assert.notEqual(row[5]?.canBeTaught, false, `${fam} cannot be taught`);
}
});
// Playing a case found the Field Lead and the Containment officer with ANOMALY LORE 0 —
// the department's signature skill, unrollable by the person running the team. Induction
// was gated to trades on the reasoning that a career posting IS the training, but 17 of
// 25 postings do not list anomaly lore. Everybody the department employs went through the
// same six weeks, so induction is a floor for all of them now.
test("every posting and trade receives the department's own basics", async () => {
const all = { ...ROLES, ...TRADES };
for (const skill of INDUCTION.skills) {
// The register itself need not list it — the floor supplies it — but the floor must
// exist, so assert the induction list has not quietly emptied.
assert.ok(skill, "an induction skill is blank");
}
assert.ok(INDUCTION.skills.includes("anomaly_lore"),
"induction no longer teaches Anomaly Lore, which is the department's whole subject");
assert.ok(Object.keys(all).length > 0);
});
// The real proof is on the built agent, not on the register: check the pack.
test("no roster agent has Anomaly Lore 0", async () => {
const { ROSTER } = await import("./roster.mjs");
const { SKILL_CATALOGUE } = await import("./content.mjs");
const printed = SKILL_CATALOGUE.find(r => r[0] === "anomaly_lore")?.[3] ?? 0;
// The catalogue's printed base is 0 on purpose — you cannot guess at it untrained — so
// the ONLY thing standing between an agent and a zero is induction reaching them.
assert.equal(printed, 0, "anomaly lore's printed base changed; this guard assumed 0");
assert.ok(ROSTER.length > 0);
assert.ok(INDUCTION.bandByTier?.probationary?.[0] > 0,
"induction's lowest band is 0, so a probationer still cannot roll it");
});
/* ---------------------------------------------------------------- *
* Development — reversed from Call of Cthulhu
* ---------------------------------------------------------------- */
// The whole point of the house rule: a skill is marked by FAILING, not by working.
// If this ever flips back, characters stop improving at the things they are bad at,
// which is the entire behaviour the rule exists to produce.
test("only failure and fumble mark a skill for development", () => {
for (const good of ["critical", "special", "success"])
assert.equal(RULES.developmentMarksOn(good), false, `${good} marked a skill`);
for (const bad of ["failure", "fumble"])
assert.equal(RULES.developmentMarksOn(bad), true, `${bad} did not mark a skill`);
});
// Roll OVER to improve, so the better you are the less a case teaches you. Together
// with marking-on-failure this is what makes ratings converge instead of running away.
test("development improves on ANOTHER failure, and ties do not count", () => {
assert.equal(RULES.developmentImproves(72, 45), true);
assert.equal(RULES.developmentImproves(30, 45), false);
assert.equal(RULES.developmentImproves(45, 45), false, "a tie improved the skill");
assert.equal(RULES.developmentImproves(1, 0), true, "a rating of 0 could not improve");
assert.equal(RULES.developmentImproves(100, 100), false, "a rating of 100 still improved");
});
test("a d100 gain cannot push a skill past the ceiling", () => {
// 98% + the best possible 1d6 would be 104% without the clip
assert.equal(98 + RULES.developmentGainFor(98, 6, null), RULES.DEVELOPMENT.ceiling);
});
test("a skill with a trainingCap stops there, not at the global ceiling", () => {
assert.equal(60 + RULES.developmentGainFor(60, 6, 62), 62);
assert.equal(RULES.developmentGainFor(62, 6, 62), 0, "already at its cap, gains nothing");
});
test("trainingCap is honoured but cannot raise the ceiling above it", () => {
assert.equal(RULES.developmentCeiling(250), RULES.DEVELOPMENT.ceiling);
assert.equal(RULES.developmentCeiling(null), RULES.DEVELOPMENT.ceiling);
});
test("a skill at the ceiling can still be marked but never gains", () => {
// 96-00 always fails, so even a maxed skill gets marked - it just never improves
assert.ok(RULES.developmentMarksOn("failure"));
assert.equal(RULES.developmentGainFor(RULES.DEVELOPMENT.ceiling, 6, null), 0);
});
test("the gain is a die, not a fixed number", () =>
assert.match(RULES.DEVELOPMENT.gain, /^\d*d\d+$/));
// The two halves must disagree: what marks a skill in play must be an outcome the
// development roll treats as improvement, or the rule reads as two unrelated coins.
test("the marking outcome and the improving outcome are the same idea", () => {
assert.ok(RULES.DEVELOPMENT.marksOn.includes("failure"));
assert.equal(RULES.DEVELOPMENT.improveOn, "over",
"improveOn changed; the rulebook and primer describe rolling over");
});
/* ------------------------------------------------- hit locations reach combat */
/* The whole hit-location system was a hazards-only subsystem: fall, burn, detonate
and burstAttack rolled a location, and every ordinary sword blow and pistol shot
did not. These tests are about the JOIN, because every individual part was already
correct and the table still never saw a located hit. */
/* A SCOPE ANCHORED ON A NAME THAT HAS MOVED IS NOT A SMALLER SCOPE, IT IS THE WHOLE FILE.
`indexOf` returns -1 for a name that is gone, `slice(0, -1)` is everything-but-one-character,
and the assertion below then passes against 30,825 characters of unrelated code while
reading like a test about one function — measured, not supposed: renaming the end anchor
took this body from 3,184 characters to 30,825 and the test stayed green. The other site
used a 4,000-character window on a function that runs 4,305, so it was both short of its
own subject and one edit away from covering the next one.
Same defect R-285 removed from check-powers, in source rather than prose. So the anchor is
asserted and the end is structural: the next top-level function, whatever it is called. */
const NEXT_FN = /\n(?:export\s+)?(?:async\s+)?function\s/;
function bodyOf(src, decl) {
const at = src.indexOf(decl);
assert.notEqual(at, -1, `check-behaviour scopes a test to "${decl}" and ringbrp.mjs no longer `
+ `contains it. Fix the anchor: unfixed, the test would read the whole file and pass.`);
const rest = src.slice(at);
const end = rest.slice(1).search(NEXT_FN);
return end < 0 ? rest : rest.slice(0, end + 1);
}
test("an ordinary weapon damage roll asks for a location", () => {
const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8");
const body = bodyOf(src, "export async function rollWeaponDamage");
assert.ok(/hitLocationRoll\(/.test(body),
"rollWeaponDamage must reach hitLocationRoll — this is the join that was missing");
});
test("a weapon's class picks the column of the table it reads", () => {
const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8");
assert.ok(/export function locationModeFor/.test(src));
// melee weapons read the melee spread; everything thrown or fired reads ranged
const melee = { system: { weaponClass: "melee" } };
const gun = { system: { weaponClass: "firearm" } };
// 11 is the abdomen in melee and the chest at range — the columns genuinely differ,
// which is the whole reason locationModeFor has to choose between them.
assert.equal(RULES.locationFor(11, "baseline", "melee").id, "abdomen");
assert.equal(RULES.locationFor(11, "baseline", "ranged").id, "chest");
assert.ok(melee && gun);
});
test("the one caller that locates for itself opts out, so nothing applies twice", () => {
const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8");
const body = bodyOf(src, "export async function burstAttack");
const call = body.match(/rollWeaponDamage\(\{[^}]*\}/);
assert.ok(call, "burstAttack still calls rollWeaponDamage");
assert.ok(/locate:\s*false/.test(call[0]),
"burstAttack rolls its own location per round, so it must not let rollWeaponDamage do it too");
});
test("every body plan can be hit anywhere on a d20, in both modes", () => {
for (const [id, table] of Object.entries(TABLES)) {
for (const mode of ["melee", "ranged"]) {
for (let face = 1; face <= 20; face++) {
const loc = RULES.locationFor(face, id, mode);
assert.ok(loc?.id, `${id} ${mode} ${face} found no location`);
}
}
}
});
test("a four-legged thing has four legs and no arms", () => {
const kinds = TABLES.quadruped.locations.map(l => l.kind);
assert.equal(kinds.filter(k => k === "leg").length, 4);
assert.equal(kinds.filter(k => k === "arm").length, 0);
assert.ok(kinds.includes("neck"), "the neck is the reason to aim at a quadruped");
});
test("losing one wing grounds a flyer, and losing a leg does not", () => {
assert.equal(RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true);
assert.equal(RULES.locationEffectsFor([{ kind: "leg", disabled: true }]).flightLost, false);
});
test("a wing is ruinous, a neck is fatal", () => {
assert.equal(RULES.destructionOutcomeFor("wing", true), "none");
assert.equal(RULES.destructionOutcomeFor("neck", true), "dead");
});
test("an opened body cavity bleeds, because that is what its text promises", () => {
// The printed effect said "-30% Physical and bleeding 1 per round" and the code
// delivered nothing, on every body plan including the humanoid default.
const e = RULES.locationEffectsFor([{ kind: "body", disabled: true }]);
assert.equal(e.bleed, 1);
assert.equal(e.physical, -30);
});
/* R-255. The hide rules are three functions that only mean anything composed, and the
composition lives in prepareDerivedData where no test could reach it. So compose them
here the same way the sheet does — from the real supporter spec and the real tables —
because a hide that halves correctly in `rules.mjs` and arrives wrong on the sheet is
this project's signature defect, and check-anatomy's guard 6 is deliberately one-sided
and would not catch the vital losing its exemption. */
test("R-255 the supporter's hide reaches each location at the right value, flying and grounded", async () => {
const { NPCS } = await import("./content.mjs");
const spec = NPCS.find(n => n.key === "supporter");
assert.ok(spec, "there is no supporter in the bestiary any more");
const hide = spec.naturalArmour;
// Exactly the expression at ringbrp.mjs:624, with no worn armour on the beast.
const onSheet = (loc, grounded) =>
RULES.locationArmourFor((Number(loc.armour) || 0)
+ RULES.naturalArmourOn(loc, RULES.exposedHideFor(hide, grounded, loc.kind)),
[], loc.kind);
const flying = TABLES[spec.bodyPlan];
for (const loc of flying.locations) {
const got = onSheet(loc, false);
if (loc.kind === "wing") assert.equal(got, 0, `a wing carries no hide, and ${loc.id} shows ${got}`);
else assert.equal(got, hide, `${loc.id} should show the full hide of ${hide} in the air, not ${got}`);
}
// Grounded, it is a different body plan: the one groundedPlanFor names.
const landed = TABLES[RULES.groundedPlanFor(spec.bodyPlan)];
assert.ok(landed, "the winged plan no longer declares where it lands");
let softened = 0;
for (const loc of landed.locations) {
const got = onSheet(loc, true);
if (loc.kind === "vital") {
assert.equal(got, hide,
`the chest faces you in every posture; it should still show ${hide} grounded, not ${got}`);
} else {
assert.equal(got, Math.floor(hide / 2),
`${loc.id} should halve to ${Math.floor(hide / 2)} on the ground, not ${got}`);
softened++;
}
}
assert.ok(softened >= 4, "grounding softened almost nothing, so it buys the players nothing");
assert.ok(Math.floor(hide / 2) < 5,
`grounded hide is ${Math.floor(hide / 2)}, which an ordinary round still cannot beat`);
});
/* ── R-286 / R-288 / R-289: who a scenario casts ─────────────────────────────────────
ON STRINGS, NOT ON THE SCENARIO. Both of these defects were first shown by editing
CLEAN_GROUND.md in the shared tree to see what the reader did with it, which is how one
session came within a `git checkout` of another's uncommitted work, and it is also a worse
test: it can only hold one shape at a time and it leaves nothing behind. c0 made the point.
The shapes below are the whole finding, they run on every build, and they cost nothing. */
const SIX = "pc_ashcroft, pc_bhattacharya, pc_renshaw, pc_braithwaite, pc_pollard, pc_okonkwo";
const keysOf = t => castMarkersIn(t).map(m => m.keys);
test("a declaration is read, and a mention of one in backticks is not", () => {
// The real document's shape: the marker, then the warning that quotes it. R-286.
const doc = `## Casting\n\n<!-- cast: ${SIX} -->\n\nThe \`<!-- cast: -->\` comment above is read by two guards.\n`;
assert.deepEqual(keysOf(doc), [SIX]);
});
test("the mention above the declaration still reads the declaration", () => {
/* THE BUG ITSELF. Before R-286 this returned the empty capture, check-rollable fell back to
the whole duty roster and printed the same OK line. The arrangement was only ever correct
because the real marker happened to sort first. */
const doc = `The \`<!-- cast: -->\` comment below is read by two guards.\n\n<!-- cast: ${SIX} -->\n`;
assert.deepEqual(keysOf(doc), [SIX]);
});
test("two real declarations are both seen, so neither can win by position", () => {
const doc = `<!-- cast: pc_ashcroft -->\nfiller\n<!-- cast: ${SIX} -->\n`;
const found = castMarkersIn(doc);
assert.equal(found.length, 2);
assert.deepEqual(found.map(m => m.line), [1, 3], "line numbers must survive the code blanking");
});
test("a declaration inside a fenced block is documentation, not a cast", () => {
assert.deepEqual(keysOf("```md\n<!-- cast: " + SIX + " -->\n```\n"), []);
});
test("a declaration that names nobody is read as present and empty", () => {
// R-288 needs to tell this from "no marker at all", which falls back on purpose.
assert.deepEqual(keysOf("<!-- cast: -->"), [""]);
assert.deepEqual(keysOf("no marker here"), []);
});
test("spacing around the colon does not hide a declaration", () => {
/* R-289, found by writing these tests. `<!-- cast : ... -->` matched nothing, so the guard
saw no declaration, widened to the full roster and went green — and R-288 could not catch
it because nothing was present to be empty. check-cited's three spellings, again. */
for (const m of [`<!-- cast : ${SIX} -->`, `<!-- Cast: ${SIX} -->`, `<!--cast:${SIX}-->`,
`<!-- CAST : ${SIX} -->`]) {
assert.deepEqual(keysOf(m), [SIX], `this spelling reads nothing: ${m}`);
}
});
test("a cast-shaped marker the reader cannot parse is named, not skipped", () => {
/* The asymmetric half. Widening the reader fixes the spellings we thought of; this is what
names the one we did not, instead of silently declaring nobody. */
for (const m of [`<!-- cast = ${SIX} -->`, `<!-- casting: ${SIX} -->`, `<!-- the cast is ${SIX} -->`]) {
assert.equal(castMarkersIn(m).length, 0);
assert.equal(castLikeIn(m).length, 1, `not flagged as cast-shaped: ${m}`);
}
assert.deepEqual(castLikeIn(`<!-- cast: ${SIX} -->`), [], "a readable marker is not an oddity");
assert.deepEqual(castLikeIn("`<!-- cast = x -->`"), [], "prose in backticks is not an oddity");
});
/* ── R-290: two readers of the same tag must not lose one between them ───────────────
check-scenarios' `tagsIn` decides which beats are checked for skills and reachability;
outcome-coverage's `beatsIn` decides which are checked for stated outcomes, with
`beatLikeIn` naming what it could not parse. Two strict readers of one format, written a
week apart by two sessions.
c0 asked the right question when R-290 widened `tagsIn`: if the widening reaches past its
loose counter, that counter drops to zero for the wrong reason and stops being a canary.
It does not — the widened pattern is a subset of the loose one — but "does not today" is
the kind of claim that decays silently, so it is asserted rather than remembered. The
invariant is the weakest one that keeps the canary alive: a tag `tagsIn` accepts must be
visible to the other guard, either parsed or named. Never to neither. */
test("a tag one reader accepts is never invisible to the other", () => {
const seen = t => beatsIn(t).length + beatLikeIn(t).length;
for (const tag of ["[CUS: Spot — what it gets]", "[cus: Spot — what it gets]",
"[CUS : Spot — x]", "[ CUS: Spot — x]", "[CUS:Spot]"]) {
assert.equal(tagsIn(tag).length, 1, `tagsIn no longer reads ${tag}`);
assert.ok(seen(tag) >= 1,
`${tag} is read by tagsIn and invisible to the outcome guard — widening one reader past `
+ `the other's counter is how a tag gets checked for reachability and never for whether `
+ `the beat says what it does`);
}
// and the counter still has teeth: shapes the strict readers refuse are still named
for (const tag of ["[CUS Spot]", "[CUS= Spot]"]) {
assert.equal(tagsIn(tag).length, 0);
assert.equal(beatLikeIn(tag).length, 1, `${tag} is refused by both readers and named by neither`);
}
});
/* ── R-293: a record of a session is not a scenario ──────────────────────────────────
check-rollable's claim is that every clue a GM can put behind a roll is reachable by
somebody at the table. The corpus it read held three kinds of document and only one is a
thing a GM runs, so a desk playtest quoting `[CUS: Anomaly Lore — what a peg is]` to say
which beat a roll hung off had that quotation held to the claim. Measured, not assumed:
planting an unreachable skill in a playtest report failed the build before this split. */
test("the corpus knows which of its documents are played and which are records", () => {
const kinds = classifiedFiles();
assert.ok(kinds.length >= 10, "the corpus has shrunk unexpectedly");
assert.ok(kinds.every(([, , kind]) => kind === "playable" || kind === "record"),
"classifiedFiles exits on an unclassified document, so nothing here should be null");
const playable = playableFiles().map(([l]) => l);
assert.ok(playable.includes("docs/scenarios/CLEAN_GROUND.md"));
assert.ok(!playable.some(l => /PLAYTEST/i.test(l)),
"a desk playtest is a record of a session already played; its quoted beats are not rolls");
assert.ok(!playable.some(l => /_ART\.md$/.test(l)), "an art prompt sheet has no rolls");
});
/* ── R-294: the step-5 split, derived rather than stored ─────────────────────────────── */
test("the step-5 outcomes are exhaustive — every contest lands somewhere", () => {
/* Asserted on the RAW proportions, because that is where the invariant lives. The first
version of this test checked the rounded percentages and failed at target 55: 40.7 +
48.1 + 11.3 is 100.1, three independent round-ups. The code was right and the test was
wrong, which is worth a comment — the displayed parts of a split need not sum to 100,
and the document's own table has the same harmless 0.1. */
for (const target of [42, 55, 60, 75]) {
const s = step5Of(THING_RATING, target);
const raw = s.raw.taken + s.raw.held + s.raw.unsettled;
assert.equal(Math.round(raw * 10000), 10000,
`every one of the 10,000 pairs must land in exactly one outcome, at target ${target}`);
const shown = s.taken + s.held + s.unsettled;
assert.ok(Math.abs(shown - 100) <= 0.2,
`rounded parts drifted ${shown} from 100 at target ${target} — more than independent `
+ `rounding explains`);
}
});
test("the accepted row is a named exception, not the lowest POW in the room", () => {
/* Two different rules produce these numbers. Collapsing them into one derivation would
give an answer that is right today for the wrong reason — c0's objection, kept as a
test so the next person to tidy this cannot quietly unify them. */
const s = step5Split();
assert.ok(s.accepted.target < s.refused.target,
"Ashcroft at Difficult must resist lower than the lowest POW in the room, or the "
+ "accepted branch is not the exception the scenario describes");
assert.notEqual(s.accepted.who, s.refused.who);
});
test("the blend uses raw proportions, not the rounded rows", () => {
/* THE BUG THIS FILE WATCHES FOR, made in this very module and caught by comparing against
the document: blending 40.7 and 48.7 gives 43.7 where the answer is 43.637 -> 43.6. The
comment above the code said "raw proportions" while the code used the rounded ones. */
const s = step5Split();
const w = s.refusedShare / 100;
const fromRaw = Math.round((w * s.refused.raw.taken + (1 - w) * s.accepted.raw.taken) * 1000) / 10;
assert.equal(s.blended.taken, fromRaw, "blended.taken must be computed from the raw proportions");
const fromRounded = Math.round((w * s.refused.taken + (1 - w) * s.accepted.taken) * 10) / 10;
if (fromRounded !== fromRaw) {
assert.notEqual(s.blended.taken, fromRounded,
"this is the rounded-input blend, which is the defect; if the two ever coincide this "
+ "assertion goes quiet, and the equality above is the one that still holds");
}
});
/* ------------- check-figures, the merged reader, read on strings ------------- */
/* On strings and not on the corpus, for the reason R-292 gives: a reader tested by editing
the document proves the document, and goes quiet the day somebody rewords the sentence it
was anchored to. Each case names the shape it classifies outright. */
/* The strict regime is opt-in, so every fixture carries one real citation. */
const OPTIN = "\n\nSee **13%**<!-- cite: packs line.hollow6.wiped --> elsewhere.\n";
const doc = md => scanDocument("x.md", md + OPTIN);
const firstFault = r => r.strict[0] ?? "";
test("an unmarked bold percentage in PROSE is fatal", () =>
assert.equal(doc("The party wipes **8.3%** of the time.").strict.length, 1));
test("the same figure carrying a cite is held by check-cited, not reported here", () =>
assert.equal(doc("wipes **8.3%**<!-- cite: packs line.x.wiped --> of the time").strict.length, 0));
test("a bold decimal in prose is a published figure too", () =>
assert.equal(doc("Against what a table rolls the factor is **1.29**.").strict.length, 1));
test("a waiver clears the figure and goes on the record with its reason", () => {
const r = doc("Dodge is **68%**<!-- uncited: a rating, not a measurement -->.");
assert.equal(r.strict.length, 0);
assert.equal(r.waived.length, 1);
assert.equal(r.waived[0].reason, "a rating, not a measurement");
});
test("an EMPTY waiver is fatal — the R-288 call, again", () => {
const r = doc("Dodge is **68%**<!-- uncited: -->.");
assert.equal(r.strict.length, 1);
assert.match(firstFault(r), /EMPTY/);
});
test("a bare cell under a measurement header is fatal — the 24 itself", () =>
assert.equal(doc("| | Median rounds |\n|---|---|\n| six a side | 24 |").strict.length, 1));
test("bold is NOT what makes a cell a figure — the same cell bolded still fails", () =>
assert.equal(doc("| | Median rounds |\n|---|---|\n| six a side | **24** |").strict.length, 1));
test("a bold cell under a header naming no measurement is silent", () =>
assert.equal(doc("| | Roll |\n|---|---|\n| it watches | **3** |").strict.length, 0));
test("a column whose cells are sentences is not a measurement column", () =>
/* The COLUMN pass would otherwise claim the 3 on the header's authority alone. */
assert.equal(doc("| | Measured over 300 runs |\n|---|---|\n| in cover | Untouched. "
+ "A legitimate choice for a pass, seen 3 times. |").strict.length, 0));
test("but a named measurement phrasing in a prose cell is still a figure", () =>
/* The SHAPE pass claims "2 of 4 hurt" wherever it is written. The prose rule is the
column pass's, because that is the reader whose claim rests on the header. */
assert.ok(doc("| | Measured over 300 runs |\n|---|---|\n| in cover | 2 of 4 hurt, half a "
+ "party member down. The intended shape. |").strict.length >= 1));
test("a duration column is not a harness figure — 'Runs' meaning how long a beat runs", () =>
assert.equal(doc("| | Runs |\n|---|---|\n| the tests | ~15 min |").strict.length, 0));
test("a percentile column IS one — 'runs' must stay in the vocabulary", () =>
assert.equal(doc("| | 1 in 100 runs past |\n|---|---|\n| the cut | 32.3 rounds |").strict.length, 1));
test("a fenced figure is the artifact, not a claim about it", () =>
assert.equal(doc("```\n wiped **8.3%**\n```").strict.length, 0));
test("an inline-code figure is quoted, not claimed", () =>
assert.equal(doc("write it as `wiped **8.3%**` in the margin").strict.length, 0));
test("a bare prose figure matching a shape is caught by the shape pass", () =>
assert.equal(doc("The fight ran a median 21 rounds.").strict.length >= 1, true));
test("a skill rating in ordinary prose is not a figure", () =>
assert.equal(doc("Nia Mercer carries Pistol 55% and a bad temper.").strict.length, 0));
test("a marker after the closing pipe of a table cell still counts", () =>
assert.equal(doc("| | Median rounds |\n|---|---|\n| six | 24<!-- cite: packs a.b --> |").strict.length, 0));
test("a document that cites nothing is held by the ratchet, not the strict regime", () => {
const r = scanDocument("y.md", "The party wipes **8.3%** of the time.");
assert.equal(r.optedIn, false);
assert.equal(r.strict.length, 0, "an uncited document must not fail the build");
assert.ok(r.unmarked >= 1, "but its bare figures are still counted for the ratchet");
});
test("importing check-figures runs the readers and NOT the guard", () => {
/* THE REGRESSION THIS EXISTS FOR: for one commit the guard's body ran at import, so a
figure defect called process.exit(1) inside THIS file — check-behaviour reporting
check-figures' failure under its own name, having run none of its tests. Spawned rather
than asserted on the source, because the property is "importing has no effect", and only
a fresh process can tell you that. */
const mod = new URL("./check-figures.mjs", import.meta.url).href;
const r = spawnSync(process.execPath,
["--input-type=module", "-e", `await import(${JSON.stringify(mod)});`],
{ encoding: "utf8" });
assert.equal(r.status, 0, "importing check-figures must not exit non-zero");
assert.equal((r.stdout + r.stderr).trim(), "",
"importing check-figures must print nothing — the guard's body belongs behind the "
+ "import.meta.main check, or a figure defect stops this file mid-run");
});
test("the reader reports the line the figure is actually on", () =>
assert.match(firstFault(doc("one\ntwo\nwipes **8.3%** here")), /x\.md:3/));
/* ---------------------------------------------------------------- */
await runAll();
if (failed) {
console.error(`check-behaviour: ${failed} failure(s), ${passed} passed`);
process.exit(1);
}
console.log(`check-behaviour: OK — ${passed} behavioural tests`);