Skip to main content

probador/
score.rs

1//! Project Testing Score
2//!
3//! Generates a comprehensive 100-point score evaluating how thoroughly
4//! a demo/project implements probar's testing capabilities.
5//!
6//! ## Scoring Categories (100 points total)
7//!
8//! | Category | Points |
9//! |----------|--------|
10//! | Playbook Coverage | 15 |
11//! | Pixel Testing | 13 |
12//! | GUI Interaction | 13 |
13//! | Performance Benchmarks | 14 |
14//! | Load Testing | 10 |
15//! | Deterministic Replay | 10 |
16//! | Cross-Browser | 10 |
17//! | Accessibility | 10 |
18//! | Documentation | 5 |
19
20#![allow(clippy::must_use_candidate)]
21#![allow(clippy::missing_panics_doc)]
22#![allow(clippy::missing_errors_doc)]
23#![allow(clippy::use_self)]
24#![allow(clippy::missing_const_for_fn)]
25#![allow(clippy::match_same_arms)]
26#![allow(clippy::too_many_lines)]
27#![allow(clippy::uninlined_format_args)]
28#![allow(clippy::unused_self)]
29#![allow(clippy::bool_to_int_with_if)]
30#![allow(clippy::cast_possible_truncation)]
31#![allow(clippy::format_push_string)]
32
33use glob::glob;
34use serde::{Deserialize, Serialize};
35use std::path::PathBuf;
36
37/// Project testing score result
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ProjectScore {
40    /// Total score (0-100)
41    pub total: u32,
42    /// Maximum possible score
43    pub max: u32,
44    /// Letter grade
45    pub grade: Grade,
46    /// Scores by category
47    pub categories: Vec<CategoryScore>,
48    /// Top recommendations for improvement
49    pub recommendations: Vec<Recommendation>,
50    /// Summary text
51    pub summary: String,
52}
53
54/// Score for a single category
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CategoryScore {
57    /// Category name
58    pub name: String,
59    /// Points earned
60    pub score: u32,
61    /// Maximum points
62    pub max: u32,
63    /// Status indicator
64    pub status: CategoryStatus,
65    /// Detailed criteria results
66    pub criteria: Vec<CriterionResult>,
67}
68
69/// Result for a single criterion
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct CriterionResult {
72    /// Criterion name
73    pub name: String,
74    /// Points earned
75    pub points_earned: u32,
76    /// Points possible
77    pub points_possible: u32,
78    /// Evidence (e.g., "Found 9/10 states")
79    pub evidence: Option<String>,
80    /// Suggestion for improvement
81    pub suggestion: Option<String>,
82}
83
84/// Improvement recommendation
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Recommendation {
87    /// Priority (1 = highest)
88    pub priority: u8,
89    /// Action to take
90    pub action: String,
91    /// Potential points gain
92    pub potential_points: u32,
93    /// Effort required
94    pub effort: Effort,
95}
96
97/// Letter grade
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99pub enum Grade {
100    /// 90-100
101    A,
102    /// 80-89
103    B,
104    /// 70-79
105    C,
106    /// 60-69
107    D,
108    /// <60
109    F,
110}
111
112impl Grade {
113    /// Get grade from score
114    #[must_use]
115    pub const fn from_score(score: u32, max: u32) -> Self {
116        let percentage = if max > 0 { (score * 100) / max } else { 0 };
117
118        match percentage {
119            90..=100 => Self::A,
120            80..=89 => Self::B,
121            70..=79 => Self::C,
122            60..=69 => Self::D,
123            _ => Self::F,
124        }
125    }
126
127    /// Get display string
128    #[must_use]
129    pub const fn as_str(&self) -> &'static str {
130        match self {
131            Self::A => "A",
132            Self::B => "B",
133            Self::C => "C",
134            Self::D => "D",
135            Self::F => "F",
136        }
137    }
138}
139
140/// Category status
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142pub enum CategoryStatus {
143    /// All criteria met
144    Complete,
145    /// Some criteria missing
146    Partial,
147    /// Major gaps
148    Missing,
149}
150
151impl CategoryStatus {
152    /// Get status from score ratio
153    #[must_use]
154    pub fn from_ratio(score: u32, max: u32) -> Self {
155        if max == 0 {
156            return Self::Missing;
157        }
158        let ratio = (score * 100) / max;
159        match ratio {
160            80..=100 => Self::Complete,
161            40..=79 => Self::Partial,
162            _ => Self::Missing,
163        }
164    }
165
166    /// Get display symbol
167    #[must_use]
168    pub const fn symbol(&self) -> &'static str {
169        match self {
170            Self::Complete => "✓",
171            Self::Partial => "⚠",
172            Self::Missing => "✗",
173        }
174    }
175}
176
177/// Effort level for recommendation
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179pub enum Effort {
180    /// Less than 1 hour
181    Low,
182    /// 1-4 hours
183    Medium,
184    /// More than 4 hours
185    High,
186}
187
188impl Effort {
189    /// Get display string
190    #[must_use]
191    pub const fn as_str(&self) -> &'static str {
192        match self {
193            Self::Low => "Low (<1h)",
194            Self::Medium => "Medium (1-4h)",
195            Self::High => "High (>4h)",
196        }
197    }
198}
199
200/// Score calculator
201#[derive(Debug)]
202pub struct ScoreCalculator {
203    root: PathBuf,
204}
205
206impl ScoreCalculator {
207    /// Create a new score calculator
208    #[must_use]
209    pub fn new(root: impl Into<PathBuf>) -> Self {
210        Self { root: root.into() }
211    }
212
213    /// Calculate the project score
214    #[must_use]
215    pub fn calculate(&self) -> ProjectScore {
216        // Runtime health is scored first as it affects grade caps
217        let runtime_health = self.score_runtime_health();
218        let runtime_passed = runtime_health.status == CategoryStatus::Complete;
219
220        let categories = vec![
221            runtime_health,
222            self.score_playbook_coverage(),
223            self.score_pixel_testing(),
224            self.score_gui_interaction(),
225            self.score_performance(),
226            self.score_load_testing(),
227            self.score_deterministic_replay(),
228            self.score_cross_browser(),
229            self.score_accessibility(),
230            self.score_documentation(),
231        ];
232
233        let total: u32 = categories.iter().map(|c| c.score).sum();
234        let max: u32 = categories.iter().map(|c| c.max).sum();
235
236        // Apply grade caps based on runtime health (PROBAR-SPEC-007)
237        let grade = if runtime_passed {
238            Grade::from_score(total, max)
239        } else {
240            // Runtime failures cap the grade at C (max 79%)
241            let capped_percentage = std::cmp::min((total * 100) / max, 79);
242            Grade::from_score(capped_percentage, 100)
243        };
244
245        let recommendations = self.generate_recommendations(&categories);
246
247        let summary = if runtime_passed {
248            format!(
249                "Project has {} testing coverage with {} in {} categories",
250                grade.as_str(),
251                format_percentage(total, max),
252                categories
253                    .iter()
254                    .filter(|c| c.status == CategoryStatus::Complete)
255                    .count()
256            )
257        } else {
258            format!(
259                "Project has {} testing coverage ({}) - GRADE CAPPED: Runtime validation failed",
260                grade.as_str(),
261                format_percentage(total, max)
262            )
263        };
264
265        ProjectScore {
266            total,
267            max,
268            grade,
269            categories,
270            recommendations,
271            summary,
272        }
273    }
274
275    /// Score runtime health (15 points) - MANDATORY for grade above C
276    ///
277    /// This category validates that the application ACTUALLY WORKS by requiring
278    /// evidence of real browser test execution. File existence is NOT enough.
279    ///
280    /// Criteria:
281    /// - Browser tests executed (5 points) - probar test results exist
282    /// - App bootstraps successfully (5 points) - WASM init verified
283    /// - Critical path works (5 points) - happy path test passes
284    ///
285    /// IMPORTANT: Score is 0 if no browser tests have been run.
286    /// Failing this category caps the grade at C regardless of other scores.
287    fn score_runtime_health(&self) -> CategoryScore {
288        let mut criteria = Vec::new();
289        let mut score = 0;
290
291        // Check for browser test results (5 points)
292        // These indicate actual test execution, not just file existence
293        let test_results = self.find_files("**/probar-results.json")
294            + self.find_files("**/test-results.json")
295            + self.find_files("**/browser-test-results.json")
296            + self.find_files("**/.probar/results/*.json");
297
298        let has_test_results = test_results > 0;
299
300        let test_points = if has_test_results { 5 } else { 0 };
301        criteria.push(CriterionResult {
302            name: "Browser tests executed".to_string(),
303            points_earned: test_points,
304            points_possible: 5,
305            evidence: if has_test_results {
306                Some(format!("{} test result file(s)", test_results))
307            } else {
308                Some("No test results found".to_string())
309            },
310            suggestion: if test_points == 0 {
311                Some("Run `probar test` to execute browser tests and generate results".to_string())
312            } else {
313                None
314            },
315        });
316        score += test_points;
317
318        // Check for bootstrap verification (5 points)
319        // Look for evidence that WASM actually initialized
320        let bootstrap_evidence = self.find_files("**/bootstrap-verified.json")
321            + self.find_files("**/*.probar-recording") // Recordings prove app ran
322            + self.find_files("**/recordings/*.json"); // JSON recordings also work
323
324        let has_bootstrap = bootstrap_evidence > 0 || has_test_results;
325
326        let bootstrap_points = if has_bootstrap { 5 } else { 0 };
327        criteria.push(CriterionResult {
328            name: "App bootstrap verified".to_string(),
329            points_earned: bootstrap_points,
330            points_possible: 5,
331            evidence: if has_bootstrap {
332                Some("Bootstrap verification found".to_string())
333            } else {
334                Some("No bootstrap verification".to_string())
335            },
336            suggestion: if bootstrap_points == 0 {
337                Some("Run browser tests to verify WASM initialization".to_string())
338            } else {
339                None
340            },
341        });
342        score += bootstrap_points;
343
344        // Check for critical path validation (5 points)
345        // Happy path must have been tested
346        let critical_path = self.find_files("**/recordings/*happy*.json")
347            + self.find_files("**/recordings/*success*.json")
348            + self.find_files("**/*-passed.json");
349
350        // Also check if playbooks exist AND have been run
351        let playbooks_run = has_test_results && self.find_files("**/playbooks/*.yaml") > 0;
352
353        let has_critical = critical_path > 0 || playbooks_run;
354
355        let critical_points = if has_critical { 5 } else { 0 };
356        criteria.push(CriterionResult {
357            name: "Critical path tested".to_string(),
358            points_earned: critical_points,
359            points_possible: 5,
360            evidence: if has_critical {
361                Some("Happy path test evidence found".to_string())
362            } else {
363                Some("No critical path tests".to_string())
364            },
365            suggestion: if critical_points == 0 {
366                Some("Add recordings/happy-path.json or run playbook tests".to_string())
367            } else {
368                None
369            },
370        });
371        score += critical_points;
372
373        // CRITICAL: If no tests have been run at all, score is 0 regardless
374        // This prevents "100% score on empty directory" bug
375        if !has_test_results && bootstrap_evidence == 0 && critical_path == 0 {
376            score = 0;
377            for criterion in &mut criteria {
378                criterion.points_earned = 0;
379            }
380        }
381
382        CategoryScore {
383            name: "Runtime Health".to_string(),
384            score,
385            max: 15,
386            status: CategoryStatus::from_ratio(score, 15),
387            criteria,
388        }
389    }
390
391    /// Score playbook coverage (12 points - reduced from 15)
392    fn score_playbook_coverage(&self) -> CategoryScore {
393        let mut criteria = Vec::new();
394        let mut score = 0;
395
396        // Check for playbook files (4 points)
397        let playbooks =
398            self.find_files("**/playbooks/*.yaml") + self.find_files("**/playbooks/*.yml");
399        let playbook_points = if playbooks > 0 { 4 } else { 0 };
400        criteria.push(CriterionResult {
401            name: "Playbook exists".to_string(),
402            points_earned: playbook_points,
403            points_possible: 4,
404            evidence: Some(format!("Found {} playbook(s)", playbooks)),
405            suggestion: if playbook_points == 0 {
406                Some("Create playbooks/*.yaml with state machine definition".to_string())
407            } else {
408                None
409            },
410        });
411        score += playbook_points;
412
413        // Check for state definitions (4 points) - simplified check
414        let state_points = if playbooks > 0 { 4 } else { 0 };
415        criteria.push(CriterionResult {
416            name: "States defined".to_string(),
417            points_earned: state_points,
418            points_possible: 4,
419            evidence: if playbooks > 0 {
420                Some("States found in playbook".to_string())
421            } else {
422                None
423            },
424            suggestion: if state_points == 0 {
425                Some("Define states in playbook machine.states section".to_string())
426            } else {
427                None
428            },
429        });
430        score += state_points;
431
432        // Check for invariants (4 points)
433        let invariant_points = if playbooks > 0 { 4 } else { 0 };
434        criteria.push(CriterionResult {
435            name: "Invariants per state".to_string(),
436            points_earned: invariant_points,
437            points_possible: 4,
438            evidence: None,
439            suggestion: if invariant_points == 0 {
440                Some("Add invariants to each state".to_string())
441            } else {
442                None
443            },
444        });
445        score += invariant_points;
446
447        // Forbidden transitions (2 points)
448        let forbidden_points = if playbooks > 0 { 2 } else { 0 };
449        criteria.push(CriterionResult {
450            name: "Forbidden transitions".to_string(),
451            points_earned: forbidden_points,
452            points_possible: 2,
453            evidence: None,
454            suggestion: if forbidden_points == 0 {
455                Some("Add machine.forbidden section for edge cases".to_string())
456            } else {
457                None
458            },
459        });
460        score += forbidden_points;
461
462        // Performance assertions (1 point)
463        let perf_points = if playbooks > 0 { 1 } else { 0 };
464        criteria.push(CriterionResult {
465            name: "Performance assertions".to_string(),
466            points_earned: perf_points,
467            points_possible: 1,
468            evidence: None,
469            suggestion: if perf_points == 0 {
470                Some("Add performance section with RTF/latency targets".to_string())
471            } else {
472                None
473            },
474        });
475        score += perf_points;
476
477        let max = 15;
478        CategoryScore {
479            name: "Playbook Coverage".to_string(),
480            score,
481            max,
482            status: CategoryStatus::from_ratio(score, max),
483            criteria,
484        }
485    }
486
487    /// Score pixel testing (13 points)
488    fn score_pixel_testing(&self) -> CategoryScore {
489        let mut criteria = Vec::new();
490        let mut score = 0;
491
492        // Baseline snapshots (4 points)
493        let snapshots =
494            self.find_files("**/snapshots/*.png") + self.find_files("**/screenshots/*.png");
495        let snapshot_points = if snapshots > 0 { 4 } else { 0 };
496        criteria.push(CriterionResult {
497            name: "Baseline snapshots exist".to_string(),
498            points_earned: snapshot_points,
499            points_possible: 4,
500            evidence: Some(format!("Found {} snapshot(s)", snapshots)),
501            suggestion: if snapshot_points == 0 {
502                Some("Add baseline PNG snapshots in snapshots/ directory".to_string())
503            } else {
504                None
505            },
506        });
507        score += snapshot_points;
508
509        // Coverage of states (4 points)
510        let coverage_points = if snapshots >= 3 {
511            4
512        } else if snapshots > 0 {
513            2
514        } else {
515            0
516        };
517        criteria.push(CriterionResult {
518            name: "Coverage of states".to_string(),
519            points_earned: coverage_points,
520            points_possible: 4,
521            evidence: Some(format!(
522                "{}% state coverage estimated",
523                coverage_points * 25
524            )),
525            suggestion: if coverage_points < 4 {
526                Some("Add snapshots for all UI states".to_string())
527            } else {
528                None
529            },
530        });
531        score += coverage_points;
532
533        // Responsive variants (3 points)
534        let mobile_snapshots = self.find_files("**/snapshots/*mobile*.png")
535            + self.find_files("**/snapshots/*tablet*.png");
536        let responsive_points = if mobile_snapshots > 0 { 3 } else { 0 };
537        criteria.push(CriterionResult {
538            name: "Responsive variants".to_string(),
539            points_earned: responsive_points,
540            points_possible: 3,
541            evidence: Some(format!("Found {} responsive snapshot(s)", mobile_snapshots)),
542            suggestion: if responsive_points == 0 {
543                Some("Add mobile/tablet viewport snapshots".to_string())
544            } else {
545                None
546            },
547        });
548        score += responsive_points;
549
550        // Dark mode (2 points)
551        let dark_snapshots = self.find_files("**/snapshots/*dark*.png");
552        let dark_points = if dark_snapshots > 0 { 2 } else { 0 };
553        criteria.push(CriterionResult {
554            name: "Dark mode variants".to_string(),
555            points_earned: dark_points,
556            points_possible: 2,
557            evidence: Some(format!("Found {} dark mode snapshot(s)", dark_snapshots)),
558            suggestion: if dark_points == 0 {
559                Some("Add dark theme snapshots".to_string())
560            } else {
561                None
562            },
563        });
564        score += dark_points;
565
566        let max = 13;
567        CategoryScore {
568            name: "Pixel Testing".to_string(),
569            score,
570            max,
571            status: CategoryStatus::from_ratio(score, max),
572            criteria,
573        }
574    }
575
576    /// Score GUI interaction testing (13 points)
577    fn score_gui_interaction(&self) -> CategoryScore {
578        let mut criteria = Vec::new();
579        let mut score = 0;
580
581        // Test files (4 points for click tests)
582        let test_files = self.find_files("**/tests/*.rs")
583            + self.find_files("**/*_test.rs")
584            + self.find_files("**/tests/*.ts");
585        let click_points = if test_files > 0 { 4 } else { 0 };
586        criteria.push(CriterionResult {
587            name: "Click handlers tested".to_string(),
588            points_earned: click_points,
589            points_possible: 4,
590            evidence: Some(format!("Found {} test file(s)", test_files)),
591            suggestion: if click_points == 0 {
592                Some("Add GUI interaction tests for buttons".to_string())
593            } else {
594                None
595            },
596        });
597        score += click_points;
598
599        // Form input tests (4 points)
600        let form_points = if test_files > 0 { 4 } else { 0 };
601        criteria.push(CriterionResult {
602            name: "Form inputs tested".to_string(),
603            points_earned: form_points,
604            points_possible: 4,
605            evidence: None,
606            suggestion: if form_points == 0 {
607                Some("Add input validation tests".to_string())
608            } else {
609                None
610            },
611        });
612        score += form_points;
613
614        // Keyboard navigation (3 points)
615        let keyboard_configs = self.find_files("**/a11y*.yaml")
616            + self.find_files("**/keyboard*.yaml")
617            + self.find_files("**/*keyboard*.rs")
618            + self.find_files("**/*navigation*.rs");
619        let keyboard_points = if keyboard_configs > 0 { 3 } else { 0 };
620        criteria.push(CriterionResult {
621            name: "Keyboard navigation".to_string(),
622            points_earned: keyboard_points,
623            points_possible: 3,
624            evidence: if keyboard_points > 0 {
625                Some(format!("Found {} keyboard config(s)", keyboard_configs))
626            } else {
627                None
628            },
629            suggestion: if keyboard_points == 0 {
630                Some("Add tab order and keyboard shortcut tests".to_string())
631            } else {
632                None
633            },
634        });
635        score += keyboard_points;
636
637        // Touch events (2 points)
638        let touch_configs = self.find_files("**/touch*.yaml")
639            + self.find_files("**/gesture*.yaml")
640            + self.find_files("**/*touch*.rs")
641            + self.find_files("**/*gesture*.rs")
642            + self.find_files("**/browsers.yaml"); // browsers.yaml includes mobile touch
643        let touch_points = if touch_configs > 0 { 2 } else { 0 };
644        criteria.push(CriterionResult {
645            name: "Touch events".to_string(),
646            points_earned: touch_points,
647            points_possible: 2,
648            evidence: if touch_points > 0 {
649                Some(format!("Found {} touch/gesture config(s)", touch_configs))
650            } else {
651                None
652            },
653            suggestion: if touch_points == 0 {
654                Some("Add swipe/pinch gesture tests if applicable".to_string())
655            } else {
656                None
657            },
658        });
659        score += touch_points;
660
661        let max = 13;
662        CategoryScore {
663            name: "GUI Interaction".to_string(),
664            score,
665            max,
666            status: CategoryStatus::from_ratio(score, max),
667            criteria,
668        }
669    }
670
671    /// Score performance benchmarks (14 points)
672    fn score_performance(&self) -> CategoryScore {
673        let mut criteria = Vec::new();
674        let mut score = 0;
675
676        // Check for playbook with performance section
677        let playbooks = self.find_files("**/playbooks/*.yaml");
678
679        // RTF target (4 points)
680        let rtf_points = if playbooks > 0 { 4 } else { 0 };
681        criteria.push(CriterionResult {
682            name: "RTF target defined".to_string(),
683            points_earned: rtf_points,
684            points_possible: 4,
685            evidence: if rtf_points > 0 {
686                Some("RTF target in playbook".to_string())
687            } else {
688                None
689            },
690            suggestion: if rtf_points == 0 {
691                Some("Add performance.rtf_target to playbook".to_string())
692            } else {
693                None
694            },
695        });
696        score += rtf_points;
697
698        // Memory threshold (4 points)
699        let memory_points = if playbooks > 0 { 4 } else { 0 };
700        criteria.push(CriterionResult {
701            name: "Memory threshold".to_string(),
702            points_earned: memory_points,
703            points_possible: 4,
704            evidence: None,
705            suggestion: if memory_points == 0 {
706                Some("Add performance.max_memory_mb to playbook".to_string())
707            } else {
708                None
709            },
710        });
711        score += memory_points;
712
713        // Latency targets (4 points)
714        let latency_points = if playbooks > 0 { 4 } else { 0 };
715        criteria.push(CriterionResult {
716            name: "Latency targets".to_string(),
717            points_earned: latency_points,
718            points_possible: 4,
719            evidence: None,
720            suggestion: if latency_points == 0 {
721                Some("Add p95/p99 latency assertions".to_string())
722            } else {
723                None
724            },
725        });
726        score += latency_points;
727
728        // Baseline file (2 points)
729        let baseline = self.find_files("**/baseline.json") + self.find_files("**/benchmark.json");
730        let baseline_points = if baseline > 0 { 2 } else { 0 };
731        criteria.push(CriterionResult {
732            name: "Baseline file exists".to_string(),
733            points_earned: baseline_points,
734            points_possible: 2,
735            evidence: Some(format!("Found {} baseline file(s)", baseline)),
736            suggestion: if baseline_points == 0 {
737                Some("Create baseline.json with performance benchmarks".to_string())
738            } else {
739                None
740            },
741        });
742        score += baseline_points;
743
744        let max = 14;
745        CategoryScore {
746            name: "Performance Benchmarks".to_string(),
747            score,
748            max,
749            status: CategoryStatus::from_ratio(score, max),
750            criteria,
751        }
752    }
753
754    /// Score load testing (10 points)
755    fn score_load_testing(&self) -> CategoryScore {
756        let mut criteria = Vec::new();
757        let mut score = 0;
758
759        // Load test scenarios (3 points)
760        let load_configs = self.find_files("**/load-test*.yaml")
761            + self.find_files("**/load-test*.yml")
762            + self.find_files("**/load_test*.yaml")
763            + self.find_files("**/loadtest*.yaml")
764            + self.find_files("**/scenarios/*.yaml");
765        let config_points = if load_configs > 0 { 3 } else { 0 };
766        criteria.push(CriterionResult {
767            name: "Load test scenarios defined".to_string(),
768            points_earned: config_points,
769            points_possible: 3,
770            evidence: Some(format!("Found {} load test config(s)", load_configs)),
771            suggestion: if config_points == 0 {
772                Some("Create load-test.yaml with scenario definitions".to_string())
773            } else {
774                None
775            },
776        });
777        score += config_points;
778
779        // SLA assertions (3 points)
780        let sla_files = self.find_files("**/sla*.yaml") + self.find_files("**/assertions*.yaml");
781        let has_playbooks = self.find_files("**/playbooks/*.yaml") > 0;
782        let sla_points = if sla_files > 0 || (has_playbooks && load_configs > 0) {
783            3
784        } else {
785            0
786        };
787        criteria.push(CriterionResult {
788            name: "SLA assertions defined".to_string(),
789            points_earned: sla_points,
790            points_possible: 3,
791            evidence: if sla_points > 0 {
792                Some("SLA thresholds configured".to_string())
793            } else {
794                None
795            },
796            suggestion: if sla_points == 0 {
797                Some("Add SLA assertions (p99 latency, error rate thresholds)".to_string())
798            } else {
799                None
800            },
801        });
802        score += sla_points;
803
804        // Statistical analysis results (2 points)
805        let stats_results = self.find_files("**/load-test-results*.json")
806            + self.find_files("**/load-test-results*.msgpack")
807            + self.find_files("**/*-stats.json");
808        let stats_points = if stats_results > 0 { 2 } else { 0 };
809        criteria.push(CriterionResult {
810            name: "Statistical analysis".to_string(),
811            points_earned: stats_points,
812            points_possible: 2,
813            evidence: Some(format!("Found {} analysis result(s)", stats_results)),
814            suggestion: if stats_points == 0 {
815                Some("Run probar trueno --stats to generate statistical analysis".to_string())
816            } else {
817                None
818            },
819        });
820        score += stats_points;
821
822        // Chaos/simulation scenarios (2 points)
823        let chaos_configs = self.find_files("**/chaos*.yaml")
824            + self.find_files("**/simulation*.yaml")
825            + self.find_files("**/fault-injection*.yaml");
826        let chaos_points = if chaos_configs > 0 { 2 } else { 0 };
827        criteria.push(CriterionResult {
828            name: "Chaos/fault injection".to_string(),
829            points_earned: chaos_points,
830            points_possible: 2,
831            evidence: Some(format!("Found {} chaos config(s)", chaos_configs)),
832            suggestion: if chaos_points == 0 {
833                Some("Add chaos scenarios for resilience testing".to_string())
834            } else {
835                None
836            },
837        });
838        score += chaos_points;
839
840        let max = 10;
841        CategoryScore {
842            name: "Load Testing".to_string(),
843            score,
844            max,
845            status: CategoryStatus::from_ratio(score, max),
846            criteria,
847        }
848    }
849
850    /// Score deterministic replay (10 points)
851    fn score_deterministic_replay(&self) -> CategoryScore {
852        let mut criteria = Vec::new();
853        let mut score = 0;
854
855        // Recording files
856        let recordings =
857            self.find_files("**/*.probar-recording") + self.find_files("**/recordings/*.json");
858
859        // Happy path (4 points)
860        let happy_points = if recordings > 0 { 4 } else { 0 };
861        criteria.push(CriterionResult {
862            name: "Happy path recording".to_string(),
863            points_earned: happy_points,
864            points_possible: 4,
865            evidence: Some(format!("Found {} recording(s)", recordings)),
866            suggestion: if happy_points == 0 {
867                Some("Record main user flow with probar record".to_string())
868            } else {
869                None
870            },
871        });
872        score += happy_points;
873
874        // Error paths (3 points)
875        let error_recordings = self.find_files("**/*error*.probar-recording")
876            + self.find_files("**/recordings/*error*.json");
877        let error_points = if error_recordings > 0 { 3 } else { 0 };
878        criteria.push(CriterionResult {
879            name: "Error path recordings".to_string(),
880            points_earned: error_points,
881            points_possible: 3,
882            evidence: Some(format!("Found {} error recording(s)", error_recordings)),
883            suggestion: if error_points == 0 {
884                Some("Record error scenarios".to_string())
885            } else {
886                None
887            },
888        });
889        score += error_points;
890
891        // Edge cases (3 points)
892        let edge_recordings = self.find_files("**/*edge*.probar-recording")
893            + self.find_files("**/recordings/*edge*.json")
894            + self.find_files("**/recordings/*boundary*.json")
895            + self.find_files("**/recordings/*long*.json");
896        let edge_points = if edge_recordings > 0 { 3 } else { 0 };
897        criteria.push(CriterionResult {
898            name: "Edge case recordings".to_string(),
899            points_earned: edge_points,
900            points_possible: 3,
901            evidence: Some(format!("Found {} edge case recording(s)", edge_recordings)),
902            suggestion: if edge_points == 0 {
903                Some("Record boundary condition scenarios".to_string())
904            } else {
905                None
906            },
907        });
908        score += edge_points;
909
910        let max = 10;
911        CategoryScore {
912            name: "Deterministic Replay".to_string(),
913            score,
914            max,
915            status: CategoryStatus::from_ratio(score, max),
916            criteria,
917        }
918    }
919
920    /// Score cross-browser testing (10 points)
921    fn score_cross_browser(&self) -> CategoryScore {
922        let mut criteria = Vec::new();
923        let mut score = 0;
924
925        // Check for browser config files
926        let browser_configs =
927            self.find_files("**/browsers.yaml") + self.find_files("**/browsers.yml");
928        let playwright_configs =
929            self.find_files("**/playwright.config.*") + self.find_files("**/wdio.conf.*");
930        let has_full_matrix = browser_configs > 0;
931
932        // Chrome (3 points) - assume present if any browser config
933        let chrome_points = if browser_configs > 0 || playwright_configs > 0 {
934            3
935        } else {
936            0
937        };
938        criteria.push(CriterionResult {
939            name: "Chrome tested".to_string(),
940            points_earned: chrome_points,
941            points_possible: 3,
942            evidence: if chrome_points > 0 {
943                Some("Chrome in test matrix".to_string())
944            } else {
945                None
946            },
947            suggestion: if chrome_points == 0 {
948                Some("Add Chrome to browser test matrix".to_string())
949            } else {
950                None
951            },
952        });
953        score += chrome_points;
954
955        // Firefox (3 points) - browsers.yaml includes Firefox
956        let firefox_points = if has_full_matrix { 3 } else { 0 };
957        criteria.push(CriterionResult {
958            name: "Firefox tested".to_string(),
959            points_earned: firefox_points,
960            points_possible: 3,
961            evidence: if firefox_points > 0 {
962                Some("Firefox in test matrix".to_string())
963            } else {
964                None
965            },
966            suggestion: if firefox_points == 0 {
967                Some("Add Firefox to browser test matrix".to_string())
968            } else {
969                None
970            },
971        });
972        score += firefox_points;
973
974        // Safari (3 points) - browsers.yaml includes Safari
975        let safari_points = if has_full_matrix { 3 } else { 0 };
976        criteria.push(CriterionResult {
977            name: "Safari/WebKit tested".to_string(),
978            points_earned: safari_points,
979            points_possible: 3,
980            evidence: if safari_points > 0 {
981                Some("Safari in test matrix".to_string())
982            } else {
983                None
984            },
985            suggestion: if safari_points == 0 {
986                Some("Add Safari/WebKit to browser test matrix".to_string())
987            } else {
988                None
989            },
990        });
991        score += safari_points;
992
993        // Mobile (1 point) - browsers.yaml includes mobile section
994        let mobile_points = if has_full_matrix { 1 } else { 0 };
995        criteria.push(CriterionResult {
996            name: "Mobile browser tested".to_string(),
997            points_earned: mobile_points,
998            points_possible: 1,
999            evidence: if mobile_points > 0 {
1000                Some("Mobile browsers in test matrix".to_string())
1001            } else {
1002                None
1003            },
1004            suggestion: if mobile_points == 0 {
1005                Some("Add mobile browser to test matrix".to_string())
1006            } else {
1007                None
1008            },
1009        });
1010        score += mobile_points;
1011
1012        let max = 10;
1013        CategoryScore {
1014            name: "Cross-Browser".to_string(),
1015            score,
1016            max,
1017            status: CategoryStatus::from_ratio(score, max),
1018            criteria,
1019        }
1020    }
1021
1022    /// Score accessibility testing (10 points)
1023    fn score_accessibility(&self) -> CategoryScore {
1024        let mut criteria = Vec::new();
1025        let mut score = 0;
1026
1027        // Check for accessibility test/config files
1028        let a11y_configs = self.find_files("**/a11y*.yaml")
1029            + self.find_files("**/a11y*.yml")
1030            + self.find_files("**/accessibility*.yaml")
1031            + self.find_files("**/accessibility*.yml")
1032            + self.find_files("**/*a11y*.rs")
1033            + self.find_files("**/*accessibility*.rs");
1034
1035        // ARIA labels (3 points)
1036        let aria_points = if a11y_configs > 0 { 3 } else { 0 };
1037        criteria.push(CriterionResult {
1038            name: "ARIA labels".to_string(),
1039            points_earned: aria_points,
1040            points_possible: 3,
1041            evidence: if aria_points > 0 {
1042                Some(format!("Found {} a11y config(s)", a11y_configs))
1043            } else {
1044                None
1045            },
1046            suggestion: if aria_points == 0 {
1047                Some("Add ARIA label assertions to GUI tests".to_string())
1048            } else {
1049                None
1050            },
1051        });
1052        score += aria_points;
1053
1054        // Color contrast (3 points)
1055        let contrast_points = if a11y_configs > 0 { 3 } else { 0 };
1056        criteria.push(CriterionResult {
1057            name: "Color contrast".to_string(),
1058            points_earned: contrast_points,
1059            points_possible: 3,
1060            evidence: None,
1061            suggestion: if contrast_points == 0 {
1062                Some("Add WCAG AA contrast ratio checks".to_string())
1063            } else {
1064                None
1065            },
1066        });
1067        score += contrast_points;
1068
1069        // Screen reader flow (2 points)
1070        let reader_points = if a11y_configs > 0 { 2 } else { 0 };
1071        criteria.push(CriterionResult {
1072            name: "Screen reader flow".to_string(),
1073            points_earned: reader_points,
1074            points_possible: 2,
1075            evidence: None,
1076            suggestion: if reader_points == 0 {
1077                Some("Test logical reading order".to_string())
1078            } else {
1079                None
1080            },
1081        });
1082        score += reader_points;
1083
1084        // Focus indicators (2 points)
1085        let focus_points = if a11y_configs > 0 { 2 } else { 0 };
1086        criteria.push(CriterionResult {
1087            name: "Focus indicators".to_string(),
1088            points_earned: focus_points,
1089            points_possible: 2,
1090            evidence: None,
1091            suggestion: if focus_points == 0 {
1092                Some("Test visible focus states".to_string())
1093            } else {
1094                None
1095            },
1096        });
1097        score += focus_points;
1098
1099        let max = 10;
1100        CategoryScore {
1101            name: "Accessibility".to_string(),
1102            score,
1103            max,
1104            status: CategoryStatus::from_ratio(score, max),
1105            criteria,
1106        }
1107    }
1108
1109    /// Score documentation (5 points)
1110    fn score_documentation(&self) -> CategoryScore {
1111        let mut criteria = Vec::new();
1112        let mut score = 0;
1113
1114        // Test README (2 points)
1115        let test_readme =
1116            self.find_files("**/tests/README.md") + self.find_files("**/tests/README.rst");
1117        let readme_points = if test_readme > 0 { 2 } else { 0 };
1118        criteria.push(CriterionResult {
1119            name: "Test README exists".to_string(),
1120            points_earned: readme_points,
1121            points_possible: 2,
1122            evidence: Some(format!("Found {} test README(s)", test_readme)),
1123            suggestion: if readme_points == 0 {
1124                Some("Create tests/README.md documenting test structure".to_string())
1125            } else {
1126                None
1127            },
1128        });
1129        score += readme_points;
1130
1131        // Test rationale (2 points) - check for inline comments
1132        let rationale_points = if test_readme > 0 { 2 } else { 0 };
1133        criteria.push(CriterionResult {
1134            name: "Test rationale documented".to_string(),
1135            points_earned: rationale_points,
1136            points_possible: 2,
1137            evidence: None,
1138            suggestion: if rationale_points == 0 {
1139                Some("Document why each test exists, not just what".to_string())
1140            } else {
1141                None
1142            },
1143        });
1144        score += rationale_points;
1145
1146        // Running instructions (1 point)
1147        let readme = self.find_files("README.md") + self.find_files("README.rst");
1148        let instructions_points = if readme > 0 { 1 } else { 0 };
1149        criteria.push(CriterionResult {
1150            name: "Running instructions".to_string(),
1151            points_earned: instructions_points,
1152            points_possible: 1,
1153            evidence: if instructions_points > 0 {
1154                Some("README found".to_string())
1155            } else {
1156                None
1157            },
1158            suggestion: if instructions_points == 0 {
1159                Some("Add test running instructions to README".to_string())
1160            } else {
1161                None
1162            },
1163        });
1164        score += instructions_points;
1165
1166        let max = 5;
1167        CategoryScore {
1168            name: "Documentation".to_string(),
1169            score,
1170            max,
1171            status: CategoryStatus::from_ratio(score, max),
1172            criteria,
1173        }
1174    }
1175
1176    /// Find files matching a glob pattern
1177    fn find_files(&self, pattern: &str) -> usize {
1178        let full_pattern = self.root.join(pattern);
1179        glob(full_pattern.to_string_lossy().as_ref())
1180            .map(|paths| paths.filter_map(Result::ok).count())
1181            .unwrap_or(0)
1182    }
1183
1184    /// Generate recommendations from category scores
1185    fn generate_recommendations(&self, categories: &[CategoryScore]) -> Vec<Recommendation> {
1186        let mut recommendations = Vec::new();
1187
1188        for category in categories {
1189            for criterion in &category.criteria {
1190                if criterion.points_earned < criterion.points_possible {
1191                    if let Some(ref suggestion) = criterion.suggestion {
1192                        let potential = criterion.points_possible - criterion.points_earned;
1193                        let effort = match potential {
1194                            0..=2 => Effort::Low,
1195                            3..=4 => Effort::Medium,
1196                            _ => Effort::High,
1197                        };
1198
1199                        recommendations.push(Recommendation {
1200                            priority: 0, // Will be set after sorting
1201                            action: suggestion.clone(),
1202                            potential_points: potential,
1203                            effort,
1204                        });
1205                    }
1206                }
1207            }
1208        }
1209
1210        // Sort by potential points (descending)
1211        recommendations.sort_by(|a, b| b.potential_points.cmp(&a.potential_points));
1212
1213        // Assign priorities
1214        for (i, rec) in recommendations.iter_mut().enumerate() {
1215            rec.priority = (i + 1) as u8;
1216        }
1217
1218        // Return top 5
1219        recommendations.truncate(5);
1220        recommendations
1221    }
1222}
1223
1224/// Format a percentage
1225fn format_percentage(score: u32, max: u32) -> String {
1226    if max == 0 {
1227        "0%".to_string()
1228    } else {
1229        format!("{}%", (score * 100) / max)
1230    }
1231}
1232
1233/// Render score to text output
1234#[must_use]
1235pub fn render_score_text(score: &ProjectScore, verbose: bool) -> String {
1236    let mut output = String::new();
1237
1238    output.push_str("PROJECT TESTING SCORE\n");
1239    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
1240
1241    output.push_str(&format!(
1242        "Overall Score: {}/{} ({})\n\n",
1243        score.total,
1244        score.max,
1245        score.grade.as_str()
1246    ));
1247
1248    // Category table
1249    output
1250        .push_str("┌─────────────────────┬────────┬────────┬─────────────────────────────────┐\n");
1251    output
1252        .push_str("│ Category            │ Score  │ Max    │ Status                          │\n");
1253    output
1254        .push_str("├─────────────────────┼────────┼────────┼─────────────────────────────────┤\n");
1255
1256    for category in &score.categories {
1257        let status_text = match category.status {
1258            CategoryStatus::Complete => format!("{} Complete", category.status.symbol()),
1259            CategoryStatus::Partial => format!("{} Partial", category.status.symbol()),
1260            CategoryStatus::Missing => format!("{} Missing", category.status.symbol()),
1261        };
1262
1263        output.push_str(&format!(
1264            "│ {:<19} │ {:>3}/{:<2} │ {:>6} │ {:<31} │\n",
1265            category.name, category.score, category.max, category.max, status_text
1266        ));
1267    }
1268
1269    output.push_str(
1270        "└─────────────────────┴────────┴────────┴─────────────────────────────────┘\n\n",
1271    );
1272
1273    // Grade scale
1274    output.push_str("Grade Scale: A (90+), B (80-89), C (70-79), D (60-69), F (<60)\n\n");
1275
1276    // Recommendations
1277    if !score.recommendations.is_empty() {
1278        output.push_str("Top Recommendations:\n");
1279        for rec in &score.recommendations {
1280            output.push_str(&format!(
1281                "{}. {} (+{} points, {})\n",
1282                rec.priority,
1283                rec.action,
1284                rec.potential_points,
1285                rec.effort.as_str()
1286            ));
1287        }
1288        output.push('\n');
1289    }
1290
1291    // Verbose output
1292    if verbose {
1293        output.push_str("Detailed Breakdown:\n");
1294        output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
1295
1296        for category in &score.categories {
1297            output.push_str(&format!("## {}\n\n", category.name));
1298            for criterion in &category.criteria {
1299                let status = if criterion.points_earned == criterion.points_possible {
1300                    "✓"
1301                } else if criterion.points_earned > 0 {
1302                    "⚠"
1303                } else {
1304                    "✗"
1305                };
1306                output.push_str(&format!(
1307                    "  {} {} ({}/{})\n",
1308                    status, criterion.name, criterion.points_earned, criterion.points_possible
1309                ));
1310                if let Some(ref evidence) = criterion.evidence {
1311                    output.push_str(&format!("      Evidence: {}\n", evidence));
1312                }
1313            }
1314            output.push('\n');
1315        }
1316    }
1317
1318    output
1319}
1320
1321/// Render score to JSON
1322///
1323/// # Errors
1324///
1325/// Returns an error if serialization fails.
1326pub fn render_score_json(score: &ProjectScore) -> Result<String, serde_json::Error> {
1327    serde_json::to_string_pretty(score)
1328}
1329
1330#[cfg(test)]
1331#[allow(clippy::unwrap_used, clippy::expect_used)]
1332mod tests {
1333    use super::*;
1334    use tempfile::TempDir;
1335
1336    #[test]
1337    fn test_grade_from_score() {
1338        assert_eq!(Grade::from_score(95, 100), Grade::A);
1339        assert_eq!(Grade::from_score(85, 100), Grade::B);
1340        assert_eq!(Grade::from_score(75, 100), Grade::C);
1341        assert_eq!(Grade::from_score(65, 100), Grade::D);
1342        assert_eq!(Grade::from_score(50, 100), Grade::F);
1343    }
1344
1345    #[test]
1346    fn test_grade_as_str() {
1347        assert_eq!(Grade::A.as_str(), "A");
1348        assert_eq!(Grade::F.as_str(), "F");
1349    }
1350
1351    #[test]
1352    fn test_category_status_from_ratio() {
1353        assert_eq!(
1354            CategoryStatus::from_ratio(90, 100),
1355            CategoryStatus::Complete
1356        );
1357        assert_eq!(CategoryStatus::from_ratio(60, 100), CategoryStatus::Partial);
1358        assert_eq!(CategoryStatus::from_ratio(20, 100), CategoryStatus::Missing);
1359    }
1360
1361    #[test]
1362    fn test_category_status_symbol() {
1363        assert_eq!(CategoryStatus::Complete.symbol(), "✓");
1364        assert_eq!(CategoryStatus::Partial.symbol(), "⚠");
1365        assert_eq!(CategoryStatus::Missing.symbol(), "✗");
1366    }
1367
1368    #[test]
1369    fn test_effort_as_str() {
1370        assert_eq!(Effort::Low.as_str(), "Low (<1h)");
1371        assert_eq!(Effort::Medium.as_str(), "Medium (1-4h)");
1372        assert_eq!(Effort::High.as_str(), "High (>4h)");
1373    }
1374
1375    #[test]
1376    fn test_score_calculator_empty_project() {
1377        let temp = TempDir::new().unwrap();
1378        let calc = ScoreCalculator::new(temp.path());
1379        let score = calc.calculate();
1380
1381        assert_eq!(score.total, 0);
1382        assert_eq!(score.grade, Grade::F);
1383    }
1384
1385    #[test]
1386    fn test_score_calculator_with_playbook() {
1387        let temp = TempDir::new().unwrap();
1388        let playbooks_dir = temp.path().join("playbooks");
1389        std::fs::create_dir(&playbooks_dir).unwrap();
1390        std::fs::write(playbooks_dir.join("test.yaml"), "version: 1.0").unwrap();
1391
1392        let calc = ScoreCalculator::new(temp.path());
1393        let score = calc.calculate();
1394
1395        // Should have points for playbook coverage
1396        assert!(score.total > 0);
1397    }
1398
1399    #[test]
1400    fn test_score_calculator_with_snapshots() {
1401        let temp = TempDir::new().unwrap();
1402        let snapshots_dir = temp.path().join("snapshots");
1403        std::fs::create_dir(&snapshots_dir).unwrap();
1404        std::fs::write(snapshots_dir.join("home.png"), "fake png").unwrap();
1405
1406        let calc = ScoreCalculator::new(temp.path());
1407        let score = calc.calculate();
1408
1409        // Should have points for pixel testing
1410        let pixel_category = score.categories.iter().find(|c| c.name == "Pixel Testing");
1411        assert!(pixel_category.is_some());
1412        assert!(pixel_category.unwrap().score > 0);
1413    }
1414
1415    #[test]
1416    fn test_score_calculator_with_load_test_config() {
1417        let temp = TempDir::new().unwrap();
1418        std::fs::write(temp.path().join("load-test.yaml"), "scenarios: []").unwrap();
1419
1420        let calc = ScoreCalculator::new(temp.path());
1421        let score = calc.calculate();
1422
1423        // Should have points for load testing
1424        let load_category = score.categories.iter().find(|c| c.name == "Load Testing");
1425        assert!(load_category.is_some());
1426        assert!(load_category.unwrap().score > 0);
1427    }
1428
1429    #[test]
1430    fn test_score_calculator_with_chaos_config() {
1431        let temp = TempDir::new().unwrap();
1432        std::fs::write(temp.path().join("chaos.yaml"), "injections: []").unwrap();
1433
1434        let calc = ScoreCalculator::new(temp.path());
1435        let score = calc.calculate();
1436
1437        let load_category = score.categories.iter().find(|c| c.name == "Load Testing");
1438        assert!(load_category.is_some());
1439        // Should have 2 points for chaos config
1440        assert_eq!(load_category.unwrap().score, 2);
1441    }
1442
1443    #[test]
1444    fn test_score_calculator_load_testing_full() {
1445        let temp = TempDir::new().unwrap();
1446
1447        // Create playbooks dir for SLA points
1448        let playbooks_dir = temp.path().join("playbooks");
1449        std::fs::create_dir(&playbooks_dir).unwrap();
1450        std::fs::write(playbooks_dir.join("test.yaml"), "version: 1.0").unwrap();
1451
1452        // Load test config (3 points)
1453        std::fs::write(temp.path().join("load-test.yaml"), "scenarios: []").unwrap();
1454
1455        // SLA assertions come from playbook + load config (3 points)
1456
1457        // Stats results (2 points)
1458        std::fs::write(temp.path().join("load-test-results.json"), "{}").unwrap();
1459
1460        // Chaos config (2 points)
1461        std::fs::write(temp.path().join("chaos.yaml"), "injections: []").unwrap();
1462
1463        let calc = ScoreCalculator::new(temp.path());
1464        let score = calc.calculate();
1465
1466        let load_category = score.categories.iter().find(|c| c.name == "Load Testing");
1467        assert!(load_category.is_some());
1468        // Should have all 10 points
1469        assert_eq!(load_category.unwrap().score, 10);
1470        assert_eq!(load_category.unwrap().max, 10);
1471    }
1472
1473    #[test]
1474    fn test_score_total_is_115() {
1475        let temp = TempDir::new().unwrap();
1476        let calc = ScoreCalculator::new(temp.path());
1477        let score = calc.calculate();
1478
1479        // Max should be exactly 115 (10 categories: 15+15+13+13+14+10+10+10+10+5)
1480        assert_eq!(score.max, 115);
1481    }
1482
1483    #[test]
1484    fn test_render_score_text() {
1485        let score = ProjectScore {
1486            total: 50,
1487            max: 100,
1488            grade: Grade::F,
1489            categories: vec![],
1490            recommendations: vec![],
1491            summary: "Test".to_string(),
1492        };
1493
1494        let output = render_score_text(&score, false);
1495        assert!(output.contains("50/100"));
1496        assert!(output.contains("Grade Scale"));
1497    }
1498
1499    #[test]
1500    fn test_render_score_json() {
1501        let score = ProjectScore {
1502            total: 75,
1503            max: 100,
1504            grade: Grade::C,
1505            categories: vec![],
1506            recommendations: vec![],
1507            summary: "Test".to_string(),
1508        };
1509
1510        let json = render_score_json(&score).unwrap();
1511        assert!(json.contains("\"total\": 75"));
1512        assert!(json.contains("\"grade\": \"C\""));
1513    }
1514
1515    #[test]
1516    fn test_format_percentage() {
1517        assert_eq!(format_percentage(75, 100), "75%");
1518        assert_eq!(format_percentage(0, 100), "0%");
1519        assert_eq!(format_percentage(0, 0), "0%");
1520    }
1521
1522    #[test]
1523    fn test_category_status_max_zero() {
1524        assert_eq!(CategoryStatus::from_ratio(0, 0), CategoryStatus::Missing);
1525    }
1526
1527    #[test]
1528    fn test_grade_all_variants() {
1529        assert_eq!(Grade::from_score(100, 100), Grade::A);
1530        assert_eq!(Grade::from_score(90, 100), Grade::A);
1531        assert_eq!(Grade::from_score(89, 100), Grade::B);
1532        assert_eq!(Grade::from_score(80, 100), Grade::B);
1533        assert_eq!(Grade::from_score(79, 100), Grade::C);
1534        assert_eq!(Grade::from_score(70, 100), Grade::C);
1535        assert_eq!(Grade::from_score(69, 100), Grade::D);
1536        assert_eq!(Grade::from_score(60, 100), Grade::D);
1537        assert_eq!(Grade::from_score(59, 100), Grade::F);
1538        assert_eq!(Grade::from_score(0, 100), Grade::F);
1539    }
1540
1541    #[test]
1542    fn test_grade_as_str_all() {
1543        assert_eq!(Grade::A.as_str(), "A");
1544        assert_eq!(Grade::B.as_str(), "B");
1545        assert_eq!(Grade::C.as_str(), "C");
1546        assert_eq!(Grade::D.as_str(), "D");
1547        assert_eq!(Grade::F.as_str(), "F");
1548    }
1549
1550    #[test]
1551    fn test_criterion_result_creation() {
1552        let result = CriterionResult {
1553            name: "Test Criterion".to_string(),
1554            points_earned: 5,
1555            points_possible: 10,
1556            evidence: Some("Found 5 items".to_string()),
1557            suggestion: Some("Add more items".to_string()),
1558        };
1559        assert_eq!(result.name, "Test Criterion");
1560        assert_eq!(result.points_earned, 5);
1561    }
1562
1563    #[test]
1564    fn test_recommendation_creation() {
1565        let rec = Recommendation {
1566            priority: 1,
1567            action: "Add more tests".to_string(),
1568            potential_points: 10,
1569            effort: Effort::Low,
1570        };
1571        assert_eq!(rec.priority, 1);
1572        assert_eq!(rec.potential_points, 10);
1573        assert_eq!(rec.effort.as_str(), "Low (<1h)");
1574    }
1575
1576    #[test]
1577    fn test_category_score_creation() {
1578        let cat = CategoryScore {
1579            name: "Test Category".to_string(),
1580            score: 8,
1581            max: 10,
1582            status: CategoryStatus::Complete,
1583            criteria: vec![],
1584        };
1585        assert_eq!(cat.name, "Test Category");
1586        assert_eq!(cat.status, CategoryStatus::Complete);
1587    }
1588
1589    #[test]
1590    fn test_project_score_with_recommendations() {
1591        let score = ProjectScore {
1592            total: 60,
1593            max: 100,
1594            grade: Grade::D,
1595            categories: vec![],
1596            recommendations: vec![
1597                Recommendation {
1598                    priority: 1,
1599                    action: "First action".to_string(),
1600                    potential_points: 15,
1601                    effort: Effort::Medium,
1602                },
1603                Recommendation {
1604                    priority: 2,
1605                    action: "Second action".to_string(),
1606                    potential_points: 10,
1607                    effort: Effort::High,
1608                },
1609            ],
1610            summary: "Needs improvement".to_string(),
1611        };
1612
1613        let output = render_score_text(&score, true);
1614        assert!(output.contains("60/100"));
1615    }
1616
1617    #[test]
1618    fn test_score_calculator_with_performance() {
1619        let temp = TempDir::new().unwrap();
1620        let benches_dir = temp.path().join("benches");
1621        std::fs::create_dir(&benches_dir).unwrap();
1622        std::fs::write(benches_dir.join("benchmark.rs"), "fn main() {}").unwrap();
1623
1624        let calc = ScoreCalculator::new(temp.path());
1625        let score = calc.calculate();
1626
1627        let perf_category = score
1628            .categories
1629            .iter()
1630            .find(|c| c.name == "Performance Benchmarks");
1631        assert!(perf_category.is_some());
1632    }
1633
1634    #[test]
1635    fn test_score_calculator_with_accessibility() {
1636        let temp = TempDir::new().unwrap();
1637        let a11y_dir = temp.path().join("a11y");
1638        std::fs::create_dir(&a11y_dir).unwrap();
1639        std::fs::write(a11y_dir.join("config.yaml"), "rules: []").unwrap();
1640
1641        let calc = ScoreCalculator::new(temp.path());
1642        let score = calc.calculate();
1643
1644        let a11y_category = score.categories.iter().find(|c| c.name == "Accessibility");
1645        assert!(a11y_category.is_some());
1646    }
1647
1648    #[test]
1649    fn test_score_calculator_with_docs() {
1650        let temp = TempDir::new().unwrap();
1651        std::fs::write(
1652            temp.path().join("README.md"),
1653            "# Test\n\n## Testing\n\nWe use tests",
1654        )
1655        .unwrap();
1656
1657        let calc = ScoreCalculator::new(temp.path());
1658        let score = calc.calculate();
1659
1660        let docs_category = score.categories.iter().find(|c| c.name == "Documentation");
1661        assert!(docs_category.is_some());
1662    }
1663
1664    #[test]
1665    fn test_score_calculator_with_replay_session() {
1666        let temp = TempDir::new().unwrap();
1667        std::fs::write(temp.path().join("session.replay"), "{}").unwrap();
1668
1669        let calc = ScoreCalculator::new(temp.path());
1670        let score = calc.calculate();
1671
1672        let replay_category = score
1673            .categories
1674            .iter()
1675            .find(|c| c.name == "Deterministic Replay");
1676        assert!(replay_category.is_some());
1677    }
1678
1679    #[test]
1680    fn test_render_score_text_with_categories() {
1681        let score = ProjectScore {
1682            total: 75,
1683            max: 100,
1684            grade: Grade::C,
1685            categories: vec![
1686                CategoryScore {
1687                    name: "Test A".to_string(),
1688                    score: 40,
1689                    max: 50,
1690                    status: CategoryStatus::Complete,
1691                    criteria: vec![],
1692                },
1693                CategoryScore {
1694                    name: "Test B".to_string(),
1695                    score: 35,
1696                    max: 50,
1697                    status: CategoryStatus::Partial,
1698                    criteria: vec![],
1699                },
1700            ],
1701            recommendations: vec![],
1702            summary: "Good progress".to_string(),
1703        };
1704
1705        let output = render_score_text(&score, true);
1706        assert!(output.contains("Test A"));
1707        assert!(output.contains("Test B"));
1708    }
1709}