Adds course/11-conditions: lesson, scenarios, nine test_11 tests in both tracks, the cumulative reference (Module 10's plus conditions) and the NEAR drop. The engine learns a condition role, rules carry conditions, and Level.hasProperty and conversion evaluate content's condition checks against the board whenever a rule is used. Resolution pairs only rules with the same conditions. Catalog, harness contract, research notes and the diff-tracks generator follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
7.0 KiB
JavaScript
135 lines
7.0 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { toolsDir } from "./helpers.mjs";
|
|
import { validate } from "../schema.mjs";
|
|
import { cardDirs } from "../lib.mjs";
|
|
import { generateCase, loadVocab, parseArgs, rng, unifiedDiff, verdict } from "../diff-tracks.mjs";
|
|
|
|
const course = join(toolsDir, "..", "course");
|
|
const levelSchema = JSON.parse(readFileSync(join(course, "schema", "level.schema.json"), "utf8"));
|
|
// The Module 10 vocabulary: its words.json and every card its workspace has.
|
|
const vocab = loadVocab(join(course, "10-spellgrid", "reference", "ts", "src", "content", "words.json"), cardDirs("10"));
|
|
const SEEDS = Array.from({ length: 200 }, (_, i) => i + 1);
|
|
|
|
test("diff_tracks_parses_args", () => {
|
|
assert.deepEqual(parseArgs([]), { seeds: 100, seed: null, module: null, dir: null, keep: false });
|
|
assert.deepEqual(parseArgs(["--seeds", "500", "--module", "10", "--dir", "x", "--keep"]), {
|
|
seeds: 500, seed: null, module: "10", dir: "x", keep: true,
|
|
});
|
|
assert.equal(parseArgs(["--seed", "42"]).seed, 42);
|
|
});
|
|
|
|
test("diff_tracks_rng_is_seeded", () => {
|
|
const draw = (seed) => { const r = rng(seed); return [r(), r(), r()]; };
|
|
assert.deepEqual(draw(7), draw(7));
|
|
assert.notDeepEqual(draw(7), draw(8));
|
|
for (const x of draw(123)) assert.ok(x >= 0 && x < 1);
|
|
});
|
|
|
|
test("diff_tracks_cases_are_deterministic_and_vary", () => {
|
|
assert.deepEqual(generateCase(17, vocab), generateCase(17, vocab));
|
|
const distinct = new Set(SEEDS.map((s) => JSON.stringify(generateCase(s, vocab))));
|
|
assert.equal(distinct.size, SEEDS.length);
|
|
});
|
|
|
|
test("diff_tracks_levels_are_valid", () => {
|
|
const nouns = new Set(vocab.words.filter((w) => w.role === "noun").map((w) => w.name));
|
|
const words = new Set([...vocab.words.map((w) => w.name), "IS", "AND", "NOT"]);
|
|
const placeable = new Set(vocab.cards.filter((c) => c.type === "minion" && c.noun).map((c) => c.name));
|
|
const holdable = new Set(vocab.cards.filter((c) => c.type === "spell" || (c.type === "minion" && c.noun)).map((c) => c.name));
|
|
for (const seed of SEEDS) {
|
|
const { level } = generateCase(seed, vocab);
|
|
assert.deepEqual(validate(levelSchema, level), [], `seed ${seed}`);
|
|
assert.ok(level.width >= 4 && level.width <= 8 && level.height >= 4 && level.height <= 8, `seed ${seed} size`);
|
|
for (const o of [...level.objects, ...level.cards]) {
|
|
assert.ok(o.x < level.width && o.y < level.height, `seed ${seed}: (${o.x}, ${o.y}) off the grid`);
|
|
}
|
|
for (const { kind } of level.objects) {
|
|
const ok = kind.startsWith("text:") ? words.has(kind.slice(5)) : nouns.has(kind.toUpperCase());
|
|
assert.ok(ok, `seed ${seed}: kind ${kind}`);
|
|
}
|
|
for (const { card } of level.cards) assert.ok(placeable.has(card), `seed ${seed}: card ${card}`);
|
|
for (const card of level.hand) assert.ok(holdable.has(card), `seed ${seed}: hand ${card}`);
|
|
}
|
|
});
|
|
|
|
test("diff_tracks_scripts_follow_the_cli_grammar", () => {
|
|
const names = vocab.cards.map((c) => c.name);
|
|
const answers = new Set([...names, "top", "bottom"]);
|
|
let casts = 0;
|
|
let moves = 0;
|
|
for (const seed of SEEDS) {
|
|
const { script } = generateCase(seed, vocab);
|
|
assert.ok(script.length >= 5 && script.length <= 25, `seed ${seed}: ${script.length} commands`);
|
|
for (const line of script) {
|
|
const cast = /^c (.+?)(?: (\d+) (\d+))?$/.exec(line);
|
|
const card = cast && cast[1].replace(/ \(Assembled\)$/, "");
|
|
const ok = /^[wasd]$/.test(line) || /^\d+$/.test(line) || answers.has(line) || (cast && names.includes(card));
|
|
assert.ok(ok, `seed ${seed}: ${JSON.stringify(line)}`);
|
|
if (cast) casts++;
|
|
if (/^[wasd]$/.test(line)) moves++;
|
|
}
|
|
}
|
|
assert.ok(casts > 100 && moves > 500, `casts ${casts}, moves ${moves}`);
|
|
});
|
|
|
|
test("diff_tracks_levels_form_sentences", () => {
|
|
// Biased toward whole sentences: most levels have at least one NOUN IS ... in a row or column.
|
|
const withSentence = SEEDS.filter((seed) => {
|
|
const { level } = generateCase(seed, vocab);
|
|
const at = new Map(level.objects.filter((o) => o.kind.startsWith("text:")).map((o) => [`${o.x},${o.y}`, o.kind.slice(5)]));
|
|
return [...at].some(([key, word]) => {
|
|
const [x, y] = key.split(",").map(Number);
|
|
return word !== "IS" && (at.get(`${x + 1},${y}`) === "IS" && at.has(`${x + 2},${y}`) ||
|
|
at.get(`${x},${y + 1}`) === "IS" && at.has(`${x},${y + 2}`));
|
|
});
|
|
});
|
|
assert.ok(withSentence.length > SEEDS.length * 0.8, `${withSentence.length} of ${SEEDS.length}`);
|
|
});
|
|
|
|
test("diff_tracks_writes_conditions_from_module_11", () => {
|
|
// Module 11's vocabulary has a condition word (ON): some sentences carry a condition.
|
|
// Module 10's has none, so its cases have no condition word in a sentence.
|
|
const vocab11 = loadVocab(join(course, "11-conditions", "reference", "ts", "src", "content", "words.json"), cardDirs("11"));
|
|
const conditions = new Set(vocab11.words.filter((w) => w.role === "condition").map((w) => w.name));
|
|
assert.ok(conditions.has("ON"));
|
|
// A condition word followed by a noun in a row or column, as a sentence writes it.
|
|
const withCondition = (v) => SEEDS.filter((seed) => {
|
|
const { level } = generateCase(seed, v);
|
|
const at = new Map(level.objects.filter((o) => o.kind.startsWith("text:")).map((o) => [`${o.x},${o.y}`, o.kind.slice(5)]));
|
|
const noun = (w) => w !== undefined && v.words.some((x) => x.name === w && x.role === "noun");
|
|
return [...at].some(([key, word]) => {
|
|
const [x, y] = key.split(",").map(Number);
|
|
return conditions.has(word) && (noun(at.get(`${x + 1},${y}`)) || noun(at.get(`${x},${y + 1}`)));
|
|
});
|
|
});
|
|
assert.ok(withCondition(vocab11).length > SEEDS.length * 0.3, `${withCondition(vocab11).length} of ${SEEDS.length}`);
|
|
assert.deepEqual(withCondition(vocab), []);
|
|
});
|
|
|
|
test("diff_tracks_verdict", () => {
|
|
const ok = (out, code = 1) => ({ out, err: "", code, signal: null });
|
|
assert.equal(verdict(ok("a\n"), ok("a\n")), "agree");
|
|
// The same error printed by both is agreement; so is the same exit code for a lost game.
|
|
assert.equal(verdict(ok("x is not in the hand\n"), ok("x is not in the hand\n")), "agree");
|
|
assert.equal(verdict(ok("a\n", 0), ok("b\n", 0)), "outputs differ");
|
|
const panic = { out: "a\n", err: "thread 'main' panicked at src/cli.rs:1:1", code: 101, signal: null };
|
|
const thrown = { out: "a\n", err: "TypeError: boom", code: 1, signal: null };
|
|
const killed = { out: "", err: "", code: null, signal: "SIGKILL" };
|
|
assert.equal(verdict(panic, ok("a\n")), "the Rust CLI crashed");
|
|
assert.equal(verdict(ok("a\n"), thrown), "the TypeScript CLI crashed");
|
|
assert.equal(verdict(killed, thrown), "both crashed");
|
|
});
|
|
|
|
test("diff_tracks_unified_diff", () => {
|
|
const a = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"].join("\n");
|
|
const b = ["1", "2", "3", "4", "5", "six", "7", "8", "9", "10", "11"].join("\n");
|
|
assert.equal(unifiedDiff(a, b), [
|
|
"--- rust", "+++ ts",
|
|
"@@ -3,8 +3,9 @@", " 3", " 4", " 5", "-6", "+six", " 7", " 8", " 9", " 10", "+11",
|
|
].join("\n"));
|
|
assert.equal(unifiedDiff("same", "same"), "--- rust\n+++ ts");
|
|
});
|