Skip to main content

tarn/report/
human.rs

1use crate::assert::hints::step_hints;
2use crate::assert::types::{FailureCategory, FileResult, RunResult, StepResult, TestResult};
3use crate::model::RedactionConfig;
4use crate::report::redaction::sanitize_assertion;
5use crate::report::RenderOptions;
6use colored::Colorize;
7
8/// Render test results as colored human-readable output.
9pub fn render(result: &RunResult) -> String {
10    render_with_options(result, RenderOptions::default())
11}
12
13/// Render test results as colored human-readable output with the given options.
14pub fn render_with_options(result: &RunResult, opts: RenderOptions) -> String {
15    let mut output = String::new();
16
17    for file_result in &result.file_results {
18        if opts.only_failed && file_result.passed {
19            continue;
20        }
21        render_file(&mut output, file_result, opts);
22    }
23
24    output.push_str(&render_summary(result));
25    output
26}
27
28/// Render the trailing summary line (with a leading newline) for a run result.
29pub fn render_summary(result: &RunResult) -> String {
30    let passed = result.passed_steps();
31    let failed = result.failed_steps();
32    let duration = result.duration_ms;
33
34    let mut out = String::from("\n");
35    if failed == 0 {
36        out.push_str(&format!(
37            " {} {} passed ({}ms)\n",
38            "Results:".bold(),
39            passed.to_string().green(),
40            duration
41        ));
42    } else {
43        out.push_str(&format!(
44            " {} {} passed, {} failed ({}ms)\n",
45            "Results:".bold(),
46            passed.to_string().green(),
47            failed.to_string().red(),
48            duration
49        ));
50    }
51    out
52}
53
54/// Render the file header line (the `TARN Running <path>` banner plus file name).
55pub fn render_file_header(file_result: &FileResult) -> String {
56    render_file_header_parts(&file_result.file, &file_result.name)
57}
58
59/// Render a file header from its raw parts. Used by progress reporters that
60/// don't yet have a `FileResult` struct built (e.g. streaming before tests run).
61pub fn render_file_header_parts(file_path: &str, file_name: &str) -> String {
62    let mut out = String::new();
63    out.push_str(&format!(
64        "\n {} Running {}\n\n",
65        "TARN".bold().white().on_blue(),
66        file_path.dimmed()
67    ));
68    out.push_str(&format!(" {} {}\n", "●".bold(), file_name.bold()));
69    out
70}
71
72/// Render the setup block if it contains any (filtered) steps to display.
73pub fn render_setup_block(
74    setup_results: &[StepResult],
75    redaction: &RedactionConfig,
76    redacted_values: &[String],
77    opts: RenderOptions,
78) -> String {
79    let has_visible = setup_results
80        .iter()
81        .any(|s| !(opts.only_failed && s.passed));
82    if !has_visible {
83        return String::new();
84    }
85    let mut out = String::new();
86    out.push_str(&format!("\n   {}\n", "Setup".dimmed()));
87    for step in setup_results {
88        if opts.only_failed && step.passed {
89            continue;
90        }
91        render_step_into(&mut out, step, redaction, redacted_values);
92    }
93    out
94}
95
96/// Render a single test group block (header + steps).
97pub fn render_test_block(
98    test: &TestResult,
99    redaction: &RedactionConfig,
100    redacted_values: &[String],
101    opts: RenderOptions,
102) -> String {
103    if opts.only_failed && test.passed {
104        return String::new();
105    }
106    let mut out = String::new();
107    out.push('\n');
108    if let Some(ref desc) = test.description {
109        out.push_str(&format!("   {} — {}\n", test.name.bold(), desc.dimmed()));
110    } else {
111        out.push_str(&format!("   {}\n", test.name.bold()));
112    }
113    for step in &test.step_results {
114        if opts.only_failed && step.passed {
115            continue;
116        }
117        render_step_into(&mut out, step, redaction, redacted_values);
118    }
119    out
120}
121
122/// Render the teardown block if it contains any (filtered) steps to display.
123pub fn render_teardown_block(
124    teardown_results: &[StepResult],
125    redaction: &RedactionConfig,
126    redacted_values: &[String],
127    opts: RenderOptions,
128) -> String {
129    let has_visible = teardown_results
130        .iter()
131        .any(|s| !(opts.only_failed && s.passed));
132    if !has_visible {
133        return String::new();
134    }
135    let mut out = String::new();
136    out.push_str(&format!("\n   {}\n", "Teardown".dimmed()));
137    for step in teardown_results {
138        if opts.only_failed && step.passed {
139            continue;
140        }
141        render_step_into(&mut out, step, redaction, redacted_values);
142    }
143    out
144}
145
146fn render_file(output: &mut String, file_result: &FileResult, opts: RenderOptions) {
147    output.push_str(&render_file_header(file_result));
148    output.push_str(&render_setup_block(
149        &file_result.setup_results,
150        &file_result.redaction,
151        &file_result.redacted_values,
152        opts,
153    ));
154    for test in &file_result.test_results {
155        output.push_str(&render_test_block(
156            test,
157            &file_result.redaction,
158            &file_result.redacted_values,
159            opts,
160        ));
161    }
162    output.push_str(&render_teardown_block(
163        &file_result.teardown_results,
164        &file_result.redaction,
165        &file_result.redacted_values,
166        opts,
167    ));
168}
169
170/// Render a step's optional `description:` underneath its name line.
171/// Each description line is indented so it visually nests under the step
172/// glyph and dimmed via the `colored` crate to match how setup/teardown
173/// headers and the `└─` connector are rendered elsewhere in this module.
174/// Multi-line descriptions (from YAML `|` or `>` scalars) are split so
175/// every line gets the same dimmed indent — no raw `\n` tokens leak into
176/// the human report.
177fn render_step_description_into(output: &mut String, step: &StepResult) {
178    if let Some(ref description) = step.description {
179        for line in description.lines() {
180            output.push_str(&format!("     {}\n", line.dimmed()));
181        }
182    }
183}
184
185fn render_step_into(
186    output: &mut String,
187    step: &StepResult,
188    redaction: &RedactionConfig,
189    redacted_values: &[String],
190) {
191    if step.passed {
192        output.push_str(&format!(
193            "   {} {} ({}ms)\n",
194            "✓".green(),
195            step.name,
196            step.duration_ms
197        ));
198        render_step_description_into(output, step);
199    } else if matches!(
200        step.error_category,
201        Some(FailureCategory::SkippedDueToFailedCapture)
202            | Some(FailureCategory::SkippedDueToFailFast)
203    ) {
204        // Skipped-cascade steps use a distinct glyph so operators can
205        // tell cascade fallout apart from primary failures at a glance.
206        output.push_str(&format!(
207            "   {} {} (skipped)\n",
208            "⊘".yellow(),
209            step.name.yellow(),
210        ));
211        render_step_description_into(output, step);
212        if let Some(reason) = step.assertion_results.iter().find(|a| !a.passed) {
213            let reason = sanitize_assertion(reason, redaction, redacted_values);
214            output.push_str(&format!(
215                "     {} {}\n",
216                "└─".dimmed(),
217                reason.message.yellow()
218            ));
219        }
220    } else {
221        output.push_str(&format!(
222            "   {} {} ({}ms)\n",
223            "✗".red(),
224            step.name.red(),
225            step.duration_ms
226        ));
227        render_step_description_into(output, step);
228        // Show failure details
229        let failures = step.failures();
230        let hints = step_hints(step);
231        let failure_count = failures.len();
232        let hint_count = hints.len();
233        for (i, failure) in failures.iter().enumerate() {
234            let failure = sanitize_assertion(failure, redaction, redacted_values);
235            // Reserve the closing `└─` for the very last line we emit
236            // (the final hint if present, otherwise the final failure).
237            let is_last_line = i == failure_count - 1 && hint_count == 0;
238            let connector = if is_last_line { "└─" } else { "├─" };
239            output.push_str(&format!(
240                "     {} {}\n",
241                connector.dimmed(),
242                failure.message.red()
243            ));
244            if let Some(diff) = &failure.diff {
245                for line in diff.lines() {
246                    let colored = if line.starts_with("---") || line.starts_with("+++") {
247                        line.bold().to_string()
248                    } else if line.starts_with('+') {
249                        line.green().to_string()
250                    } else if line.starts_with('-') {
251                        line.red().to_string()
252                    } else {
253                        line.dimmed().to_string()
254                    };
255                    output.push_str(&format!("       {}\n", colored));
256                }
257            }
258        }
259
260        // Emit optional diagnostic hints (e.g. route-ordering) after
261        // the raw failure messages. Each hint renders as a dimmed
262        // `note:` line so it's visibly separate from the failure
263        // itself and does not masquerade as a new assertion failure.
264        for (i, hint) in hints.iter().enumerate() {
265            let is_last_line = i == hint_count - 1;
266            let connector = if is_last_line { "└─" } else { "├─" };
267            output.push_str(&format!("     {} {}\n", connector.dimmed(), hint.dimmed()));
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::assert::types::*;
276    use std::collections::HashMap;
277
278    fn make_run_result(passed: bool) -> RunResult {
279        RunResult {
280            duration_ms: 100,
281            file_results: vec![FileResult {
282                file: "test.tarn.yaml".into(),
283                name: "Test Suite".into(),
284                passed,
285                duration_ms: 100,
286                redaction: crate::model::RedactionConfig::default(),
287                redacted_values: vec![],
288                setup_results: vec![],
289                test_results: vec![TestResult {
290                    name: "my_test".into(),
291                    description: Some("A test".into()),
292                    passed,
293                    duration_ms: 100,
294                    step_results: vec![StepResult {
295                        name: "Check status".into(),
296                        description: None,
297                        debug: false,
298                        passed,
299                        duration_ms: 50,
300                        assertion_results: if passed {
301                            vec![AssertionResult::pass("status", "200", "200")]
302                        } else {
303                            vec![AssertionResult::fail(
304                                "status",
305                                "200",
306                                "404",
307                                "Expected HTTP status 200, got 404",
308                            )]
309                        },
310                        request_info: None,
311                        response_info: None,
312                        error_category: None,
313                        response_status: None,
314                        response_summary: None,
315                        captures_set: vec![],
316                        location: None,
317                        response_shape_mismatch: None,
318                    }],
319                    captures: HashMap::new(),
320                }],
321                teardown_results: vec![],
322            }],
323        }
324    }
325
326    #[test]
327    fn render_passing_test() {
328        let result = make_run_result(true);
329        let output = render(&result);
330        assert!(output.contains("Test Suite"));
331        assert!(output.contains("Check status"));
332        assert!(output.contains("1")); // 1 passed
333    }
334
335    #[test]
336    fn render_failing_test() {
337        let result = make_run_result(false);
338        let output = render(&result);
339        assert!(output.contains("Check status"));
340        assert!(output.contains("Expected HTTP status 200, got 404"));
341    }
342
343    #[test]
344    fn render_with_setup_and_teardown() {
345        let result = RunResult {
346            duration_ms: 200,
347            file_results: vec![FileResult {
348                file: "test.tarn.yaml".into(),
349                name: "Suite".into(),
350                passed: true,
351                duration_ms: 200,
352                redaction: crate::model::RedactionConfig::default(),
353                redacted_values: vec![],
354                setup_results: vec![StepResult {
355                    name: "Auth".into(),
356                    description: None,
357                    debug: false,
358                    passed: true,
359                    duration_ms: 50,
360                    assertion_results: vec![],
361                    request_info: None,
362                    response_info: None,
363                    error_category: None,
364                    response_status: None,
365                    response_summary: None,
366                    captures_set: vec![],
367                    location: None,
368                    response_shape_mismatch: None,
369                }],
370                test_results: vec![],
371                teardown_results: vec![StepResult {
372                    name: "Cleanup".into(),
373                    description: None,
374                    debug: false,
375                    passed: true,
376                    duration_ms: 30,
377                    assertion_results: vec![],
378                    request_info: None,
379                    response_info: None,
380                    error_category: None,
381                    response_status: None,
382                    response_summary: None,
383                    captures_set: vec![],
384                    location: None,
385                    response_shape_mismatch: None,
386                }],
387            }],
388        };
389        let output = render(&result);
390        assert!(output.contains("Setup"));
391        assert!(output.contains("Auth"));
392        assert!(output.contains("Teardown"));
393        assert!(output.contains("Cleanup"));
394    }
395
396    #[test]
397    fn render_multiple_failures_shows_all() {
398        let result = RunResult {
399            duration_ms: 100,
400            file_results: vec![FileResult {
401                file: "test.tarn.yaml".into(),
402                name: "Suite".into(),
403                passed: false,
404                duration_ms: 100,
405                redaction: crate::model::RedactionConfig::default(),
406                redacted_values: vec![],
407                setup_results: vec![],
408                test_results: vec![TestResult {
409                    name: "test".into(),
410                    description: None,
411                    passed: false,
412                    duration_ms: 100,
413                    step_results: vec![StepResult {
414                        name: "step".into(),
415                        description: None,
416                        debug: false,
417                        passed: false,
418                        duration_ms: 50,
419                        assertion_results: vec![
420                            AssertionResult::fail("status", "200", "403", "status mismatch"),
421                            AssertionResult::fail(
422                                "body $.error",
423                                "\"ok\"",
424                                "\"forbidden\"",
425                                "body mismatch",
426                            ),
427                        ],
428                        request_info: None,
429                        response_info: None,
430                        error_category: None,
431                        response_status: None,
432                        response_summary: None,
433                        captures_set: vec![],
434                        location: None,
435                        response_shape_mismatch: None,
436                    }],
437                    captures: HashMap::new(),
438                }],
439                teardown_results: vec![],
440            }],
441        };
442        let output = render(&result);
443        assert!(output.contains("status mismatch"));
444        assert!(output.contains("body mismatch"));
445    }
446
447    #[test]
448    fn render_whole_body_diff() {
449        let result = RunResult {
450            duration_ms: 100,
451            file_results: vec![FileResult {
452                file: "test.tarn.yaml".into(),
453                name: "Suite".into(),
454                passed: false,
455                duration_ms: 100,
456                redaction: crate::model::RedactionConfig::default(),
457                redacted_values: vec![],
458                setup_results: vec![],
459                test_results: vec![TestResult {
460                    name: "test".into(),
461                    description: None,
462                    passed: false,
463                    duration_ms: 100,
464                    step_results: vec![StepResult {
465                        name: "step".into(),
466                        description: None,
467                        debug: false,
468                        passed: false,
469                        duration_ms: 50,
470                        assertion_results: vec![AssertionResult::fail_with_diff(
471                            "body $",
472                            "{\"name\":\"Alice\"}",
473                            "{\"name\":\"Bob\"}",
474                            "whole body mismatch",
475                            "--- expected\n+++ actual\n-  \"name\": \"Alice\"\n+  \"name\": \"Bob\"\n",
476                        )],
477                        request_info: None,
478                        response_info: None,
479                        error_category: None,
480                        response_status: None,
481                        response_summary: None,
482                        captures_set: vec![],
483                        location: None,
484                        response_shape_mismatch: None,
485                    }],
486                    captures: HashMap::new(),
487                }],
488                teardown_results: vec![],
489            }],
490        };
491        let output = render(&result);
492        assert!(output.contains("whole body mismatch"));
493        assert!(output.contains("--- expected"));
494        assert!(output.contains("+++ actual"));
495    }
496
497    #[test]
498    fn route_ordering_hint_rendered_example_snapshot() {
499        // Snapshot-style test: captures the exact rendered output
500        // (without ANSI color codes) so doc examples can't drift from
501        // the real formatter.
502        colored::control::set_override(false);
503
504        let mut headers = HashMap::new();
505        headers.insert("Content-Type".into(), "application/json".into());
506
507        let run = RunResult {
508            duration_ms: 12,
509            file_results: vec![FileResult {
510                file: "orders.tarn.yaml".into(),
511                name: "Orders API".into(),
512                passed: false,
513                duration_ms: 12,
514                redaction: crate::model::RedactionConfig::default(),
515                redacted_values: vec![],
516                setup_results: vec![],
517                test_results: vec![TestResult {
518                    name: "approve_order".into(),
519                    description: None,
520                    passed: false,
521                    duration_ms: 12,
522                    step_results: vec![StepResult {
523                        name: "POST /orders/approve".into(),
524                        description: None,
525                        debug: false,
526                        passed: false,
527                        duration_ms: 12,
528                        assertion_results: vec![AssertionResult::fail(
529                            "status",
530                            "201",
531                            "400",
532                            "Expected HTTP status 201, got 400",
533                        )],
534                        request_info: Some(RequestInfo {
535                            method: "POST".into(),
536                            url: "http://api.example.com/orders/approve".into(),
537                            headers,
538                            body: None,
539                            multipart: None,
540                        }),
541                        response_info: Some(ResponseInfo {
542                            status: 400,
543                            headers: HashMap::new(),
544                            body: Some(serde_json::json!({
545                                "statusCode": 400,
546                                "message": "Validation failed (uuid is expected)",
547                                "error": "Bad Request"
548                            })),
549                        }),
550                        error_category: Some(FailureCategory::AssertionFailed),
551                        response_status: Some(400),
552                        response_summary: None,
553                        captures_set: vec![],
554                        location: None,
555                        response_shape_mismatch: None,
556                    }],
557                    captures: HashMap::new(),
558                }],
559                teardown_results: vec![],
560            }],
561        };
562
563        let output = render(&run);
564        // Intentionally match on exact substrings rather than the
565        // full banner so we don't break on cosmetic whitespace.
566        assert!(output.contains("✗ POST /orders/approve (12ms)"));
567        assert!(output.contains("├─ Expected HTTP status 201, got 400"));
568        assert!(output.contains(
569            "└─ note: the server may have matched this path to a dynamic route (e.g. /foo/:id); check for route ordering conflicts (see docs/TROUBLESHOOTING.md#route-ordering)."
570        ));
571
572        colored::control::unset_override();
573    }
574
575    #[test]
576    fn render_emits_route_ordering_hint_when_body_signals_it() {
577        let mut headers = HashMap::new();
578        headers.insert("Content-Type".into(), "application/json".into());
579
580        let run = RunResult {
581            duration_ms: 5,
582            file_results: vec![FileResult {
583                file: "test.tarn.yaml".into(),
584                name: "Suite".into(),
585                passed: false,
586                duration_ms: 5,
587                redaction: crate::model::RedactionConfig::default(),
588                redacted_values: vec![],
589                setup_results: vec![],
590                test_results: vec![TestResult {
591                    name: "approve_order".into(),
592                    description: None,
593                    passed: false,
594                    duration_ms: 5,
595                    step_results: vec![StepResult {
596                        name: "POST /orders/approve".into(),
597                        description: None,
598                        debug: false,
599                        passed: false,
600                        duration_ms: 5,
601                        assertion_results: vec![AssertionResult::fail(
602                            "status",
603                            "201",
604                            "400",
605                            "Expected HTTP status 201, got 400",
606                        )],
607                        request_info: Some(RequestInfo {
608                            method: "POST".into(),
609                            url: "http://api.example.com/orders/approve".into(),
610                            headers,
611                            body: None,
612                            multipart: None,
613                        }),
614                        response_info: Some(ResponseInfo {
615                            status: 400,
616                            headers: HashMap::new(),
617                            body: Some(serde_json::json!({
618                                "statusCode": 400,
619                                "message": "Validation failed (uuid is expected)",
620                                "error": "Bad Request"
621                            })),
622                        }),
623                        error_category: Some(FailureCategory::AssertionFailed),
624                        response_status: Some(400),
625                        response_summary: None,
626                        captures_set: vec![],
627                        location: None,
628                        response_shape_mismatch: None,
629                    }],
630                    captures: HashMap::new(),
631                }],
632                teardown_results: vec![],
633            }],
634        };
635
636        let output = render(&run);
637        assert!(
638            output.contains("route ordering"),
639            "expected route-ordering hint in output, got:\n{}",
640            output
641        );
642        assert!(output.contains("docs/TROUBLESHOOTING.md#route-ordering"));
643    }
644
645    #[test]
646    fn render_does_not_emit_route_ordering_hint_without_signal() {
647        let run = RunResult {
648            duration_ms: 5,
649            file_results: vec![FileResult {
650                file: "test.tarn.yaml".into(),
651                name: "Suite".into(),
652                passed: false,
653                duration_ms: 5,
654                redaction: crate::model::RedactionConfig::default(),
655                redacted_values: vec![],
656                setup_results: vec![],
657                test_results: vec![TestResult {
658                    name: "approve_order".into(),
659                    description: None,
660                    passed: false,
661                    duration_ms: 5,
662                    step_results: vec![StepResult {
663                        name: "POST /orders/approve".into(),
664                        description: None,
665                        debug: false,
666                        passed: false,
667                        duration_ms: 5,
668                        assertion_results: vec![AssertionResult::fail(
669                            "status",
670                            "201",
671                            "400",
672                            "Expected HTTP status 201, got 400",
673                        )],
674                        request_info: Some(RequestInfo {
675                            method: "POST".into(),
676                            url: "http://api.example.com/orders/approve".into(),
677                            headers: HashMap::new(),
678                            body: None,
679                            multipart: None,
680                        }),
681                        response_info: Some(ResponseInfo {
682                            status: 400,
683                            headers: HashMap::new(),
684                            body: Some(serde_json::json!({"message": "Insufficient funds"})),
685                        }),
686                        error_category: Some(FailureCategory::AssertionFailed),
687                        response_status: Some(400),
688                        response_summary: None,
689                        captures_set: vec![],
690                        location: None,
691                        response_shape_mismatch: None,
692                    }],
693                    captures: HashMap::new(),
694                }],
695                teardown_results: vec![],
696            }],
697        };
698
699        let output = render(&run);
700        assert!(!output.contains("route ordering"));
701        assert!(!output.contains("docs/TROUBLESHOOTING.md#route-ordering"));
702    }
703
704    #[test]
705    fn render_redacts_secret_values_in_messages() {
706        let result = RunResult {
707            duration_ms: 10,
708            file_results: vec![FileResult {
709                file: "test.tarn.yaml".into(),
710                name: "Suite".into(),
711                passed: false,
712                duration_ms: 10,
713                redaction: crate::model::RedactionConfig {
714                    replacement: "[hidden]".into(),
715                    ..crate::model::RedactionConfig::default()
716                },
717                redacted_values: vec!["secret-token".into()],
718                setup_results: vec![],
719                test_results: vec![TestResult {
720                    name: "test".into(),
721                    description: None,
722                    passed: false,
723                    duration_ms: 10,
724                    step_results: vec![StepResult {
725                        name: "step".into(),
726                        description: None,
727                        debug: false,
728                        passed: false,
729                        duration_ms: 10,
730                        assertion_results: vec![AssertionResult::fail(
731                            "status",
732                            "200",
733                            "401",
734                            "Expected secret-token to be accepted",
735                        )],
736                        request_info: None,
737                        response_info: None,
738                        error_category: None,
739                        response_status: None,
740                        response_summary: None,
741                        captures_set: vec![],
742                        location: None,
743                        response_shape_mismatch: None,
744                    }],
745                    captures: HashMap::new(),
746                }],
747                teardown_results: vec![],
748            }],
749        };
750
751        let output = render(&result);
752        assert!(!output.contains("secret-token"));
753        assert!(output.contains("Expected [hidden] to be accepted"));
754    }
755
756    // --- Step-level descriptions in human report (NAZ-243) ---
757
758    /// Strip the ANSI SGR escape sequences the `colored` crate emits so
759    /// assertions can compare raw text regardless of whether another
760    /// parallel test flipped the global color override. Only matches the
761    /// `\x1b[...m` sequences `colored` produces — the `.dimmed()`,
762    /// `.green()`, `.red()`, and `.yellow()` wrappers used in this
763    /// module — so we do not pull in an extra dependency just for tests.
764    fn strip_ansi(input: &str) -> String {
765        let mut out = String::with_capacity(input.len());
766        let mut chars = input.chars();
767        while let Some(c) = chars.next() {
768            if c == '\x1b' {
769                // Consume the `[...m` CSI sequence.
770                if chars.next() != Some('[') {
771                    continue;
772                }
773                for ch in chars.by_ref() {
774                    if ch == 'm' {
775                        break;
776                    }
777                }
778            } else {
779                out.push(c);
780            }
781        }
782        out
783    }
784
785    /// Build a run result whose sole step carries the given pass-state
786    /// and optional description so each human-render test touches exactly
787    /// one variable. Tests pair this with `strip_ansi` on the render
788    /// output so assertions are stable even when another parallel test
789    /// is flipping the `colored` crate's global override.
790    fn run_with_single_step(passed: bool, description: Option<&str>) -> RunResult {
791        RunResult {
792            duration_ms: 10,
793            file_results: vec![FileResult {
794                file: "test.tarn.yaml".into(),
795                name: "Suite".into(),
796                passed,
797                duration_ms: 10,
798                redaction: crate::model::RedactionConfig::default(),
799                redacted_values: vec![],
800                setup_results: vec![],
801                test_results: vec![TestResult {
802                    name: "group".into(),
803                    description: None,
804                    passed,
805                    duration_ms: 10,
806                    step_results: vec![StepResult {
807                        name: "Step name".into(),
808                        description: description.map(str::to_string),
809                        debug: false,
810                        passed,
811                        duration_ms: 5,
812                        assertion_results: if passed {
813                            vec![AssertionResult::pass("status", "200", "200")]
814                        } else {
815                            vec![AssertionResult::fail(
816                                "status",
817                                "200",
818                                "500",
819                                "status mismatch",
820                            )]
821                        },
822                        request_info: None,
823                        response_info: None,
824                        error_category: None,
825                        response_status: None,
826                        response_summary: None,
827                        captures_set: vec![],
828                        location: None,
829                        response_shape_mismatch: None,
830                    }],
831                    captures: HashMap::new(),
832                }],
833                teardown_results: vec![],
834            }],
835        }
836    }
837
838    #[test]
839    fn human_renders_step_description_for_passing_step() {
840        // After stripping ANSI escapes, the description text must appear
841        // in the output underneath the step name.
842        let result = run_with_single_step(true, Some("Checks /health"));
843        let output = strip_ansi(&render(&result));
844        assert!(
845            output.contains("Step name"),
846            "step name must still render, got: {}",
847            output
848        );
849        assert!(
850            output.contains("Checks /health"),
851            "description must render beneath the step, got: {}",
852            output
853        );
854        // Description should appear AFTER the step name line, not before.
855        let name_pos = output.find("Step name").unwrap();
856        let desc_pos = output.find("Checks /health").unwrap();
857        assert!(
858            desc_pos > name_pos,
859            "description must render below the step name, got name@{} desc@{}",
860            name_pos,
861            desc_pos
862        );
863    }
864
865    #[test]
866    fn human_renders_step_description_for_failing_step() {
867        // Failures must still surface the description so operators see
868        // the author's intent alongside the mismatch.
869        let result = run_with_single_step(false, Some("Checks /health"));
870        let output = strip_ansi(&render(&result));
871        assert!(output.contains("Checks /health"));
872        assert!(output.contains("status mismatch"));
873    }
874
875    #[test]
876    fn human_omits_step_description_line_when_missing() {
877        // No description must mean no "Checks /health" line leaks in —
878        // the guard ensures the helper is a true no-op when the field is
879        // absent rather than emitting an empty indented row.
880        let result = run_with_single_step(true, None);
881        let output = strip_ansi(&render(&result));
882        assert!(output.contains("Step name"));
883        assert!(!output.contains("Checks /health"));
884    }
885
886    #[test]
887    fn human_renders_multi_line_step_description_indented() {
888        // Multi-line descriptions (from YAML `|` scalars) must keep each
889        // line on its own indented row so the report stays readable.
890        let result = run_with_single_step(true, Some("First line\nSecond line"));
891        let output = strip_ansi(&render(&result));
892        assert!(output.contains("First line"));
893        assert!(output.contains("Second line"));
894        // Each rendered line should carry the same five-space indent used
895        // by the description block so the two lines align under the step.
896        assert!(output.contains("     First line"));
897        assert!(output.contains("     Second line"));
898    }
899}