1use std::fmt::Write;
6
7use super::{DurationTrend, FlakyTest, SlowTest, TestHistory, TestTrend};
8
9pub fn format_recent_runs(history: &TestHistory, n: usize) -> String {
11 let runs = history.recent_runs(n);
12 let mut out = String::with_capacity(1024);
13
14 let _ = writeln!(out);
15 let _ = writeln!(out, " Recent Test Runs");
16 let _ = writeln!(out, " ═══════════════════════════════════════");
17
18 if runs.is_empty() {
19 let _ = writeln!(out, " No test runs recorded yet.");
20 return out;
21 }
22
23 let _ = writeln!(
24 out,
25 " {:<22} {:>5} {:>5} {:>5} {:>5} {:>8} Status",
26 "Timestamp", "Total", "Pass", "Fail", "Skip", "Duration"
27 );
28 let _ = writeln!(
29 out,
30 " {:<22} {:>5} {:>5} {:>5} {:>5} {:>8} ──────",
31 "──────────────────────", "─────", "─────", "─────", "─────", "────────"
32 );
33
34 for run in runs.iter().rev() {
35 let status = if run.failed == 0 { "✅" } else { "❌" };
36 let duration = format_duration_ms(run.duration_ms);
37 let _ = writeln!(
38 out,
39 " {:<22} {:>5} {:>5} {:>5} {:>5} {:>8} {}",
40 &run.timestamp[..19.min(run.timestamp.len())],
41 run.total,
42 run.passed,
43 run.failed,
44 run.skipped,
45 duration,
46 status,
47 );
48 }
49
50 let _ = writeln!(out);
51 out
52}
53
54pub fn format_flaky_tests(flaky: &[FlakyTest]) -> String {
56 let mut out = String::with_capacity(512);
57
58 let _ = writeln!(out);
59 let _ = writeln!(out, " Flaky Tests");
60 let _ = writeln!(out, " ═══════════════════════════════════════");
61
62 if flaky.is_empty() {
63 let _ = writeln!(out, " No flaky tests detected! ✅");
64 return out;
65 }
66
67 let _ = writeln!(
68 out,
69 " {:<40} {:>8} {:>6} {:>5} Recent",
70 "Test", "PassRate", "Runs", "Fails"
71 );
72 let _ = writeln!(
73 out,
74 " {:<40} {:>8} {:>6} {:>5} ──────────",
75 "────────────────────────────────────────", "────────", "──────", "─────"
76 );
77
78 for test in flaky {
79 let name = if test.name.len() > 40 {
80 format!("…{}", &test.name[test.name.len() - 39..])
81 } else {
82 test.name.clone()
83 };
84
85 let _ = writeln!(
86 out,
87 " {:<40} {:>7.1}% {:>6} {:>5} {}",
88 name,
89 test.pass_rate * 100.0,
90 test.total_runs,
91 test.failures,
92 test.recent_pattern,
93 );
94 }
95
96 let _ = writeln!(out);
97 out
98}
99
100pub fn format_slow_tests(slow: &[SlowTest]) -> String {
102 let mut out = String::with_capacity(512);
103
104 let _ = writeln!(out);
105 let _ = writeln!(out, " Slow Test Trends");
106 let _ = writeln!(out, " ═══════════════════════════════════════");
107
108 if slow.is_empty() {
109 let _ = writeln!(out, " No significant duration trends detected.");
110 return out;
111 }
112
113 let _ = writeln!(
114 out,
115 " {:<40} {:>8} {:>8} {:>8} Trend",
116 "Test", "Avg", "Latest", "Change"
117 );
118 let _ = writeln!(
119 out,
120 " {:<40} {:>8} {:>8} {:>8} ─────",
121 "────────────────────────────────────────", "────────", "────────", "────────"
122 );
123
124 for test in slow.iter().take(20) {
125 let name = if test.name.len() > 40 {
126 format!("…{}", &test.name[test.name.len() - 39..])
127 } else {
128 test.name.clone()
129 };
130
131 let trend_icon = match test.trend {
132 DurationTrend::Faster => "↓ ✅",
133 DurationTrend::Slower => "↑ ⚠",
134 DurationTrend::Stable => "→",
135 };
136
137 let _ = writeln!(
138 out,
139 " {:<40} {:>8} {:>8} {:>+7.1}% {}",
140 name,
141 format_duration_ms(test.avg_duration.as_millis() as u64),
142 format_duration_ms(test.latest_duration.as_millis() as u64),
143 test.change_pct,
144 trend_icon,
145 );
146 }
147
148 let _ = writeln!(out);
149 out
150}
151
152pub fn format_test_trend(test_name: &str, trend: &[TestTrend]) -> String {
154 let mut out = String::with_capacity(256);
155
156 let _ = writeln!(out);
157 let _ = writeln!(out, " Trend: {test_name}");
158 let _ = writeln!(out, " ─────────────────────────────────────");
159
160 if trend.is_empty() {
161 let _ = writeln!(out, " No data available for this test.");
162 return out;
163 }
164
165 let durations: Vec<u64> = trend.iter().map(|t| t.duration_ms).collect();
167 let sparkline = make_sparkline(&durations);
168 let _ = writeln!(out, " Duration: {sparkline}");
169 let _ = writeln!(out);
170
171 let _ = writeln!(out, " {:<22} {:>8} Status", "Timestamp", "Duration");
172 let _ = writeln!(
173 out,
174 " {:<22} {:>8} ──────",
175 "──────────────────────", "────────"
176 );
177
178 for point in trend.iter().rev().take(20) {
179 let status = match point.status.as_str() {
180 "passed" => "✅",
181 "failed" => "❌",
182 "skipped" => "⏭️",
183 _ => "?",
184 };
185 let _ = writeln!(
186 out,
187 " {:<22} {:>8} {}",
188 &point.timestamp[..19.min(point.timestamp.len())],
189 format_duration_ms(point.duration_ms),
190 status,
191 );
192 }
193
194 let _ = writeln!(out);
195 out
196}
197
198pub fn format_stats_summary(history: &TestHistory) -> String {
200 let mut out = String::with_capacity(512);
201
202 let _ = writeln!(out);
203 let _ = writeln!(out, " Test Health Dashboard");
204 let _ = writeln!(out, " ═══════════════════════════════════════");
205
206 let _ = writeln!(out, " Total Runs: {}", history.run_count());
207 let _ = writeln!(out, " Pass Rate: {:.1}%", history.pass_rate(30));
208 let _ = writeln!(
209 out,
210 " Avg Duration: {}",
211 format_duration_ms(history.avg_duration(30).as_millis() as u64)
212 );
213
214 let recent = history.recent_runs(30);
216 if !recent.is_empty() {
217 let pass_rates: Vec<u64> = recent
218 .iter()
219 .map(|r| {
220 if r.total > 0 {
221 (r.passed as f64 / r.total as f64 * 100.0) as u64
222 } else {
223 0
224 }
225 })
226 .collect();
227 let sparkline = make_sparkline(&pass_rates);
228 let _ = writeln!(out, " Pass Rate: {sparkline}");
229 }
230
231 let _ = writeln!(out);
232 out
233}
234
235fn make_sparkline(values: &[u64]) -> String {
237 if values.is_empty() {
238 return String::new();
239 }
240
241 let chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
242 let min = *values.iter().min().unwrap_or(&0);
243 let max = *values.iter().max().unwrap_or(&1);
244 let range = if max == min { 1 } else { max - min };
245
246 values
247 .iter()
248 .map(|&v| {
249 let idx = ((v - min) as f64 / range as f64 * 7.0).round() as usize;
250 chars[idx.min(7)]
251 })
252 .collect()
253}
254
255fn format_duration_ms(ms: u64) -> String {
257 if ms == 0 {
258 "<1ms".to_string()
259 } else if ms < 1000 {
260 format!("{ms}ms")
261 } else if ms < 60000 {
262 format!("{:.1}s", ms as f64 / 1000.0)
263 } else {
264 let minutes = ms / 60000;
265 let seconds = (ms % 60000) / 1000;
266 format!("{minutes}m{seconds}s")
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::adapters::{TestCase, TestRunResult, TestStatus, TestSuite};
274 use crate::history::RunRecord;
275 use std::time::Duration;
276
277 fn make_result(passed: usize, failed: usize) -> TestRunResult {
278 let mut tests = Vec::new();
279 for i in 0..passed {
280 tests.push(TestCase {
281 name: format!("pass_{i}"),
282 status: TestStatus::Passed,
283 duration: Duration::from_millis(10),
284 error: None,
285 });
286 }
287 for i in 0..failed {
288 tests.push(TestCase {
289 name: format!("fail_{i}"),
290 status: TestStatus::Failed,
291 duration: Duration::from_millis(5),
292 error: None,
293 });
294 }
295 TestRunResult {
296 suites: vec![TestSuite {
297 name: "suite".into(),
298 tests,
299 }],
300 duration: Duration::from_millis(100),
301 raw_exit_code: if failed > 0 { 1 } else { 0 },
302 }
303 }
304
305 fn populated_history() -> TestHistory {
306 let mut h = TestHistory::new_in_memory();
307 for _ in 0..5 {
308 h.runs.push(RunRecord::from_result(&make_result(5, 0)));
309 }
310 h.runs.push(RunRecord::from_result(&make_result(4, 1)));
311 h
312 }
313
314 #[test]
315 fn recent_runs_format() {
316 let h = populated_history();
317 let output = format_recent_runs(&h, 3);
318 assert!(output.contains("Recent Test Runs"));
319 assert!(output.contains("Total"));
320 }
321
322 #[test]
323 fn recent_runs_empty() {
324 let h = TestHistory::new_in_memory();
325 let output = format_recent_runs(&h, 5);
326 assert!(output.contains("No test runs recorded"));
327 }
328
329 #[test]
330 fn flaky_format_empty() {
331 let output = format_flaky_tests(&[]);
332 assert!(output.contains("No flaky tests"));
333 }
334
335 #[test]
336 fn flaky_format_with_tests() {
337 let flaky = vec![FlakyTest {
338 name: "suite::test_oauth".into(),
339 pass_rate: 0.72,
340 total_runs: 25,
341 failures: 7,
342 recent_pattern: "PPFPFPPFPF".into(),
343 }];
344 let output = format_flaky_tests(&flaky);
345 assert!(output.contains("test_oauth"));
346 assert!(output.contains("72.0%"));
347 assert!(output.contains("PPFPFPPFPF"));
348 }
349
350 #[test]
351 fn slow_format_empty() {
352 let output = format_slow_tests(&[]);
353 assert!(output.contains("No significant duration"));
354 }
355
356 #[test]
357 fn slow_format_with_tests() {
358 let slow = vec![SlowTest {
359 name: "suite::test_migration".into(),
360 avg_duration: Duration::from_millis(2100),
361 latest_duration: Duration::from_millis(3400),
362 trend: DurationTrend::Slower,
363 change_pct: 62.0,
364 }];
365 let output = format_slow_tests(&slow);
366 assert!(output.contains("test_migration"));
367 assert!(output.contains("⚠"));
368 }
369
370 #[test]
371 fn test_trend_format() {
372 let trend = vec![
373 TestTrend {
374 timestamp: "2024-01-01T00:00:00Z".into(),
375 status: "passed".into(),
376 duration_ms: 100,
377 },
378 TestTrend {
379 timestamp: "2024-01-02T00:00:00Z".into(),
380 status: "failed".into(),
381 duration_ms: 150,
382 },
383 ];
384 let output = format_test_trend("suite::test_login", &trend);
385 assert!(output.contains("test_login"));
386 assert!(output.contains("Duration:"));
387 }
388
389 #[test]
390 fn test_trend_empty() {
391 let output = format_test_trend("missing_test", &[]);
392 assert!(output.contains("No data available"));
393 }
394
395 #[test]
396 fn stats_summary() {
397 let h = populated_history();
398 let output = format_stats_summary(&h);
399 assert!(output.contains("Test Health Dashboard"));
400 assert!(output.contains("Total Runs"));
401 assert!(output.contains("Pass Rate"));
402 }
403
404 #[test]
405 fn sparkline_basic() {
406 let spark = make_sparkline(&[0, 50, 100]);
407 assert_eq!(spark.chars().count(), 3);
408 assert!(spark.contains('▁'));
409 assert!(spark.contains('█'));
410 }
411
412 #[test]
413 fn sparkline_empty() {
414 assert!(make_sparkline(&[]).is_empty());
415 }
416
417 #[test]
418 fn sparkline_single() {
419 let spark = make_sparkline(&[42]);
420 assert_eq!(spark.chars().count(), 1);
421 }
422
423 #[test]
424 fn format_duration_ms_tests() {
425 assert_eq!(format_duration_ms(0), "<1ms");
426 assert_eq!(format_duration_ms(42), "42ms");
427 assert_eq!(format_duration_ms(1500), "1.5s");
428 assert_eq!(format_duration_ms(65000), "1m5s");
429 }
430}