Skip to main content

speccoder_core/
verify.rs

1//! The judge's vocabulary: check results, baseline comparison, and verdicts
2//!.
3//!
4//! Pure: running checks is the runtime's job; deciding what their results
5//! mean happens here, where it is property-tested.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11/// Outcome of one check.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum CheckStatus {
15    /// Exited 0.
16    Passed,
17    /// Exited non-zero.
18    Failed,
19    /// Killed at its deadline.
20    TimedOut,
21    /// Not applicable (its `when_exists` path is absent); not evidence of anything.
22    Skipped,
23    /// Could not be run at all (e.g. command not found).
24    Error,
25}
26
27impl CheckStatus {
28    /// Whether the check counts against verification.
29    #[must_use]
30    pub const fn is_failure(self) -> bool {
31        matches!(self, Self::Failed | Self::TimedOut | Self::Error)
32    }
33}
34
35/// Outcome of one test from a JUnit report.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
37#[serde(rename_all = "snake_case")]
38pub enum TestOutcome {
39    /// Passed.
40    Passed,
41    /// Failed (an assertion, a `<failure>` in JUnit).
42    Failed,
43    /// Skipped or ignored.
44    Skipped,
45    /// Errored before or outside an assertion (a `<error>` in JUnit: setup,
46    /// collection, an unexpected exception).
47    Error,
48}
49
50impl TestOutcome {
51    /// Whether the test counts as failing (for regressions and digests).
52    #[must_use]
53    pub const fn is_failing(self) -> bool {
54        matches!(self, Self::Failed | Self::Error)
55    }
56}
57
58/// One test.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60pub struct TestResult {
61    /// `classname::name`, or `name` when there is no class.
62    pub id: String,
63    /// What happened.
64    pub outcome: TestOutcome,
65}
66
67/// One check run.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
69pub struct CheckResult {
70    /// Name from `speccoder.toml`.
71    pub name: String,
72    /// The command, as run.
73    pub argv: Vec<String>,
74    /// What happened.
75    pub status: CheckStatus,
76    /// Process exit code, if it exited.
77    pub exit_code: Option<i32>,
78    /// Wall-clock duration.
79    pub duration_ms: u64,
80    /// Whether the check declares a JUnit report.
81    pub junit: bool,
82    /// Tests from the JUnit report, if one was declared and freshly written.
83    pub tests: Option<Vec<TestResult>>,
84    /// Last part of stdout (lossy UTF-8).
85    pub stdout_tail: String,
86    /// Last part of stderr (lossy UTF-8).
87    pub stderr_tail: String,
88    /// Output exceeded the capture cap.
89    pub truncated: bool,
90    /// Why the check was skipped, errored, or has no tests.
91    pub note: Option<String>,
92    /// It failed once and passed when run again: named, never trusted as
93    /// task evidence.
94    #[serde(default)]
95    pub flaky: bool,
96}
97
98impl CheckResult {
99    /// Counts of (passed, failed, skipped) tests.
100    #[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    /// Whether this check, as a task-specific check, is evidence for the
113    /// task: it passed, and either declares no report (a command check) or
114    /// its report has at least one passing test.
115    #[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/// Everything one `verify` run established.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
138pub struct VerifyReport {
139    /// Project root the checks ran in.
140    pub root: String,
141    /// `fast` or `full`.
142    pub tier: String,
143    /// One result per configured check, in configuration order.
144    pub checks: Vec<CheckResult>,
145    /// Comparison with a baseline, if one was given.
146    pub comparison: Option<Comparison>,
147    /// Whether verification passed (see [`passes`]).
148    pub passed: bool,
149}
150
151/// What changed relative to a baseline (pass-to-pass).
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
153pub struct Comparison {
154    /// Tests that passed in the baseline and now fail or are missing.
155    pub regressions: Vec<Regression>,
156    /// Tests failing in both runs.
157    pub pre_existing: Vec<String>,
158    /// Tests not in the baseline.
159    pub new_tests: Vec<String>,
160    /// Tests collected in the baseline.
161    pub count_before: usize,
162    /// Tests collected now.
163    pub count_after: usize,
164    /// Checks failing now only because of tests that already failed in the baseline.
165    pub failing_as_before: Vec<String>,
166}
167
168/// A test that passed in the baseline and no longer does.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
170pub struct Regression {
171    /// `check/test`.
172    pub id: String,
173    /// `failed`, `skipped`, or `missing`.
174    pub now: String,
175}
176
177/// Compares `current` with `baseline`, test by test and check by check.
178#[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    // A check that failed before and now fails only on tests that failed
228    // before is not blamed on the change. Without a report this can't be
229    // known, so such checks stay failures.
230    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
250/// Checks that count against verification: failures not explained by the baseline.
251fn 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/// Whether a run passes: no unexplained failing check and no regression.
260#[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/// What a red run of acceptance tests showed.
266///
267/// Only [`RedClass::Assertion`] and [`RedClass::Absent`] are red: a test was
268/// collected and failed on an assertion, or the tests could not build only
269/// because symbols the task is to introduce do not exist yet. Everything
270/// else is not demonstrated.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
272#[serde(rename_all = "snake_case")]
273pub enum RedClass {
274    /// A collected test failed on an assertion.
275    Assertion,
276    /// The only build or import errors name symbols that do not exist yet.
277    Absent,
278    /// Any other build error.
279    BuildError,
280    /// Tests errored at setup or collection.
281    CollectionError,
282    /// The run hit its deadline.
283    Timeout,
284    /// Nothing was collected.
285    NoTests,
286    /// Red once, not red when confirmed.
287    Flaky,
288    /// The output was not recognised.
289    Unclassified,
290    /// The tests passed.
291    Passed,
292}
293
294impl RedClass {
295    /// Whether this is red: evidence the tests fail without the task's work.
296    #[must_use]
297    pub const fn is_red(self) -> bool {
298        matches!(self, Self::Assertion | Self::Absent)
299    }
300}
301
302/// Counts read from a test runner's own summary output (libtest, pytest,
303/// jest/vitest, go test), when it writes no JUnit report.
304#[derive(
305    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
306)]
307pub struct RunnerSummary {
308    /// Tests that passed.
309    pub passed: usize,
310    /// Tests that failed on an assertion.
311    pub failed: usize,
312    /// Tests that errored at setup or collection.
313    pub errors: usize,
314    /// The test code did not build or import.
315    pub build_failed: bool,
316    /// Every build or import diagnostic is an unresolved name.
317    pub unresolved_only: bool,
318}
319
320/// How many tests a run collected — ran, passed or failed (skipped and
321/// errored at setup excluded): from its JUnit report, else the runner's
322/// summary; `None` when neither says.
323#[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/// Classifies a red run: a JUnit report first, then the runner's summary,
339/// then the check's status. Nothing recognised is [`RedClass::Unclassified`].
340#[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/// Classifies a red run against the same command run without the file
385/// under test: red by an assertion only where the file adds a failing test —
386/// one that did not fail without it (by id when both runs name their tests,
387/// else by more failures). A failure the command shows anyway is another
388/// test's, never this file's red: it is [`RedClass::Passed`].
389#[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            // Without the file the command reports no count — a real runner
411            // refuses a missing test file: no assertion failed there, so the
412            // failure with it is the file's own.
413            (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/// The class of each test in `wanted` from a run's JUnit report: a test
427/// matches by its id or its last `::` segments; one the report does not
428/// name was not collected. `None` when the run has no per-test report —
429/// then each test is run on its own, by name.
430#[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/// The class of the one test `test` from a run of the command that is to
466/// run only it: by name from a per-test report; without one, from the
467/// runner's summary only when it ran exactly one test — a run of several
468/// tests cannot say which of them failed, so it is
469/// [`RedClass::Unclassified`], never an assertion of `test`'s.
470#[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        // No name: a per-test report must hold exactly one test.
478        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/// How strong the evidence for a task is.
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
493#[serde(rename_all = "snake_case", tag = "verdict")]
494pub enum Verdict {
495    /// A task-specific check produced evidence, nothing failed, nothing regressed.
496    Verified,
497    /// Nothing failed or regressed, but nothing task-specific was established.
498    RegressionOnly,
499    /// Something failed or regressed.
500    Failed {
501        /// One line per reason.
502        reasons: Vec<String>,
503    },
504}
505
506/// The verdict for a task whose task-specific checks are named in `task_checks`.
507#[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/// What the held-out acceptance tests said about an attempt that passed
534/// visibly: the judge's own tests, never shown to the model.
535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
536#[serde(rename_all = "snake_case", tag = "outcome")]
537pub enum HeldOut {
538    /// `[acceptance] held_out = false`.
539    Off,
540    /// No held-out test was kept (none generated, all discarded, …).
541    Absent {
542        /// Why.
543        reason: String,
544    },
545    /// Every kept held-out test passed.
546    Passed {
547        /// How many files.
548        files: usize,
549    },
550    /// These scenarios' held-out tests failed.
551    Failed {
552        /// Scenario IDs, sorted.
553        scenarios: Vec<String>,
554    },
555}
556
557/// The verdict after the held-out tests: a failure fails it (naming the
558/// scenarios only); anything else leaves it as it was.
559#[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        // An unrelated test failing next to it says nothing about `t`.
639        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        // By name from a report: the unrelated failure is not `t`'s.
649        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        // A reused set names files, not tests: the one-test rule alone.
664        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        // The command fails anyway: the file's own test passes — not red.
680        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        // The file's own test fails: red.
690        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        // No per-test report: by the runner's counts.
700        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        // Without the file the runner refused (no count): the failure is the file's.
716        let refused = plain(CheckStatus::Failed);
717        assert_eq!(
718            classify_red_added((&w, Some(&count(1))), (&refused, None)),
719            RedClass::Assertion
720        );
721        // Not red on its own terms stays what it is.
722        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}