Files
openidea/src/features/coverage/model.ts
T
Jaw 55836e1b49 chore: Format the codebase with oxfmt and check formatting in CI (#135)
Run oxfmt once over the whole repository (formatting only, no logic changes) and add `pnpm format:check` to the frontend CI job so unformatted code no longer builds up. `.oxfmtrc.json` now skips the generated theme docs, test fixtures and code blocks inside Markdown.

Co-authored-by: jaw52 <[email protected]>
2026-10-28 19:35:16 +08:00

192 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Coverage data as the views need it: which configurations can run with coverage, the directory tree, percentages. */
import type { CoverageSuite, FileCoverage, RunConfig } from "@/platform/ipc";
/** File name of a command without a Windows executable extension. */
export function programName(command: string): string {
const name = command.split(/[/\\]/).pop() ?? command;
return name.replace(/\.(exe|cmd|bat)$/i, "");
}
const runsJvmTests = (args: readonly string[]) =>
args.some((a) => {
const task = a.split(":").pop() ?? a;
return ["test", "verify", "check", "integrationTest"].includes(task) || a.startsWith("-Dtest=") || a === "--tests";
});
/**
* Whether a coverage runner is known for `config` (cheap; the backend decides for real):
* cargo test / run / nextest, go test, Maven / Gradle tests, package.json test scripts,
* Node.js programs and Python.
*/
export function coverageSupports(config: Pick<RunConfig, "command" | "args" | "kind" | "isTest">): boolean {
const program = programName(config.command);
const args = config.args;
switch (program) {
case "cargo": {
const sub = args.find((a) => !a.startsWith("+"));
return sub === "test" || sub === "run" || sub === "nextest";
}
case "go":
return args[0] === "test";
case "mvn":
case "mvnw":
case "gradle":
case "gradlew":
return runsJvmTests(args);
case "npm":
case "pnpm":
case "yarn":
case "bun":
return config.isTest;
case "node":
case "npx":
case "tsx":
case "ts-node":
return true;
}
return (
config.kind === "python" ||
config.kind === "pytest" ||
/^(python[\d.]*|py|pytest|py\.test)$/.test(program) ||
((program === "uv" || program === "poetry") && args[0] === "run")
);
}
/** Percentage (0–100), or null when there is nothing to cover. */
export function percent(covered: number, total: number): number | null {
return total > 0 ? (covered / total) * 100 : null;
}
/** IDEA style: `85% (17/20)`, whole percent unless below 1. */
export function formatPercent(covered: number, total: number): string {
const p = percent(covered, total);
if (p === null) return "n/a";
const shown = p > 0 && p < 1 ? "<1" : String(Math.floor(p));
return `${shown}% (${covered}/${total})`;
}
export interface CoverageNode {
/** Absolute path (directories too). */
path: string;
name: string;
dir: boolean;
children: CoverageNode[];
linesTotal: number;
linesCovered: number;
branchesTotal: number;
branchesCovered: number;
}
const join = (a: string, b: string) => (a ? `${a.replace(/\/$/, "")}/${b}` : b);
/**
* The files of `suite` as a directory tree relative to `root` (files elsewhere
* under their absolute directories); single-child directory chains are compacted
* (`src/main/java/com/x`) and every directory sums its files.
*/
export function coverageTree(suite: CoverageSuite, root: string, compactDirs = true): CoverageNode[] {
const top: CoverageNode = {
path: root,
name: "",
dir: true,
children: [],
linesTotal: 0,
linesCovered: 0,
branchesTotal: 0,
branchesCovered: 0,
};
const prefix = root.replace(/\/$/, "") + "/";
for (const f of suite.files) {
const inside = f.path.startsWith(prefix);
const rel = inside ? f.path.slice(prefix.length) : f.path.replace(/^\//, "");
const parts = rel.split("/");
let node = top;
let base = inside ? root : "";
for (const part of parts.slice(0, -1)) {
base = inside || base ? join(base, part) : `/${part}`;
let child = node.children.find((c) => c.dir && c.name === part);
if (!child) {
child = {
path: base,
name: part,
dir: true,
children: [],
linesTotal: 0,
linesCovered: 0,
branchesTotal: 0,
branchesCovered: 0,
};
node.children.push(child);
}
node = child;
}
node.children.push({
path: f.path,
name: parts[parts.length - 1]!,
dir: false,
children: [],
linesTotal: f.linesTotal,
linesCovered: f.linesCovered,
branchesTotal: f.branchesTotal,
branchesCovered: f.branchesCovered,
});
}
const sum = (n: CoverageNode) => {
if (!n.dir) return;
n.children.forEach(sum);
n.linesTotal = n.children.reduce((a, c) => a + c.linesTotal, 0);
n.linesCovered = n.children.reduce((a, c) => a + c.linesCovered, 0);
n.branchesTotal = n.children.reduce((a, c) => a + c.branchesTotal, 0);
n.branchesCovered = n.children.reduce((a, c) => a + c.branchesCovered, 0);
};
const compact = (n: CoverageNode): CoverageNode => {
let cur = n;
while (cur.dir && cur.children.length === 1 && cur.children[0]!.dir) {
const only = cur.children[0]!;
cur = { ...only, name: `${cur.name}/${only.name}` };
}
return { ...cur, children: cur.children.map(compact) };
};
sum(top);
return compactDirs ? top.children.map(compact) : top.children;
}
export type SortKey = "name" | "lines" | "branches";
/** Sorted copy: by name (directories first) or by a percentage (files without data last). */
export function sortTree(nodes: readonly CoverageNode[], key: SortKey, ascending: boolean): CoverageNode[] {
const dir = ascending ? 1 : -1;
const value = (n: CoverageNode) =>
key === "lines" ? percent(n.linesCovered, n.linesTotal) : percent(n.branchesCovered, n.branchesTotal);
const cmp = (a: CoverageNode, b: CoverageNode) => {
if (key === "name") {
if (a.dir !== b.dir) return a.dir ? -1 : 1;
return dir * a.name.localeCompare(b.name);
}
const va = value(a);
const vb = value(b);
if (va === null || vb === null) return va === vb ? a.name.localeCompare(b.name) : va === null ? 1 : -1;
return dir * (va - vb) || a.name.localeCompare(b.name);
};
return [...nodes].sort(cmp).map((n) => (n.dir ? { ...n, children: sortTree(n.children, key, ascending) } : n));
}
/** Per path (files and directories): the line coverage badge of the project tree, e.g. `85% lines`. */
export function coverageBadges(nodes: readonly CoverageNode[]): Map<string, string> {
const out = new Map<string, string>();
const visit = (n: CoverageNode) => {
const p = percent(n.linesCovered, n.linesTotal);
if (p !== null) out.set(n.path, `${p > 0 && p < 1 ? "<1" : Math.floor(p)}% lines`);
n.children.forEach(visit);
};
nodes.forEach(visit);
return out;
}
export type LineState = "covered" | "partial" | "uncovered";
export function lineState(l: FileCoverage["lines"][number]): LineState {
if (l.hits === 0) return "uncovered";
return l.branchesCovered < l.branchesTotal ? "partial" : "covered";
}