fastapi_output/components/
test_results.rs1use crate::mode::OutputMode;
7use crate::themes::FastApiTheme;
8use std::fmt::Write;
9
10const ANSI_RESET: &str = "\x1b[0m";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TestStatus {
15 Pass,
17 Fail,
19 Skip,
21}
22
23impl TestStatus {
24 #[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 #[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#[derive(Debug, Clone)]
56pub struct TestCaseResult {
57 pub name: String,
59 pub status: TestStatus,
61 pub duration_ms: Option<u128>,
63 pub details: Option<String>,
65}
66
67impl TestCaseResult {
68 #[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 #[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 #[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#[derive(Debug, Clone)]
96pub struct TestModuleResult {
97 pub name: String,
99 pub cases: Vec<TestCaseResult>,
101}
102
103impl TestModuleResult {
104 #[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 #[must_use]
115 pub fn case(mut self, case: TestCaseResult) -> Self {
116 self.cases.push(case);
117 self
118 }
119}
120
121#[derive(Debug, Clone)]
123pub struct TestReport {
124 pub modules: Vec<TestModuleResult>,
126}
127
128impl TestReport {
129 #[must_use]
131 pub fn new(modules: Vec<TestModuleResult>) -> Self {
132 Self { modules }
133 }
134
135 #[must_use]
137 pub fn module(mut self, module: TestModuleResult) -> Self {
138 self.modules.push(module);
139 self
140 }
141
142 #[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 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
197pub struct TestCounts {
198 pub total: usize,
200 pub passed: usize,
202 pub failed: usize,
204 pub skipped: usize,
206 pub duration_ms: Option<u128>,
208}
209
210#[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 #[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 #[must_use]
239 pub fn theme(mut self, theme: FastApiTheme) -> Self {
240 self.theme = theme;
241 self
242 }
243
244 #[must_use]
246 pub fn hide_timings(mut self) -> Self {
247 self.show_timings = false;
248 self
249 }
250
251 #[must_use]
253 pub fn hide_summary(mut self) -> Self {
254 self.show_summary = false;
255 self
256 }
257
258 #[must_use]
260 pub fn hide_progress(mut self) -> Self {
261 self.show_progress = false;
262 self
263 }
264
265 #[must_use]
267 pub fn progress_width(mut self, width: usize) -> Self {
268 self.progress_width = width.max(8);
269 self
270 }
271
272 #[must_use]
274 pub fn title(mut self, title: Option<String>) -> Self {
275 self.title = title;
276 self
277 }
278
279 #[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}