Skip to main content

fastapi_output/components/
test_results.rs

1//! Test results formatter component.
2//!
3//! Produces grouped, readable test output with summary statistics
4//! and optional progress bar rendering.
5
6use crate::mode::OutputMode;
7use crate::themes::FastApiTheme;
8use std::fmt::Write;
9
10const ANSI_RESET: &str = "\x1b[0m";
11
12/// Test case status.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TestStatus {
15    /// Test passed successfully.
16    Pass,
17    /// Test failed.
18    Fail,
19    /// Test was skipped.
20    Skip,
21}
22
23impl TestStatus {
24    /// Return the plain label for this status.
25    #[must_use]
26    pub const fn label(self) -> &'static str {
27        match self {
28            Self::Pass => "PASS",
29            Self::Fail => "FAIL",
30            Self::Skip => "SKIP",
31        }
32    }
33
34    /// Return the display indicator for this status in a given mode.
35    #[must_use]
36    pub const fn indicator(self, mode: OutputMode) -> &'static str {
37        match (self, mode) {
38            (Self::Pass, OutputMode::Rich) => "✓",
39            (Self::Fail, OutputMode::Rich) => "✗",
40            (Self::Skip, OutputMode::Rich) => "↷",
41            _ => self.label(),
42        }
43    }
44
45    fn color(self, theme: &FastApiTheme) -> crate::themes::Color {
46        match self {
47            Self::Pass => theme.success,
48            Self::Fail => theme.error,
49            Self::Skip => theme.warning,
50        }
51    }
52}
53
54/// A single test case result.
55#[derive(Debug, Clone)]
56pub struct TestCaseResult {
57    /// Test name.
58    pub name: String,
59    /// Test status.
60    pub status: TestStatus,
61    /// Duration in milliseconds.
62    pub duration_ms: Option<u128>,
63    /// Optional details (diff, error message, etc.).
64    pub details: Option<String>,
65}
66
67impl TestCaseResult {
68    /// Create a new test case result.
69    #[must_use]
70    pub fn new(name: impl Into<String>, status: TestStatus) -> Self {
71        Self {
72            name: name.into(),
73            status,
74            duration_ms: None,
75            details: None,
76        }
77    }
78
79    /// Set duration in milliseconds.
80    #[must_use]
81    pub fn duration_ms(mut self, duration_ms: u128) -> Self {
82        self.duration_ms = Some(duration_ms);
83        self
84    }
85
86    /// Add details for failed/skipped tests.
87    #[must_use]
88    pub fn details(mut self, details: impl Into<String>) -> Self {
89        self.details = Some(details.into());
90        self
91    }
92}
93
94/// Group of test cases under a module or file.
95#[derive(Debug, Clone)]
96pub struct TestModuleResult {
97    /// Module or file name.
98    pub name: String,
99    /// Test cases in this module.
100    pub cases: Vec<TestCaseResult>,
101}
102
103impl TestModuleResult {
104    /// Create a new module result.
105    #[must_use]
106    pub fn new(name: impl Into<String>, cases: Vec<TestCaseResult>) -> Self {
107        Self {
108            name: name.into(),
109            cases,
110        }
111    }
112
113    /// Add a test case to the module.
114    #[must_use]
115    pub fn case(mut self, case: TestCaseResult) -> Self {
116        self.cases.push(case);
117        self
118    }
119}
120
121/// Aggregate test report.
122#[derive(Debug, Clone)]
123pub struct TestReport {
124    /// All module results.
125    pub modules: Vec<TestModuleResult>,
126}
127
128impl TestReport {
129    /// Create a new report.
130    #[must_use]
131    pub fn new(modules: Vec<TestModuleResult>) -> Self {
132        Self { modules }
133    }
134
135    /// Add a module to the report.
136    #[must_use]
137    pub fn module(mut self, module: TestModuleResult) -> Self {
138        self.modules.push(module);
139        self
140    }
141
142    /// Get summary counts for the report.
143    #[must_use]
144    pub fn counts(&self) -> TestCounts {
145        let mut counts = TestCounts::default();
146        for module in &self.modules {
147            for case in &module.cases {
148                counts.total += 1;
149                match case.status {
150                    TestStatus::Pass => counts.passed += 1,
151                    TestStatus::Fail => counts.failed += 1,
152                    TestStatus::Skip => counts.skipped += 1,
153                }
154                if let Some(duration) = case.duration_ms {
155                    counts.duration_ms = Some(counts.duration_ms.unwrap_or(0) + duration);
156                }
157            }
158        }
159        counts
160    }
161
162    /// Render as TAP (Test Anything Protocol) output.
163    #[must_use]
164    pub fn to_tap(&self) -> String {
165        let counts = self.counts();
166        let mut lines = Vec::new();
167        lines.push("TAP version 13".to_string());
168        lines.push(format!("1..{}", counts.total));
169
170        let mut index = 1;
171        for module in &self.modules {
172            for case in &module.cases {
173                let status = match case.status {
174                    TestStatus::Fail => "not ok",
175                    TestStatus::Pass | TestStatus::Skip => "ok",
176                };
177                let mut line = format!("{status} {index} - {}::{}", module.name, case.name);
178                if case.status == TestStatus::Skip {
179                    line.push_str(" # SKIP");
180                }
181                lines.push(line);
182
183                if let Some(details) = &case.details {
184                    lines.push(format!("# {details}"));
185                }
186
187                index += 1;
188            }
189        }
190
191        lines.join("\n")
192    }
193}
194
195/// Summary counts for a test report.
196#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
197pub struct TestCounts {
198    /// Total test cases.
199    pub total: usize,
200    /// Passed count.
201    pub passed: usize,
202    /// Failed count.
203    pub failed: usize,
204    /// Skipped count.
205    pub skipped: usize,
206    /// Total duration (ms) if available.
207    pub duration_ms: Option<u128>,
208}
209
210/// Display configuration for test reports.
211#[derive(Debug, Clone)]
212pub struct TestReportDisplay {
213    mode: OutputMode,
214    theme: FastApiTheme,
215    show_timings: bool,
216    show_summary: bool,
217    show_progress: bool,
218    progress_width: usize,
219    title: Option<String>,
220}
221
222impl TestReportDisplay {
223    /// Create a new display for a given mode.
224    #[must_use]
225    pub fn new(mode: OutputMode) -> Self {
226        Self {
227            mode,
228            theme: FastApiTheme::default(),
229            show_timings: true,
230            show_summary: true,
231            show_progress: true,
232            progress_width: 24,
233            title: Some("Test Results".to_string()),
234        }
235    }
236
237    /// Set the theme.
238    #[must_use]
239    pub fn theme(mut self, theme: FastApiTheme) -> Self {
240        self.theme = theme;
241        self
242    }
243
244    /// Hide per-test timings.
245    #[must_use]
246    pub fn hide_timings(mut self) -> Self {
247        self.show_timings = false;
248        self
249    }
250
251    /// Hide summary footer.
252    #[must_use]
253    pub fn hide_summary(mut self) -> Self {
254        self.show_summary = false;
255        self
256    }
257
258    /// Hide progress bar.
259    #[must_use]
260    pub fn hide_progress(mut self) -> Self {
261        self.show_progress = false;
262        self
263    }
264
265    /// Set progress bar width.
266    #[must_use]
267    pub fn progress_width(mut self, width: usize) -> Self {
268        self.progress_width = width.max(8);
269        self
270    }
271
272    /// Set a custom title (None to disable).
273    #[must_use]
274    pub fn title(mut self, title: Option<String>) -> Self {
275        self.title = title;
276        self
277    }
278
279    /// Render the report to a string.
280    #[must_use]
281    pub fn render(&self, report: &TestReport) -> String {
282        let mut lines = Vec::new();
283
284        if let Some(title) = &self.title {
285            lines.push(title.clone());
286            lines.push("-".repeat(title.len()));
287        }
288
289        for module in &report.modules {
290            lines.push(self.render_module_header(&module.name));
291            for case in &module.cases {
292                lines.push(self.render_case_line(case));
293                if case.status == TestStatus::Fail {
294                    if let Some(details) = &case.details {
295                        lines.push(format!("    -> {details}"));
296                    }
297                }
298            }
299            lines.push(String::new());
300        }
301
302        let counts = report.counts();
303        if self.show_summary {
304            lines.push(Self::render_summary(&counts));
305        }
306        if self.show_progress && counts.total > 0 {
307            lines.push(self.render_progress(&counts));
308        }
309
310        lines.join("\n").trim_end().to_string()
311    }
312
313    fn render_module_header(&self, name: &str) -> String {
314        if self.mode.uses_ansi() {
315            let mut line = format!(
316                "{}Module:{} {}{}",
317                self.theme.accent.to_ansi_fg(),
318                ANSI_RESET,
319                self.theme.primary.to_ansi_fg(),
320                name
321            );
322            line.push_str(ANSI_RESET);
323            line
324        } else {
325            format!("Module: {name}")
326        }
327    }
328
329    fn render_case_line(&self, case: &TestCaseResult) -> String {
330        let indicator = case.status.indicator(self.mode);
331        let indicator = if self.mode.uses_ansi() {
332            format!(
333                "{}{}{}",
334                case.status.color(&self.theme).to_ansi_fg(),
335                indicator,
336                ANSI_RESET
337            )
338        } else {
339            indicator.to_string()
340        };
341
342        let timing = if self.show_timings {
343            match case.duration_ms {
344                Some(ms) => format!(" ({ms}ms)"),
345                None => String::new(),
346            }
347        } else {
348            String::new()
349        };
350
351        format!("  {indicator} {}{timing}", case.name)
352    }
353
354    fn render_summary(counts: &TestCounts) -> String {
355        let mut summary = format!(
356            "Summary: {} passed, {} failed, {} skipped ({} total)",
357            counts.passed, counts.failed, counts.skipped, counts.total
358        );
359        if let Some(duration) = counts.duration_ms {
360            let _ = write!(summary, " in {duration}ms");
361        }
362        summary
363    }
364
365    fn render_progress(&self, counts: &TestCounts) -> String {
366        let bar = progress_bar(
367            counts.passed,
368            counts.failed,
369            counts.skipped,
370            counts.total,
371            self.progress_width,
372            self.mode,
373            &self.theme,
374        );
375        format!("Progress: {bar}")
376    }
377}
378
379fn progress_bar(
380    passed: usize,
381    failed: usize,
382    skipped: usize,
383    total: usize,
384    width: usize,
385    mode: OutputMode,
386    theme: &FastApiTheme,
387) -> String {
388    if total == 0 {
389        return "[no tests]".to_string();
390    }
391
392    let width = width.max(8);
393    let pass_len = passed.saturating_mul(width) / total;
394    let fail_len = failed.saturating_mul(width) / total;
395    let skip_len = skipped.saturating_mul(width) / total;
396    let used = pass_len.saturating_add(fail_len).saturating_add(skip_len);
397    let remaining = width.saturating_sub(used);
398
399    let mut bar = String::new();
400    bar.push('[');
401
402    if mode.uses_ansi() {
403        if pass_len > 0 {
404            bar.push_str(&theme.success.to_ansi_fg());
405            bar.push_str(&"=".repeat(pass_len));
406            bar.push_str(ANSI_RESET);
407        }
408        if fail_len > 0 {
409            bar.push_str(&theme.error.to_ansi_fg());
410            bar.push_str(&"!".repeat(fail_len));
411            bar.push_str(ANSI_RESET);
412        }
413        if skip_len > 0 {
414            bar.push_str(&theme.warning.to_ansi_fg());
415            bar.push_str(&"-".repeat(skip_len));
416            bar.push_str(ANSI_RESET);
417        }
418        if remaining > 0 {
419            bar.push_str(&theme.muted.to_ansi_fg());
420            bar.push_str(&"-".repeat(remaining));
421            bar.push_str(ANSI_RESET);
422        }
423    } else {
424        bar.push_str(&"=".repeat(pass_len));
425        bar.push_str(&"!".repeat(fail_len));
426        bar.push_str(&"-".repeat(skip_len + remaining));
427    }
428
429    bar.push(']');
430    let _ = write!(bar, " {passed}/{total} passed");
431
432    if failed > 0 {
433        let _ = write!(bar, ", {failed} failed");
434    }
435    if skipped > 0 {
436        let _ = write!(bar, ", {skipped} skipped");
437    }
438
439    bar
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::testing::{assert_contains, assert_no_ansi};
446
447    #[test]
448    fn renders_plain_report() {
449        let module = TestModuleResult::new(
450            "core::routing",
451            vec![
452                TestCaseResult::new("test_match", TestStatus::Pass).duration_ms(12),
453                TestCaseResult::new("test_conflict", TestStatus::Fail)
454                    .duration_ms(3)
455                    .details("expected 2 routes, got 3"),
456            ],
457        );
458        let report = TestReport::new(vec![module]);
459        let display = TestReportDisplay::new(OutputMode::Plain);
460        let output = display.render(&report);
461
462        assert_contains(&output, "Test Results");
463        assert_contains(&output, "Module: core::routing");
464        assert_contains(&output, "PASS test_match");
465        assert_contains(&output, "FAIL test_conflict");
466        assert_contains(&output, "expected 2 routes");
467        assert_contains(&output, "Summary:");
468        assert_contains(&output, "Progress:");
469        assert_no_ansi(&output);
470    }
471
472    #[test]
473    fn renders_tap_output() {
474        let report = TestReport::new(vec![TestModuleResult::new(
475            "module",
476            vec![
477                TestCaseResult::new("ok_case", TestStatus::Pass),
478                TestCaseResult::new("skip_case", TestStatus::Skip),
479            ],
480        )]);
481
482        let tap = report.to_tap();
483        assert_contains(&tap, "TAP version 13");
484        assert_contains(&tap, "1..2");
485        assert_contains(&tap, "ok 1 - module::ok_case");
486        assert_contains(&tap, "ok 2 - module::skip_case # SKIP");
487    }
488}