Skip to main content

nextest_runner/reporter/displayer/
status_level.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Status levels: filters for which test statuses are displayed.
5//!
6//! Status levels play a role that's similar to log levels in typical loggers.
7
8use super::TestOutputDisplay;
9use crate::reporter::events::{CancelReason, ExecutionResultDescription};
10use serde::Deserialize;
11
12/// Level of status information to display during test runs.
13///
14/// Status levels are incremental: each level causes all the statuses listed
15/// above it to be output. For example, `slow` implies `retry` and `fail`.
16#[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize)]
17#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
18#[cfg_attr(test, derive(test_strategy::Arbitrary))]
19#[serde(rename_all = "kebab-case")]
20#[non_exhaustive]
21pub enum StatusLevel {
22    /// No output.
23    None,
24
25    /// Only output test failures.
26    Fail,
27
28    /// Output test retries; includes `fail`.
29    Retry,
30
31    /// Output slow tests; includes `retry`.
32    Slow,
33
34    /// Output leaky tests; includes `slow`.
35    Leak,
36
37    /// Output passing tests; includes `leak`.
38    Pass,
39
40    /// Output skipped tests; includes `pass`.
41    Skip,
42
43    /// Equivalent to `"skip"` today; reserved for future expansion.
44    All,
45}
46
47/// Level of status information to display in the final summary.
48///
49/// Status levels are incremental. This differs from `status-level` in two
50/// ways:
51///
52/// * It has a "flaky" test indicator distinct from "retry" (though "retry"
53///   works as an alias).
54/// * It has a different ordering: skipped tests are prioritized over passing
55///   ones.
56#[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize)]
57#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
58#[cfg_attr(test, derive(test_strategy::Arbitrary))]
59#[serde(rename_all = "kebab-case")]
60#[non_exhaustive]
61pub enum FinalStatusLevel {
62    /// No output.
63    None,
64
65    /// Only output test failures.
66    Fail,
67
68    /// Output flaky tests; includes `fail`. Accepts `"retry"` as an alias.
69    #[serde(alias = "retry")]
70    Flaky,
71
72    /// Output slow tests; includes `flaky`.
73    Slow,
74
75    /// Output skipped tests; includes `slow`.
76    Skip,
77
78    /// Output leaky tests; includes `skip`.
79    Leak,
80
81    /// Output passing tests; includes `leak`.
82    Pass,
83
84    /// Equivalent to `"pass"` today; reserved for future expansion.
85    All,
86}
87
88pub(crate) struct StatusLevels {
89    pub(crate) status_level: StatusLevel,
90    pub(crate) final_status_level: FinalStatusLevel,
91}
92
93impl StatusLevels {
94    pub(super) fn compute_output_on_test_finished(
95        &self,
96        display: TestOutputDisplay,
97        cancel_status: Option<CancelReason>,
98        test_status_level: StatusLevel,
99        test_final_status_level: FinalStatusLevel,
100        execution_result: &ExecutionResultDescription,
101    ) -> OutputOnTestFinished {
102        let write_status_line = self.status_level >= test_status_level;
103
104        let is_immediate = display.is_immediate();
105        // We store entries in the final output map if either the final status level is high enough or
106        // if `display` says we show the output at the end.
107        let is_final = display.is_final() || self.final_status_level >= test_final_status_level;
108
109        // Check if this test was terminated by nextest during immediate termination mode.
110        // This is a heuristic: we check if the test failed with SIGTERM (Unix) or JobObject (Windows)
111        // during TestFailureImmediate cancellation. This suppresses output spam from tests we killed.
112        let terminated_by_nextest = cancel_status == Some(CancelReason::TestFailureImmediate)
113            && execution_result.is_termination_failure();
114
115        // This table is tested below. The basic invariant is that we generally follow what
116        // is_immediate and is_final suggests, except:
117        //
118        // - if the run is cancelled due to a non-interrupt signal, we display test output at most
119        //   once.
120        // - if the run is cancelled due to an interrupt, we hide the output because dumping a bunch
121        //   of output at the end is likely to not be helpful (though in the future we may want to
122        //   at least dump outputs into files and write their names out, or whenever nextest gains
123        //   the ability to replay test runs to be able to display it then.)
124        // - if the run is cancelled due to immediate test failure termination, we hide output for
125        //   tests that were terminated by nextest (via SIGTERM/job object), but still show output
126        //   for tests that failed naturally (e.g. due to assertion failures or other exit codes).
127        //
128        // is_immediate  is_final      cancel_status     terminated_by_nextest  |  show_immediate  store_final
129        //
130        //     false      false          <= Signal                *             |      false          false
131        //     false       true          <= Signal                *             |      false           true  [1]
132        //      true      false          <= Signal                *             |       true          false  [1]
133        //      true       true           < Signal                *             |       true           true
134        //      true       true             Signal                *             |       true          false  [2]
135        //       *          *            Interrupt                *             |      false          false  [3]
136        //       *          *       TestFailureImmediate         true           |      false          false  [4]
137        //       *          *       TestFailureImmediate        false           |  (use rules above)  [5]
138        //
139        // [1] In non-interrupt cases, we want to display output if specified once.
140        //
141        // [2] If there's a signal, we shouldn't display output twice at the end since it's
142        //     redundant -- instead, just show the output as part of the immediate display.
143        //
144        // [3] For interrupts, hide all output to avoid spam.
145        //
146        // [4] For tests terminated by nextest during immediate mode, hide output to avoid spam.
147        //
148        // [5] For tests that failed naturally during immediate mode (race condition), show output
149        //     normally since these are real failures.
150        let show_immediate =
151            is_immediate && cancel_status <= Some(CancelReason::Signal) && !terminated_by_nextest;
152
153        let store_final = if cancel_status == Some(CancelReason::Interrupt) || terminated_by_nextest
154        {
155            // Hide output completely for interrupt and nextest-initiated termination.
156            OutputStoreFinal::No
157        } else if is_final && cancel_status < Some(CancelReason::Signal)
158            || !is_immediate && is_final && cancel_status == Some(CancelReason::Signal)
159        {
160            OutputStoreFinal::Yes {
161                display_output: display.is_final(),
162            }
163        } else if is_immediate && is_final && cancel_status == Some(CancelReason::Signal) {
164            // In this special case, we already display the output once as the test is being
165            // cancelled, so don't display it again at the end since that's redundant.
166            OutputStoreFinal::Yes {
167                display_output: false,
168            }
169        } else {
170            OutputStoreFinal::No
171        };
172
173        OutputOnTestFinished {
174            write_status_line,
175            show_immediate,
176            store_final,
177        }
178    }
179}
180
181#[derive(Debug, PartialEq, Eq)]
182pub(super) struct OutputOnTestFinished {
183    pub(super) write_status_line: bool,
184    pub(super) show_immediate: bool,
185    pub(super) store_final: OutputStoreFinal,
186}
187
188#[derive(Debug, PartialEq, Eq)]
189pub(super) enum OutputStoreFinal {
190    /// Do not store the output.
191    No,
192
193    /// Store the output. display_output controls whether stdout and stderr should actually be
194    /// displayed at the end.
195    Yes { display_output: bool },
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::{
202        output_spec::RecordingSpec,
203        record::{LoadOutput, OutputEventKind},
204        reporter::{
205            displayer::{OutputLoadDecider, unit_output::OutputDisplayOverrides},
206            events::ExecutionStatuses,
207        },
208    };
209    use test_strategy::{Arbitrary, proptest};
210
211    // ---
212    // The proptests here are probabilistically exhaustive, and it's just easier to express them
213    // as property-based tests. We could also potentially use a model checker like Kani here.
214    // ---
215
216    #[proptest(cases = 64)]
217    fn on_test_finished_dont_write_status_line(
218        display: TestOutputDisplay,
219        cancel_status: Option<CancelReason>,
220        #[filter(StatusLevel::Pass < #test_status_level)] test_status_level: StatusLevel,
221        test_final_status_level: FinalStatusLevel,
222    ) {
223        let status_levels = StatusLevels {
224            status_level: StatusLevel::Pass,
225            final_status_level: FinalStatusLevel::Fail,
226        };
227
228        let actual = status_levels.compute_output_on_test_finished(
229            display,
230            cancel_status,
231            test_status_level,
232            test_final_status_level,
233            &ExecutionResultDescription::Pass,
234        );
235
236        assert!(!actual.write_status_line);
237    }
238
239    #[proptest(cases = 64)]
240    fn on_test_finished_write_status_line(
241        display: TestOutputDisplay,
242        cancel_status: Option<CancelReason>,
243        #[filter(StatusLevel::Pass >= #test_status_level)] test_status_level: StatusLevel,
244        test_final_status_level: FinalStatusLevel,
245    ) {
246        let status_levels = StatusLevels {
247            status_level: StatusLevel::Pass,
248            final_status_level: FinalStatusLevel::Fail,
249        };
250
251        let actual = status_levels.compute_output_on_test_finished(
252            display,
253            cancel_status,
254            test_status_level,
255            test_final_status_level,
256            &ExecutionResultDescription::Pass,
257        );
258        assert!(actual.write_status_line);
259    }
260
261    #[proptest(cases = 64)]
262    fn on_test_finished_with_interrupt(
263        // We always hide output on interrupt.
264        display: TestOutputDisplay,
265        // cancel_status is fixed to Interrupt.
266
267        // In this case, the status levels are not relevant for is_immediate and is_final.
268        test_status_level: StatusLevel,
269        test_final_status_level: FinalStatusLevel,
270    ) {
271        let status_levels = StatusLevels {
272            status_level: StatusLevel::Pass,
273            final_status_level: FinalStatusLevel::Fail,
274        };
275
276        let actual = status_levels.compute_output_on_test_finished(
277            display,
278            Some(CancelReason::Interrupt),
279            test_status_level,
280            test_final_status_level,
281            &ExecutionResultDescription::Pass,
282        );
283        assert!(!actual.show_immediate);
284        assert_eq!(actual.store_final, OutputStoreFinal::No);
285    }
286
287    #[proptest(cases = 64)]
288    fn on_test_finished_dont_show_immediate(
289        #[filter(!#display.is_immediate())] display: TestOutputDisplay,
290        cancel_status: Option<CancelReason>,
291        // The status levels are not relevant for show_immediate.
292        test_status_level: StatusLevel,
293        test_final_status_level: FinalStatusLevel,
294    ) {
295        let status_levels = StatusLevels {
296            status_level: StatusLevel::Pass,
297            final_status_level: FinalStatusLevel::Fail,
298        };
299
300        let actual = status_levels.compute_output_on_test_finished(
301            display,
302            cancel_status,
303            test_status_level,
304            test_final_status_level,
305            &ExecutionResultDescription::Pass,
306        );
307        assert!(!actual.show_immediate);
308    }
309
310    #[proptest(cases = 64)]
311    fn on_test_finished_show_immediate(
312        #[filter(#display.is_immediate())] display: TestOutputDisplay,
313        #[filter(#cancel_status <= Some(CancelReason::Signal))] cancel_status: Option<CancelReason>,
314        // The status levels are not relevant for show_immediate.
315        test_status_level: StatusLevel,
316        test_final_status_level: FinalStatusLevel,
317    ) {
318        let status_levels = StatusLevels {
319            status_level: StatusLevel::Pass,
320            final_status_level: FinalStatusLevel::Fail,
321        };
322
323        let actual = status_levels.compute_output_on_test_finished(
324            display,
325            cancel_status,
326            test_status_level,
327            test_final_status_level,
328            &ExecutionResultDescription::Pass,
329        );
330        assert!(actual.show_immediate);
331    }
332
333    // Where we don't store final output: if display.is_final() is false, and if the test final
334    // status level is too high.
335    #[proptest(cases = 64)]
336    fn on_test_finished_dont_store_final(
337        #[filter(!#display.is_final())] display: TestOutputDisplay,
338        cancel_status: Option<CancelReason>,
339        // The status level is not relevant for store_final.
340        test_status_level: StatusLevel,
341        // But the final status level is.
342        #[filter(FinalStatusLevel::Fail < #test_final_status_level)]
343        test_final_status_level: FinalStatusLevel,
344    ) {
345        let status_levels = StatusLevels {
346            status_level: StatusLevel::Pass,
347            final_status_level: FinalStatusLevel::Fail,
348        };
349
350        let actual = status_levels.compute_output_on_test_finished(
351            display,
352            cancel_status,
353            test_status_level,
354            test_final_status_level,
355            &ExecutionResultDescription::Pass,
356        );
357        assert_eq!(actual.store_final, OutputStoreFinal::No);
358    }
359
360    // Case 1 where we store final output: if display is exactly TestOutputDisplay::Final, and if
361    // the cancel status is not Interrupt.
362    #[proptest(cases = 64)]
363    fn on_test_finished_store_final_1(
364        #[filter(#cancel_status <= Some(CancelReason::Signal))] cancel_status: Option<CancelReason>,
365        // In this case, it isn't relevant what test_status_level and test_final_status_level are.
366        test_status_level: StatusLevel,
367        test_final_status_level: FinalStatusLevel,
368    ) {
369        let status_levels = StatusLevels {
370            status_level: StatusLevel::Pass,
371            final_status_level: FinalStatusLevel::Fail,
372        };
373
374        let actual = status_levels.compute_output_on_test_finished(
375            TestOutputDisplay::Final,
376            cancel_status,
377            test_status_level,
378            test_final_status_level,
379            &ExecutionResultDescription::Pass,
380        );
381        assert_eq!(
382            actual.store_final,
383            OutputStoreFinal::Yes {
384                display_output: true
385            }
386        );
387    }
388
389    // Case 2 where we store final output: if display is TestOutputDisplay::ImmediateFinal and the
390    // cancel status is not Signal or Interrupt
391    #[proptest(cases = 64)]
392    fn on_test_finished_store_final_2(
393        #[filter(#cancel_status < Some(CancelReason::Signal))] cancel_status: Option<CancelReason>,
394        test_status_level: StatusLevel,
395        test_final_status_level: FinalStatusLevel,
396    ) {
397        let status_levels = StatusLevels {
398            status_level: StatusLevel::Pass,
399            final_status_level: FinalStatusLevel::Fail,
400        };
401
402        let actual = status_levels.compute_output_on_test_finished(
403            TestOutputDisplay::ImmediateFinal,
404            cancel_status,
405            test_status_level,
406            test_final_status_level,
407            &ExecutionResultDescription::Pass,
408        );
409        assert_eq!(
410            actual.store_final,
411            OutputStoreFinal::Yes {
412                display_output: true
413            }
414        );
415    }
416
417    // Case 3 where we store final output: if display is TestOutputDisplay::ImmediateFinal and the
418    // cancel status is exactly Signal. In this special case, we don't display the output.
419    #[proptest(cases = 64)]
420    fn on_test_finished_store_final_3(
421        test_status_level: StatusLevel,
422        test_final_status_level: FinalStatusLevel,
423    ) {
424        let status_levels = StatusLevels {
425            status_level: StatusLevel::Pass,
426            final_status_level: FinalStatusLevel::Fail,
427        };
428
429        let actual = status_levels.compute_output_on_test_finished(
430            TestOutputDisplay::ImmediateFinal,
431            Some(CancelReason::Signal),
432            test_status_level,
433            test_final_status_level,
434            &ExecutionResultDescription::Pass,
435        );
436        assert_eq!(
437            actual.store_final,
438            OutputStoreFinal::Yes {
439                display_output: false,
440            }
441        );
442    }
443
444    // Case 4: if display.is_final() is *false* but the test_final_status_level is low enough.
445    #[proptest(cases = 64)]
446    fn on_test_finished_store_final_4(
447        #[filter(!#display.is_final())] display: TestOutputDisplay,
448        #[filter(#cancel_status <= Some(CancelReason::Signal))] cancel_status: Option<CancelReason>,
449        // The status level is not relevant for store_final.
450        test_status_level: StatusLevel,
451        // But the final status level is.
452        #[filter(FinalStatusLevel::Fail >= #test_final_status_level)]
453        test_final_status_level: FinalStatusLevel,
454    ) {
455        let status_levels = StatusLevels {
456            status_level: StatusLevel::Pass,
457            final_status_level: FinalStatusLevel::Fail,
458        };
459
460        let actual = status_levels.compute_output_on_test_finished(
461            display,
462            cancel_status,
463            test_status_level,
464            test_final_status_level,
465            &ExecutionResultDescription::Pass,
466        );
467        assert_eq!(
468            actual.store_final,
469            OutputStoreFinal::Yes {
470                display_output: false,
471            }
472        );
473    }
474
475    #[test]
476    fn on_test_finished_terminated_by_nextest() {
477        use crate::reporter::events::{AbortDescription, FailureDescription, SIGTERM};
478
479        let status_levels = StatusLevels {
480            status_level: StatusLevel::Pass,
481            final_status_level: FinalStatusLevel::Fail,
482        };
483
484        // Test 1: Terminated by nextest (SIGTERM) during TestFailureImmediate - should hide
485        {
486            let execution_result = ExecutionResultDescription::Fail {
487                failure: FailureDescription::Abort {
488                    abort: AbortDescription::UnixSignal {
489                        signal: SIGTERM,
490                        name: Some("TERM".into()),
491                    },
492                },
493                leaked: false,
494            };
495
496            let actual = status_levels.compute_output_on_test_finished(
497                TestOutputDisplay::ImmediateFinal,
498                Some(CancelReason::TestFailureImmediate),
499                StatusLevel::Fail,
500                FinalStatusLevel::Fail,
501                &execution_result,
502            );
503
504            assert!(
505                !actual.show_immediate,
506                "should not show immediate for SIGTERM during TestFailureImmediate"
507            );
508            assert_eq!(
509                actual.store_final,
510                OutputStoreFinal::No,
511                "should not store final for SIGTERM during TestFailureImmediate"
512            );
513        }
514
515        // Test 2: Terminated by nextest (JobObject) during TestFailureImmediate - should hide
516        {
517            let execution_result = ExecutionResultDescription::Fail {
518                failure: FailureDescription::Abort {
519                    abort: AbortDescription::WindowsJobObject,
520                },
521                leaked: false,
522            };
523
524            let actual = status_levels.compute_output_on_test_finished(
525                TestOutputDisplay::ImmediateFinal,
526                Some(CancelReason::TestFailureImmediate),
527                StatusLevel::Fail,
528                FinalStatusLevel::Fail,
529                &execution_result,
530            );
531
532            assert!(
533                !actual.show_immediate,
534                "should not show immediate for JobObject during TestFailureImmediate"
535            );
536            assert_eq!(
537                actual.store_final,
538                OutputStoreFinal::No,
539                "should not store final for JobObject during TestFailureImmediate"
540            );
541        }
542
543        // Test 3: Natural failure (exit code) during TestFailureImmediate - should show
544        let execution_result = ExecutionResultDescription::Fail {
545            failure: FailureDescription::ExitCode { code: 1 },
546            leaked: false,
547        };
548
549        let actual = status_levels.compute_output_on_test_finished(
550            TestOutputDisplay::ImmediateFinal,
551            Some(CancelReason::TestFailureImmediate),
552            StatusLevel::Fail,
553            FinalStatusLevel::Fail,
554            &execution_result,
555        );
556
557        assert!(
558            actual.show_immediate,
559            "should show immediate for natural failure during TestFailureImmediate"
560        );
561        assert_eq!(
562            actual.store_final,
563            OutputStoreFinal::Yes {
564                display_output: true
565            },
566            "should store final for natural failure"
567        );
568
569        // Test 4: SIGTERM but not during TestFailureImmediate (user sent signal) - should show
570        {
571            let execution_result = ExecutionResultDescription::Fail {
572                failure: FailureDescription::Abort {
573                    abort: AbortDescription::UnixSignal {
574                        signal: SIGTERM,
575                        name: Some("TERM".into()),
576                    },
577                },
578                leaked: false,
579            };
580
581            let actual = status_levels.compute_output_on_test_finished(
582                TestOutputDisplay::ImmediateFinal,
583                Some(CancelReason::Signal), // Regular signal, not TestFailureImmediate
584                StatusLevel::Fail,
585                FinalStatusLevel::Fail,
586                &execution_result,
587            );
588
589            assert!(
590                actual.show_immediate,
591                "should show immediate for user-initiated SIGTERM"
592            );
593            assert_eq!(
594                actual.store_final,
595                OutputStoreFinal::Yes {
596                    display_output: false
597                },
598                "should store but not display final"
599            );
600        }
601    }
602
603    // --- OutputLoadDecider safety invariant tests ---
604    //
605    // If OutputLoadDecider returns Skip, we ensure that the reporter's display
606    // logic will never show output. (This is a one-directional invariant -- the
607    // decider errs towards loading more than strictly necessary.)
608    //
609    // The invariants established below are:
610    //
611    // 1. OutputLoadDecider conservatively returns Load whenever output
612    //    might be shown.
613    // 2. The cancellation_only_hides_output test verifies that
614    //    cancellation never causes output to appear that wouldn't appear
615    //    without cancellation. This justifies the decider ignoring
616    //    cancel_status.
617    // 3. The test-finished tests verify that if the decider says Skip,
618    //    compute_output_on_test_finished (the displayer's oracle) with
619    //    cancel_status=None produces no output.
620    //
621    // Together, they imply that if we skip loading, then there's no output.
622
623    /// Cancellation can only hide output, never show more than the baseline
624    /// (cancel_status = None).
625    ///
626    /// The `OutputLoadDecider` relies on this property.
627    #[proptest(cases = 512)]
628    fn cancellation_only_hides_output(
629        display: TestOutputDisplay,
630        cancel_status: Option<CancelReason>,
631        test_status_level: StatusLevel,
632        test_final_status_level: FinalStatusLevel,
633        execution_result: ExecutionResultDescription,
634        status_level: StatusLevel,
635        final_status_level: FinalStatusLevel,
636    ) {
637        let status_levels = StatusLevels {
638            status_level,
639            final_status_level,
640        };
641
642        let baseline = status_levels.compute_output_on_test_finished(
643            display,
644            None,
645            test_status_level,
646            test_final_status_level,
647            &execution_result,
648        );
649
650        let with_cancel = status_levels.compute_output_on_test_finished(
651            display,
652            cancel_status,
653            test_status_level,
654            test_final_status_level,
655            &execution_result,
656        );
657
658        // Cancellation must never show MORE output than the baseline.
659        if !baseline.show_immediate {
660            assert!(
661                !with_cancel.show_immediate,
662                "cancel_status={cancel_status:?} caused immediate output that \
663                 wouldn't appear without cancellation"
664            );
665        }
666
667        // For store_final, monotonicity has two dimensions:
668        // 1. An entry stored (No -> Yes is an escalation).
669        // 2. Output bytes displayed (display_output: false -> true is an
670        //    escalation).
671        //
672        // All 9 combinations are enumerated so that adding a new
673        // OutputStoreFinal variant forces an update here.
674        match (&baseline.store_final, &with_cancel.store_final) {
675            // Cancellation caused storage that wouldn't happen without it.
676            (OutputStoreFinal::No, OutputStoreFinal::Yes { display_output }) => {
677                panic!(
678                    "cancel_status={cancel_status:?} caused final output storage \
679                     (display_output={display_output}) that wouldn't happen \
680                     without cancellation"
681                );
682            }
683            // Cancellation caused output bytes to be displayed when they
684            // wouldn't be without it.
685            (
686                OutputStoreFinal::Yes {
687                    display_output: false,
688                },
689                OutputStoreFinal::Yes {
690                    display_output: true,
691                },
692            ) => {
693                panic!(
694                    "cancel_status={cancel_status:?} caused final output display \
695                     that wouldn't happen without cancellation"
696                );
697            }
698
699            // Same or reduced visibility is all right.
700            (OutputStoreFinal::No, OutputStoreFinal::No)
701            | (
702                OutputStoreFinal::Yes {
703                    display_output: false,
704                },
705                OutputStoreFinal::No,
706            )
707            | (
708                OutputStoreFinal::Yes {
709                    display_output: false,
710                },
711                OutputStoreFinal::Yes {
712                    display_output: false,
713                },
714            )
715            | (
716                OutputStoreFinal::Yes {
717                    display_output: true,
718                },
719                _,
720            ) => {}
721        }
722    }
723
724    // --- should_load_for_test_finished with real ExecutionStatuses ---
725    //
726    // These tests use ExecutionStatuses<RecordingSpec> which naturally
727    // covers flaky runs (multi-attempt with last passing), is_slow
728    // interactions (is_slow changes final_status_level), and multi-attempt
729    // scenarios.
730
731    #[derive(Debug, Arbitrary)]
732    struct TestFinishedLoadDeciderInput {
733        status_level: StatusLevel,
734        final_status_level: FinalStatusLevel,
735        success_output: TestOutputDisplay,
736        failure_output: TestOutputDisplay,
737        force_success_output: Option<TestOutputDisplay>,
738        force_failure_output: Option<TestOutputDisplay>,
739        force_exec_fail_output: Option<TestOutputDisplay>,
740        run_statuses: ExecutionStatuses<RecordingSpec>,
741    }
742
743    /// If the decider returns Skip for a TestFinished event, the displayer's
744    /// `compute_output_on_test_finished` must never access output bytes. The
745    /// cancellation_only_hides test above ensures this extends to all
746    /// cancel_status values.
747    ///
748    /// The invariant is one-directional: Skip implies no output byte access.
749    /// The displayer may still store a final entry for the status line, which
750    /// is fine if display_output is false.
751    ///
752    /// This test exercises the full `should_load_for_test_finished` path
753    /// with real `ExecutionStatuses`.
754    #[proptest(cases = 512)]
755    fn load_decider_test_finished_skip_implies_no_output(input: TestFinishedLoadDeciderInput) {
756        let TestFinishedLoadDeciderInput {
757            status_level,
758            final_status_level,
759            success_output,
760            failure_output,
761            force_success_output,
762            force_failure_output,
763            force_exec_fail_output,
764            run_statuses,
765        } = input;
766
767        let decider = OutputLoadDecider {
768            status_level,
769            overrides: OutputDisplayOverrides {
770                force_success_output,
771                force_failure_output,
772                force_exec_fail_output,
773            },
774        };
775
776        let load_decision =
777            decider.should_load_for_test_finished(success_output, failure_output, &run_statuses);
778
779        if load_decision == LoadOutput::Skip {
780            // Derive the same inputs the displayer would compute.
781            let describe = run_statuses.describe();
782            let last_status = describe.last_status();
783
784            let display =
785                decider
786                    .overrides
787                    .resolve_for_describe(success_output, failure_output, &describe);
788
789            let test_status_level = describe.status_level();
790            let test_final_status_level = describe.final_status_level();
791
792            let status_levels = StatusLevels {
793                status_level,
794                final_status_level,
795            };
796
797            let output = status_levels.compute_output_on_test_finished(
798                display,
799                None, // cancel status
800                test_status_level,
801                test_final_status_level,
802                &last_status.result,
803            );
804
805            assert!(
806                !output.show_immediate,
807                "load decider returned Skip but displayer would show immediate output \
808                 (display={display:?}, test_status_level={test_status_level:?}, \
809                 test_final_status_level={test_final_status_level:?})"
810            );
811            // The displayer may still store an entry for the status line,
812            // but it must not display output bytes (display_output: false).
813            if let OutputStoreFinal::Yes {
814                display_output: true,
815            } = output.store_final
816            {
817                panic!(
818                    "load decider returned Skip but displayer would display final output \
819                     (display={display:?}, test_status_level={test_status_level:?}, \
820                     test_final_status_level={test_final_status_level:?})"
821                );
822            }
823        }
824    }
825
826    /// For TestAttemptFailedWillRetry, the decider's Load/Skip decision
827    /// must exactly match whether the displayer would show retry output.
828    ///
829    /// The displayer shows retry output iff both conditions hold:
830    ///
831    /// 1. `status_level >= Retry` (the retry line is printed at all)
832    /// 2. `resolved_failure_output.is_immediate()` (output is shown inline)
833    ///
834    /// The decider must return Load for exactly these cases and Skip
835    /// otherwise.
836    ///
837    /// ```text
838    /// status_level >= Retry   resolved.is_immediate()   displayer shows   decider
839    ///       false                    false                   no             Skip
840    ///       false                    true                    no             Skip
841    ///       true                     false                   no             Skip
842    ///       true                     true                    yes            Load
843    /// ```
844    #[proptest(cases = 64)]
845    fn load_decider_matches_retry_output(
846        status_level: StatusLevel,
847        failure_output: TestOutputDisplay,
848        force_failure_output: Option<TestOutputDisplay>,
849    ) {
850        let decider = OutputLoadDecider {
851            status_level,
852            overrides: OutputDisplayOverrides {
853                force_success_output: None,
854                force_failure_output,
855                force_exec_fail_output: None,
856            },
857        };
858
859        let resolved = decider.overrides.failure_output(failure_output);
860        let displayer_would_show = resolved.is_immediate() && status_level >= StatusLevel::Retry;
861
862        let expected = if displayer_would_show {
863            LoadOutput::Load
864        } else {
865            LoadOutput::Skip
866        };
867
868        let actual = OutputLoadDecider::should_load_for_retry(resolved, status_level);
869        assert_eq!(actual, expected);
870    }
871
872    /// For SetupScriptFinished: the decider returns Load iff the result
873    /// is not a success (the displayer always shows output for failures).
874    #[proptest(cases = 64)]
875    fn load_decider_matches_setup_script_output(execution_result: ExecutionResultDescription) {
876        let expected = if execution_result.is_success() {
877            LoadOutput::Skip
878        } else {
879            LoadOutput::Load
880        };
881        let actual = OutputLoadDecider::should_load_for_setup_script(&execution_result);
882        assert_eq!(actual, expected);
883    }
884
885    // --- Wiring test for should_load_output ---
886    //
887    // The public entry point should_load_output dispatches to the
888    // individual helper methods. This test verifies the dispatch is
889    // correct: a wiring error (e.g. passing success_output where
890    // failure_output is intended) would be caught.
891
892    /// `should_load_output` must produce the same result as calling the
893    /// corresponding helper method for each `OutputEventKind` variant.
894    #[proptest(cases = 256)]
895    fn should_load_output_consistent_with_helpers(
896        status_level: StatusLevel,
897        force_success_output: Option<TestOutputDisplay>,
898        force_failure_output: Option<TestOutputDisplay>,
899        force_exec_fail_output: Option<TestOutputDisplay>,
900        event_kind: OutputEventKind<RecordingSpec>,
901    ) {
902        let decider = OutputLoadDecider {
903            status_level,
904            overrides: OutputDisplayOverrides {
905                force_success_output,
906                force_failure_output,
907                force_exec_fail_output,
908            },
909        };
910
911        let actual = decider.should_load_output(&event_kind);
912
913        let expected = match &event_kind {
914            OutputEventKind::SetupScriptFinished { run_status, .. } => {
915                OutputLoadDecider::should_load_for_setup_script(&run_status.result)
916            }
917            OutputEventKind::TestAttemptFailedWillRetry { failure_output, .. } => {
918                let display = decider.overrides.failure_output(*failure_output);
919                OutputLoadDecider::should_load_for_retry(display, status_level)
920            }
921            OutputEventKind::TestFinished {
922                success_output,
923                failure_output,
924                run_statuses,
925                ..
926            } => decider.should_load_for_test_finished(
927                *success_output,
928                *failure_output,
929                run_statuses,
930            ),
931        };
932
933        assert_eq!(
934            actual, expected,
935            "should_load_output disagrees with individual helper for event kind"
936        );
937    }
938}