Skip to main content

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