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>
98 lines
4.8 KiB
JavaScript
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`);
|
|
}
|
|
});
|