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 name.len() <= max {
332        name.to_string()
333    } else {
334        format!("…{}", &name[name.len() - max + 1..])
335    }
336}
337
338fn format_duration_ms(ms: u64) -> String {
339    if ms == 0 {
340        "<1ms".to_string()
341    } else if ms < 1000 {
342        format!("{ms}ms")
343    } else {
344        format!("{:.1}s", ms as f64 / 1000.0)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::adapters::{TestCase, TestRunResult, TestStatus, TestSuite};
352    use crate::history::RunRecord;
353    use std::time::Duration;
354
355    fn make_result(passed: usize, failed: usize) -> TestRunResult {
356        let mut tests = Vec::new();
357        for i in 0..passed {
358            tests.push(TestCase {
359                name: format!("pass_{i}"),
360                status: TestStatus::Passed,
361                duration: Duration::from_millis(10),
362                error: None,
363            });
364        }
365        for i in 0..failed {
366            tests.push(TestCase {
367                name: format!("fail_{i}"),
368                status: TestStatus::Failed,
369                duration: Duration::from_millis(5),
370                error: None,
371            });
372        }
373        TestRunResult {
374            suites: vec![TestSuite {
375                name: "suite".into(),
376                tests,
377            }],
378            duration: Duration::from_millis(100),
379            raw_exit_code: if failed > 0 { 1 } else { 0 },
380        }
381    }
382
383    fn populated_history() -> TestHistory {
384        let mut h = TestHistory::new_in_memory();
385        for _ in 0..10 {
386            h.runs.push(RunRecord::from_result(&make_result(5, 0)));
387        }
388        h
389    }
390
391    #[test]
392    fn health_score_all_pass() {
393        let h = populated_history();
394        let score = HealthScore::compute(&h);
395        assert!(score.score > 90.0);
396        assert_eq!(score.grade(), "A");
397        assert_eq!(score.indicator(), "🟢");
398    }
399
400    #[test]
401    fn health_score_empty() {
402        let h = TestHistory::new_in_memory();
403        let score = HealthScore::compute(&h);
404        assert_eq!(score.score, 0.0); // No runs → score 0
405    }
406
407    #[test]
408    fn health_score_with_failures() {
409        let mut h = TestHistory::new_in_memory();
410        for _ in 0..10 {
411            h.runs.push(RunRecord::from_result(&make_result(3, 2)));
412        }
413        let score = HealthScore::compute(&h);
414        assert!(score.pass_rate < 70.0);
415        assert!(score.score <= 80.0);
416    }
417
418    #[test]
419    fn stability_no_transitions() {
420        let mut h = TestHistory::new_in_memory();
421        for _ in 0..5 {
422            h.runs.push(RunRecord::from_result(&make_result(5, 0)));
423        }
424        let score = HealthScore::compute(&h);
425        assert_eq!(score.stability, 100.0);
426    }
427
428    #[test]
429    fn stability_with_transitions() {
430        let mut h = TestHistory::new_in_memory();
431        // Use the same test name alternating between pass and fail
432        for i in 0..10 {
433            let status = if i % 2 == 0 {
434                TestStatus::Passed
435            } else {
436                TestStatus::Failed
437            };
438            let result = TestRunResult {
439                suites: vec![TestSuite {
440                    name: "suite".into(),
441                    tests: vec![TestCase {
442                        name: "alternating_test".into(),
443                        status,
444                        duration: Duration::from_millis(10),
445                        error: None,
446                    }],
447                }],
448                duration: Duration::from_millis(100),
449                raw_exit_code: 0,
450            };
451            h.runs.push(RunRecord::from_result(&result));
452        }
453        let score = HealthScore::compute(&h);
454        assert!(score.stability < 50.0);
455    }
456
457    #[test]
458    fn grade_boundaries() {
459        let s = |score: f64| {
460            let h = HealthScore {
461                score,
462                pass_rate: score,
463                stability: 100.0,
464                performance: 100.0,
465                coverage: None,
466            };
467            h.grade().to_string()
468        };
469        assert_eq!(s(95.0), "A");
470        assert_eq!(s(85.0), "B");
471        assert_eq!(s(75.0), "C");
472        assert_eq!(s(65.0), "D");
473        assert_eq!(s(50.0), "F");
474    }
475
476    #[test]
477    fn analytics_dashboard() {
478        let h = populated_history();
479        let output = format_analytics_dashboard(&h);
480        assert!(output.contains("Test Analytics Dashboard"));
481        assert!(output.contains("Health Score"));
482        assert!(output.contains("Pass Rate"));
483        assert!(output.contains("Run Statistics"));
484    }
485
486    #[test]
487    fn score_bar_full() {
488        let bar = score_bar(100.0);
489        assert!(bar.contains("█████"));
490    }
491
492    #[test]
493    fn score_bar_empty() {
494        let bar = score_bar(0.0);
495        assert!(bar.contains("░░░░░"));
496    }
497
498    #[test]
499    fn failure_correlation_empty() {
500        let h = populated_history();
501        let corr = FailureCorrelation::compute(&h, 2);
502        assert!(corr.pairs.is_empty());
503    }
504
505    #[test]
506    fn failure_correlation_detected() {
507        let mut h = TestHistory::new_in_memory();
508        // Create runs where fail_0 and fail_1 always fail together
509        for _ in 0..5 {
510            h.runs.push(RunRecord::from_result(&make_result(3, 2)));
511        }
512        let corr = FailureCorrelation::compute(&h, 2);
513        assert!(!corr.pairs.is_empty());
514        assert!(corr.pairs[0].correlation > 0.5);
515    }
516
517    #[test]
518    fn truncate_name_short() {
519        assert_eq!(truncate_name("short", 10), "short");
520    }
521
522    #[test]
523    fn truncate_name_long() {
524        let truncated = truncate_name("very_long_test_name_that_exceeds", 15);
525        assert!(truncated.starts_with('…'));
526        assert_eq!(truncated.chars().count(), 15);
527    }
528
529    #[test]
530    fn performance_score_consistent() {
531        let recent: Vec<RunRecord> = (0..5)
532            .map(|_| {
533                let mut r = RunRecord::from_result(&make_result(5, 0));
534                r.duration_ms = 100;
535                r
536            })
537            .collect();
538        let score = compute_performance_score(&recent);
539        assert_eq!(score, 100.0);
540    }
541
542    #[test]
543    fn performance_score_variable() {
544        let recent: Vec<RunRecord> = [100, 500, 100, 500, 100]
545            .iter()
546            .map(|&ms| {
547                let mut r = RunRecord::from_result(&make_result(5, 0));
548                r.duration_ms = ms;
549                r
550            })
551            .collect();
552        let score = compute_performance_score(&recent);
553        assert!(score < 100.0);
554    }
555}