/** * Unit-test coverage report — the pure core. * * The project is working towards a unit test (with 100% mutation coverage) for * every source file, on top of the integration/e2e tests that already exercise * the app end-to-end. This report measures progress towards that goal by a * simple, mechanical convention: a source file at `src/.ts` is considered * to have a unit test when a test file lives at the *mirror* location under * `test/`, either as a single file or — when one source needs several test * files — inside a directory named after the source: * * src/shared/accounting/store.ts -> test/shared/accounting/store.test.ts * or test/shared/accounting/store/*.test.ts * * From that one rule the report derives three things a human (or a future * agent) can act on directly: * * 1. Source files with no mirrored test at all — the untested list. * 2. Source files that *have* tests but a high source-lines-to-test-lines * ratio — the thinly-tested list, a good place to add cases. * 3. Test files that don't sit at any source's mirror location — either a * test for something that isn't a single source file, or a test that * belongs somewhere else and should be moved. * * Everything here is a pure function of the file lists (path + line count) it is * handed; the walking and line-counting live in the thin shell * `scripts/unit-tests-report.ts`, so every rule below is unit-testable with * crafted inputs. */ import { filter, map, pipe, sort, sumOf } from "#fp"; import { endsWithAny } from "#fp-strings"; import { nonBlankLines } from "./lines.ts"; /** A source or test file paired with its line count. */ export type FileLines = { path: string; lines: number; }; /** How to read the tree and which files are exempt from the "needs a test" * rule. Roots are configurable so the logic can be tested against a temp * tree, and default to the real `src`/`test` roots. */ export type ReportOptions = { srcRoot: string; testRoot: string; /** Path prefixes under `src` that are not expected to have their own unit * test (e.g. generated data, translation tables). */ exemptSourcePrefixes: string[]; /** Path prefixes under `test` that are not expected to mirror a source file * (e.g. end-to-end suites, script tests, shared test helpers). */ exemptTestPrefixes: string[]; }; /** A single source file enriched with the tests that cover it. */ export type SourceReport = { path: string; lines: number; /** Mirror-located test files covering this source, in path order. */ testFiles: string[]; /** Combined line count of `testFiles`. */ testLines: number; tested: boolean; /** Source lines per test line — how thin the tests are. `Infinity` when the * source has no test at all, so untested files always sort to the top. */ ratio: number; }; /** The whole report, ready to format or serialise. */ export type Report = { totalSources: number; testedCount: number; /** Untested source files, biggest first (the largest gap is the best * target). */ untested: SourceReport[]; /** Tested source files, thinnest coverage first. */ ranked: SourceReport[]; /** Test files that don't sit at any source's mirror location. */ orphanTests: string[]; }; /** Default roots and exemptions for a real run against this repo. */ export const DEFAULT_OPTIONS: ReportOptions = { // Translation tables are data, not logic, so they carry no unit test. exemptSourcePrefixes: ["src/locales/"], // These test trees exercise the app as a whole or test the tooling itself, // so they deliberately don't mirror a single source file. exemptTestPrefixes: [ "test/e2e/", "test/integration/", "test/scripts/", "test/test-utils/", "test/setup.ts", ], srcRoot: "src", testRoot: "test", }; /** Whether a path is on an exempt prefix (an exact match, or inside a * `dir/`-style prefix). Shared with the import-attribution module. */ export const hasExemptPrefix = (path: string, prefixes: string[]): boolean => prefixes.some((prefix) => path === prefix || path.startsWith(prefix)); /** Count non-blank lines, so whitespace padding doesn't inflate the ratio. */ export const countLines = (text: string): number => nonBlankLines(text).length; /** A `.ts`/`.tsx` file of any kind, including declaration files. */ const isTypeScriptPath = endsWithAny([".ts", ".tsx"]); /** A `.ts`/`.tsx` file that isn't a type-declaration file. */ export const isSourcePath = (path: string): boolean => isTypeScriptPath(path) && !path.endsWith(".d.ts"); /** A `.test.ts`/`.test.tsx` test file. */ export const isTestPath = endsWithAny([".test.ts", ".test.tsx"]); /** Whether a source file is one we expect to carry its own unit test. Type * declaration files and anything on an exempt prefix are left out. */ export const isTestableSource = ( path: string, options: ReportOptions, ): boolean => !path.endsWith(".d.ts") && !hasExemptPrefix(path, options.exemptSourcePrefixes); /** * The mirror location a source file's test(s) must live at, with the source * root swapped for the test root and the extension dropped. A `src/a/b/name.ts` * maps to the prefix `test/a/b/name`, matched either as `.test.ts(x)` * or as any file inside `/`. */ export const mirrorPrefix = ( srcPath: string, options: ReportOptions, ): string => { const withoutRoot = srcPath.slice(options.srcRoot.length + 1); const withoutExt = withoutRoot.replace(/\.(?:ts|tsx)$/, ""); return `${options.testRoot}/${withoutExt}`; }; /** * The mirror prefix of the source a test belongs to, or `null` when no source * owns it. A test at `test/a/b/name.test.ts` is owned by the source whose * mirror is the longest path-prefix of it that actually exists: its own direct * mirror `test/a/b/name` when `src/a/b/name.ts` exists, otherwise the nearest * ancestor directory that mirrors a source (the directory-suite convention). * * Picking the longest matching prefix is what stops a child's own mirror from * being counted against its parent: given both `src/db/attendees.ts` and * `src/db/attendees/kind.ts`, the test `test/db/attendees/kind.test.ts` matches * the child prefix `test/db/attendees/kind` (longer) over the parent * `test/db/attendees`, so it counts only for the child and never hides a * missing parent test. The trailing-slash check keeps a sibling like * `test/db/attendees-notes.test.ts` from matching the `test/db/attendees` prefix. */ export const owningSourcePrefix = ( testPath: string, sourcePrefixes: Iterable, ): string | null => { const base = testPath.replace(/\.test\.(?:ts|tsx)$/, ""); let best: string | null = null; for (const prefix of sourcePrefixes) { const owns = base === prefix || base.startsWith(`${prefix}/`); if (owns && (best === null || prefix.length > best.length)) { best = prefix; } } return best; }; /** Build the per-source view from the tests already assigned to it. */ const describeSource = ( source: FileLines, ownedTests: FileLines[], ): SourceReport => { const testLines = sumOf((test: FileLines) => test.lines)(ownedTests); return { lines: source.lines, path: source.path, ratio: testLines === 0 ? Number.POSITIVE_INFINITY : source.lines / testLines, tested: ownedTests.length > 0, testFiles: pipe( map((test: FileLines) => test.path), sort((a: string, b: string) => a.localeCompare(b)), )(ownedTests), testLines, }; }; /** Biggest source file first — the largest untested file is the best target. */ const byLinesDesc = (a: SourceReport, b: SourceReport): number => b.lines - a.lines || a.path.localeCompare(b.path); /** Thinnest coverage first, then biggest source as a tie-breaker. */ const byRatioDesc = (a: SourceReport, b: SourceReport): number => b.ratio - a.ratio || b.lines - a.lines || a.path.localeCompare(b.path); /** * Fold the raw source and test file lists into the full report: which sources * are (un)tested, how thin the tested ones are, and which test files don't * mirror any source. Each non-exempt test is assigned to exactly one owning * source (its nearest mirror), so a test is never double-counted or credited to * a parent when it really covers a child. */ export const buildReport = ( allSources: FileLines[], allTests: FileLines[], options: ReportOptions, ): Report => { const sources = filter((s: FileLines) => isTestableSource(s.path, options))( allSources, ); // Pre-seed a bucket per source (keyed by its mirror prefix) so every owned // test lands in an existing list and every source has one to describe from. const prefixes = sources.map((s) => mirrorPrefix(s.path, options)); const ownedTests = new Map(prefixes.map((p) => [p, []])); // Assign each non-exempt test to its owning source; the rest are orphans. // Exempt trees (e2e, integration, …) sit outside the mirror system entirely. const orphans: string[] = []; for (const test of allTests) { if (hasExemptPrefix(test.path, options.exemptTestPrefixes)) continue; const owner = owningSourcePrefix(test.path, prefixes); if (owner === null) orphans.push(test.path); else ownedTests.get(owner)!.push(test); } const described = sources.map((s, i) => describeSource(s, ownedTests.get(prefixes[i]!)!), ); return { orphanTests: sort((a: string, b: string) => a.localeCompare(b))(orphans), ranked: pipe( filter((s: SourceReport) => s.tested), sort(byRatioDesc), )(described), testedCount: filter((s: SourceReport) => s.tested)(described).length, totalSources: described.length, untested: pipe( filter((s: SourceReport) => !s.tested), sort(byLinesDesc), )(described), }; }; /** The single best file to write tests for next: the largest untested file if * any remain, otherwise the thinnest-tested one. `null` only when every source * is exempt (an empty tree). */ export const suggestedTarget = (report: Report): SourceReport | null => report.untested[0] ?? report.ranked[0] ?? null; /** A source entry shaped for JSON: `ratio` becomes `null` for untested files, * because JSON has no infinity. The untested signal is still carried by * `tested: false` and `testLines: 0`. */ export type JsonSourceReport = Omit & { ratio: number | null; }; /** The report shaped for JSON output. */ export type JsonReport = Omit & { ranked: JsonSourceReport[]; untested: JsonSourceReport[]; }; const toJsonSource = (source: SourceReport): JsonSourceReport => ({ ...source, ratio: Number.isFinite(source.ratio) ? source.ratio : null, }); /** * Convert a report to a JSON-safe shape. `JSON.stringify` turns the infinite * ratio of untested files into `null` on its own, but doing it here keeps the * numeric contract explicit (a `ratio` is a number or `null`, never a silently * dropped `Infinity`) rather than relying on that quirk. */ export const toJsonReport = (report: Report): JsonReport => ({ ...report, ranked: report.ranked.map(toJsonSource), untested: report.untested.map(toJsonSource), }); /* -------------------------------------------------------------------------- * * Formatting * * -------------------------------------------------------------------------- */ /** How many rows each list shows before it is truncated, unless `--all`. */ export const DEFAULT_LIMIT = 25; const pct = (part: number, whole: number): string => whole === 0 ? "0.0" : ((part / whole) * 100).toFixed(1); /** A ratio for display: `∞` for untested, else two decimals. */ export const formatRatio = (ratio: number): string => ratio === Number.POSITIVE_INFINITY ? "∞" : ratio.toFixed(2); const limitRows = (rows: T[], limit: number | null): T[] => limit === null ? rows : rows.slice(0, limit); const truncationNote = (shown: number, total: number): string[] => shown < total ? [` … and ${total - shown} more (use --all to list)`] : []; /** * Render one of the report's lists: a "none" note when it is empty, else the * rows (with an optional header line on top) plus a "… and N more" note when * the limit hid some. `pickRows` reads the list off the report so each list * only names its own field once. */ const listSection = ( pickRows: (report: Report) => T[], look: { whenEmpty: string; header?: string; renderRow: (row: T) => string }, ) => (report: Report, limit: number | null): string[] => { const rows = pickRows(report); if (rows.length === 0) return [look.whenEmpty]; const shown = limitRows(rows, limit); return [ ...(look.header ? [look.header] : []), ...shown.map(look.renderRow), ...truncationNote(shown.length, rows.length), ]; }; const untestedLines = listSection((report) => report.untested, { renderRow: (s) => ` ${String(s.lines).padStart(5)} ${s.path}`, whenEmpty: " (none — every source has a test)", }); const rankedLines = listSection((report) => report.ranked, { header: ` ${"ratio".padStart(6)} ${"src".padStart(5)} ${"test".padStart(5)} file`, renderRow: (s) => ` ${formatRatio(s.ratio).padStart(6)} ${String(s.lines).padStart( 5, )} ${String(s.testLines).padStart(5)} ${s.path}`, whenEmpty: " (none)", }); const orphanLines = listSection((report) => report.orphanTests, { renderRow: (path) => ` ${path}`, whenEmpty: " (none)", }); /** * Render the report as plain text lines. `limit` caps each list (null shows * everything); the thinnest-tested and largest-untested files come first so the * top of each list is the best place to spend effort next. */ export const formatReport = ( report: Report, limit: number | null, ): string[] => { const target = suggestedTarget(report); return [ "Unit-test coverage report", "=========================", `Source files needing a test: ${report.totalSources}`, ` with a mirrored test: ${report.testedCount} (${pct( report.testedCount, report.totalSources, )}%)`, ` untested: ${report.untested.length}`, `Orphan test files (mirror no source): ${report.orphanTests.length}`, "", target ? `👉 Suggested next target: ${target.path} (${ target.tested ? `ratio ${formatRatio(target.ratio)}, ${target.lines} src / ${target.testLines} test lines` : `untested, ${target.lines} lines` })` : "👉 Nothing to do — no testable sources found.", "", "Untested source files (largest first):", ...untestedLines(report, limit), "", "Thinnest-tested source files (highest src:test ratio first):", ...rankedLines(report, limit), "", `Orphan test files (not at any source's mirror path):`, ...orphanLines(report, limit), ]; };