1use crate::result::{ProbarError, ProbarResult};
12
13pub const MIN_CONTRAST_NORMAL: f32 = 4.5;
15
16pub const MIN_CONTRAST_LARGE: f32 = 3.0;
18
19pub const MIN_CONTRAST_UI: f32 = 3.0;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Color {
25 pub r: u8,
27 pub g: u8,
29 pub b: u8,
31}
32
33impl Color {
34 #[must_use]
36 pub const fn new(r: u8, g: u8, b: u8) -> Self {
37 Self { r, g, b }
38 }
39
40 #[must_use]
42 #[allow(clippy::cast_possible_truncation)]
43 pub const fn from_hex(hex: u32) -> Self {
44 Self {
45 r: ((hex >> 16) & 0xFF) as u8,
46 g: ((hex >> 8) & 0xFF) as u8,
47 b: (hex & 0xFF) as u8,
48 }
49 }
50
51 #[must_use]
53 pub fn relative_luminance(&self) -> f32 {
54 let r = srgb_to_linear(f32::from(self.r) / 255.0);
56 let g = srgb_to_linear(f32::from(self.g) / 255.0);
57 let b = srgb_to_linear(f32::from(self.b) / 255.0);
58
59 0.2126 * r + 0.7152 * g + 0.0722 * b
61 }
62
63 #[must_use]
65 pub fn contrast_ratio(&self, other: &Self) -> f32 {
66 let l1 = self.relative_luminance();
67 let l2 = other.relative_luminance();
68
69 let lighter = l1.max(l2);
70 let darker = l1.min(l2);
71
72 (lighter + 0.05) / (darker + 0.05)
73 }
74
75 #[must_use]
77 pub fn meets_wcag_aa_normal(&self, other: &Self) -> bool {
78 self.contrast_ratio(other) >= MIN_CONTRAST_NORMAL
79 }
80
81 #[must_use]
83 pub fn meets_wcag_aa_large(&self, other: &Self) -> bool {
84 self.contrast_ratio(other) >= MIN_CONTRAST_LARGE
85 }
86
87 #[must_use]
89 pub fn meets_wcag_aa_ui(&self, other: &Self) -> bool {
90 self.contrast_ratio(other) >= MIN_CONTRAST_UI
91 }
92}
93
94fn srgb_to_linear(value: f32) -> f32 {
96 if value <= 0.03928 {
97 value / 12.92
98 } else {
99 ((value + 0.055) / 1.055).powf(2.4)
100 }
101}
102
103#[derive(Debug, Clone)]
105pub struct ContrastAnalysis {
106 pub min_ratio: f32,
108 pub max_ratio: f32,
110 pub avg_ratio: f32,
112 pub pairs_analyzed: usize,
114 pub failing_pairs: Vec<ContrastPair>,
116 pub passes_wcag_aa: bool,
118}
119
120impl ContrastAnalysis {
121 #[must_use]
123 pub fn empty() -> Self {
124 Self {
125 min_ratio: f32::MAX,
126 max_ratio: 0.0,
127 avg_ratio: 0.0,
128 pairs_analyzed: 0,
129 failing_pairs: Vec::new(),
130 passes_wcag_aa: true,
131 }
132 }
133
134 pub fn add_pair(&mut self, foreground: Color, background: Color, context: impl Into<String>) {
136 let ratio = foreground.contrast_ratio(&background);
137 self.pairs_analyzed += 1;
138
139 self.min_ratio = self.min_ratio.min(ratio);
140 self.max_ratio = self.max_ratio.max(ratio);
141
142 self.avg_ratio = self.avg_ratio + (ratio - self.avg_ratio) / (self.pairs_analyzed as f32);
144
145 if ratio < MIN_CONTRAST_NORMAL {
147 self.passes_wcag_aa = false;
148 self.failing_pairs.push(ContrastPair {
149 foreground,
150 background,
151 ratio,
152 context: context.into(),
153 });
154 }
155 }
156}
157
158#[derive(Debug, Clone)]
160pub struct ContrastPair {
161 pub foreground: Color,
163 pub background: Color,
165 pub ratio: f32,
167 pub context: String,
169}
170
171#[derive(Debug, Clone)]
173pub struct AccessibilityConfig {
174 pub check_contrast: bool,
176 pub check_focus: bool,
178 pub check_reduced_motion: bool,
180 pub check_keyboard: bool,
182 pub min_contrast_text: f32,
184 pub min_contrast_ui: f32,
186}
187
188impl Default for AccessibilityConfig {
189 fn default() -> Self {
190 Self {
191 check_contrast: true,
192 check_focus: true,
193 check_reduced_motion: true,
194 check_keyboard: true,
195 min_contrast_text: MIN_CONTRAST_NORMAL,
196 min_contrast_ui: MIN_CONTRAST_UI,
197 }
198 }
199}
200
201#[derive(Debug, Clone)]
203pub struct AccessibilityAudit {
204 pub contrast: ContrastAnalysis,
206 pub has_focus_indicators: bool,
208 pub respects_reduced_motion: bool,
210 pub keyboard_issues: Vec<KeyboardIssue>,
212 pub score: u8,
214 pub issues: Vec<AccessibilityIssue>,
216}
217
218impl AccessibilityAudit {
219 #[must_use]
221 pub fn new() -> Self {
222 Self {
223 contrast: ContrastAnalysis::empty(),
224 has_focus_indicators: true,
225 respects_reduced_motion: true,
226 keyboard_issues: Vec::new(),
227 score: 100,
228 issues: Vec::new(),
229 }
230 }
231
232 #[must_use]
234 pub fn passes(&self) -> bool {
235 self.issues.is_empty() && self.score >= 80
236 }
237
238 pub fn add_issue(&mut self, issue: AccessibilityIssue) {
240 let deduction = match issue.severity {
242 Severity::Critical => 30,
243 Severity::Major => 20,
244 Severity::Minor => 10,
245 Severity::Info => 0,
246 };
247 self.score = self.score.saturating_sub(deduction);
248 self.issues.push(issue);
249 }
250}
251
252impl Default for AccessibilityAudit {
253 fn default() -> Self {
254 Self::new()
255 }
256}
257
258#[derive(Debug, Clone)]
260pub struct AccessibilityIssue {
261 pub wcag_code: String,
263 pub description: String,
265 pub severity: Severity,
267 pub context: Option<String>,
269 pub fix_suggestion: Option<String>,
271}
272
273impl AccessibilityIssue {
274 #[must_use]
276 pub fn new(
277 wcag_code: impl Into<String>,
278 description: impl Into<String>,
279 severity: Severity,
280 ) -> Self {
281 Self {
282 wcag_code: wcag_code.into(),
283 description: description.into(),
284 severity,
285 context: None,
286 fix_suggestion: None,
287 }
288 }
289
290 #[must_use]
292 pub fn with_context(mut self, context: impl Into<String>) -> Self {
293 self.context = Some(context.into());
294 self
295 }
296
297 #[must_use]
299 pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
300 self.fix_suggestion = Some(fix.into());
301 self
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum Severity {
308 Critical,
310 Major,
312 Minor,
314 Info,
316}
317
318#[derive(Debug, Clone)]
320pub struct KeyboardIssue {
321 pub description: String,
323 pub element: Option<String>,
325 pub wcag: String,
327}
328
329#[derive(Debug, Clone)]
331pub struct FocusConfig {
332 pub min_outline_width: f32,
334 pub min_contrast: f32,
336}
337
338impl Default for FocusConfig {
339 fn default() -> Self {
340 Self {
341 min_outline_width: 2.0,
342 min_contrast: 3.0,
343 }
344 }
345}
346
347#[derive(Debug, Clone, Default)]
351pub struct AccessibilityValidator {
352 config: AccessibilityConfig,
353}
354
355impl AccessibilityValidator {
356 #[must_use]
358 pub fn new() -> Self {
359 Self {
360 config: AccessibilityConfig::default(),
361 }
362 }
363
364 #[must_use]
366 pub const fn with_config(config: AccessibilityConfig) -> Self {
367 Self { config }
368 }
369
370 #[must_use]
374 pub fn analyze_contrast(&self, colors: &[(Color, Color, &str)]) -> ContrastAnalysis {
375 let mut analysis = ContrastAnalysis::empty();
376
377 for (fg, bg, context) in colors {
378 analysis.add_pair(*fg, *bg, *context);
379 }
380
381 analysis
382 }
383
384 #[must_use]
388 pub fn check_reduced_motion(&self, animations_disabled_when_preferred: bool) -> bool {
389 animations_disabled_when_preferred
390 }
391
392 pub fn validate_focus(&self, has_focus_visible: bool) -> ProbarResult<()> {
398 if has_focus_visible {
399 Ok(())
400 } else {
401 Err(ProbarError::AssertionError {
402 message: "Focus indicator missing".to_string(),
403 })
404 }
405 }
406
407 #[must_use]
409 pub fn audit(
410 &self,
411 colors: &[(Color, Color, &str)],
412 has_focus_indicators: bool,
413 respects_reduced_motion: bool,
414 ) -> AccessibilityAudit {
415 let mut audit = AccessibilityAudit::new();
416
417 if self.config.check_contrast {
419 audit.contrast = self.analyze_contrast(colors);
420 if !audit.contrast.passes_wcag_aa {
421 audit.add_issue(
422 AccessibilityIssue::new(
423 "1.4.3",
424 "Color contrast is insufficient for WCAG AA",
425 Severity::Major,
426 )
427 .with_fix("Increase contrast ratio to at least 4.5:1 for normal text"),
428 );
429 }
430 }
431
432 if self.config.check_focus && !has_focus_indicators {
434 audit.has_focus_indicators = false;
435 audit.add_issue(
436 AccessibilityIssue::new(
437 "2.4.7",
438 "Focus indicators are not visible",
439 Severity::Critical,
440 )
441 .with_fix("Add visible focus styles using :focus-visible"),
442 );
443 }
444
445 if self.config.check_reduced_motion && !respects_reduced_motion {
447 audit.respects_reduced_motion = false;
448 audit.add_issue(
449 AccessibilityIssue::new(
450 "2.3.3",
451 "Animations do not respect prefers-reduced-motion",
452 Severity::Major,
453 )
454 .with_fix("Check prefers-reduced-motion media query and disable animations"),
455 );
456 }
457
458 audit
459 }
460}
461
462#[derive(Debug, Clone)]
466pub struct FlashDetector {
467 pub max_flash_rate: f32,
469 pub max_red_intensity: f32,
471 pub max_flash_area: f32,
473}
474
475impl Default for FlashDetector {
476 fn default() -> Self {
477 Self {
478 max_flash_rate: 3.0, max_red_intensity: 0.8,
480 max_flash_area: 0.25, }
482 }
483}
484
485#[derive(Debug, Clone)]
487pub struct FlashResult {
488 pub flash_rate: f32,
490 pub red_flash_exceeded: bool,
492 pub flash_area: f32,
494 pub is_safe: bool,
496 pub warning: Option<String>,
498}
499
500impl FlashDetector {
501 #[must_use]
503 pub fn new() -> Self {
504 Self::default()
505 }
506
507 #[must_use]
509 pub fn analyze(
510 &self,
511 luminance_change: f32,
512 red_intensity: f32,
513 flash_area: f32,
514 time_delta_secs: f32,
515 ) -> FlashResult {
516 let flash_rate = if luminance_change > 0.1 && time_delta_secs > 0.0 {
518 1.0 / time_delta_secs
519 } else {
520 0.0
521 };
522
523 let is_safe = flash_rate <= self.max_flash_rate
524 && red_intensity <= self.max_red_intensity
525 && flash_area <= self.max_flash_area;
526
527 let warning = if is_safe {
528 None
529 } else if flash_rate > self.max_flash_rate {
530 Some("Flash rate exceeds safe threshold".to_string())
531 } else if red_intensity > self.max_red_intensity {
532 Some("Red flash intensity exceeds safe threshold".to_string())
533 } else {
534 Some("Flash area exceeds safe threshold".to_string())
535 };
536
537 FlashResult {
538 flash_rate,
539 red_flash_exceeded: red_intensity > self.max_red_intensity,
540 flash_area,
541 is_safe,
542 warning,
543 }
544 }
545}
546
547#[cfg(test)]
548#[allow(clippy::unwrap_used)]
549mod tests {
550 use super::*;
551
552 mod color_tests {
557 use super::*;
558
559 #[test]
560 fn test_color_from_hex() {
561 let color = Color::from_hex(0x00FF_5500);
562 assert_eq!(color.r, 255);
563 assert_eq!(color.g, 0x55);
564 assert_eq!(color.b, 0);
565 }
566
567 #[test]
568 fn test_relative_luminance_black() {
569 let black = Color::new(0, 0, 0);
570 assert!(black.relative_luminance() < 0.01);
571 }
572
573 #[test]
574 fn test_relative_luminance_white() {
575 let white = Color::new(255, 255, 255);
576 assert!(white.relative_luminance() > 0.99);
577 }
578
579 #[test]
580 fn test_contrast_ratio_black_white() {
581 let black = Color::new(0, 0, 0);
582 let white = Color::new(255, 255, 255);
583 let ratio = black.contrast_ratio(&white);
584 assert!((ratio - 21.0).abs() < 0.1);
586 }
587
588 #[test]
589 fn test_contrast_ratio_same_color() {
590 let red = Color::new(255, 0, 0);
591 let ratio = red.contrast_ratio(&red);
592 assert!((ratio - 1.0).abs() < 0.01);
594 }
595
596 #[test]
597 fn test_wcag_aa_black_white() {
598 let black = Color::new(0, 0, 0);
599 let white = Color::new(255, 255, 255);
600 assert!(black.meets_wcag_aa_normal(&white));
601 assert!(black.meets_wcag_aa_large(&white));
602 assert!(black.meets_wcag_aa_ui(&white));
603 }
604
605 #[test]
606 fn test_wcag_aa_low_contrast() {
607 let light_gray = Color::new(200, 200, 200);
608 let white = Color::new(255, 255, 255);
609 assert!(!light_gray.meets_wcag_aa_normal(&white));
610 }
611 }
612
613 mod contrast_analysis_tests {
614 use super::*;
615
616 #[test]
617 fn test_empty_analysis() {
618 let analysis = ContrastAnalysis::empty();
619 assert_eq!(analysis.pairs_analyzed, 0);
620 assert!(analysis.passes_wcag_aa);
621 }
622
623 #[test]
624 fn test_add_passing_pair() {
625 let mut analysis = ContrastAnalysis::empty();
626 let black = Color::new(0, 0, 0);
627 let white = Color::new(255, 255, 255);
628 analysis.add_pair(black, white, "text");
629 assert_eq!(analysis.pairs_analyzed, 1);
630 assert!(analysis.passes_wcag_aa);
631 assert!(analysis.failing_pairs.is_empty());
632 }
633
634 #[test]
635 fn test_add_failing_pair() {
636 let mut analysis = ContrastAnalysis::empty();
637 let gray = Color::new(150, 150, 150);
638 let white = Color::new(255, 255, 255);
639 analysis.add_pair(gray, white, "button");
640 assert!(!analysis.passes_wcag_aa);
641 assert_eq!(analysis.failing_pairs.len(), 1);
642 }
643
644 #[test]
645 fn test_min_max_ratio() {
646 let mut analysis = ContrastAnalysis::empty();
647 let black = Color::new(0, 0, 0);
648 let white = Color::new(255, 255, 255);
649 let gray = Color::new(128, 128, 128);
650
651 analysis.add_pair(black, white, "high contrast");
652 analysis.add_pair(gray, white, "lower contrast");
653
654 assert!(analysis.max_ratio > analysis.min_ratio);
655 }
656 }
657
658 mod accessibility_issue_tests {
659 use super::*;
660
661 #[test]
662 fn test_issue_creation() {
663 let issue = AccessibilityIssue::new("1.4.3", "Low contrast", Severity::Major);
664 assert_eq!(issue.wcag_code, "1.4.3");
665 assert!(matches!(issue.severity, Severity::Major));
666 }
667
668 #[test]
669 fn test_issue_with_context() {
670 let issue = AccessibilityIssue::new("2.4.7", "No focus", Severity::Critical)
671 .with_context("Submit button");
672 assert_eq!(issue.context, Some("Submit button".to_string()));
673 }
674
675 #[test]
676 fn test_issue_with_fix() {
677 let issue = AccessibilityIssue::new("2.3.3", "Animations", Severity::Minor)
678 .with_fix("Add reduced motion check");
679 assert!(issue.fix_suggestion.is_some());
680 }
681 }
682
683 mod audit_tests {
684 use super::*;
685
686 #[test]
687 fn test_new_audit_passes() {
688 let audit = AccessibilityAudit::new();
689 assert!(audit.passes());
690 assert_eq!(audit.score, 100);
691 }
692
693 #[test]
694 fn test_audit_with_critical_issue() {
695 let mut audit = AccessibilityAudit::new();
696 audit.add_issue(AccessibilityIssue::new(
697 "2.4.7",
698 "No focus indicators",
699 Severity::Critical,
700 ));
701 assert_eq!(audit.score, 70); assert!(!audit.passes());
703 }
704
705 #[test]
706 fn test_audit_with_multiple_issues() {
707 let mut audit = AccessibilityAudit::new();
708 audit.add_issue(AccessibilityIssue::new(
709 "1.4.3",
710 "Low contrast",
711 Severity::Major,
712 ));
713 audit.add_issue(AccessibilityIssue::new(
714 "2.3.3",
715 "No motion",
716 Severity::Minor,
717 ));
718 assert_eq!(audit.score, 70); }
720 }
721
722 mod validator_tests {
723 use super::*;
724
725 #[test]
726 fn test_validator_new() {
727 let validator = AccessibilityValidator::new();
728 assert!(validator.config.check_contrast);
729 assert!(validator.config.check_focus);
730 }
731
732 #[test]
733 fn test_analyze_contrast() {
734 let validator = AccessibilityValidator::new();
735 let black = Color::new(0, 0, 0);
736 let white = Color::new(255, 255, 255);
737
738 let analysis = validator.analyze_contrast(&[(black, white, "text")]);
739 assert!(analysis.passes_wcag_aa);
740 }
741
742 #[test]
743 fn test_validate_focus_pass() {
744 let validator = AccessibilityValidator::new();
745 assert!(validator.validate_focus(true).is_ok());
746 }
747
748 #[test]
749 fn test_validate_focus_fail() {
750 let validator = AccessibilityValidator::new();
751 assert!(validator.validate_focus(false).is_err());
752 }
753
754 #[test]
755 fn test_check_reduced_motion() {
756 let validator = AccessibilityValidator::new();
757 assert!(validator.check_reduced_motion(true));
758 assert!(!validator.check_reduced_motion(false));
759 }
760
761 #[test]
762 fn test_full_audit_pass() {
763 let validator = AccessibilityValidator::new();
764 let black = Color::new(0, 0, 0);
765 let white = Color::new(255, 255, 255);
766
767 let audit = validator.audit(
768 &[(black, white, "text")],
769 true, true, );
772
773 assert!(audit.passes());
774 assert_eq!(audit.score, 100);
775 }
776
777 #[test]
778 fn test_full_audit_fail_contrast() {
779 let validator = AccessibilityValidator::new();
780 let gray = Color::new(180, 180, 180);
781 let white = Color::new(255, 255, 255);
782
783 let audit = validator.audit(&[(gray, white, "text")], true, true);
784
785 assert!(!audit.passes());
786 assert!(audit.issues.iter().any(|i| i.wcag_code == "1.4.3"));
787 }
788
789 #[test]
790 fn test_full_audit_fail_focus() {
791 let validator = AccessibilityValidator::new();
792 let black = Color::new(0, 0, 0);
793 let white = Color::new(255, 255, 255);
794
795 let audit = validator.audit(
796 &[(black, white, "text")],
797 false, true,
799 );
800
801 assert!(!audit.passes());
802 assert!(audit.issues.iter().any(|i| i.wcag_code == "2.4.7"));
803 }
804 }
805
806 mod flash_detector_tests {
807 use super::*;
808
809 #[test]
810 fn test_flash_detector_default() {
811 let detector = FlashDetector::default();
812 assert!((detector.max_flash_rate - 3.0).abs() < 0.01);
813 }
814
815 #[test]
816 fn test_analyze_safe_flash() {
817 let detector = FlashDetector::new();
818 let result = detector.analyze(0.05, 0.2, 0.1, 0.5);
819 assert!(result.is_safe);
820 assert!(result.warning.is_none());
821 }
822
823 #[test]
824 fn test_analyze_high_flash_rate() {
825 let detector = FlashDetector::new();
826 let result = detector.analyze(0.5, 0.2, 0.1, 0.1);
828 assert!(!result.is_safe);
829 assert!(result.warning.is_some());
830 }
831
832 #[test]
833 fn test_analyze_high_red_intensity() {
834 let detector = FlashDetector::new();
835 let result = detector.analyze(0.1, 0.95, 0.1, 1.0);
836 assert!(!result.is_safe);
837 assert!(result.red_flash_exceeded);
838 }
839
840 #[test]
841 fn test_analyze_large_flash_area() {
842 let detector = FlashDetector::new();
843 let result = detector.analyze(0.1, 0.2, 0.5, 1.0);
844 assert!(!result.is_safe);
845 }
846 }
847
848 mod config_tests {
849 use super::*;
850
851 #[test]
852 fn test_accessibility_config_default() {
853 let config = AccessibilityConfig::default();
854 assert!(config.check_contrast);
855 assert!(config.check_focus);
856 assert!(config.check_reduced_motion);
857 assert!(config.check_keyboard);
858 }
859
860 #[test]
861 fn test_focus_config_default() {
862 let config = FocusConfig::default();
863 assert!((config.min_outline_width - 2.0).abs() < 0.01);
864 assert!((config.min_contrast - 3.0).abs() < 0.01);
865 }
866 }
867
868 mod h0_color_tests {
873 use super::*;
874
875 #[test]
876 fn h0_a11y_01_color_new() {
877 let color = Color::new(128, 64, 32);
878 assert_eq!(color.r, 128);
879 assert_eq!(color.g, 64);
880 assert_eq!(color.b, 32);
881 }
882
883 #[test]
884 fn h0_a11y_02_color_from_hex_white() {
885 let color = Color::from_hex(0xFFFFFF);
886 assert_eq!(color.r, 255);
887 assert_eq!(color.g, 255);
888 assert_eq!(color.b, 255);
889 }
890
891 #[test]
892 fn h0_a11y_03_color_from_hex_black() {
893 let color = Color::from_hex(0x000000);
894 assert_eq!(color.r, 0);
895 assert_eq!(color.g, 0);
896 assert_eq!(color.b, 0);
897 }
898
899 #[test]
900 fn h0_a11y_04_color_from_hex_red() {
901 let color = Color::from_hex(0xFF0000);
902 assert_eq!(color.r, 255);
903 assert_eq!(color.g, 0);
904 assert_eq!(color.b, 0);
905 }
906
907 #[test]
908 fn h0_a11y_05_color_from_hex_green() {
909 let color = Color::from_hex(0x00FF00);
910 assert_eq!(color.r, 0);
911 assert_eq!(color.g, 255);
912 assert_eq!(color.b, 0);
913 }
914
915 #[test]
916 fn h0_a11y_06_color_from_hex_blue() {
917 let color = Color::from_hex(0x0000FF);
918 assert_eq!(color.r, 0);
919 assert_eq!(color.g, 0);
920 assert_eq!(color.b, 255);
921 }
922
923 #[test]
924 fn h0_a11y_07_color_relative_luminance_black() {
925 let black = Color::new(0, 0, 0);
926 assert!(black.relative_luminance() < 0.001);
927 }
928
929 #[test]
930 fn h0_a11y_08_color_relative_luminance_white() {
931 let white = Color::new(255, 255, 255);
932 assert!(white.relative_luminance() > 0.99);
933 }
934
935 #[test]
936 fn h0_a11y_09_color_contrast_ratio_max() {
937 let black = Color::new(0, 0, 0);
938 let white = Color::new(255, 255, 255);
939 let ratio = black.contrast_ratio(&white);
940 assert!((ratio - 21.0).abs() < 0.5);
941 }
942
943 #[test]
944 fn h0_a11y_10_color_contrast_ratio_min() {
945 let red = Color::new(255, 0, 0);
946 let ratio = red.contrast_ratio(&red);
947 assert!((ratio - 1.0).abs() < 0.01);
948 }
949 }
950
951 mod h0_wcag_tests {
952 use super::*;
953
954 #[test]
955 fn h0_a11y_11_meets_wcag_aa_normal_pass() {
956 let black = Color::new(0, 0, 0);
957 let white = Color::new(255, 255, 255);
958 assert!(black.meets_wcag_aa_normal(&white));
959 }
960
961 #[test]
962 fn h0_a11y_12_meets_wcag_aa_normal_fail() {
963 let light_gray = Color::new(200, 200, 200);
964 let white = Color::new(255, 255, 255);
965 assert!(!light_gray.meets_wcag_aa_normal(&white));
966 }
967
968 #[test]
969 fn h0_a11y_13_meets_wcag_aa_large_pass() {
970 let gray = Color::new(100, 100, 100);
971 let white = Color::new(255, 255, 255);
972 assert!(gray.meets_wcag_aa_large(&white));
973 }
974
975 #[test]
976 fn h0_a11y_14_meets_wcag_aa_ui_pass() {
977 let gray = Color::new(100, 100, 100);
978 let white = Color::new(255, 255, 255);
979 assert!(gray.meets_wcag_aa_ui(&white));
980 }
981
982 #[test]
983 fn h0_a11y_15_min_contrast_normal_constant() {
984 assert!((MIN_CONTRAST_NORMAL - 4.5).abs() < 0.01);
985 }
986
987 #[test]
988 fn h0_a11y_16_min_contrast_large_constant() {
989 assert!((MIN_CONTRAST_LARGE - 3.0).abs() < 0.01);
990 }
991
992 #[test]
993 fn h0_a11y_17_min_contrast_ui_constant() {
994 assert!((MIN_CONTRAST_UI - 3.0).abs() < 0.01);
995 }
996
997 #[test]
998 fn h0_a11y_18_color_equality() {
999 let color1 = Color::new(100, 100, 100);
1000 let color2 = Color::new(100, 100, 100);
1001 assert_eq!(color1, color2);
1002 }
1003
1004 #[test]
1005 fn h0_a11y_19_color_clone() {
1006 let color = Color::new(50, 100, 150);
1007 let cloned = color;
1008 assert_eq!(cloned.r, 50);
1009 }
1010
1011 #[test]
1012 fn h0_a11y_20_color_debug() {
1013 let color = Color::new(128, 128, 128);
1014 let debug = format!("{:?}", color);
1015 assert!(debug.contains("Color"));
1016 }
1017 }
1018
1019 mod h0_contrast_analysis_tests {
1020 use super::*;
1021
1022 #[test]
1023 fn h0_a11y_21_contrast_analysis_empty() {
1024 let analysis = ContrastAnalysis::empty();
1025 assert_eq!(analysis.pairs_analyzed, 0);
1026 }
1027
1028 #[test]
1029 fn h0_a11y_22_contrast_analysis_passes_wcag_empty() {
1030 let analysis = ContrastAnalysis::empty();
1031 assert!(analysis.passes_wcag_aa);
1032 }
1033
1034 #[test]
1035 fn h0_a11y_23_contrast_analysis_add_pair_count() {
1036 let mut analysis = ContrastAnalysis::empty();
1037 analysis.add_pair(Color::new(0, 0, 0), Color::new(255, 255, 255), "test");
1038 assert_eq!(analysis.pairs_analyzed, 1);
1039 }
1040
1041 #[test]
1042 fn h0_a11y_24_contrast_analysis_add_failing_pair() {
1043 let mut analysis = ContrastAnalysis::empty();
1044 analysis.add_pair(Color::new(200, 200, 200), Color::new(255, 255, 255), "fail");
1045 assert!(!analysis.passes_wcag_aa);
1046 }
1047
1048 #[test]
1049 fn h0_a11y_25_contrast_analysis_failing_pairs_list() {
1050 let mut analysis = ContrastAnalysis::empty();
1051 analysis.add_pair(Color::new(220, 220, 220), Color::new(255, 255, 255), "low");
1052 assert_eq!(analysis.failing_pairs.len(), 1);
1053 }
1054
1055 #[test]
1056 fn h0_a11y_26_contrast_analysis_min_ratio() {
1057 let mut analysis = ContrastAnalysis::empty();
1058 analysis.add_pair(Color::new(0, 0, 0), Color::new(255, 255, 255), "high");
1059 assert!(analysis.min_ratio > 20.0);
1060 }
1061
1062 #[test]
1063 fn h0_a11y_27_contrast_analysis_max_ratio() {
1064 let mut analysis = ContrastAnalysis::empty();
1065 analysis.add_pair(Color::new(0, 0, 0), Color::new(255, 255, 255), "high");
1066 assert!(analysis.max_ratio > 20.0);
1067 }
1068
1069 #[test]
1070 fn h0_a11y_28_contrast_analysis_avg_ratio() {
1071 let mut analysis = ContrastAnalysis::empty();
1072 analysis.add_pair(Color::new(0, 0, 0), Color::new(255, 255, 255), "high");
1073 assert!(analysis.avg_ratio > 20.0);
1074 }
1075
1076 #[test]
1077 fn h0_a11y_29_contrast_pair_context() {
1078 let pair = ContrastPair {
1079 foreground: Color::new(0, 0, 0),
1080 background: Color::new(255, 255, 255),
1081 ratio: 21.0,
1082 context: "button text".to_string(),
1083 };
1084 assert_eq!(pair.context, "button text");
1085 }
1086
1087 #[test]
1088 fn h0_a11y_30_contrast_pair_ratio() {
1089 let pair = ContrastPair {
1090 foreground: Color::new(0, 0, 0),
1091 background: Color::new(255, 255, 255),
1092 ratio: 21.0,
1093 context: "test".to_string(),
1094 };
1095 assert!((pair.ratio - 21.0).abs() < 0.01);
1096 }
1097 }
1098
1099 mod h0_audit_tests {
1100 use super::*;
1101
1102 #[test]
1103 fn h0_a11y_31_audit_new_score() {
1104 let audit = AccessibilityAudit::new();
1105 assert_eq!(audit.score, 100);
1106 }
1107
1108 #[test]
1109 fn h0_a11y_32_audit_new_passes() {
1110 let audit = AccessibilityAudit::new();
1111 assert!(audit.passes());
1112 }
1113
1114 #[test]
1115 fn h0_a11y_33_audit_default() {
1116 let audit = AccessibilityAudit::default();
1117 assert_eq!(audit.score, 100);
1118 }
1119
1120 #[test]
1121 fn h0_a11y_34_audit_add_critical_issue() {
1122 let mut audit = AccessibilityAudit::new();
1123 audit.add_issue(AccessibilityIssue::new(
1124 "2.4.7",
1125 "No focus",
1126 Severity::Critical,
1127 ));
1128 assert_eq!(audit.score, 70);
1129 }
1130
1131 #[test]
1132 fn h0_a11y_35_audit_add_major_issue() {
1133 let mut audit = AccessibilityAudit::new();
1134 audit.add_issue(AccessibilityIssue::new(
1135 "1.4.3",
1136 "Low contrast",
1137 Severity::Major,
1138 ));
1139 assert_eq!(audit.score, 80);
1140 }
1141
1142 #[test]
1143 fn h0_a11y_36_audit_add_minor_issue() {
1144 let mut audit = AccessibilityAudit::new();
1145 audit.add_issue(AccessibilityIssue::new("2.3.3", "Motion", Severity::Minor));
1146 assert_eq!(audit.score, 90);
1147 }
1148
1149 #[test]
1150 fn h0_a11y_37_audit_add_info_issue() {
1151 let mut audit = AccessibilityAudit::new();
1152 audit.add_issue(AccessibilityIssue::new("1.1.1", "Info", Severity::Info));
1153 assert_eq!(audit.score, 100);
1154 }
1155
1156 #[test]
1157 fn h0_a11y_38_audit_has_focus_indicators() {
1158 let audit = AccessibilityAudit::new();
1159 assert!(audit.has_focus_indicators);
1160 }
1161
1162 #[test]
1163 fn h0_a11y_39_audit_respects_reduced_motion() {
1164 let audit = AccessibilityAudit::new();
1165 assert!(audit.respects_reduced_motion);
1166 }
1167
1168 #[test]
1169 fn h0_a11y_40_audit_keyboard_issues_empty() {
1170 let audit = AccessibilityAudit::new();
1171 assert!(audit.keyboard_issues.is_empty());
1172 }
1173 }
1174
1175 mod h0_issue_tests {
1176 use super::*;
1177
1178 #[test]
1179 fn h0_a11y_41_issue_wcag_code() {
1180 let issue = AccessibilityIssue::new("1.4.3", "test", Severity::Major);
1181 assert_eq!(issue.wcag_code, "1.4.3");
1182 }
1183
1184 #[test]
1185 fn h0_a11y_42_issue_description() {
1186 let issue = AccessibilityIssue::new("1.4.3", "Low contrast", Severity::Major);
1187 assert_eq!(issue.description, "Low contrast");
1188 }
1189
1190 #[test]
1191 fn h0_a11y_43_issue_with_context() {
1192 let issue = AccessibilityIssue::new("1.4.3", "test", Severity::Major)
1193 .with_context("Submit button");
1194 assert_eq!(issue.context, Some("Submit button".to_string()));
1195 }
1196
1197 #[test]
1198 fn h0_a11y_44_issue_with_fix() {
1199 let issue = AccessibilityIssue::new("1.4.3", "test", Severity::Major)
1200 .with_fix("Increase contrast");
1201 assert_eq!(issue.fix_suggestion, Some("Increase contrast".to_string()));
1202 }
1203
1204 #[test]
1205 fn h0_a11y_45_severity_critical() {
1206 let issue = AccessibilityIssue::new("2.4.7", "test", Severity::Critical);
1207 assert!(matches!(issue.severity, Severity::Critical));
1208 }
1209
1210 #[test]
1211 fn h0_a11y_46_severity_major() {
1212 let issue = AccessibilityIssue::new("1.4.3", "test", Severity::Major);
1213 assert!(matches!(issue.severity, Severity::Major));
1214 }
1215
1216 #[test]
1217 fn h0_a11y_47_severity_minor() {
1218 let issue = AccessibilityIssue::new("2.3.3", "test", Severity::Minor);
1219 assert!(matches!(issue.severity, Severity::Minor));
1220 }
1221
1222 #[test]
1223 fn h0_a11y_48_severity_info() {
1224 let issue = AccessibilityIssue::new("1.1.1", "test", Severity::Info);
1225 assert!(matches!(issue.severity, Severity::Info));
1226 }
1227
1228 #[test]
1229 fn h0_a11y_49_keyboard_issue_struct() {
1230 let issue = KeyboardIssue {
1231 description: "Cannot tab to element".to_string(),
1232 element: Some("button".to_string()),
1233 wcag: "2.1.1".to_string(),
1234 };
1235 assert_eq!(issue.wcag, "2.1.1");
1236 }
1237
1238 #[test]
1239 fn h0_a11y_50_focus_config_default_values() {
1240 let config = FocusConfig::default();
1241 assert!((config.min_outline_width - 2.0).abs() < 0.001);
1242 assert!((config.min_contrast - 3.0).abs() < 0.001);
1243 }
1244 }
1245
1246 mod h0_flash_detector_tests {
1247 use super::*;
1248
1249 #[test]
1250 fn h0_a11y_51_flash_detector_new() {
1251 let detector = FlashDetector::new();
1252 assert!((detector.max_flash_rate - 3.0).abs() < 0.01);
1253 }
1254
1255 #[test]
1256 fn h0_a11y_52_flash_detector_default_rate() {
1257 let detector = FlashDetector::default();
1258 assert!((detector.max_flash_rate - 3.0).abs() < 0.01);
1259 }
1260
1261 #[test]
1262 fn h0_a11y_53_flash_detector_default_red_intensity() {
1263 let detector = FlashDetector::default();
1264 assert!((detector.max_red_intensity - 0.8).abs() < 0.01);
1265 }
1266
1267 #[test]
1268 fn h0_a11y_54_flash_detector_default_area() {
1269 let detector = FlashDetector::default();
1270 assert!((detector.max_flash_area - 0.25).abs() < 0.01);
1271 }
1272
1273 #[test]
1274 fn h0_a11y_55_flash_result_safe() {
1275 let detector = FlashDetector::new();
1276 let result = detector.analyze(0.01, 0.1, 0.05, 1.0);
1277 assert!(result.is_safe);
1278 }
1279
1280 #[test]
1281 fn h0_a11y_56_flash_result_unsafe_rate() {
1282 let detector = FlashDetector::new();
1283 let result = detector.analyze(0.2, 0.1, 0.1, 0.05); assert!(!result.is_safe);
1285 }
1286
1287 #[test]
1288 fn h0_a11y_57_flash_result_red_exceeded() {
1289 let detector = FlashDetector::new();
1290 let result = detector.analyze(0.1, 0.95, 0.1, 1.0);
1291 assert!(result.red_flash_exceeded);
1292 }
1293
1294 #[test]
1295 fn h0_a11y_58_flash_result_area() {
1296 let detector = FlashDetector::new();
1297 let result = detector.analyze(0.1, 0.1, 0.3, 1.0);
1298 assert!((result.flash_area - 0.3).abs() < 0.01);
1299 }
1300
1301 #[test]
1302 fn h0_a11y_59_flash_result_warning_present() {
1303 let detector = FlashDetector::new();
1304 let result = detector.analyze(0.2, 0.1, 0.1, 0.05);
1305 assert!(result.warning.is_some());
1306 }
1307
1308 #[test]
1309 fn h0_a11y_60_flash_result_warning_none() {
1310 let detector = FlashDetector::new();
1311 let result = detector.analyze(0.01, 0.1, 0.05, 1.0);
1312 assert!(result.warning.is_none());
1313 }
1314 }
1315
1316 mod h0_validator_tests {
1317 use super::*;
1318
1319 #[test]
1320 fn h0_a11y_61_validator_new() {
1321 let validator = AccessibilityValidator::new();
1322 assert!(validator.config.check_contrast);
1323 }
1324
1325 #[test]
1326 fn h0_a11y_62_validator_with_config() {
1327 let config = AccessibilityConfig {
1328 check_contrast: false,
1329 ..Default::default()
1330 };
1331 let validator = AccessibilityValidator::with_config(config);
1332 assert!(!validator.config.check_contrast);
1333 }
1334
1335 #[test]
1336 fn h0_a11y_63_validator_analyze_contrast_pass() {
1337 let validator = AccessibilityValidator::new();
1338 let result = validator.analyze_contrast(&[(
1339 Color::new(0, 0, 0),
1340 Color::new(255, 255, 255),
1341 "text",
1342 )]);
1343 assert!(result.passes_wcag_aa);
1344 }
1345
1346 #[test]
1347 fn h0_a11y_64_validator_analyze_contrast_fail() {
1348 let validator = AccessibilityValidator::new();
1349 let result = validator.analyze_contrast(&[(
1350 Color::new(200, 200, 200),
1351 Color::new(255, 255, 255),
1352 "text",
1353 )]);
1354 assert!(!result.passes_wcag_aa);
1355 }
1356
1357 #[test]
1358 fn h0_a11y_65_validator_check_reduced_motion_true() {
1359 let validator = AccessibilityValidator::new();
1360 assert!(validator.check_reduced_motion(true));
1361 }
1362
1363 #[test]
1364 fn h0_a11y_66_validator_check_reduced_motion_false() {
1365 let validator = AccessibilityValidator::new();
1366 assert!(!validator.check_reduced_motion(false));
1367 }
1368
1369 #[test]
1370 fn h0_a11y_67_validator_validate_focus_pass() {
1371 let validator = AccessibilityValidator::new();
1372 assert!(validator.validate_focus(true).is_ok());
1373 }
1374
1375 #[test]
1376 fn h0_a11y_68_validator_validate_focus_fail() {
1377 let validator = AccessibilityValidator::new();
1378 assert!(validator.validate_focus(false).is_err());
1379 }
1380
1381 #[test]
1382 fn h0_a11y_69_validator_audit_full_pass() {
1383 let validator = AccessibilityValidator::new();
1384 let audit = validator.audit(
1385 &[(Color::new(0, 0, 0), Color::new(255, 255, 255), "text")],
1386 true,
1387 true,
1388 );
1389 assert!(audit.passes());
1390 }
1391
1392 #[test]
1393 fn h0_a11y_70_validator_audit_contrast_fail() {
1394 let validator = AccessibilityValidator::new();
1395 let audit = validator.audit(
1396 &[(Color::new(200, 200, 200), Color::new(255, 255, 255), "text")],
1397 true,
1398 true,
1399 );
1400 assert!(!audit.contrast.passes_wcag_aa);
1401 }
1402 }
1403}