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