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