125 lines
5.4 KiB
TypeScript
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 });
|
|
});
|