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
165    /// baseline, or tests of a story's oracle (judged by the oracle's own runs).
166    pub failing_as_before: Vec<String>,
167}
168
169/// A test that passed in the baseline and no longer does.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
171pub struct Regression {
172    /// `check/test`.
173    pub id: String,
174    /// `failed`, `skipped`, or `missing`.
175    pub now: String,
176}
177
178/// Compares `current` with `baseline`, test by test and check by check.
179#[must_use]
180pub fn compare(baseline: &VerifyReport, current: &VerifyReport) -> Comparison {
181    compare_except(baseline, current, &[])
182}
183
184/// Whether test `id` (as a runner reports it) is one of `files`: the path
185/// itself, or a segment naming the file (`us1_count` for
186/// `tests/us1_count.rs`, `tests.test_x.test_a` for `tests/test_x.py`).
187#[must_use]
188pub fn test_of(id: &str, files: &[String]) -> bool {
189    files.iter().any(|f| {
190        let name = f.rsplit('/').next().unwrap_or(f);
191        let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
192        id.contains(f.as_str())
193            || id
194                .split(['/', ':', ' ', '>'])
195                .any(|seg| seg == name || seg.split('.').any(|part| part == stem))
196    })
197}
198
199/// [`compare`], with the failing tests of `oracle` files never blamed on
200/// the change: a story's oracle is judged by its own runs, and its
201/// scenarios that later tasks make pass stay red meanwhile.
202#[must_use]
203pub fn compare_except(
204    baseline: &VerifyReport,
205    current: &VerifyReport,
206    oracle: &[String],
207) -> Comparison {
208    let index = |r: &VerifyReport| -> BTreeMap<String, TestOutcome> {
209        r.checks
210            .iter()
211            .flat_map(|c| {
212                c.tests
213                    .iter()
214                    .flatten()
215                    .map(move |t| (format!("{}/{}", c.name, t.id), t.outcome))
216            })
217            .collect()
218    };
219    let before = index(baseline);
220    let after = index(current);
221
222    let mut cmp = Comparison {
223        count_before: before.len(),
224        count_after: after.len(),
225        ..Comparison::default()
226    };
227    for (id, was) in &before {
228        match (was, after.get(id)) {
229            (TestOutcome::Passed, Some(TestOutcome::Passed)) => {}
230            (TestOutcome::Passed, Some(now)) => cmp.regressions.push(Regression {
231                id: id.clone(),
232                now: match now {
233                    TestOutcome::Failed => "failed",
234                    TestOutcome::Error => "error",
235                    _ => "skipped",
236                }
237                .to_owned(),
238            }),
239            (TestOutcome::Passed, None) => cmp.regressions.push(Regression {
240                id: id.clone(),
241                now: "missing".to_owned(),
242            }),
243            (was, Some(now)) if was.is_failing() && now.is_failing() => {
244                cmp.pre_existing.push(id.clone());
245            }
246            _ => {}
247        }
248    }
249    cmp.new_tests = after
250        .keys()
251        .filter(|id| !before.contains_key(*id))
252        .cloned()
253        .collect();
254
255    // A check that failed before and now fails only on tests that failed
256    // before is not blamed on the change. Without a report this can't be
257    // known, so such checks stay failures.
258    for c in current
259        .checks
260        .iter()
261        .filter(|c| c.status == CheckStatus::Failed)
262    {
263        let Some(old) = baseline.checks.iter().find(|b| b.name == c.name) else {
264            continue;
265        };
266        let now = c.failing_tests();
267        let old_failing = old.failing_tests();
268        let unexplained = now
269            .iter()
270            .filter(|t| !old_failing.contains(*t) && !test_of(t, oracle))
271            .count();
272        if c.tests.is_some()
273            && !now.is_empty()
274            && unexplained == 0
275            && (old.status == CheckStatus::Failed || now.iter().all(|t| test_of(t, oracle)))
276        {
277            cmp.failing_as_before.push(c.name.clone());
278        }
279    }
280    cmp
281}
282
283/// Checks that count against verification: failures not explained by the baseline.
284fn failures<'r>(checks: &'r [CheckResult], cmp: Option<&Comparison>) -> Vec<&'r CheckResult> {
285    checks
286        .iter()
287        .filter(|c| c.status.is_failure())
288        .filter(|c| !cmp.is_some_and(|cmp| cmp.failing_as_before.contains(&c.name)))
289        .collect()
290}
291
292/// Whether a run passes: no unexplained failing check and no regression.
293#[must_use]
294pub fn passes(checks: &[CheckResult], cmp: Option<&Comparison>) -> bool {
295    failures(checks, cmp).is_empty() && cmp.is_none_or(|c| c.regressions.is_empty())
296}
297
298/// What a red run of acceptance tests showed.
299///
300/// Only [`RedClass::Assertion`] and [`RedClass::Absent`] are red: a test was
301/// collected and failed on an assertion, or the tests could not build only
302/// because symbols the task is to introduce do not exist yet. Everything
303/// else is not demonstrated.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
305#[serde(rename_all = "snake_case")]
306pub enum RedClass {
307    /// A collected test failed on an assertion.
308    Assertion,
309    /// The only build or import errors name symbols that do not exist yet.
310    Absent,
311    /// Any other build error.
312    BuildError,
313    /// Tests errored at setup or collection.
314    CollectionError,
315    /// The run hit its deadline.
316    Timeout,
317    /// Nothing was collected.
318    NoTests,
319    /// Red once, not red when confirmed.
320    Flaky,
321    /// The output was not recognised.
322    Unclassified,
323    /// The tests passed.
324    Passed,
325}
326
327impl RedClass {
328    /// Whether this is red: evidence the tests fail without the task's work.
329    #[must_use]
330    pub const fn is_red(self) -> bool {
331        matches!(self, Self::Assertion | Self::Absent)
332    }
333}
334
335/// Counts read from a test runner's own summary output (libtest, pytest,
336/// jest/vitest, go test), when it writes no JUnit report.
337#[derive(
338    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
339)]
340pub struct RunnerSummary {
341    /// Tests that passed.
342    pub passed: usize,
343    /// Tests that failed on an assertion.
344    pub failed: usize,
345    /// Tests that errored at setup or collection.
346    pub errors: usize,
347    /// The test code did not build or import.
348    pub build_failed: bool,
349    /// Every build or import diagnostic is an unresolved name.
350    pub unresolved_only: bool,
351}
352
353/// How many tests a run collected — ran, passed or failed (skipped and
354/// errored at setup excluded): from its JUnit report, else the runner's
355/// summary; `None` when neither says.
356#[must_use]
357pub fn collected_count(result: &CheckResult, summary: Option<&RunnerSummary>) -> Option<usize> {
358    if let Some(tests) = result.tests.as_ref() {
359        return Some(
360            tests
361                .iter()
362                .filter(|t| matches!(t.outcome, TestOutcome::Passed | TestOutcome::Failed))
363                .count(),
364        );
365    }
366    summary
367        .filter(|s| !s.build_failed)
368        .map(|s| s.passed + s.failed)
369}
370
371/// Classifies a red run: a JUnit report first, then the runner's summary,
372/// then the check's status. Nothing recognised is [`RedClass::Unclassified`].
373#[must_use]
374pub fn classify_red(result: &CheckResult, summary: Option<&RunnerSummary>) -> RedClass {
375    if result.status == CheckStatus::TimedOut {
376        return RedClass::Timeout;
377    }
378    if let Some(tests) = result.tests.as_ref() {
379        if tests.iter().any(|t| t.outcome == TestOutcome::Failed) {
380            return RedClass::Assertion;
381        }
382        if tests.iter().any(|t| t.outcome == TestOutcome::Error) {
383            return RedClass::CollectionError;
384        }
385        if tests.is_empty() {
386            return RedClass::NoTests;
387        }
388        if result.status == CheckStatus::Passed {
389            return RedClass::Passed;
390        }
391    }
392    if let Some(s) = summary {
393        if s.build_failed {
394            return if s.unresolved_only {
395                RedClass::Absent
396            } else {
397                RedClass::BuildError
398            };
399        }
400        if s.failed > 0 {
401            return RedClass::Assertion;
402        }
403        if s.errors > 0 {
404            return RedClass::CollectionError;
405        }
406        if s.passed == 0 {
407            return RedClass::NoTests;
408        }
409    }
410    match result.status {
411        CheckStatus::Passed => RedClass::Passed,
412        CheckStatus::Error => RedClass::BuildError,
413        _ => RedClass::Unclassified,
414    }
415}
416
417/// Classifies a red run against the same command run without the file
418/// under test: red by an assertion only where the file adds a failing test —
419/// one that did not fail without it (by id when both runs name their tests,
420/// else by more failures). A failure the command shows anyway is another
421/// test's, never this file's red: it is [`RedClass::Passed`].
422#[must_use]
423pub fn classify_red_added(
424    with: (&CheckResult, Option<&RunnerSummary>),
425    without: (&CheckResult, Option<&RunnerSummary>),
426) -> RedClass {
427    let class = classify_red(with.0, with.1);
428    if class != RedClass::Assertion {
429        return class;
430    }
431    let failing = |r: &CheckResult| {
432        r.tests.as_ref().map(|t| {
433            t.iter()
434                .filter(|t| t.outcome == TestOutcome::Failed)
435                .map(|t| t.id.clone())
436                .collect::<std::collections::BTreeSet<_>>()
437        })
438    };
439    let added = match (failing(with.0), failing(without.0)) {
440        (Some(w), Some(o)) => w.difference(&o).next().is_some(),
441        _ => match (with.1, without.1) {
442            (Some(w), Some(o)) => w.failed > o.failed,
443            // Without the file the command reports no count — a real runner
444            // refuses a missing test file: no assertion failed there, so the
445            // failure with it is the file's own.
446            (Some(_), None) => classify_red(without.0, None) != RedClass::Assertion,
447            _ => false,
448        },
449    };
450    if added {
451        RedClass::Assertion
452    } else if with.1.is_none() && with.0.tests.is_none() {
453        RedClass::Unclassified
454    } else {
455        RedClass::Passed
456    }
457}
458
459/// The class of each test in `wanted` from a run's JUnit report: a test
460/// matches by its id or its last `::` segments; one the report does not
461/// name was not collected. `None` when the run has no per-test report —
462/// then each test is run on its own, by name.
463#[must_use]
464pub fn classify_red_each(
465    result: &CheckResult,
466    wanted: &[String],
467) -> Option<Vec<(String, RedClass)>> {
468    if result.status == CheckStatus::TimedOut {
469        return Some(
470            wanted
471                .iter()
472                .map(|w| (w.clone(), RedClass::Timeout))
473                .collect(),
474        );
475    }
476    let tests = result.tests.as_ref()?;
477    Some(
478        wanted
479            .iter()
480            .map(|w| {
481                let found = tests.iter().find(|t| {
482                    t.id == *w
483                        || t.id.ends_with(&format!("::{w}"))
484                        || t.id.ends_with(&format!(".{w}"))
485                });
486                let class = match found.map(|t| t.outcome) {
487                    Some(TestOutcome::Failed) => RedClass::Assertion,
488                    Some(TestOutcome::Error) => RedClass::CollectionError,
489                    Some(TestOutcome::Passed) => RedClass::Passed,
490                    Some(TestOutcome::Skipped) | None => RedClass::NoTests,
491                };
492                (w.clone(), class)
493            })
494            .collect(),
495    )
496}
497
498/// The class of the one test `test` from a run of the command that is to
499/// run only it: by name from a per-test report; without one, from the
500/// runner's summary only when it ran exactly one test — a run of several
501/// tests cannot say which of them failed, so it is
502/// [`RedClass::Unclassified`], never an assertion of `test`'s.
503#[must_use]
504pub fn classify_red_one(
505    result: &CheckResult,
506    summary: Option<&RunnerSummary>,
507    test: &str,
508) -> RedClass {
509    if test.is_empty() {
510        // No name: a per-test report must hold exactly one test.
511        if result.tests.as_ref().is_some_and(|t| t.len() > 1) {
512            return RedClass::Unclassified;
513        }
514    } else if let Some(each) = classify_red_each(result, &[test.to_owned()]) {
515        return each.first().map_or(RedClass::NoTests, |(_, c)| *c);
516    }
517    let class = classify_red(result, summary);
518    match summary {
519        Some(s) if !s.build_failed && s.passed + s.failed + s.errors > 1 => RedClass::Unclassified,
520        _ => class,
521    }
522}
523
524/// How strong the evidence for a task is.
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
526#[serde(rename_all = "snake_case", tag = "verdict")]
527pub enum Verdict {
528    /// A task-specific check produced evidence, nothing failed, nothing regressed.
529    Verified,
530    /// Nothing failed or regressed, but nothing task-specific was established.
531    RegressionOnly,
532    /// Something failed or regressed.
533    Failed {
534        /// One line per reason.
535        reasons: Vec<String>,
536    },
537}
538
539/// The verdict for a task whose task-specific checks are named in `task_checks`.
540#[must_use]
541pub fn verdict(task_checks: &[&str], checks: &[CheckResult], cmp: Option<&Comparison>) -> Verdict {
542    let mut reasons: Vec<String> = failures(checks, cmp)
543        .iter()
544        .map(|c| format!("check `{}` {:?}", c.name, c.status).to_lowercase())
545        .collect();
546    if let Some(cmp) = cmp {
547        reasons.extend(
548            cmp.regressions
549                .iter()
550                .map(|r| format!("regression: {} is {}", r.id, r.now)),
551        );
552    }
553    if !reasons.is_empty() {
554        return Verdict::Failed { reasons };
555    }
556    let evidence = checks
557        .iter()
558        .any(|c| task_checks.contains(&c.name.as_str()) && c.is_evidence());
559    if evidence {
560        Verdict::Verified
561    } else {
562        Verdict::RegressionOnly
563    }
564}
565
566/// What the held-out acceptance tests said about an attempt that passed
567/// visibly: the judge's own tests, never shown to the model.
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
569#[serde(rename_all = "snake_case", tag = "outcome")]
570pub enum HeldOut {
571    /// `[acceptance] held_out = false`.
572    Off,
573    /// No held-out test was kept (none generated, all discarded, …).
574    Absent {
575        /// Why.
576        reason: String,
577    },
578    /// Every kept held-out test passed.
579    Passed {
580        /// How many files.
581        files: usize,
582    },
583    /// These scenarios' held-out tests failed.
584    Failed {
585        /// Scenario IDs, sorted.
586        scenarios: Vec<String>,
587    },
588}
589
590/// The verdict after the held-out tests: a failure fails it (naming the
591/// scenarios only); anything else leaves it as it was.
592#[must_use]
593pub fn with_held_out(verdict: Verdict, held_out: &HeldOut) -> Verdict {
594    let HeldOut::Failed { scenarios } = held_out else {
595        return verdict;
596    };
597    let mut ids = scenarios.clone();
598    ids.sort();
599    ids.dedup();
600    let reason = format!("held-out acceptance failed: {}", ids.join(", "));
601    match verdict {
602        Verdict::Failed { mut reasons } => {
603            reasons.push(reason);
604            Verdict::Failed { reasons }
605        }
606        Verdict::Verified | Verdict::RegressionOnly => Verdict::Failed {
607            reasons: vec![reason],
608        },
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    #[test]
617    fn each_test_gets_its_own_red_class() {
618        let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
619        r.tests = Some(vec![
620            TestResult {
621                id: "title::empty_title_is_refused".to_owned(),
622                outcome: TestOutcome::Failed,
623            },
624            TestResult {
625                id: "tests.test_title.test_long".to_owned(),
626                outcome: TestOutcome::Passed,
627            },
628            TestResult {
629                id: "setup_broken".to_owned(),
630                outcome: TestOutcome::Error,
631            },
632        ]);
633        let wanted: Vec<String> = [
634            "empty_title_is_refused",
635            "test_long",
636            "setup_broken",
637            "gone",
638        ]
639        .iter()
640        .map(|s| (*s).to_owned())
641        .collect();
642        let classes: Vec<RedClass> = classify_red_each(&r, &wanted)
643            .unwrap_or_default()
644            .into_iter()
645            .map(|(_, c)| c)
646            .collect();
647        assert_eq!(
648            classes,
649            [
650                RedClass::Assertion,
651                RedClass::Passed,
652                RedClass::CollectionError,
653                RedClass::NoTests
654            ]
655        );
656        r.tests = None;
657        assert!(classify_red_each(&r, &wanted).is_none(), "by name instead");
658    }
659
660    #[test]
661    fn one_test_is_red_only_by_its_own_assertion() {
662        let failed = check("acceptance", CheckStatus::Failed, None);
663        let one = RunnerSummary {
664            failed: 1,
665            ..RunnerSummary::default()
666        };
667        assert_eq!(
668            classify_red_one(&failed, Some(&one), "t"),
669            RedClass::Assertion
670        );
671        // An unrelated test failing next to it says nothing about `t`.
672        let many = RunnerSummary {
673            passed: 3,
674            failed: 1,
675            ..RunnerSummary::default()
676        };
677        assert_eq!(
678            classify_red_one(&failed, Some(&many), "t"),
679            RedClass::Unclassified
680        );
681        // By name from a report: the unrelated failure is not `t`'s.
682        let mut r = check("acceptance", CheckStatus::Failed, Some(&[]));
683        r.tests = Some(vec![
684            TestResult {
685                id: "other::broken".to_owned(),
686                outcome: TestOutcome::Failed,
687            },
688            TestResult {
689                id: "repro::t".to_owned(),
690                outcome: TestOutcome::Passed,
691            },
692        ]);
693        assert_eq!(classify_red_one(&r, None, "t"), RedClass::Passed);
694        assert_eq!(classify_red_one(&r, None, "broken"), RedClass::Assertion);
695        assert_eq!(classify_red_one(&r, None, "gone"), RedClass::NoTests);
696        // A reused set names files, not tests: the one-test rule alone.
697        assert_eq!(classify_red_one(&r, None, ""), RedClass::Unclassified);
698        assert_eq!(
699            classify_red_one(&failed, Some(&many), ""),
700            RedClass::Unclassified
701        );
702    }
703
704    #[test]
705    fn a_file_is_red_only_by_the_failures_it_adds() {
706        use TestOutcome::{Failed, Passed};
707        let without = check(
708            "held-out",
709            CheckStatus::Failed,
710            Some(&[("old::broken", Failed)]),
711        );
712        // The command fails anyway: the file's own test passes — not red.
713        let borrowed = check(
714            "held-out",
715            CheckStatus::Failed,
716            Some(&[("old::broken", Failed), ("new::variant", Passed)]),
717        );
718        assert_eq!(
719            classify_red_added((&borrowed, None), (&without, None)),
720            RedClass::Passed
721        );
722        // The file's own test fails: red.
723        let own = check(
724            "held-out",
725            CheckStatus::Failed,
726            Some(&[("old::broken", Failed), ("new::variant", Failed)]),
727        );
728        assert_eq!(
729            classify_red_added((&own, None), (&without, None)),
730            RedClass::Assertion
731        );
732        // No per-test report: by the runner's counts.
733        let plain = |status| check("held-out", status, None);
734        let count = |failed| RunnerSummary {
735            passed: 1,
736            failed,
737            ..RunnerSummary::default()
738        };
739        let (w, o) = (plain(CheckStatus::Failed), plain(CheckStatus::Failed));
740        assert_eq!(
741            classify_red_added((&w, Some(&count(1))), (&o, Some(&count(1)))),
742            RedClass::Passed
743        );
744        assert_eq!(
745            classify_red_added((&w, Some(&count(2))), (&o, Some(&count(1)))),
746            RedClass::Assertion
747        );
748        // Without the file the runner refused (no count): the failure is the file's.
749        let refused = plain(CheckStatus::Failed);
750        assert_eq!(
751            classify_red_added((&w, Some(&count(1))), (&refused, None)),
752            RedClass::Assertion
753        );
754        // Not red on its own terms stays what it is.
755        let built = plain(CheckStatus::Error);
756        let broken = RunnerSummary {
757            build_failed: true,
758            ..RunnerSummary::default()
759        };
760        assert_eq!(
761            classify_red_added((&built, Some(&broken)), (&o, Some(&count(1)))),
762            RedClass::BuildError
763        );
764    }
765
766    fn check(
767        name: &str,
768        status: CheckStatus,
769        tests: Option<&[(&str, TestOutcome)]>,
770    ) -> CheckResult {
771        CheckResult {
772            name: name.to_owned(),
773            argv: vec!["x".to_owned()],
774            status,
775            exit_code: Some(i32::from(status != CheckStatus::Passed)),
776            duration_ms: 1,
777            junit: tests.is_some(),
778            tests: tests.map(|t| {
779                t.iter()
780                    .map(|(id, o)| TestResult {
781                        id: (*id).to_owned(),
782                        outcome: *o,
783                    })
784                    .collect()
785            }),
786            stdout_tail: String::new(),
787            stderr_tail: String::new(),
788            truncated: false,
789            note: None,
790            flaky: false,
791        }
792    }
793
794    fn report(checks: Vec<CheckResult>) -> VerifyReport {
795        VerifyReport {
796            root: "/p".to_owned(),
797            tier: "fast".to_owned(),
798            checks,
799            comparison: None,
800            passed: true,
801        }
802    }
803
804    use CheckStatus::{Failed, Passed};
805    use TestOutcome as T;
806
807    #[test]
808    fn a_test_is_known_by_its_file_in_each_runner_s_words() {
809        let files = [
810            "tests/us1_word_count.rs".to_owned(),
811            "tests/test_title.py".to_owned(),
812        ];
813        assert!(test_of("wordcount::us1_word_count::us1_1_counts", &files));
814        assert!(test_of("tests.test_title.test_long", &files));
815        assert!(test_of("tests/test_title.py::test_long", &files));
816        assert!(!test_of("wordcount::us1_word_counter::other", &files));
817        assert!(!test_of("lib::tests::word_count", &files));
818    }
819
820    #[test]
821    fn the_story_s_oracle_tests_failing_are_never_the_check_s_failure() {
822        let oracle = ["tests/us1_count.rs".to_owned()];
823        // At baseline the oracle's file did not build: no test ran.
824        let before = report(vec![check("test", Failed, Some(&[]))]);
825        let after = report(vec![check(
826            "test",
827            Failed,
828            Some(&[
829                ("demo::us1_count::one", T::Passed),
830                ("demo::us1_count::two", T::Failed),
831            ]),
832        )]);
833        let cmp = compare_except(&before, &after, &oracle);
834        assert_eq!(cmp.failing_as_before, ["test"]);
835        assert!(passes(&after.checks, Some(&cmp)));
836        // Any other failing test is the change's.
837        let worse = report(vec![check(
838            "test",
839            Failed,
840            Some(&[
841                ("demo::us1_count::two", T::Failed),
842                ("demo::lib::other", T::Failed),
843            ]),
844        )]);
845        assert!(
846            compare_except(&before, &worse, &oracle)
847                .failing_as_before
848                .is_empty()
849        );
850        // Without the oracle, as before.
851        assert!(compare(&before, &after).failing_as_before.is_empty());
852    }
853
854    #[test]
855    fn comparison_finds_regressions_pre_existing_and_new() {
856        let base = report(vec![check(
857            "test",
858            Failed,
859            Some(&[("a", T::Passed), ("b", T::Passed), ("c", T::Failed)]),
860        )]);
861        let now = report(vec![check(
862            "test",
863            Failed,
864            Some(&[
865                ("a", T::Passed),
866                ("b", T::Failed),
867                ("c", T::Failed),
868                ("d", T::Passed),
869            ]),
870        )]);
871        let cmp = compare(&base, &now);
872        assert_eq!(
873            cmp.regressions,
874            [Regression {
875                id: "test/b".to_owned(),
876                now: "failed".to_owned()
877            }]
878        );
879        assert_eq!(cmp.pre_existing, ["test/c"]);
880        assert_eq!(cmp.new_tests, ["test/d"]);
881        assert_eq!((cmp.count_before, cmp.count_after), (3, 4));
882        assert!(cmp.failing_as_before.is_empty(), "b is a new failure");
883    }
884
885    #[test]
886    fn missing_test_is_a_regression() {
887        let base = report(vec![check(
888            "test",
889            Passed,
890            Some(&[("a", T::Passed), ("b", T::Passed)]),
891        )]);
892        let now = report(vec![check("test", Passed, Some(&[("a", T::Passed)]))]);
893        let cmp = compare(&base, &now);
894        assert_eq!(cmp.regressions[0].now, "missing");
895        assert!(!passes(&now.checks, Some(&cmp)));
896    }
897
898    #[test]
899    fn pre_existing_failures_alone_do_not_fail_against_a_baseline() {
900        let base = report(vec![check(
901            "test",
902            Failed,
903            Some(&[("a", T::Passed), ("c", T::Failed)]),
904        )]);
905        let now = report(vec![check(
906            "test",
907            Failed,
908            Some(&[("a", T::Passed), ("c", T::Failed)]),
909        )]);
910        let cmp = compare(&base, &now);
911        assert_eq!(cmp.failing_as_before, ["test"]);
912        assert!(passes(&now.checks, Some(&cmp)));
913        assert!(
914            !passes(&now.checks, None),
915            "without a baseline a failing check fails"
916        );
917    }
918
919    #[test]
920    fn verdict_levels() {
921        let passing = check("unit", Passed, Some(&[("a", T::Passed)]));
922        let empty = check("unit", Passed, Some(&[]));
923        let lint = check("lint", Passed, None);
924        assert_eq!(
925            verdict(&["unit"], &[passing.clone(), lint.clone()], None),
926            Verdict::Verified
927        );
928        assert_eq!(
929            verdict(&[], &[passing.clone(), lint.clone()], None),
930            Verdict::RegressionOnly
931        );
932        assert_eq!(
933            verdict(&["unit"], &[empty], None),
934            Verdict::RegressionOnly,
935            "zero tests is no evidence"
936        );
937        assert_eq!(
938            verdict(&["lint"], &[lint], None),
939            Verdict::Verified,
940            "a passing command check is evidence"
941        );
942        let failing = check("lint", Failed, None);
943        assert!(matches!(
944            verdict(&["unit"], &[passing, failing], None),
945            Verdict::Failed { .. }
946        ));
947    }
948
949    fn summary(failed: usize, build_failed: bool, unresolved_only: bool) -> RunnerSummary {
950        RunnerSummary {
951            passed: 0,
952            failed,
953            errors: 0,
954            build_failed,
955            unresolved_only,
956        }
957    }
958
959    #[test]
960    fn red_is_an_assertion_or_an_absent_symbol() {
961        use TestOutcome as T;
962        let failed = check("a", CheckStatus::Failed, Some(&[("t", T::Failed)]));
963        assert_eq!(classify_red(&failed, None), RedClass::Assertion);
964        let errored = check("a", CheckStatus::Failed, Some(&[("t", T::Error)]));
965        assert_eq!(classify_red(&errored, None), RedClass::CollectionError);
966        let empty = check("a", CheckStatus::Failed, Some(&[]));
967        assert_eq!(classify_red(&empty, None), RedClass::NoTests);
968        let plain = check("a", CheckStatus::Failed, None);
969        assert_eq!(classify_red(&plain, None), RedClass::Unclassified);
970        assert_eq!(
971            classify_red(&plain, Some(&summary(2, false, false))),
972            RedClass::Assertion
973        );
974        assert_eq!(
975            classify_red(&plain, Some(&summary(0, true, false))),
976            RedClass::BuildError
977        );
978        assert_eq!(
979            classify_red(&plain, Some(&summary(0, true, true))),
980            RedClass::Absent
981        );
982        assert_eq!(
983            classify_red(&plain, Some(&summary(0, false, false))),
984            RedClass::NoTests
985        );
986        let timed_out = check("a", CheckStatus::TimedOut, None);
987        assert_eq!(
988            classify_red(&timed_out, Some(&summary(1, false, false))),
989            RedClass::Timeout
990        );
991        let passed = check("a", CheckStatus::Passed, Some(&[("t", T::Passed)]));
992        assert_eq!(classify_red(&passed, None), RedClass::Passed);
993        assert!(RedClass::Assertion.is_red() && RedClass::Absent.is_red());
994        assert!(!RedClass::BuildError.is_red() && !RedClass::Flaky.is_red());
995    }
996
997    #[test]
998    fn a_flaky_pass_is_not_evidence_and_errors_regress() {
999        use TestOutcome as T;
1000        let mut c = check("acceptance", CheckStatus::Passed, None);
1001        assert!(c.is_evidence());
1002        c.flaky = true;
1003        assert!(!c.is_evidence());
1004        assert_eq!(
1005            verdict(&["acceptance"], &[c], None),
1006            Verdict::RegressionOnly
1007        );
1008
1009        let before = report(vec![check(
1010            "t",
1011            CheckStatus::Passed,
1012            Some(&[("x", T::Passed)]),
1013        )]);
1014        let after = report(vec![check(
1015            "t",
1016            CheckStatus::Failed,
1017            Some(&[("x", T::Error)]),
1018        )]);
1019        let cmp = compare(&before, &after);
1020        assert_eq!(cmp.regressions[0].now, "error");
1021        let again = compare(&after, &after);
1022        assert_eq!(again.pre_existing, ["t/x"]);
1023    }
1024
1025    mod properties {
1026        use super::*;
1027        use proptest::prelude::*;
1028
1029        fn statuses() -> impl Strategy<Value = CheckStatus> {
1030            prop_oneof![
1031                Just(CheckStatus::Passed),
1032                Just(CheckStatus::Failed),
1033                Just(CheckStatus::TimedOut),
1034                Just(CheckStatus::Skipped),
1035                Just(CheckStatus::Error),
1036            ]
1037        }
1038
1039        fn outcomes() -> impl Strategy<Value = TestOutcome> {
1040            prop_oneof![
1041                Just(T::Passed),
1042                Just(T::Failed),
1043                Just(T::Skipped),
1044                Just(T::Error)
1045            ]
1046        }
1047
1048        fn checks() -> impl Strategy<Value = Vec<CheckResult>> {
1049            prop::collection::vec(
1050                (
1051                    statuses(),
1052                    prop::option::of(prop::collection::vec(outcomes(), 0..5)),
1053                    any::<bool>(),
1054                ),
1055                1..5,
1056            )
1057            .prop_map(|v| {
1058                v.into_iter()
1059                    .enumerate()
1060                    .map(|(i, (status, tests, flaky))| CheckResult {
1061                        name: format!("c{i}"),
1062                        argv: vec!["x".to_owned()],
1063                        status,
1064                        exit_code: None,
1065                        duration_ms: 0,
1066                        junit: tests.is_some(),
1067                        tests: tests.map(|t| {
1068                            t.into_iter()
1069                                .enumerate()
1070                                .map(|(j, outcome)| TestResult {
1071                                    id: format!("t{j}"),
1072                                    outcome,
1073                                })
1074                                .collect()
1075                        }),
1076                        stdout_tail: String::new(),
1077                        stderr_tail: String::new(),
1078                        truncated: false,
1079                        note: None,
1080                        flaky,
1081                    })
1082                    .collect()
1083            })
1084        }
1085
1086        fn held_outs() -> impl Strategy<Value = HeldOut> {
1087            prop_oneof![
1088                Just(HeldOut::Off),
1089                Just(HeldOut::Absent {
1090                    reason: "not_red".to_owned()
1091                }),
1092                (0..4usize).prop_map(|files| HeldOut::Passed { files }),
1093                proptest::collection::vec("US[1-3]-[1-4]", 1..4)
1094                    .prop_map(|scenarios| HeldOut::Failed { scenarios }),
1095            ]
1096        }
1097
1098        proptest! {
1099            #[test]
1100            fn a_held_out_failure_is_never_a_pass(cs in checks(), pick in 0..5usize, h in held_outs()) {
1101                let name = format!("c{pick}");
1102                let before = verdict(&[name.as_str()], &cs, None);
1103                let after = with_held_out(before.clone(), &h);
1104                match h {
1105                    HeldOut::Failed { .. } => {
1106                        let failed = matches!(after, Verdict::Failed { .. });
1107                        prop_assert!(failed);
1108                    }
1109                    _ => prop_assert_eq!(after, before),
1110                }
1111            }
1112
1113            #[test]
1114            fn any_failing_check_means_failed(cs in checks(), pick in 0..5usize) {
1115                let task = [format!("c{pick}")];
1116                let task: Vec<&str> = task.iter().map(String::as_str).collect();
1117                let v = verdict(&task, &cs, None);
1118                let any_failure = cs.iter().any(|c| c.status.is_failure());
1119                prop_assert_eq!(matches!(v, Verdict::Failed { .. }), any_failure);
1120            }
1121
1122            #[test]
1123            fn verified_requires_task_evidence(cs in checks(), pick in 0..5usize) {
1124                let name = format!("c{pick}");
1125                let v = verdict(&[name.as_str()], &cs, None);
1126                if v == Verdict::Verified {
1127                    let c = cs.iter().find(|c| c.name == name);
1128                    prop_assert!(c.is_some_and(CheckResult::is_evidence));
1129                }
1130            }
1131
1132            #[test]
1133            fn a_flaky_task_check_is_never_verified(cs in checks(), pick in 0..5usize) {
1134                let name = format!("c{pick}");
1135                let v = verdict(&[name.as_str()], &cs, None);
1136                if cs.iter().find(|c| c.name == name).is_some_and(|c| c.flaky) {
1137                    prop_assert_ne!(v, Verdict::Verified);
1138                }
1139            }
1140
1141            #[test]
1142            fn red_always_has_a_cause(
1143                cs in checks(),
1144                passed in 0..3usize,
1145                failed in 0..3usize,
1146                errors in 0..3usize,
1147                build_failed in any::<bool>(),
1148                unresolved_only in any::<bool>(),
1149                with_summary in any::<bool>(),
1150            ) {
1151                let summary = RunnerSummary { passed, failed, errors, build_failed, unresolved_only };
1152                let summary = with_summary.then_some(summary);
1153                for c in &cs {
1154                    let class = classify_red(c, summary.as_ref());
1155                    if class.is_red() {
1156                        let assertion_in_report = c
1157                            .tests
1158                            .iter()
1159                            .flatten()
1160                            .any(|t| t.outcome == T::Failed);
1161                        let by_summary = summary.is_some_and(|s| s.failed > 0 || (s.build_failed && s.unresolved_only));
1162                        prop_assert!(assertion_in_report || by_summary);
1163                        prop_assert_ne!(c.status, CheckStatus::TimedOut);
1164                    }
1165                }
1166            }
1167
1168            #[test]
1169            fn a_run_compared_with_itself_has_no_regressions(cs in checks()) {
1170                let r = report(cs);
1171                let cmp = compare(&r, &r);
1172                prop_assert!(cmp.regressions.is_empty());
1173                prop_assert!(cmp.new_tests.is_empty());
1174                prop_assert_eq!(cmp.count_before, cmp.count_after);
1175            }
1176
1177            #[test]
1178            fn regressions_always_fail_the_verdict(cs in checks()) {
1179                let before = report(cs.clone());
1180                let mut after = report(cs);
1181                for c in &mut after.checks {
1182                    if let Some(tests) = &mut c.tests {
1183                        tests.clear();
1184                    }
1185                }
1186                let cmp = compare(&before, &after);
1187                let had_passing = cmp.count_before > 0
1188                    && before.checks.iter().flat_map(|c| c.tests.iter().flatten()).any(|t| t.outcome == T::Passed);
1189                if had_passing {
1190                    let failed = matches!(verdict(&[], &after.checks, Some(&cmp)), Verdict::Failed { .. });
1191                    prop_assert!(failed);
1192                }
1193            }
1194        }
1195    }
1196}