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/// Classifies a red run: a JUnit report first, then the runner's summary,
321/// then the check's status. Nothing recognised is [`RedClass::Unclassified`].
322#[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/// Classifies a red run against the same command run without the file
367/// under test: red by an assertion only where the file adds a failing test —
368/// one that did not fail without it (by id when both runs name their tests,
369/// else by more failures). A failure the command shows anyway is another
370/// test's, never this file's red: it is [`RedClass::Passed`].
371#[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            // Without the file the command reports no count — a real runner
393            // refuses a missing test file: no assertion failed there, so the
394            // failure with it is the file's own.
395            (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/// The class of each test in `wanted` from a run's JUnit report: a test
409/// matches by its id or its last `::` segments; one the report does not
410/// name was not collected. `None` when the run has no per-test report —
411/// then each test is run on its own, by name.
412#[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/// The class of the one test `test` from a run of the command that is to
448/// run only it: by name from a per-test report; without one, from the
449/// runner's summary only when it ran exactly one test — a run of several
450/// tests cannot say which of them failed, so it is
451/// [`RedClass::Unclassified`], never an assertion of `test`'s.
452#[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        // No name: a per-test report must hold exactly one test.
460        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/// How strong the evidence for a task is.
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
475#[serde(rename_all = "snake_case", tag = "verdict")]
476pub enum Verdict {
477    /// A task-specific check produced evidence, nothing failed, nothing regressed.
478    Verified,
479    /// Nothing failed or regressed, but nothing task-specific was established.
480    RegressionOnly,
481    /// Something failed or regressed.
482    Failed {
483        /// One line per reason.
484        reasons: Vec<String>,
485    },
486}
487
488/// The verdict for a task whose task-specific checks are named in `task_checks`.
489#[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/// What the held-out acceptance tests said about an attempt that passed
516/// visibly: the judge's own tests, never shown to the model.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
518#[serde(rename_all = "snake_case", tag = "outcome")]
519pub enum HeldOut {
520    /// `[acceptance] held_out = false`.
521    Off,
522    /// No held-out test was kept (none generated, all discarded, …).
523    Absent {
524        /// Why.
525        reason: String,
526    },
527    /// Every kept held-out test passed.
528    Passed {
529        /// How many files.
530        files: usize,
531    },
532    /// These scenarios' held-out tests failed.
533    Failed {
534        /// Scenario IDs, sorted.
535        scenarios: Vec<String>,
536    },
537}
538
539/// The verdict after the held-out tests: a failure fails it (naming the
540/// scenarios only); anything else leaves it as it was.
541#[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        // An unrelated test failing next to it says nothing about `t`.
621        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        // By name from a report: the unrelated failure is not `t`'s.
631        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        // A reused set names files, not tests: the one-test rule alone.
646        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        // The command fails anyway: the file's own test passes — not red.
662        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        // The file's own test fails: red.
672        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        // No per-test report: by the runner's counts.
682        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        // Without the file the runner refused (no count): the failure is the file's.
698        let refused = plain(CheckStatus::Failed);
699        assert_eq!(
700            classify_red_added((&w, Some(&count(1))), (&refused, None)),
701            RedClass::Assertion
702        );
703        // Not red on its own terms stays what it is.
704        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}