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_writes_conditions_from_module_11", () => { // Module 11's vocabulary has a condition word (ON): some sentences carry a condition. // Module 10's has none, so its cases have no condition word in a sentence. const vocab11 = loadVocab(join(course, "11-conditions", "reference", "ts", "src", "content", "words.json"), cardDirs("11")); const conditions = new Set(vocab11.words.filter((w) => w.role === "condition").map((w) => w.name)); assert.ok(conditions.has("ON")); // A condition word followed by a noun in a row or column, as a sentence writes it. const withCondition = (v) => SEEDS.filter((seed) => { const { level } = generateCase(seed, v); const at = new Map(level.objects.filter((o) => o.kind.startsWith("text:")).map((o) => [`${o.x},${o.y}`, o.kind.slice(5)])); const noun = (w) => w !== undefined && v.words.some((x) => x.name === w && x.role === "noun"); return [...at].some(([key, word]) => { const [x, y] = key.split(",").map(Number); return conditions.has(word) && (noun(at.get(`${x + 1},${y}`)) || noun(at.get(`${x},${y + 1}`))); }); }); assert.ok(withCondition(vocab11).length > SEEDS.length * 0.3, `${withCondition(vocab11).length} of ${SEEDS.length}`); assert.deepEqual(withCondition(vocab), []); }); 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"); });