Files
systemic-game-design-course/tools/test/diff-tracks.test.mjs
T
Lucas WintherandClaude Opus 5.5 06a1b34c5d feat: Module 11 becomes 13; diff-tracks fuzzer; align the two reference tracks
- Rename course/11-break-your-engine to 13-break-your-engine and update every
  link and mention; start-module explains that Module 13 has no tests to start.
- tools/diff-tracks.mjs (npm run diff:tracks): stages both references of a
  module, generates a seeded random SpellGrid level and script per seed, pipes
  it to the Rust and TypeScript CLIs and diffs their output; crashes on one or
  both sides fail. stagePlay takes a module number.
- register_assembled: the TypeScript track compared only the text of an
  existing Assembled card, so a base re-registered with a new cost (or a
  different split) silently rewrote the earlier card. It now compares every
  field the engine reads, as Rust does (Modules 5-10).
  New test_05_assemble_never_rewrites_an_earlier_card.
- SpellGrid CLIs: coordinates and chooser numbers are 1-9 plain digits in both
  tracks (Rust accepted signs, TypeScript accepted "+2", "0x2", "1e0"); Rust
  quotes card names verbatim instead of with Debug escapes; Rust joins the
  minion list with "; " instead of rewriting ", ". Two new CLI tests per track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:08:56 +01:00

