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