Files
systemic-game-design-course/tools/test/verify-levels.test.mjs
T
Lucas WintherandClaude Opus 5.5 570e5d6826 feat: age-aware solver, verify-levels, seven solver-proved SpellGrid levels (l4-l10)
The solver's position key reads stackAt (each tile oldest first). New tool
tools/verify-levels.mjs checks course/10-spellgrid/levels/proofs.json: each
level's shortest solution, and that it exhausts without its idea (neverRule)
or on the naive engine. Levels 4-10, one course idea each, with
test_10_level_N_solution in both tracks, scenarios and Lesson 10's list;
l1 no longer matches l10, and proofs.json is not listed as a level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:22:58 +01:00

98 lines
4.8 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { toolsDir } from "./helpers.mjs";
import { checkProof, describe, EXHAUST_MAX, parseArgs, proofProblems, solverRuns } from "../verify-levels.mjs";
const levelsDir = join(toolsDir, "..", "course", "10-spellgrid", "levels");
// What the solver prints (tools/solver/solve.ts), cut down to the lines verify-levels reads.
const SOLVED_IN = (n) => ({ code: 0, out: `1 input: 3 new positions, 4 in all\nsolution in ${n}: right; cast Whirlwind\n` });
const EXHAUSTED = { code: 1, out: "2 inputs: 0 new positions, 9 in all\nno solution: every reachable position was searched\n" };
const CUT_OFF = { code: 1, out: `no solution within ${EXHAUST_MAX} inputs (the search was cut off, not exhausted)\n` };
/** A fake solver: answers each run from `answer(args)` and records the arguments it was given. */
function fakeSolver(answer) {
const calls = [];
const run = async (args) => {
calls.push(args);
return answer(args);
};
return { run, calls };
}
test("verify_levels_parses_args", () => {
assert.deepEqual(parseArgs([]), { levels: [], proofs: null, dir: null });
assert.deepEqual(parseArgs(["l4", "--proofs", "p.json", "l5-x", "--dir", "d"]), {
levels: ["l4", "l5-x"],
proofs: "p.json",
dir: "d",
});
});
test("verify_levels_runs_the_solver_once_per_claim", () => {
assert.deepEqual(solverRuns({ level: "l2", shortest: 4 }).map((r) => r.args), [["--max", "4"]]);
assert.deepEqual(
solverRuns({ level: "l1", shortest: 10, neverRule: "DEATHRATTLE IS DOUBLE" }).map((r) => r.args),
[["--max", "10"], ["--max", "60", "--never-rule", "DEATHRATTLE IS DOUBLE"]],
);
assert.deepEqual(
solverRuns({ level: "l3", shortest: 6, naiveUnsolvable: true }).map((r) => r.args),
[["--max", "6"], ["--max", "60", "--naive"]],
);
assert.equal(describe({ level: "l3", shortest: 6, naiveUnsolvable: true }), "shortest 6; none under --naive");
});
test("verify_levels_accepts_a_proof_that_holds", async () => {
const proof = { level: "l1", shortest: 10, neverRule: "DEATHRATTLE IS DOUBLE" };
const solver = fakeSolver((args) => (args.includes("--never-rule") ? EXHAUSTED : SOLVED_IN(10)));
assert.deepEqual(await checkProof(proof, solver.run), []);
assert.equal(solver.calls.length, 2);
});
test("verify_levels_reports_a_proof_that_fails", async () => {
// The fixture proof claims 5 inputs; the solver finds 4, so the claim "none shorter" is false.
const shorter = await checkProof({ level: "l3", shortest: 5 }, fakeSolver(() => SOLVED_IN(4)).run);
assert.equal(shorter.length, 1);
assert.match(shorter[0], /a solution in 4 inputs, shorter than 5/);
const none = await checkProof({ level: "l3", shortest: 5 }, fakeSolver(() => EXHAUSTED).run);
assert.deepEqual(none, ["no solution within 5 inputs"]);
const cooked = await checkProof(
{ level: "l4", shortest: 7, neverRule: "DEATHRATTLE IS DOUBLE" },
fakeSolver((args) => (args.includes("--never-rule") ? SOLVED_IN(9) : SOLVED_IN(7))).run,
);
assert.equal(cooked.length, 1);
assert.match(cooked[0], /solved without DEATHRATTLE IS DOUBLE in 9 inputs/);
const cutOff = await checkProof(
{ level: "l3", shortest: 6, naiveUnsolvable: true },
fakeSolver((args) => (args.includes("--naive") ? CUT_OFF : SOLVED_IN(6))).run,
);
assert.equal(cutOff.length, 1);
assert.match(cutOff[0], /under --naive was cut off at 60 inputs, not exhausted/);
});
test("verify_levels_rejects_malformed_proofs", async () => {
assert.deepEqual(proofProblems({ level: "l1", shortest: 10 }), []);
assert.ok(proofProblems({ level: "l1" }).some((p) => /shortest/.test(p)));
assert.ok(proofProblems({ level: "l1", shortest: 3, neverRule: "double" }).some((p) => /neverRule/.test(p)));
assert.ok(proofProblems({ level: "l1", shortest: 3, naive: true }).some((p) => /unknown key "naive"/.test(p)));
const solver = fakeSolver(() => SOLVED_IN(3));
assert.notDeepEqual(await checkProof({ level: "l1", shortest: 0 }, solver.run), []);
assert.equal(solver.calls.length, 0);
});
test("verify_levels_every_level_has_one_proof", () => {
const levels = readdirSync(levelsDir).filter((f) => /^l\d+-.*\.json$/.test(f)).map((f) => f.replace(/\.json$/, "")).sort();
const proofs = JSON.parse(readFileSync(join(levelsDir, "proofs.json"), "utf8"));
for (const proof of proofs) assert.deepEqual(proofProblems(proof), [], proof.level);
assert.deepEqual(proofs.map((p) => p.level).sort(), levels);
// Each proof says what the level is for: a rule it can't be solved without, or the naive engine.
for (const proof of proofs.filter((p) => p.level !== "l2-spliced-corpse")) {
assert.ok(proof.neverRule !== undefined || proof.naiveUnsolvable, `${proof.level} proves nothing about its idea`);
}
});