115 lines
5.8 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { toolsDir } from "./helpers.mjs";
import { validate } from "../schema.mjs";
import { cardDirs } from "../lib.mjs";
import { generateCase, loadVocab, parseArgs, rng, unifiedDiff, verdict } from "../diff-tracks.mjs";
const course = join(toolsDir, "..", "course");
const levelSchema = JSON.parse(readFileSync(join(course, "schema", "level.schema.json"), "utf8"));
// The Module 10 vocabulary: its words.json and every card its workspace has.
const vocab = loadVocab(join(course, "10-spellgrid", "reference", "ts", "src", "content", "words.json"), cardDirs("10"));
const SEEDS = Array.from({ length: 200 }, (_, i) => i + 1);
test("diff_tracks_parses_args", () => {
assert.deepEqual(parseArgs([]), { seeds: 100, seed: null, module: null, dir: null, keep: false });
assert.deepEqual(parseArgs(["--seeds", "500", "--module", "10", "--dir", "x", "--keep"]), {
seeds: 500, seed: null, module: "10", dir: "x", keep: true,
});
assert.equal(parseArgs(["--seed", "42"]).seed, 42);
});
test("diff_tracks_rng_is_seeded", () => {
const draw = (seed) => { const r = rng(seed); return [r(), r(), r()]; };
assert.deepEqual(draw(7), draw(7));
assert.notDeepEqual(draw(7), draw(8));
for (const x of draw(123)) assert.ok(x >= 0 && x < 1);
});
test("diff_tracks_cases_are_deterministic_and_vary", () => {
assert.deepEqual(generateCase(17, vocab), generateCase(17, vocab));
const distinct = new Set(SEEDS.map((s) => JSON.stringify(generateCase(s, vocab))));
assert.equal(distinct.size, SEEDS.length);
});
test("diff_tracks_levels_are_valid", () => {
const nouns = new Set(vocab.words.filter((w) => w.role === "noun").map((w) => w.name));
const words = new Set([...vocab.words.map((w) => w.name), "IS", "AND", "NOT"]);
const placeable = new Set(vocab.cards.filter((c) => c.type === "minion" && c.noun).map((c) => c.name));
const holdable = new Set(vocab.cards.filter((c) => c.type === "spell" || (c.type === "minion" && c.noun)).map((c) => c.name));
for (const seed of SEEDS) {
const { level } = generateCase(seed, vocab);
assert.deepEqual(validate(levelSchema, level), [], `seed ${seed}`);
assert.ok(level.width >= 4 && level.width <= 8 && level.height >= 4 && level.height <= 8, `seed ${seed} size`);
for (const o of [...level.objects, ...level.cards]) {
assert.ok(o.x < level.width && o.y < level.height, `seed ${seed}: (${o.x}, ${o.y}) off the grid`);
}
for (const { kind } of level.objects) {
const ok = kind.startsWith("text:") ? words.has(kind.slice(5)) : nouns.has(kind.toUpperCase());
assert.ok(ok, `seed ${seed}: kind ${kind}`);
}
for (const { card } of level.cards) assert.ok(placeable.has(card), `seed ${seed}: card ${card}`);
for (const card of level.hand) assert.ok(holdable.has(card), `seed ${seed}: hand ${card}`);
}
});
test("diff_tracks_scripts_follow_the_cli_grammar", () => {
const names = vocab.cards.map((c) => c.name);
const answers = new Set([...names, "top", "bottom"]);
let casts = 0;
let moves = 0;
for (const seed of SEEDS) {
const { script } = generateCase(seed, vocab);
assert.ok(script.length >= 5 && script.length <= 25, `seed ${seed}: ${script.length} commands`);
for (const line of script) {
const cast = /^c (.+?)(?: (\d+) (\d+))?$/.exec(line);
const card = cast && cast[1].replace(/ \(Assembled\)$/, "");
const ok = /^[wasd]$/.test(line) || /^\d+$/.test(line) || answers.has(line) || (cast && names.includes(card));
assert.ok(ok, `seed ${seed}: ${JSON.stringify(line)}`);
if (cast) casts++;
if (/^[wasd]$/.test(line)) moves++;
}
}
assert.ok(casts > 100 && moves > 500, `casts ${casts}, moves ${moves}`);
});
test("diff_tracks_levels_form_sentences", () => {
// Biased toward whole sentences: most levels have at least one NOUN IS ... in a row or column.
const withSentence = SEEDS.filter((seed) => {
const { level } = generateCase(seed, vocab);
const at = new Map(level.objects.filter((o) => o.kind.startsWith("text:")).map((o) => [`${o.x},${o.y}`, o.kind.slice(5)]));
return [...at].some(([key, word]) => {
const [x, y] = key.split(",").map(Number);
return word !== "IS" && (at.get(`${x + 1},${y}`) === "IS" && at.has(`${x + 2},${y}`) ||
at.get(`${x},${y + 1}`) === "IS" && at.has(`${x},${y + 2}`));
});
});
assert.ok(withSentence.length > SEEDS.length * 0.8, `${withSentence.length} of ${SEEDS.length}`);
});
test("diff_tracks_verdict", () => {
const ok = (out, code = 1) => ({ out, err: "", code, signal: null });
assert.equal(verdict(ok("a\n"), ok("a\n")), "agree");
// The same error printed by both is agreement; so is the same exit code for a lost game.
assert.equal(verdict(ok("x is not in the hand\n"), ok("x is not in the hand\n")), "agree");
assert.equal(verdict(ok("a\n", 0), ok("b\n", 0)), "outputs differ");
const panic = { out: "a\n", err: "thread 'main' panicked at src/cli.rs:1:1", code: 101, signal: null };
const thrown = { out: "a\n", err: "TypeError: boom", code: 1, signal: null };
const killed = { out: "", err: "", code: null, signal: "SIGKILL" };
assert.equal(verdict(panic, ok("a\n")), "the Rust CLI crashed");
assert.equal(verdict(ok("a\n"), thrown), "the TypeScript CLI crashed");
assert.equal(verdict(killed, thrown), "both crashed");
});
test("diff_tracks_unified_diff", () => {
const a = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"].join("\n");
const b = ["1", "2", "3", "4", "5", "six", "7", "8", "9", "10", "11"].join("\n");
assert.equal(unifiedDiff(a, b), [
"--- rust", "+++ ts",
"@@ -3,8 +3,9 @@", " 3", " 4", " 5", "-6", "+six", " 7", " 8", " 9", " 10", "+11",
].join("\n"));
assert.equal(unifiedDiff("same", "same"), "--- rust\n+++ ts");
});