Skip to main content

testing_conventions/
coverage.rs

1//! Coverage rule (Python — issue #26; TypeScript — issue #31; exemptions — issue #32).
2//!
3//! Enforces the README's Coverage rule: a library's unit suite must meet the
4//! configured floor, with test files excluded from the denominator. This module
5//! is the deterministic core — given a parsed coverage report and the thresholds
6//! from config, an `evaluate` function decides pass/fail. Producing the report
7//! (shelling out to the language's coverage tool) is a thin layer on top, kept
8//! separate so the guarantee is testable without that toolchain installed.
9//!
10//! Python (#26) uses coverage.py: a single total, branch coverage on. Given a
11//! [`CoverageReport`] and [`Thresholds`], [`evaluate`] decides pass/fail, and
12//! [`measure`] shells out to `coverage`. TypeScript (#31) is the twin: vitest
13//! reports four independent metrics (lines / branches / functions / statements),
14//! so it carries its own [`TypeScriptThresholds`], [`VitestReport`], and
15//! [`evaluate_typescript`] / [`measure_typescript`] pair — sharing only the
16//! [`Outcome`] type. Its subprocess layer shells out to `vitest`.
17//!
18//! Files exempted from coverage in config (issue #32) are omitted from the
19//! denominator alongside the test files; the caller resolves them
20//! ([`crate::config::resolve_exempt`]) and passes their paths to [`measure`] /
21//! [`measure_typescript`].
22
23use std::path::{Path, PathBuf};
24use std::process::Command;
25use std::sync::atomic::{AtomicU64, Ordering};
26
27use anyhow::{bail, Context, Result};
28use serde::Deserialize;
29
30/// Always omitted from the coverage denominator: colocated unit tests are the
31/// suite, never a subject of it.
32const TEST_OMIT: &str = "*_test.py";
33
34/// The coverage floor to enforce, from a `[<language>].coverage` table.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Thresholds {
37    /// Minimum total coverage percent the unit suite must meet.
38    pub fail_under: u8,
39    /// Whether branch coverage must be measured (and folded into the total).
40    pub branch: bool,
41}
42
43/// A coverage.py JSON report (`coverage json`), pared to the totals the check
44/// needs. Unmodeled fields (per-file data, metadata) are ignored.
45#[derive(Debug, Clone, Deserialize)]
46pub struct CoverageReport {
47    pub totals: Totals,
48}
49
50/// The `totals` block of a coverage.py report.
51#[derive(Debug, Clone, Deserialize)]
52pub struct Totals {
53    /// Total covered percent — line coverage, plus branch when measured.
54    pub percent_covered: f64,
55    /// Branches measured; `0` when branch coverage was not enabled.
56    #[serde(default)]
57    pub num_branches: u64,
58}
59
60/// The result of checking a report against the thresholds.
61#[derive(Debug, Clone, PartialEq)]
62pub enum Outcome {
63    /// The floor is met.
64    Pass,
65    /// The floor is not met; the message explains why (actual vs. required).
66    Fail(String),
67}
68
69/// Parse a coverage.py JSON report (the output of `coverage json`).
70pub fn parse_report(json: &str) -> Result<CoverageReport> {
71    serde_json::from_str(json).context("parsing coverage.py JSON report")
72}
73
74/// Decide whether `report` meets `thresholds`.
75///
76/// Fails when total coverage is below `fail_under`, or when branch coverage was
77/// required but the report measured no branches (a misconfigured run).
78pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
79    if thresholds.branch && report.totals.num_branches == 0 {
80        return Outcome::Fail(
81            "branch coverage is required but the report measured no branches".to_string(),
82        );
83    }
84    let actual = report.totals.percent_covered;
85    let required = f64::from(thresholds.fail_under);
86    // A hair of tolerance so a report that rounds to the floor (e.g. 99.999…%
87    // for a 100% target) isn't failed by float noise.
88    if actual + 1e-9 >= required {
89        Outcome::Pass
90    } else {
91        Outcome::Fail(format!(
92            "coverage {actual:.2}% is below the required {}%",
93            thresholds.fail_under
94        ))
95    }
96}
97
98/// Run the unit suite under coverage.py in `root` and check it against
99/// `thresholds`.
100///
101/// Shells out to `coverage run --branch` (omitting `*_test.py` and every path in
102/// `omit` from the denominator) then `coverage json`, and evaluates the report.
103/// `omit` holds the `coverage`-rule exemptions resolved from config, as
104/// `root`-relative paths. The `coverage` CLI — with `pytest` importable — must be
105/// on `PATH`.
106pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
107    let report = run_coverage(root, omit)?;
108    Ok(evaluate(&report, thresholds))
109}
110
111/// A coverage.py data file under the temp dir — unique per call (so checks
112/// running in parallel don't collide) and removed on drop (so nothing leaks
113/// into the scanned tree).
114struct DataFile(PathBuf);
115
116impl DataFile {
117    fn new() -> Self {
118        static COUNTER: AtomicU64 = AtomicU64::new(0);
119        let name = format!(
120            "testing-conventions-{}-{}.coverage",
121            std::process::id(),
122            COUNTER.fetch_add(1, Ordering::Relaxed),
123        );
124        DataFile(std::env::temp_dir().join(name))
125    }
126}
127
128impl Drop for DataFile {
129    fn drop(&mut self) {
130        let _ = std::fs::remove_file(&self.0);
131    }
132}
133
134/// Run coverage.py over the unit suite in `root` and return the parsed report.
135fn run_coverage(root: &Path, omit: &[String]) -> Result<CoverageReport> {
136    let data = DataFile::new();
137    let omit = build_omit(omit);
138
139    // Branch coverage on; measure the sources in `root` with the test files —
140    // and any `coverage`-waived files — omitted from the denominator. Byte-code
141    // and the pytest cache are suppressed so the scanned tree stays pristine.
142    let run = Command::new("coverage")
143        .current_dir(root)
144        .args(["run", "--branch"])
145        .arg(format!("--omit={omit}"))
146        .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
147        .env("COVERAGE_FILE", &data.0)
148        .env("PYTHONDONTWRITEBYTECODE", "1")
149        .output()
150        .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
151    if !run.status.success() {
152        bail!(
153            "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
154            root.display(),
155            String::from_utf8_lossy(&run.stdout),
156            String::from_utf8_lossy(&run.stderr),
157        );
158    }
159
160    // Emit the report to stdout and parse it.
161    let json = Command::new("coverage")
162        .current_dir(root)
163        .args(["json", "-o", "-"])
164        .env("COVERAGE_FILE", &data.0)
165        .output()
166        .context("running `coverage json`")?;
167    if !json.status.success() {
168        bail!(
169            "`coverage json` failed:\n{}",
170            String::from_utf8_lossy(&json.stderr),
171        );
172    }
173
174    parse_report(&String::from_utf8_lossy(&json.stdout))
175}
176
177/// The single comma-joined `--omit` value for the coverage run: always
178/// `*_test.py`, plus every `coverage`-exempt path from config. (coverage.py
179/// takes one `--omit` — repeated flags don't accumulate, so the patterns must be
180/// joined.) An exempt file leaves the denominator with its reason recorded in
181/// config — an auditable omission, not a silent ignore-glob.
182fn build_omit(omit: &[String]) -> String {
183    std::iter::once(TEST_OMIT.to_string())
184        .chain(omit.iter().cloned())
185        .collect::<Vec<_>>()
186        .join(",")
187}
188
189// ---------------------------------------------------------------------------
190// TypeScript (vitest) — issue #31.
191//
192// The TypeScript twin of the Python rule above. vitest reports four independent
193// metrics rather than Python's single total-plus-branch, so it carries its own
194// thresholds, report shape, and evaluate/measure pair; only `Outcome` is shared.
195// The split is the same: a pure `evaluate_typescript` over a parsed json-summary
196// report, and a thin `measure_typescript` that shells out to vitest to produce
197// one — so the enforcement core is testable without a Node toolchain.
198// ---------------------------------------------------------------------------
199
200/// What vitest measures: every TypeScript source under the scanned root. The
201/// braces are a vitest (picomatch) glob, expanded by vitest, not the shell.
202const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
203/// Always excluded from the denominator: the colocated unit tests are the suite,
204/// never a subject of it (`*.test.*`), and declaration files carry no runtime
205/// code (`*.d.ts` / `*.d.mts` / `*.d.cts`).
206const TS_TEST_EXCLUDE: &str = "**/*.test.*";
207const TS_DECL_EXCLUDE: &str = "**/*.d.{ts,mts,cts}";
208
209/// The four vitest coverage floors, from a `[typescript].coverage` table. Each
210/// is an independent percent the unit suite must meet — vitest measures all four.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub struct TypeScriptThresholds {
213    pub lines: u8,
214    pub branches: u8,
215    pub functions: u8,
216    pub statements: u8,
217}
218
219/// A vitest `coverage-summary.json` report, pared to the `total` block the check
220/// needs. Per-file entries and unmodeled fields are ignored.
221#[derive(Debug, Clone, Copy, Deserialize)]
222pub struct VitestReport {
223    pub total: VitestTotals,
224}
225
226/// The `total` block of a vitest json-summary report — the four metrics this
227/// rule enforces. vitest also emits `branchesTrue`, which the check ignores.
228#[derive(Debug, Clone, Copy, Deserialize)]
229pub struct VitestTotals {
230    pub lines: VitestMetric,
231    pub branches: VitestMetric,
232    pub functions: VitestMetric,
233    pub statements: VitestMetric,
234}
235
236/// One metric's totals from a vitest json-summary block, pared to what the check
237/// needs: the covered percent and the denominator size.
238#[derive(Debug, Clone, Copy, Deserialize)]
239pub struct VitestMetric {
240    /// Percent covered — `None` when nothing was measured, which vitest writes as
241    /// the string `"Unknown"` (and `total` is then `0`).
242    #[serde(deserialize_with = "deserialize_pct")]
243    pub pct: Option<f64>,
244    /// Size of the denominator (statements/branches/functions/lines counted).
245    pub total: u64,
246}
247
248/// Deserialize a json-summary `pct`: a number for a measured metric (vitest
249/// emits whole percents as JSON integers and fractional ones as floats), or the
250/// string `"Unknown"` (→ `None`) when the denominator is empty.
251fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
252where
253    D: serde::Deserializer<'de>,
254{
255    struct PctVisitor;
256    impl serde::de::Visitor<'_> for PctVisitor {
257        type Value = Option<f64>;
258
259        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
260            f.write_str("a coverage percent number or the string \"Unknown\"")
261        }
262
263        fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
264            Ok(Some(value))
265        }
266
267        // serde_json hands a whole-number percent (e.g. `100`) to `visit_u64`;
268        // percents are never negative, so `visit_i64` is not needed.
269        fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
270            Ok(Some(value as f64))
271        }
272
273        // Any non-numeric percent (vitest writes the literal "Unknown") means the
274        // metric had nothing to measure.
275        fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
276            Ok(None)
277        }
278    }
279    deserializer.deserialize_any(PctVisitor)
280}
281
282/// Parse a vitest json-summary report (`coverage-summary.json`).
283pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
284    serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
285}
286
287/// Decide whether `report` meets every threshold in `thresholds`.
288///
289/// Fails when the run measured no code at all (an empty line denominator — a
290/// wrong path, or a suite that touched nothing — is never a silent pass),
291/// otherwise checks each of the four metrics and fails listing every one below
292/// its floor. A metric whose denominator is empty *amid* a non-empty run (e.g.
293/// branch-free code measured alongside real code) has nothing to miss and is
294/// vacuously satisfied.
295pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
296    let total = &report.total;
297    // Vacuous-run guard: every source file has lines, so a zero line-denominator
298    // means nothing was measured — a misconfigured run (wrong path, or every file
299    // excluded), failed rather than passed on an empty measurement.
300    if total.lines.total == 0 {
301        return Outcome::Fail(
302            "the unit suite measured no code — check the path and that the suite runs".to_string(),
303        );
304    }
305    let checks = [
306        ("lines", total.lines, thresholds.lines),
307        ("branches", total.branches, thresholds.branches),
308        ("functions", total.functions, thresholds.functions),
309        ("statements", total.statements, thresholds.statements),
310    ];
311    let mut shortfalls = Vec::new();
312    for (name, metric, required) in checks {
313        // A metric with an empty denominator (e.g. branch-free code) has nothing
314        // to cover and is vacuously full; a measured one compares its percent.
315        let actual = metric.pct.unwrap_or(100.0);
316        // A hair of tolerance so a percent that rounds to the floor isn't failed
317        // by float noise (matches the Python path).
318        if actual + 1e-9 < f64::from(required) {
319            shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
320        }
321    }
322    if shortfalls.is_empty() {
323        Outcome::Pass
324    } else {
325        Outcome::Fail(format!(
326            "coverage below thresholds: {}",
327            shortfalls.join(", ")
328        ))
329    }
330}
331
332/// Run the unit suite under vitest coverage in `root` and check it against
333/// `thresholds`.
334///
335/// Shells out to `npx vitest run` with v8 coverage and the json-summary reporter,
336/// excluding `*.test.*`, declaration files, and every path in `exclude` from the
337/// denominator, then evaluates the report. `exclude` holds the `coverage`-rule
338/// exemptions resolved from config, as `root`-relative paths. `npx` resolves the
339/// project-local `vitest`, so it and `@vitest/coverage-v8` must be installed
340/// under `root`.
341pub fn measure_typescript(
342    root: &Path,
343    thresholds: TypeScriptThresholds,
344    exclude: &[String],
345) -> Result<Outcome> {
346    let report = run_vitest(root, exclude)?;
347    Ok(evaluate_typescript(&report, thresholds))
348}
349
350/// A vitest reports directory under the temp dir — unique per call (so checks
351/// running in parallel don't collide) and removed on drop (so the report never
352/// leaks into the scanned tree). vitest writes `coverage-summary.json` here.
353struct ReportDir(PathBuf);
354
355impl ReportDir {
356    fn new() -> Self {
357        static COUNTER: AtomicU64 = AtomicU64::new(0);
358        let name = format!(
359            "testing-conventions-vitest-{}-{}",
360            std::process::id(),
361            COUNTER.fetch_add(1, Ordering::Relaxed),
362        );
363        ReportDir(std::env::temp_dir().join(name))
364    }
365
366    /// The json-summary file vitest writes under this directory.
367    fn summary(&self) -> PathBuf {
368        self.0.join("coverage-summary.json")
369    }
370}
371
372impl Drop for ReportDir {
373    fn drop(&mut self) {
374        let _ = std::fs::remove_dir_all(&self.0);
375    }
376}
377
378/// Run vitest over the unit suite in `root` and return the parsed report.
379fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
380    let reports = ReportDir::new();
381
382    // v8 coverage with the json-summary reporter, written to an out-of-tree temp
383    // dir so the scanned tree stays pristine. `include` scopes measurement to the
384    // sources under `root`; the test glob, declaration files, and the config
385    // exemptions are excluded from the denominator. `all=true` counts source files
386    // the suite never imported, so an untested file lowers coverage rather than
387    // vanishing. `--no-cache` keeps vitest from writing a cache into the tree.
388    let mut command = Command::new("npx");
389    command
390        .current_dir(root)
391        .args(["--yes", "vitest", "run", "--no-cache"])
392        .args([
393            "--coverage.enabled",
394            "--coverage.provider=v8",
395            "--coverage.reporter=json-summary",
396            "--coverage.all=true",
397        ])
398        .arg(format!(
399            "--coverage.reportsDirectory={}",
400            reports.0.display()
401        ))
402        .arg(format!("--coverage.include={TS_INCLUDE}"))
403        .arg(format!("--coverage.exclude={TS_TEST_EXCLUDE}"))
404        .arg(format!("--coverage.exclude={TS_DECL_EXCLUDE}"));
405    for path in exclude {
406        command.arg(format!("--coverage.exclude={path}"));
407    }
408    // CI=1 keeps vitest non-interactive (no watch prompt, plain output).
409    let run = command.env("CI", "1").output().context(
410        "running `npx vitest run --coverage` (are vitest and @vitest/coverage-v8 installed?)",
411    )?;
412    if !run.status.success() {
413        bail!(
414            "the unit suite did not run cleanly under vitest in `{}`:\n{}{}",
415            root.display(),
416            String::from_utf8_lossy(&run.stdout),
417            String::from_utf8_lossy(&run.stderr),
418        );
419    }
420
421    let summary = reports.summary();
422    let json = std::fs::read_to_string(&summary).with_context(|| {
423        format!(
424            "reading vitest coverage summary `{}` (did the run produce a json-summary report?)",
425            summary.display()
426        )
427    })?;
428    parse_vitest_report(&json)
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
436        CoverageReport {
437            totals: Totals {
438                percent_covered,
439                num_branches,
440            },
441        }
442    }
443
444    #[test]
445    fn passes_when_total_meets_the_floor() {
446        assert_eq!(
447            evaluate(
448                &report(100.0, 12),
449                Thresholds {
450                    fail_under: 100,
451                    branch: true
452                }
453            ),
454            Outcome::Pass
455        );
456    }
457
458    #[test]
459    fn fails_when_total_is_below_the_floor() {
460        assert!(matches!(
461            evaluate(
462                &report(80.0, 12),
463                Thresholds {
464                    fail_under: 100,
465                    branch: true
466                }
467            ),
468            Outcome::Fail(_)
469        ));
470    }
471
472    #[test]
473    fn fails_when_branch_required_but_unmeasured() {
474        // branch=true but the report measured no branches → a misconfigured run.
475        assert!(matches!(
476            evaluate(
477                &report(100.0, 0),
478                Thresholds {
479                    fail_under: 90,
480                    branch: true
481                }
482            ),
483            Outcome::Fail(_)
484        ));
485    }
486
487    #[test]
488    fn parses_a_coverage_py_report() {
489        let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
490        let report = parse_report(json).expect("valid coverage.py json");
491        assert_eq!(report.totals.percent_covered, 91.5);
492        assert_eq!(report.totals.num_branches, 8);
493    }
494
495    #[test]
496    fn omit_is_just_the_test_glob_when_nothing_is_exempt() {
497        assert_eq!(build_omit(&[]), "*_test.py");
498    }
499
500    #[test]
501    fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
502        // The caller passes already-resolved, sorted, `root`-relative paths.
503        let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
504        assert_eq!(build_omit(&exempt), "*_test.py,pkg/gen.py,shim.py");
505    }
506
507    // --- TypeScript (vitest) — issue #31 ---
508
509    fn metric(pct: f64) -> VitestMetric {
510        VitestMetric {
511            pct: Some(pct),
512            total: 10,
513        }
514    }
515
516    fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
517        VitestReport {
518            total: VitestTotals {
519                lines: metric(lines),
520                branches: metric(branches),
521                functions: metric(functions),
522                statements: metric(statements),
523            },
524        }
525    }
526
527    const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
528        lines: 100,
529        branches: 100,
530        functions: 100,
531        statements: 100,
532    };
533    const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
534        lines: 80,
535        branches: 75,
536        functions: 80,
537        statements: 80,
538    };
539
540    #[test]
541    fn typescript_passes_when_every_metric_meets_its_floor() {
542        assert_eq!(
543            evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
544            Outcome::Pass
545        );
546    }
547
548    #[test]
549    fn typescript_fails_on_the_one_metric_below_its_floor() {
550        // 100% lines but only 66.66% branches (the `below` fixture's shape): the
551        // branch floor catches what line coverage misses — and only `branches` is
552        // named as a shortfall, not the metrics that met their floor.
553        let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
554        assert!(
555            matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
556            "got: {outcome:?}"
557        );
558    }
559
560    #[test]
561    fn typescript_fail_message_names_every_metric_below() {
562        let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
563        assert!(
564            matches!(&outcome, Outcome::Fail(message)
565                if message.contains("lines")
566                    && message.contains("branches")
567                    && message.contains("functions")
568                    && message.contains("statements")),
569            "got: {outcome:?}"
570        );
571    }
572
573    #[test]
574    fn typescript_tolerates_float_noise_at_the_floor() {
575        // A percent a hair under the floor from rounding still passes.
576        assert_eq!(
577            evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
578            Outcome::Pass
579        );
580    }
581
582    #[test]
583    fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
584        // Branch-free code measured alongside real code: branches has nothing to
585        // cover (pct "Unknown") but lines/etc. are real and pass → overall pass.
586        let report = VitestReport {
587            total: VitestTotals {
588                lines: metric(100.0),
589                branches: VitestMetric {
590                    pct: None,
591                    total: 0,
592                },
593                functions: metric(100.0),
594                statements: metric(100.0),
595            },
596        };
597        assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
598    }
599
600    #[test]
601    fn typescript_fails_a_vacuous_run_that_measured_no_code() {
602        // No lines in the denominator (everything excluded, or a wrong path): a
603        // vacuous run is a failure, never a silent pass.
604        let nothing = VitestMetric {
605            pct: None,
606            total: 0,
607        };
608        let report = VitestReport {
609            total: VitestTotals {
610                lines: nothing,
611                branches: nothing,
612                functions: nothing,
613                statements: nothing,
614            },
615        };
616        let outcome = evaluate_typescript(&report, TS_MID);
617        assert!(
618            matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
619            "got: {outcome:?}"
620        );
621    }
622
623    #[test]
624    fn parses_a_vitest_summary_report() {
625        // A realistic `coverage-summary.json`: the four metrics plus the
626        // `branchesTrue` block and a per-file entry the check ignores.
627        let json = r#"{
628            "total": {
629                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
630                "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
631                "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
632                "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
633                "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
634            },
635            "/abs/widget.ts": {
636                "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
637            }
638        }"#;
639        let report = parse_vitest_report(json).expect("valid vitest json-summary");
640        // A whole-number percent (`visit_u64`) and a fractional one (`visit_f64`).
641        assert_eq!(report.total.lines.pct, Some(80.0));
642        assert_eq!(report.total.branches.pct, Some(66.66));
643        assert_eq!(report.total.functions.total, 2);
644    }
645
646    #[test]
647    fn parses_an_unknown_pct_as_unmeasured() {
648        let json = r#"{"total": {
649            "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
650            "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
651            "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
652            "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
653        }}"#;
654        let report = parse_vitest_report(json).expect("valid vitest json-summary");
655        assert_eq!(report.total.lines.pct, None);
656        assert_eq!(report.total.lines.total, 0);
657    }
658
659    #[test]
660    fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
661        // vitest only ever writes a number or "Unknown"; anything else (here a
662        // bool) is a malformed report, surfaced as an error rather than guessed.
663        let json = r#"{"total":{
664            "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
665            "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
666            "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
667            "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
668        }}"#;
669        assert!(parse_vitest_report(json).is_err());
670    }
671}