1#![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#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ProjectScore {
40 pub total: u32,
42 pub max: u32,
44 pub grade: Grade,
46 pub categories: Vec<CategoryScore>,
48 pub recommendations: Vec<Recommendation>,
50 pub summary: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CategoryScore {
57 pub name: String,
59 pub score: u32,
61 pub max: u32,
63 pub status: CategoryStatus,
65 pub criteria: Vec<CriterionResult>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct CriterionResult {
72 pub name: String,
74 pub points_earned: u32,
76 pub points_possible: u32,
78 pub evidence: Option<String>,
80 pub suggestion: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Recommendation {
87 pub priority: u8,
89 pub action: String,
91 pub potential_points: u32,
93 pub effort: Effort,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99pub enum Grade {
100 A,
102 B,
104 C,
106 D,
108 F,
110}
111
112impl Grade {
113 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142pub enum CategoryStatus {
143 Complete,
145 Partial,
147 Missing,
149}
150
151impl CategoryStatus {
152 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179pub enum Effort {
180 Low,
182 Medium,
184 High,
186}
187
188impl Effort {
189 #[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#[derive(Debug)]
202pub struct ScoreCalculator {
203 root: PathBuf,
204}
205
206impl ScoreCalculator {
207 #[must_use]
209 pub fn new(root: impl Into<PathBuf>) -> Self {
210 Self { root: root.into() }
211 }
212
213 #[must_use]
215 pub fn calculate(&self) -> ProjectScore {
216 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 let grade = if runtime_passed {
238 Grade::from_score(total, max)
239 } else {
240 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 fn score_runtime_health(&self) -> CategoryScore {
288 let mut criteria = Vec::new();
289 let mut score = 0;
290
291 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 let bootstrap_evidence = self.find_files("**/bootstrap-verified.json")
321 + self.find_files("**/*.probar-recording") + self.find_files("**/recordings/*.json"); 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 let critical_path = self.find_files("**/recordings/*happy*.json")
347 + self.find_files("**/recordings/*success*.json")
348 + self.find_files("**/*-passed.json");
349
350 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 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 fn score_playbook_coverage(&self) -> CategoryScore {
393 let mut criteria = Vec::new();
394 let mut score = 0;
395
396 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 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 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 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 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 fn score_pixel_testing(&self) -> CategoryScore {
489 let mut criteria = Vec::new();
490 let mut score = 0;
491
492 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 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 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 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 fn score_gui_interaction(&self) -> CategoryScore {
578 let mut criteria = Vec::new();
579 let mut score = 0;
580
581 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 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 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 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"); 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 fn score_performance(&self) -> CategoryScore {
673 let mut criteria = Vec::new();
674 let mut score = 0;
675
676 let playbooks = self.find_files("**/playbooks/*.yaml");
678
679 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 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 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 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 fn score_load_testing(&self) -> CategoryScore {
756 let mut criteria = Vec::new();
757 let mut score = 0;
758
759 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 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 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 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 fn score_deterministic_replay(&self) -> CategoryScore {
852 let mut criteria = Vec::new();
853 let mut score = 0;
854
855 let recordings =
857 self.find_files("**/*.probar-recording") + self.find_files("**/recordings/*.json");
858
859 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 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 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 fn score_cross_browser(&self) -> CategoryScore {
922 let mut criteria = Vec::new();
923 let mut score = 0;
924
925 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 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 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 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 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 fn score_accessibility(&self) -> CategoryScore {
1024 let mut criteria = Vec::new();
1025 let mut score = 0;
1026
1027 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 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 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 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 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 fn score_documentation(&self) -> CategoryScore {
1111 let mut criteria = Vec::new();
1112 let mut score = 0;
1113
1114 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 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 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 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 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, action: suggestion.clone(),
1202 potential_points: potential,
1203 effort,
1204 });
1205 }
1206 }
1207 }
1208 }
1209
1210 recommendations.sort_by(|a, b| b.potential_points.cmp(&a.potential_points));
1212
1213 for (i, rec) in recommendations.iter_mut().enumerate() {
1215 rec.priority = (i + 1) as u8;
1216 }
1217
1218 recommendations.truncate(5);
1220 recommendations
1221 }
1222}
1223
1224fn 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#[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 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 output.push_str("Grade Scale: A (90+), B (80-89), C (70-79), D (60-69), F (<60)\n\n");
1275
1276 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 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
1321pub 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 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 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 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 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 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 std::fs::write(temp.path().join("load-test.yaml"), "scenarios: []").unwrap();
1454
1455 std::fs::write(temp.path().join("load-test-results.json"), "{}").unwrap();
1459
1460 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 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 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}