1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum CheckStatus {
15 Passed,
17 Failed,
19 TimedOut,
21 Skipped,
23 Error,
25}
26
27impl CheckStatus {
28 #[must_use]
30 pub const fn is_failure(self) -> bool {
31 matches!(self, Self::Failed | Self::TimedOut | Self::Error)
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
37#[serde(rename_all = "snake_case")]
38pub enum TestOutcome {
39 Passed,
41 Failed,
43 Skipped,
45 Error,
48}
49
50impl TestOutcome {
51 #[must_use]
53 pub const fn is_failing(self) -> bool {
54 matches!(self, Self::Failed | Self::Error)
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60pub struct TestResult {
61 pub id: String,
63 pub outcome: TestOutcome,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
69pub struct CheckResult {
70 pub name: String,
72 pub argv: Vec<String>,
74 pub status: CheckStatus,
76 pub exit_code: Option<i32>,
78 pub duration_ms: u64,
80 pub junit: bool,
82 pub tests: Option<Vec<TestResult>>,
84 pub stdout_tail: String,
86 pub stderr_tail: String,
88 pub truncated: bool,
90 pub note: Option<String>,
92 #[serde(default)]
95 pub flaky: bool,
96}
97
98impl CheckResult {
99 #[must_use]
101 pub fn test_counts(&self) -> Option<(usize, usize, usize)> {
102 self.tests.as_ref().map(|tests| {
103 let n = |o| tests.iter().filter(|t| t.outcome == o).count();
104 (
105 n(TestOutcome::Passed),
106 n(TestOutcome::Failed) + n(TestOutcome::Error),
107 n(TestOutcome::Skipped),
108 )
109 })
110 }
111
112 #[must_use]
116 pub fn is_evidence(&self) -> bool {
117 !self.flaky
118 && self.status == CheckStatus::Passed
119 && (!self.junit
120 || self
121 .tests
122 .as_ref()
123 .is_some_and(|t| t.iter().any(|t| t.outcome == TestOutcome::Passed)))
124 }
125
126 fn failing_tests(&self) -> BTreeSet<&str> {
127 self.tests
128 .iter()
129 .flatten()
130 .filter(|t| t.outcome.is_failing())
131 .map(|t| t.id.as_str())
132 .collect()
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
138pub struct VerifyReport {
139 pub root: String,
141 pub tier: String,
143 pub checks: Vec<CheckResult>,
145 pub comparison: Option<Comparison>,
147 pub passed: bool,
149}
150
151#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
153pub struct Comparison {
154 pub regressions: Vec<Regression>,
156 pub pre_existing: Vec<String>,
158 pub new_tests: Vec<String>,
160 pub count_before: usize,
162 pub count_after: usize,
164 pub failing_as_before: Vec<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
171pub struct Regression {
172 pub id: String,
174 pub now: String,
176}
177
178#[must_use]
180pub fn compare(baseline: &VerifyReport, current: &VerifyReport) -> Comparison {
181 compare_except(baseline, current, &[])
182}
183
184#[must_use]
188pub fn test_of(id: &str, files: &[String]) -> bool {
189 files.iter().any(|f| {
190 let name = f.rsplit('/').next().unwrap_or(f);
191 let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
192 id.contains(f.as_str())
193 || id
194 .split(['/', ':', ' ', '>'])
195 .any(|seg| seg == name || seg.split('.').any(|part| part == stem))
196 })
197}
198
199#[must_use]
203pub fn compare_except(
204 baseline: &VerifyReport,
205 current: &VerifyReport,
206 oracle: &[String],
207) -> Comparison {
208 let index = |r: &VerifyReport| -> BTreeMap<String, TestOutcome> {
209 r.checks
210 .iter()
211 .flat_map(|c| {
212 c.tests
213 .iter()
214 .flatten()
215 .map(move |t| (format!("{}/{}", c.name, t.id), t.outcome))
216 })
217 .collect()
218 };
219 let before = index(baseline);
220 let after = index(current);
221
222 let mut cmp = Comparison {
223 count_before: before.len(),
224 count_after: after.len(),
225 ..Comparison::default()
226 };
227 for (id, was) in &before {
228 match (was, after.get(id)) {
229 (TestOutcome::Passed, Some(TestOutcome::Passed)) => {}
230 (TestOutcome::Passed, Some(now)) => cmp.regressions.push(Regression {
231 id: id.clone(),
232 now: match now {
233 TestOutcome::Failed => "failed",
234 TestOutcome::Error => "error",
235 _ => "skipped",
236 }
237 .to_owned(),
238 }),
239 (TestOutcome::Passed, None) => cmp.regressions.push(Regression {
240 id: id.clone(),
241 now: "missing".to_owned(),
242 }),
243 (was, Some(now)) if was.is_failing() && now.is_failing() => {
244 cmp.pre_existing.push(id.clone());
245 }
246 _ => {}
247 }
248 }
249 cmp.new_tests = after
250 .keys()
251 .filter(|id| !before.contains_key(*id))
252 .cloned()
253 .collect();
254
255 for c in current
259 .checks
260 .iter()
261 .filter(|c| c.status == CheckStatus::Failed)
262 {
263 let Some(old) = baseline.checks.iter().find(|b| b.name == c.name) else {
264 continue;
265 };
266 let now = c.failing_tests();
267 let old_failing = old.failing_tests();
268 let unexplained = now
269 .iter()
270 .filter(|t| !old_failing.contains(*t) && !test_of(t, oracle))
271 .count();
272 if c.tests.is_some()
273 && !now.is_empty()
274 && unexplained == 0
275 && (old.status == CheckStatus::Failed || now.iter().all(|t| test_of(t, oracle)))
276 {
277 cmp.failing_as_before.push(c.name.clone());
278 }
279 }
280 cmp
281}
282
283fn failures<'r>(checks: &'r [CheckResult], cmp: Option<&Comparison>) -> Vec<&'r CheckResult> {
285 checks
286 .iter()
287 .filter(|c| c.status.is_failure())
288 .filter(|c| !cmp.is_some_and(|cmp| cmp.failing_as_before.contains(&c.name)))
289 .collect()
290}
291
292#[must_use]
294pub fn passes(checks: &[CheckResult], cmp: Option<&Comparison>) -> bool {
295 failures(checks, cmp).is_empty() && cmp.is_none_or(|c| c.regressions.is_empty())
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
305#[serde(rename_all = "snake_case")]
306pub enum RedClass {
307 Assertion,
309 Absent,
311 BuildError,
313 CollectionError,
315 Timeout,
317 NoTests,
319 Flaky,
321 Unclassified,
323 Passed,
325}
326
327impl RedClass {
328 #[must_use]
330 pub const fn is_red(self) -> bool {
331 matches!(self, Self::Assertion | Self::Absent)
332 }
333}
334
335#[derive(
338 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
339)]
340pub struct RunnerSummary {
341 pub passed: usize,
343 pub failed: usize,
345 pub errors: usize,
347 pub build_failed: bool,
349 pub unresolved_only: bool,
351}
352
353#[must_use]
357pub fn collected_count(result: &CheckResult, summary: Option<&RunnerSummary>) -> Option<usize> {
358 if let Some(tests) = result.tests.as_ref() {
359 return Some(
360 tests
361 .iter()
362 .filter(|t| matches!(t.outcome, TestOutcome::Passed | TestOutcome::Failed))
363 .count(),
364 );
365 }
366 summary
367 .filter(|s| !s.build_failed)
368 .map(|s| s.passed + s.failed)
369}
370
371#[must_use]
374pub fn classify_red(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
375 if result.status == CheckStatus::TimedOut {
376 return RedClass::Timeout;
377 }
378 if let Some(tests) = result.tests.as_ref() {
379 if tests.iter().any(|t| t.outcome == TestOutcome::Failed) {
380 return RedClass::Assertion;
381 }
382 if tests.iter().any(|t| t.outcome == TestOutcome::Error) {
383 return RedClass::CollectionError;
384 }
385 if tests.is_empty() {
386 return RedClass::NoTests;
387 }
388 if result.status == CheckStatus::Passed {
389 return RedClass::Passed;
390 }
391 }
392 if let Some(s) = summary {
393 if s.build_failed {
394 return if s.unresolved_only {
395 RedClass::Absent
396 } else {
397 RedClass::BuildError
398 };
399 }
400 if s.failed > 0 {
401 return RedClass::Assertion;
402 }
403 if s.errors > 0 {
404 return RedClass::CollectionError;
405 }
406 if s.passed == 0 {
407 return RedClass::NoTests;
408 }
409 }
410 match result.status {
411 CheckStatus::Passed => RedClass::Passed,
412 CheckStatus::Error => RedClass::BuildError,
413 _ => RedClass::Unclassified,
414 }
415}
416
417#[must_use]
423pub fn classify_red_added(
424 with: (&CheckResult, Option<&RunnerSummary>),
425 without: (&CheckResult, Option<&RunnerSummary>),
426) -> RedClass {
427 let class = classify_red(with.0, with.1);
428 if class != RedClass::Assertion {
429 return class;
430 }
431 let failing = |r: &CheckResult| {
432 r.tests.as_ref().map(|t| {
433 t.iter()
434 .filter(|t| t.outcome == TestOutcome::Failed)
435 .map(|t| t.id.clone())
436 .collect::<std::collections::BTreeSet<_>>()
437 })
438 };
439 let added = match (failing(with.0), failing(without.0)) {
440 (Some(w), Some(o)) => w.difference(&o).next().is_some(),
441 _ => match (with.1, without.1) {
442 (Some(w), Some(o)) => w.failed > o.failed,
443 (Some(_), None) => classify_red(without.0, None) != RedClass::Assertion,
447 _ => false,
448 },
449 };
450 if added {
451 RedClass::Assertion
452 } else if with.1.is_none() && with.0.tests.is_none() {
453 RedClass::Unclassified
454 } else {
455 RedClass::Passed
456 }
457}
458
459#[must_use]
464pub fn classify_red_each(
465 result: &CheckResult,
466 wanted: &[String],
467) -> Option<Vec<(String, RedClass)>> {
468 if result.status == CheckStatus::TimedOut {
469 return Some(
470 wanted
471 .iter()
472 .map(|w| (w.clone(), RedClass::Timeout))
473 .collect(),
474 );
475 }
476 let tests = result.tests.as_ref()?;
477 Some(
478 wanted
479 .iter()
480 .map(|w| {
481 let found = tests.iter().find(|t| {
482 t.id == *w
483 || t.id.ends_with(&format!("::{w}"))
484 || t.id.ends_with(&format!(".{w}"))
485 });
486 let class = match found.map(|t| t.outcome) {
487 Some(TestOutcome::Failed) => RedClass::Assertion,
488 Some(TestOutcome::Error) => RedClass::CollectionError,
489 Some(TestOutcome::Passed) => RedClass::Passed,
490 Some(TestOutcome::Skipped) | None => RedClass::NoTests,
491 };
492 (w.clone(), class)
493 })
494 .collect(),
495 )
496}
497
498#[must_use]
504pub fn classify_red_one(
505 result: &CheckResult,
506 summary: Option<&RunnerSummary>,
507 test: &str,
508) -> RedClass {
509 if test.is_empty() {
510 if result.tests.as_ref().is_some_and(|t| t.len() > 1) {
512 return RedClass::Unclassified;
513 }
514 } else if let Some(each) = classify_red_each(result, &[test.to_owned()]) {
515 return each.first().map_or(RedClass::NoTests, |(_, c)| *c);
516 }
517 let class = classify_red(result, summary);
518 match summary {
519 Some(s) if !s.build_failed && s.passed + s.failed + s.errors > 1 => RedClass::Unclassified,
520 _ => class,
521 }
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
526#[serde(rename_all = "snake_case", tag = "verdict")]
527pub enum Verdict {
528 Verified,
530 RegressionOnly,
532 Failed {
534 reasons: Vec<String>,
536 },
537}
538
539#[must_use]
541pub fn verdict(task_checks: &[&str], checks: &[CheckResult], cmp: Option<&Comparison>) -> Verdict {
542 let mut reasons: Vec<String> = failures(checks, cmp)
543 .iter()
544 .map(|c| format!("check `{}` {:?}", c.name, c.status).to_lowercase())
545 .collect();
546 if let Some(cmp) = cmp {
547 reasons.extend(
548 cmp.regressions
549 .iter()
550 .map(|r| format!("regression: {} is {}", r.id, r.now)),
551 );
552 }
553 if !reasons.is_empty() {
554 return Verdict::Failed { reasons };
555 }
556 let evidence = checks
557 .iter()
558 .any(|c| task_checks.contains(&c.name.as_str()) && c.is_evidence());
559 if evidence {
560 Verdict::Verified
561 } else {
562 Verdict::RegressionOnly
563 }
564}
565
566#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
569#[serde(rename_all = "snake_case", tag = "outcome")]
570pub enum HeldOut {
571 Off,
573 Absent {
575 reason: String,
577 },
578 Passed {
580 files: usize,
582 },
583 Failed {
585 scenarios: Vec<String>,
587 },
588}
589
590#[must_use]
593pub fn with_held_out(verdict: Verdict, held_out: &HeldOut) -> Verdict {
594 let HeldOut::Failed { scenarios } = held_out else {
595 return verdict;
596 };
597 let mut ids = scenarios.clone();
598 ids.sort();
599 ids.dedup();
600 let reason = format!("held-out acceptance failed: {}", ids.join(", "));
601 match verdict {
602 Verdict::Failed { mut reasons } => {
603 reasons.push(reason);
604 Verdict::Failed { reasons }
605 }
606 Verdict::Verified | Verdict::RegressionOnly => Verdict::Failed {
607 reasons: vec![reason],
608 },
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 #[test]
617 fn each_test_gets_its_own_red_class() {
618 let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
619 r.tests = Some(vec![
620 TestResult {
621 id: "title::empty_title_is_refused".to_owned(),
622 outcome: TestOutcome::Failed,
623 },
624 TestResult {
625 id: "tests.test_title.test_long".to_owned(),
626 outcome: TestOutcome::Passed,
627 },
628 TestResult {
629 id: "setup_broken".to_owned(),
630 outcome: TestOutcome::Error,
631 },
632 ]);
633 let wanted: Vec<String> = [
634 "empty_title_is_refused",
635 "test_long",
636 "setup_broken",
637 "gone",
638 ]
639 .iter()
640 .map(|s| (*s).to_owned())
641 .collect();
642 let classes: Vec<RedClass> = classify_red_each(&r, &wanted)
643 .unwrap_or_default()
644 .into_iter()
645 .map(|(_, c)| c)
646 .collect();
647 assert_eq!(
648 classes,
649 [
650 RedClass::Assertion,
651 RedClass::Passed,
652 RedClass::CollectionError,
653 RedClass::NoTests
654 ]
655 );
656 r.tests = None;
657 assert!(classify_red_each(&r, &wanted).is_none(), "by name instead");
658 }
659
660 #[test]
661 fn one_test_is_red_only_by_its_own_assertion() {
662 let failed = check("acceptance", CheckStatus::Failed, None);
663 let one = RunnerSummary {
664 failed: 1,
665 ..RunnerSummary::default()
666 };
667 assert_eq!(
668 classify_red_one(&failed, Some(&one), "t"),
669 RedClass::Assertion
670 );
671 let many = RunnerSummary {
673 passed: 3,
674 failed: 1,
675 ..RunnerSummary::default()
676 };
677 assert_eq!(
678 classify_red_one(&failed, Some(&many), "t"),
679 RedClass::Unclassified
680 );
681 let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
683 r.tests = Some(vec![
684 TestResult {
685 id: "other::broken".to_owned(),
686 outcome: TestOutcome::Failed,
687 },
688 TestResult {
689 id: "repro::t".to_owned(),
690 outcome: TestOutcome::Passed,
691 },
692 ]);
693 assert_eq!(classify_red_one(&r, None, "t"), RedClass::Passed);
694 assert_eq!(classify_red_one(&r, None, "broken"), RedClass::Assertion);
695 assert_eq!(classify_red_one(&r, None, "gone"), RedClass::NoTests);
696 assert_eq!(classify_red_one(&r, None, ""), RedClass::Unclassified);
698 assert_eq!(
699 classify_red_one(&failed, Some(&many), ""),
700 RedClass::Unclassified
701 );
702 }
703
704 #[test]
705 fn a_file_is_red_only_by_the_failures_it_adds() {
706 use TestOutcome::{Failed, Passed};
707 let without = check(
708 "held-out",
709 CheckStatus::Failed,
710 Some(&[("old::broken", Failed)]),
711 );
712 let borrowed = check(
714 "held-out",
715 CheckStatus::Failed,
716 Some(&[("old::broken", Failed), ("new::variant", Passed)]),
717 );
718 assert_eq!(
719 classify_red_added((&borrowed, None), (&without, None)),
720 RedClass::Passed
721 );
722 let own = check(
724 "held-out",
725 CheckStatus::Failed,
726 Some(&[("old::broken", Failed), ("new::variant", Failed)]),
727 );
728 assert_eq!(
729 classify_red_added((&own, None), (&without, None)),
730 RedClass::Assertion
731 );
732 let plain = |status| check("held-out", status, None);
734 let count = |failed| RunnerSummary {
735 passed: 1,
736 failed,
737 ..RunnerSummary::default()
738 };
739 let (w, o) = (plain(CheckStatus::Failed), plain(CheckStatus::Failed));
740 assert_eq!(
741 classify_red_added((&w, Some(&count(1))), (&o, Some(&count(1)))),
742 RedClass::Passed
743 );
744 assert_eq!(
745 classify_red_added((&w, Some(&count(2))), (&o, Some(&count(1)))),
746 RedClass::Assertion
747 );
748 let refused = plain(CheckStatus::Failed);
750 assert_eq!(
751 classify_red_added((&w, Some(&count(1))), (&refused, None)),
752 RedClass::Assertion
753 );
754 let built = plain(CheckStatus::Error);
756 let broken = RunnerSummary {
757 build_failed: true,
758 ..RunnerSummary::default()
759 };
760 assert_eq!(
761 classify_red_added((&built, Some(&broken)), (&o, Some(&count(1)))),
762 RedClass::BuildError
763 );
764 }
765
766 fn check(
767 name: &str,
768 status: CheckStatus,
769 tests: Option<&[(&str, TestOutcome)]>,
770 ) -> CheckResult {
771 CheckResult {
772 name: name.to_owned(),
773 argv: vec!["x".to_owned()],
774 status,
775 exit_code: Some(i32::from(status != CheckStatus::Passed)),
776 duration_ms: 1,
777 junit: tests.is_some(),
778 tests: tests.map(|t| {
779 t.iter()
780 .map(|(id, o)| TestResult {
781 id: (*id).to_owned(),
782 outcome: *o,
783 })
784 .collect()
785 }),
786 stdout_tail: String::new(),
787 stderr_tail: String::new(),
788 truncated: false,
789 note: None,
790 flaky: false,
791 }
792 }
793
794 fn report(checks: Vec<CheckResult>) -> VerifyReport {
795 VerifyReport {
796 root: "/p".to_owned(),
797 tier: "fast".to_owned(),
798 checks,
799 comparison: None,
800 passed: true,
801 }
802 }
803
804 use CheckStatus::{Failed, Passed};
805 use TestOutcome as T;
806
807 #[test]
808 fn a_test_is_known_by_its_file_in_each_runner_s_words() {
809 let files = [
810 "tests/us1_word_count.rs".to_owned(),
811 "tests/test_title.py".to_owned(),
812 ];
813 assert!(test_of("wordcount::us1_word_count::us1_1_counts", &files));
814 assert!(test_of("tests.test_title.test_long", &files));
815 assert!(test_of("tests/test_title.py::test_long", &files));
816 assert!(!test_of("wordcount::us1_word_counter::other", &files));
817 assert!(!test_of("lib::tests::word_count", &files));
818 }
819
820 #[test]
821 fn the_story_s_oracle_tests_failing_are_never_the_check_s_failure() {
822 let oracle = ["tests/us1_count.rs".to_owned()];
823 let before = report(vec![check("test", Failed, Some(&[]))]);
825 let after = report(vec![check(
826 "test",
827 Failed,
828 Some(&[
829 ("demo::us1_count::one", T::Passed),
830 ("demo::us1_count::two", T::Failed),
831 ]),
832 )]);
833 let cmp = compare_except(&before, &after, &oracle);
834 assert_eq!(cmp.failing_as_before, ["test"]);
835 assert!(passes(&after.checks, Some(&cmp)));
836 let worse = report(vec![check(
838 "test",
839 Failed,
840 Some(&[
841 ("demo::us1_count::two", T::Failed),
842 ("demo::lib::other", T::Failed),
843 ]),
844 )]);
845 assert!(
846 compare_except(&before, &worse, &oracle)
847 .failing_as_before
848 .is_empty()
849 );
850 assert!(compare(&before, &after).failing_as_before.is_empty());
852 }
853
854 #[test]
855 fn comparison_finds_regressions_pre_existing_and_new() {
856 let base = report(vec![check(
857 "test",
858 Failed,
859 Some(&[("a", T::Passed), ("b", T::Passed), ("c", T::Failed)]),
860 )]);
861 let now = report(vec![check(
862 "test",
863 Failed,
864 Some(&[
865 ("a", T::Passed),
866 ("b", T::Failed),
867 ("c", T::Failed),
868 ("d", T::Passed),
869 ]),
870 )]);
871 let cmp = compare(&base, &now);
872 assert_eq!(
873 cmp.regressions,
874 [Regression {
875 id: "test/b".to_owned(),
876 now: "failed".to_owned()
877 }]
878 );
879 assert_eq!(cmp.pre_existing, ["test/c"]);
880 assert_eq!(cmp.new_tests, ["test/d"]);
881 assert_eq!((cmp.count_before, cmp.count_after), (3, 4));
882 assert!(cmp.failing_as_before.is_empty(), "b is a new failure");
883 }
884
885 #[test]
886 fn missing_test_is_a_regression() {
887 let base = report(vec![check(
888 "test",
889 Passed,
890 Some(&[("a", T::Passed), ("b", T::Passed)]),
891 )]);
892 let now = report(vec![check("test", Passed, Some(&[("a", T::Passed)]))]);
893 let cmp = compare(&base, &now);
894 assert_eq!(cmp.regressions[0].now, "missing");
895 assert!(!passes(&now.checks, Some(&cmp)));
896 }
897
898 #[test]
899 fn pre_existing_failures_alone_do_not_fail_against_a_baseline() {
900 let base = report(vec![check(
901 "test",
902 Failed,
903 Some(&[("a", T::Passed), ("c", T::Failed)]),
904 )]);
905 let now = report(vec![check(
906 "test",
907 Failed,
908 Some(&[("a", T::Passed), ("c", T::Failed)]),
909 )]);
910 let cmp = compare(&base, &now);
911 assert_eq!(cmp.failing_as_before, ["test"]);
912 assert!(passes(&now.checks, Some(&cmp)));
913 assert!(
914 !passes(&now.checks, None),
915 "without a baseline a failing check fails"
916 );
917 }
918
919 #[test]
920 fn verdict_levels() {
921 let passing = check("unit", Passed, Some(&[("a", T::Passed)]));
922 let empty = check("unit", Passed, Some(&[]));
923 let lint = check("lint", Passed, None);
924 assert_eq!(
925 verdict(&["unit"], &[passing.clone(), lint.clone()], None),
926 Verdict::Verified
927 );
928 assert_eq!(
929 verdict(&[], &[passing.clone(), lint.clone()], None),
930 Verdict::RegressionOnly
931 );
932 assert_eq!(
933 verdict(&["unit"], &[empty], None),
934 Verdict::RegressionOnly,
935 "zero tests is no evidence"
936 );
937 assert_eq!(
938 verdict(&["lint"], &[lint], None),
939 Verdict::Verified,
940 "a passing command check is evidence"
941 );
942 let failing = check("lint", Failed, None);
943 assert!(matches!(
944 verdict(&["unit"], &[passing, failing], None),
945 Verdict::Failed { .. }
946 ));
947 }
948
949 fn summary(failed: usize, build_failed: bool, unresolved_only: bool) -> RunnerSummary {
950 RunnerSummary {
951 passed: 0,
952 failed,
953 errors: 0,
954 build_failed,
955 unresolved_only,
956 }
957 }
958
959 #[test]
960 fn red_is_an_assertion_or_an_absent_symbol() {
961 use TestOutcome as T;
962 let failed = check("a", CheckStatus::Failed, Some(&[("t", T::Failed)]));
963 assert_eq!(classify_red(&failed, None), RedClass::Assertion);
964 let errored = check("a", CheckStatus::Failed, Some(&[("t", T::Error)]));
965 assert_eq!(classify_red(&errored, None), RedClass::CollectionError);
966 let empty = check("a", CheckStatus::Failed, Some(&[]));
967 assert_eq!(classify_red(&empty, None), RedClass::NoTests);
968 let plain = check("a", CheckStatus::Failed, None);
969 assert_eq!(classify_red(&plain, None), RedClass::Unclassified);
970 assert_eq!(
971 classify_red(&plain, Some(&summary(2, false, false))),
972 RedClass::Assertion
973 );
974 assert_eq!(
975 classify_red(&plain, Some(&summary(0, true, false))),
976 RedClass::BuildError
977 );
978 assert_eq!(
979 classify_red(&plain, Some(&summary(0, true, true))),
980 RedClass::Absent
981 );
982 assert_eq!(
983 classify_red(&plain, Some(&summary(0, false, false))),
984 RedClass::NoTests
985 );
986 let timed_out = check("a", CheckStatus::TimedOut, None);
987 assert_eq!(
988 classify_red(&timed_out, Some(&summary(1, false, false))),
989 RedClass::Timeout
990 );
991 let passed = check("a", CheckStatus::Passed, Some(&[("t", T::Passed)]));
992 assert_eq!(classify_red(&passed, None), RedClass::Passed);
993 assert!(RedClass::Assertion.is_red() && RedClass::Absent.is_red());
994 assert!(!RedClass::BuildError.is_red() && !RedClass::Flaky.is_red());
995 }
996
997 #[test]
998 fn a_flaky_pass_is_not_evidence_and_errors_regress() {
999 use TestOutcome as T;
1000 let mut c = check("acceptance", CheckStatus::Passed, None);
1001 assert!(c.is_evidence());
1002 c.flaky = true;
1003 assert!(!c.is_evidence());
1004 assert_eq!(
1005 verdict(&["acceptance"], &[c], None),
1006 Verdict::RegressionOnly
1007 );
1008
1009 let before = report(vec![check(
1010 "t",
1011 CheckStatus::Passed,
1012 Some(&[("x", T::Passed)]),
1013 )]);
1014 let after = report(vec![check(
1015 "t",
1016 CheckStatus::Failed,
1017 Some(&[("x", T::Error)]),
1018 )]);
1019 let cmp = compare(&before, &after);
1020 assert_eq!(cmp.regressions[0].now, "error");
1021 let again = compare(&after, &after);
1022 assert_eq!(again.pre_existing, ["t/x"]);
1023 }
1024
1025 mod properties {
1026 use super::*;
1027 use proptest::prelude::*;
1028
1029 fn statuses() -> impl Strategy<Value = CheckStatus> {
1030 prop_oneof![
1031 Just(CheckStatus::Passed),
1032 Just(CheckStatus::Failed),
1033 Just(CheckStatus::TimedOut),
1034 Just(CheckStatus::Skipped),
1035 Just(CheckStatus::Error),
1036 ]
1037 }
1038
1039 fn outcomes() -> impl Strategy<Value = TestOutcome> {
1040 prop_oneof![
1041 Just(T::Passed),
1042 Just(T::Failed),
1043 Just(T::Skipped),
1044 Just(T::Error)
1045 ]
1046 }
1047
1048 fn checks() -> impl Strategy<Value = Vec<CheckResult>> {
1049 prop::collection::vec(
1050 (
1051 statuses(),
1052 prop::option::of(prop::collection::vec(outcomes(), 0..5)),
1053 any::<bool>(),
1054 ),
1055 1..5,
1056 )
1057 .prop_map(|v| {
1058 v.into_iter()
1059 .enumerate()
1060 .map(|(i, (status, tests, flaky))| CheckResult {
1061 name: format!("c{i}"),
1062 argv: vec!["x".to_owned()],
1063 status,
1064 exit_code: None,
1065 duration_ms: 0,
1066 junit: tests.is_some(),
1067 tests: tests.map(|t| {
1068 t.into_iter()
1069 .enumerate()
1070 .map(|(j, outcome)| TestResult {
1071 id: format!("t{j}"),
1072 outcome,
1073 })
1074 .collect()
1075 }),
1076 stdout_tail: String::new(),
1077 stderr_tail: String::new(),
1078 truncated: false,
1079 note: None,
1080 flaky,
1081 })
1082 .collect()
1083 })
1084 }
1085
1086 fn held_outs() -> impl Strategy<Value = HeldOut> {
1087 prop_oneof![
1088 Just(HeldOut::Off),
1089 Just(HeldOut::Absent {
1090 reason: "not_red".to_owned()
1091 }),
1092 (0..4usize).prop_map(|files| HeldOut::Passed { files }),
1093 proptest::collection::vec("US[1-3]-[1-4]", 1..4)
1094 .prop_map(|scenarios| HeldOut::Failed { scenarios }),
1095 ]
1096 }
1097
1098 proptest! {
1099 #[test]
1100 fn a_held_out_failure_is_never_a_pass(cs in checks(), pick in 0..5usize, h in held_outs()) {
1101 let name = format!("c{pick}");
1102 let before = verdict(&[name.as_str()], &cs, None);
1103 let after = with_held_out(before.clone(), &h);
1104 match h {
1105 HeldOut::Failed { .. } => {
1106 let failed = matches!(after, Verdict::Failed { .. });
1107 prop_assert!(failed);
1108 }
1109 _ => prop_assert_eq!(after, before),
1110 }
1111 }
1112
1113 #[test]
1114 fn any_failing_check_means_failed(cs in checks(), pick in 0..5usize) {
1115 let task = [format!("c{pick}")];
1116 let task: Vec<&str> = task.iter().map(String::as_str).collect();
1117 let v = verdict(&task, &cs, None);
1118 let any_failure = cs.iter().any(|c| c.status.is_failure());
1119 prop_assert_eq!(matches!(v, Verdict::Failed { .. }), any_failure);
1120 }
1121
1122 #[test]
1123 fn verified_requires_task_evidence(cs in checks(), pick in 0..5usize) {
1124 let name = format!("c{pick}");
1125 let v = verdict(&[name.as_str()], &cs, None);
1126 if v == Verdict::Verified {
1127 let c = cs.iter().find(|c| c.name == name);
1128 prop_assert!(c.is_some_and(CheckResult::is_evidence));
1129 }
1130 }
1131
1132 #[test]
1133 fn a_flaky_task_check_is_never_verified(cs in checks(), pick in 0..5usize) {
1134 let name = format!("c{pick}");
1135 let v = verdict(&[name.as_str()], &cs, None);
1136 if cs.iter().find(|c| c.name == name).is_some_and(|c| c.flaky) {
1137 prop_assert_ne!(v, Verdict::Verified);
1138 }
1139 }
1140
1141 #[test]
1142 fn red_always_has_a_cause(
1143 cs in checks(),
1144 passed in 0..3usize,
1145 failed in 0..3usize,
1146 errors in 0..3usize,
1147 build_failed in any::<bool>(),
1148 unresolved_only in any::<bool>(),
1149 with_summary in any::<bool>(),
1150 ) {
1151 let summary = RunnerSummary { passed, failed, errors, build_failed, unresolved_only };
1152 let summary = with_summary.then_some(summary);
1153 for c in &cs {
1154 let class = classify_red(c, summary.as_ref());
1155 if class.is_red() {
1156 let assertion_in_report = c
1157 .tests
1158 .iter()
1159 .flatten()
1160 .any(|t| t.outcome == T::Failed);
1161 let by_summary = summary.is_some_and(|s| s.failed > 0 || (s.build_failed && s.unresolved_only));
1162 prop_assert!(assertion_in_report || by_summary);
1163 prop_assert_ne!(c.status, CheckStatus::TimedOut);
1164 }
1165 }
1166 }
1167
1168 #[test]
1169 fn a_run_compared_with_itself_has_no_regressions(cs in checks()) {
1170 let r = report(cs);
1171 let cmp = compare(&r, &r);
1172 prop_assert!(cmp.regressions.is_empty());
1173 prop_assert!(cmp.new_tests.is_empty());
1174 prop_assert_eq!(cmp.count_before, cmp.count_after);
1175 }
1176
1177 #[test]
1178 fn regressions_always_fail_the_verdict(cs in checks()) {
1179 let before = report(cs.clone());
1180 let mut after = report(cs);
1181 for c in &mut after.checks {
1182 if let Some(tests) = &mut c.tests {
1183 tests.clear();
1184 }
1185 }
1186 let cmp = compare(&before, &after);
1187 let had_passing = cmp.count_before > 0
1188 && before.checks.iter().flat_map(|c| c.tests.iter().flatten()).any(|t| t.outcome == T::Passed);
1189 if had_passing {
1190 let failed = matches!(verdict(&[], &after.checks, Some(&cmp)), Verdict::Failed { .. });
1191 prop_assert!(failed);
1192 }
1193 }
1194 }
1195 }
1196}