Skip to main content

testx/history/
display.rs

1//! History display and formatting.
2//!
3//! Formats test history data for terminal output.
4
5use std::fmt::Write;
6
7use super::{DurationTrend, FlakyTest, SlowTest, TestHistory, TestTrend};
8
9/// Format a summary of recent test runs.
10pub 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
54/// Format flaky test report.
55pub 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
100/// Format slow test trends.
101pub 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
152/// Format trend data for a specific test.
153pub 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    // Show sparkline of durations
166    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
198/// Format a quick stats summary.
199pub 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    // Sparkline of recent pass rates
215    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
235/// Create a sparkline from a series of values.
236fn 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
255/// Format milliseconds as a human-readable duration.
256fn 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}