Files
diegosouzapw--omniroute/tests/unit/build/check-mutation-ratchet.test.ts
2026-07-13 13:39:12 +08:00

125 lines
5.4 KiB
TypeScript

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.<path> 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 });
});