- tools/mutation-check.mjs with tools/mutants.json: thirteen mutants against the Module 12 TypeScript reference (the eight of Module 13's first table, plus Armor before redirect, conditions at parse time, no defender priority, and the summon seam's noun check); all killed. - tools/run-scenario.mjs with tools/scenario/run.ts: a designer's cards, words and seam words as data, played on the Module 10 TypeScript reference. - course/10-spellgrid/designer-capstone.md and designer-example/ (output.txt is checked against the tool's output in the tools tests). - CI: diff-tracks (300 seeds), mutation-check, verify-levels. - Module 13: the mutation table is the tool's output; differential testing, undo and the solver sections describe the repository as it is. Lesson 10 says what the level proofs prove for levels 6, 8 and 9. - schema.mjs: a value that matches no oneOf branch is reported against the branch its const fields pick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
6.2 KiB
JavaScript
114 lines
6.2 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { run, toolsDir } from "./helpers.mjs";
|
|
import { mergeSeams, mergeWords, scenarioProblems } from "../run-scenario.mjs";
|
|
|
|
const course = join(toolsDir, "..", "course");
|
|
const example = join(course, "10-spellgrid", "designer-example");
|
|
|
|
/** Write `scenario` to a temp file and run the tool on it. */
|
|
function scenario(s) {
|
|
const file = join(mkdtempSync(join(tmpdir(), "sdc-scn-")), "scenario.json");
|
|
writeFileSync(file, typeof s === "string" ? s : JSON.stringify(s));
|
|
return run("run-scenario.mjs", [file]);
|
|
}
|
|
|
|
const text = (word, x, y) => ({ kind: `text:${word}`, x, y });
|
|
// BABA IS YOU on row 0, WOUND IS DOUBLE on row 1, Baba at (0,3).
|
|
const LEVEL = {
|
|
width: 5,
|
|
height: 4,
|
|
objects: [text("BABA", 0, 0), text("IS", 1, 0), text("YOU", 2, 0), text("WOUND", 0, 1), text("IS", 1, 1), text("DOUBLE", 2, 1), { kind: "baba", x: 0, y: 3 }],
|
|
cards: [],
|
|
hand: [],
|
|
};
|
|
const TOTEM = {
|
|
name: "Test Totem",
|
|
type: "minion",
|
|
cost: 1,
|
|
attack: 0,
|
|
health: 3,
|
|
noun: "ROCK",
|
|
split: 0,
|
|
text: [{ kind: "trigger", on: "selfDamaged" }, { kind: "effect", effect: { type: "dealDamage", target: "enemyHero", amount: 1 } }],
|
|
};
|
|
|
|
test("run_scenario_reports_schema_errors_readably", () => {
|
|
const bad = { ...TOTEM, text: [TOTEM.text[0], { kind: "effect", effect: { type: "dealDamage", target: "everyone", amount: 1 } }] };
|
|
const r = scenario({ cards: [bad], level: LEVEL, inputs: [] });
|
|
assert.equal(r.code, 1, r.out);
|
|
assert.match(r.out, /is not a valid scenario:\n/);
|
|
assert.match(r.out, /cards\[0\] \("Test Totem"\)\.text\[1\]\.effect\.target: not in enum \["self",/);
|
|
assert.doesNotMatch(r.out, /oneOf/);
|
|
|
|
const shape = scenario({ level: { width: 3 }, inputs: "d", seams: { triggerWords: { HURT: "whenever" } } });
|
|
assert.equal(shape.code, 1);
|
|
assert.match(shape.out, /level: missing "height"/);
|
|
assert.match(shape.out, /inputs: expected a list of command lines/);
|
|
assert.match(shape.out, /seams\.triggerWords\.HURT: "whenever" is not a trigger/);
|
|
|
|
const json = scenario("{ not json");
|
|
assert.equal(json.code, 1);
|
|
assert.match(json.out, /is not JSON: /);
|
|
});
|
|
|
|
test("run_scenario_merges_words_into_the_rules", () => {
|
|
const r = scenario({
|
|
words: [{ name: "WOUND", role: "noun" }],
|
|
seams: { triggerWords: { WOUND: "selfDamaged" } },
|
|
level: LEVEL,
|
|
inputs: [],
|
|
});
|
|
assert.equal(r.code, 0, r.out);
|
|
assert.match(r.out, /^Rules: BABA IS YOU, TEXT IS PUSH, WOUND IS DOUBLE$/m);
|
|
// Without the word, the text spells nothing.
|
|
const without = scenario({ level: LEVEL, inputs: [] });
|
|
assert.match(without.out, /^Rules: BABA IS YOU, TEXT IS PUSH$/m);
|
|
});
|
|
|
|
test("run_scenario_prints_the_log_as_a_tree", () => {
|
|
const level = { ...LEVEL, cards: [{ card: "Test Totem", x: 3, y: 3 }], hand: ["Whirlwind"] };
|
|
const r = scenario({ cards: [TOTEM], words: [{ name: "WOUND", role: "noun" }], seams: { triggerWords: { WOUND: "selfDamaged" } }, level, inputs: ["c Whirlwind", "w"] });
|
|
assert.equal(r.code, 0, r.out);
|
|
// WOUND IS DOUBLE: the totem's damage trigger activates twice, each with its Damage one level down.
|
|
assert.match(r.out, /\n> c Whirlwind\nLog:\nSpellCast\{card: "Whirlwind"\}\nDamaged\{target: (\d+), amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n {2}Damaged\{target: \d+, amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n {2}Damaged/);
|
|
assert.match(r.out, /Minions: Test Totem \(3,3\) 2 Health\nHand: empty\nHeroes: 30 Health \(yours\), 28 Health \(enemy\)\n/);
|
|
assert.match(r.out, /\n> w\n/);
|
|
assert.match(r.out, /\(end of inputs\)\n$/);
|
|
});
|
|
|
|
test("run_scenario_validates_words_and_seams", () => {
|
|
const base = [{ name: "BABA", role: "noun" }, { name: "DOUBLE", role: "property" }];
|
|
assert.deepEqual(mergeWords(base, [{ name: "WOUND", role: "noun" }, { name: "BABA", role: "noun" }]).map((w) => w.name), ["BABA", "DOUBLE", "WOUND"]);
|
|
assert.throws(() => mergeWords(base, [{ name: "BABA", role: "property" }]), /BABA is already a noun/);
|
|
const seams = { triggerWords: { DEATHRATTLE: "deathrattle" }, multiplierWords: { DOUBLE: { times: 2, stacking: "max" } } };
|
|
assert.deepEqual(mergeSeams(seams, { triggerWords: { WOUND: "selfDamaged" } }).triggerWords, { DEATHRATTLE: "deathrattle", WOUND: "selfDamaged" });
|
|
assert.throws(() => mergeSeams(seams, { triggerWords: { DEATHRATTLE: "battlecry" } }), /DEATHRATTLE already names "deathrattle"/);
|
|
|
|
const ctx = { words: base, cardNames: ["Whirlwind"] };
|
|
const problems = (s) => scenarioProblems({ level: LEVEL, inputs: [], ...s }, ctx);
|
|
assert.deepEqual(problems({}), []);
|
|
assert.match(problems({ words: [{ name: "wound", role: "noun" }] }).join("\n"), /words\[0\]\.name: "wound" must be capital letters/);
|
|
assert.match(problems({ words: [{ name: "AND", role: "operator" }] }).join("\n"), /words\[0\]\.role: must be "noun" or "property"/);
|
|
assert.match(problems({ seams: { triggerWords: { HURT: "selfDamaged" } } }).join("\n"), /seams\.triggerWords\.HURT: HURT is not a noun/);
|
|
assert.match(problems({ seams: { multiplierWords: { DOUBLE: { times: 1, stacking: "max" } } } }).join("\n"), /seams\.multiplierWords\.DOUBLE\.times: below minimum 2/);
|
|
assert.match(problems({ cards: [{ ...TOTEM, name: "Whirlwind" }] }).join("\n"), /cards\[0\] \("Whirlwind"\): the course already has a card named "Whirlwind"/);
|
|
assert.match(problems({ extra: 1 }).join("\n"), /unknown key "extra"/);
|
|
});
|
|
|
|
test("run_scenario_designer_example_output_is_current", () => {
|
|
// The capstone's worked example shows the tool's real output; this keeps it from going stale.
|
|
const r = run("run-scenario.mjs", [join(example, "scenario.json")]);
|
|
assert.equal(r.code, 0, r.out);
|
|
assert.equal(r.out, readFileSync(join(example, "output.txt"), "utf8"));
|
|
// The scenario carries the example's data files: the three cards, the word and its seam.
|
|
const json = (f) => JSON.parse(readFileSync(join(example, f), "utf8"));
|
|
const s = json("scenario.json");
|
|
assert.deepEqual(s.cards, ["ash-totem", "thorn-idol", "scorch"].map((c) => json(`cards/${c}.json`)));
|
|
assert.deepEqual(s.words, json("words.json"));
|
|
assert.deepEqual(s.seams, json("seams.json"));
|
|
});
|