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"] } } }; test("catalog_valid", () => { const cat = loadCatalog(); assert.ok(Array.isArray(cat) && cat.length > 0); const names = new Set(); for (const e of cat) { assert.deepEqual(validate({ ...entry, required: e.fictional ? ["name", "kind", "text"] : entry.required }, 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" && !e.fictional) assert.match(e.source, /^https:\/\/hearthstone\.wiki\.gg\//, e.name); 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 card the references load) 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))) { const d = join(courseDir, m, "expansion/solution/cards"); 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 Hearthstone (and fictional) card in the catalog has its data. for (const e of cat.values()) if (e.kind === "card" && !e.game) 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 }] }), []); });