- tools/mutation-check.mjs with tools/mutants.json: thirteen mutants against the Module 12 TypeScript reference (the eight of Module 13's first table, plus Armor before redirect, conditions at parse time, no defender priority, and the summon seam's noun check); all killed. - tools/run-scenario.mjs with tools/scenario/run.ts: a designer's cards, words and seam words as data, played on the Module 10 TypeScript reference. - course/10-spellgrid/designer-capstone.md and designer-example/ (output.txt is checked against the tool's output in the tools tests). - CI: diff-tracks (300 seeds), mutation-check, verify-levels. - Module 13: the mutation table is the tool's output; differential testing, undo and the solver sections describe the repository as it is. Lesson 10 says what the level proofs prove for levels 6, 8 and 9. - schema.mjs: a value that matches no oneOf branch is reported against the branch its const fields pick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
5.6 KiB
JavaScript
107 lines
5.6 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fixtures, run, toolsDir } from "./helpers.mjs";
|
|
import { applyMutant, judge, mutantProblems, parseArgs, readReport, targetModule } from "../mutation-check.mjs";
|
|
|
|
// A course with one module whose TypeScript reference has one list (src/order.mjs) and a
|
|
// stand-in test runner (run-tests.mjs) that writes a vitest-shaped report.
|
|
const env = { COURSE_ROOT: join(fixtures, "course-mutation"), MUTATION_CMD: "node run-tests.mjs" };
|
|
const SWAP = {
|
|
name: "Order swapped",
|
|
file: "src/order.mjs",
|
|
find: 'export const ORDER = ["first", "second"];',
|
|
replace: 'export const ORDER = ["second", "first"];',
|
|
expect: ["test_01_first_comes_first"],
|
|
};
|
|
|
|
/** Run the tool on the fixture course with these mutants. */
|
|
function check(mutants, ...args) {
|
|
const file = join(mkdtempSync(join(tmpdir(), "sdc-mut-")), "mutants.json");
|
|
writeFileSync(file, JSON.stringify(mutants));
|
|
return run("mutation-check.mjs", ["--mutants", file, ...args], env);
|
|
}
|
|
|
|
test("mutation_check_parses_args", () => {
|
|
assert.deepEqual(parseArgs([]), { names: [], mutants: null, module: null });
|
|
assert.deepEqual(parseArgs(["A", "--mutants", "m.json", "--module", "9", "B"]), { names: ["A", "B"], mutants: "m.json", module: "09" });
|
|
});
|
|
|
|
test("mutation_check_kills_a_fixture_mutant", () => {
|
|
const r = check([SWAP]);
|
|
assert.equal(r.code, 0, r.out);
|
|
assert.match(r.out, /unmutated: 2 tests pass/);
|
|
assert.match(r.out, /killed {3}Order swapped: test_01_first_comes_first\n/);
|
|
assert.match(r.out, /1 mutants killed/);
|
|
// The file is restored: the staged copy is gone, and the course's copy was never touched.
|
|
assert.match(readFileSync(join(env.COURSE_ROOT, "01-m01", "reference", "ts", "src", "order.mjs"), "utf8"), /\["first", "second"\]/);
|
|
});
|
|
|
|
test("mutation_check_fails_on_a_surviving_mutant", () => {
|
|
// Renaming the second step changes nothing the fixture's tests look at.
|
|
const survivor = { ...SWAP, name: "Second renamed", replace: 'export const ORDER = ["first", "later"];' };
|
|
const r = check([SWAP, survivor]);
|
|
assert.equal(r.code, 1, r.out);
|
|
assert.match(r.out, /killed {3}Order swapped/);
|
|
assert.match(r.out, /SURVIVED Second renamed: test_01_first_comes_first passed; failed: none/);
|
|
assert.match(r.out, /1 of 2 mutants not killed/);
|
|
});
|
|
|
|
test("mutation_check_fails_on_a_stale_find", () => {
|
|
const stale = { ...SWAP, name: "Stale", find: 'export const ORDER = ["first", "third"];' };
|
|
const r = check([stale]);
|
|
assert.equal(r.code, 1, r.out);
|
|
assert.match(r.out, /STALE {4}Stale: stale: `find` occurs 0 times in src\/order\.mjs, not once/);
|
|
const missing = check([{ ...SWAP, name: "Gone", file: "src/gone.mjs" }]);
|
|
assert.equal(missing.code, 1);
|
|
assert.match(missing.out, /STALE {4}Gone: no file src\/gone\.mjs/);
|
|
});
|
|
|
|
test("mutation_check_fails_on_a_run_that_is_not_sane", () => {
|
|
// A mutant that breaks the module kills every test, but by not loading: that isn't a kill.
|
|
const r = check([{ ...SWAP, name: "Broken", replace: "export const ORDER = [" }]);
|
|
assert.equal(r.code, 1, r.out);
|
|
assert.match(r.out, /INSANE {3}Broken: a test file did not run: order: /);
|
|
});
|
|
|
|
test("mutation_check_runs_only_the_named_mutants", () => {
|
|
const r = check([SWAP, { ...SWAP, name: "Stale", find: "nowhere" }], "Order swapped");
|
|
assert.equal(r.code, 0, r.out);
|
|
assert.doesNotMatch(r.out, /Stale/);
|
|
});
|
|
|
|
test("mutation_check_applies_exactly_one_replacement", () => {
|
|
assert.equal(applyMutant("a [x] b", { file: "f", find: "[x]", replace: "$&y" }), "a $&y b");
|
|
assert.throws(() => applyMutant("x x", { file: "f", find: "x", replace: "y" }), /occurs 2 times in f/);
|
|
});
|
|
|
|
test("mutation_check_judges_from_the_report", () => {
|
|
const report = (tests, extra = {}) => ({ testResults: [{ name: "t", status: "failed", assertionResults: tests.map(([title, status]) => ({ title, status })), ...extra }] });
|
|
const baseline = readReport(report([["test_a", "passed"], ["test_b", "passed"]]));
|
|
const m = { expect: ["test_a"] };
|
|
assert.equal(judge(m, baseline, readReport(report([["test_a", "failed"], ["test_b", "passed"]]))).killed, true);
|
|
assert.deepEqual(judge(m, baseline, readReport(report([["test_a", "passed"], ["test_b", "failed"]]))).survivors, ["test_a"]);
|
|
const lost = judge(m, baseline, readReport(report([["test_a", "failed"]])));
|
|
assert.equal(lost.sane, false);
|
|
assert.match(lost.problems[0], /1 tests did not run, e\.g\. test_b/);
|
|
assert.match(judge({ expect: ["test_zz"] }, baseline, baseline).problems[0], /doesn't have: test_zz/);
|
|
});
|
|
|
|
test("mutation_check_rejects_malformed_mutants", () => {
|
|
assert.deepEqual(mutantProblems(SWAP), []);
|
|
assert.ok(mutantProblems({ ...SWAP, expect: [] }).some((p) => /expect/.test(p)));
|
|
assert.ok(mutantProblems({ ...SWAP, replace: SWAP.find }).some((p) => /nothing would change/.test(p)));
|
|
assert.ok(mutantProblems({ ...SWAP, why: "x" }).some((p) => /unknown key "why"/.test(p)));
|
|
});
|
|
|
|
test("mutation_check_course_mutants_are_well_formed", () => {
|
|
const mutants = JSON.parse(readFileSync(join(toolsDir, "mutants.json"), "utf8"));
|
|
for (const m of mutants) assert.deepEqual(mutantProblems(m), [], m.name);
|
|
assert.equal(new Set(mutants.map((m) => m.name)).size, mutants.length, "names are unique");
|
|
// Each mutant's find occurs once in the highest module's TypeScript reference (the full run checks it too).
|
|
const ref = join(targetModule(null).path, "reference", "ts");
|
|
for (const m of mutants) assert.doesNotThrow(() => applyMutant(readFileSync(join(ref, m.file), "utf8"), m), m.name);
|
|
});
|