import { test } from "node:test"; import assert from "node:assert"; import os from "node:os"; import fs from "node:fs"; import path from "node:path"; import { evaluateMutationRatchet, mutationScoreForFile, measureMutationScores, readBaselineMutationScores, MUTATION_RATCHET_EPS, } from "../../../scripts/check/check-mutation-ratchet.mjs"; // ── evaluateMutationRatchet: direction UP (score can only improve) ─────────── test("mutation ratchet flags a drop (direction up)", () => { assert.equal(evaluateMutationRatchet(72.0, 75.0).regressed, true); assert.equal(evaluateMutationRatchet(76.0, 75.0).regressed, false); assert.equal(evaluateMutationRatchet(76.0, 75.0).improved, true); assert.equal(evaluateMutationRatchet(75.0, 75.0).regressed, false); // equal holds assert.equal(evaluateMutationRatchet(75.0, 75.0).improved, false); }); // ── eps anti-flake: a drop WITHIN eps holds; a drop BEYOND eps regresses ────── test("mutation ratchet tolerates sub-eps jitter but catches real drops", () => { assert.ok(MUTATION_RATCHET_EPS > 0); // drop of 0.5pt with default eps 1.0 -> within tolerance, not a regression assert.equal(evaluateMutationRatchet(74.5, 75.0).regressed, false); // drop just past eps -> regression assert.equal(evaluateMutationRatchet(75.0 - MUTATION_RATCHET_EPS - 0.01, 75.0).regressed, true); // explicit eps argument is honored (0 eps = strict) assert.equal(evaluateMutationRatchet(74.99, 75.0, 0).regressed, true); }); // ── mutationScoreForFile: covered score = detected/(detected+survived), // NoCoverage EXCLUDED (it is a coverage gap, not a test-quality signal). ───── test("mutationScoreForFile computes the covered score and excludes NoCoverage", () => { const fileData = { mutants: [ { status: "Killed" }, { status: "Killed" }, { status: "Killed" }, { status: "Timeout" }, // Timeout counts as detected { status: "Survived" }, { status: "Survived" }, { status: "NoCoverage" }, // excluded from denominator { status: "NoCoverage" }, { status: "Ignored" }, // excluded (not a valid mutant) ], }; // detected = 4 (3 Killed + 1 Timeout); denom = 6 (+2 Survived); NoCoverage/Ignored out. assert.ok(Math.abs(mutationScoreForFile(fileData) - (4 / 6) * 100) < 1e-9); }); test("mutationScoreForFile returns null when there are no covered mutants", () => { assert.equal(mutationScoreForFile({ mutants: [{ status: "NoCoverage" }] }), null); assert.equal(mutationScoreForFile({ mutants: [] }), null); }); // ── measureMutationScores: per-file scores from a report (and merges batches) ─ test("measureMutationScores maps each mutated file to its score", () => { const report = { files: { "src/a.ts": { mutants: [{ status: "Killed" }, { status: "Survived" }] }, // 50 "src/b.ts": { mutants: [{ status: "Killed" }, { status: "Killed" }] }, // 100 "src/empty.ts": { mutants: [{ status: "NoCoverage" }] }, // null -> omitted }, }; const scores = measureMutationScores(report); assert.equal(scores["src/a.ts"], 50); assert.equal(scores["src/b.ts"], 100); assert.equal("src/empty.ts" in scores, false); }); test("measureMutationScores accepts several reports (per-batch) and unions them", () => { const c = { files: { "src/a.ts": { mutants: [{ status: "Killed" }, { status: "Survived" }] } } }; const g = { files: { "src/b.ts": { mutants: [{ status: "Killed" }] } } }; const scores = measureMutationScores([c, g]); assert.equal(scores["src/a.ts"], 50); assert.equal(scores["src/b.ts"], 100); }); // ── SAME file split across sibling batches (auth.ts -> a1:1-1109 + a2:1110-2218 by // mutation range): the file's mutants are DISJOINT per batch and must be UNIONED, not // overwritten — else the last batch wins and the score reflects only half the file. ── test("measureMutationScores unions same-file mutants across split batches (not overwrite)", () => { // a1 slice: 1 killed, 1 survived (would score 50 alone) const a1 = { files: { "src/sse/services/auth.ts": { mutants: [{ status: "Killed" }, { status: "Survived" }] } }, }; // a2 slice: 3 killed (would score 100 alone) const a2 = { files: { "src/sse/services/auth.ts": { mutants: [{ status: "Killed" }, { status: "Killed" }, { status: "Killed" }], }, }, }; const scores = measureMutationScores([a1, a2]); // Combined: detected = 4 (1+3), survived = 1 -> 4/5 = 80. NOT 50 (a1) nor 100 (a2). assert.ok(Math.abs(scores["src/sse/services/auth.ts"] - 80) < 1e-9); }); // ── readBaselineMutationScores: graceful skip when the file/keys are absent ── test("readBaselineMutationScores returns {} when the baseline file is missing", () => { assert.deepEqual(readBaselineMutationScores("/no/such/baseline.json"), {}); }); test("readBaselineMutationScores extracts mutationScore. metric values", () => { // Write a tiny baseline to a temp file and read it back. const tmp = path.join(os.tmpdir(), `mut-baseline-${process.pid}.json`); fs.writeFileSync( tmp, JSON.stringify({ metrics: { eslintWarnings: { value: 10, direction: "down" }, "mutationScore.src/a.ts": { value: 70, direction: "up", dedicatedGate: true }, "mutationScore.src/b.ts": { value: 80, direction: "up", dedicatedGate: true }, }, }) ); const base = readBaselineMutationScores(tmp); assert.deepEqual(base, { "src/a.ts": 70, "src/b.ts": 80 }); fs.rmSync(tmp, { force: true }); });