The sync step copies the reference's engine, harness and content into site/src/game/generated/ and replaces only content/index.ts (node:fs) with a re-export of a browser content module (static imports of cards, words, seams, levels). /play/ has a level picker, text-tile grid, keyboard and buttons, cards then tiles, the chooser as buttons, rules, minions, the log tree, Undo by replay and Restart. test:site plays l3 to the win under plain Node. Lessons 5, 10-13 and the home page link the page; lesson links to site pages go through their source file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
6.2 KiB
JavaScript
138 lines
6.2 KiB
JavaScript
// 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");
|
|
});
|