From 7854f6f531bc15990abb9d1b5ac439686d82dc29 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:54:55 +0100 Subject: [PATCH] feat: repo foundation and course tooling Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 5 ++ LICENSE | 21 ++++++ README.md | 39 ++++++++++ package.json | 10 +++ tools/coupling-audit.mjs | 73 +++++++++++++++++++ tools/expansion-check.mjs | 20 +++++ tools/lib.mjs | 60 +++++++++++++++ tools/start-module.mjs | 45 ++++++++++++ tools/sync-check.mjs | 26 +++++++ tools/test/coupling-audit.test.mjs | 26 +++++++ tools/test/expansion-check.test.mjs | 36 +++++++++ .../fixtures/course-bad/01-a/scenarios.md | 3 + .../fixtures/course-bad/01-a/tests/rust/a.rs | 2 + .../course-bad/01-a/tests/ts/a.test.ts | 2 + tools/test/fixtures/course-bad/catalog.json | 1 + .../fixtures/course/01-m01/harness-adds.md | 1 + .../course/01-m01/reference/rust/Cargo.toml | 2 + .../course/01-m01/reference/ts/package.json | 1 + .../test/fixtures/course/01-m01/scenarios.md | 1 + .../fixtures/course/01-m01/tests/rust/m01.rs | 2 + .../course/01-m01/tests/ts/m01.test.ts | 1 + .../course/02-m02/reference/rust/Cargo.toml | 2 + .../course/02-m02/reference/ts/package.json | 1 + .../test/fixtures/course/02-m02/scenarios.md | 1 + .../fixtures/course/02-m02/tests/rust/m02.rs | 2 + .../course/02-m02/tests/ts/m02.test.ts | 1 + .../course/03-m03/reference/rust/Cargo.toml | 2 + .../course/03-m03/reference/ts/package.json | 1 + .../test/fixtures/course/03-m03/scenarios.md | 1 + .../fixtures/course/03-m03/tests/rust/m03.rs | 2 + .../course/03-m03/tests/ts/m03.test.ts | 1 + tools/test/fixtures/course/catalog.json | 9 +++ .../fixtures/ws-content-only/.course.json | 1 + .../ws-content-only/src/content/cards.rs | 1 + .../ws-content-only/src/engine/core.rs | 1 + tools/test/fixtures/ws-coupled/.course.json | 1 + .../fixtures/ws-coupled/src/content/cards.rs | 1 + .../fixtures/ws-coupled/src/engine/core.rs | 3 + tools/test/fixtures/ws-grammar/.course.json | 1 + .../fixtures/ws-grammar/src/content/cards.rs | 0 .../fixtures/ws-grammar/src/engine/core.rs | 2 + tools/test/fixtures/ws-start-ts/.course.json | 1 + .../fixtures/ws-start-ts/src/engine/.keep | 0 tools/test/fixtures/ws-start/.course.json | 1 + tools/test/fixtures/ws-start/src/engine/.keep | 0 tools/test/helpers.mjs | 31 ++++++++ tools/test/start-module.test.mjs | 47 ++++++++++++ tools/test/sync-check.test.mjs | 16 ++++ tools/test/verify-references.test.mjs | 47 ++++++++++++ tools/verify-references.mjs | 53 ++++++++++++++ 50 files changed, 607 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package.json create mode 100644 tools/coupling-audit.mjs create mode 100644 tools/expansion-check.mjs create mode 100644 tools/lib.mjs create mode 100644 tools/start-module.mjs create mode 100644 tools/sync-check.mjs create mode 100644 tools/test/coupling-audit.test.mjs create mode 100644 tools/test/expansion-check.test.mjs create mode 100644 tools/test/fixtures/course-bad/01-a/scenarios.md create mode 100644 tools/test/fixtures/course-bad/01-a/tests/rust/a.rs create mode 100644 tools/test/fixtures/course-bad/01-a/tests/ts/a.test.ts create mode 100644 tools/test/fixtures/course-bad/catalog.json create mode 100644 tools/test/fixtures/course/01-m01/harness-adds.md create mode 100644 tools/test/fixtures/course/01-m01/reference/rust/Cargo.toml create mode 100644 tools/test/fixtures/course/01-m01/reference/ts/package.json create mode 100644 tools/test/fixtures/course/01-m01/scenarios.md create mode 100644 tools/test/fixtures/course/01-m01/tests/rust/m01.rs create mode 100644 tools/test/fixtures/course/01-m01/tests/ts/m01.test.ts create mode 100644 tools/test/fixtures/course/02-m02/reference/rust/Cargo.toml create mode 100644 tools/test/fixtures/course/02-m02/reference/ts/package.json create mode 100644 tools/test/fixtures/course/02-m02/scenarios.md create mode 100644 tools/test/fixtures/course/02-m02/tests/rust/m02.rs create mode 100644 tools/test/fixtures/course/02-m02/tests/ts/m02.test.ts create mode 100644 tools/test/fixtures/course/03-m03/reference/rust/Cargo.toml create mode 100644 tools/test/fixtures/course/03-m03/reference/ts/package.json create mode 100644 tools/test/fixtures/course/03-m03/scenarios.md create mode 100644 tools/test/fixtures/course/03-m03/tests/rust/m03.rs create mode 100644 tools/test/fixtures/course/03-m03/tests/ts/m03.test.ts create mode 100644 tools/test/fixtures/course/catalog.json create mode 100644 tools/test/fixtures/ws-content-only/.course.json create mode 100644 tools/test/fixtures/ws-content-only/src/content/cards.rs create mode 100644 tools/test/fixtures/ws-content-only/src/engine/core.rs create mode 100644 tools/test/fixtures/ws-coupled/.course.json create mode 100644 tools/test/fixtures/ws-coupled/src/content/cards.rs create mode 100644 tools/test/fixtures/ws-coupled/src/engine/core.rs create mode 100644 tools/test/fixtures/ws-grammar/.course.json create mode 100644 tools/test/fixtures/ws-grammar/src/content/cards.rs create mode 100644 tools/test/fixtures/ws-grammar/src/engine/core.rs create mode 100644 tools/test/fixtures/ws-start-ts/.course.json create mode 100644 tools/test/fixtures/ws-start-ts/src/engine/.keep create mode 100644 tools/test/fixtures/ws-start/.course.json create mode 100644 tools/test/fixtures/ws-start/src/engine/.keep create mode 100644 tools/test/helpers.mjs create mode 100644 tools/test/start-module.test.mjs create mode 100644 tools/test/sync-check.test.mjs create mode 100644 tools/test/verify-references.test.mjs create mode 100644 tools/verify-references.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9db400 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +target/ +dist/ +.DS_Store +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1e62461 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lucas Winther + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..da37036 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Systemic Design Course + +A free, hands-on course on **systemic game design**. You learn by rebuilding the rules machinery of two games, then extending it: + +- **Part I: Hearthstone** (cards, zones, triggers, death phases, auras, text assembly) +- **Part II: Baba Is You** (rules as pushable text, parsing, movement, win/lose) +- **Part III: SpellGrid**, a small original game that combines both systems + +Every module follows the same beats: Observe, Predict, Spec, Build, Name it, Expansion Drop, Further reading. The Expansion Drop is the test of your design: you add new content without touching the engine. + +## Two paths + +- **Designer path**: read the lessons and scenarios, predict outcomes, and reason about the design. +- **Builder path**: also make the tests pass in your own engine. + +## Two tracks + +Builder path exercises exist in **Rust** and **TypeScript**. Both tracks have identical test names, matching the scenarios in each module's `scenarios.md`. + +## Quick start + +```sh +node --version # Node 24 for the course tooling +npm run test:tools # tooling tests +npm run check # test names agree across Rust, TS and scenarios +node tools/start-module.mjs 01 path/to/your/workspace +``` + +`start-module` copies a module's tests for your track (from your workspace's `.course.json`) and refuses to start a module if you have not yet taken the previous one. Module 00 uses the `naive-engine/` project and is not started this way. + +Other tools: `tools/coupling-audit.mjs ` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs ` (an Expansion Drop must change content only), `tools/verify-references.mjs [NN]` (reference solutions pass the tests). + +## Licence + +MIT, for both prose and code. See [LICENSE](LICENSE). The course is and stays free. + +## Disclaimer + +Not affiliated with Blizzard or Hempuli. See the [disclaimer](/disclaimer/). diff --git a/package.json b/package.json new file mode 100644 index 0000000..88b996c --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "systemic-design-course", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "test:tools": "node --test tools/test/*.test.mjs", + "check": "node tools/sync-check.mjs" + } +} diff --git a/tools/coupling-audit.mjs b/tools/coupling-audit.mjs new file mode 100644 index 0000000..fb60c04 --- /dev/null +++ b/tools/coupling-audit.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Zero-Coupling Law: engine dirs must not name catalog cards or noun words. +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { loadCatalog, fail } from "./lib.mjs"; + +const META_NOUNS = new Set(["TEXT", "ALL", "EMPTY"]); + +export function auditNames(catalog) { + return catalog + .filter((e) => e.kind === "card" || (e.kind === "word" && e.role === "noun" && !META_NOUNS.has(e.name))) + .map((e) => e.name) + .filter((n) => n.length >= 2); +} + +export function variants(name) { + const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean); + const lower = words.map((w) => w.toLowerCase()); + const cap = (w) => w[0].toUpperCase() + w.slice(1); + return new Set([ + name, + lower.join("_"), + lower[0] + lower.slice(1).map(cap).join(""), + lower.map(cap).join(""), + lower.join("_").toUpperCase(), + ]); +} + +const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +function walk(dir, out = []) { + for (const e of readdirSync(dir)) { + if (e === "node_modules" || e === "target" || e.startsWith(".")) continue; + const p = join(dir, e); + statSync(p).isDirectory() ? walk(p, out) : out.push(p); + } + return out; +} + +export function audit(ws, catalog) { + const cfg = JSON.parse(readFileSync(join(ws, ".course.json"), "utf8")); + const matchers = []; + for (const name of auditNames(catalog)) { + for (const v of variants(name)) { + matchers.push({ name, re: new RegExp(`(? { + const seen = new Set(); + for (const m of matchers) { + if (!seen.has(m.name) && m.re.test(line)) { + seen.add(m.name); + hits.push(`${relative(ws, file)}:${i + 1}: names "${m.name}"`); + } + } + }); + } + } + return hits; +} + +if (import.meta.main) { + const ws = process.argv[2]; + if (!ws) fail("usage: coupling-audit.mjs "); + const hits = audit(ws, loadCatalog()); + if (hits.length) fail("coupling-audit: engine names specific content\n" + hits.map((h) => " " + h).join("\n")); + console.log("coupling-audit: ok"); +} diff --git a/tools/expansion-check.mjs b/tools/expansion-check.mjs new file mode 100644 index 0000000..8181893 --- /dev/null +++ b/tools/expansion-check.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +// Expansion Drop must touch only content: fail if any changed path is under an engine dir. +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join, posix } from "node:path"; +import { fail } from "./lib.mjs"; + +const [ws, baseRef] = process.argv.slice(2); +if (!ws || !baseRef) fail("usage: expansion-check.mjs "); + +const git = (...a) => execFileSync("git", a, { cwd: ws, encoding: "utf8" }).split("\n").filter(Boolean); +const cfg = JSON.parse(readFileSync(join(ws, ".course.json"), "utf8")); +const changed = new Set([ + ...git("diff", "--name-only", "--relative", baseRef), + ...git("ls-files", "--others", "--exclude-standard"), +]); +const engine = (cfg.engineDirs || []).map((d) => posix.normalize(d).replace(/\/$/, "")); +const bad = [...changed].filter((p) => engine.some((d) => p === d || p.startsWith(d + "/"))); +if (bad.length) fail("expansion-check: engine changed during Expansion Drop\n" + bad.map((p) => " " + p).join("\n")); +console.log(`expansion-check: ok (${changed.size} changed, none in engine)`); diff --git a/tools/lib.mjs b/tools/lib.mjs new file mode 100644 index 0000000..a8ff1c3 --- /dev/null +++ b/tools/lib.mjs @@ -0,0 +1,60 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); + +/** Course directory; override with COURSE_ROOT (used by tests). */ +export function courseRoot() { + return process.env.COURSE_ROOT + ? resolve(process.env.COURSE_ROOT) + : join(here, "..", "course"); +} + +/** Module directories `course/NN-slug/`, sorted by number. */ +export function moduleDirs() { + const root = courseRoot(); + if (!existsSync(root)) return []; + return readdirSync(root) + .map((name) => ({ name, m: /^(\d\d)-(.+)$/.exec(name) })) + .filter(({ name, m }) => m && statSync(join(root, name)).isDirectory()) + .map(({ name, m }) => ({ num: m[1], slug: m[2], path: join(root, name) })) + .sort((a, b) => a.num.localeCompare(b.num)); +} + +export function testFiles(path, lang) { + const dir = join(path, "tests", lang === "rust" ? "rust" : "ts"); + if (!existsSync(dir)) return []; + const ext = lang === "rust" ? /\.rs$/ : /\.test\.ts$/; + return readdirSync(dir).filter((f) => ext.test(f)).sort().map((f) => join(dir, f)); +} + +/** Test names declared by a module's tests for `lang` ("rust" | "ts"). */ +export function testNames(path, lang) { + const re = lang === "rust" ? /fn\s+(test_\w+)/g : /test\(\s*["'](test_\w+)/g; + const names = new Set(); + for (const f of testFiles(path, lang)) { + for (const m of readFileSync(f, "utf8").matchAll(re)) names.add(m[1]); + } + return [...names].sort(); +} + +/** `### test_…` headings in a module's scenarios.md. */ +export function scenarioNames(path) { + const f = join(path, "scenarios.md"); + if (!existsSync(f)) return []; + const names = new Set(); + for (const m of readFileSync(f, "utf8").matchAll(/^### (test_\w+)/gm)) names.add(m[1]); + return [...names].sort(); +} + +export function loadCatalog() { + const f = join(courseRoot(), "catalog.json"); + if (!existsSync(f)) throw new Error(`catalog not found: ${f}`); + return JSON.parse(readFileSync(f, "utf8")); +} + +export function fail(msg) { + console.error(msg); + process.exit(1); +} diff --git a/tools/start-module.mjs b/tools/start-module.mjs new file mode 100644 index 0000000..d1dba76 --- /dev/null +++ b/tools/start-module.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// Copies module NN's tests for the learner's track into /tests/. +import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { moduleDirs, courseRoot, testFiles, fail } from "./lib.mjs"; + +const [num, ws] = process.argv.slice(2); +if (!/^\d\d$/.test(num || "") || !ws) fail("usage: start-module.mjs "); +if (num === "00") fail("Module 00 is not startable: M0 uses the naive-engine/ project, not a workspace."); + +const cfg = JSON.parse(readFileSync(join(ws, ".course.json"), "utf8")); +const track = cfg.track; +if (track !== "rust" && track !== "ts") fail(`bad track in .course.json: ${track}`); + +const mods = moduleDirs(); +const mod = mods.find((m) => m.num === num); +if (!mod) fail(`no module ${num} in ${courseRoot()}`); + +if (num !== "01") { + const prevNum = String(Number(num) - 1).padStart(2, "0"); + const prev = mods.find((m) => m.num === prevNum); + if (prev) { + const missing = testFiles(prev.path, track).map((f) => basename(f)) + .filter((f) => !existsSync(join(ws, "tests", f))); + if (missing.length) fail(`Cannot start module ${num}: finish module ${prevNum} first. Missing tests/${missing.join(", tests/")}`); + } +} + +const files = testFiles(mod.path, track); +if (!files.length) fail(`module ${num} has no ${track} tests`); +mkdirSync(join(ws, "tests"), { recursive: true }); +for (const f of files) cpSync(f, join(ws, "tests", basename(f))); +console.log(`Copied ${files.length} test file(s) for module ${num} into ${join(ws, "tests")}`); + +// Harness additions: module-local note, else the "- M:" line of course/harness.md. +const note = join(mod.path, "harness-adds.md"); +let adds = existsSync(note) ? readFileSync(note, "utf8").trim() : ""; +if (!adds) { + const h = join(courseRoot(), "harness.md"); + if (existsSync(h)) { + const re = new RegExp(`^\\s*[-*]\\s*\\**M${Number(num)}\\b.*$`, "m"); + adds = (re.exec(readFileSync(h, "utf8")) || [""])[0].trim(); + } +} +console.log(adds ? `Harness functions added by module ${num}:\n${adds}` : `No harness additions recorded for module ${num} (see course/harness.md).`); diff --git a/tools/sync-check.mjs b/tools/sync-check.mjs new file mode 100644 index 0000000..02f0566 --- /dev/null +++ b/tools/sync-check.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Verifies Rust tests, TS tests and scenarios.md list identical test names per module. +import { moduleDirs, testNames, scenarioNames } from "./lib.mjs"; + +const problems = []; +for (const mod of moduleDirs()) { + const sets = { + rust: testNames(mod.path, "rust"), + ts: testNames(mod.path, "ts"), + scenarios: scenarioNames(mod.path), + }; + if (Object.values(sets).every((s) => s.length === 0)) continue; + const all = new Set(Object.values(sets).flat()); + for (const name of [...all].sort()) { + for (const [where, names] of Object.entries(sets)) { + if (!names.includes(name)) { + problems.push(`module ${mod.num}-${mod.slug}: ${name} missing from ${where}`); + } + } + } +} +if (problems.length) { + console.error("sync-check: mismatches\n" + problems.map((p) => " " + p).join("\n")); + process.exit(1); +} +console.log("sync-check: ok"); diff --git a/tools/test/coupling-audit.test.mjs b/tools/test/coupling-audit.test.mjs new file mode 100644 index 0000000..e2d9c2e --- /dev/null +++ b/tools/test/coupling-audit.test.mjs @@ -0,0 +1,26 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { run, fixtures } from "./helpers.mjs"; +import { join } from "node:path"; + +const env = { COURSE_ROOT: join(fixtures, "course") }; +const audit = (ws) => run("coupling-audit.mjs", [join(fixtures, ws)], env); + +test("audit_flags_card_name_in_engine", () => { + const r = audit("ws-coupled"); + assert.equal(r.code, 1); + assert.match(r.out, /core\.rs:2/); + assert.match(r.out, /core\.rs:3/); + assert.match(r.out, /Baron Rivendare/); + assert.doesNotMatch(r.out, /cards\.rs/); +}); + +test("audit_ignores_content_dir", () => { + const r = audit("ws-content-only"); + assert.equal(r.code, 0, r.out); +}); + +test("audit_ignores_grammar_words", () => { + const r = audit("ws-grammar"); + assert.equal(r.code, 0, r.out); +}); diff --git a/tools/test/expansion-check.test.mjs b/tools/test/expansion-check.test.mjs new file mode 100644 index 0000000..d313de1 --- /dev/null +++ b/tools/test/expansion-check.test.mjs @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { run, copyFixture, git } from "./helpers.mjs"; + +function repo() { + const ws = copyFixture("ws-content-only"); + git(ws, "init", "-q"); + git(ws, "add", "-A"); + git(ws, "commit", "-qm", "base"); + git(ws, "tag", "base"); + return ws; +} + +test("expansion_check_rejects_engine_edit", () => { + const ws = repo(); + writeFileSync(join(ws, "src/engine/core.rs"), "fn changed() {}\n"); + const r = run("expansion-check.mjs", [ws, "base"]); + assert.equal(r.code, 1); + assert.match(r.out, /src\/engine\/core\.rs/); +}); + +test("expansion_check_rejects_new_engine_file", () => { + const ws = repo(); + writeFileSync(join(ws, "src/engine/new.rs"), "fn n() {}\n"); + assert.equal(run("expansion-check.mjs", [ws, "base"]).code, 1); +}); + +test("expansion_check_accepts_content_only", () => { + const ws = repo(); + writeFileSync(join(ws, "src/content/cards.rs"), "// new card\n"); + writeFileSync(join(ws, "src/content/more.rs"), "// untracked\n"); + const r = run("expansion-check.mjs", [ws, "base"]); + assert.equal(r.code, 0, r.out); +}); diff --git a/tools/test/fixtures/course-bad/01-a/scenarios.md b/tools/test/fixtures/course-bad/01-a/scenarios.md new file mode 100644 index 0000000..b69ea00 --- /dev/null +++ b/tools/test/fixtures/course-bad/01-a/scenarios.md @@ -0,0 +1,3 @@ +# Scenarios +### test_01_a +### test_01_b diff --git a/tools/test/fixtures/course-bad/01-a/tests/rust/a.rs b/tools/test/fixtures/course-bad/01-a/tests/rust/a.rs new file mode 100644 index 0000000..e98ebe7 --- /dev/null +++ b/tools/test/fixtures/course-bad/01-a/tests/rust/a.rs @@ -0,0 +1,2 @@ +#[test] +fn test_01_a() {} diff --git a/tools/test/fixtures/course-bad/01-a/tests/ts/a.test.ts b/tools/test/fixtures/course-bad/01-a/tests/ts/a.test.ts new file mode 100644 index 0000000..0d08780 --- /dev/null +++ b/tools/test/fixtures/course-bad/01-a/tests/ts/a.test.ts @@ -0,0 +1,2 @@ +import { test } from "vitest"; +test("test_01_b", () => {}); diff --git a/tools/test/fixtures/course-bad/catalog.json b/tools/test/fixtures/course-bad/catalog.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tools/test/fixtures/course-bad/catalog.json @@ -0,0 +1 @@ +[] diff --git a/tools/test/fixtures/course/01-m01/harness-adds.md b/tools/test/fixtures/course/01-m01/harness-adds.md new file mode 100644 index 0000000..f85ae95 --- /dev/null +++ b/tools/test/fixtures/course/01-m01/harness-adds.md @@ -0,0 +1 @@ +newGame, summon, play diff --git a/tools/test/fixtures/course/01-m01/reference/rust/Cargo.toml b/tools/test/fixtures/course/01-m01/reference/rust/Cargo.toml new file mode 100644 index 0000000..9a9fd77 --- /dev/null +++ b/tools/test/fixtures/course/01-m01/reference/rust/Cargo.toml @@ -0,0 +1,2 @@ +[package] +name = "ref" diff --git a/tools/test/fixtures/course/01-m01/reference/ts/package.json b/tools/test/fixtures/course/01-m01/reference/ts/package.json new file mode 100644 index 0000000..40e054a --- /dev/null +++ b/tools/test/fixtures/course/01-m01/reference/ts/package.json @@ -0,0 +1 @@ +{"name":"ref"} diff --git a/tools/test/fixtures/course/01-m01/scenarios.md b/tools/test/fixtures/course/01-m01/scenarios.md new file mode 100644 index 0000000..9d283ba --- /dev/null +++ b/tools/test/fixtures/course/01-m01/scenarios.md @@ -0,0 +1 @@ +### test_01_a diff --git a/tools/test/fixtures/course/01-m01/tests/rust/m01.rs b/tools/test/fixtures/course/01-m01/tests/rust/m01.rs new file mode 100644 index 0000000..e98ebe7 --- /dev/null +++ b/tools/test/fixtures/course/01-m01/tests/rust/m01.rs @@ -0,0 +1,2 @@ +#[test] +fn test_01_a() {} diff --git a/tools/test/fixtures/course/01-m01/tests/ts/m01.test.ts b/tools/test/fixtures/course/01-m01/tests/ts/m01.test.ts new file mode 100644 index 0000000..e344bf7 --- /dev/null +++ b/tools/test/fixtures/course/01-m01/tests/ts/m01.test.ts @@ -0,0 +1 @@ +test("test_01_a", () => {}); diff --git a/tools/test/fixtures/course/02-m02/reference/rust/Cargo.toml b/tools/test/fixtures/course/02-m02/reference/rust/Cargo.toml new file mode 100644 index 0000000..9a9fd77 --- /dev/null +++ b/tools/test/fixtures/course/02-m02/reference/rust/Cargo.toml @@ -0,0 +1,2 @@ +[package] +name = "ref" diff --git a/tools/test/fixtures/course/02-m02/reference/ts/package.json b/tools/test/fixtures/course/02-m02/reference/ts/package.json new file mode 100644 index 0000000..40e054a --- /dev/null +++ b/tools/test/fixtures/course/02-m02/reference/ts/package.json @@ -0,0 +1 @@ +{"name":"ref"} diff --git a/tools/test/fixtures/course/02-m02/scenarios.md b/tools/test/fixtures/course/02-m02/scenarios.md new file mode 100644 index 0000000..e2b5f1c --- /dev/null +++ b/tools/test/fixtures/course/02-m02/scenarios.md @@ -0,0 +1 @@ +### test_02_a diff --git a/tools/test/fixtures/course/02-m02/tests/rust/m02.rs b/tools/test/fixtures/course/02-m02/tests/rust/m02.rs new file mode 100644 index 0000000..80a4555 --- /dev/null +++ b/tools/test/fixtures/course/02-m02/tests/rust/m02.rs @@ -0,0 +1,2 @@ +#[test] +fn test_02_a() {} diff --git a/tools/test/fixtures/course/02-m02/tests/ts/m02.test.ts b/tools/test/fixtures/course/02-m02/tests/ts/m02.test.ts new file mode 100644 index 0000000..9f4c31a --- /dev/null +++ b/tools/test/fixtures/course/02-m02/tests/ts/m02.test.ts @@ -0,0 +1 @@ +test("test_02_a", () => {}); diff --git a/tools/test/fixtures/course/03-m03/reference/rust/Cargo.toml b/tools/test/fixtures/course/03-m03/reference/rust/Cargo.toml new file mode 100644 index 0000000..9a9fd77 --- /dev/null +++ b/tools/test/fixtures/course/03-m03/reference/rust/Cargo.toml @@ -0,0 +1,2 @@ +[package] +name = "ref" diff --git a/tools/test/fixtures/course/03-m03/reference/ts/package.json b/tools/test/fixtures/course/03-m03/reference/ts/package.json new file mode 100644 index 0000000..40e054a --- /dev/null +++ b/tools/test/fixtures/course/03-m03/reference/ts/package.json @@ -0,0 +1 @@ +{"name":"ref"} diff --git a/tools/test/fixtures/course/03-m03/scenarios.md b/tools/test/fixtures/course/03-m03/scenarios.md new file mode 100644 index 0000000..48b3c26 --- /dev/null +++ b/tools/test/fixtures/course/03-m03/scenarios.md @@ -0,0 +1 @@ +### test_03_a diff --git a/tools/test/fixtures/course/03-m03/tests/rust/m03.rs b/tools/test/fixtures/course/03-m03/tests/rust/m03.rs new file mode 100644 index 0000000..93b2443 --- /dev/null +++ b/tools/test/fixtures/course/03-m03/tests/rust/m03.rs @@ -0,0 +1,2 @@ +#[test] +fn test_03_a() {} diff --git a/tools/test/fixtures/course/03-m03/tests/ts/m03.test.ts b/tools/test/fixtures/course/03-m03/tests/ts/m03.test.ts new file mode 100644 index 0000000..2da61c0 --- /dev/null +++ b/tools/test/fixtures/course/03-m03/tests/ts/m03.test.ts @@ -0,0 +1 @@ +test("test_03_a", () => {}); diff --git a/tools/test/fixtures/course/catalog.json b/tools/test/fixtures/course/catalog.json new file mode 100644 index 0000000..155b018 --- /dev/null +++ b/tools/test/fixtures/course/catalog.json @@ -0,0 +1,9 @@ +[ + {"name":"Baron Rivendare","kind":"card","text":"x","source":"https://hearthstone.wiki.gg/wiki/Baron_Rivendare"}, + {"name":"Test Wisp","kind":"card","text":"x","source":"-","fictional":true}, + {"name":"BABA","kind":"word","role":"noun","text":"x","source":"-"}, + {"name":"TEXT","kind":"word","role":"noun","text":"x","source":"-"}, + {"name":"IS","kind":"word","role":"operator","text":"x","source":"-"}, + {"name":"PUSH","kind":"word","role":"property","text":"x","source":"-"}, + {"name":"YOU","kind":"word","role":"property","text":"x","source":"-"} +] diff --git a/tools/test/fixtures/ws-content-only/.course.json b/tools/test/fixtures/ws-content-only/.course.json new file mode 100644 index 0000000..49aea3b --- /dev/null +++ b/tools/test/fixtures/ws-content-only/.course.json @@ -0,0 +1 @@ +{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"} diff --git a/tools/test/fixtures/ws-content-only/src/content/cards.rs b/tools/test/fixtures/ws-content-only/src/content/cards.rs new file mode 100644 index 0000000..6edc290 --- /dev/null +++ b/tools/test/fixtures/ws-content-only/src/content/cards.rs @@ -0,0 +1 @@ +let c = "Baron Rivendare"; fn baron_rivendare() {} diff --git a/tools/test/fixtures/ws-content-only/src/engine/core.rs b/tools/test/fixtures/ws-content-only/src/engine/core.rs new file mode 100644 index 0000000..aeffbf4 --- /dev/null +++ b/tools/test/fixtures/ws-content-only/src/engine/core.rs @@ -0,0 +1 @@ +fn resolve() {} diff --git a/tools/test/fixtures/ws-coupled/.course.json b/tools/test/fixtures/ws-coupled/.course.json new file mode 100644 index 0000000..49aea3b --- /dev/null +++ b/tools/test/fixtures/ws-coupled/.course.json @@ -0,0 +1 @@ +{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"} diff --git a/tools/test/fixtures/ws-coupled/src/content/cards.rs b/tools/test/fixtures/ws-coupled/src/content/cards.rs new file mode 100644 index 0000000..8fab283 --- /dev/null +++ b/tools/test/fixtures/ws-coupled/src/content/cards.rs @@ -0,0 +1 @@ +let c = "Baron Rivendare"; diff --git a/tools/test/fixtures/ws-coupled/src/engine/core.rs b/tools/test/fixtures/ws-coupled/src/engine/core.rs new file mode 100644 index 0000000..739550b --- /dev/null +++ b/tools/test/fixtures/ws-coupled/src/engine/core.rs @@ -0,0 +1,3 @@ +fn a() {} +let c = "Baron Rivendare"; +fn baron_rivendare() {} diff --git a/tools/test/fixtures/ws-grammar/.course.json b/tools/test/fixtures/ws-grammar/.course.json new file mode 100644 index 0000000..49aea3b --- /dev/null +++ b/tools/test/fixtures/ws-grammar/.course.json @@ -0,0 +1 @@ +{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"} diff --git a/tools/test/fixtures/ws-grammar/src/content/cards.rs b/tools/test/fixtures/ws-grammar/src/content/cards.rs new file mode 100644 index 0000000..e69de29 diff --git a/tools/test/fixtures/ws-grammar/src/engine/core.rs b/tools/test/fixtures/ws-grammar/src/engine/core.rs new file mode 100644 index 0000000..d856ec5 --- /dev/null +++ b/tools/test/fixtures/ws-grammar/src/engine/core.rs @@ -0,0 +1,2 @@ +const PUSH: u8 = 1; const IS: u8 = 2; const YOU: u8 = 3; +let t = "Test"; let x = "TEXT"; diff --git a/tools/test/fixtures/ws-start-ts/.course.json b/tools/test/fixtures/ws-start-ts/.course.json new file mode 100644 index 0000000..cc3f6ce --- /dev/null +++ b/tools/test/fixtures/ws-start-ts/.course.json @@ -0,0 +1 @@ +{"track":"ts","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"} diff --git a/tools/test/fixtures/ws-start-ts/src/engine/.keep b/tools/test/fixtures/ws-start-ts/src/engine/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tools/test/fixtures/ws-start/.course.json b/tools/test/fixtures/ws-start/.course.json new file mode 100644 index 0000000..49aea3b --- /dev/null +++ b/tools/test/fixtures/ws-start/.course.json @@ -0,0 +1 @@ +{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"} diff --git a/tools/test/fixtures/ws-start/src/engine/.keep b/tools/test/fixtures/ws-start/src/engine/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tools/test/helpers.mjs b/tools/test/helpers.mjs new file mode 100644 index 0000000..4b17327 --- /dev/null +++ b/tools/test/helpers.mjs @@ -0,0 +1,31 @@ +import { spawnSync, execFileSync } from "node:child_process"; +import { cpSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const fixtures = join(here, "fixtures"); +export const toolsDir = join(here, ".."); + +export function run(script, args = [], env = {}) { + const r = spawnSync("node", [join(toolsDir, script), ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; +} + +export function copyFixture(name) { + const dir = mkdtempSync(join(tmpdir(), "sdc-")); + cpSync(join(fixtures, name), dir, { recursive: true }); + return dir; +} + +export function git(cwd, ...args) { + return execFileSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@t", ...args], + { cwd, encoding: "utf8" }, + ); +} diff --git a/tools/test/start-module.test.mjs b/tools/test/start-module.test.mjs new file mode 100644 index 0000000..381ae8a --- /dev/null +++ b/tools/test/start-module.test.mjs @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { run, copyFixture, fixtures } from "./helpers.mjs"; + +const env = { COURSE_ROOT: join(fixtures, "course") }; + +test("start_module_copies_tests", () => { + const ws = copyFixture("ws-start"); + const r = run("start-module.mjs", ["01", ws], env); + assert.equal(r.code, 0, r.out); + assert.ok(existsSync(join(ws, "tests/m01.rs"))); + assert.match(r.out, /newGame, summon, play/); +}); + +test("start_module_uses_track", () => { + const ws = copyFixture("ws-start-ts"); + assert.equal(run("start-module.mjs", ["01", ws], env).code, 0); + assert.ok(existsSync(join(ws, "tests/m01.test.ts"))); + assert.ok(!existsSync(join(ws, "tests/m01.rs"))); +}); + +test("start_module_accepts_when_previous_present", () => { + const ws = copyFixture("ws-start"); + mkdirSync(join(ws, "tests"), { recursive: true }); + writeFileSync(join(ws, "tests/m02.rs"), ""); + const r = run("start-module.mjs", ["03", ws], env); + assert.equal(r.code, 0, r.out); + assert.ok(existsSync(join(ws, "tests/m03.rs"))); +}); + +test("start_module_refuses_skipping", () => { + const ws = copyFixture("ws-start"); + writeFileSync(join(ws, "tests/m01.rs"), ""); + const r = run("start-module.mjs", ["03", ws], env); + assert.equal(r.code, 1); + assert.match(r.out, /finish module 02 first/); + assert.ok(!existsSync(join(ws, "tests/m03.rs"))); +}); + +test("start_module_00_not_startable", () => { + const ws = copyFixture("ws-start"); + const r = run("start-module.mjs", ["00", ws], env); + assert.notEqual(r.code, 0); + assert.match(r.out, /naive-engine/); +}); diff --git a/tools/test/sync-check.test.mjs b/tools/test/sync-check.test.mjs new file mode 100644 index 0000000..9c478f3 --- /dev/null +++ b/tools/test/sync-check.test.mjs @@ -0,0 +1,16 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { run, fixtures } from "./helpers.mjs"; +import { join } from "node:path"; + +test("sync_detects_missing_ts_test", () => { + const r = run("sync-check.mjs", [], { COURSE_ROOT: join(fixtures, "course-bad") }); + assert.equal(r.code, 1); + assert.match(r.out, /test_01_a/); + assert.match(r.out, /test_01_b/); +}); + +test("sync_passes_consistent_course", () => { + const r = run("sync-check.mjs", [], { COURSE_ROOT: join(fixtures, "course") }); + assert.equal(r.code, 0, r.out); +}); diff --git a/tools/test/verify-references.test.mjs b/tools/test/verify-references.test.mjs new file mode 100644 index 0000000..dcf89a1 --- /dev/null +++ b/tools/test/verify-references.test.mjs @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { run, fixtures } from "./helpers.mjs"; +import { planVerification } from "../verify-references.mjs"; +import { moduleDirs } from "../lib.mjs"; + +const root = join(fixtures, "course"); + +test("verify_plan_skips_modules_without_reference", () => { + process.env.COURSE_ROOT = root; + const plan = planVerification(moduleDirs(), 3); + assert.deepEqual(plan.map((p) => p.num), ["01", "02", "03"]); + assert.deepEqual(plan[2].testModules.map((m) => m.num), ["01", "02", "03"]); + assert.deepEqual(plan[2].langs, ["rust", "ts"]); +}); + +test("verify_runs_with_copied_reference_and_tests", () => { + const r = run("verify-references.mjs", ["02"], { + COURSE_ROOT: root, + VERIFY_CMD_RUST: "test -f tests/m01.rs && test -f Cargo.toml && ! test -f tests/m03.rs", + VERIFY_CMD_TS: "test -f tests/m01.test.ts && test -f package.json", + }); + assert.equal(r.code, 0, r.out); + assert.match(r.out, /01.*ok/s); + assert.match(r.out, /02.*ok/s); +}); + +test("verify_reports_failure", () => { + const r = run("verify-references.mjs", ["01"], { + COURSE_ROOT: root, + VERIFY_CMD_RUST: "false", + VERIFY_CMD_TS: "true", + }); + assert.equal(r.code, 1); + assert.match(r.out, /FAIL/); +}); + +test("verify_stage_copies_all_earlier_module_tests", async () => { + process.env.COURSE_ROOT = root; + const { planVerification, stage } = await import("../verify-references.mjs"); + const { existsSync } = await import("node:fs"); + const plan = planVerification(moduleDirs(), 3)[2]; + const dir = stage(plan, "rust"); + for (const f of ["m01.rs", "m02.rs", "m03.rs"]) assert.ok(existsSync(join(dir, "tests", f))); + assert.ok(existsSync(join(dir, "Cargo.toml"))); +}); diff --git a/tools/verify-references.mjs b/tools/verify-references.mjs new file mode 100644 index 0000000..402a712 --- /dev/null +++ b/tools/verify-references.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// For each module <= N with a reference/: copy reference + tests of modules 01..module into a temp dir, run tests. +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { moduleDirs, testFiles, fail } from "./lib.mjs"; + +const COMMANDS = { + rust: () => process.env.VERIFY_CMD_RUST || "cargo test", + ts: () => process.env.VERIFY_CMD_TS || "npx vitest run", +}; + +export function planVerification(mods, upTo) { + return mods + .filter((m) => Number(m.num) >= 1 && Number(m.num) <= upTo) + .map((m) => ({ + ...m, + langs: ["rust", "ts"].filter((l) => existsSync(join(m.path, "reference", l))), + testModules: mods.filter((t) => Number(t.num) >= 1 && Number(t.num) <= Number(m.num)), + })) + .filter((p) => p.langs.length > 0); +} + +export function stage(plan, lang) { + const dir = mkdtempSync(join(tmpdir(), `verify-${plan.num}-${lang}-`)); + cpSync(join(plan.path, "reference", lang), dir, { recursive: true }); + mkdirSync(join(dir, "tests"), { recursive: true }); + for (const t of plan.testModules) { + for (const f of testFiles(t.path, lang)) cpSync(f, join(dir, "tests", basename(f))); + } + return dir; +} + +if (import.meta.main) { + const all = moduleDirs(); + const upTo = process.argv[2] ? Number(process.argv[2]) : Math.max(0, ...all.map((m) => Number(m.num))); + const plans = planVerification(all, upTo); + if (!plans.length) console.log("verify-references: no modules with reference/ up to " + upTo); + let failed = false; + for (const p of plans) { + for (const lang of p.langs) { + const dir = stage(p, lang); + const r = spawnSync(COMMANDS[lang](), { cwd: dir, shell: true, encoding: "utf8" }); + const ok = r.status === 0; + failed ||= !ok; + console.log(`module ${p.num}-${p.slug} [${lang}]: ${ok ? "ok" : "FAIL"}`); + if (!ok) console.log((r.stdout || "") + (r.stderr || "")); + rmSync(dir, { recursive: true, force: true }); + } + } + if (failed) process.exit(1); +}