Files
wizwar6e/packages/engine/test/ui-coverage.test.ts
T
Eric WagonerandClaude Fable 5 a4f572a164 The telepath audit becomes a guard test, and the audits get a skill
ui-coverage.test.ts fails the suite the moment an engine event has no
client handling (allowlist entries require a written reason) or a
cell/edge-targeting card is missing from the board's aim sets — the
one-at-a-time bug reports become a red test instead. The wizwar-audits
skill documents the three house audits (UI coverage, ledger inspection
by room code, the determinism gate); .claude/skills/ joins the repo so
they travel with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:01:22 -04:00

74 lines
3.8 KiB
TypeScript

// The telepath guard: every event the engine emits must reach the player —
// a log line, an effect, or a modal — and every targeted cast must be
// aimable on the board. A new event or card that slips past the client
// fails here, instead of surfacing one puzzled bug report at a time.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ENGINE = join(__dirname, "..", "src");
const WEB = join(__dirname, "..", "..", "web", "src");
const game = readFileSync(join(ENGINE, "game.ts"), "utf8");
const app = readFileSync(join(WEB, "App.svelte"), "utf8");
const clientSources = ["net.svelte.ts", "fx.ts", "local.svelte.ts", "Replay.svelte", "hints.ts"]
.map((f) => readFileSync(join(WEB, f), "utf8"))
.concat(app)
.join("\n");
/** Events whose information reaches the player another way — each with the
* reason. Add here ONLY with a reason; "I'll wire it later" is not one. */
const SILENT_BY_DESIGN: Record<string, string> = {
cardsDealt: "the opening deal: the hand tray is the reveal",
cardsDealtPrivate: "same — your opening hand appears in the tray",
boobytrapPlacedPrivate: "the board marks the caster's real token from the view",
};
function eventTypes(): string[] {
const start = game.indexOf("export type GameEvent =");
const block = game.slice(start, game.indexOf("\nexport ", start + 10));
return [...new Set([...block.matchAll(/\| \{ type: "([a-zA-Z]+)"/g)].map((m) => m[1]!))];
}
describe("every engine event reaches the player", () => {
it("is humanized, animated, handled in a modal, or silent by design", () => {
const handled = new Set<string>();
for (const m of clientSources.matchAll(/case "([a-zA-Z]+)"/g)) handled.add(m[1]!);
for (const m of clientSources.matchAll(/type === "([a-zA-Z]+)"/g)) handled.add(m[1]!);
const orphans = eventTypes().filter((e) => !handled.has(e) && !(e in SILENT_BY_DESIGN));
expect(orphans, `events no client code touches: ${orphans.join(", ")}`).toEqual([]);
});
it("keeps the silent-by-design list honest (no stale entries)", () => {
const known = new Set(eventTypes());
const stale = Object.keys(SILENT_BY_DESIGN).filter((e) => !known.has(e));
expect(stale, `allowlisted events the engine no longer emits: ${stale.join(", ")}`).toEqual([]);
});
});
describe("every targeted cast is aimable on the board", () => {
/** A card's resolver demanding a cell/edge target must appear in App's
* click-targeting sets, or selecting the card leaves it uncastable. */
it("cell- and edge-demanding resolvers appear in CELL_CARDS / EDGE_CARDS", () => {
const start = game.indexOf("const CARD_EFFECTS");
const end = game.indexOf("\n};", start); // the table's own closing brace
const entries = game.slice(start, end).split(/\n (?="?[a-z][a-z0-9-]*"?: \{)/);
const appSet = (name: string) =>
new Set([...(app.match(new RegExp(`${name} = new Set\\(\\[([^\\]]*)\\]`))?.[1] ?? "")
.matchAll(/"([a-z-]+)"/g)].map((m) => m[1]!));
const cellCards = appSet("CELL_CARDS");
const edgeCards = appSet("EDGE_CARDS");
const missing: string[] = [];
for (const entry of entries) {
const id = entry.match(/^"?([a-z][a-z0-9-]*)"?: \{/)?.[1];
if (!id) continue;
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
// (an optional `target?.kind === ...` branch is not a requirement).
if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
!cellCards.has(id)) missing.push(`${id} (cell)`);
if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
!edgeCards.has(id)) missing.push(`${id} (edge)`);
}
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
});
});