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>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
170pub struct Regression {
171 pub id: String,
173 pub now: String,
175}
176
177#[must_use]
179pub fn compare(baseline: &VerifyReport, current: &VerifyReport) -> Comparison {
180 let index = |r: &VerifyReport| -> BTreeMap<String, TestOutcome> {
181 r.checks
182 .iter()
183 .flat_map(|c| {
184 c.tests
185 .iter()
186 .flatten()
187 .map(move |t| (format!("{}/{}", c.name, t.id), t.outcome))
188 })
189 .collect()
190 };
191 let before = index(baseline);
192 let after = index(current);
193
194 let mut cmp = Comparison {
195 count_before: before.len(),
196 count_after: after.len(),
197 ..Comparison::default()
198 };
199 for (id, was) in &before {
200 match (was, after.get(id)) {
201 (TestOutcome::Passed, Some(TestOutcome::Passed)) => {}
202 (TestOutcome::Passed, Some(now)) => cmp.regressions.push(Regression {
203 id: id.clone(),
204 now: match now {
205 TestOutcome::Failed => "failed",
206 TestOutcome::Error => "error",
207 _ => "skipped",
208 }
209 .to_owned(),
210 }),
211 (TestOutcome::Passed, None) => cmp.regressions.push(Regression {
212 id: id.clone(),
213 now: "missing".to_owned(),
214 }),
215 (was, Some(now)) if was.is_failing() && now.is_failing() => {
216 cmp.pre_existing.push(id.clone());
217 }
218 _ => {}
219 }
220 }
221 cmp.new_tests = after
222 .keys()
223 .filter(|id| !before.contains_key(*id))
224 .cloned()
225 .collect();
226
227 for c in current
231 .checks
232 .iter()
233 .filter(|c| c.status == CheckStatus::Failed)
234 {
235 let Some(old) = baseline.checks.iter().find(|b| b.name == c.name) else {
236 continue;
237 };
238 let now = c.failing_tests();
239 if old.status == CheckStatus::Failed
240 && c.tests.is_some()
241 && !now.is_empty()
242 && now.is_subset(&old.failing_tests())
243 {
244 cmp.failing_as_before.push(c.name.clone());
245 }
246 }
247 cmp
248}
249
250fn failures<'r>(checks: &'r [CheckResult], cmp: Option<&Comparison>) -> Vec<&'r CheckResult> {
252 checks
253 .iter()
254 .filter(|c| c.status.is_failure())
255 .filter(|c| !cmp.is_some_and(|cmp| cmp.failing_as_before.contains(&c.name)))
256 .collect()
257}
258
259#[must_use]
261pub fn passes(checks: &[CheckResult], cmp: Option<&Comparison>) -> bool {
262 failures(checks, cmp).is_empty() && cmp.is_none_or(|c| c.regressions.is_empty())
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
272#[serde(rename_all = "snake_case")]
273pub enum RedClass {
274 Assertion,
276 Absent,
278 BuildError,
280 CollectionError,
282 Timeout,
284 NoTests,
286 Flaky,
288 Unclassified,
290 Passed,
292}
293
294impl RedClass {
295 #[must_use]
297 pub const fn is_red(self) -> bool {
298 matches!(self, Self::Assertion | Self::Absent)
299 }
300}
301
302#[derive(
305 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
306)]
307pub struct RunnerSummary {
308 pub passed: usize,
310 pub failed: usize,
312 pub errors: usize,
314 pub build_failed: bool,
316 pub unresolved_only: bool,
318}
319
320#[must_use]
324pub fn collected_count(result: &CheckResult, summary: Option<&RunnerSummary>) -> Option<usize> {
325 if let Some(tests) = result.tests.as_ref() {
326 return Some(
327 tests
328 .iter()
329 .filter(|t| matches!(t.outcome, TestOutcome::Passed | TestOutcome::Failed))
330 .count(),
331 );
332 }
333 summary
334 .filter(|s| !s.build_failed)
335 .map(|s| s.passed + s.failed)
336}
337
338#[must_use]
341pub fn classify_red(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
342 if result.status == CheckStatus::TimedOut {
343 return RedClass::Timeout;
344 }
345 if let Some(tests) = result.tests.as_ref() {
346 if tests.iter().any(|t| t.outcome == TestOutcome::Failed) {
347 return RedClass::Assertion;
348 }
349 if tests.iter().any(|t| t.outcome == TestOutcome::Error) {
350 return RedClass::CollectionError;
351 }
352 if tests.is_empty() {
353 return RedClass::NoTests;
354 }
355 if result.status == CheckStatus::Passed {
356 return RedClass::Passed;
357 }
358 }
359 if let Some(s) = summary {
360 if s.build_failed {
361 return if s.unresolved_only {
362 RedClass::Absent
363 } else {
364 RedClass::BuildError
365 };
366 }
367 if s.failed > 0 {
368 return RedClass::Assertion;
369 }
370 if s.errors > 0 {
371 return RedClass::CollectionError;
372 }
373 if s.passed == 0 {
374 return RedClass::NoTests;
375 }
376 }
377 match result.status {
378 CheckStatus::Passed => RedClass::Passed,
379 CheckStatus::Error => RedClass::BuildError,
380 _ => RedClass::Unclassified,
381 }
382}
383
384#[must_use]
390pub fn classify_red_added(
391 with: (&CheckResult, Option<&RunnerSummary>),
392 without: (&CheckResult, Option<&RunnerSummary>),
393) -> RedClass {
394 let class = classify_red(with.0, with.1);
395 if class != RedClass::Assertion {
396 return class;
397 }
398 let failing = |r: &CheckResult| {
399 r.tests.as_ref().map(|t| {
400 t.iter()
401 .filter(|t| t.outcome == TestOutcome::Failed)
402 .map(|t| t.id.clone())
403 .collect::<std::collections::BTreeSet<_>>()
404 })
405 };
406 let added = match (failing(with.0), failing(without.0)) {
407 (Some(w), Some(o)) => w.difference(&o).next().is_some(),
408 _ => match (with.1, without.1) {
409 (Some(w), Some(o)) => w.failed > o.failed,
410 (Some(_), None) => classify_red(without.0, None) != RedClass::Assertion,
414 _ => false,
415 },
416 };
417 if added {
418 RedClass::Assertion
419 } else if with.1.is_none() && with.0.tests.is_none() {
420 RedClass::Unclassified
421 } else {
422 RedClass::Passed
423 }
424}
425
426#[must_use]
431pub fn classify_red_each(
432 result: &CheckResult,
433 wanted: &[String],
434) -> Option<Vec<(String, RedClass)>> {
435 if result.status == CheckStatus::TimedOut {
436 return Some(
437 wanted
438 .iter()
439 .map(|w| (w.clone(), RedClass::Timeout))
440 .collect(),
441 );
442 }
443 let tests = result.tests.as_ref()?;
444 Some(
445 wanted
446 .iter()
447 .map(|w| {
448 let found = tests.iter().find(|t| {
449 t.id == *w
450 || t.id.ends_with(&format!("::{w}"))
451 || t.id.ends_with(&format!(".{w}"))
452 });
453 let class = match found.map(|t| t.outcome) {
454 Some(TestOutcome::Failed) => RedClass::Assertion,
455 Some(TestOutcome::Error) => RedClass::CollectionError,
456 Some(TestOutcome::Passed) => RedClass::Passed,
457 Some(TestOutcome::Skipped) | None => RedClass::NoTests,
458 };
459 (w.clone(), class)
460 })
461 .collect(),
462 )
463}
464
465#[must_use]
471pub fn classify_red_one(
472 result: &CheckResult,
473 summary: Option<&RunnerSummary>,
474 test: &str,
475) -> RedClass {
476 if test.is_empty() {
477 if result.tests.as_ref().is_some_and(|t| t.len() > 1) {
479 return RedClass::Unclassified;
480 }
481 } else if let Some(each) = classify_red_each(result, &[test.to_owned()]) {
482 return each.first().map_or(RedClass::NoTests, |(_, c)| *c);
483 }
484 let class = classify_red(result, summary);
485 match summary {
486 Some(s) if !s.build_failed && s.passed + s.failed + s.errors > 1 => RedClass::Unclassified,
487 _ => class,
488 }
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
493#[serde(rename_all = "snake_case", tag = "verdict")]
494pub enum Verdict {
495 Verified,
497 RegressionOnly,
499 Failed {
501 reasons: Vec<String>,
503 },
504}
505
506#[must_use]
508pub fn verdict(task_checks: &[&str], checks: &[CheckResult], cmp: Option<&Comparison>) -> Verdict {
509 let mut reasons: Vec<String> = failures(checks, cmp)
510 .iter()
511 .map(|c| format!("check `{}` {:?}", c.name, c.status).to_lowercase())
512 .collect();
513 if let Some(cmp) = cmp {
514 reasons.extend(
515 cmp.regressions
516 .iter()
517 .map(|r| format!("regression: {} is {}", r.id, r.now)),
518 );
519 }
520 if !reasons.is_empty() {
521 return Verdict::Failed { reasons };
522 }
523 let evidence = checks
524 .iter()
525 .any(|c| task_checks.contains(&c.name.as_str()) && c.is_evidence());
526 if evidence {
527 Verdict::Verified
528 } else {
529 Verdict::RegressionOnly
530 }
531}
532
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
536#[serde(rename_all = "snake_case", tag = "outcome")]
537pub enum HeldOut {
538 Off,
540 Absent {
542 reason: String,
544 },
545 Passed {
547 files: usize,
549 },
550 Failed {
552 scenarios: Vec<String>,
554 },
555}
556
557#[must_use]
560pub fn with_held_out(verdict: Verdict, held_out: &HeldOut) -> Verdict {
561 let HeldOut::Failed { scenarios } = held_out else {
562 return verdict;
563 };
564 let mut ids = scenarios.clone();
565 ids.sort();
566 ids.dedup();
567 let reason = format!("held-out acceptance failed: {}", ids.join(", "));
568 match verdict {
569 Verdict::Failed { mut reasons } => {
570 reasons.push(reason);
571 Verdict::Failed { reasons }
572 }
573 Verdict::Verified | Verdict::RegressionOnly => Verdict::Failed {
574 reasons: vec![reason],
575 },
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn each_test_gets_its_own_red_class() {
585 let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
586 r.tests = Some(vec![
587 TestResult {
588 id: "title::empty_title_is_refused".to_owned(),
589 outcome: TestOutcome::Failed,
590 },
591 TestResult {
592 id: "tests.test_title.test_long".to_owned(),
593 outcome: TestOutcome::Passed,
594 },
595 TestResult {
596 id: "setup_broken".to_owned(),
597 outcome: TestOutcome::Error,
598 },
599 ]);
600 let wanted: Vec<String> = [
601 "empty_title_is_refused",
602 "test_long",
603 "setup_broken",
604 "gone",
605 ]
606 .iter()
607 .map(|s| (*s).to_owned())
608 .collect();
609 let classes: Vec<RedClass> = classify_red_each(&r, &wanted)
610 .unwrap_or_default()
611 .into_iter()
612 .map(|(_, c)| c)
613 .collect();
614 assert_eq!(
615 classes,
616 [
617 RedClass::Assertion,
618 RedClass::Passed,
619 RedClass::CollectionError,
620 RedClass::NoTests
621 ]
622 );
623 r.tests = None;
624 assert!(classify_red_each(&r, &wanted).is_none(), "by name instead");
625 }
626
627 #[test]
628 fn one_test_is_red_only_by_its_own_assertion() {
629 let failed = check("acceptance", CheckStatus::Failed, None);
630 let one = RunnerSummary {
631 failed: 1,
632 ..RunnerSummary::default()
633 };
634 assert_eq!(
635 classify_red_one(&failed, Some(&one), "t"),
636 RedClass::Assertion
637 );
638 let many = RunnerSummary {
640 passed: 3,
641 failed: 1,
642 ..RunnerSummary::default()
643 };
644 assert_eq!(
645 classify_red_one(&failed, Some(&many), "t"),
646 RedClass::Unclassified
647 );
648 let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
650 r.tests = Some(vec![
651 TestResult {
652 id: "other::broken".to_owned(),
653 outcome: TestOutcome::Failed,
654 },
655 TestResult {
656 id: "repro::t".to_owned(),
657 outcome: TestOutcome::Passed,
658 },
659 ]);
660 assert_eq!(classify_red_one(&r, None, "t"), RedClass::Passed);
661 assert_eq!(classify_red_one(&r, None, "broken"), RedClass::Assertion);
662 assert_eq!(classify_red_one(&r, None, "gone"), RedClass::NoTests);
663 assert_eq!(classify_red_one(&r, None, ""), RedClass::Unclassified);
665 assert_eq!(
666 classify_red_one(&failed, Some(&many), ""),
667 RedClass::Unclassified
668 );
669 }
670
671 #[test]
672 fn a_file_is_red_only_by_the_failures_it_adds() {
673 use TestOutcome::{Failed, Passed};
674 let without = check(
675 "held-out",
676 CheckStatus::Failed,
677 Some(&[("old::broken", Failed)]),
678 );
679 let borrowed = check(
681 "held-out",
682 CheckStatus::Failed,
683 Some(&[("old::broken", Failed), ("new::variant", Passed)]),
684 );
685 assert_eq!(
686 classify_red_added((&borrowed, None), (&without, None)),
687 RedClass::Passed
688 );
689 let own = check(
691 "held-out",
692 CheckStatus::Failed,
693 Some(&[("old::broken", Failed), ("new::variant", Failed)]),
694 );
695 assert_eq!(
696 classify_red_added((&own, None), (&without, None)),
697 RedClass::Assertion
698 );
699 let plain = |status| check("held-out", status, None);
701 let count = |failed| RunnerSummary {
702 passed: 1,
703 failed,
704 ..RunnerSummary::default()
705 };
706 let (w, o) = (plain(CheckStatus::Failed), plain(CheckStatus::Failed));
707 assert_eq!(
708 classify_red_added((&w, Some(&count(1))), (&o, Some(&count(1)))),
709 RedClass::Passed
710 );
711 assert_eq!(
712 classify_red_added((&w, Some(&count(2))), (&o, Some(&count(1)))),
713 RedClass::Assertion
714 );
715 let refused = plain(CheckStatus::Failed);
717 assert_eq!(
718 classify_red_added((&w, Some(&count(1))), (&refused, None)),
719 RedClass::Assertion
720 );
721 let built = plain(CheckStatus::Error);
723 let broken = RunnerSummary {
724 build_failed: true,
725 ..RunnerSummary::default()
726 };
727 assert_eq!(
728 classify_red_added((&built, Some(&broken)), (&o, Some(&count(1)))),
729 RedClass::BuildError
730 );
731 }
732
733 fn check(
734 name: &str,
735 status: CheckStatus,
736 tests: Option<&[(&str, TestOutcome)]>,
737 ) -> CheckResult {
738 CheckResult {
739 name: name.to_owned(),
740 argv: vec!["x".to_owned()],
741 status,
742 exit_code: Some(i32::from(status != CheckStatus::Passed)),
743 duration_ms: 1,
744 junit: tests.is_some(),
745 tests: tests.map(|t| {
746 t.iter()
747 .map(|(id, o)| TestResult {
748 id: (*id).to_owned(),
749 outcome: *o,
750 })
751 .collect()
752 }),
753 stdout_tail: String::new(),
754 stderr_tail: String::new(),
755 truncated: false,
756 note: None,
757 flaky: false,
758 }
759 }
760
761 fn report(checks: Vec<CheckResult>) -> VerifyReport {
762 VerifyReport {
763 root: "/p".to_owned(),
764 tier: "fast".to_owned(),
765 checks,
766 comparison: None,
767 passed: true,
768 }
769 }
770
771 use CheckStatus::{Failed, Passed};
772 use TestOutcome as T;
773
774 #[test]
775 fn comparison_finds_regressions_pre_existing_and_new() {
776 let base = report(vec![check(
777 "test",
778 Failed,
779 Some(&[("a", T::Passed), ("b", T::Passed), ("c", T::Failed)]),
780 )]);
781 let now = report(vec![check(
782 "test",
783 Failed,
784 Some(&[
785 ("a", T::Passed),
786 ("b", T::Failed),
787 ("c", T::Failed),
788 ("d", T::Passed),
789 ]),
790 )]);
791 let cmp = compare(&base, &now);
792 assert_eq!(
793 cmp.regressions,
794 [Regression {
795 id: "test/b".to_owned(),
796 now: "failed".to_owned()
797 }]
798 );
799 assert_eq!(cmp.pre_existing, ["test/c"]);
800 assert_eq!(cmp.new_tests, ["test/d"]);
801 assert_eq!((cmp.count_before, cmp.count_after), (3, 4));
802 assert!(cmp.failing_as_before.is_empty(), "b is a new failure");
803 }
804
805 #[test]
806 fn missing_test_is_a_regression() {
807 let base = report(vec![check(
808 "test",
809 Passed,
810 Some(&[("a", T::Passed), ("b", T::Passed)]),
811 )]);
812 let now = report(vec![check("test", Passed, Some(&[("a", T::Passed)]))]);
813 let cmp = compare(&base, &now);
814 assert_eq!(cmp.regressions[0].now, "missing");
815 assert!(!passes(&now.checks, Some(&cmp)));
816 }
817
818 #[test]
819 fn pre_existing_failures_alone_do_not_fail_against_a_baseline() {
820 let base = report(vec![check(
821 "test",
822 Failed,
823 Some(&[("a", T::Passed), ("c", T::Failed)]),
824 )]);
825 let now = report(vec![check(
826 "test",
827 Failed,
828 Some(&[("a", T::Passed), ("c", T::Failed)]),
829 )]);
830 let cmp = compare(&base, &now);
831 assert_eq!(cmp.failing_as_before, ["test"]);
832 assert!(passes(&now.checks, Some(&cmp)));
833 assert!(
834 !passes(&now.checks, None),
835 "without a baseline a failing check fails"
836 );
837 }
838
839 #[test]
840 fn verdict_levels() {
841 let passing = check("unit", Passed, Some(&[("a", T::Passed)]));
842 let empty = check("unit", Passed, Some(&[]));
843 let lint = check("lint", Passed, None);
844 assert_eq!(
845 verdict(&["unit"], &[passing.clone(), lint.clone()], None),
846 Verdict::Verified
847 );
848 assert_eq!(
849 verdict(&[], &[passing.clone(), lint.clone()], None),
850 Verdict::RegressionOnly
851 );
852 assert_eq!(
853 verdict(&["unit"], &[empty], None),
854 Verdict::RegressionOnly,
855 "zero tests is no evidence"
856 );
857 assert_eq!(
858 verdict(&["lint"], &[lint], None),
859 Verdict::Verified,
860 "a passing command check is evidence"
861 );
862 let failing = check("lint", Failed, None);
863 assert!(matches!(
864 verdict(&["unit"], &[passing, failing], None),
865 Verdict::Failed { .. }
866 ));
867 }
868
869 fn summary(failed: usize, build_failed: bool, unresolved_only: bool) -> RunnerSummary {
870 RunnerSummary {
871 passed: 0,
872 failed,
873 errors: 0,
874 build_failed,
875 unresolved_only,
876 }
877 }
878
879 #[test]
880 fn red_is_an_assertion_or_an_absent_symbol() {
881 use TestOutcome as T;
882 let failed = check("a", CheckStatus::Failed, Some(&[("t", T::Failed)]));
883 assert_eq!(classify_red(&failed, None), RedClass::Assertion);
884 let errored = check("a", CheckStatus::Failed, Some(&[("t", T::Error)]));
885 assert_eq!(classify_red(&errored, None), RedClass::CollectionError);
886 let empty = check("a", CheckStatus::Failed, Some(&[]));
887 assert_eq!(classify_red(&empty, None), RedClass::NoTests);
888 let plain = check("a", CheckStatus::Failed, None);
889 assert_eq!(classify_red(&plain, None), RedClass::Unclassified);
890 assert_eq!(
891 classify_red(&plain, Some(&summary(2, false, false))),
892 RedClass::Assertion
893 );
894 assert_eq!(
895 classify_red(&plain, Some(&summary(0, true, false))),
896 RedClass::BuildError
897 );
898 assert_eq!(
899 classify_red(&plain, Some(&summary(0, true, true))),
900 RedClass::Absent
901 );
902 assert_eq!(
903 classify_red(&plain, Some(&summary(0, false, false))),
904 RedClass::NoTests
905 );
906 let timed_out = check("a", CheckStatus::TimedOut, None);
907 assert_eq!(
908 classify_red(&timed_out, Some(&summary(1, false, false))),
909 RedClass::Timeout
910 );
911 let passed = check("a", CheckStatus::Passed, Some(&[("t", T::Passed)]));
912 assert_eq!(classify_red(&passed, None), RedClass::Passed);
913 assert!(RedClass::Assertion.is_red() && RedClass::Absent.is_red());
914 assert!(!RedClass::BuildError.is_red() && !RedClass::Flaky.is_red());
915 }
916
917 #[test]
918 fn a_flaky_pass_is_not_evidence_and_errors_regress() {
919 use TestOutcome as T;
920 let mut c = check("acceptance", CheckStatus::Passed, None);
921 assert!(c.is_evidence());
922 c.flaky = true;
923 assert!(!c.is_evidence());
924 assert_eq!(
925 verdict(&["acceptance"], &[c], None),
926 Verdict::RegressionOnly
927 );
928
929 let before = report(vec![check(
930 "t",
931 CheckStatus::Passed,
932 Some(&[("x", T::Passed)]),
933 )]);
934 let after = report(vec![check(
935 "t",
936 CheckStatus::Failed,
937 Some(&[("x", T::Error)]),
938 )]);
939 let cmp = compare(&before, &after);
940 assert_eq!(cmp.regressions[0].now, "error");
941 let again = compare(&after, &after);
942 assert_eq!(again.pre_existing, ["t/x"]);
943 }
944
945 mod properties {
946 use super::*;
947 use proptest::prelude::*;
948
949 fn statuses() -> impl Strategy<Value = CheckStatus> {
950 prop_oneof![
951 Just(CheckStatus::Passed),
952 Just(CheckStatus::Failed),
953 Just(CheckStatus::TimedOut),
954 Just(CheckStatus::Skipped),
955 Just(CheckStatus::Error),
956 ]
957 }
958
959 fn outcomes() -> impl Strategy<Value = TestOutcome> {
960 prop_oneof![
961 Just(T::Passed),
962 Just(T::Failed),
963 Just(T::Skipped),
964 Just(T::Error)
965 ]
966 }
967
968 fn checks() -> impl Strategy<Value = Vec<CheckResult>> {
969 prop::collection::vec(
970 (
971 statuses(),
972 prop::option::of(prop::collection::vec(outcomes(), 0..5)),
973 any::<bool>(),
974 ),
975 1..5,
976 )
977 .prop_map(|v| {
978 v.into_iter()
979 .enumerate()
980 .map(|(i, (status, tests, flaky))| CheckResult {
981 name: format!("c{i}"),
982 argv: vec!["x".to_owned()],
983 status,
984 exit_code: None,
985 duration_ms: 0,
986 junit: tests.is_some(),
987 tests: tests.map(|t| {
988 t.into_iter()
989 .enumerate()
990 .map(|(j, outcome)| TestResult {
991 id: format!("t{j}"),
992 outcome,
993 })
994 .collect()
995 }),
996 stdout_tail: String::new(),
997 stderr_tail: String::new(),
998 truncated: false,
999 note: None,
1000 flaky,
1001 })
1002 .collect()
1003 })
1004 }
1005
1006 fn held_outs() -> impl Strategy<Value = HeldOut> {
1007 prop_oneof![
1008 Just(HeldOut::Off),
1009 Just(HeldOut::Absent {
1010 reason: "not_red".to_owned()
1011 }),
1012 (0..4usize).prop_map(|files| HeldOut::Passed { files }),
1013 proptest::collection::vec("US[1-3]-[1-4]", 1..4)
1014 .prop_map(|scenarios| HeldOut::Failed { scenarios }),
1015 ]
1016 }
1017
1018 proptest! {
1019 #[test]
1020 fn a_held_out_failure_is_never_a_pass(cs in checks(), pick in 0..5usize, h in held_outs()) {
1021 let name = format!("c{pick}");
1022 let before = verdict(&[name.as_str()], &cs, None);
1023 let after = with_held_out(before.clone(), &h);
1024 match h {
1025 HeldOut::Failed { .. } => {
1026 let failed = matches!(after, Verdict::Failed { .. });
1027 prop_assert!(failed);
1028 }
1029 _ => prop_assert_eq!(after, before),
1030 }
1031 }
1032
1033 #[test]
1034 fn any_failing_check_means_failed(cs in checks(), pick in 0..5usize) {
1035 let task = [format!("c{pick}")];
1036 let task: Vec<&str> = task.iter().map(String::as_str).collect();
1037 let v = verdict(&task, &cs, None);
1038 let any_failure = cs.iter().any(|c| c.status.is_failure());
1039 prop_assert_eq!(matches!(v, Verdict::Failed { .. }), any_failure);
1040 }
1041
1042 #[test]
1043 fn verified_requires_task_evidence(cs in checks(), pick in 0..5usize) {
1044 let name = format!("c{pick}");
1045 let v = verdict(&[name.as_str()], &cs, None);
1046 if v == Verdict::Verified {
1047 let c = cs.iter().find(|c| c.name == name);
1048 prop_assert!(c.is_some_and(CheckResult::is_evidence));
1049 }
1050 }
1051
1052 #[test]
1053 fn a_flaky_task_check_is_never_verified(cs in checks(), pick in 0..5usize) {
1054 let name = format!("c{pick}");
1055 let v = verdict(&[name.as_str()], &cs, None);
1056 if cs.iter().find(|c| c.name == name).is_some_and(|c| c.flaky) {
1057 prop_assert_ne!(v, Verdict::Verified);
1058 }
1059 }
1060
1061 #[test]
1062 fn red_always_has_a_cause(
1063 cs in checks(),
1064 passed in 0..3usize,
1065 failed in 0..3usize,
1066 errors in 0..3usize,
1067 build_failed in any::<bool>(),
1068 unresolved_only in any::<bool>(),
1069 with_summary in any::<bool>(),
1070 ) {
1071 let summary = RunnerSummary { passed, failed, errors, build_failed, unresolved_only };
1072 let summary = with_summary.then_some(summary);
1073 for c in &cs {
1074 let class = classify_red(c, summary.as_ref());
1075 if class.is_red() {
1076 let assertion_in_report = c
1077 .tests
1078 .iter()
1079 .flatten()
1080 .any(|t| t.outcome == T::Failed);
1081 let by_summary = summary.is_some_and(|s| s.failed > 0 || (s.build_failed && s.unresolved_only));
1082 prop_assert!(assertion_in_report || by_summary);
1083 prop_assert_ne!(c.status, CheckStatus::TimedOut);
1084 }
1085 }
1086 }
1087
1088 #[test]
1089 fn a_run_compared_with_itself_has_no_regressions(cs in checks()) {
1090 let r = report(cs);
1091 let cmp = compare(&r, &r);
1092 prop_assert!(cmp.regressions.is_empty());
1093 prop_assert!(cmp.new_tests.is_empty());
1094 prop_assert_eq!(cmp.count_before, cmp.count_after);
1095 }
1096
1097 #[test]
1098 fn regressions_always_fail_the_verdict(cs in checks()) {
1099 let before = report(cs.clone());
1100 let mut after = report(cs);
1101 for c in &mut after.checks {
1102 if let Some(tests) = &mut c.tests {
1103 tests.clear();
1104 }
1105 }
1106 let cmp = compare(&before, &after);
1107 let had_passing = cmp.count_before > 0
1108 && before.checks.iter().flat_map(|c| c.tests.iter().flatten()).any(|t| t.outcome == T::Passed);
1109 if had_passing {
1110 let failed = matches!(verdict(&[], &after.checks, Some(&cmp)), Verdict::Failed { .. });
1111 prop_assert!(failed);
1112 }
1113 }
1114 }
1115 }
1116}