Skip to main content

testx/history/
analytics.rs

1//! Analytics and statistics module.
2//!
3//! Provides aggregate analysis over test history data,
4//! including health scores, failure correlations, and
5//! performance monitoring.
6
7use std::collections::HashMap;
8use std::fmt::Write;
9
10use super::{RunRecord, TestHistory};
11
12/// Overall test suite health score (0-100).
13#[derive(Debug, Clone)]
14pub struct HealthScore {
15    /// Overall score (0-100)
16    pub score: f64,
17    /// Pass rate component (0-100)
18    pub pass_rate: f64,
19    /// Stability component (0-100) — low flakiness
20    pub stability: f64,
21    /// Performance component (0-100) — consistent durations
22    pub performance: f64,
23    /// Coverage component (0-100) — if available
24    pub coverage: Option<f64>,
25}
26
27impl HealthScore {
28    /// Compute a health score from test history.
29    pub fn compute(history: &TestHistory) -> Self {
30        let recent = history.recent_runs(30);
31        if recent.is_empty() {
32            return Self {
33                score: 0.0,
34                pass_rate: 0.0,
35                stability: 100.0,
36                performance: 100.0,
37                coverage: None,
38            };
39        }
40
41        let pass_rate = compute_pass_rate(recent);
42        let stability = compute_stability(recent);
43        let performance = compute_performance_score(recent);
44
45        // Weighted average: pass_rate 50%, stability 30%, performance 20%
46        let score = pass_rate * 0.5 + stability * 0.3 + performance * 0.2;
47
48        Self {
49            score,
50            pass_rate,
51            stability,
52            performance,
53            coverage: None,
54        }
55    }
56
57    /// Get a letter grade for the score.
58    pub fn grade(&self) -> &str {
59        match self.score as u32 {
60            90..=100 => "A",
61            80..=89 => "B",
62            70..=79 => "C",
63            60..=69 => "D",
64            _ => "F",
65        }
66    }
67
68    /// Get a color indicator for the score.
69    pub fn indicator(&self) -> &str {
70        if self.score >= 90.0 {
71            "🟢"
72        } else if self.score >= 70.0 {
73            "🟡"
74        } else {
75            "🔴"
76        }
77    }
78}
79
80/// Compute pass rate as a 0-100 score.
81fn compute_pass_rate(runs: &[RunRecord]) -> f64 {
82    let total_passed: usize = runs.iter().map(|r| r.passed).sum();
83    let total_tests: usize = runs.iter().map(|r| r.total).sum();
84
85    if total_tests > 0 {
86        total_passed as f64 / total_tests as f64 * 100.0
87    } else {
88        0.0
89    }
90}
91
92/// Compute stability score (inversely proportional to flakiness).
93fn compute_stability(runs: &[RunRecord]) -> f64 {
94    if runs.len() < 2 {
95        return 100.0;
96    }
97
98    // Count status transitions per test
99    let mut test_results: HashMap<String, Vec<bool>> = HashMap::new();
100    for run in runs {
101        for test in &run.tests {
102            test_results
103                .entry(test.name.clone())
104                .or_default()
105                .push(test.status == "passed");
106        }
107    }
108
109    let mut total_transitions = 0usize;
110    let mut total_comparisons = 0usize;
111
112    for results in test_results.values() {
113        if results.len() < 2 {
114            continue;
115        }
116        for window in results.windows(2) {
117            total_comparisons += 1;
118            if window[0] != window[1] {
119                total_transitions += 1;
120            }
121        }
122    }
123
124    if total_comparisons == 0 {
125        return 100.0;
126    }
127
128    let transition_rate = total_transitions as f64 / total_comparisons as f64;
129    // Convert rate to score: 0 transitions = 100%, 50% transitions = 0%
130    (1.0 - transition_rate * 2.0).max(0.0) * 100.0
131}
132
133/// Compute performance consistency score.
134fn compute_performance_score(runs: &[RunRecord]) -> f64 {
135    if runs.len() < 3 {
136        return 100.0;
137    }
138
139    let durations: Vec<f64> = runs.iter().map(|r| r.duration_ms as f64).collect();
140    let mean = durations.iter().sum::<f64>() / durations.len() as f64;
141
142    if mean == 0.0 {
143        return 100.0;
144    }
145
146    // Coefficient of variation (lower = more consistent)
147    let variance =
148        durations.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / durations.len() as f64;
149    let std_dev = variance.sqrt();
150    let cv = std_dev / mean;
151
152    // Score: CV of 0 = 100, CV of 1.0+ = 0
153    (1.0 - cv).max(0.0) * 100.0
154}
155
156/// Failure correlation analysis.
157#[derive(Debug, Clone)]
158pub struct FailureCorrelation {
159    /// Tests that tend to fail together
160    pub pairs: Vec<CorrelatedPair>,
161}
162
163/// A pair of tests that frequently fail together.
164#[derive(Debug, Clone)]
165pub struct CorrelatedPair {
166    pub test_a: String,
167    pub test_b: String,
168    /// How often they fail together vs individually (0.0 - 1.0)
169    pub correlation: f64,
170    /// Number of co-failures
171    pub co_failures: usize,
172}
173
174impl FailureCorrelation {
175    /// Compute failure correlations from history.
176    pub fn compute(history: &TestHistory, min_cooccurrences: usize) -> Self {
177        let recent = history.recent_runs(50);
178        let mut failure_sets: Vec<Vec<String>> = Vec::new();
179
180        for run in recent {
181            let failures: Vec<String> = run
182                .tests
183                .iter()
184                .filter(|t| t.status == "failed")
185                .map(|t| t.name.clone())
186                .collect();
187            if !failures.is_empty() {
188                failure_sets.push(failures);
189            }
190        }
191
192        let mut pair_counts: HashMap<(String, String), usize> = HashMap::new();
193        let mut individual_counts: HashMap<String, usize> = HashMap::new();
194
195        for failures in &failure_sets {
196            for test in failures {
197                *individual_counts.entry(test.clone()).or_default() += 1;
198            }
199
200            for i in 0..failures.len() {
201                for j in (i + 1)..failures.len() {
202                    let (a, b) = if failures[i] < failures[j] {
203                        (failures[i].clone(), failures[j].clone())
204                    } else {
205                        (failures[j].clone(), failures[i].clone())
206                    };
207                    *pair_counts.entry((a, b)).or_default() += 1;
208                }
209            }
210        }
211
212        let mut pairs = Vec::new();
213        for ((a, b), count) in &pair_counts {
214            if *count < min_cooccurrences {
215                continue;
216            }
217
218            let a_count = individual_counts.get(a).copied().unwrap_or(0);
219            let b_count = individual_counts.get(b).copied().unwrap_or(0);
220            let max_individual = a_count.max(b_count);
221
222            let correlation = if max_individual > 0 {
223                *count as f64 / max_individual as f64
224            } else {
225                0.0
226            };
227
228            pairs.push(CorrelatedPair {
229                test_a: a.clone(),
230                test_b: b.clone(),
231                correlation,
232                co_failures: *count,
233            });
234        }
235
236        pairs.sort_by(|a, b| {
237            b.correlation
238                .partial_cmp(&a.correlation)
239                .unwrap_or(std::cmp::Ordering::Equal)
240        });
241
242        FailureCorrelation { pairs }
243    }
244}
245
246/// Format the analytics dashboard.
247pub fn format_analytics_dashboard(history: &TestHistory) -> String {
248    let mut out = String::with_capacity(2048);
249    let health = HealthScore::compute(history);
250
251    let _ = writeln!(out);
252    let _ = writeln!(out, "  Test Analytics Dashboard");
253    let _ = writeln!(out, "  ═══════════════════════════════════════");
254    let _ = writeln!(out);
255    let _ = writeln!(
256        out,
257        "  Health Score: {} {:.0}/100 ({})",
258        health.indicator(),
259        health.score,
260        health.grade()
261    );
262    let _ = writeln!(out);
263    let _ = writeln!(out, "  Components:");
264    let _ = writeln!(
265        out,
266        "    Pass Rate:    {} {:.1}%",
267        score_bar(health.pass_rate),
268        health.pass_rate
269    );
270    let _ = writeln!(
271        out,
272        "    Stability:    {} {:.1}%",
273        score_bar(health.stability),
274        health.stability
275    );
276    let _ = writeln!(
277        out,
278        "    Performance:  {} {:.1}%",
279        score_bar(health.performance),
280        health.performance
281    );
282    if let Some(cov) = health.coverage {
283        let _ = writeln!(out, "    Coverage:     {} {:.1}%", score_bar(cov), cov);
284    }
285    let _ = writeln!(out);
286
287    // Run stats
288    let _ = writeln!(out, "  Run Statistics:");
289    let _ = writeln!(out, "    Total Runs:   {}", history.run_count());
290    let _ = writeln!(
291        out,
292        "    Avg Duration: {}",
293        format_duration_ms(history.avg_duration(30).as_millis() as u64)
294    );
295
296    let recent = history.recent_runs(30);
297    let total_failures: usize = recent.iter().map(|r| r.failed).sum();
298    let _ = writeln!(out, "    Total Fails:  {} (last 30 runs)", total_failures);
299
300    // Failure correlation
301    let correlations = FailureCorrelation::compute(history, 2);
302    if !correlations.pairs.is_empty() {
303        let _ = writeln!(out);
304        let _ = writeln!(out, "  Correlated Failures:");
305        for pair in correlations.pairs.iter().take(5) {
306            let _ = writeln!(
307                out,
308                "    {:.0}% {} ↔ {} ({} co-failures)",
309                pair.correlation * 100.0,
310                truncate_name(&pair.test_a, 25),
311                truncate_name(&pair.test_b, 25),
312                pair.co_failures,
313            );
314        }
315    }
316
317    let _ = writeln!(out);
318    out
319}
320
321/// Create a score bar (5 characters wide).
322fn score_bar(score: f64) -> String {
323    let filled = ((score / 100.0) * 5.0).round() as usize;
324    let filled = filled.min(5);
325    let empty = 5 - filled;
326    format!("│{}{}│", "█".repeat(filled), "░".repeat(empty))
327}
328
329/// Truncate a test name to max characters.
330fn truncate_name(name: &str, max: usize) -> String {
331    if max == 0 {
332        return String::new();
333    }
334    if name.len() <= max {
335        name.to_string()
336    } else {
337        let start = name.ceil_char_boundary(name.len().saturating_sub(max - 1));
338        format!("…{}", &name[start..])
339    }
340}
341
342fn format_duration_ms(ms: u64) -> String {
343    if ms == 0 {
344        "<1ms".to_string()
345    } else if ms < 1000 {
346        format!("{ms}ms")
347    } else {
348        format!("{:.1}s", ms as f64 / 1000.0)
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::adapters::{TestCase, TestRunResult, TestStatus, TestSuite};
356    use crate::history::RunRecord;
357    use std::time::Duration;
358
359    fn make_result(passed: usize, failed: usize) -> TestRunResult {
360        let mut tests = Vec::new();
361        for i in 0..passed {
362            tests.push(TestCase {
363                name: format!("pass_{i}"),
364                status: TestStatus::Passed,
365                duration: Duration::from_millis(10),
366                error: None,
367            });
368        }
369        for i in 0..failed {
370            tests.push(TestCase {
371                name: format!("fail_{i}"),
372                status: TestStatus::Failed,
373                duration: Duration::from_millis(5),
374                error: None,
375            });
376        }
377        TestRunResult {
378            suites: vec![TestSuite {
379                name: "suite".into(),
380                tests,
381            }],
382            duration: Duration::from_millis(100),
383            raw_exit_code: if failed > 0 { 1 } else { 0 },
384        }
385    }
386
387    fn populated_history() -> TestHistory {
388        let mut h = TestHistory::new_in_memory();
389        for _ in 0..10 {
390            h.runs.push(RunRecord::from_result(&make_result(5, 0)));
391        }
392        h
393    }
394
395    #[test]
396    fn health_score_all_pass() {
397        let h = populated_history();
398        let score = HealthScore::compute(&h);
399        assert!(score.score > 90.0);
400        assert_eq!(score.grade(), "A");
401        assert_eq!(score.indicator(), "🟢");
402    }
403
404    #[test]
405    fn health_score_empty() {
406        let h = TestHistory::new_in_memory();
407        let score = HealthScore::compute(&h);
408        assert_eq!(score.score, 0.0); // No runs → score 0
409    }
410
411    #[test]
412    fn health_score_with_failures() {
413        let mut h = TestHistory::new_in_memory();
414        for _ in 0..10 {
415            h.runs.push(RunRecord::from_result(&make_result(3, 2)));
416        }
417        let score = HealthScore::compute(&h);
418        assert!(score.pass_rate < 70.0);
419        assert!(score.score <= 80.0);
420    }
421
422    #[test]
423    fn stability_no_transitions() {
424        let mut h = TestHistory::new_in_memory();
425        for _ in 0..5 {
426            h.runs.push(RunRecord::from_result(&make_result(5, 0)));
427        }
428        let score = HealthScore::compute(&h);
429        assert_eq!(score.stability, 100.0);
430    }
431
432    #[test]
433    fn stability_with_transitions() {
434        let mut h = TestHistory::new_in_memory();
435        // Use the same test name alternating between pass and fail
436        for i in 0..10 {
437            let status = if i % 2 == 0 {
438                TestStatus::Passed
439            } else {
440                TestStatus::Failed
441            };
442            let result = TestRunResult {
443                suites: vec![TestSuite {
444                    name: "suite".into(),
445                    tests: vec![TestCase {
446                        name: "alternating_test".into(),
447                        status,
448                        duration: Duration::from_millis(10),
449                        error: None,
450                    }],
451                }],
452                duration: Duration::from_millis(100),
453                raw_exit_code: 0,
454            };
455            h.runs.push(RunRecord::from_result(&result));
456        }
457        let score = HealthScore::compute(&h);
458        assert!(score.stability < 50.0);
459    }
460
461    #[test]
462    fn grade_boundaries() {
463        let s = |score: f64| {
464            let h = HealthScore {
465                score,
466                pass_rate: score,
467                stability: 100.0,
468                performance: 100.0,
469                coverage: None,
470            };
471            h.grade().to_string()
472        };
473        assert_eq!(s(95.0), "A");
474        assert_eq!(s(85.0), "B");
475        assert_eq!(s(75.0), "C");
476        assert_eq!(s(65.0), "D");
477        assert_eq!(s(50.0), "F");
478    }
479
480    #[test]
481    fn analytics_dashboard() {
482        let h = populated_history();
483        let output = format_analytics_dashboard(&h);
484        assert!(output.contains("Test Analytics Dashboard"));
485        assert!(output.contains("Health Score"));
486        assert!(output.contains("Pass Rate"));
487        assert!(output.contains("Run Statistics"));
488    }
489
490    #[test]
491    fn score_bar_full() {
492        let bar = score_bar(100.0);
493        assert!(bar.contains("█████"));
494    }
495
496    #[test]
497    fn score_bar_empty() {
498        let bar = score_bar(0.0);
499        assert!(bar.contains("░░░░░"));
500    }
501
502    #[test]
503    fn failure_correlation_empty() {
504        let h = populated_history();
505        let corr = FailureCorrelation::compute(&h, 2);
506        assert!(corr.pairs.is_empty());
507    }
508
509    #[test]
510    fn failure_correlation_detected() {
511        let mut h = TestHistory::new_in_memory();
512        // Create runs where fail_0 and fail_1 always fail together
513        for _ in 0..5 {
514            h.runs.push(RunRecord::from_result(&make_result(3, 2)));
515        }
516        let corr = FailureCorrelation::compute(&h, 2);
517        assert!(!corr.pairs.is_empty());
518        assert!(corr.pairs[0].correlation > 0.5);
519    }
520
521    #[test]
522    fn truncate_name_short() {
523        assert_eq!(truncate_name("short", 10), "short");
524    }
525
526    #[test]
527    fn truncate_name_long() {
528        let truncated = truncate_name("very_long_test_name_that_exceeds", 15);
529        assert!(truncated.starts_with('…'));
530        assert_eq!(truncated.chars().count(), 15);
531    }
532
533    #[test]
534    fn performance_score_consistent() {
535        let recent: Vec<RunRecord> = (0..5)
536            .map(|_| {
537                let mut r = RunRecord::from_result(&make_result(5, 0));
538                r.duration_ms = 100;
539                r
540            })
541            .collect();
542        let score = compute_performance_score(&recent);
543        assert_eq!(score, 100.0);
544    }
545
546    #[test]
547    fn performance_score_variable() {
548        let recent: Vec<RunRecord> = [100, 500, 100, 500, 100]
549            .iter()
550            .map(|&ms| {
551                let mut r = RunRecord::from_result(&make_result(5, 0));
552                r.duration_ms = ms;
553                r
554            })
555            .collect();
556        let score = compute_performance_score(&recent);
557        assert!(score < 100.0);
558    }
559}