1use std::collections::{BTreeMap, BTreeSet};
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::sync::atomic::{AtomicU64, Ordering};
27
28use anyhow::{bail, Context, Result};
29use serde::Deserialize;
30
31const TEST_OMIT: &str = "*_test.py";
34
35const SUPPORT_OMIT: &str = "*conftest.py";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Thresholds {
43 pub fail_under: u8,
45 pub branch: bool,
47}
48
49#[derive(Debug, Clone, Deserialize)]
54pub struct CoverageReport {
55 pub totals: Totals,
56 #[serde(default)]
60 pub files: BTreeMap<String, FileCoverage>,
61}
62
63#[derive(Debug, Clone, Default, Deserialize)]
67pub struct FileCoverage {
68 #[serde(default)]
70 pub executed_lines: Vec<u64>,
71 #[serde(default)]
74 pub missing_lines: Vec<u64>,
75 #[serde(default)]
77 pub excluded_lines: Vec<u64>,
78 #[serde(default)]
82 pub missing_branches: Vec<Vec<i64>>,
83}
84
85#[derive(Debug, Clone, Deserialize)]
87pub struct Totals {
88 pub percent_covered: f64,
90 #[serde(default)]
92 pub num_branches: u64,
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub enum Outcome {
98 Pass,
100 Fail(String),
102}
103
104pub fn parse_report(json: &str) -> Result<CoverageReport> {
106 serde_json::from_str(json).context("parsing coverage.py JSON report")
107}
108
109pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
114 if thresholds.branch && report.totals.num_branches == 0 {
115 return Outcome::Fail(
116 "branch coverage is required but the report measured no branches".to_string(),
117 );
118 }
119 let actual = report.totals.percent_covered;
120 let required = f64::from(thresholds.fail_under);
121 if actual + 1e-9 >= required {
124 Outcome::Pass
125 } else {
126 Outcome::Fail(format!(
127 "coverage {actual:.2}% is below the required {}%",
128 thresholds.fail_under
129 ))
130 }
131}
132
133pub const BASELINE_PATH: &str = "coverage-baseline.json";
148
149#[derive(Debug, Clone, Default, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct Baseline {
156 #[serde(default)]
158 pub python: Option<PythonBaseline>,
159}
160
161#[derive(Debug, Clone, Copy, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct PythonBaseline {
165 pub percent_covered: f64,
167}
168
169pub fn read_baseline(root: &Path) -> Result<Option<Baseline>> {
173 let path = root.join(BASELINE_PATH);
174 if !path.exists() {
175 return Ok(None);
176 }
177 let contents = std::fs::read_to_string(&path)
178 .with_context(|| format!("reading coverage baseline `{}`", path.display()))?;
179 let baseline = serde_json::from_str(&contents)
180 .with_context(|| format!("parsing coverage baseline `{}`", path.display()))?;
181 Ok(Some(baseline))
182}
183
184pub fn evaluate_ratchet(percent: f64, baseline: Option<f64>) -> Outcome {
189 match baseline {
190 Some(required) if percent + 1e-9 < required => Outcome::Fail(format!(
191 "coverage {percent:.2}% regressed below the recorded baseline {required:.2}%"
192 )),
193 _ => Outcome::Pass,
194 }
195}
196
197pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
206 Ok(evaluate(&measure_report(root, omit)?, thresholds))
207}
208
209pub fn measure_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
213 run_coverage(root, omit, false)
214}
215
216pub fn measure_patch_report(root: &Path, omit: &[String]) -> Result<CoverageReport> {
223 run_coverage(root, omit, true)
224}
225
226struct DataFile(PathBuf);
230
231impl DataFile {
232 fn new() -> Self {
233 static COUNTER: AtomicU64 = AtomicU64::new(0);
234 let name = format!(
235 "testing-conventions-{}-{}.coverage",
236 std::process::id(),
237 COUNTER.fetch_add(1, Ordering::Relaxed),
238 );
239 DataFile(std::env::temp_dir().join(name))
240 }
241}
242
243impl Drop for DataFile {
244 fn drop(&mut self) {
245 let _ = std::fs::remove_file(&self.0);
246 }
247}
248
249fn run_coverage(root: &Path, omit: &[String], include_all_sources: bool) -> Result<CoverageReport> {
256 let data = DataFile::new();
257 let omit = build_omit(omit);
258
259 let mut command = Command::new("coverage");
263 command
264 .current_dir(root)
265 .args(["run", "--branch"])
266 .arg(format!("--omit={omit}"));
267 if include_all_sources {
268 command.arg("--source=.");
269 }
270 let run = command
271 .args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
272 .env("COVERAGE_FILE", &data.0)
273 .env("PYTHONDONTWRITEBYTECODE", "1")
274 .output()
275 .context("running `coverage run -m pytest` (is coverage.py installed?)")?;
276 if !run.status.success() {
277 bail!(
278 "the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
279 root.display(),
280 String::from_utf8_lossy(&run.stdout),
281 String::from_utf8_lossy(&run.stderr),
282 );
283 }
284
285 let json = Command::new("coverage")
287 .current_dir(root)
288 .args(["json", "-o", "-"])
289 .env("COVERAGE_FILE", &data.0)
290 .output()
291 .context("running `coverage json`")?;
292 if !json.status.success() {
293 bail!(
294 "`coverage json` failed:\n{}",
295 String::from_utf8_lossy(&json.stderr),
296 );
297 }
298
299 parse_report(&String::from_utf8_lossy(&json.stdout))
300}
301
302fn build_omit(omit: &[String]) -> String {
309 [TEST_OMIT.to_string(), SUPPORT_OMIT.to_string()]
310 .into_iter()
311 .chain(omit.iter().cloned())
312 .collect::<Vec<_>>()
313 .join(",")
314}
315
316const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
330const TS_TEST_EXCLUDE: &str = "**/*.test.*";
334const TS_DECL_EXCLUDE: &str = "**/*.d.{ts,mts,cts}";
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct TypeScriptThresholds {
340 pub lines: u8,
341 pub branches: u8,
342 pub functions: u8,
343 pub statements: u8,
344}
345
346#[derive(Debug, Clone, Copy, Deserialize)]
349pub struct VitestReport {
350 pub total: VitestTotals,
351}
352
353#[derive(Debug, Clone, Copy, Deserialize)]
356pub struct VitestTotals {
357 pub lines: VitestMetric,
358 pub branches: VitestMetric,
359 pub functions: VitestMetric,
360 pub statements: VitestMetric,
361}
362
363#[derive(Debug, Clone, Copy, Deserialize)]
366pub struct VitestMetric {
367 #[serde(deserialize_with = "deserialize_pct")]
370 pub pct: Option<f64>,
371 pub total: u64,
373}
374
375fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
379where
380 D: serde::Deserializer<'de>,
381{
382 struct PctVisitor;
383 impl serde::de::Visitor<'_> for PctVisitor {
384 type Value = Option<f64>;
385
386 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
387 f.write_str("a coverage percent number or the string \"Unknown\"")
388 }
389
390 fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
391 Ok(Some(value))
392 }
393
394 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
397 Ok(Some(value as f64))
398 }
399
400 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
403 Ok(None)
404 }
405 }
406 deserializer.deserialize_any(PctVisitor)
407}
408
409pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
411 serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
412}
413
414pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
423 let total = &report.total;
424 if total.lines.total == 0 {
428 return Outcome::Fail(
429 "the unit suite measured no code — check the path and that the suite runs".to_string(),
430 );
431 }
432 let checks = [
433 ("lines", total.lines, thresholds.lines),
434 ("branches", total.branches, thresholds.branches),
435 ("functions", total.functions, thresholds.functions),
436 ("statements", total.statements, thresholds.statements),
437 ];
438 let mut shortfalls = Vec::new();
439 for (name, metric, required) in checks {
440 let actual = metric.pct.unwrap_or(100.0);
443 if actual + 1e-9 < f64::from(required) {
446 shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
447 }
448 }
449 if shortfalls.is_empty() {
450 Outcome::Pass
451 } else {
452 Outcome::Fail(format!(
453 "coverage below thresholds: {}",
454 shortfalls.join(", ")
455 ))
456 }
457}
458
459pub fn measure_typescript(
469 root: &Path,
470 thresholds: TypeScriptThresholds,
471 exclude: &[String],
472) -> Result<Outcome> {
473 let report = run_vitest(root, exclude)?;
474 Ok(evaluate_typescript(&report, thresholds))
475}
476
477struct ReportDir(PathBuf);
481
482impl ReportDir {
483 fn new() -> Self {
484 static COUNTER: AtomicU64 = AtomicU64::new(0);
485 let name = format!(
486 "testing-conventions-vitest-{}-{}",
487 std::process::id(),
488 COUNTER.fetch_add(1, Ordering::Relaxed),
489 );
490 ReportDir(std::env::temp_dir().join(name))
491 }
492}
493
494impl Drop for ReportDir {
495 fn drop(&mut self) {
496 let _ = std::fs::remove_dir_all(&self.0);
497 }
498}
499
500fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
502 let json = run_vitest_coverage(root, exclude, "json-summary", "coverage-summary.json")?;
503 parse_vitest_report(&json)
504}
505
506fn run_vitest_coverage(
519 root: &Path,
520 exclude: &[String],
521 reporter: &str,
522 report_file: &str,
523) -> Result<String> {
524 let reports = ReportDir::new();
525
526 let mut command = Command::new("npx");
527 command
528 .current_dir(root)
529 .args(["--yes", "vitest", "run", "--no-cache"])
530 .args(["--coverage.enabled", "--coverage.provider=v8"])
531 .arg(format!("--coverage.reporter={reporter}"))
532 .arg("--coverage.all=true")
533 .arg(format!(
534 "--coverage.reportsDirectory={}",
535 reports.0.display()
536 ))
537 .arg(format!("--coverage.include={TS_INCLUDE}"))
538 .arg(format!("--coverage.exclude={TS_TEST_EXCLUDE}"))
539 .arg(format!("--coverage.exclude={TS_DECL_EXCLUDE}"));
540 for path in exclude {
541 command.arg(format!("--coverage.exclude={path}"));
542 }
543 let run = command.env("CI", "1").output().context(
545 "running `npx vitest run --coverage` (are vitest and @vitest/coverage-v8 installed?)",
546 )?;
547 if !run.status.success() {
548 bail!(
549 "the unit suite did not run cleanly under vitest in `{}`:\n{}{}",
550 root.display(),
551 String::from_utf8_lossy(&run.stdout),
552 String::from_utf8_lossy(&run.stderr),
553 );
554 }
555
556 let path = reports.0.join(report_file);
557 std::fs::read_to_string(&path).with_context(|| {
558 format!(
559 "reading vitest coverage report `{}` (did the run produce a {reporter} report?)",
560 path.display()
561 )
562 })
563}
564
565pub fn measure_patch_typescript(
585 root: &Path,
586 exclude: &[String],
587) -> Result<BTreeMap<String, BTreeSet<u64>>> {
588 let json = run_vitest_coverage(root, exclude, "json", "coverage-final.json")?;
589 uncovered_istanbul_lines(&json)
590}
591
592#[derive(Debug, Clone, Deserialize)]
596struct IstanbulFile {
597 #[serde(rename = "statementMap", default)]
600 statement_map: BTreeMap<String, IstanbulSpan>,
601 #[serde(default)]
603 s: BTreeMap<String, u64>,
604 #[serde(rename = "branchMap", default)]
607 branch_map: BTreeMap<String, IstanbulBranch>,
608 #[serde(default)]
610 b: BTreeMap<String, Vec<u64>>,
611}
612
613#[derive(Debug, Clone, Deserialize)]
615struct IstanbulSpan {
616 start: IstanbulPos,
617 end: IstanbulPos,
618}
619
620#[derive(Debug, Clone, Deserialize)]
622struct IstanbulPos {
623 line: u64,
624}
625
626#[derive(Debug, Clone, Deserialize)]
629struct IstanbulBranch {
630 loc: IstanbulSpan,
631}
632
633fn uncovered_istanbul_lines(json: &str) -> Result<BTreeMap<String, BTreeSet<u64>>> {
640 let files: BTreeMap<String, IstanbulFile> = serde_json::from_str(json)
641 .context("parsing vitest coverage-final (Istanbul) JSON report")?;
642 let mut out = BTreeMap::new();
643 for (path, file) in files {
644 let mut lines = BTreeSet::new();
645 for (id, span) in &file.statement_map {
648 if file.s.get(id) == Some(&0) {
649 lines.extend(span.start.line..=span.end.line);
650 }
651 }
652 for (id, branch) in &file.branch_map {
655 if file.b.get(id).is_some_and(|counts| counts.contains(&0)) {
656 lines.insert(branch.loc.start.line);
657 }
658 }
659 out.insert(path, lines);
660 }
661 Ok(out)
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
669 CoverageReport {
670 totals: Totals {
671 percent_covered,
672 num_branches,
673 },
674 files: BTreeMap::new(),
675 }
676 }
677
678 #[test]
679 fn passes_when_total_meets_the_floor() {
680 assert_eq!(
681 evaluate(
682 &report(100.0, 12),
683 Thresholds {
684 fail_under: 100,
685 branch: true
686 }
687 ),
688 Outcome::Pass
689 );
690 }
691
692 #[test]
693 fn fails_when_total_is_below_the_floor() {
694 assert!(matches!(
695 evaluate(
696 &report(80.0, 12),
697 Thresholds {
698 fail_under: 100,
699 branch: true
700 }
701 ),
702 Outcome::Fail(_)
703 ));
704 }
705
706 #[test]
707 fn fails_when_branch_required_but_unmeasured() {
708 assert!(matches!(
710 evaluate(
711 &report(100.0, 0),
712 Thresholds {
713 fail_under: 90,
714 branch: true
715 }
716 ),
717 Outcome::Fail(_)
718 ));
719 }
720
721 #[test]
722 fn parses_a_coverage_py_report() {
723 let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
724 let report = parse_report(json).expect("valid coverage.py json");
725 assert_eq!(report.totals.percent_covered, 91.5);
726 assert_eq!(report.totals.num_branches, 8);
727 }
728
729 #[test]
730 fn parses_the_per_file_block_for_patch_coverage() {
731 let json = r#"{
734 "files": {
735 "widget.py": {
736 "executed_lines": [1, 2, 3, 4, 6],
737 "summary": {"percent_covered": 85.0},
738 "missing_lines": [5],
739 "excluded_lines": [],
740 "missing_branches": [[4, 5]]
741 }
742 },
743 "totals": {"percent_covered": 85.0, "num_branches": 4}
744 }"#;
745 let report = parse_report(json).expect("valid coverage.py json with files");
746 let widget = report.files.get("widget.py").expect("widget.py is present");
747 assert_eq!(widget.missing_lines, vec![5]);
748 assert_eq!(widget.missing_branches, vec![vec![4, 5]]);
749 assert_eq!(report.totals.percent_covered, 85.0);
751 }
752
753 #[test]
754 fn a_report_without_a_files_block_parses_with_an_empty_map() {
755 let report = parse_report(r#"{"totals":{"percent_covered":100.0,"num_branches":2}}"#)
757 .expect("valid coverage.py json");
758 assert!(report.files.is_empty());
759 }
760
761 #[test]
762 fn omit_is_the_test_and_support_globs_when_nothing_is_exempt() {
763 assert_eq!(build_omit(&[]), "*_test.py,*conftest.py");
764 }
765
766 #[test]
767 fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
768 let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
770 assert_eq!(
771 build_omit(&exempt),
772 "*_test.py,*conftest.py,pkg/gen.py,shim.py"
773 );
774 }
775
776 #[test]
779 fn ratchet_passes_when_coverage_holds_at_the_baseline() {
780 assert_eq!(evaluate_ratchet(100.0, Some(100.0)), Outcome::Pass);
781 }
782
783 #[test]
784 fn ratchet_passes_when_coverage_improves_over_the_baseline() {
785 assert_eq!(evaluate_ratchet(92.0, Some(85.0)), Outcome::Pass);
786 }
787
788 #[test]
789 fn ratchet_fails_on_a_drop_below_the_baseline() {
790 assert!(matches!(
791 evaluate_ratchet(86.0, Some(90.0)),
792 Outcome::Fail(message) if message.contains("regressed") && message.contains("90")
793 ));
794 }
795
796 #[test]
797 fn ratchet_is_vacuous_without_a_recorded_baseline() {
798 assert_eq!(evaluate_ratchet(10.0, None), Outcome::Pass);
799 }
800
801 #[test]
802 fn ratchet_tolerates_float_noise_at_the_baseline() {
803 assert_eq!(evaluate_ratchet(99.999_999_999, Some(100.0)), Outcome::Pass);
804 }
805
806 static BASELINE_COUNTER: AtomicU64 = AtomicU64::new(0);
807
808 struct TempDir(PathBuf);
811
812 impl TempDir {
813 fn new() -> Self {
814 let dir = std::env::temp_dir().join(format!(
815 "tc-baseline-{}-{}",
816 std::process::id(),
817 BASELINE_COUNTER.fetch_add(1, Ordering::Relaxed),
818 ));
819 std::fs::create_dir_all(&dir).unwrap();
820 TempDir(dir)
821 }
822 }
823
824 impl Drop for TempDir {
825 fn drop(&mut self) {
826 let _ = std::fs::remove_dir_all(&self.0);
827 }
828 }
829
830 #[test]
831 fn read_baseline_is_none_when_the_file_is_absent() {
832 let dir = TempDir::new();
833 assert!(read_baseline(&dir.0).unwrap().is_none());
834 }
835
836 #[test]
837 fn read_baseline_parses_the_recorded_python_total() {
838 let dir = TempDir::new();
839 std::fs::write(
840 dir.0.join(BASELINE_PATH),
841 r#"{"python":{"percent_covered":91.5}}"#,
842 )
843 .unwrap();
844 let baseline = read_baseline(&dir.0)
845 .unwrap()
846 .expect("a baseline file is present");
847 assert_eq!(baseline.python.unwrap().percent_covered, 91.5);
848 }
849
850 #[test]
851 fn read_baseline_errors_on_a_malformed_file() {
852 let dir = TempDir::new();
853 std::fs::write(dir.0.join(BASELINE_PATH), "{ not json").unwrap();
854 assert!(read_baseline(&dir.0).is_err());
855 }
856
857 fn metric(pct: f64) -> VitestMetric {
860 VitestMetric {
861 pct: Some(pct),
862 total: 10,
863 }
864 }
865
866 fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
867 VitestReport {
868 total: VitestTotals {
869 lines: metric(lines),
870 branches: metric(branches),
871 functions: metric(functions),
872 statements: metric(statements),
873 },
874 }
875 }
876
877 const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
878 lines: 100,
879 branches: 100,
880 functions: 100,
881 statements: 100,
882 };
883 const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
884 lines: 80,
885 branches: 75,
886 functions: 80,
887 statements: 80,
888 };
889
890 #[test]
891 fn typescript_passes_when_every_metric_meets_its_floor() {
892 assert_eq!(
893 evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
894 Outcome::Pass
895 );
896 }
897
898 #[test]
899 fn typescript_fails_on_the_one_metric_below_its_floor() {
900 let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
904 assert!(
905 matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
906 "got: {outcome:?}"
907 );
908 }
909
910 #[test]
911 fn typescript_fail_message_names_every_metric_below() {
912 let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
913 assert!(
914 matches!(&outcome, Outcome::Fail(message)
915 if message.contains("lines")
916 && message.contains("branches")
917 && message.contains("functions")
918 && message.contains("statements")),
919 "got: {outcome:?}"
920 );
921 }
922
923 #[test]
924 fn typescript_tolerates_float_noise_at_the_floor() {
925 assert_eq!(
927 evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
928 Outcome::Pass
929 );
930 }
931
932 #[test]
933 fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
934 let report = VitestReport {
937 total: VitestTotals {
938 lines: metric(100.0),
939 branches: VitestMetric {
940 pct: None,
941 total: 0,
942 },
943 functions: metric(100.0),
944 statements: metric(100.0),
945 },
946 };
947 assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
948 }
949
950 #[test]
951 fn typescript_fails_a_vacuous_run_that_measured_no_code() {
952 let nothing = VitestMetric {
955 pct: None,
956 total: 0,
957 };
958 let report = VitestReport {
959 total: VitestTotals {
960 lines: nothing,
961 branches: nothing,
962 functions: nothing,
963 statements: nothing,
964 },
965 };
966 let outcome = evaluate_typescript(&report, TS_MID);
967 assert!(
968 matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
969 "got: {outcome:?}"
970 );
971 }
972
973 #[test]
974 fn parses_a_vitest_summary_report() {
975 let json = r#"{
978 "total": {
979 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
980 "statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
981 "functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
982 "branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
983 "branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
984 },
985 "/abs/widget.ts": {
986 "lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
987 }
988 }"#;
989 let report = parse_vitest_report(json).expect("valid vitest json-summary");
990 assert_eq!(report.total.lines.pct, Some(80.0));
992 assert_eq!(report.total.branches.pct, Some(66.66));
993 assert_eq!(report.total.functions.total, 2);
994 }
995
996 #[test]
997 fn parses_an_unknown_pct_as_unmeasured() {
998 let json = r#"{"total": {
999 "lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1000 "statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1001 "functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
1002 "branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
1003 }}"#;
1004 let report = parse_vitest_report(json).expect("valid vitest json-summary");
1005 assert_eq!(report.total.lines.pct, None);
1006 assert_eq!(report.total.lines.total, 0);
1007 }
1008
1009 #[test]
1010 fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
1011 let json = r#"{"total":{
1014 "lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
1015 "statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1016 "functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
1017 "branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
1018 }}"#;
1019 assert!(parse_vitest_report(json).is_err());
1020 }
1021
1022 #[test]
1025 fn istanbul_flags_an_unexecuted_statement() {
1026 let json = r#"{"/abs/widget.ts":{
1028 "statementMap":{"0":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}},
1029 "1":{"start":{"line":2,"column":2},"end":{"line":2,"column":20}}},
1030 "s":{"0":1,"1":0},
1031 "branchMap":{},"b":{}
1032 }}"#;
1033 let out = uncovered_istanbul_lines(json).unwrap();
1034 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([2]));
1035 }
1036
1037 #[test]
1038 fn istanbul_flags_an_untaken_branch_source() {
1039 let json = r#"{"/abs/widget.ts":{
1042 "statementMap":{"0":{"start":{"line":3,"column":2},"end":{"line":3,"column":20}}},
1043 "s":{"0":5},
1044 "branchMap":{"0":{"loc":{"start":{"line":3,"column":2},"end":{"line":3,"column":40}}}},
1045 "b":{"0":[4,0]}
1046 }}"#;
1047 let out = uncovered_istanbul_lines(json).unwrap();
1048 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([3]));
1049 }
1050
1051 #[test]
1052 fn istanbul_v8_single_arm_branch_counts_as_uncovered() {
1053 let json = r#"{"/abs/widget.ts":{
1056 "statementMap":{},"s":{},
1057 "branchMap":{"0":{"loc":{"start":{"line":7,"column":0},"end":{"line":7,"column":3}}}},
1058 "b":{"0":[0]}
1059 }}"#;
1060 let out = uncovered_istanbul_lines(json).unwrap();
1061 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([7]));
1062 }
1063
1064 #[test]
1065 fn istanbul_spans_every_line_of_an_unexecuted_multiline_statement() {
1066 let json = r#"{"/abs/widget.ts":{
1068 "statementMap":{"0":{"start":{"line":4,"column":2},"end":{"line":6,"column":3}}},
1069 "s":{"0":0},
1070 "branchMap":{},"b":{}
1071 }}"#;
1072 let out = uncovered_istanbul_lines(json).unwrap();
1073 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([4, 5, 6]));
1074 }
1075
1076 #[test]
1077 fn istanbul_fully_covered_file_has_no_uncovered_lines() {
1078 let json = r#"{"/abs/widget.ts":{
1079 "statementMap":{"0":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}}},
1080 "s":{"0":3},
1081 "branchMap":{"0":{"loc":{"start":{"line":1,"column":0},"end":{"line":1,"column":40}}}},
1082 "b":{"0":[2,1]}
1083 }}"#;
1084 let out = uncovered_istanbul_lines(json).unwrap();
1085 assert!(out["/abs/widget.ts"].is_empty());
1086 }
1087
1088 #[test]
1089 fn istanbul_widget_report_flags_statement_and_branch_lines() {
1090 let json = r#"{"/abs/widget.ts":{
1094 "statementMap":{
1095 "0":{"start":{"line":1,"column":0},"end":{"line":1,"column":43}},
1096 "1":{"start":{"line":2,"column":2},"end":{"line":2,"column":25}},
1097 "2":{"start":{"line":3,"column":2},"end":{"line":3,"column":16}},
1098 "3":{"start":{"line":4,"column":4},"end":{"line":4,"column":20}},
1099 "4":{"start":{"line":5,"column":2},"end":{"line":5,"column":3}},
1100 "5":{"start":{"line":6,"column":2},"end":{"line":6,"column":15}}
1101 },
1102 "s":{"0":1,"1":2,"2":2,"3":0,"4":0,"5":1},
1103 "branchMap":{
1104 "0":{"loc":{"start":{"line":2,"column":2},"end":{"line":2,"column":25}}},
1105 "1":{"loc":{"start":{"line":3,"column":2},"end":{"line":3,"column":16}}}
1106 },
1107 "b":{"0":[2],"1":[0]}
1108 }}"#;
1109 let out = uncovered_istanbul_lines(json).unwrap();
1110 assert_eq!(out["/abs/widget.ts"], BTreeSet::from([3, 4, 5]));
1111 }
1112
1113 #[test]
1114 fn istanbul_malformed_json_is_an_error() {
1115 assert!(uncovered_istanbul_lines("{ not json").is_err());
1116 }
1117}