1use std::collections::HashMap;
8use std::fmt::Write;
9
10use super::{RunRecord, TestHistory};
11
12#[derive(Debug, Clone)]
14pub struct HealthScore {
15 pub score: f64,
17 pub pass_rate: f64,
19 pub stability: f64,
21 pub performance: f64,
23 pub coverage: Option<f64>,
25}
26
27impl HealthScore {
28 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 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 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 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
80fn 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
92fn compute_stability(runs: &[RunRecord]) -> f64 {
94 if runs.len() < 2 {
95 return 100.0;
96 }
97
98 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 (1.0 - transition_rate * 2.0).max(0.0) * 100.0
131}
132
133fn 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 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 (1.0 - cv).max(0.0) * 100.0
154}
155
156#[derive(Debug, Clone)]
158pub struct FailureCorrelation {
159 pub pairs: Vec<CorrelatedPair>,
161}
162
163#[derive(Debug, Clone)]
165pub struct CorrelatedPair {
166 pub test_a: String,
167 pub test_b: String,
168 pub correlation: f64,
170 pub co_failures: usize,
172}
173
174impl FailureCorrelation {
175 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
246pub 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 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 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
321fn 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
329fn 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); }
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 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 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}