Skip to main content

jugar_probar/
accessibility.rs

1//! Accessibility validation for games.
2//!
3//! Per spec Section 6.3: "Automated a11y testing for games."
4//!
5//! This module provides tools for validating accessibility compliance:
6//! - Color contrast analysis (WCAG 2.1 AA)
7//! - Focus indicator detection
8//! - Reduced motion preference handling
9//! - Screen reader compatibility
10
11use crate::result::{ProbarError, ProbarResult};
12
13/// Minimum contrast ratio for normal text (WCAG 2.1 AA)
14pub const MIN_CONTRAST_NORMAL: f32 = 4.5;
15
16/// Minimum contrast ratio for large text (WCAG 2.1 AA)
17pub const MIN_CONTRAST_LARGE: f32 = 3.0;
18
19/// Minimum contrast ratio for UI components (WCAG 2.1 AA)
20pub const MIN_CONTRAST_UI: f32 = 3.0;
21
22/// Color represented as RGB values
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Color {
25    /// Red component (0-255)
26    pub r: u8,
27    /// Green component (0-255)
28    pub g: u8,
29    /// Blue component (0-255)
30    pub b: u8,
31}
32
33impl Color {
34    /// Create a new color
35    #[must_use]
36    pub const fn new(r: u8, g: u8, b: u8) -> Self {
37        Self { r, g, b }
38    }
39
40    /// Create from hex value (e.g., 0xFF5500)
41    #[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    /// Get the relative luminance (per WCAG 2.1)
52    #[must_use]
53    pub fn relative_luminance(&self) -> f32 {
54        // Convert to sRGB
55        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        // Calculate luminance
60        0.2126 * r + 0.7152 * g + 0.0722 * b
61    }
62
63    /// Calculate contrast ratio with another color
64    #[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    /// Check if contrast meets WCAG AA for normal text
76    #[must_use]
77    pub fn meets_wcag_aa_normal(&self, other: &Self) -> bool {
78        self.contrast_ratio(other) >= MIN_CONTRAST_NORMAL
79    }
80
81    /// Check if contrast meets WCAG AA for large text
82    #[must_use]
83    pub fn meets_wcag_aa_large(&self, other: &Self) -> bool {
84        self.contrast_ratio(other) >= MIN_CONTRAST_LARGE
85    }
86
87    /// Check if contrast meets WCAG AA for UI components
88    #[must_use]
89    pub fn meets_wcag_aa_ui(&self, other: &Self) -> bool {
90        self.contrast_ratio(other) >= MIN_CONTRAST_UI
91    }
92}
93
94/// Convert sRGB to linear RGB (per WCAG 2.1)
95fn 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/// Results of a contrast analysis
104#[derive(Debug, Clone)]
105pub struct ContrastAnalysis {
106    /// Minimum contrast ratio found
107    pub min_ratio: f32,
108    /// Maximum contrast ratio found
109    pub max_ratio: f32,
110    /// Average contrast ratio
111    pub avg_ratio: f32,
112    /// Number of color pairs analyzed
113    pub pairs_analyzed: usize,
114    /// Color pairs that fail WCAG AA
115    pub failing_pairs: Vec<ContrastPair>,
116    /// Whether the analysis passes WCAG AA
117    pub passes_wcag_aa: bool,
118}
119
120impl ContrastAnalysis {
121    /// Create an empty analysis
122    #[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    /// Add a color pair to the analysis
135    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        // Rolling average
143        self.avg_ratio = self.avg_ratio + (ratio - self.avg_ratio) / (self.pairs_analyzed as f32);
144
145        // Check WCAG AA
146        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/// A pair of colors with their contrast ratio
159#[derive(Debug, Clone)]
160pub struct ContrastPair {
161    /// Foreground color
162    pub foreground: Color,
163    /// Background color
164    pub background: Color,
165    /// Contrast ratio between them
166    pub ratio: f32,
167    /// Context where this pair was found
168    pub context: String,
169}
170
171/// Configuration for accessibility validation
172#[derive(Debug, Clone)]
173pub struct AccessibilityConfig {
174    /// Check color contrast
175    pub check_contrast: bool,
176    /// Check focus indicators
177    pub check_focus: bool,
178    /// Check reduced motion
179    pub check_reduced_motion: bool,
180    /// Check keyboard navigation
181    pub check_keyboard: bool,
182    /// Minimum contrast ratio for text
183    pub min_contrast_text: f32,
184    /// Minimum contrast ratio for UI
185    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/// Result of an accessibility audit
202#[derive(Debug, Clone)]
203pub struct AccessibilityAudit {
204    /// Contrast analysis results
205    pub contrast: ContrastAnalysis,
206    /// Whether focus indicators are present
207    pub has_focus_indicators: bool,
208    /// Whether reduced motion is respected
209    pub respects_reduced_motion: bool,
210    /// Keyboard navigation issues
211    pub keyboard_issues: Vec<KeyboardIssue>,
212    /// Overall accessibility score (0-100)
213    pub score: u8,
214    /// Issues found
215    pub issues: Vec<AccessibilityIssue>,
216}
217
218impl AccessibilityAudit {
219    /// Create a new empty audit
220    #[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    /// Check if the audit passes
233    #[must_use]
234    pub fn passes(&self) -> bool {
235        self.issues.is_empty() && self.score >= 80
236    }
237
238    /// Add an issue
239    pub fn add_issue(&mut self, issue: AccessibilityIssue) {
240        // Deduct points based on severity
241        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/// An accessibility issue found during audit
259#[derive(Debug, Clone)]
260pub struct AccessibilityIssue {
261    /// WCAG criterion code (e.g., "1.4.3")
262    pub wcag_code: String,
263    /// Issue description
264    pub description: String,
265    /// Severity level
266    pub severity: Severity,
267    /// Element or context where issue was found
268    pub context: Option<String>,
269    /// Suggested fix
270    pub fix_suggestion: Option<String>,
271}
272
273impl AccessibilityIssue {
274    /// Create a new accessibility issue
275    #[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    /// Add context
291    #[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    /// Add fix suggestion
298    #[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/// Severity level of an accessibility issue
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum Severity {
308    /// Critical - must be fixed
309    Critical,
310    /// Major - should be fixed
311    Major,
312    /// Minor - nice to fix
313    Minor,
314    /// Informational
315    Info,
316}
317
318/// A keyboard navigation issue
319#[derive(Debug, Clone)]
320pub struct KeyboardIssue {
321    /// Description of the issue
322    pub description: String,
323    /// Element that has the issue
324    pub element: Option<String>,
325    /// WCAG criterion
326    pub wcag: String,
327}
328
329/// Focus indicator configuration
330#[derive(Debug, Clone)]
331pub struct FocusConfig {
332    /// Minimum focus outline width (pixels)
333    pub min_outline_width: f32,
334    /// Minimum contrast for focus indicator
335    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/// Accessibility validator for game testing
348///
349/// Per spec Section 6.3: Validates accessibility compliance
350#[derive(Debug, Clone, Default)]
351pub struct AccessibilityValidator {
352    config: AccessibilityConfig,
353}
354
355impl AccessibilityValidator {
356    /// Create a new validator with default config
357    #[must_use]
358    pub fn new() -> Self {
359        Self {
360            config: AccessibilityConfig::default(),
361        }
362    }
363
364    /// Create a validator with custom config
365    #[must_use]
366    pub const fn with_config(config: AccessibilityConfig) -> Self {
367        Self { config }
368    }
369
370    /// Analyze color contrast
371    ///
372    /// Per spec: `page.analyze_contrast().await?`
373    #[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    /// Check if reduced motion is respected
385    ///
386    /// Per spec: "Check motion preferences"
387    #[must_use]
388    pub fn check_reduced_motion(&self, animations_disabled_when_preferred: bool) -> bool {
389        animations_disabled_when_preferred
390    }
391
392    /// Validate focus indicators
393    ///
394    /// # Errors
395    ///
396    /// Returns error if focus indicator is missing
397    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    /// Run a full accessibility audit
408    #[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        // Contrast analysis
418        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        // Focus indicators
433        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        // Reduced motion
446        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/// Flash detection for photosensitivity protection
463///
464/// Per spec Section 9.3: Protect against seizure-inducing content
465#[derive(Debug, Clone)]
466pub struct FlashDetector {
467    /// Maximum allowed flash rate (Hz)
468    pub max_flash_rate: f32,
469    /// Maximum red flash intensity
470    pub max_red_intensity: f32,
471    /// Maximum flash area (percentage of screen)
472    pub max_flash_area: f32,
473}
474
475impl Default for FlashDetector {
476    fn default() -> Self {
477        Self {
478            max_flash_rate: 3.0, // WCAG 2.3.1: < 3 flashes per second
479            max_red_intensity: 0.8,
480            max_flash_area: 0.25, // 25% of screen max
481        }
482    }
483}
484
485/// Result of flash detection
486#[derive(Debug, Clone)]
487pub struct FlashResult {
488    /// Detected flash rate (Hz)
489    pub flash_rate: f32,
490    /// Whether red flash threshold was exceeded
491    pub red_flash_exceeded: bool,
492    /// Flash area percentage
493    pub flash_area: f32,
494    /// Whether the content is safe
495    pub is_safe: bool,
496    /// Warning message if applicable
497    pub warning: Option<String>,
498}
499
500impl FlashDetector {
501    /// Create a new flash detector with default settings
502    #[must_use]
503    pub fn new() -> Self {
504        Self::default()
505    }
506
507    /// Analyze frame transition for flashing
508    #[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        // Calculate effective flash rate
517        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    // ========================================================================
553    // EXTREME TDD: Tests for Accessibility validation per Section 6.3
554    // ========================================================================
555
556    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            // Should be exactly 21:1
585            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            // Same color = 1:1 ratio
593            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); // 100 - 30
702            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); // 100 - 20 - 10
719        }
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, // has focus
770                true, // respects reduced motion
771            );
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, // no focus
798                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            // 10 flashes per second (1/0.1)
827            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    // =========================================================================
869    // Hâ‚€ EXTREME TDD: Accessibility Tests (Section 6.3 P1)
870    // =========================================================================
871
872    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); // 20 Hz
1284            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}