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 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); }
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 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 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}