feat: repo foundation and course tooling
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
12081223ef
commit
7854f6f531
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
target/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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 <workspace>` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs <workspace> <baseRef>` (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/).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(`(?<![A-Za-z0-9_])${esc(v)}(?![A-Za-z0-9_])`) });
|
||||
}
|
||||
}
|
||||
const hits = [];
|
||||
for (const d of cfg.engineDirs || []) {
|
||||
const dir = join(ws, d);
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const file of walk(dir)) {
|
||||
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
||||
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 <workspaceDir>");
|
||||
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");
|
||||
}
|
||||
@@ -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 <workspaceDir> <baseRef>");
|
||||
|
||||
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)`);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
// Copies module NN's tests for the learner's track into <workspace>/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 <NN> <workspaceDir>");
|
||||
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<N>:" 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).`);
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
# Scenarios
|
||||
### test_01_a
|
||||
### test_01_b
|
||||
@@ -0,0 +1,2 @@
|
||||
#[test]
|
||||
fn test_01_a() {}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { test } from "vitest";
|
||||
test("test_01_b", () => {});
|
||||
+1
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
newGame, summon, play
|
||||
@@ -0,0 +1,2 @@
|
||||
[package]
|
||||
name = "ref"
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"ref"}
|
||||
@@ -0,0 +1 @@
|
||||
### test_01_a
|
||||
@@ -0,0 +1,2 @@
|
||||
#[test]
|
||||
fn test_01_a() {}
|
||||
@@ -0,0 +1 @@
|
||||
test("test_01_a", () => {});
|
||||
@@ -0,0 +1,2 @@
|
||||
[package]
|
||||
name = "ref"
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"ref"}
|
||||
@@ -0,0 +1 @@
|
||||
### test_02_a
|
||||
@@ -0,0 +1,2 @@
|
||||
#[test]
|
||||
fn test_02_a() {}
|
||||
@@ -0,0 +1 @@
|
||||
test("test_02_a", () => {});
|
||||
@@ -0,0 +1,2 @@
|
||||
[package]
|
||||
name = "ref"
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"ref"}
|
||||
@@ -0,0 +1 @@
|
||||
### test_03_a
|
||||
@@ -0,0 +1,2 @@
|
||||
#[test]
|
||||
fn test_03_a() {}
|
||||
@@ -0,0 +1 @@
|
||||
test("test_03_a", () => {});
|
||||
+9
@@ -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":"-"}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
|
||||
@@ -0,0 +1 @@
|
||||
let c = "Baron Rivendare"; fn baron_rivendare() {}
|
||||
@@ -0,0 +1 @@
|
||||
fn resolve() {}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
|
||||
@@ -0,0 +1 @@
|
||||
let c = "Baron Rivendare";
|
||||
@@ -0,0 +1,3 @@
|
||||
fn a() {}
|
||||
let c = "Baron Rivendare";
|
||||
fn baron_rivendare() {}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
|
||||
@@ -0,0 +1,2 @@
|
||||
const PUSH: u8 = 1; const IS: u8 = 2; const YOU: u8 = 3;
|
||||
let t = "Test"; let x = "TEXT";
|
||||
@@ -0,0 +1 @@
|
||||
{"track":"ts","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"track":"rust","engineDirs":["src/engine"],"contentDirs":["src/content"],"module":"01"}
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user