nextest_runner/record/
rerun.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Rerun support for nextest.
5//!
6//! This module provides types and functions for rerunning tests that failed or
7//! didn't complete in a previous recorded run.
8
9use crate::{
10    errors::RecordReadError,
11    list::OwnedTestInstanceId,
12    record::{
13        CoreEventKind, OutputEventKind, PortableRecording, RecordReader, StoreReader,
14        TestEventKindSummary,
15        format::{RerunInfo, RerunRootInfo, RerunTestSuiteInfo},
16    },
17};
18use iddqd::IdOrdMap;
19use nextest_metadata::{
20    FilterMatch, MismatchReason, RustBinaryId, RustTestSuiteStatusSummary, TestCaseName,
21    TestListSummary,
22};
23use quick_junit::ReportUuid;
24use std::{
25    borrow::Borrow,
26    collections::{BTreeSet, HashMap},
27};
28
29/// Trait abstracting over test list access for rerun computation.
30///
31/// This allows the same logic to work with both the real [`TestListSummary`]
32/// and a simplified model for property-based testing.
33pub(crate) trait TestListInfo {
34    /// Iterator type for binaries.
35    type BinaryIter<'a>: Iterator<Item = (&'a RustBinaryId, BinaryInfo<'a>)>
36    where
37        Self: 'a;
38
39    /// Returns an iterator over all binaries in the test list.
40    fn binaries(&self) -> Self::BinaryIter<'_>;
41}
42
43/// Information about a single binary in the test list.
44pub(crate) enum BinaryInfo<'a> {
45    /// Binary was listed; contains test cases.
46    Listed {
47        /// Iterator over test cases: (name, filter match).
48        test_cases: Box<dyn Iterator<Item = (&'a TestCaseName, FilterMatch)> + 'a>,
49    },
50    /// Binary was skipped (not listed).
51    Skipped,
52}
53
54impl TestListInfo for TestListSummary {
55    type BinaryIter<'a> = TestListSummaryBinaryIter<'a>;
56
57    fn binaries(&self) -> Self::BinaryIter<'_> {
58        TestListSummaryBinaryIter {
59            inner: self.rust_suites.iter(),
60        }
61    }
62}
63
64/// Iterator over binaries in a [`TestListSummary`].
65pub(crate) struct TestListSummaryBinaryIter<'a> {
66    inner:
67        std::collections::btree_map::Iter<'a, RustBinaryId, nextest_metadata::RustTestSuiteSummary>,
68}
69
70impl<'a> Iterator for TestListSummaryBinaryIter<'a> {
71    type Item = (&'a RustBinaryId, BinaryInfo<'a>);
72
73    fn next(&mut self) -> Option<Self::Item> {
74        self.inner.next().map(|(binary_id, suite)| {
75            let info = if suite.status == RustTestSuiteStatusSummary::LISTED {
76                BinaryInfo::Listed {
77                    test_cases: Box::new(
78                        suite
79                            .test_cases
80                            .iter()
81                            .map(|(name, tc)| (name, tc.filter_match)),
82                    ),
83                }
84            } else {
85                BinaryInfo::Skipped
86            };
87            (binary_id, info)
88        })
89    }
90}
91
92/// Pure computation of outstanding tests.
93pub(crate) fn compute_outstanding_pure(
94    prev_info: Option<&IdOrdMap<RerunTestSuiteInfo>>,
95    test_list: &impl TestListInfo,
96    outcomes: &HashMap<OwnedTestInstanceId, TestOutcome>,
97) -> IdOrdMap<RerunTestSuiteInfo> {
98    let mut new_outstanding = IdOrdMap::new();
99
100    // Track which binaries were in the test list (listed or skipped) so we can
101    // distinguish between "binary is in test list but has no tests to track"
102    // vs "binary is not in test list at all".
103    let mut binaries_in_test_list = BTreeSet::new();
104
105    for (binary_id, binary_info) in test_list.binaries() {
106        binaries_in_test_list.insert(binary_id.clone());
107
108        match binary_info {
109            BinaryInfo::Listed { test_cases } => {
110                // The binary was listed, so we can rely on the set of test cases
111                // produced by it.
112                let prev = prev_info.and_then(|p| p.get(binary_id));
113
114                let mut curr = RerunTestSuiteInfo::new(binary_id.clone());
115                for (test_name, filter_match) in test_cases {
116                    match filter_match {
117                        FilterMatch::Matches => {
118                            // This test should have been run.
119                            let key = OwnedTestInstanceId {
120                                binary_id: binary_id.clone(),
121                                test_name: test_name.clone(),
122                            };
123                            match outcomes.get(&key) {
124                                Some(TestOutcome::Passed) => {
125                                    // This test passed.
126                                    curr.passing.insert(test_name.clone());
127                                }
128                                Some(TestOutcome::Failed) => {
129                                    // This test failed, and so is outstanding.
130                                    curr.outstanding.insert(test_name.clone());
131                                }
132                                Some(TestOutcome::Skipped(skipped)) => {
133                                    // This is strange! FilterMatch::Matches means
134                                    // the test should not be skipped. But compute
135                                    // this anyway.
136                                    handle_skipped(test_name, *skipped, prev, &mut curr);
137                                }
138                                None => {
139                                    // The test was scheduled, but was not seen in
140                                    // the event log. It must be re-run.
141                                    curr.outstanding.insert(test_name.clone());
142                                }
143                            }
144                        }
145                        FilterMatch::Mismatch { reason } => {
146                            handle_skipped(
147                                test_name,
148                                TestOutcomeSkipped::from_mismatch_reason(reason),
149                                prev,
150                                &mut curr,
151                            );
152                        }
153                    }
154                }
155
156                // Any outstanding tests that were not accounted for in the
157                // loop above should be carried forward, since we're still
158                // tracking them.
159                if let Some(prev) = prev {
160                    for t in &prev.outstanding {
161                        if !curr.passing.contains(t) && !curr.outstanding.contains(t) {
162                            curr.outstanding.insert(t.clone());
163                        }
164                    }
165                }
166
167                // What about tests that were originally passing, and now not
168                // present? We want to treat them as implicitly outstanding (not
169                // actively tracking, but if they show up again we'll want to
170                // re-run them).
171
172                // Only insert if there are tests to track.
173                if !curr.passing.is_empty() || !curr.outstanding.is_empty() {
174                    new_outstanding
175                        .insert_unique(curr)
176                        .expect("binaries iterator should not yield duplicates");
177                }
178            }
179            BinaryInfo::Skipped => {
180                // The suite was not listed.
181                //
182                // If this is an original run, then there's not much we can do. (If
183                // the subsequent rerun causes a test to be included, it will be run
184                // by dint of not being in the passing set.)
185                //
186                // If this is a rerun, then we should carry forward the cached list
187                // of passing tests for this binary. The next time the binary is
188                // seen, we'll reuse the serialized cached list.
189                if let Some(prev_outstanding) = prev_info
190                    && let Some(outstanding) = prev_outstanding.get(binary_id)
191                {
192                    // We know the set of outstanding tests.
193                    new_outstanding
194                        .insert_unique(outstanding.clone())
195                        .expect("binaries iterator should not yield duplicates");
196                }
197                // Else: An interesting case -- the test suite was discovered but
198                // not listed, and also was not known. Not much we can do
199                // here for now, but maybe we want to track this explicitly
200                // in the future?
201            }
202        }
203    }
204
205    // Carry forward binaries from previous run that are not in the current test
206    // list at all (neither listed nor skipped).
207    if let Some(prev) = prev_info {
208        for prev_suite in prev.iter() {
209            if !binaries_in_test_list.contains(&prev_suite.binary_id) {
210                new_outstanding
211                    .insert_unique(prev_suite.clone())
212                    .expect("binary not in test list, so this should succeed");
213            }
214        }
215    }
216
217    new_outstanding
218}
219
220/// Result of computing outstanding and passing tests from a recorded run.
221#[derive(Clone, Debug)]
222pub struct ComputedRerunInfo {
223    /// The set of tests that are outstanding.
224    ///
225    /// This set is serialized into `rerun-info.json`.
226    pub test_suites: IdOrdMap<RerunTestSuiteInfo>,
227}
228
229impl ComputedRerunInfo {
230    /// Returns the set of all outstanding test instance IDs.
231    ///
232    /// This is used to track which tests were expected to run in a rerun.
233    pub fn expected_test_ids(&self) -> BTreeSet<OwnedTestInstanceId> {
234        self.test_suites
235            .iter()
236            .flat_map(|suite| {
237                suite.outstanding.iter().map(|name| OwnedTestInstanceId {
238                    binary_id: suite.binary_id.clone(),
239                    test_name: name.clone(),
240                })
241            })
242            .collect()
243    }
244
245    /// Computes outstanding tests from a recorded run.
246    ///
247    /// If this is a rerun chain, also returns information about the root of the
248    /// chain.
249    pub fn compute(
250        reader: &mut RecordReader,
251    ) -> Result<(Self, Option<RerunRootInfo>), RecordReadError> {
252        let rerun_info = reader.read_rerun_info()?;
253        let test_list = reader.read_test_list()?;
254        let outcomes = TestEventOutcomes::collect(reader)?;
255
256        let prev_test_suites = rerun_info.as_ref().map(|info| &info.test_suites);
257        let new_test_suites =
258            compute_outstanding_pure(prev_test_suites, &test_list, &outcomes.outcomes);
259
260        let root_info = rerun_info.map(|info| info.root_info);
261
262        Ok((
263            Self {
264                test_suites: new_test_suites,
265            },
266            root_info,
267        ))
268    }
269
270    /// Computes outstanding tests from a portable recording.
271    ///
272    /// If the archive is itself a rerun, also returns information about the
273    /// root of the chain.
274    pub fn compute_from_archive(
275        archive: &mut PortableRecording,
276    ) -> Result<(Self, Option<RerunRootInfo>), RecordReadError> {
277        let mut store = archive
278            .open_store()
279            .map_err(RecordReadError::PortableRecording)?;
280        let rerun_info = store.read_rerun_info()?;
281        let test_list = store.read_test_list()?;
282        // No need to load dictionaries since we're not using them.
283
284        // Read events from the archive's run log.
285        let run_log = archive
286            .read_run_log()
287            .map_err(RecordReadError::PortableRecording)?;
288        let outcomes = collect_from_events(run_log.events()?.map(|r| r.map(|e| e.kind)))?;
289
290        let prev_test_suites = rerun_info.as_ref().map(|info| &info.test_suites);
291        let new_test_suites = compute_outstanding_pure(prev_test_suites, &test_list, &outcomes);
292
293        let root_info = rerun_info.map(|info| info.root_info);
294
295        Ok((
296            Self {
297                test_suites: new_test_suites,
298            },
299            root_info,
300        ))
301    }
302
303    /// Consumes self, converting to a [`RerunInfo`] for storage.
304    pub fn into_rerun_info(self, parent_run_id: ReportUuid, root_info: RerunRootInfo) -> RerunInfo {
305        RerunInfo {
306            parent_run_id,
307            root_info,
308            test_suites: self.test_suites,
309        }
310    }
311}
312
313fn handle_skipped(
314    test_name: &TestCaseName,
315    skipped: TestOutcomeSkipped,
316    prev: Option<&RerunTestSuiteInfo>,
317    curr: &mut RerunTestSuiteInfo,
318) {
319    match skipped {
320        TestOutcomeSkipped::Rerun => {
321            // This test was skipped due to having passed in a prior run in this
322            // rerun chain. Add it to passing.
323            //
324            // Note that if a test goes from passing to not being present in the
325            // list at all, and then back to being present, it becomes
326            // outstanding. This is deliberate.
327            curr.passing.insert(test_name.clone());
328        }
329        TestOutcomeSkipped::Explicit => {
330            // If a test is explicitly skipped, the behavior depends on whether
331            // this is the rerun of an initial run or part of a rerun chain.
332            //
333            // If this is a rerun of an initial run, then it doesn't make sense
334            // to add the test to the outstanding list, because the user
335            // explicitly skipped it.
336            //
337            // If this is a rerun chain, then whether it is still outstanding
338            // depends on whether it was originally outstanding. If it was
339            // originally outstanding, then that should be carried forward. If
340            // it was originally passing, we should assume that that hasn't
341            // changed and it is still passing. If neither, then it's not part
342            // of the set of tests we care about.
343            if let Some(prev) = prev {
344                if prev.outstanding.contains(test_name) {
345                    curr.outstanding.insert(test_name.clone());
346                } else if prev.passing.contains(test_name) {
347                    curr.passing.insert(test_name.clone());
348                }
349            } else {
350                // This is either not a rerun chain, or it is a rerun chain and
351                // this binary has never been seen before.
352            }
353        }
354    }
355}
356
357/// Reason why a test was skipped.
358#[derive(Clone, Copy, Debug, PartialEq, Eq)]
359pub(crate) enum TestOutcomeSkipped {
360    /// Test was explicitly skipped by the user.
361    Explicit,
362
363    /// Test was skipped due to this being a rerun.
364    Rerun,
365}
366
367impl TestOutcomeSkipped {
368    /// Computes the skipped reason from a `MismatchReason`.
369    fn from_mismatch_reason(reason: MismatchReason) -> Self {
370        match reason {
371            MismatchReason::NotBenchmark
372            | MismatchReason::Ignored
373            | MismatchReason::String
374            | MismatchReason::Expression
375            | MismatchReason::Partition
376            | MismatchReason::DefaultFilter => TestOutcomeSkipped::Explicit,
377            MismatchReason::RerunAlreadyPassed => TestOutcomeSkipped::Rerun,
378            other => unreachable!("all known match arms are covered, found {other:?}"),
379        }
380    }
381}
382
383/// Outcome of a single test from a run's event log.
384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
385pub(crate) enum TestOutcome {
386    /// Test passed (had a successful `TestFinished` event).
387    Passed,
388
389    /// Test was skipped.
390    Skipped(TestOutcomeSkipped),
391
392    /// Test failed (had a `TestFinished` event but did not pass).
393    Failed,
394}
395
396/// Outcomes extracted from a run's event log.
397///
398/// This is used for computing outstanding and passing tests.
399#[derive(Clone, Debug)]
400struct TestEventOutcomes {
401    /// Map from test instance to its outcome.
402    outcomes: HashMap<OwnedTestInstanceId, TestOutcome>,
403}
404
405impl TestEventOutcomes {
406    /// Collects test outcomes from the event log.
407    ///
408    /// Returns information about which tests passed and which tests were seen
409    /// (had any event: started, finished, or skipped).
410    fn collect(reader: &mut RecordReader) -> Result<Self, RecordReadError> {
411        reader.load_dictionaries()?;
412        let outcomes = collect_from_events(reader.events()?.map(|r| r.map(|e| e.kind)))?;
413
414        Ok(Self { outcomes })
415    }
416}
417
418/// Collects test outcomes from a fallible iterator of events.
419///
420/// This helper exists to make the event processing logic testable without
421/// requiring a full `RecordReader`. It accepts a fallible iterator to enable
422/// streaming without in-memory buffering.
423fn collect_from_events<K, S: crate::output_spec::OutputSpec, E>(
424    events: impl Iterator<Item = Result<K, E>>,
425) -> Result<HashMap<OwnedTestInstanceId, TestOutcome>, E>
426where
427    K: Borrow<TestEventKindSummary<S>>,
428{
429    let mut outcomes = HashMap::new();
430
431    for kind_result in events {
432        let kind = kind_result?;
433        match kind.borrow() {
434            TestEventKindSummary::Output(OutputEventKind::TestFinished {
435                test_instance,
436                run_statuses,
437                ..
438            }) => {
439                // Determine outcome for this iteration/finish event.
440                let outcome = if run_statuses.last_status().result.is_success() {
441                    TestOutcome::Passed
442                } else {
443                    TestOutcome::Failed
444                };
445
446                // For stress runs: multiple TestFinished events occur for the
447                // same test_instance (one per stress iteration). The overall
448                // outcome is Failed if any iteration failed.
449                //
450                // We use entry() to only "upgrade" from Passed to Failed, never
451                // downgrade. This ensures [Pass, Fail, Pass] → Failed.
452                outcomes
453                    .entry(test_instance.clone())
454                    .and_modify(|existing| {
455                        if outcome == TestOutcome::Failed {
456                            *existing = TestOutcome::Failed;
457                        }
458                    })
459                    .or_insert(outcome);
460            }
461            TestEventKindSummary::Core(CoreEventKind::TestSkipped {
462                test_instance,
463                reason,
464                ..
465            }) => {
466                let skipped_reason = TestOutcomeSkipped::from_mismatch_reason(*reason);
467                outcomes.insert(test_instance.clone(), TestOutcome::Skipped(skipped_reason));
468            }
469            _ => {}
470        }
471    }
472
473    Ok(outcomes)
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::{
480        output_spec::RecordingSpec,
481        record::{
482            OutputEventKind, StressIndexSummary, TestEventKindSummary, ZipStoreOutputDescription,
483        },
484        reporter::{
485            TestOutputDisplay,
486            events::{
487                ChildExecutionOutputDescription, ExecuteStatus, ExecutionResultDescription,
488                ExecutionStatuses, FailureDescription, RetryData, RunStats,
489            },
490        },
491    };
492    use chrono::Utc;
493    use proptest::prelude::*;
494    use std::{
495        collections::{BTreeMap, btree_map},
496        convert::Infallible,
497        num::NonZero,
498        sync::OnceLock,
499        time::Duration,
500    };
501    use test_strategy::proptest;
502
503    // ---
504    // Tests
505    // ---
506
507    /// Main property: the SUT matches the oracle.
508    #[proptest(cases = 200)]
509    fn sut_matches_oracle(#[strategy(arb_rerun_model())] model: RerunModel) {
510        let expected = model.compute_rerun_info_decision_table();
511        let actual = run_sut(&model);
512        prop_assert_eq!(actual, expected);
513    }
514
515    /// Property: passing and outstanding are always disjoint.
516    #[proptest(cases = 200)]
517    fn passing_and_outstanding_disjoint(#[strategy(arb_rerun_model())] model: RerunModel) {
518        let result = run_sut(&model);
519        for suite in result.iter() {
520            let intersection: BTreeSet<_> =
521                suite.passing.intersection(&suite.outstanding).collect();
522            prop_assert!(
523                intersection.is_empty(),
524                "passing and outstanding should be disjoint for {}: {:?}",
525                suite.binary_id,
526                intersection
527            );
528        }
529    }
530
531    /// Property: every matching test with a definitive outcome ends up in either
532    /// passing or outstanding.
533    ///
534    /// Tests that are explicitly skipped (with no prior tracking history) are
535    /// not tracked, so they may not be in either set.
536    #[proptest(cases = 200)]
537    fn matching_tests_with_outcomes_are_tracked(#[strategy(arb_rerun_model())] model: RerunModel) {
538        let result = run_sut(&model);
539
540        // Check final state against final test list.
541        let final_step = model.reruns.last().unwrap_or(&model.initial);
542
543        for (binary_id, binary_model) in &final_step.test_list.binaries {
544            if let BinaryModel::Listed { tests } = binary_model {
545                let rust_binary_id = binary_id.rust_binary_id();
546
547                for (test_name, filter_match) in tests {
548                    if matches!(filter_match, FilterMatch::Matches) {
549                        let key = (*binary_id, *test_name);
550                        let outcome = final_step.outcomes.get(&key);
551
552                        // Tests with Passed/Failed/Skipped(Rerun) or no outcome
553                        // (not seen) should be tracked. Tests with
554                        // Skipped(Explicit) might not be tracked if there's no
555                        // prior history.
556                        let should_be_tracked = match outcome {
557                            Some(TestOutcome::Passed)
558                            | Some(TestOutcome::Failed)
559                            | Some(TestOutcome::Skipped(TestOutcomeSkipped::Rerun))
560                            | None => true,
561                            Some(TestOutcome::Skipped(TestOutcomeSkipped::Explicit)) => false,
562                        };
563
564                        if should_be_tracked {
565                            let tcn = test_name.test_case_name();
566                            let suite = result.get(&rust_binary_id);
567                            let in_passing = suite.is_some_and(|s| s.passing.contains(tcn));
568                            let in_outstanding = suite.is_some_and(|s| s.outstanding.contains(tcn));
569                            prop_assert!(
570                                in_passing || in_outstanding,
571                                "matching test {:?}::{:?} with outcome {:?} should be in passing or outstanding",
572                                binary_id,
573                                test_name,
574                                outcome
575                            );
576                        }
577                    }
578                }
579            }
580        }
581    }
582
583    /// Test the decision table function directly with all combinations.
584    #[test]
585    fn decide_test_outcome_truth_table() {
586        use Decision as D;
587        use FilterMatchResult as F;
588        use PrevStatus as P;
589
590        // Binary not present: carry forward previous status.
591        assert_eq!(
592            decide_test_outcome(P::Passing, F::BinaryNotPresent, None),
593            D::Passing
594        );
595        assert_eq!(
596            decide_test_outcome(P::Outstanding, F::BinaryNotPresent, None),
597            D::Outstanding
598        );
599        assert_eq!(
600            decide_test_outcome(P::Unknown, F::BinaryNotPresent, None),
601            D::NotTracked
602        );
603
604        // Binary skipped: carry forward previous status.
605        assert_eq!(
606            decide_test_outcome(P::Passing, F::BinarySkipped, None),
607            D::Passing
608        );
609        assert_eq!(
610            decide_test_outcome(P::Outstanding, F::BinarySkipped, None),
611            D::Outstanding
612        );
613        assert_eq!(
614            decide_test_outcome(P::Unknown, F::BinarySkipped, None),
615            D::NotTracked
616        );
617
618        // Test not in list: only carry forward outstanding.
619        assert_eq!(
620            decide_test_outcome(P::Passing, F::TestNotInList, None),
621            D::NotTracked
622        );
623        assert_eq!(
624            decide_test_outcome(P::Outstanding, F::TestNotInList, None),
625            D::Outstanding
626        );
627        assert_eq!(
628            decide_test_outcome(P::Unknown, F::TestNotInList, None),
629            D::NotTracked
630        );
631
632        // FilterMatch::Matches with various outcomes.
633        let matches = F::HasMatch(FilterMatch::Matches);
634
635        // Passed -> Passing.
636        assert_eq!(
637            decide_test_outcome(P::Unknown, matches, Some(TestOutcome::Passed)),
638            D::Passing
639        );
640        assert_eq!(
641            decide_test_outcome(P::Passing, matches, Some(TestOutcome::Passed)),
642            D::Passing
643        );
644        assert_eq!(
645            decide_test_outcome(P::Outstanding, matches, Some(TestOutcome::Passed)),
646            D::Passing
647        );
648
649        // Failed -> Outstanding.
650        assert_eq!(
651            decide_test_outcome(P::Unknown, matches, Some(TestOutcome::Failed)),
652            D::Outstanding
653        );
654        assert_eq!(
655            decide_test_outcome(P::Passing, matches, Some(TestOutcome::Failed)),
656            D::Outstanding
657        );
658        assert_eq!(
659            decide_test_outcome(P::Outstanding, matches, Some(TestOutcome::Failed)),
660            D::Outstanding
661        );
662
663        // Not seen (None outcome) -> Outstanding.
664        assert_eq!(
665            decide_test_outcome(P::Unknown, matches, None),
666            D::Outstanding
667        );
668        assert_eq!(
669            decide_test_outcome(P::Passing, matches, None),
670            D::Outstanding
671        );
672        assert_eq!(
673            decide_test_outcome(P::Outstanding, matches, None),
674            D::Outstanding
675        );
676
677        // Skipped(Rerun) -> Passing.
678        let rerun_skipped = Some(TestOutcome::Skipped(TestOutcomeSkipped::Rerun));
679        assert_eq!(
680            decide_test_outcome(P::Unknown, matches, rerun_skipped),
681            D::Passing
682        );
683        assert_eq!(
684            decide_test_outcome(P::Passing, matches, rerun_skipped),
685            D::Passing
686        );
687        assert_eq!(
688            decide_test_outcome(P::Outstanding, matches, rerun_skipped),
689            D::Passing
690        );
691
692        // Skipped(Explicit) -> carry forward.
693        let explicit_skipped = Some(TestOutcome::Skipped(TestOutcomeSkipped::Explicit));
694        assert_eq!(
695            decide_test_outcome(P::Unknown, matches, explicit_skipped),
696            D::NotTracked
697        );
698        assert_eq!(
699            decide_test_outcome(P::Passing, matches, explicit_skipped),
700            D::Passing
701        );
702        assert_eq!(
703            decide_test_outcome(P::Outstanding, matches, explicit_skipped),
704            D::Outstanding
705        );
706
707        // FilterMatch::Mismatch with RerunAlreadyPassed -> Passing.
708        let rerun_mismatch = F::HasMatch(FilterMatch::Mismatch {
709            reason: MismatchReason::RerunAlreadyPassed,
710        });
711        assert_eq!(
712            decide_test_outcome(P::Unknown, rerun_mismatch, None),
713            D::Passing
714        );
715        assert_eq!(
716            decide_test_outcome(P::Passing, rerun_mismatch, None),
717            D::Passing
718        );
719        assert_eq!(
720            decide_test_outcome(P::Outstanding, rerun_mismatch, None),
721            D::Passing
722        );
723
724        // FilterMatch::Mismatch with other reasons -> carry forward.
725        let explicit_mismatch = F::HasMatch(FilterMatch::Mismatch {
726            reason: MismatchReason::Ignored,
727        });
728        assert_eq!(
729            decide_test_outcome(P::Unknown, explicit_mismatch, None),
730            D::NotTracked
731        );
732        assert_eq!(
733            decide_test_outcome(P::Passing, explicit_mismatch, None),
734            D::Passing
735        );
736        assert_eq!(
737            decide_test_outcome(P::Outstanding, explicit_mismatch, None),
738            D::Outstanding
739        );
740    }
741
742    // ---
743    // Spec property verification
744    // ---
745    //
746    // These tests verify properties of the decision table itself (not the
747    // implementation). Since the (sub)domain is finite, we enumerate all cases.
748
749    /// All possible previous states.
750    const ALL_PREV_STATUSES: [PrevStatus; 3] = [
751        PrevStatus::Passing,
752        PrevStatus::Outstanding,
753        PrevStatus::Unknown,
754    ];
755
756    /// All possible outcomes (including None = not seen).
757    fn all_outcomes() -> [Option<TestOutcome>; 5] {
758        [
759            None,
760            Some(TestOutcome::Passed),
761            Some(TestOutcome::Failed),
762            Some(TestOutcome::Skipped(TestOutcomeSkipped::Rerun)),
763            Some(TestOutcome::Skipped(TestOutcomeSkipped::Explicit)),
764        ]
765    }
766
767    /// All HasMatch filter results (test is in the list).
768    fn all_in_list_filter_results() -> Vec<FilterMatchResult> {
769        let mut results = vec![FilterMatchResult::HasMatch(FilterMatch::Matches)];
770        for &reason in MismatchReason::ALL_VARIANTS {
771            results.push(FilterMatchResult::HasMatch(FilterMatch::Mismatch {
772                reason,
773            }));
774        }
775        results
776    }
777
778    /// Spec property: Passing tests stay Passing under non-regressing conditions.
779    ///
780    /// A test that was Passing remains Passing if:
781    /// - It's still in the test list (any HasMatch variant)
782    /// - Its outcome is non-regressing (Passed, Skipped(Rerun), or Skipped(Explicit))
783    ///
784    /// Verified exhaustively: 8 filter variants × 3 outcomes = 24 cases.
785    #[test]
786    fn spec_property_passing_monotonicity() {
787        let non_regressing_outcomes = [
788            Some(TestOutcome::Passed),
789            Some(TestOutcome::Skipped(TestOutcomeSkipped::Rerun)),
790            Some(TestOutcome::Skipped(TestOutcomeSkipped::Explicit)),
791        ];
792
793        for filter in all_in_list_filter_results() {
794            for outcome in non_regressing_outcomes {
795                let decision = decide_test_outcome(PrevStatus::Passing, filter, outcome);
796                assert_eq!(
797                    decision,
798                    Decision::Passing,
799                    "monotonicity violated: Passing + {:?} + {:?} -> {:?}",
800                    filter,
801                    outcome,
802                    decision
803                );
804            }
805        }
806    }
807
808    /// Spec property: Outstanding tests become Passing when they pass.
809    ///
810    /// This is the convergence property: the only way out of Outstanding is to
811    /// pass.
812    #[test]
813    fn spec_property_outstanding_to_passing_on_pass() {
814        let passing_outcomes = [
815            Some(TestOutcome::Passed),
816            Some(TestOutcome::Skipped(TestOutcomeSkipped::Rerun)),
817        ];
818
819        for outcome in passing_outcomes {
820            let decision = decide_test_outcome(
821                PrevStatus::Outstanding,
822                FilterMatchResult::HasMatch(FilterMatch::Matches),
823                outcome,
824            );
825            assert_eq!(
826                decision,
827                Decision::Passing,
828                "convergence violated: Outstanding + Matches + {:?} -> {:?}",
829                outcome,
830                decision
831            );
832        }
833    }
834
835    /// Spec property: Failed or not-seen tests become Outstanding.
836    ///
837    /// If a test matches the filter but fails or isn't seen, it's outstanding.
838    #[test]
839    fn spec_property_failed_becomes_outstanding() {
840        let failing_outcomes = [None, Some(TestOutcome::Failed)];
841
842        for prev in ALL_PREV_STATUSES {
843            for outcome in failing_outcomes {
844                let decision = decide_test_outcome(
845                    prev,
846                    FilterMatchResult::HasMatch(FilterMatch::Matches),
847                    outcome,
848                );
849                assert_eq!(
850                    decision,
851                    Decision::Outstanding,
852                    "FAILED->OUTSTANDING VIOLATED: {:?} + Matches + {:?} -> {:?}",
853                    prev,
854                    outcome,
855                    decision
856                );
857            }
858        }
859    }
860
861    /// Spec property: Carry-forward preserves Outstanding but drops Passing for
862    /// tests not in the list.
863    ///
864    /// When a listed binary no longer contains a test (TestNotInList),
865    /// Outstanding is preserved but Passing is dropped (becomes NotTracked).
866    /// This ensures tests that disappear and reappear are re-run.
867    #[test]
868    fn spec_property_test_not_in_list_behavior() {
869        for outcome in all_outcomes() {
870            // Outstanding is preserved.
871            assert_eq!(
872                decide_test_outcome(
873                    PrevStatus::Outstanding,
874                    FilterMatchResult::TestNotInList,
875                    outcome
876                ),
877                Decision::Outstanding,
878            );
879            // Passing is dropped.
880            assert_eq!(
881                decide_test_outcome(
882                    PrevStatus::Passing,
883                    FilterMatchResult::TestNotInList,
884                    outcome
885                ),
886                Decision::NotTracked,
887            );
888            // Unknown stays untracked.
889            assert_eq!(
890                decide_test_outcome(
891                    PrevStatus::Unknown,
892                    FilterMatchResult::TestNotInList,
893                    outcome
894                ),
895                Decision::NotTracked,
896            );
897        }
898    }
899
900    // ---
901    // Model types
902    // ---
903
904    /// A fixed universe of binary IDs for testing.
905    ///
906    /// Using a small, fixed set ensures meaningful interactions between reruns.
907    #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
908    enum ModelBinaryId {
909        A,
910        B,
911        C,
912        D,
913    }
914
915    impl ModelBinaryId {
916        fn rust_binary_id(self) -> &'static RustBinaryId {
917            match self {
918                Self::A => {
919                    static ID: OnceLock<RustBinaryId> = OnceLock::new();
920                    ID.get_or_init(|| RustBinaryId::new("binary-a"))
921                }
922                Self::B => {
923                    static ID: OnceLock<RustBinaryId> = OnceLock::new();
924                    ID.get_or_init(|| RustBinaryId::new("binary-b"))
925                }
926                Self::C => {
927                    static ID: OnceLock<RustBinaryId> = OnceLock::new();
928                    ID.get_or_init(|| RustBinaryId::new("binary-c"))
929                }
930                Self::D => {
931                    static ID: OnceLock<RustBinaryId> = OnceLock::new();
932                    ID.get_or_init(|| RustBinaryId::new("binary-d"))
933                }
934            }
935        }
936    }
937
938    /// A fixed universe of test names for testing.
939    #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
940    enum ModelTestName {
941        Test1,
942        Test2,
943        Test3,
944        Test4,
945        Test5,
946    }
947
948    impl ModelTestName {
949        fn test_case_name(self) -> &'static TestCaseName {
950            match self {
951                Self::Test1 => {
952                    static NAME: OnceLock<TestCaseName> = OnceLock::new();
953                    NAME.get_or_init(|| TestCaseName::new("test_1"))
954                }
955                Self::Test2 => {
956                    static NAME: OnceLock<TestCaseName> = OnceLock::new();
957                    NAME.get_or_init(|| TestCaseName::new("test_2"))
958                }
959                Self::Test3 => {
960                    static NAME: OnceLock<TestCaseName> = OnceLock::new();
961                    NAME.get_or_init(|| TestCaseName::new("test_3"))
962                }
963                Self::Test4 => {
964                    static NAME: OnceLock<TestCaseName> = OnceLock::new();
965                    NAME.get_or_init(|| TestCaseName::new("test_4"))
966                }
967                Self::Test5 => {
968                    static NAME: OnceLock<TestCaseName> = OnceLock::new();
969                    NAME.get_or_init(|| TestCaseName::new("test_5"))
970                }
971            }
972        }
973    }
974
975    /// Model of a binary's state.
976    #[derive(Clone, Debug)]
977    enum BinaryModel {
978        /// Binary was listed; contains test cases with their filter match.
979        Listed {
980            tests: BTreeMap<ModelTestName, FilterMatch>,
981        },
982        /// Binary was skipped, so it cannot have tests.
983        Skipped,
984    }
985
986    /// Test list state for one run.
987    #[derive(Clone, Debug)]
988    struct TestListModel {
989        binaries: BTreeMap<ModelBinaryId, BinaryModel>,
990    }
991
992    /// A single run (initial or rerun).
993    #[derive(Clone, Debug)]
994    struct RunStep {
995        /// The test list state for this run.
996        test_list: TestListModel,
997        /// Outcomes for tests that ran.
998        outcomes: BTreeMap<(ModelBinaryId, ModelTestName), TestOutcome>,
999    }
1000
1001    /// The complete model: initial run + subsequent reruns.
1002    #[derive(Clone, Debug)]
1003    struct RerunModel {
1004        /// The initial run.
1005        initial: RunStep,
1006        /// The sequence of reruns.
1007        reruns: Vec<RunStep>,
1008    }
1009
1010    impl TestListInfo for TestListModel {
1011        type BinaryIter<'a> = TestListModelBinaryIter<'a>;
1012
1013        fn binaries(&self) -> Self::BinaryIter<'_> {
1014            TestListModelBinaryIter {
1015                inner: self.binaries.iter(),
1016            }
1017        }
1018    }
1019
1020    /// Iterator over binaries in a [`TestListModel`].
1021    struct TestListModelBinaryIter<'a> {
1022        inner: btree_map::Iter<'a, ModelBinaryId, BinaryModel>,
1023    }
1024
1025    impl<'a> Iterator for TestListModelBinaryIter<'a> {
1026        type Item = (&'a RustBinaryId, BinaryInfo<'a>);
1027
1028        fn next(&mut self) -> Option<Self::Item> {
1029            self.inner.next().map(|(model_id, binary_model)| {
1030                let rust_id = model_id.rust_binary_id();
1031                let info = match binary_model {
1032                    BinaryModel::Listed { tests } => BinaryInfo::Listed {
1033                        test_cases: Box::new(
1034                            tests.iter().map(|(name, fm)| (name.test_case_name(), *fm)),
1035                        ),
1036                    },
1037                    BinaryModel::Skipped => BinaryInfo::Skipped,
1038                };
1039                (rust_id, info)
1040            })
1041        }
1042    }
1043
1044    // ---
1045    // Generators
1046    // ---
1047
1048    fn arb_model_binary_id() -> impl Strategy<Value = ModelBinaryId> {
1049        prop_oneof![
1050            Just(ModelBinaryId::A),
1051            Just(ModelBinaryId::B),
1052            Just(ModelBinaryId::C),
1053            Just(ModelBinaryId::D),
1054        ]
1055    }
1056
1057    fn arb_model_test_name() -> impl Strategy<Value = ModelTestName> {
1058        prop_oneof![
1059            Just(ModelTestName::Test1),
1060            Just(ModelTestName::Test2),
1061            Just(ModelTestName::Test3),
1062            Just(ModelTestName::Test4),
1063            Just(ModelTestName::Test5),
1064        ]
1065    }
1066
1067    fn arb_filter_match() -> impl Strategy<Value = FilterMatch> {
1068        prop_oneof![
1069            4 => Just(FilterMatch::Matches),
1070            1 => any::<MismatchReason>().prop_map(|reason| FilterMatch::Mismatch { reason }),
1071        ]
1072    }
1073
1074    fn arb_test_outcome() -> impl Strategy<Value = TestOutcome> {
1075        prop_oneof![
1076            4 => Just(TestOutcome::Passed),
1077            2 => Just(TestOutcome::Failed),
1078            1 => Just(TestOutcome::Skipped(TestOutcomeSkipped::Explicit)),
1079            1 => Just(TestOutcome::Skipped(TestOutcomeSkipped::Rerun)),
1080        ]
1081    }
1082
1083    fn arb_test_map() -> impl Strategy<Value = BTreeMap<ModelTestName, FilterMatch>> {
1084        proptest::collection::btree_map(arb_model_test_name(), arb_filter_match(), 0..5)
1085    }
1086
1087    fn arb_binary_model() -> impl Strategy<Value = BinaryModel> {
1088        prop_oneof![
1089            8 => arb_test_map().prop_map(|tests| BinaryModel::Listed { tests }),
1090            2 => Just(BinaryModel::Skipped),
1091        ]
1092    }
1093
1094    fn arb_test_list_model() -> impl Strategy<Value = TestListModel> {
1095        proptest::collection::btree_map(arb_model_binary_id(), arb_binary_model(), 0..4)
1096            .prop_map(|binaries| TestListModel { binaries })
1097    }
1098
1099    /// Generate outcomes consistent with a test list.
1100    ///
1101    /// Only generates outcomes for tests that match the filter in listed binaries.
1102    /// Takes a list of matching tests to generate outcomes for.
1103    fn arb_outcomes_for_matching_tests(
1104        matching_tests: Vec<(ModelBinaryId, ModelTestName)>,
1105    ) -> BoxedStrategy<BTreeMap<(ModelBinaryId, ModelTestName), TestOutcome>> {
1106        if matching_tests.is_empty() {
1107            Just(BTreeMap::new()).boxed()
1108        } else {
1109            let len = matching_tests.len();
1110            proptest::collection::btree_map(
1111                proptest::sample::select(matching_tests),
1112                arb_test_outcome(),
1113                0..=len,
1114            )
1115            .boxed()
1116        }
1117    }
1118
1119    /// Extract matching tests from a test list model.
1120    fn extract_matching_tests(test_list: &TestListModel) -> Vec<(ModelBinaryId, ModelTestName)> {
1121        test_list
1122            .binaries
1123            .iter()
1124            .filter_map(|(binary_id, model)| match model {
1125                BinaryModel::Listed { tests } => Some(
1126                    tests
1127                        .iter()
1128                        .filter(|(_, fm)| matches!(fm, FilterMatch::Matches))
1129                        .map(move |(tn, _)| (*binary_id, *tn)),
1130                ),
1131                BinaryModel::Skipped => None,
1132            })
1133            .flatten()
1134            .collect()
1135    }
1136
1137    fn arb_run_step() -> impl Strategy<Value = RunStep> {
1138        arb_test_list_model().prop_flat_map(|test_list| {
1139            let matching_tests = extract_matching_tests(&test_list);
1140            arb_outcomes_for_matching_tests(matching_tests).prop_map(move |outcomes| RunStep {
1141                test_list: test_list.clone(),
1142                outcomes,
1143            })
1144        })
1145    }
1146
1147    fn arb_rerun_model() -> impl Strategy<Value = RerunModel> {
1148        (
1149            arb_run_step(),
1150            proptest::collection::vec(arb_run_step(), 0..5),
1151        )
1152            .prop_map(|(initial, reruns)| RerunModel { initial, reruns })
1153    }
1154
1155    // ---
1156    // Helper to convert model outcomes to HashMap<OwnedTestInstanceId, TestOutcome>
1157    // ---
1158
1159    fn model_outcomes_to_hashmap(
1160        outcomes: &BTreeMap<(ModelBinaryId, ModelTestName), TestOutcome>,
1161    ) -> HashMap<OwnedTestInstanceId, TestOutcome> {
1162        outcomes
1163            .iter()
1164            .map(|((binary_id, test_name), outcome)| {
1165                let id = OwnedTestInstanceId {
1166                    binary_id: binary_id.rust_binary_id().clone(),
1167                    test_name: test_name.test_case_name().clone(),
1168                };
1169                (id, *outcome)
1170            })
1171            .collect()
1172    }
1173
1174    // ---
1175    // Helpers
1176    // ---
1177
1178    /// Runs the SUT through an entire `RerunModel`.
1179    fn run_sut(model: &RerunModel) -> IdOrdMap<RerunTestSuiteInfo> {
1180        let outcomes = model_outcomes_to_hashmap(&model.initial.outcomes);
1181        let mut result = compute_outstanding_pure(None, &model.initial.test_list, &outcomes);
1182
1183        for rerun in &model.reruns {
1184            let outcomes = model_outcomes_to_hashmap(&rerun.outcomes);
1185            result = compute_outstanding_pure(Some(&result), &rerun.test_list, &outcomes);
1186        }
1187
1188        result
1189    }
1190
1191    // ---
1192    // Oracle: per-test decision table
1193    // ---
1194    //
1195    // The oracle determines each test's fate independently using a decision
1196    // table (`decide_test_outcome`). This is verifiable by inspection and
1197    // structurally different from the SUT.
1198
1199    /// Status of a test in the previous run.
1200    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1201    enum PrevStatus {
1202        /// Test was in the passing set.
1203        Passing,
1204        /// Test was in the outstanding set.
1205        Outstanding,
1206        /// Test was not tracked (not in either set).
1207        Unknown,
1208    }
1209
1210    /// What to do with this test after applying the decision table.
1211    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1212    enum Decision {
1213        /// Add to the passing set.
1214        Passing,
1215        /// Add to the outstanding set.
1216        Outstanding,
1217        /// Don't track this test.
1218        NotTracked,
1219    }
1220
1221    /// Result of looking up a test's filter match in the current step.
1222    ///
1223    /// This distinguishes between different reasons a filter match might not
1224    /// exist, which affects how the test's state is handled.
1225    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1226    enum FilterMatchResult {
1227        /// Binary is not in the test list at all. Carry forward the entire
1228        /// suite.
1229        BinaryNotPresent,
1230        /// Binary is in the test list but skipped. Carry forward the entire
1231        /// suite.
1232        BinarySkipped,
1233        /// Binary is listed but this test is not in its test map. Only carry
1234        /// forward outstanding tests; passing tests become untracked.
1235        TestNotInList,
1236        /// Test has a filter match.
1237        HasMatch(FilterMatch),
1238    }
1239
1240    /// Pure decision table for a single test.
1241    ///
1242    /// This is the core logic expressed as a truth table, making it easy to verify
1243    /// by inspection that each case is handled correctly.
1244    fn decide_test_outcome(
1245        prev: PrevStatus,
1246        filter_result: FilterMatchResult,
1247        outcome: Option<TestOutcome>,
1248    ) -> Decision {
1249        match filter_result {
1250            FilterMatchResult::BinaryNotPresent | FilterMatchResult::BinarySkipped => {
1251                // Binary not present or skipped: carry forward previous status.
1252                match prev {
1253                    PrevStatus::Passing => Decision::Passing,
1254                    PrevStatus::Outstanding => Decision::Outstanding,
1255                    PrevStatus::Unknown => Decision::NotTracked,
1256                }
1257            }
1258            FilterMatchResult::TestNotInList => {
1259                // Test is not in the current test list of a listed binary.
1260                // Only carry forward outstanding tests. Passing tests that
1261                // disappear from the list become untracked (and will be re-run
1262                // if they reappear).
1263                match prev {
1264                    PrevStatus::Outstanding => Decision::Outstanding,
1265                    PrevStatus::Passing | PrevStatus::Unknown => Decision::NotTracked,
1266                }
1267            }
1268            FilterMatchResult::HasMatch(FilterMatch::Matches) => {
1269                match outcome {
1270                    Some(TestOutcome::Passed) => Decision::Passing,
1271                    Some(TestOutcome::Failed) => Decision::Outstanding,
1272                    None => {
1273                        // Test was scheduled but not seen in event log: outstanding.
1274                        Decision::Outstanding
1275                    }
1276                    Some(TestOutcome::Skipped(TestOutcomeSkipped::Rerun)) => Decision::Passing,
1277                    Some(TestOutcome::Skipped(TestOutcomeSkipped::Explicit)) => {
1278                        // Carry forward, or not tracked if unknown.
1279                        match prev {
1280                            PrevStatus::Passing => Decision::Passing,
1281                            PrevStatus::Outstanding => Decision::Outstanding,
1282                            PrevStatus::Unknown => Decision::NotTracked,
1283                        }
1284                    }
1285                }
1286            }
1287            FilterMatchResult::HasMatch(FilterMatch::Mismatch { reason }) => {
1288                match TestOutcomeSkipped::from_mismatch_reason(reason) {
1289                    TestOutcomeSkipped::Rerun => Decision::Passing,
1290                    TestOutcomeSkipped::Explicit => {
1291                        // Carry forward, or not tracked if unknown.
1292                        match prev {
1293                            PrevStatus::Passing => Decision::Passing,
1294                            PrevStatus::Outstanding => Decision::Outstanding,
1295                            PrevStatus::Unknown => Decision::NotTracked,
1296                        }
1297                    }
1298                }
1299            }
1300        }
1301    }
1302
1303    impl RerunModel {
1304        /// Per-test decision table oracle.
1305        ///
1306        /// This is structurally different from the main oracle: instead of iterating
1307        /// through binaries and updating state imperatively, it determines each
1308        /// test's fate independently using a truth table.
1309        fn compute_rerun_info_decision_table(&self) -> IdOrdMap<RerunTestSuiteInfo> {
1310            // Compute all previous states by running through the chain.
1311            let mut prev_state: HashMap<(ModelBinaryId, ModelTestName), PrevStatus> =
1312                HashMap::new();
1313
1314            // Process initial run.
1315            self.update_state_from_step(&mut prev_state, &self.initial);
1316
1317            // Process reruns.
1318            for rerun in &self.reruns {
1319                self.update_state_from_step(&mut prev_state, rerun);
1320            }
1321
1322            // Convert final state to result.
1323            self.collect_final_state(&prev_state)
1324        }
1325
1326        fn update_state_from_step(
1327            &self,
1328            state: &mut HashMap<(ModelBinaryId, ModelTestName), PrevStatus>,
1329            step: &RunStep,
1330        ) {
1331            // Enumerate all tests we need to consider:
1332            // - Tests in the current test list
1333            // - Tests from previous state (for carry-forward)
1334            let all_tests = self.enumerate_all_tests(state, step);
1335
1336            for (binary_id, test_name) in all_tests {
1337                let prev = state
1338                    .get(&(binary_id, test_name))
1339                    .copied()
1340                    .unwrap_or(PrevStatus::Unknown);
1341
1342                let filter_result = self.get_filter_match_result(step, binary_id, test_name);
1343                let outcome = step.outcomes.get(&(binary_id, test_name)).copied();
1344
1345                let decision = decide_test_outcome(prev, filter_result, outcome);
1346
1347                // Update state based on decision.
1348                match decision {
1349                    Decision::Passing => {
1350                        state.insert((binary_id, test_name), PrevStatus::Passing);
1351                    }
1352                    Decision::Outstanding => {
1353                        state.insert((binary_id, test_name), PrevStatus::Outstanding);
1354                    }
1355                    Decision::NotTracked => {
1356                        state.remove(&(binary_id, test_name));
1357                    }
1358                }
1359            }
1360        }
1361
1362        /// Gets the filter match result for a test in a step.
1363        ///
1364        /// Returns a `FilterMatchResult` indicating why the filter match is
1365        /// present or absent.
1366        fn get_filter_match_result(
1367            &self,
1368            step: &RunStep,
1369            binary_id: ModelBinaryId,
1370            test_name: ModelTestName,
1371        ) -> FilterMatchResult {
1372            match step.test_list.binaries.get(&binary_id) {
1373                None => FilterMatchResult::BinaryNotPresent,
1374                Some(BinaryModel::Skipped) => FilterMatchResult::BinarySkipped,
1375                Some(BinaryModel::Listed { tests }) => match tests.get(&test_name) {
1376                    Some(filter_match) => FilterMatchResult::HasMatch(*filter_match),
1377                    None => FilterMatchResult::TestNotInList,
1378                },
1379            }
1380        }
1381
1382        /// Enumerates all tests that need to be considered for a step.
1383        ///
1384        /// This includes tests from the current test list and tests from the
1385        /// previous state (for carry-forward).
1386        fn enumerate_all_tests(
1387            &self,
1388            prev_state: &HashMap<(ModelBinaryId, ModelTestName), PrevStatus>,
1389            step: &RunStep,
1390        ) -> BTreeSet<(ModelBinaryId, ModelTestName)> {
1391            let mut tests = BTreeSet::new();
1392
1393            // Tests from current test list.
1394            for (binary_id, binary_model) in &step.test_list.binaries {
1395                if let BinaryModel::Listed { tests: test_map } = binary_model {
1396                    for test_name in test_map.keys() {
1397                        tests.insert((*binary_id, *test_name));
1398                    }
1399                }
1400            }
1401
1402            // Tests from previous state (for carry-forward).
1403            for (binary_id, test_name) in prev_state.keys() {
1404                tests.insert((*binary_id, *test_name));
1405            }
1406
1407            tests
1408        }
1409
1410        /// Converts the final state to an `IdOrdMap<TestSuiteOutstanding>`.
1411        fn collect_final_state(
1412            &self,
1413            state: &HashMap<(ModelBinaryId, ModelTestName), PrevStatus>,
1414        ) -> IdOrdMap<RerunTestSuiteInfo> {
1415            let mut result: BTreeMap<ModelBinaryId, RerunTestSuiteInfo> = BTreeMap::new();
1416
1417            for ((binary_id, test_name), status) in state {
1418                let suite = result
1419                    .entry(*binary_id)
1420                    .or_insert_with(|| RerunTestSuiteInfo::new(binary_id.rust_binary_id().clone()));
1421
1422                match status {
1423                    PrevStatus::Passing => {
1424                        suite.passing.insert(test_name.test_case_name().clone());
1425                    }
1426                    PrevStatus::Outstanding => {
1427                        suite.outstanding.insert(test_name.test_case_name().clone());
1428                    }
1429                    PrevStatus::Unknown => {
1430                        // Not tracked: don't add.
1431                    }
1432                }
1433            }
1434
1435            let mut id_map = IdOrdMap::new();
1436            for (_, suite) in result {
1437                id_map.insert_unique(suite).expect("unique binaries");
1438            }
1439            id_map
1440        }
1441    }
1442
1443    // ---
1444    // Stress run accumulation tests.
1445    // ---
1446
1447    /// Creates a `TestFinished` event for testing.
1448    ///
1449    /// Uses `RecordingSpec` as the output spec since we don't need actual output
1450    /// data for these tests (all outputs are `None`).
1451    fn make_test_finished(
1452        test_instance: OwnedTestInstanceId,
1453        stress_index: Option<(u32, Option<u32>)>,
1454        passed: bool,
1455    ) -> TestEventKindSummary<RecordingSpec> {
1456        let result = if passed {
1457            ExecutionResultDescription::Pass
1458        } else {
1459            ExecutionResultDescription::Fail {
1460                failure: FailureDescription::ExitCode { code: 1 },
1461                leaked: false,
1462            }
1463        };
1464
1465        let execute_status = ExecuteStatus {
1466            retry_data: RetryData {
1467                attempt: 1,
1468                total_attempts: 1,
1469            },
1470            output: ChildExecutionOutputDescription::Output {
1471                result: Some(result.clone()),
1472                output: ZipStoreOutputDescription::Split {
1473                    stdout: None,
1474                    stderr: None,
1475                },
1476                errors: None,
1477            },
1478            result,
1479            start_time: Utc::now().into(),
1480            time_taken: Duration::from_millis(100),
1481            is_slow: false,
1482            delay_before_start: Duration::ZERO,
1483            error_summary: None,
1484            output_error_slice: None,
1485        };
1486
1487        TestEventKindSummary::Output(OutputEventKind::TestFinished {
1488            stress_index: stress_index.map(|(current, total)| StressIndexSummary {
1489                current,
1490                total: total.and_then(NonZero::new),
1491            }),
1492            test_instance,
1493            success_output: TestOutputDisplay::Never,
1494            failure_output: TestOutputDisplay::Never,
1495            junit_store_success_output: false,
1496            junit_store_failure_output: false,
1497            run_statuses: ExecutionStatuses::new(vec![execute_status]),
1498            current_stats: RunStats::default(),
1499            running: 0,
1500        })
1501    }
1502
1503    /// Test stress run accumulation: if any iteration fails, the test is Failed.
1504    ///
1505    /// This tests the fix for the stress run accumulation logic. Multiple
1506    /// `TestFinished` events for the same test (one per stress iteration) should
1507    /// result in Failed if any iteration failed, regardless of order.
1508    #[test]
1509    fn stress_run_accumulation() {
1510        // [Pass, Fail, Pass] -> Failed.
1511        let test_pass_fail_pass = OwnedTestInstanceId {
1512            binary_id: RustBinaryId::new("test-binary"),
1513            test_name: TestCaseName::new("pass_fail_pass"),
1514        };
1515
1516        // [Pass, Pass, Pass] -> Passed.
1517        let test_all_pass = OwnedTestInstanceId {
1518            binary_id: RustBinaryId::new("test-binary"),
1519            test_name: TestCaseName::new("all_pass"),
1520        };
1521
1522        // [Fail, Fail, Fail] -> Failed.
1523        let test_all_fail = OwnedTestInstanceId {
1524            binary_id: RustBinaryId::new("test-binary"),
1525            test_name: TestCaseName::new("all_fail"),
1526        };
1527
1528        // [Fail, Pass, Pass] -> Failed.
1529        let test_fail_first = OwnedTestInstanceId {
1530            binary_id: RustBinaryId::new("test-binary"),
1531            test_name: TestCaseName::new("fail_first"),
1532        };
1533
1534        // Regular (non-stress) pass.
1535        let test_regular_pass = OwnedTestInstanceId {
1536            binary_id: RustBinaryId::new("test-binary"),
1537            test_name: TestCaseName::new("regular_pass"),
1538        };
1539
1540        // Regular (non-stress) fail.
1541        let test_regular_fail = OwnedTestInstanceId {
1542            binary_id: RustBinaryId::new("test-binary"),
1543            test_name: TestCaseName::new("regular_fail"),
1544        };
1545
1546        // Construct all events in one stream.
1547        let events = [
1548            // pass_fail_pass: [Pass, Fail, Pass]
1549            make_test_finished(test_pass_fail_pass.clone(), Some((0, Some(3))), true),
1550            make_test_finished(test_pass_fail_pass.clone(), Some((1, Some(3))), false),
1551            make_test_finished(test_pass_fail_pass.clone(), Some((2, Some(3))), true),
1552            // all_pass: [Pass, Pass, Pass]
1553            make_test_finished(test_all_pass.clone(), Some((0, Some(3))), true),
1554            make_test_finished(test_all_pass.clone(), Some((1, Some(3))), true),
1555            make_test_finished(test_all_pass.clone(), Some((2, Some(3))), true),
1556            // all_fail: [Fail, Fail, Fail]
1557            make_test_finished(test_all_fail.clone(), Some((0, Some(3))), false),
1558            make_test_finished(test_all_fail.clone(), Some((1, Some(3))), false),
1559            make_test_finished(test_all_fail.clone(), Some((2, Some(3))), false),
1560            // fail_first: [Fail, Pass, Pass]
1561            make_test_finished(test_fail_first.clone(), Some((0, Some(3))), false),
1562            make_test_finished(test_fail_first.clone(), Some((1, Some(3))), true),
1563            make_test_finished(test_fail_first.clone(), Some((2, Some(3))), true),
1564            // regular_pass: single pass (no stress index)
1565            make_test_finished(test_regular_pass.clone(), None, true),
1566            // regular_fail: single fail (no stress index)
1567            make_test_finished(test_regular_fail.clone(), None, false),
1568        ];
1569
1570        let outcomes = collect_from_events(events.iter().map(Ok::<_, Infallible>)).unwrap();
1571
1572        assert_eq!(
1573            outcomes.get(&test_pass_fail_pass),
1574            Some(&TestOutcome::Failed),
1575            "[Pass, Fail, Pass] should be Failed"
1576        );
1577        assert_eq!(
1578            outcomes.get(&test_all_pass),
1579            Some(&TestOutcome::Passed),
1580            "[Pass, Pass, Pass] should be Passed"
1581        );
1582        assert_eq!(
1583            outcomes.get(&test_all_fail),
1584            Some(&TestOutcome::Failed),
1585            "[Fail, Fail, Fail] should be Failed"
1586        );
1587        assert_eq!(
1588            outcomes.get(&test_fail_first),
1589            Some(&TestOutcome::Failed),
1590            "[Fail, Pass, Pass] should be Failed"
1591        );
1592        assert_eq!(
1593            outcomes.get(&test_regular_pass),
1594            Some(&TestOutcome::Passed),
1595            "regular pass should be Passed"
1596        );
1597        assert_eq!(
1598            outcomes.get(&test_regular_fail),
1599            Some(&TestOutcome::Failed),
1600            "regular fail should be Failed"
1601        );
1602    }
1603
1604    /// Test that multiple tests in a stress run are tracked independently.
1605    ///
1606    /// Interleaved events for different tests should not interfere with each
1607    /// other's outcome accumulation.
1608    #[test]
1609    fn stress_run_multiple_tests_independent() {
1610        let test_a = OwnedTestInstanceId {
1611            binary_id: RustBinaryId::new("test-binary"),
1612            test_name: TestCaseName::new("test_a"),
1613        };
1614        let test_b = OwnedTestInstanceId {
1615            binary_id: RustBinaryId::new("test-binary"),
1616            test_name: TestCaseName::new("test_b"),
1617        };
1618
1619        // Interleaved stress run events for two tests:
1620        // test_a: [Pass, Pass] -> Passed
1621        // test_b: [Pass, Fail] -> Failed
1622        let events = [
1623            make_test_finished(test_a.clone(), Some((0, Some(2))), true),
1624            make_test_finished(test_b.clone(), Some((0, Some(2))), true),
1625            make_test_finished(test_a.clone(), Some((1, Some(2))), true),
1626            make_test_finished(test_b.clone(), Some((1, Some(2))), false),
1627        ];
1628
1629        let outcomes = collect_from_events(events.iter().map(Ok::<_, Infallible>)).unwrap();
1630
1631        assert_eq!(
1632            outcomes.get(&test_a),
1633            Some(&TestOutcome::Passed),
1634            "test_a [Pass, Pass] should be Passed"
1635        );
1636        assert_eq!(
1637            outcomes.get(&test_b),
1638            Some(&TestOutcome::Failed),
1639            "test_b [Pass, Fail] should be Failed"
1640        );
1641    }
1642}