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, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
76pub struct Denials {
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub denied: Vec<String>,
80 #[serde(default, skip_serializing_if = "is_zero")]
82 pub more: usize,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub note: Option<String>,
86}
87
88impl Denials {
89 #[must_use]
91 pub fn is_empty(&self) -> bool {
92 self.denied.is_empty() && self.note.is_none()
93 }
94}
95
96#[expect(clippy::trivially_copy_pass_by_ref, reason = "serde's signature")]
97fn is_zero(n: &usize) -> bool {
98 *n == 0
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
103pub struct CheckResult {
104 pub name: String,
106 pub argv: Vec<String>,
108 pub status: CheckStatus,
110 pub exit_code: Option<i32>,
112 pub duration_ms: u64,
114 pub junit: bool,
116 pub tests: Option<Vec<TestResult>>,
118 pub stdout_tail: String,
120 pub stderr_tail: String,
122 pub truncated: bool,
124 pub note: Option<String>,
126 #[serde(default)]
129 pub flaky: bool,
130 #[serde(default, skip_serializing_if = "Denials::is_empty")]
132 pub sandbox: Denials,
133}
134
135impl CheckResult {
136 #[must_use]
138 pub fn test_counts(&self) -> Option<(usize, usize, usize)> {
139 self.tests.as_ref().map(|tests| {
140 let n = |o| tests.iter().filter(|t| t.outcome == o).count();
141 (
142 n(TestOutcome::Passed),
143 n(TestOutcome::Failed) + n(TestOutcome::Error),
144 n(TestOutcome::Skipped),
145 )
146 })
147 }
148
149 #[must_use]
153 pub fn is_evidence(&self) -> bool {
154 !self.flaky
155 && self.status == CheckStatus::Passed
156 && (!self.junit
157 || self
158 .tests
159 .as_ref()
160 .is_some_and(|t| t.iter().any(|t| t.outcome == TestOutcome::Passed)))
161 }
162
163 fn failing_tests(&self) -> BTreeSet<&str> {
164 self.tests
165 .iter()
166 .flatten()
167 .filter(|t| t.outcome.is_failing())
168 .map(|t| t.id.as_str())
169 .collect()
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
175pub struct VerifyReport {
176 pub root: String,
178 pub tier: String,
180 pub checks: Vec<CheckResult>,
182 pub comparison: Option<Comparison>,
184 pub passed: bool,
186}
187
188#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
190pub struct Comparison {
191 pub regressions: Vec<Regression>,
193 pub pre_existing: Vec<String>,
195 pub new_tests: Vec<String>,
197 pub count_before: usize,
199 pub count_after: usize,
201 pub failing_as_before: Vec<String>,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
208pub struct Regression {
209 pub id: String,
211 pub now: String,
213}
214
215#[must_use]
217pub fn compare(baseline: &VerifyReport, current: &VerifyReport) -> Comparison {
218 compare_except(baseline, current, &[])
219}
220
221#[must_use]
225pub fn test_of(id: &str, files: &[String]) -> bool {
226 files.iter().any(|f| {
227 let name = f.rsplit('/').next().unwrap_or(f);
228 let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
229 id.contains(f.as_str())
230 || id
231 .split(['/', ':', ' ', '>'])
232 .any(|seg| seg == name || seg.split('.').any(|part| part == stem))
233 })
234}
235
236#[must_use]
240pub fn compare_except(
241 baseline: &VerifyReport,
242 current: &VerifyReport,
243 oracle: &[String],
244) -> Comparison {
245 let index = |r: &VerifyReport| -> BTreeMap<String, TestOutcome> {
246 r.checks
247 .iter()
248 .flat_map(|c| {
249 c.tests
250 .iter()
251 .flatten()
252 .map(move |t| (format!("{}/{}", c.name, t.id), t.outcome))
253 })
254 .collect()
255 };
256 let before = index(baseline);
257 let after = index(current);
258
259 let mut cmp = Comparison {
260 count_before: before.len(),
261 count_after: after.len(),
262 ..Comparison::default()
263 };
264 for (id, was) in &before {
265 match (was, after.get(id)) {
266 (TestOutcome::Passed, Some(TestOutcome::Passed)) => {}
267 (TestOutcome::Passed, Some(now)) => cmp.regressions.push(Regression {
268 id: id.clone(),
269 now: match now {
270 TestOutcome::Failed => "failed",
271 TestOutcome::Error => "error",
272 _ => "skipped",
273 }
274 .to_owned(),
275 }),
276 (TestOutcome::Passed, None) => cmp.regressions.push(Regression {
277 id: id.clone(),
278 now: "missing".to_owned(),
279 }),
280 (was, Some(now)) if was.is_failing() && now.is_failing() => {
281 cmp.pre_existing.push(id.clone());
282 }
283 _ => {}
284 }
285 }
286 cmp.new_tests = after
287 .keys()
288 .filter(|id| !before.contains_key(*id))
289 .cloned()
290 .collect();
291
292 for c in current
296 .checks
297 .iter()
298 .filter(|c| c.status == CheckStatus::Failed)
299 {
300 let Some(old) = baseline.checks.iter().find(|b| b.name == c.name) else {
301 continue;
302 };
303 let now = c.failing_tests();
304 let old_failing = old.failing_tests();
305 let unexplained = now
306 .iter()
307 .filter(|t| !old_failing.contains(*t) && !test_of(t, oracle))
308 .count();
309 if c.tests.is_some()
310 && !now.is_empty()
311 && unexplained == 0
312 && (old.status == CheckStatus::Failed || now.iter().all(|t| test_of(t, oracle)))
313 {
314 cmp.failing_as_before.push(c.name.clone());
315 }
316 }
317 cmp
318}
319
320fn failures<'r>(checks: &'r [CheckResult], cmp: Option<&Comparison>) -> Vec<&'r CheckResult> {
322 checks
323 .iter()
324 .filter(|c| c.status.is_failure())
325 .filter(|c| !cmp.is_some_and(|cmp| cmp.failing_as_before.contains(&c.name)))
326 .collect()
327}
328
329#[must_use]
331pub fn passes(checks: &[CheckResult], cmp: Option<&Comparison>) -> bool {
332 failures(checks, cmp).is_empty() && cmp.is_none_or(|c| c.regressions.is_empty())
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
342#[serde(rename_all = "snake_case")]
343pub enum RedClass {
344 Assertion,
346 Absent,
348 BuildError,
350 CollectionError,
352 Timeout,
354 NoTests,
356 Flaky,
358 Unclassified,
360 Passed,
362}
363
364impl RedClass {
365 #[must_use]
367 pub const fn is_red(self) -> bool {
368 matches!(self, Self::Assertion | Self::Absent)
369 }
370}
371
372#[derive(
375 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
376)]
377pub struct RunnerSummary {
378 pub passed: usize,
380 pub failed: usize,
382 pub errors: usize,
384 pub build_failed: bool,
386 pub unresolved_only: bool,
388}
389
390#[must_use]
394pub fn collected_count(result: &CheckResult, summary: Option<&RunnerSummary>) -> Option<usize> {
395 if let Some(tests) = result.tests.as_ref() {
396 return Some(
397 tests
398 .iter()
399 .filter(|t| matches!(t.outcome, TestOutcome::Passed | TestOutcome::Failed))
400 .count(),
401 );
402 }
403 summary
404 .filter(|s| !s.build_failed)
405 .map(|s| s.passed + s.failed)
406}
407
408#[must_use]
411pub fn classify_red(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
412 classified(result, summary)
413}
414
415#[must_use]
421pub fn missing_names(result: &CheckResult) -> Option<Vec<String>> {
422 const PYTHON: &[&str] = &[
423 "ModuleNotFoundError",
424 "ImportError",
425 "AttributeError",
426 "NameError",
427 ];
428 let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
429 let quoted = |s: &str| -> Vec<String> {
430 s.split(['\'', '"'])
431 .skip(1)
432 .step_by(2)
433 .map(str::to_owned)
434 .collect()
435 };
436 let failed: Vec<&str> = text
437 .lines()
438 .map(str::trim)
439 .filter_map(|l| {
440 l.strip_prefix("FAILED ")
441 .or_else(|| l.strip_prefix("ERROR "))
442 })
443 .collect();
444 if !failed.is_empty() {
445 let mut names = Vec::new();
446 for rest in failed {
447 let cause = rest.split_once(" - ").map_or("", |(_, c)| c);
448 let (kind, message) = cause.split_once(':').unwrap_or((cause, ""));
449 let kind = kind.trim();
450 if !PYTHON.contains(&kind.rsplit('.').next().unwrap_or(kind)) {
451 return None;
452 }
453 names.extend(quoted(message).pop());
456 }
457 return Some(names);
458 }
459 let js = text.lines().any(|l| l.trim_start().starts_with("FAIL "));
460 if js && !text.contains("AssertionError") && !text.contains("expected ") {
461 let names: Vec<String> = text
462 .lines()
463 .filter(|l| l.contains("Cannot find module") || l.contains("Failed to resolve import"))
464 .filter_map(|l| quoted(l).first().cloned())
465 .collect();
466 return (!names.is_empty()).then_some(names);
467 }
468 None
469}
470
471fn classified(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
472 if result.status == CheckStatus::TimedOut {
473 return RedClass::Timeout;
474 }
475 if let Some(tests) = result.tests.as_ref() {
476 if tests.iter().any(|t| t.outcome == TestOutcome::Failed) {
477 return RedClass::Assertion;
478 }
479 if tests.iter().any(|t| t.outcome == TestOutcome::Error) {
480 return RedClass::CollectionError;
481 }
482 if tests.is_empty() {
483 return RedClass::NoTests;
484 }
485 if result.status == CheckStatus::Passed {
486 return RedClass::Passed;
487 }
488 }
489 if let Some(s) = summary {
490 if s.build_failed {
491 return if s.unresolved_only {
492 RedClass::Absent
493 } else {
494 RedClass::BuildError
495 };
496 }
497 if s.failed > 0 {
498 return RedClass::Assertion;
499 }
500 if s.errors > 0 {
501 return RedClass::CollectionError;
502 }
503 if s.passed == 0 {
504 return RedClass::NoTests;
505 }
506 }
507 match result.status {
508 CheckStatus::Passed => RedClass::Passed,
509 CheckStatus::Error => RedClass::BuildError,
510 _ => RedClass::Unclassified,
511 }
512}
513
514#[must_use]
520pub fn classify_red_added(
521 with: (&CheckResult, Option<&RunnerSummary>),
522 without: (&CheckResult, Option<&RunnerSummary>),
523) -> RedClass {
524 let class = classify_red(with.0, with.1);
525 if class != RedClass::Assertion {
526 return class;
527 }
528 let failing = |r: &CheckResult| {
529 r.tests.as_ref().map(|t| {
530 t.iter()
531 .filter(|t| t.outcome == TestOutcome::Failed)
532 .map(|t| t.id.clone())
533 .collect::<std::collections::BTreeSet<_>>()
534 })
535 };
536 let added = match (failing(with.0), failing(without.0)) {
537 (Some(w), Some(o)) => w.difference(&o).next().is_some(),
538 _ => match (with.1, without.1) {
539 (Some(w), Some(o)) => w.failed > o.failed,
540 (Some(_), None) => classify_red(without.0, None) != RedClass::Assertion,
544 _ => false,
545 },
546 };
547 if added {
548 RedClass::Assertion
549 } else if with.1.is_none() && with.0.tests.is_none() {
550 RedClass::Unclassified
551 } else {
552 RedClass::Passed
553 }
554}
555
556#[must_use]
561pub fn classify_red_each(
562 result: &CheckResult,
563 wanted: &[String],
564) -> Option<Vec<(String, RedClass)>> {
565 if result.status == CheckStatus::TimedOut {
566 return Some(
567 wanted
568 .iter()
569 .map(|w| (w.clone(), RedClass::Timeout))
570 .collect(),
571 );
572 }
573 let tests = result.tests.as_ref()?;
574 Some(
575 wanted
576 .iter()
577 .map(|w| {
578 let found = tests.iter().find(|t| {
579 t.id == *w
580 || t.id.ends_with(&format!("::{w}"))
581 || t.id.ends_with(&format!(".{w}"))
582 });
583 let class = match found.map(|t| t.outcome) {
584 Some(TestOutcome::Failed) => RedClass::Assertion,
585 Some(TestOutcome::Error) => RedClass::CollectionError,
586 Some(TestOutcome::Passed) => RedClass::Passed,
587 Some(TestOutcome::Skipped) | None => RedClass::NoTests,
588 };
589 (w.clone(), class)
590 })
591 .collect(),
592 )
593}
594
595#[must_use]
601pub fn classify_red_one(
602 result: &CheckResult,
603 summary: Option<&RunnerSummary>,
604 test: &str,
605) -> RedClass {
606 if test.is_empty() {
607 if result.tests.as_ref().is_some_and(|t| t.len() > 1) {
609 return RedClass::Unclassified;
610 }
611 } else if let Some(each) = classify_red_each(result, &[test.to_owned()]) {
612 return each.first().map_or(RedClass::NoTests, |(_, c)| *c);
613 }
614 let class = classify_red(result, summary);
615 match summary {
616 Some(s) if !s.build_failed && s.passed + s.failed + s.errors > 1 => RedClass::Unclassified,
617 _ => class,
618 }
619}
620
621#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
623#[serde(rename_all = "snake_case", tag = "verdict")]
624pub enum Verdict {
625 Verified,
627 RegressionOnly,
629 Failed {
631 reasons: Vec<String>,
633 },
634}
635
636fn regression_reasons(checks: &[CheckResult], regressions: &[Regression]) -> Vec<String> {
640 let mut out = Vec::new();
641 let mut seen: Vec<&str> = Vec::new();
642 for r in regressions {
643 let check = r.id.split('/').next().unwrap_or(&r.id);
644 if seen.contains(&check) {
645 continue;
646 }
647 seen.push(check);
648 let of: Vec<&Regression> = regressions
649 .iter()
650 .filter(|x| x.id.split('/').next() == Some(check))
651 .collect();
652 const BROKEN: &[&str] = &[
655 "ERROR collecting",
656 "ImportError while importing",
657 "SyntaxError",
658 "error: could not compile",
659 "error[E",
660 "Failed to load",
661 "no tests ran",
662 ];
663 let broken = checks
664 .iter()
665 .find(|c| c.name == check)
666 .map(|c| format!("{}\n{}", c.stdout_tail, c.stderr_tail))
667 .filter(|t| BROKEN.iter().any(|m| t.contains(m)));
668 if let Some(text) =
669 broken.filter(|_| of.len() >= 3 && of.iter().all(|x| x.now == "missing"))
670 {
671 let lines = || text.lines().map(str::trim);
673 let first = lines()
674 .find(|l| l.starts_with("E "))
675 .or_else(|| lines().find(|l| BROKEN.iter().any(|m| l.contains(m))))
676 .map(|l| format!(": {}", l.chars().take(200).collect::<String>()))
677 .unwrap_or_default();
678 out.push(format!(
679 "regression: check `{check}` no longer runs {} of its tests{first}",
680 of.len()
681 ));
682 } else {
683 out.extend(
684 of.iter()
685 .map(|x| format!("regression: {} is {}", x.id, x.now)),
686 );
687 }
688 }
689 out
690}
691
692#[must_use]
694pub fn verdict(task_checks: &[&str], checks: &[CheckResult], cmp: Option<&Comparison>) -> Verdict {
695 let mut reasons: Vec<String> = failures(checks, cmp)
696 .iter()
697 .map(|c| format!("check `{}` {:?}", c.name, c.status).to_lowercase())
698 .collect();
699 if let Some(cmp) = cmp {
700 reasons.extend(regression_reasons(checks, &cmp.regressions));
701 }
702 if !reasons.is_empty() {
703 return Verdict::Failed { reasons };
704 }
705 let evidence = checks
706 .iter()
707 .any(|c| task_checks.contains(&c.name.as_str()) && c.is_evidence());
708 if evidence {
709 Verdict::Verified
710 } else {
711 Verdict::RegressionOnly
712 }
713}
714
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
718#[serde(rename_all = "snake_case", tag = "outcome")]
719pub enum HeldOut {
720 Off,
722 Absent {
724 reason: String,
726 },
727 Passed {
729 files: usize,
731 },
732 Failed {
734 scenarios: Vec<String>,
736 },
737}
738
739#[must_use]
742pub fn with_held_out(verdict: Verdict, held_out: &HeldOut) -> Verdict {
743 let HeldOut::Failed { scenarios } = held_out else {
744 return verdict;
745 };
746 let mut ids = scenarios.clone();
747 ids.sort();
748 ids.dedup();
749 let reason = format!("held-out acceptance failed: {}", ids.join(", "));
750 match verdict {
751 Verdict::Failed { mut reasons } => {
752 reasons.push(reason);
753 Verdict::Failed { reasons }
754 }
755 Verdict::Verified | Verdict::RegressionOnly => Verdict::Failed {
756 reasons: vec![reason],
757 },
758 }
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn each_test_gets_its_own_red_class() {
767 let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
768 r.tests = Some(vec![
769 TestResult {
770 id: "title::empty_title_is_refused".to_owned(),
771 outcome: TestOutcome::Failed,
772 },
773 TestResult {
774 id: "tests.test_title.test_long".to_owned(),
775 outcome: TestOutcome::Passed,
776 },
777 TestResult {
778 id: "setup_broken".to_owned(),
779 outcome: TestOutcome::Error,
780 },
781 ]);
782 let wanted: Vec<String> = [
783 "empty_title_is_refused",
784 "test_long",
785 "setup_broken",
786 "gone",
787 ]
788 .iter()
789 .map(|s| (*s).to_owned())
790 .collect();
791 let classes: Vec<RedClass> = classify_red_each(&r, &wanted)
792 .unwrap_or_default()
793 .into_iter()
794 .map(|(_, c)| c)
795 .collect();
796 assert_eq!(
797 classes,
798 [
799 RedClass::Assertion,
800 RedClass::Passed,
801 RedClass::CollectionError,
802 RedClass::NoTests
803 ]
804 );
805 r.tests = None;
806 assert!(classify_red_each(&r, &wanted).is_none(), "by name instead");
807 }
808
809 #[test]
810 fn one_test_is_red_only_by_its_own_assertion() {
811 let failed = check("acceptance", CheckStatus::Failed, None);
812 let one = RunnerSummary {
813 failed: 1,
814 ..RunnerSummary::default()
815 };
816 assert_eq!(
817 classify_red_one(&failed, Some(&one), "t"),
818 RedClass::Assertion
819 );
820 let many = RunnerSummary {
822 passed: 3,
823 failed: 1,
824 ..RunnerSummary::default()
825 };
826 assert_eq!(
827 classify_red_one(&failed, Some(&many), "t"),
828 RedClass::Unclassified
829 );
830 let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
832 r.tests = Some(vec![
833 TestResult {
834 id: "other::broken".to_owned(),
835 outcome: TestOutcome::Failed,
836 },
837 TestResult {
838 id: "repro::t".to_owned(),
839 outcome: TestOutcome::Passed,
840 },
841 ]);
842 assert_eq!(classify_red_one(&r, None, "t"), RedClass::Passed);
843 assert_eq!(classify_red_one(&r, None, "broken"), RedClass::Assertion);
844 assert_eq!(classify_red_one(&r, None, "gone"), RedClass::NoTests);
845 assert_eq!(classify_red_one(&r, None, ""), RedClass::Unclassified);
847 assert_eq!(
848 classify_red_one(&failed, Some(&many), ""),
849 RedClass::Unclassified
850 );
851 }
852
853 #[test]
854 fn a_file_is_red_only_by_the_failures_it_adds() {
855 use TestOutcome::{Failed, Passed};
856 let without = check(
857 "held-out",
858 CheckStatus::Failed,
859 Some(&[("old::broken", Failed)]),
860 );
861 let borrowed = check(
863 "held-out",
864 CheckStatus::Failed,
865 Some(&[("old::broken", Failed), ("new::variant", Passed)]),
866 );
867 assert_eq!(
868 classify_red_added((&borrowed, None), (&without, None)),
869 RedClass::Passed
870 );
871 let own = check(
873 "held-out",
874 CheckStatus::Failed,
875 Some(&[("old::broken", Failed), ("new::variant", Failed)]),
876 );
877 assert_eq!(
878 classify_red_added((&own, None), (&without, None)),
879 RedClass::Assertion
880 );
881 let plain = |status| check("held-out", status, None);
883 let count = |failed| RunnerSummary {
884 passed: 1,
885 failed,
886 ..RunnerSummary::default()
887 };
888 let (w, o) = (plain(CheckStatus::Failed), plain(CheckStatus::Failed));
889 assert_eq!(
890 classify_red_added((&w, Some(&count(1))), (&o, Some(&count(1)))),
891 RedClass::Passed
892 );
893 assert_eq!(
894 classify_red_added((&w, Some(&count(2))), (&o, Some(&count(1)))),
895 RedClass::Assertion
896 );
897 let refused = plain(CheckStatus::Failed);
899 assert_eq!(
900 classify_red_added((&w, Some(&count(1))), (&refused, None)),
901 RedClass::Assertion
902 );
903 let built = plain(CheckStatus::Error);
905 let broken = RunnerSummary {
906 build_failed: true,
907 ..RunnerSummary::default()
908 };
909 assert_eq!(
910 classify_red_added((&built, Some(&broken)), (&o, Some(&count(1)))),
911 RedClass::BuildError
912 );
913 }
914
915 #[test]
919 fn a_red_run_says_which_names_are_not_there() {
920 let ran = |out: &str| {
921 let mut c = check(
922 "acceptance",
923 CheckStatus::Failed,
924 Some(&[("t::a", TestOutcome::Failed)]),
925 );
926 c.stdout_tail = out.to_owned();
927 (classify_red(&c, None), missing_names(&c))
928 };
929 let names = |v: &[&str]| Some(v.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>());
930 assert_eq!(
931 ran(
932 "FAILED tests/test_acceptance_t032.py::test_header - ModuleNotFoundError: No module named 'src.lib.convert_table_to_csv'\n1 failed"
933 ),
934 (
935 RedClass::Assertion,
936 names(&["src.lib.convert_table_to_csv"])
937 )
938 );
939 assert_eq!(
940 ran("FAILED tests/t.py::a - AttributeError: 'tuple' object has no attribute 'header'")
941 .1,
942 names(&["header"])
943 );
944 assert_eq!(ran("FAILED tests/t.py::a - NameError: x\nFAILED tests/t.py::b - AssertionError: assert 1 == 2").1, None);
945 assert_eq!(
946 ran("FAILED tests/t.py::a - src.models.ConversionError: header missing").1,
947 None
948 );
949 assert_eq!(
950 ran("FAILED tests/t.py::a - NotImplementedError: soon").1,
951 None,
952 "a stub the task fills"
953 );
954 assert_eq!(
955 ran(" FAIL tests/csv.test.ts > converts\nError: Cannot find module '../src/convert'")
956 .1,
957 names(&["../src/convert"])
958 );
959 assert_eq!(
960 ran(" FAIL tests/csv.test.ts > converts\nAssertionError: expected 'a' to be 'b'").1,
961 None
962 );
963 assert_eq!(ran("test result: FAILED. 0 passed; 1 failed").1, None);
964 }
965
966 fn check(
967 name: &str,
968 status: CheckStatus,
969 tests: Option<&[(&str, TestOutcome)]>,
970 ) -> CheckResult {
971 CheckResult {
972 name: name.to_owned(),
973 argv: vec!["x".to_owned()],
974 status,
975 exit_code: Some(i32::from(status != CheckStatus::Passed)),
976 duration_ms: 1,
977 junit: tests.is_some(),
978 tests: tests.map(|t| {
979 t.iter()
980 .map(|(id, o)| TestResult {
981 id: (*id).to_owned(),
982 outcome: *o,
983 })
984 .collect()
985 }),
986 stdout_tail: String::new(),
987 stderr_tail: String::new(),
988 truncated: false,
989 note: None,
990 flaky: false,
991 sandbox: Default::default(),
992 }
993 }
994
995 fn report(checks: Vec<CheckResult>) -> VerifyReport {
996 VerifyReport {
997 root: "/p".to_owned(),
998 tier: "fast".to_owned(),
999 checks,
1000 comparison: None,
1001 passed: true,
1002 }
1003 }
1004
1005 use CheckStatus::{Failed, Passed};
1006 use TestOutcome as T;
1007
1008 #[test]
1009 fn a_test_is_known_by_its_file_in_each_runner_s_words() {
1010 let files = [
1011 "tests/us1_word_count.rs".to_owned(),
1012 "tests/test_title.py".to_owned(),
1013 ];
1014 assert!(test_of("wordcount::us1_word_count::us1_1_counts", &files));
1015 assert!(test_of("tests.test_title.test_long", &files));
1016 assert!(test_of("tests/test_title.py::test_long", &files));
1017 assert!(!test_of("wordcount::us1_word_counter::other", &files));
1018 assert!(!test_of("lib::tests::word_count", &files));
1019 }
1020
1021 #[test]
1022 fn the_story_s_oracle_tests_failing_are_never_the_check_s_failure() {
1023 let oracle = ["tests/us1_count.rs".to_owned()];
1024 let before = report(vec![check("test", Failed, Some(&[]))]);
1026 let after = report(vec![check(
1027 "test",
1028 Failed,
1029 Some(&[
1030 ("demo::us1_count::one", T::Passed),
1031 ("demo::us1_count::two", T::Failed),
1032 ]),
1033 )]);
1034 let cmp = compare_except(&before, &after, &oracle);
1035 assert_eq!(cmp.failing_as_before, ["test"]);
1036 assert!(passes(&after.checks, Some(&cmp)));
1037 let worse = report(vec![check(
1039 "test",
1040 Failed,
1041 Some(&[
1042 ("demo::us1_count::two", T::Failed),
1043 ("demo::lib::other", T::Failed),
1044 ]),
1045 )]);
1046 assert!(
1047 compare_except(&before, &worse, &oracle)
1048 .failing_as_before
1049 .is_empty()
1050 );
1051 assert!(compare(&before, &after).failing_as_before.is_empty());
1053 }
1054
1055 #[test]
1056 fn comparison_finds_regressions_pre_existing_and_new() {
1057 let base = report(vec![check(
1058 "test",
1059 Failed,
1060 Some(&[("a", T::Passed), ("b", T::Passed), ("c", T::Failed)]),
1061 )]);
1062 let now = report(vec![check(
1063 "test",
1064 Failed,
1065 Some(&[
1066 ("a", T::Passed),
1067 ("b", T::Failed),
1068 ("c", T::Failed),
1069 ("d", T::Passed),
1070 ]),
1071 )]);
1072 let cmp = compare(&base, &now);
1073 assert_eq!(
1074 cmp.regressions,
1075 [Regression {
1076 id: "test/b".to_owned(),
1077 now: "failed".to_owned()
1078 }]
1079 );
1080 assert_eq!(cmp.pre_existing, ["test/c"]);
1081 assert_eq!(cmp.new_tests, ["test/d"]);
1082 assert_eq!((cmp.count_before, cmp.count_after), (3, 4));
1083 assert!(cmp.failing_as_before.is_empty(), "b is a new failure");
1084 }
1085
1086 #[test]
1089 fn a_check_whose_tests_are_gone_is_one_reason() {
1090 let mut now = check("test", CheckStatus::Failed, None);
1091 now.stdout_tail = "ERROR collecting tests/test_acceptance_us1.py\nE SyntaxError: from __future__ imports must occur at the beginning of the file".to_owned();
1092 let gone: Vec<Regression> = (1..=17)
1093 .map(|n| Regression {
1094 id: format!("test/tests.unit::t{n}"),
1095 now: "missing".to_owned(),
1096 })
1097 .collect();
1098 assert_eq!(
1099 regression_reasons(&[now], &gone),
1100 [
1101 "regression: check `test` no longer runs 17 of its tests: E SyntaxError: from __future__ imports must occur at the beginning of the file"
1102 ]
1103 );
1104 assert_eq!(regression_reasons(&[], &gone[..2]).len(), 2);
1107 let ran = check("test", CheckStatus::Passed, None);
1108 assert_eq!(regression_reasons(&[ran], &gone[..3]).len(), 3);
1109 }
1110
1111 #[test]
1112 fn missing_test_is_a_regression() {
1113 let base = report(vec![check(
1114 "test",
1115 Passed,
1116 Some(&[("a", T::Passed), ("b", T::Passed)]),
1117 )]);
1118 let now = report(vec![check("test", Passed, Some(&[("a", T::Passed)]))]);
1119 let cmp = compare(&base, &now);
1120 assert_eq!(cmp.regressions[0].now, "missing");
1121 assert!(!passes(&now.checks, Some(&cmp)));
1122 }
1123
1124 #[test]
1125 fn pre_existing_failures_alone_do_not_fail_against_a_baseline() {
1126 let base = report(vec![check(
1127 "test",
1128 Failed,
1129 Some(&[("a", T::Passed), ("c", T::Failed)]),
1130 )]);
1131 let now = report(vec![check(
1132 "test",
1133 Failed,
1134 Some(&[("a", T::Passed), ("c", T::Failed)]),
1135 )]);
1136 let cmp = compare(&base, &now);
1137 assert_eq!(cmp.failing_as_before, ["test"]);
1138 assert!(passes(&now.checks, Some(&cmp)));
1139 assert!(
1140 !passes(&now.checks, None),
1141 "without a baseline a failing check fails"
1142 );
1143 }
1144
1145 #[test]
1146 fn verdict_levels() {
1147 let passing = check("unit", Passed, Some(&[("a", T::Passed)]));
1148 let empty = check("unit", Passed, Some(&[]));
1149 let lint = check("lint", Passed, None);
1150 assert_eq!(
1151 verdict(&["unit"], &[passing.clone(), lint.clone()], None),
1152 Verdict::Verified
1153 );
1154 assert_eq!(
1155 verdict(&[], &[passing.clone(), lint.clone()], None),
1156 Verdict::RegressionOnly
1157 );
1158 assert_eq!(
1159 verdict(&["unit"], &[empty], None),
1160 Verdict::RegressionOnly,
1161 "zero tests is no evidence"
1162 );
1163 assert_eq!(
1164 verdict(&["lint"], &[lint], None),
1165 Verdict::Verified,
1166 "a passing command check is evidence"
1167 );
1168 let failing = check("lint", Failed, None);
1169 assert!(matches!(
1170 verdict(&["unit"], &[passing, failing], None),
1171 Verdict::Failed { .. }
1172 ));
1173 }
1174
1175 fn summary(failed: usize, build_failed: bool, unresolved_only: bool) -> RunnerSummary {
1176 RunnerSummary {
1177 passed: 0,
1178 failed,
1179 errors: 0,
1180 build_failed,
1181 unresolved_only,
1182 }
1183 }
1184
1185 #[test]
1186 fn red_is_an_assertion_or_an_absent_symbol() {
1187 use TestOutcome as T;
1188 let failed = check("a", CheckStatus::Failed, Some(&[("t", T::Failed)]));
1189 assert_eq!(classify_red(&failed, None), RedClass::Assertion);
1190 let errored = check("a", CheckStatus::Failed, Some(&[("t", T::Error)]));
1191 assert_eq!(classify_red(&errored, None), RedClass::CollectionError);
1192 let empty = check("a", CheckStatus::Failed, Some(&[]));
1193 assert_eq!(classify_red(&empty, None), RedClass::NoTests);
1194 let plain = check("a", CheckStatus::Failed, None);
1195 assert_eq!(classify_red(&plain, None), RedClass::Unclassified);
1196 assert_eq!(
1197 classify_red(&plain, Some(&summary(2, false, false))),
1198 RedClass::Assertion
1199 );
1200 assert_eq!(
1201 classify_red(&plain, Some(&summary(0, true, false))),
1202 RedClass::BuildError
1203 );
1204 assert_eq!(
1205 classify_red(&plain, Some(&summary(0, true, true))),
1206 RedClass::Absent
1207 );
1208 assert_eq!(
1209 classify_red(&plain, Some(&summary(0, false, false))),
1210 RedClass::NoTests
1211 );
1212 let timed_out = check("a", CheckStatus::TimedOut, None);
1213 assert_eq!(
1214 classify_red(&timed_out, Some(&summary(1, false, false))),
1215 RedClass::Timeout
1216 );
1217 let passed = check("a", CheckStatus::Passed, Some(&[("t", T::Passed)]));
1218 assert_eq!(classify_red(&passed, None), RedClass::Passed);
1219 assert!(RedClass::Assertion.is_red() && RedClass::Absent.is_red());
1220 assert!(!RedClass::BuildError.is_red() && !RedClass::Flaky.is_red());
1221 }
1222
1223 #[test]
1224 fn a_flaky_pass_is_not_evidence_and_errors_regress() {
1225 use TestOutcome as T;
1226 let mut c = check("acceptance", CheckStatus::Passed, None);
1227 assert!(c.is_evidence());
1228 c.flaky = true;
1229 assert!(!c.is_evidence());
1230 assert_eq!(
1231 verdict(&["acceptance"], &[c], None),
1232 Verdict::RegressionOnly
1233 );
1234
1235 let before = report(vec![check(
1236 "t",
1237 CheckStatus::Passed,
1238 Some(&[("x", T::Passed)]),
1239 )]);
1240 let after = report(vec![check(
1241 "t",
1242 CheckStatus::Failed,
1243 Some(&[("x", T::Error)]),
1244 )]);
1245 let cmp = compare(&before, &after);
1246 assert_eq!(cmp.regressions[0].now, "error");
1247 let again = compare(&after, &after);
1248 assert_eq!(again.pre_existing, ["t/x"]);
1249 }
1250
1251 mod properties {
1252 use super::*;
1253 use proptest::prelude::*;
1254
1255 fn statuses() -> impl Strategy<Value = CheckStatus> {
1256 prop_oneof![
1257 Just(CheckStatus::Passed),
1258 Just(CheckStatus::Failed),
1259 Just(CheckStatus::TimedOut),
1260 Just(CheckStatus::Skipped),
1261 Just(CheckStatus::Error),
1262 ]
1263 }
1264
1265 fn outcomes() -> impl Strategy<Value = TestOutcome> {
1266 prop_oneof![
1267 Just(T::Passed),
1268 Just(T::Failed),
1269 Just(T::Skipped),
1270 Just(T::Error)
1271 ]
1272 }
1273
1274 fn checks() -> impl Strategy<Value = Vec<CheckResult>> {
1275 prop::collection::vec(
1276 (
1277 statuses(),
1278 prop::option::of(prop::collection::vec(outcomes(), 0..5)),
1279 any::<bool>(),
1280 ),
1281 1..5,
1282 )
1283 .prop_map(|v| {
1284 v.into_iter()
1285 .enumerate()
1286 .map(|(i, (status, tests, flaky))| CheckResult {
1287 name: format!("c{i}"),
1288 argv: vec!["x".to_owned()],
1289 status,
1290 exit_code: None,
1291 duration_ms: 0,
1292 junit: tests.is_some(),
1293 tests: tests.map(|t| {
1294 t.into_iter()
1295 .enumerate()
1296 .map(|(j, outcome)| TestResult {
1297 id: format!("t{j}"),
1298 outcome,
1299 })
1300 .collect()
1301 }),
1302 stdout_tail: String::new(),
1303 stderr_tail: String::new(),
1304 truncated: false,
1305 note: None,
1306 flaky,
1307 sandbox: Denials::default(),
1308 })
1309 .collect()
1310 })
1311 }
1312
1313 fn held_outs() -> impl Strategy<Value = HeldOut> {
1314 prop_oneof![
1315 Just(HeldOut::Off),
1316 Just(HeldOut::Absent {
1317 reason: "not_red".to_owned()
1318 }),
1319 (0..4usize).prop_map(|files| HeldOut::Passed { files }),
1320 proptest::collection::vec("US[1-3]-[1-4]", 1..4)
1321 .prop_map(|scenarios| HeldOut::Failed { scenarios }),
1322 ]
1323 }
1324
1325 proptest! {
1326 #[test]
1327 fn a_held_out_failure_is_never_a_pass(cs in checks(), pick in 0..5usize, h in held_outs()) {
1328 let name = format!("c{pick}");
1329 let before = verdict(&[name.as_str()], &cs, None);
1330 let after = with_held_out(before.clone(), &h);
1331 match h {
1332 HeldOut::Failed { .. } => {
1333 let failed = matches!(after, Verdict::Failed { .. });
1334 prop_assert!(failed);
1335 }
1336 _ => prop_assert_eq!(after, before),
1337 }
1338 }
1339
1340 #[test]
1341 fn any_failing_check_means_failed(cs in checks(), pick in 0..5usize) {
1342 let task = [format!("c{pick}")];
1343 let task: Vec<&str> = task.iter().map(String::as_str).collect();
1344 let v = verdict(&task, &cs, None);
1345 let any_failure = cs.iter().any(|c| c.status.is_failure());
1346 prop_assert_eq!(matches!(v, Verdict::Failed { .. }), any_failure);
1347 }
1348
1349 #[test]
1350 fn verified_requires_task_evidence(cs in checks(), pick in 0..5usize) {
1351 let name = format!("c{pick}");
1352 let v = verdict(&[name.as_str()], &cs, None);
1353 if v == Verdict::Verified {
1354 let c = cs.iter().find(|c| c.name == name);
1355 prop_assert!(c.is_some_and(CheckResult::is_evidence));
1356 }
1357 }
1358
1359 #[test]
1360 fn a_flaky_task_check_is_never_verified(cs in checks(), pick in 0..5usize) {
1361 let name = format!("c{pick}");
1362 let v = verdict(&[name.as_str()], &cs, None);
1363 if cs.iter().find(|c| c.name == name).is_some_and(|c| c.flaky) {
1364 prop_assert_ne!(v, Verdict::Verified);
1365 }
1366 }
1367
1368 #[test]
1369 fn red_always_has_a_cause(
1370 cs in checks(),
1371 passed in 0..3usize,
1372 failed in 0..3usize,
1373 errors in 0..3usize,
1374 build_failed in any::<bool>(),
1375 unresolved_only in any::<bool>(),
1376 with_summary in any::<bool>(),
1377 ) {
1378 let summary = RunnerSummary { passed, failed, errors, build_failed, unresolved_only };
1379 let summary = with_summary.then_some(summary);
1380 for c in &cs {
1381 let class = classify_red(c, summary.as_ref());
1382 if class.is_red() {
1383 let assertion_in_report = c
1384 .tests
1385 .iter()
1386 .flatten()
1387 .any(|t| t.outcome == T::Failed);
1388 let by_summary = summary.is_some_and(|s| s.failed > 0 || (s.build_failed && s.unresolved_only));
1389 prop_assert!(assertion_in_report || by_summary);
1390 prop_assert_ne!(c.status, CheckStatus::TimedOut);
1391 }
1392 }
1393 }
1394
1395 #[test]
1396 fn a_run_compared_with_itself_has_no_regressions(cs in checks()) {
1397 let r = report(cs);
1398 let cmp = compare(&r, &r);
1399 prop_assert!(cmp.regressions.is_empty());
1400 prop_assert!(cmp.new_tests.is_empty());
1401 prop_assert_eq!(cmp.count_before, cmp.count_after);
1402 }
1403
1404 #[test]
1405 fn regressions_always_fail_the_verdict(cs in checks()) {
1406 let before = report(cs.clone());
1407 let mut after = report(cs);
1408 for c in &mut after.checks {
1409 if let Some(tests) = &mut c.tests {
1410 tests.clear();
1411 }
1412 }
1413 let cmp = compare(&before, &after);
1414 let had_passing = cmp.count_before > 0
1415 && before.checks.iter().flat_map(|c| c.tests.iter().flatten()).any(|t| t.outcome == T::Passed);
1416 if had_passing {
1417 let failed = matches!(verdict(&[], &after.checks, Some(&cmp)), Verdict::Failed { .. });
1418 prop_assert!(failed);
1419 }
1420 }
1421 }
1422 }
1423}