Three faults in 1.0.4, and the first was the serious one.
1. It never went away. Core hides the overlay with `#pause { display: none }`
and shows it with `#pause.paused { display: flex }`. 1.0.4 styled the BARE
#pause with display:flex, which wins on specificity, so unpausing removed
the class and changed nothing — GAME PAUSED sat over the table for the rest
of the session. Every rule here is now scoped to `.paused` and `display` is
not set anywhere.
2. It was off centre because I repositioned it. Core already centres it: a
full-width band at top calc(50vh - 100px), column flex, centred both ways,
pointer-events none. That layout was correct and 1.0.4 fought it with
left/bottom/transform. It is left alone now; only the height changes, and
the top moves up by exactly half the extra so the centring holds.
3. It was small: a 322x116 panel in the corner. It is now core's full-width
band at 300px with a 170px dial — against core's own 100px — and the
caption at 24px to match core's.
Guarded: check-behaviour now parses the stylesheet and fails any rule that sets
display on an unscoped #pause. Verified it bites. It strips CSS comments first,
because the first version of the guard reported the comment explaining the
guard — which is exactly what the template guard did last time, so that is twice
now and the lesson is written down properly.
Verified by driving the real toggle: running → display none, box 0x0, no class;
paused → display flex, 341x302, centre X exactly on the viewport centre;
unpaused again → display none, box 0x0.
Version 1.0.5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
460 lines
24 KiB
JavaScript
460 lines
24 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 { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE }
|
|
from "../postings.mjs";
|
|
import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM }
|
|
from "./scenario-starter.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`);
|
|
}
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
await runAll();
|
|
|
|
if (failed) {
|
|
console.error(`check-behaviour: ${failed} failure(s), ${passed} passed`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`check-behaviour: OK — ${passed} behavioural tests`);
|