- 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>
179 lines
9.3 KiB
JavaScript
179 lines
9.3 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { basename, join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { validate } from "../schema.mjs";
|
|
import { loadCatalog } from "../lib.mjs";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const json = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
const cardSchema = json(join(here, "../../course/schema/card.schema.json"));
|
|
const levelSchema = json(join(here, "../../course/schema/level.schema.json"));
|
|
const entry = {
|
|
type: "object",
|
|
required: ["name", "kind", "text", "source"],
|
|
properties: { name: { type: "string" }, kind: { enum: ["card", "word"] }, text: { type: "string" } },
|
|
};
|
|
const cardEntry = {
|
|
type: "object",
|
|
required: ["type", "cost"],
|
|
properties: { type: { enum: ["minion", "spell"] }, cost: { type: "integer", minimum: 0 }, attack: { type: "integer" }, health: { type: "integer" } },
|
|
};
|
|
const wordEntry = { type: "object", required: ["role"], properties: { role: { enum: ["noun", "operator", "property", "meta", "condition"] } } };
|
|
|
|
test("catalog_valid", () => {
|
|
const cat = loadCatalog();
|
|
assert.ok(Array.isArray(cat) && cat.length > 0);
|
|
const names = new Set();
|
|
for (const e of cat) {
|
|
// Test cards and SpellGrid's own cards and words (Module 10) have no outside source.
|
|
const sourced = !e.fictional && e.game !== "spellgrid";
|
|
assert.deepEqual(validate({ ...entry, required: sourced ? entry.required : ["name", "kind", "text"] }, e), [], e.name);
|
|
assert.deepEqual(validate(e.kind === "card" ? cardEntry : wordEntry, e), [], e.name);
|
|
assert.ok(!names.has(e.name), `duplicate ${e.name}`);
|
|
names.add(e.name);
|
|
if (e.kind === "card" && sourced) assert.match(e.source, /^https:\/\/hearthstone\.wiki\.gg\//, e.name);
|
|
if (e.game === "spellgrid") assert.ok(!e.fictional && !e.name.startsWith("Test "), `${e.name}: a SpellGrid card is not a test card`);
|
|
if (e.kind === "card" && e.name.startsWith("Test ")) assert.equal(e.fictional, true, e.name);
|
|
if (e.fictional) assert.ok(e.name.startsWith("Test "), e.name);
|
|
}
|
|
for (const n of ["Baron Rivendare", "Fleshshape", "Test Echo", "BABA", "IS", "DEATHRATTLE"]) assert.ok(names.has(n), n);
|
|
});
|
|
|
|
test("card_schema_accepts_fixtures", () => {
|
|
const dir = join(here, "fixtures/cards");
|
|
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
assert.ok(files.length >= 5);
|
|
const cat = new Map(loadCatalog().map((e) => [e.name, e]));
|
|
for (const f of files) {
|
|
const c = json(join(dir, f));
|
|
assert.deepEqual(validate(cardSchema, c), [], f);
|
|
const m = cat.get(c.name);
|
|
assert.ok(m, `${c.name} in catalog`);
|
|
assert.deepEqual([c.cost, c.attack, c.health], [m.cost, m.attack, m.health], `${f} matches catalog stats`);
|
|
}
|
|
});
|
|
|
|
// Card data the course ships: course/cards (every module's cards), module-local cards in
|
|
// course/NN-*/cards (Module 10's SpellGrid cards, staged from that module on), and the
|
|
// Expansion Drop answers in course/NN-*/expansion/solution/cards.
|
|
function courseCardFiles() {
|
|
const courseDir = join(here, "../../course");
|
|
const dirs = [join(courseDir, "cards")];
|
|
for (const m of readdirSync(courseDir).filter((d) => /^\d\d-/.test(d))) {
|
|
for (const sub of ["cards", "expansion/solution/cards"]) {
|
|
const d = join(courseDir, m, sub);
|
|
if (existsSync(d)) dirs.push(d);
|
|
}
|
|
}
|
|
return dirs.flatMap((d) => readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)));
|
|
}
|
|
|
|
test("course_cards_valid_and_match_catalog", () => {
|
|
const files = courseCardFiles();
|
|
assert.ok(files.length >= 20, `found ${files.length} card files`);
|
|
const cat = new Map(loadCatalog().map((e) => [e.name, e]));
|
|
const seen = new Set();
|
|
for (const f of files) {
|
|
const c = json(f);
|
|
assert.deepEqual(validate(cardSchema, c), [], f);
|
|
assert.equal(basename(f), c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") + ".json", `${f}: file name is the card's slug`);
|
|
assert.ok(!seen.has(c.name), `duplicate card file for ${c.name}`);
|
|
seen.add(c.name);
|
|
const m = cat.get(c.name);
|
|
assert.ok(m && m.kind === "card", `${c.name} in catalog`);
|
|
assert.deepEqual(
|
|
[c.name, c.type, c.cost, c.attack, c.health, c.fictional ?? false],
|
|
[m.name, m.type, m.cost, m.attack, m.health, m.fictional ?? false],
|
|
`${f} matches catalog`,
|
|
);
|
|
assert.ok(c.split <= c.text.length, `${f}: split within text`);
|
|
}
|
|
// Every card in the catalog (Hearthstone, fictional and SpellGrid's own) has its data.
|
|
for (const e of cat.values()) if (e.kind === "card") assert.ok(seen.has(e.name), `no card file for ${e.name}`);
|
|
});
|
|
|
|
test("card_schema_assemble_effect", () => {
|
|
const spell = (effect) => ({ name: "X", type: "spell", cost: 3, split: 0, text: [{ kind: "effect", effect }] });
|
|
assert.deepEqual(validate(cardSchema, spell({ type: "assemble", filter: { trigger: "deathrattle" } })), []);
|
|
assert.deepEqual(validate(cardSchema, spell({ type: "assemble", filter: {} })), []);
|
|
assert.deepEqual(validate(cardSchema, spell({ type: "buffHand", attack: 2, health: 2 })), []);
|
|
assert.notDeepEqual(validate(cardSchema, spell({ type: "assemble" })), []);
|
|
assert.notDeepEqual(validate(cardSchema, spell({ type: "assemble", filter: { trigger: "bogus" } })), []);
|
|
assert.notDeepEqual(validate(cardSchema, spell({ type: "buffHand", attack: 2 })), []);
|
|
});
|
|
|
|
test("card_schema_rejects_bad", () => {
|
|
const bad = { name: "X", type: "minion", cost: 1, split: 0, text: [{ kind: "trigger", on: "bogus" }] };
|
|
assert.notDeepEqual(validate(cardSchema, bad), []);
|
|
assert.notDeepEqual(validate(cardSchema, { name: "X" }), []);
|
|
});
|
|
|
|
test("level_schema", () => {
|
|
assert.deepEqual(validate(levelSchema, { width: 3, height: 1, objects: [{ kind: "baba", x: 0, y: 0 }] }), []);
|
|
assert.notDeepEqual(validate(levelSchema, { width: 3, height: 1, objects: [{ kind: "baba", x: 0 }] }), []);
|
|
});
|
|
|
|
test("level_schema_spellgrid_fields", () => {
|
|
const level = { width: 2, height: 1, objects: [], cards: [{ card: "Powder Keg", x: 1, y: 0 }], hand: ["Spark"] };
|
|
assert.deepEqual(validate(levelSchema, level), []);
|
|
assert.notDeepEqual(validate(levelSchema, { ...level, hand: [{ card: "Spark" }] }), []);
|
|
assert.notDeepEqual(validate(levelSchema, { ...level, cards: [{ card: "Powder Keg", x: 1 }] }), []);
|
|
});
|
|
|
|
// Puzzle levels the course ships: course/NN-*/levels/*.json (proofs.json beside them is
|
|
// verify-levels' data). Every one is valid, stays on its grid, writes only words its module's
|
|
// reference knows and places objects of its nouns, and names only cards that have data,
|
|
// minions with a noun on the grid.
|
|
test("course_levels_valid", () => {
|
|
const courseDir = join(here, "../../course");
|
|
const cards = new Map(courseCardFiles().map((f) => json(f)).map((c) => [c.name, c]));
|
|
const files = readdirSync(courseDir)
|
|
.filter((d) => /^\d\d-/.test(d) && existsSync(join(courseDir, d, "levels")))
|
|
.flatMap((d) =>
|
|
readdirSync(join(courseDir, d, "levels"))
|
|
.filter((f) => f.endsWith(".json") && f !== "proofs.json")
|
|
.map((f) => ({ mod: join(courseDir, d), f: join(courseDir, d, "levels", f) })),
|
|
);
|
|
assert.ok(files.length >= 3, `found ${files.length} level files`);
|
|
for (const { mod, f } of files) {
|
|
const level = json(f);
|
|
assert.deepEqual(validate(levelSchema, level), [], f);
|
|
const words = json(join(mod, "reference", "ts", "src", "content", "words.json"));
|
|
const nouns = new Set(words.filter((w) => w.role === "noun").map((w) => w.name));
|
|
const written = new Set([...words.map((w) => w.name), "IS", "AND", "NOT"]);
|
|
for (const { kind } of level.objects) {
|
|
const known = kind.startsWith("text:") ? written.has(kind.slice(5)) : nouns.has(kind.toUpperCase());
|
|
assert.ok(known, `${f}: ${kind} is not a word or noun of ${basename(mod)}`);
|
|
}
|
|
for (const o of [...level.objects, ...(level.cards ?? [])]) {
|
|
assert.ok(o.x < level.width && o.y < level.height, `${f}: (${o.x}, ${o.y}) is off the grid`);
|
|
}
|
|
for (const { card } of level.cards ?? []) {
|
|
const c = cards.get(card);
|
|
assert.ok(c && c.type === "minion" && c.noun, `${f}: ${card} is a minion with a noun`);
|
|
}
|
|
for (const card of level.hand ?? []) assert.ok(cards.has(card), `${f}: no card data for ${card}`);
|
|
}
|
|
});
|
|
|
|
test("spellgrid_cards_are_module_local", () => {
|
|
// course/cards reaches every workspace from Module 5; SpellGrid's cards must not reach
|
|
// Modules 5-9 (their spawnText effect and their place in Assemble pools are Module 10's).
|
|
const shared = join(here, "../../course/cards");
|
|
for (const f of readdirSync(shared).filter((f) => f.endsWith(".json"))) {
|
|
assert.notEqual(json(join(shared, f)).game, "spellgrid", `${f} belongs in course/10-spellgrid/cards`);
|
|
}
|
|
});
|
|
|
|
test("schema_oneof_error_names_the_meant_branch", () => {
|
|
const card = (effect) => ({ name: "X", type: "spell", cost: 1, split: 0, text: [{ kind: "effect", effect }] });
|
|
// The clause's kind and the effect's type pick one branch each: the error is about that branch.
|
|
assert.deepEqual(validate(cardSchema, card({ type: "dealDamage", target: "everyone", amount: 1 })), [
|
|
'$.text[0].effect.target: not in enum ["self","allMinions","allOtherMinions","allOtherCharacters","enemyHero","chosen"]',
|
|
]);
|
|
// An unknown effect type picks no branch: the general message stays.
|
|
assert.deepEqual(validate(cardSchema, card({ type: "heal", amount: 1 })), ["$.text[0].effect: matches 0 oneOf branches (need 1)"]);
|
|
});
|