// The playable SpellGrid page runs the TypeScript reference unmodified. These tests load // the game exactly as the page does (the generated sources plus site/src/game/*.ts), // under plain Node: no npm packages, so `npm run test:site` needs no install. import { after, test } from "node:test"; import assert from "node:assert/strict"; import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; import { registerHooks, stripTypeScriptTypes } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { syncGame } from "./sync-content.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, "../.."); // Stage site/src/game (without its generated directory) into a temp directory and generate there. const stage = mkdtempSync(join(tmpdir(), "game-")); after(() => rmSync(stage, { recursive: true, force: true })); for (const f of readdirSync(join(here, "../src/game"))) { if (f.endsWith(".ts")) cpSync(join(here, "../src/game", f), join(stage, f)); } const synced = syncGame({ repoRoot, outDir: join(stage, "generated") }); // stripTypeScriptTypes is experimental in Node 22 and 24; keep its one warning out of the test output. const emitWarning = process.emitWarning; process.emitWarning = (warning, ...rest) => { if (!String(warning).includes("stripTypeScriptTypes")) emitWarning.call(process, warning, ...rest); }; // What Vite does for the page, for the staged files only: extensionless and directory // imports, TypeScript, and JSON imports without an import attribute. const inStage = (url) => url.startsWith(pathToFileURL(stage).href); registerHooks({ resolve(specifier, context, next) { if (specifier.startsWith(".") && context.parentURL && inStage(context.parentURL)) { const base = fileURLToPath(new URL(specifier, context.parentURL)); for (const candidate of [base, `${base}.ts`, join(base, "index.ts")]) { if (existsSync(candidate) && statSync(candidate).isFile()) { return { url: pathToFileURL(candidate).href, shortCircuit: true }; } } } return next(specifier, context); }, load(url, context, next) { if (inStage(url) && url.endsWith(".ts")) { const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), "utf8"), { mode: "transform" }); return { format: "module", source, shortCircuit: true }; } if (inStage(url) && url.endsWith(".json")) { return { format: "module", source: `export default ${readFileSync(fileURLToPath(url), "utf8")};`, shortCircuit: true }; } return next(url, context); }, }); const { levels } = await import(pathToFileURL(join(stage, "content.browser.ts")).href); const { Session } = await import(pathToFileURL(join(stage, "session.ts")).href); const level = (name) => levels().find((l) => l.name.startsWith(`${name}-`)); test("game_sync_copies_the_highest_reference", () => { assert.match(synced.module, /^\d\d-/); const ref = join(repoRoot, "course", synced.module, "reference/ts/src"); assert.equal(readFileSync(join(stage, "generated/harness.ts"), "utf8"), readFileSync(join(ref, "harness.ts"), "utf8")); assert.equal( readFileSync(join(stage, "generated/engine/spellgrid.ts"), "utf8"), readFileSync(join(ref, "engine/spellgrid.ts"), "utf8"), ); const higher = readdirSync(join(repoRoot, "course")).filter( (d) => /^\d\d-/.test(d) && d > synced.module && existsSync(join(repoRoot, "course", d, "reference/ts/src/harness.ts")), ); assert.deepEqual(higher, []); }); test("game_content_lists_every_level_with_its_idea", () => { const all = levels(); const files = readdirSync(join(repoRoot, "course/10-spellgrid/levels")).filter((f) => f.endsWith(".json") && f !== "proofs.json"); assert.equal(all.length, files.length); assert.deepEqual(all.slice(0, 3).map((l) => l.name), ["l1-double-tap", "l2-spliced-corpse", "l3-simultaneous-paradox"]); assert.equal(all.at(-1).name, "l10-friendly-fire"); const l3 = level("l3"); assert.equal(l3.title, "Simultaneous Paradox"); assert.match(l3.idea, /^You are the Tin Knight \(3 Health\)\./); assert.doesNotMatch(l3.idea, /Hand:/); for (const l of all) assert.ok(l.idea.length > 0, `${l.name} has no idea`); }); test("game_solves_l3_with_its_scripted_inputs", () => { const s = new Session(level("l3").level); assert.deepEqual(s.grid.hand(), ["Whirlwind"]); assert.deepEqual(s.apply({ type: "cast", card: "Whirlwind", picks: [] }), { ok: true }); assert.match(s.tree, /^Triggered\{source: \d+, trigger: "deathrattle"\}$/m); assert.match(s.tree, /^ Damaged\{target: \d+, amount: 1\}$/m); for (let i = 0; i < 5; i++) s.apply({ type: "step", dir: "right" }); assert.equal(s.grid.won(), true); }); test("game_undo_replays_the_inputs", () => { const s = new Session(level("l3").level); s.apply({ type: "cast", card: "Whirlwind", picks: [] }); for (let i = 0; i < 5; i++) s.apply({ type: "step", dir: "right" }); assert.equal(s.undo(), true); assert.equal(s.grid.won(), false); assert.equal(s.inputs.length, 5); s.apply({ type: "step", dir: "right" }); assert.equal(s.grid.won(), true); while (s.undo()); assert.deepEqual(s.grid.hand(), ["Whirlwind"]); assert.equal(s.undo(), false); }); test("game_rejects_an_illegal_input_and_keeps_the_state", () => { const s = new Session(level("l3").level); const r = s.apply({ type: "cast", card: "Spark", picks: [] }); assert.equal(r.ok, false); assert.match(r.error, /not in the hand/); assert.deepEqual(s.inputs, []); assert.deepEqual(s.grid.hand(), ["Whirlwind"]); }); test("game_asks_the_chooser_questions_one_at_a_time", () => { const s = new Session(level("l2").level); const input = { type: "cast", card: "Graft", picks: [] }; const asked = []; let r = s.apply(input); while (r.question) { asked.push(r.question.kind); assert.deepEqual(s.inputs, [], "nothing is recorded until every question is answered"); assert.deepEqual(s.grid.hand(), ["Graft"]); input.picks.push(0); r = s.apply(input); } assert.deepEqual(r, { ok: true }); assert.deepEqual(asked, ["base", "donor", "half"]); assert.equal(s.inputs.length, 1); assert.equal(s.grid.hand().length, 1); assert.notEqual(s.grid.hand()[0], "Graft"); });