nextest_runner/record/
rerun.rs

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