presentar_test/
a11y.rs

1//! Accessibility checking for WCAG 2.1 compliance.
2//!
3//! Implements comprehensive WCAG 2.1 AA checks including:
4//! - Color contrast (1.4.3, 1.4.6)
5//! - Keyboard accessibility (2.1.1)
6//! - Focus indicators (2.4.7)
7//! - Touch target size (2.5.5)
8//! - Name/role/value (4.1.2)
9//! - Heading hierarchy (1.3.1)
10
11use presentar_core::widget::AccessibleRole;
12use presentar_core::{Color, Widget};
13
14/// Minimum touch target size in pixels (WCAG 2.5.5)
15pub const MIN_TOUCH_TARGET_SIZE: f32 = 44.0;
16
17/// Minimum focus indicator area (WCAG 2.4.11)
18pub const MIN_FOCUS_INDICATOR_AREA: f32 = 2.0;
19
20/// Accessibility checker.
21pub struct A11yChecker;
22
23impl A11yChecker {
24    /// Check a widget tree for accessibility violations.
25    #[must_use]
26    pub fn check(widget: &dyn Widget) -> A11yReport {
27        let mut violations = Vec::new();
28        let mut context = CheckContext::default();
29        Self::check_widget(widget, &mut violations, &mut context);
30        A11yReport { violations }
31    }
32
33    /// Check with custom configuration.
34    #[must_use]
35    pub fn check_with_config(widget: &dyn Widget, config: &A11yConfig) -> A11yReport {
36        let mut violations = Vec::new();
37        let mut context = CheckContext {
38            check_touch_targets: config.check_touch_targets,
39            check_heading_hierarchy: config.check_heading_hierarchy,
40            check_focus_indicators: config.check_focus_indicators,
41            ..Default::default()
42        };
43        Self::check_widget(widget, &mut violations, &mut context);
44        A11yReport { violations }
45    }
46
47    fn check_widget(
48        widget: &dyn Widget,
49        violations: &mut Vec<A11yViolation>,
50        context: &mut CheckContext,
51    ) {
52        // Check for missing accessible name on interactive elements (WCAG 4.1.2)
53        if widget.is_interactive() && widget.accessible_name().is_none() {
54            violations.push(A11yViolation {
55                rule: "aria-label".to_string(),
56                message: "Interactive element missing accessible name".to_string(),
57                wcag: "4.1.2".to_string(),
58                impact: Impact::Critical,
59            });
60        }
61
62        // Check for keyboard focusable elements (WCAG 2.1.1)
63        if widget.is_interactive() && !widget.is_focusable() {
64            violations.push(A11yViolation {
65                rule: "keyboard".to_string(),
66                message: "Interactive element is not keyboard focusable".to_string(),
67                wcag: "2.1.1".to_string(),
68                impact: Impact::Critical,
69            });
70        }
71
72        // Check touch target size (WCAG 2.5.5)
73        if context.check_touch_targets && widget.is_interactive() {
74            let bounds = widget.bounds();
75            if bounds.width < MIN_TOUCH_TARGET_SIZE || bounds.height < MIN_TOUCH_TARGET_SIZE {
76                violations.push(A11yViolation {
77                    rule: "touch-target".to_string(),
78                    message: format!(
79                        "Touch target too small: {}x{} (minimum {}x{})",
80                        bounds.width, bounds.height, MIN_TOUCH_TARGET_SIZE, MIN_TOUCH_TARGET_SIZE
81                    ),
82                    wcag: "2.5.5".to_string(),
83                    impact: Impact::Moderate,
84                });
85            }
86        }
87
88        // Check heading hierarchy (WCAG 1.3.1)
89        if context.check_heading_hierarchy && widget.accessible_role() == AccessibleRole::Heading {
90            if let Some(level) = Self::heading_level(widget) {
91                let last_level = context.last_heading_level;
92                if last_level > 0 && level > last_level + 1 {
93                    violations.push(A11yViolation {
94                        rule: "heading-order".to_string(),
95                        message: format!(
96                            "Heading level skipped: h{} followed by h{} (should be h{} or lower)",
97                            last_level,
98                            level,
99                            last_level + 1
100                        ),
101                        wcag: "1.3.1".to_string(),
102                        impact: Impact::Moderate,
103                    });
104                }
105                context.last_heading_level = level;
106            }
107        }
108
109        // Check focus indicator visibility (WCAG 2.4.7)
110        if context.check_focus_indicators && widget.is_focusable() {
111            if !Self::has_visible_focus_indicator(widget) {
112                violations.push(A11yViolation {
113                    rule: "focus-visible".to_string(),
114                    message: "Focusable element may lack visible focus indicator".to_string(),
115                    wcag: "2.4.7".to_string(),
116                    impact: Impact::Serious,
117                });
118            }
119        }
120
121        // Check for images without alt text (WCAG 1.1.1)
122        if widget.accessible_role() == AccessibleRole::Image && widget.accessible_name().is_none() {
123            violations.push(A11yViolation {
124                rule: "image-alt".to_string(),
125                message: "Image missing alternative text".to_string(),
126                wcag: "1.1.1".to_string(),
127                impact: Impact::Critical,
128            });
129        }
130
131        // Recurse into children
132        for child in widget.children() {
133            Self::check_widget(child.as_ref(), violations, context);
134        }
135    }
136
137    /// Extract heading level from widget (if it's a heading)
138    fn heading_level(widget: &dyn Widget) -> Option<u8> {
139        // Check if the accessible name contains heading level info
140        // Or use aria-level if available
141        if let Some(name) = widget.accessible_name() {
142            // Try to extract from pattern like "Heading Level 2" or "h2"
143            if name.starts_with('h') || name.starts_with('H') {
144                if let Ok(level) = name[1..2].parse::<u8>() {
145                    if (1..=6).contains(&level) {
146                        return Some(level);
147                    }
148                }
149            }
150        }
151        // Default to level 2 if we can't determine
152        Some(2)
153    }
154
155    /// Check if widget has a visible focus indicator
156    fn has_visible_focus_indicator(widget: &dyn Widget) -> bool {
157        // For now, assume all focusable widgets have focus indicators
158        // In a real implementation, we'd check for focus ring styles
159        widget.is_focusable()
160    }
161
162    /// Check contrast ratio between foreground and background colors.
163    #[must_use]
164    pub fn check_contrast(
165        foreground: &Color,
166        background: &Color,
167        large_text: bool,
168    ) -> ContrastResult {
169        let ratio = foreground.contrast_ratio(background);
170
171        // WCAG 2.1 thresholds
172        let (aa_threshold, aaa_threshold) = if large_text {
173            (3.0, 4.5) // Large text (14pt bold or 18pt regular)
174        } else {
175            (4.5, 7.0) // Normal text
176        };
177
178        ContrastResult {
179            ratio,
180            passes_aa: ratio >= aa_threshold,
181            passes_aaa: ratio >= aaa_threshold,
182        }
183    }
184}
185
186/// Accessibility report.
187#[derive(Debug)]
188pub struct A11yReport {
189    /// List of violations found
190    pub violations: Vec<A11yViolation>,
191}
192
193impl A11yReport {
194    /// Check if all accessibility tests passed.
195    #[must_use]
196    pub fn is_passing(&self) -> bool {
197        self.violations.is_empty()
198    }
199
200    /// Get critical violations only.
201    #[must_use]
202    pub fn critical(&self) -> Vec<&A11yViolation> {
203        self.violations
204            .iter()
205            .filter(|v| v.impact == Impact::Critical)
206            .collect()
207    }
208
209    /// Assert that all accessibility tests pass.
210    ///
211    /// # Panics
212    ///
213    /// Panics if there are any violations.
214    pub fn assert_pass(&self) {
215        if !self.is_passing() {
216            let messages: Vec<String> = self
217                .violations
218                .iter()
219                .map(|v| {
220                    format!(
221                        "  [{:?}] {}: {} (WCAG {})",
222                        v.impact, v.rule, v.message, v.wcag
223                    )
224                })
225                .collect();
226
227            panic!(
228                "Accessibility check failed with {} violation(s):\n{}",
229                self.violations.len(),
230                messages.join("\n")
231            );
232        }
233    }
234}
235
236/// A single accessibility violation.
237#[derive(Debug, Clone)]
238pub struct A11yViolation {
239    /// Rule that was violated
240    pub rule: String,
241    /// Human-readable message
242    pub message: String,
243    /// WCAG success criterion
244    pub wcag: String,
245    /// Impact level
246    pub impact: Impact,
247}
248
249/// Impact level of an accessibility violation.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum Impact {
252    /// Minor issue
253    Minor,
254    /// Moderate issue
255    Moderate,
256    /// Serious issue
257    Serious,
258    /// Critical issue - must fix
259    Critical,
260}
261
262/// Configuration for accessibility checks.
263#[derive(Debug, Clone)]
264pub struct A11yConfig {
265    /// Check touch target sizes (WCAG 2.5.5)
266    pub check_touch_targets: bool,
267    /// Check heading hierarchy (WCAG 1.3.1)
268    pub check_heading_hierarchy: bool,
269    /// Check focus indicators (WCAG 2.4.7)
270    pub check_focus_indicators: bool,
271    /// Minimum contrast ratio for normal text (WCAG 1.4.3)
272    pub min_contrast_normal: f32,
273    /// Minimum contrast ratio for large text (WCAG 1.4.3)
274    pub min_contrast_large: f32,
275}
276
277impl Default for A11yConfig {
278    fn default() -> Self {
279        Self {
280            check_touch_targets: true,
281            check_heading_hierarchy: true,
282            check_focus_indicators: false, // Disabled by default as requires style info
283            min_contrast_normal: 4.5,
284            min_contrast_large: 3.0,
285        }
286    }
287}
288
289impl A11yConfig {
290    /// Create a new config with all checks enabled.
291    #[must_use]
292    pub fn strict() -> Self {
293        Self {
294            check_touch_targets: true,
295            check_heading_hierarchy: true,
296            check_focus_indicators: true,
297            min_contrast_normal: 7.0, // AAA level
298            min_contrast_large: 4.5,  // AAA level
299        }
300    }
301
302    /// Create a config for mobile apps.
303    #[must_use]
304    pub fn mobile() -> Self {
305        Self {
306            check_touch_targets: true,
307            check_heading_hierarchy: true,
308            check_focus_indicators: false,
309            min_contrast_normal: 4.5,
310            min_contrast_large: 3.0,
311        }
312    }
313}
314
315/// Internal context for walking the widget tree.
316#[derive(Debug, Default)]
317struct CheckContext {
318    /// Last heading level seen (for hierarchy check)
319    last_heading_level: u8,
320    /// Whether to check touch target sizes
321    check_touch_targets: bool,
322    /// Whether to check heading hierarchy
323    check_heading_hierarchy: bool,
324    /// Whether to check focus indicators
325    check_focus_indicators: bool,
326}
327
328/// Result of a contrast check.
329#[derive(Debug, Clone)]
330pub struct ContrastResult {
331    /// Calculated contrast ratio
332    pub ratio: f32,
333    /// Passes WCAG AA
334    pub passes_aa: bool,
335    /// Passes WCAG AAA
336    pub passes_aaa: bool,
337}
338
339// =============================================================================
340// ARIA Attribute Generation
341// =============================================================================
342
343/// ARIA attributes for a widget.
344#[derive(Debug, Clone, Default)]
345pub struct AriaAttributes {
346    /// The ARIA role
347    pub role: Option<String>,
348    /// Accessible label
349    pub label: Option<String>,
350    /// Accessible description
351    pub described_by: Option<String>,
352    /// Whether element is hidden from accessibility tree
353    pub hidden: bool,
354    /// Whether element is expanded (for expandable elements)
355    pub expanded: Option<bool>,
356    /// Whether element is selected
357    pub selected: Option<bool>,
358    /// Whether element is checked (for checkboxes/switches)
359    pub checked: Option<AriaChecked>,
360    /// Whether element is pressed (for toggle buttons)
361    pub pressed: Option<AriaChecked>,
362    /// Whether element is disabled
363    pub disabled: bool,
364    /// Whether element is required
365    pub required: bool,
366    /// Whether element is invalid
367    pub invalid: bool,
368    /// Current value for range widgets
369    pub value_now: Option<f64>,
370    /// Minimum value for range widgets
371    pub value_min: Option<f64>,
372    /// Maximum value for range widgets
373    pub value_max: Option<f64>,
374    /// Text representation of value
375    pub value_text: Option<String>,
376    /// Level (for headings)
377    pub level: Option<u8>,
378    /// Position in set
379    pub pos_in_set: Option<u32>,
380    /// Set size
381    pub set_size: Option<u32>,
382    /// Controls another element (ID reference)
383    pub controls: Option<String>,
384    /// Has popup indicator
385    pub has_popup: Option<String>,
386    /// Is busy/loading
387    pub busy: bool,
388    /// Live region politeness
389    pub live: Option<AriaLive>,
390    /// Atomic live region
391    pub atomic: bool,
392}
393
394/// ARIA checked state.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum AriaChecked {
397    /// Checked
398    True,
399    /// Not checked
400    False,
401    /// Mixed/indeterminate
402    Mixed,
403}
404
405impl AriaChecked {
406    /// Return string representation.
407    #[must_use]
408    pub const fn as_str(&self) -> &'static str {
409        match self {
410            AriaChecked::True => "true",
411            AriaChecked::False => "false",
412            AriaChecked::Mixed => "mixed",
413        }
414    }
415}
416
417/// ARIA live region politeness.
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum AriaLive {
420    /// Polite announcements
421    Polite,
422    /// Assertive announcements
423    Assertive,
424    /// No announcements
425    Off,
426}
427
428impl AriaLive {
429    /// Return string representation.
430    #[must_use]
431    pub const fn as_str(&self) -> &'static str {
432        match self {
433            AriaLive::Polite => "polite",
434            AriaLive::Assertive => "assertive",
435            AriaLive::Off => "off",
436        }
437    }
438}
439
440impl AriaAttributes {
441    /// Create new empty ARIA attributes.
442    #[must_use]
443    pub fn new() -> Self {
444        Self::default()
445    }
446
447    /// Set the role.
448    #[must_use]
449    pub fn with_role(mut self, role: impl Into<String>) -> Self {
450        self.role = Some(role.into());
451        self
452    }
453
454    /// Set the label.
455    #[must_use]
456    pub fn with_label(mut self, label: impl Into<String>) -> Self {
457        self.label = Some(label.into());
458        self
459    }
460
461    /// Set hidden.
462    #[must_use]
463    pub const fn with_hidden(mut self, hidden: bool) -> Self {
464        self.hidden = hidden;
465        self
466    }
467
468    /// Set expanded state.
469    #[must_use]
470    pub const fn with_expanded(mut self, expanded: bool) -> Self {
471        self.expanded = Some(expanded);
472        self
473    }
474
475    /// Set selected state.
476    #[must_use]
477    pub const fn with_selected(mut self, selected: bool) -> Self {
478        self.selected = Some(selected);
479        self
480    }
481
482    /// Set checked state.
483    #[must_use]
484    pub const fn with_checked(mut self, checked: AriaChecked) -> Self {
485        self.checked = Some(checked);
486        self
487    }
488
489    /// Set pressed state (for toggle buttons).
490    #[must_use]
491    pub const fn with_pressed(mut self, pressed: AriaChecked) -> Self {
492        self.pressed = Some(pressed);
493        self
494    }
495
496    /// Set disabled state.
497    #[must_use]
498    pub const fn with_disabled(mut self, disabled: bool) -> Self {
499        self.disabled = disabled;
500        self
501    }
502
503    /// Set busy state.
504    #[must_use]
505    pub const fn with_busy(mut self, busy: bool) -> Self {
506        self.busy = busy;
507        self
508    }
509
510    /// Set atomic.
511    #[must_use]
512    pub const fn with_atomic(mut self, atomic: bool) -> Self {
513        self.atomic = atomic;
514        self
515    }
516
517    /// Set range values.
518    #[must_use]
519    pub fn with_range(mut self, min: f64, max: f64, now: f64) -> Self {
520        self.value_min = Some(min);
521        self.value_max = Some(max);
522        self.value_now = Some(now);
523        self
524    }
525
526    /// Set current value.
527    #[must_use]
528    pub const fn with_value_now(mut self, value: f64) -> Self {
529        self.value_now = Some(value);
530        self
531    }
532
533    /// Set minimum value.
534    #[must_use]
535    pub const fn with_value_min(mut self, value: f64) -> Self {
536        self.value_min = Some(value);
537        self
538    }
539
540    /// Set maximum value.
541    #[must_use]
542    pub const fn with_value_max(mut self, value: f64) -> Self {
543        self.value_max = Some(value);
544        self
545    }
546
547    /// Set controls reference.
548    #[must_use]
549    pub fn with_controls(mut self, controls: impl Into<String>) -> Self {
550        self.controls = Some(controls.into());
551        self
552    }
553
554    /// Set described by reference.
555    #[must_use]
556    pub fn with_described_by(mut self, described_by: impl Into<String>) -> Self {
557        self.described_by = Some(described_by.into());
558        self
559    }
560
561    /// Set has popup.
562    #[must_use]
563    pub fn with_has_popup(mut self, has_popup: impl Into<String>) -> Self {
564        self.has_popup = Some(has_popup.into());
565        self
566    }
567
568    /// Set heading level.
569    #[must_use]
570    pub const fn with_level(mut self, level: u8) -> Self {
571        self.level = Some(level);
572        self
573    }
574
575    /// Set position in set.
576    #[must_use]
577    pub const fn with_pos_in_set(mut self, pos: u32, size: u32) -> Self {
578        self.pos_in_set = Some(pos);
579        self.set_size = Some(size);
580        self
581    }
582
583    /// Set live region.
584    #[must_use]
585    pub const fn with_live(mut self, live: AriaLive) -> Self {
586        self.live = Some(live);
587        self
588    }
589
590    /// Generate HTML ARIA attributes.
591    #[must_use]
592    pub fn to_html_attrs(&self) -> Vec<(String, String)> {
593        let mut attrs = Vec::new();
594
595        if let Some(ref role) = self.role {
596            attrs.push(("role".to_string(), role.clone()));
597        }
598        if let Some(ref label) = self.label {
599            attrs.push(("aria-label".to_string(), label.clone()));
600        }
601        if let Some(ref desc) = self.described_by {
602            attrs.push(("aria-describedby".to_string(), desc.clone()));
603        }
604        if self.hidden {
605            attrs.push(("aria-hidden".to_string(), "true".to_string()));
606        }
607        if let Some(expanded) = self.expanded {
608            attrs.push(("aria-expanded".to_string(), expanded.to_string()));
609        }
610        if let Some(selected) = self.selected {
611            attrs.push(("aria-selected".to_string(), selected.to_string()));
612        }
613        if let Some(checked) = self.checked {
614            attrs.push(("aria-checked".to_string(), checked.as_str().to_string()));
615        }
616        if let Some(pressed) = self.pressed {
617            attrs.push(("aria-pressed".to_string(), pressed.as_str().to_string()));
618        }
619        if let Some(ref popup) = self.has_popup {
620            attrs.push(("aria-haspopup".to_string(), popup.clone()));
621        }
622        if self.disabled {
623            attrs.push(("aria-disabled".to_string(), "true".to_string()));
624        }
625        if self.required {
626            attrs.push(("aria-required".to_string(), "true".to_string()));
627        }
628        if self.invalid {
629            attrs.push(("aria-invalid".to_string(), "true".to_string()));
630        }
631        if let Some(val) = self.value_now {
632            attrs.push(("aria-valuenow".to_string(), val.to_string()));
633        }
634        if let Some(val) = self.value_min {
635            attrs.push(("aria-valuemin".to_string(), val.to_string()));
636        }
637        if let Some(val) = self.value_max {
638            attrs.push(("aria-valuemax".to_string(), val.to_string()));
639        }
640        if let Some(ref text) = self.value_text {
641            attrs.push(("aria-valuetext".to_string(), text.clone()));
642        }
643        if let Some(level) = self.level {
644            attrs.push(("aria-level".to_string(), level.to_string()));
645        }
646        if let Some(pos) = self.pos_in_set {
647            attrs.push(("aria-posinset".to_string(), pos.to_string()));
648        }
649        if let Some(size) = self.set_size {
650            attrs.push(("aria-setsize".to_string(), size.to_string()));
651        }
652        if let Some(ref controls) = self.controls {
653            attrs.push(("aria-controls".to_string(), controls.clone()));
654        }
655        if self.busy {
656            attrs.push(("aria-busy".to_string(), "true".to_string()));
657        }
658        if let Some(live) = self.live {
659            attrs.push(("aria-live".to_string(), live.as_str().to_string()));
660        }
661        if self.atomic {
662            attrs.push(("aria-atomic".to_string(), "true".to_string()));
663        }
664
665        attrs
666    }
667
668    /// Generate HTML attribute string.
669    #[must_use]
670    pub fn to_html_string(&self) -> String {
671        self.to_html_attrs()
672            .into_iter()
673            .map(|(k, v)| {
674                // Escape HTML special characters in values
675                let escaped = v
676                    .replace('&', "&amp;")
677                    .replace('"', "&quot;")
678                    .replace('<', "&lt;")
679                    .replace('>', "&gt;");
680                format!("{}=\"{}\"", k, escaped)
681            })
682            .collect::<Vec<_>>()
683            .join(" ")
684    }
685}
686
687/// Generate ARIA attributes from a widget.
688pub fn aria_from_widget(widget: &dyn Widget) -> AriaAttributes {
689    use presentar_core::widget::AccessibleRole;
690
691    let mut attrs = AriaAttributes::new();
692
693    // Set role from widget
694    let role = match widget.accessible_role() {
695        AccessibleRole::Generic => None,
696        AccessibleRole::Button => Some("button"),
697        AccessibleRole::Checkbox => Some("checkbox"),
698        AccessibleRole::TextInput => Some("textbox"),
699        AccessibleRole::Link => Some("link"),
700        AccessibleRole::Heading => Some("heading"),
701        AccessibleRole::Image => Some("img"),
702        AccessibleRole::List => Some("list"),
703        AccessibleRole::ListItem => Some("listitem"),
704        AccessibleRole::Table => Some("table"),
705        AccessibleRole::TableRow => Some("row"),
706        AccessibleRole::TableCell => Some("cell"),
707        AccessibleRole::Menu => Some("menu"),
708        AccessibleRole::MenuItem => Some("menuitem"),
709        AccessibleRole::ComboBox => Some("combobox"),
710        AccessibleRole::Slider => Some("slider"),
711        AccessibleRole::ProgressBar => Some("progressbar"),
712        AccessibleRole::Tab => Some("tab"),
713        AccessibleRole::TabPanel => Some("tabpanel"),
714        AccessibleRole::RadioGroup => Some("radiogroup"),
715        AccessibleRole::Radio => Some("radio"),
716    };
717
718    if let Some(role) = role {
719        attrs.role = Some(role.to_string());
720    }
721
722    // Set label from widget
723    if let Some(name) = widget.accessible_name() {
724        attrs.label = Some(name.to_string());
725    }
726
727    // Set disabled if not interactive but has a focusable role
728    if !widget.is_interactive() && widget.accessible_role() != AccessibleRole::Generic {
729        attrs.disabled = true;
730    }
731
732    attrs
733}
734
735// =============================================================================
736// Form Accessibility Validation (WCAG 1.3.1, 1.3.5, 3.3.1, 3.3.2, 4.1.2)
737// =============================================================================
738
739/// Form accessibility checker for validating form-specific WCAG requirements.
740pub struct FormA11yChecker;
741
742impl FormA11yChecker {
743    /// Check a form for accessibility violations.
744    ///
745    /// Validates:
746    /// - Label associations (WCAG 1.3.1, 2.4.6)
747    /// - Required field indicators (WCAG 3.3.2)
748    /// - Error messaging (WCAG 3.3.1)
749    /// - Input purpose/autocomplete (WCAG 1.3.5)
750    /// - Form grouping (WCAG 1.3.1)
751    #[must_use]
752    pub fn check(form: &FormAccessibility) -> FormA11yReport {
753        let mut violations = Vec::new();
754
755        // Check all fields
756        for field in &form.fields {
757            Self::check_field(field, &mut violations);
758        }
759
760        // Check form-level requirements
761        Self::check_form_level(form, &mut violations);
762
763        FormA11yReport { violations }
764    }
765
766    /// Check a single form field.
767    fn check_field(field: &FormFieldA11y, violations: &mut Vec<FormViolation>) {
768        // WCAG 1.3.1, 2.4.6: Label association
769        if field.label.is_none() && field.aria_label.is_none() && field.aria_labelledby.is_none() {
770            violations.push(FormViolation {
771                field_id: field.id.clone(),
772                rule: FormA11yRule::MissingLabel,
773                message: format!("Field '{}' has no associated label", field.id),
774                wcag: "1.3.1, 2.4.6".to_string(),
775                impact: Impact::Critical,
776            });
777        }
778
779        // WCAG 3.3.2: Required field indicators
780        if field.required {
781            if !field.aria_required {
782                violations.push(FormViolation {
783                    field_id: field.id.clone(),
784                    rule: FormA11yRule::MissingRequiredIndicator,
785                    message: format!(
786                        "Required field '{}' does not have aria-required=\"true\"",
787                        field.id
788                    ),
789                    wcag: "3.3.2".to_string(),
790                    impact: Impact::Serious,
791                });
792            }
793            if !field.has_visual_required_indicator {
794                violations.push(FormViolation {
795                    field_id: field.id.clone(),
796                    rule: FormA11yRule::MissingVisualRequired,
797                    message: format!(
798                        "Required field '{}' lacks visual required indicator (asterisk or text)",
799                        field.id
800                    ),
801                    wcag: "3.3.2".to_string(),
802                    impact: Impact::Moderate,
803                });
804            }
805        }
806
807        // WCAG 3.3.1: Error identification
808        if field.has_error {
809            if !field.aria_invalid {
810                violations.push(FormViolation {
811                    field_id: field.id.clone(),
812                    rule: FormA11yRule::MissingErrorState,
813                    message: format!(
814                        "Field '{}' in error state does not have aria-invalid=\"true\"",
815                        field.id
816                    ),
817                    wcag: "3.3.1".to_string(),
818                    impact: Impact::Serious,
819                });
820            }
821            if field.error_message.is_none() {
822                violations.push(FormViolation {
823                    field_id: field.id.clone(),
824                    rule: FormA11yRule::MissingErrorMessage,
825                    message: format!("Field '{}' in error state has no error message", field.id),
826                    wcag: "3.3.1".to_string(),
827                    impact: Impact::Serious,
828                });
829            }
830            if field.aria_describedby.is_none() && field.aria_errormessage.is_none() {
831                violations.push(FormViolation {
832                    field_id: field.id.clone(),
833                    rule: FormA11yRule::ErrorNotAssociated,
834                    message: format!(
835                        "Error message for '{}' not associated via aria-describedby or aria-errormessage",
836                        field.id
837                    ),
838                    wcag: "3.3.1".to_string(),
839                    impact: Impact::Serious,
840                });
841            }
842        }
843
844        // WCAG 1.3.5: Input purpose (autocomplete)
845        if let Some(ref input_type) = field.input_type {
846            if input_type.should_have_autocomplete() && field.autocomplete.is_none() {
847                violations.push(FormViolation {
848                    field_id: field.id.clone(),
849                    rule: FormA11yRule::MissingAutocomplete,
850                    message: format!(
851                        "Field '{}' of type {:?} should have autocomplete attribute for autofill",
852                        field.id, input_type
853                    ),
854                    wcag: "1.3.5".to_string(),
855                    impact: Impact::Moderate,
856                });
857            }
858        }
859
860        // Check for placeholder-only labeling (anti-pattern)
861        if field.placeholder.is_some() && field.label.is_none() && field.aria_label.is_none() {
862            violations.push(FormViolation {
863                field_id: field.id.clone(),
864                rule: FormA11yRule::PlaceholderAsLabel,
865                message: format!(
866                    "Field '{}' uses placeholder as sole label; placeholders disappear on input",
867                    field.id
868                ),
869                wcag: "3.3.2".to_string(),
870                impact: Impact::Serious,
871            });
872        }
873    }
874
875    /// Check form-level accessibility requirements.
876    fn check_form_level(form: &FormAccessibility, violations: &mut Vec<FormViolation>) {
877        // Check for related fields that should be grouped (WCAG 1.3.1)
878        let radio_fields: Vec<_> = form
879            .fields
880            .iter()
881            .filter(|f| f.input_type == Some(InputType::Radio))
882            .collect();
883
884        if radio_fields.len() > 1 {
885            // Radio buttons should be in a fieldset/group
886            let has_group = form.field_groups.iter().any(|g| {
887                g.field_ids
888                    .iter()
889                    .any(|id| radio_fields.iter().any(|f| &f.id == id))
890            });
891
892            if !has_group {
893                violations.push(FormViolation {
894                    field_id: "form".to_string(),
895                    rule: FormA11yRule::RelatedFieldsNotGrouped,
896                    message: "Related radio buttons should be grouped in a fieldset with legend"
897                        .to_string(),
898                    wcag: "1.3.1".to_string(),
899                    impact: Impact::Moderate,
900                });
901            }
902        }
903
904        // Check field groups have legends
905        for group in &form.field_groups {
906            if group.legend.is_none() && group.aria_label.is_none() {
907                violations.push(FormViolation {
908                    field_id: group.id.clone(),
909                    rule: FormA11yRule::GroupMissingLegend,
910                    message: format!("Field group '{}' has no legend or aria-label", group.id),
911                    wcag: "1.3.1".to_string(),
912                    impact: Impact::Serious,
913                });
914            }
915        }
916
917        // Check form has accessible name
918        if form.accessible_name.is_none() && form.aria_labelledby.is_none() {
919            violations.push(FormViolation {
920                field_id: "form".to_string(),
921                rule: FormA11yRule::FormMissingName,
922                message: "Form should have an accessible name (aria-label or aria-labelledby)"
923                    .to_string(),
924                wcag: "4.1.2".to_string(),
925                impact: Impact::Moderate,
926            });
927        }
928    }
929}
930
931/// Form accessibility data for validation.
932#[derive(Debug, Clone, Default)]
933pub struct FormAccessibility {
934    /// Form's accessible name
935    pub accessible_name: Option<String>,
936    /// Referenced labelledby ID
937    pub aria_labelledby: Option<String>,
938    /// Form fields
939    pub fields: Vec<FormFieldA11y>,
940    /// Field groups (fieldsets)
941    pub field_groups: Vec<FormFieldGroup>,
942}
943
944impl FormAccessibility {
945    /// Create a new form accessibility descriptor.
946    #[must_use]
947    pub fn new() -> Self {
948        Self::default()
949    }
950
951    /// Add a field.
952    #[must_use]
953    pub fn field(mut self, field: FormFieldA11y) -> Self {
954        self.fields.push(field);
955        self
956    }
957
958    /// Add a field group.
959    #[must_use]
960    pub fn group(mut self, group: FormFieldGroup) -> Self {
961        self.field_groups.push(group);
962        self
963    }
964
965    /// Set accessible name.
966    #[must_use]
967    pub fn with_name(mut self, name: impl Into<String>) -> Self {
968        self.accessible_name = Some(name.into());
969        self
970    }
971}
972
973/// Form field accessibility descriptor.
974#[derive(Debug, Clone, Default)]
975pub struct FormFieldA11y {
976    /// Field ID
977    pub id: String,
978    /// Associated label text
979    pub label: Option<String>,
980    /// Input type
981    pub input_type: Option<InputType>,
982    /// Is required
983    pub required: bool,
984    /// Has visual required indicator
985    pub has_visual_required_indicator: bool,
986    /// aria-required attribute
987    pub aria_required: bool,
988    /// aria-label attribute
989    pub aria_label: Option<String>,
990    /// aria-labelledby attribute
991    pub aria_labelledby: Option<String>,
992    /// aria-describedby attribute
993    pub aria_describedby: Option<String>,
994    /// Has error state
995    pub has_error: bool,
996    /// aria-invalid attribute
997    pub aria_invalid: bool,
998    /// aria-errormessage attribute
999    pub aria_errormessage: Option<String>,
1000    /// Error message text
1001    pub error_message: Option<String>,
1002    /// Autocomplete attribute
1003    pub autocomplete: Option<AutocompleteValue>,
1004    /// Placeholder text
1005    pub placeholder: Option<String>,
1006}
1007
1008impl FormFieldA11y {
1009    /// Create a new field.
1010    #[must_use]
1011    pub fn new(id: impl Into<String>) -> Self {
1012        Self {
1013            id: id.into(),
1014            ..Default::default()
1015        }
1016    }
1017
1018    /// Set label.
1019    #[must_use]
1020    pub fn with_label(mut self, label: impl Into<String>) -> Self {
1021        self.label = Some(label.into());
1022        self
1023    }
1024
1025    /// Set input type.
1026    #[must_use]
1027    pub fn with_type(mut self, input_type: InputType) -> Self {
1028        self.input_type = Some(input_type);
1029        self
1030    }
1031
1032    /// Mark as required.
1033    #[must_use]
1034    pub fn required(mut self) -> Self {
1035        self.required = true;
1036        self.aria_required = true;
1037        self.has_visual_required_indicator = true;
1038        self
1039    }
1040
1041    /// Set required with specific options.
1042    #[must_use]
1043    pub fn with_required(mut self, visual: bool, aria: bool) -> Self {
1044        self.required = true;
1045        self.has_visual_required_indicator = visual;
1046        self.aria_required = aria;
1047        self
1048    }
1049
1050    /// Set error state.
1051    #[must_use]
1052    pub fn with_error(mut self, message: impl Into<String>, associated: bool) -> Self {
1053        self.has_error = true;
1054        self.aria_invalid = true;
1055        self.error_message = Some(message.into());
1056        if associated {
1057            self.aria_describedby = Some(format!("{}-error", self.id));
1058        }
1059        self
1060    }
1061
1062    /// Set autocomplete.
1063    #[must_use]
1064    pub fn with_autocomplete(mut self, value: AutocompleteValue) -> Self {
1065        self.autocomplete = Some(value);
1066        self
1067    }
1068
1069    /// Set placeholder.
1070    #[must_use]
1071    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
1072        self.placeholder = Some(placeholder.into());
1073        self
1074    }
1075
1076    /// Set aria-label.
1077    #[must_use]
1078    pub fn with_aria_label(mut self, label: impl Into<String>) -> Self {
1079        self.aria_label = Some(label.into());
1080        self
1081    }
1082}
1083
1084/// Form field group (fieldset).
1085#[derive(Debug, Clone, Default)]
1086pub struct FormFieldGroup {
1087    /// Group ID
1088    pub id: String,
1089    /// Legend text
1090    pub legend: Option<String>,
1091    /// aria-label (alternative to legend)
1092    pub aria_label: Option<String>,
1093    /// Field IDs in this group
1094    pub field_ids: Vec<String>,
1095}
1096
1097impl FormFieldGroup {
1098    /// Create a new field group.
1099    #[must_use]
1100    pub fn new(id: impl Into<String>) -> Self {
1101        Self {
1102            id: id.into(),
1103            ..Default::default()
1104        }
1105    }
1106
1107    /// Set legend.
1108    #[must_use]
1109    pub fn with_legend(mut self, legend: impl Into<String>) -> Self {
1110        self.legend = Some(legend.into());
1111        self
1112    }
1113
1114    /// Add field ID to group.
1115    #[must_use]
1116    pub fn with_field(mut self, field_id: impl Into<String>) -> Self {
1117        self.field_ids.push(field_id.into());
1118        self
1119    }
1120}
1121
1122/// Input type for form fields.
1123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1124pub enum InputType {
1125    Text,
1126    Email,
1127    Password,
1128    Tel,
1129    Url,
1130    Number,
1131    Date,
1132    Time,
1133    Search,
1134    Radio,
1135    Checkbox,
1136    Select,
1137    Textarea,
1138    Hidden,
1139}
1140
1141impl InputType {
1142    /// Check if this input type should have autocomplete for WCAG 1.3.5.
1143    #[must_use]
1144    pub const fn should_have_autocomplete(&self) -> bool {
1145        matches!(
1146            self,
1147            Self::Text | Self::Email | Self::Password | Self::Tel | Self::Url | Self::Number
1148        )
1149    }
1150}
1151
1152/// Autocomplete attribute values (WCAG 1.3.5).
1153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1154pub enum AutocompleteValue {
1155    /// User's name
1156    Name,
1157    /// Given (first) name
1158    GivenName,
1159    /// Family (last) name
1160    FamilyName,
1161    /// Email address
1162    Email,
1163    /// Telephone number
1164    Tel,
1165    /// Street address
1166    StreetAddress,
1167    /// Address level 1 (city)
1168    AddressLevel1,
1169    /// Address level 2 (state/province)
1170    AddressLevel2,
1171    /// Postal code
1172    PostalCode,
1173    /// Country name
1174    Country,
1175    /// Organization
1176    Organization,
1177    /// Username
1178    Username,
1179    /// Current password
1180    CurrentPassword,
1181    /// New password
1182    NewPassword,
1183    /// Credit card number
1184    CcNumber,
1185    /// Credit card expiration
1186    CcExp,
1187    /// Credit card CVV
1188    CcCsc,
1189    /// One-time code
1190    OneTimeCode,
1191    /// Turn off autocomplete
1192    Off,
1193}
1194
1195impl AutocompleteValue {
1196    /// Get the HTML attribute value.
1197    #[must_use]
1198    pub const fn as_str(&self) -> &'static str {
1199        match self {
1200            Self::Name => "name",
1201            Self::GivenName => "given-name",
1202            Self::FamilyName => "family-name",
1203            Self::Email => "email",
1204            Self::Tel => "tel",
1205            Self::StreetAddress => "street-address",
1206            Self::AddressLevel1 => "address-level1",
1207            Self::AddressLevel2 => "address-level2",
1208            Self::PostalCode => "postal-code",
1209            Self::Country => "country",
1210            Self::Organization => "organization",
1211            Self::Username => "username",
1212            Self::CurrentPassword => "current-password",
1213            Self::NewPassword => "new-password",
1214            Self::CcNumber => "cc-number",
1215            Self::CcExp => "cc-exp",
1216            Self::CcCsc => "cc-csc",
1217            Self::OneTimeCode => "one-time-code",
1218            Self::Off => "off",
1219        }
1220    }
1221}
1222
1223/// Form accessibility violation rule.
1224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1225pub enum FormA11yRule {
1226    /// Field missing label (1.3.1, 2.4.6)
1227    MissingLabel,
1228    /// Required field missing aria-required (3.3.2)
1229    MissingRequiredIndicator,
1230    /// Required field missing visual indicator (3.3.2)
1231    MissingVisualRequired,
1232    /// Error field missing aria-invalid (3.3.1)
1233    MissingErrorState,
1234    /// Error field missing error message (3.3.1)
1235    MissingErrorMessage,
1236    /// Error message not associated (3.3.1)
1237    ErrorNotAssociated,
1238    /// Field should have autocomplete (1.3.5)
1239    MissingAutocomplete,
1240    /// Placeholder used as sole label (3.3.2)
1241    PlaceholderAsLabel,
1242    /// Related fields not grouped (1.3.1)
1243    RelatedFieldsNotGrouped,
1244    /// Field group missing legend (1.3.1)
1245    GroupMissingLegend,
1246    /// Form missing accessible name (4.1.2)
1247    FormMissingName,
1248}
1249
1250impl FormA11yRule {
1251    /// Get the rule name.
1252    #[must_use]
1253    pub const fn name(&self) -> &'static str {
1254        match self {
1255            Self::MissingLabel => "missing-label",
1256            Self::MissingRequiredIndicator => "missing-required-indicator",
1257            Self::MissingVisualRequired => "missing-visual-required",
1258            Self::MissingErrorState => "missing-error-state",
1259            Self::MissingErrorMessage => "missing-error-message",
1260            Self::ErrorNotAssociated => "error-not-associated",
1261            Self::MissingAutocomplete => "missing-autocomplete",
1262            Self::PlaceholderAsLabel => "placeholder-as-label",
1263            Self::RelatedFieldsNotGrouped => "related-fields-not-grouped",
1264            Self::GroupMissingLegend => "group-missing-legend",
1265            Self::FormMissingName => "form-missing-name",
1266        }
1267    }
1268}
1269
1270/// Form accessibility violation.
1271#[derive(Debug, Clone)]
1272pub struct FormViolation {
1273    /// Field ID where violation occurred
1274    pub field_id: String,
1275    /// Rule that was violated
1276    pub rule: FormA11yRule,
1277    /// Human-readable message
1278    pub message: String,
1279    /// WCAG success criterion
1280    pub wcag: String,
1281    /// Impact level
1282    pub impact: Impact,
1283}
1284
1285/// Form accessibility report.
1286#[derive(Debug)]
1287pub struct FormA11yReport {
1288    /// List of violations
1289    pub violations: Vec<FormViolation>,
1290}
1291
1292impl FormA11yReport {
1293    /// Check if form passes accessibility.
1294    #[must_use]
1295    pub fn is_passing(&self) -> bool {
1296        self.violations.is_empty()
1297    }
1298
1299    /// Check if form passes with no critical/serious issues.
1300    #[must_use]
1301    pub fn is_acceptable(&self) -> bool {
1302        !self
1303            .violations
1304            .iter()
1305            .any(|v| matches!(v.impact, Impact::Critical | Impact::Serious))
1306    }
1307
1308    /// Get violations by rule.
1309    #[must_use]
1310    pub fn violations_for_rule(&self, rule: FormA11yRule) -> Vec<&FormViolation> {
1311        self.violations.iter().filter(|v| v.rule == rule).collect()
1312    }
1313
1314    /// Get violations for a specific field.
1315    #[must_use]
1316    pub fn violations_for_field(&self, field_id: &str) -> Vec<&FormViolation> {
1317        self.violations
1318            .iter()
1319            .filter(|v| v.field_id == field_id)
1320            .collect()
1321    }
1322
1323    /// Assert form passes accessibility.
1324    ///
1325    /// # Panics
1326    ///
1327    /// Panics with violation details if form fails accessibility checks.
1328    pub fn assert_pass(&self) {
1329        if !self.is_passing() {
1330            let messages: Vec<String> = self
1331                .violations
1332                .iter()
1333                .map(|v| {
1334                    format!(
1335                        "  [{:?}] {} ({}): {} (WCAG {})",
1336                        v.impact,
1337                        v.rule.name(),
1338                        v.field_id,
1339                        v.message,
1340                        v.wcag
1341                    )
1342                })
1343                .collect();
1344
1345            panic!(
1346                "Form accessibility check failed with {} violation(s):\n{}",
1347                self.violations.len(),
1348                messages.join("\n")
1349            );
1350        }
1351    }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357    use presentar_core::{
1358        widget::{AccessibleRole, LayoutResult},
1359        Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Constraints, Event, Rect,
1360        Size, TypeId,
1361    };
1362    use std::any::Any;
1363    use std::time::Duration;
1364
1365    // Mock interactive widget
1366    struct MockButton {
1367        accessible_name: Option<String>,
1368        focusable: bool,
1369    }
1370
1371    impl MockButton {
1372        fn new() -> Self {
1373            Self {
1374                accessible_name: None,
1375                focusable: true,
1376            }
1377        }
1378
1379        fn with_name(mut self, name: &str) -> Self {
1380            self.accessible_name = Some(name.to_string());
1381            self
1382        }
1383
1384        fn not_focusable(mut self) -> Self {
1385            self.focusable = false;
1386            self
1387        }
1388    }
1389
1390    impl Brick for MockButton {
1391        fn brick_name(&self) -> &'static str {
1392            "MockButton"
1393        }
1394
1395        fn assertions(&self) -> &[BrickAssertion] {
1396            &[]
1397        }
1398
1399        fn budget(&self) -> BrickBudget {
1400            BrickBudget::uniform(16)
1401        }
1402
1403        fn verify(&self) -> BrickVerification {
1404            BrickVerification {
1405                passed: vec![],
1406                failed: vec![],
1407                verification_time: Duration::from_micros(1),
1408            }
1409        }
1410
1411        fn to_html(&self) -> String {
1412            String::new()
1413        }
1414
1415        fn to_css(&self) -> String {
1416            String::new()
1417        }
1418    }
1419
1420    impl Widget for MockButton {
1421        fn type_id(&self) -> TypeId {
1422            TypeId::of::<Self>()
1423        }
1424        fn measure(&self, c: Constraints) -> Size {
1425            c.smallest()
1426        }
1427        fn layout(&mut self, b: Rect) -> LayoutResult {
1428            LayoutResult { size: b.size() }
1429        }
1430        fn paint(&self, _: &mut dyn Canvas) {}
1431        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1432            None
1433        }
1434        fn children(&self) -> &[Box<dyn Widget>] {
1435            &[]
1436        }
1437        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1438            &mut []
1439        }
1440        fn is_interactive(&self) -> bool {
1441            true
1442        }
1443        fn is_focusable(&self) -> bool {
1444            self.focusable
1445        }
1446        fn accessible_name(&self) -> Option<&str> {
1447            self.accessible_name.as_deref()
1448        }
1449        fn accessible_role(&self) -> AccessibleRole {
1450            AccessibleRole::Button
1451        }
1452    }
1453
1454    #[test]
1455    fn test_a11y_passing() {
1456        let widget = MockButton::new().with_name("Submit");
1457        let report = A11yChecker::check(&widget);
1458        assert!(report.is_passing());
1459    }
1460
1461    #[test]
1462    fn test_a11y_missing_name() {
1463        let widget = MockButton::new();
1464        let report = A11yChecker::check(&widget);
1465        assert!(!report.is_passing());
1466        assert_eq!(report.violations.len(), 1);
1467        assert_eq!(report.violations[0].rule, "aria-label");
1468    }
1469
1470    #[test]
1471    fn test_a11y_not_focusable() {
1472        let widget = MockButton::new().with_name("OK").not_focusable();
1473        let report = A11yChecker::check(&widget);
1474        assert!(!report.is_passing());
1475        assert!(report.violations.iter().any(|v| v.rule == "keyboard"));
1476    }
1477
1478    #[test]
1479    fn test_contrast_black_white() {
1480        let result = A11yChecker::check_contrast(&Color::BLACK, &Color::WHITE, false);
1481        assert!(result.passes_aa);
1482        assert!(result.passes_aaa);
1483        assert!((result.ratio - 21.0).abs() < 0.5);
1484    }
1485
1486    #[test]
1487    fn test_contrast_low() {
1488        let light_gray = Color::rgb(0.7, 0.7, 0.7);
1489        let white = Color::WHITE;
1490        let result = A11yChecker::check_contrast(&light_gray, &white, false);
1491        assert!(!result.passes_aa);
1492    }
1493
1494    #[test]
1495    fn test_contrast_large_text_threshold() {
1496        // Gray that passes AA for large text but not for normal text
1497        let gray = Color::rgb(0.5, 0.5, 0.5);
1498        let white = Color::WHITE;
1499
1500        let normal = A11yChecker::check_contrast(&gray, &white, false);
1501        let large = A11yChecker::check_contrast(&gray, &white, true);
1502
1503        // Large text has lower threshold, should pass more easily
1504        assert!(large.passes_aa || large.ratio > normal.ratio - 1.0);
1505    }
1506
1507    #[test]
1508    fn test_report_critical() {
1509        let widget = MockButton::new().not_focusable();
1510        let report = A11yChecker::check(&widget);
1511        let critical = report.critical();
1512        assert!(!critical.is_empty());
1513    }
1514
1515    #[test]
1516    #[should_panic(expected = "Accessibility check failed")]
1517    fn test_assert_pass_fails() {
1518        let widget = MockButton::new();
1519        let report = A11yChecker::check(&widget);
1520        report.assert_pass();
1521    }
1522
1523    // =============================================================================
1524    // AriaAttributes tests
1525    // =============================================================================
1526
1527    #[test]
1528    fn test_aria_attributes_new() {
1529        let attrs = AriaAttributes::new();
1530        assert!(attrs.role.is_none());
1531        assert!(attrs.label.is_none());
1532        assert!(!attrs.disabled);
1533    }
1534
1535    #[test]
1536    fn test_aria_attributes_with_role() {
1537        let attrs = AriaAttributes::new().with_role("button");
1538        assert_eq!(attrs.role, Some("button".to_string()));
1539    }
1540
1541    #[test]
1542    fn test_aria_attributes_with_label() {
1543        let attrs = AriaAttributes::new().with_label("Submit form");
1544        assert_eq!(attrs.label, Some("Submit form".to_string()));
1545    }
1546
1547    #[test]
1548    fn test_aria_attributes_with_expanded() {
1549        let attrs = AriaAttributes::new().with_expanded(true);
1550        assert_eq!(attrs.expanded, Some(true));
1551    }
1552
1553    #[test]
1554    fn test_aria_attributes_with_checked() {
1555        let attrs = AriaAttributes::new().with_checked(AriaChecked::True);
1556        assert_eq!(attrs.checked, Some(AriaChecked::True));
1557    }
1558
1559    #[test]
1560    fn test_aria_attributes_with_disabled() {
1561        let attrs = AriaAttributes::new().with_disabled(true);
1562        assert!(attrs.disabled);
1563    }
1564
1565    #[test]
1566    fn test_aria_attributes_with_value() {
1567        let attrs = AriaAttributes::new()
1568            .with_value_min(0.0)
1569            .with_value_max(100.0)
1570            .with_value_now(50.0);
1571        assert_eq!(attrs.value_min, Some(0.0));
1572        assert_eq!(attrs.value_max, Some(100.0));
1573        assert_eq!(attrs.value_now, Some(50.0));
1574    }
1575
1576    #[test]
1577    fn test_aria_attributes_with_live() {
1578        let attrs = AriaAttributes::new().with_live(AriaLive::Polite);
1579        assert_eq!(attrs.live, Some(AriaLive::Polite));
1580    }
1581
1582    #[test]
1583    fn test_aria_attributes_with_busy() {
1584        let attrs = AriaAttributes::new().with_busy(true);
1585        assert!(attrs.busy);
1586    }
1587
1588    #[test]
1589    fn test_aria_attributes_with_atomic() {
1590        let attrs = AriaAttributes::new().with_atomic(true);
1591        assert!(attrs.atomic);
1592    }
1593
1594    #[test]
1595    fn test_aria_attributes_with_has_popup() {
1596        let attrs = AriaAttributes::new().with_has_popup("menu");
1597        assert_eq!(attrs.has_popup, Some("menu".to_string()));
1598    }
1599
1600    #[test]
1601    fn test_aria_attributes_with_controls() {
1602        let attrs = AriaAttributes::new().with_controls("panel-1");
1603        assert_eq!(attrs.controls, Some("panel-1".to_string()));
1604    }
1605
1606    #[test]
1607    fn test_aria_attributes_with_described_by() {
1608        let attrs = AriaAttributes::new().with_described_by("desc-1");
1609        assert_eq!(attrs.described_by, Some("desc-1".to_string()));
1610    }
1611
1612    #[test]
1613    fn test_aria_attributes_with_hidden() {
1614        let attrs = AriaAttributes::new().with_hidden(true);
1615        assert!(attrs.hidden);
1616    }
1617
1618    #[test]
1619    fn test_aria_attributes_with_pressed() {
1620        let attrs = AriaAttributes::new().with_pressed(AriaChecked::Mixed);
1621        assert_eq!(attrs.pressed, Some(AriaChecked::Mixed));
1622    }
1623
1624    #[test]
1625    fn test_aria_attributes_with_selected() {
1626        let attrs = AriaAttributes::new().with_selected(true);
1627        assert_eq!(attrs.selected, Some(true));
1628    }
1629
1630    #[test]
1631    fn test_aria_attributes_with_level() {
1632        let attrs = AriaAttributes::new().with_level(2);
1633        assert_eq!(attrs.level, Some(2));
1634    }
1635
1636    #[test]
1637    fn test_aria_attributes_chained_builder() {
1638        let attrs = AriaAttributes::new()
1639            .with_role("checkbox")
1640            .with_label("Accept terms")
1641            .with_checked(AriaChecked::False)
1642            .with_disabled(false);
1643
1644        assert_eq!(attrs.role, Some("checkbox".to_string()));
1645        assert_eq!(attrs.label, Some("Accept terms".to_string()));
1646        assert_eq!(attrs.checked, Some(AriaChecked::False));
1647        assert!(!attrs.disabled);
1648    }
1649
1650    // =============================================================================
1651    // AriaChecked tests
1652    // =============================================================================
1653
1654    #[test]
1655    fn test_aria_checked_as_str() {
1656        assert_eq!(AriaChecked::True.as_str(), "true");
1657        assert_eq!(AriaChecked::False.as_str(), "false");
1658        assert_eq!(AriaChecked::Mixed.as_str(), "mixed");
1659    }
1660
1661    // =============================================================================
1662    // AriaLive tests
1663    // =============================================================================
1664
1665    #[test]
1666    fn test_aria_live_as_str() {
1667        assert_eq!(AriaLive::Off.as_str(), "off");
1668        assert_eq!(AriaLive::Polite.as_str(), "polite");
1669        assert_eq!(AriaLive::Assertive.as_str(), "assertive");
1670    }
1671
1672    // =============================================================================
1673    // to_html_attrs tests
1674    // =============================================================================
1675
1676    #[test]
1677    fn test_to_html_attrs_empty() {
1678        let attrs = AriaAttributes::new();
1679        let html_attrs = attrs.to_html_attrs();
1680        assert!(html_attrs.is_empty());
1681    }
1682
1683    #[test]
1684    fn test_to_html_attrs_role() {
1685        let attrs = AriaAttributes::new().with_role("button");
1686        let html_attrs = attrs.to_html_attrs();
1687        assert_eq!(html_attrs.len(), 1);
1688        assert_eq!(html_attrs[0], ("role".to_string(), "button".to_string()));
1689    }
1690
1691    #[test]
1692    fn test_to_html_attrs_label() {
1693        let attrs = AriaAttributes::new().with_label("Submit");
1694        let html_attrs = attrs.to_html_attrs();
1695        assert_eq!(html_attrs.len(), 1);
1696        assert_eq!(
1697            html_attrs[0],
1698            ("aria-label".to_string(), "Submit".to_string())
1699        );
1700    }
1701
1702    #[test]
1703    fn test_to_html_attrs_disabled() {
1704        let attrs = AriaAttributes::new().with_disabled(true);
1705        let html_attrs = attrs.to_html_attrs();
1706        assert_eq!(html_attrs.len(), 1);
1707        assert_eq!(
1708            html_attrs[0],
1709            ("aria-disabled".to_string(), "true".to_string())
1710        );
1711    }
1712
1713    #[test]
1714    fn test_to_html_attrs_checked() {
1715        let attrs = AriaAttributes::new().with_checked(AriaChecked::Mixed);
1716        let html_attrs = attrs.to_html_attrs();
1717        assert_eq!(html_attrs.len(), 1);
1718        assert_eq!(
1719            html_attrs[0],
1720            ("aria-checked".to_string(), "mixed".to_string())
1721        );
1722    }
1723
1724    #[test]
1725    fn test_to_html_attrs_expanded() {
1726        let attrs = AriaAttributes::new().with_expanded(false);
1727        let html_attrs = attrs.to_html_attrs();
1728        assert_eq!(html_attrs.len(), 1);
1729        assert_eq!(
1730            html_attrs[0],
1731            ("aria-expanded".to_string(), "false".to_string())
1732        );
1733    }
1734
1735    #[test]
1736    fn test_to_html_attrs_value_range() {
1737        let attrs = AriaAttributes::new()
1738            .with_value_now(50.0)
1739            .with_value_min(0.0)
1740            .with_value_max(100.0);
1741        let html_attrs = attrs.to_html_attrs();
1742        assert_eq!(html_attrs.len(), 3);
1743        assert!(html_attrs.contains(&("aria-valuenow".to_string(), "50".to_string())));
1744        assert!(html_attrs.contains(&("aria-valuemin".to_string(), "0".to_string())));
1745        assert!(html_attrs.contains(&("aria-valuemax".to_string(), "100".to_string())));
1746    }
1747
1748    #[test]
1749    fn test_to_html_attrs_live() {
1750        let attrs = AriaAttributes::new().with_live(AriaLive::Assertive);
1751        let html_attrs = attrs.to_html_attrs();
1752        assert_eq!(html_attrs.len(), 1);
1753        assert_eq!(
1754            html_attrs[0],
1755            ("aria-live".to_string(), "assertive".to_string())
1756        );
1757    }
1758
1759    #[test]
1760    fn test_to_html_attrs_hidden() {
1761        let attrs = AriaAttributes::new().with_hidden(true);
1762        let html_attrs = attrs.to_html_attrs();
1763        assert_eq!(html_attrs.len(), 1);
1764        assert_eq!(
1765            html_attrs[0],
1766            ("aria-hidden".to_string(), "true".to_string())
1767        );
1768    }
1769
1770    #[test]
1771    fn test_to_html_attrs_multiple() {
1772        let attrs = AriaAttributes::new()
1773            .with_role("slider")
1774            .with_label("Volume")
1775            .with_value_now(75.0)
1776            .with_value_min(0.0)
1777            .with_value_max(100.0);
1778        let html_attrs = attrs.to_html_attrs();
1779        assert_eq!(html_attrs.len(), 5);
1780    }
1781
1782    // =============================================================================
1783    // to_html_string tests
1784    // =============================================================================
1785
1786    #[test]
1787    fn test_to_html_string_empty() {
1788        let attrs = AriaAttributes::new();
1789        let html = attrs.to_html_string();
1790        assert_eq!(html, "");
1791    }
1792
1793    #[test]
1794    fn test_to_html_string_single() {
1795        let attrs = AriaAttributes::new().with_role("button");
1796        let html = attrs.to_html_string();
1797        assert_eq!(html, "role=\"button\"");
1798    }
1799
1800    #[test]
1801    fn test_to_html_string_multiple() {
1802        let attrs = AriaAttributes::new()
1803            .with_role("checkbox")
1804            .with_checked(AriaChecked::True);
1805        let html = attrs.to_html_string();
1806        assert!(html.contains("role=\"checkbox\""));
1807        assert!(html.contains("aria-checked=\"true\""));
1808    }
1809
1810    #[test]
1811    fn test_to_html_string_escapes_quotes() {
1812        let attrs = AriaAttributes::new().with_label("Click \"here\"");
1813        let html = attrs.to_html_string();
1814        assert!(html.contains("aria-label=\"Click &quot;here&quot;\""));
1815    }
1816
1817    // =============================================================================
1818    // aria_from_widget tests
1819    // =============================================================================
1820
1821    #[test]
1822    fn test_aria_from_widget_button() {
1823        let widget = MockButton::new().with_name("Submit");
1824        let attrs = aria_from_widget(&widget);
1825        assert_eq!(attrs.role, Some("button".to_string()));
1826        assert_eq!(attrs.label, Some("Submit".to_string()));
1827        assert!(!attrs.disabled);
1828    }
1829
1830    #[test]
1831    fn test_aria_from_widget_no_name() {
1832        let widget = MockButton::new();
1833        let attrs = aria_from_widget(&widget);
1834        assert_eq!(attrs.role, Some("button".to_string()));
1835        assert!(attrs.label.is_none());
1836    }
1837
1838    // Mock non-interactive widget for testing disabled state
1839    struct MockLabel {
1840        text: String,
1841    }
1842
1843    impl MockLabel {
1844        fn new(text: &str) -> Self {
1845            Self {
1846                text: text.to_string(),
1847            }
1848        }
1849    }
1850
1851    impl Brick for MockLabel {
1852        fn brick_name(&self) -> &'static str {
1853            "MockLabel"
1854        }
1855
1856        fn assertions(&self) -> &[BrickAssertion] {
1857            &[]
1858        }
1859
1860        fn budget(&self) -> BrickBudget {
1861            BrickBudget::uniform(16)
1862        }
1863
1864        fn verify(&self) -> BrickVerification {
1865            BrickVerification {
1866                passed: vec![],
1867                failed: vec![],
1868                verification_time: Duration::from_micros(1),
1869            }
1870        }
1871
1872        fn to_html(&self) -> String {
1873            String::new()
1874        }
1875
1876        fn to_css(&self) -> String {
1877            String::new()
1878        }
1879    }
1880
1881    impl Widget for MockLabel {
1882        fn type_id(&self) -> TypeId {
1883            TypeId::of::<Self>()
1884        }
1885        fn measure(&self, c: Constraints) -> Size {
1886            c.smallest()
1887        }
1888        fn layout(&mut self, b: Rect) -> LayoutResult {
1889            LayoutResult { size: b.size() }
1890        }
1891        fn paint(&self, _: &mut dyn Canvas) {}
1892        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1893            None
1894        }
1895        fn children(&self) -> &[Box<dyn Widget>] {
1896            &[]
1897        }
1898        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1899            &mut []
1900        }
1901        fn is_interactive(&self) -> bool {
1902            false
1903        }
1904        fn is_focusable(&self) -> bool {
1905            false
1906        }
1907        fn accessible_name(&self) -> Option<&str> {
1908            Some(&self.text)
1909        }
1910        fn accessible_role(&self) -> AccessibleRole {
1911            AccessibleRole::Heading
1912        }
1913    }
1914
1915    #[test]
1916    fn test_aria_from_widget_non_interactive() {
1917        let widget = MockLabel::new("Welcome");
1918        let attrs = aria_from_widget(&widget);
1919        assert_eq!(attrs.role, Some("heading".to_string()));
1920        assert_eq!(attrs.label, Some("Welcome".to_string()));
1921        assert!(attrs.disabled);
1922    }
1923
1924    // Mock generic widget that returns Generic role
1925    struct MockGenericWidget;
1926
1927    impl Brick for MockGenericWidget {
1928        fn brick_name(&self) -> &'static str {
1929            "MockGenericWidget"
1930        }
1931
1932        fn assertions(&self) -> &[BrickAssertion] {
1933            &[]
1934        }
1935
1936        fn budget(&self) -> BrickBudget {
1937            BrickBudget::uniform(16)
1938        }
1939
1940        fn verify(&self) -> BrickVerification {
1941            BrickVerification {
1942                passed: vec![],
1943                failed: vec![],
1944                verification_time: Duration::from_micros(1),
1945            }
1946        }
1947
1948        fn to_html(&self) -> String {
1949            String::new()
1950        }
1951
1952        fn to_css(&self) -> String {
1953            String::new()
1954        }
1955    }
1956
1957    impl Widget for MockGenericWidget {
1958        fn type_id(&self) -> TypeId {
1959            TypeId::of::<Self>()
1960        }
1961        fn measure(&self, c: Constraints) -> Size {
1962            c.smallest()
1963        }
1964        fn layout(&mut self, b: Rect) -> LayoutResult {
1965            LayoutResult { size: b.size() }
1966        }
1967        fn paint(&self, _: &mut dyn Canvas) {}
1968        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1969            None
1970        }
1971        fn children(&self) -> &[Box<dyn Widget>] {
1972            &[]
1973        }
1974        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1975            &mut []
1976        }
1977        fn is_interactive(&self) -> bool {
1978            false
1979        }
1980        fn is_focusable(&self) -> bool {
1981            false
1982        }
1983        fn accessible_name(&self) -> Option<&str> {
1984            None
1985        }
1986        fn accessible_role(&self) -> AccessibleRole {
1987            AccessibleRole::Generic
1988        }
1989    }
1990
1991    #[test]
1992    fn test_aria_from_widget_generic() {
1993        let widget = MockGenericWidget;
1994        let attrs = aria_from_widget(&widget);
1995        assert!(attrs.role.is_none());
1996        assert!(attrs.label.is_none());
1997        // Generic role shouldn't trigger disabled state
1998        assert!(!attrs.disabled);
1999    }
2000
2001    // =============================================================================
2002    // A11yConfig tests
2003    // =============================================================================
2004
2005    #[test]
2006    fn test_a11y_config_default() {
2007        let config = A11yConfig::default();
2008        assert!(config.check_touch_targets);
2009        assert!(config.check_heading_hierarchy);
2010        assert!(!config.check_focus_indicators);
2011        assert!((config.min_contrast_normal - 4.5).abs() < 0.01);
2012    }
2013
2014    #[test]
2015    fn test_a11y_config_strict() {
2016        let config = A11yConfig::strict();
2017        assert!(config.check_touch_targets);
2018        assert!(config.check_heading_hierarchy);
2019        assert!(config.check_focus_indicators);
2020        assert!((config.min_contrast_normal - 7.0).abs() < 0.01);
2021    }
2022
2023    #[test]
2024    fn test_a11y_config_mobile() {
2025        let config = A11yConfig::mobile();
2026        assert!(config.check_touch_targets);
2027        assert!(config.check_heading_hierarchy);
2028        assert!(!config.check_focus_indicators);
2029    }
2030
2031    // =============================================================================
2032    // WCAG constant tests
2033    // =============================================================================
2034
2035    #[test]
2036    fn test_min_touch_target_size() {
2037        assert_eq!(MIN_TOUCH_TARGET_SIZE, 44.0);
2038    }
2039
2040    #[test]
2041    fn test_min_focus_indicator_area() {
2042        assert_eq!(MIN_FOCUS_INDICATOR_AREA, 2.0);
2043    }
2044
2045    // =============================================================================
2046    // Check with config tests
2047    // =============================================================================
2048
2049    #[test]
2050    fn test_check_with_config() {
2051        let widget = MockButton::new().with_name("OK");
2052        // Use config without touch target check since mock widgets have 0x0 bounds
2053        let config = A11yConfig {
2054            check_touch_targets: false,
2055            check_heading_hierarchy: true,
2056            check_focus_indicators: false,
2057            min_contrast_normal: 4.5,
2058            min_contrast_large: 3.0,
2059        };
2060        let report = A11yChecker::check_with_config(&widget, &config);
2061        assert!(report.is_passing());
2062    }
2063
2064    // =============================================================================
2065    // Image alt text tests
2066    // =============================================================================
2067
2068    struct MockImage {
2069        alt_text: Option<String>,
2070    }
2071
2072    impl MockImage {
2073        fn new() -> Self {
2074            Self { alt_text: None }
2075        }
2076
2077        fn with_alt(mut self, alt: &str) -> Self {
2078            self.alt_text = Some(alt.to_string());
2079            self
2080        }
2081    }
2082
2083    impl Brick for MockImage {
2084        fn brick_name(&self) -> &'static str {
2085            "MockImage"
2086        }
2087
2088        fn assertions(&self) -> &[BrickAssertion] {
2089            &[]
2090        }
2091
2092        fn budget(&self) -> BrickBudget {
2093            BrickBudget::uniform(16)
2094        }
2095
2096        fn verify(&self) -> BrickVerification {
2097            BrickVerification {
2098                passed: vec![],
2099                failed: vec![],
2100                verification_time: Duration::from_micros(1),
2101            }
2102        }
2103
2104        fn to_html(&self) -> String {
2105            String::new()
2106        }
2107
2108        fn to_css(&self) -> String {
2109            String::new()
2110        }
2111    }
2112
2113    impl Widget for MockImage {
2114        fn type_id(&self) -> TypeId {
2115            TypeId::of::<Self>()
2116        }
2117        fn measure(&self, c: Constraints) -> Size {
2118            c.smallest()
2119        }
2120        fn layout(&mut self, b: Rect) -> LayoutResult {
2121            LayoutResult { size: b.size() }
2122        }
2123        fn paint(&self, _: &mut dyn Canvas) {}
2124        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
2125            None
2126        }
2127        fn children(&self) -> &[Box<dyn Widget>] {
2128            &[]
2129        }
2130        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
2131            &mut []
2132        }
2133        fn is_interactive(&self) -> bool {
2134            false
2135        }
2136        fn is_focusable(&self) -> bool {
2137            false
2138        }
2139        fn accessible_name(&self) -> Option<&str> {
2140            self.alt_text.as_deref()
2141        }
2142        fn accessible_role(&self) -> AccessibleRole {
2143            AccessibleRole::Image
2144        }
2145    }
2146
2147    #[test]
2148    fn test_image_missing_alt() {
2149        let widget = MockImage::new();
2150        let report = A11yChecker::check(&widget);
2151        assert!(!report.is_passing());
2152        assert!(report.violations.iter().any(|v| v.rule == "image-alt"));
2153    }
2154
2155    #[test]
2156    fn test_image_with_alt() {
2157        let widget = MockImage::new().with_alt("A sunset over the ocean");
2158        let report = A11yChecker::check(&widget);
2159        // Image with alt text should pass the image-alt check
2160        assert!(!report.violations.iter().any(|v| v.rule == "image-alt"));
2161    }
2162
2163    // =============================================================================
2164    // Impact ordering tests
2165    // =============================================================================
2166
2167    #[test]
2168    fn test_impact_equality() {
2169        assert_eq!(Impact::Minor, Impact::Minor);
2170        assert_eq!(Impact::Moderate, Impact::Moderate);
2171        assert_eq!(Impact::Serious, Impact::Serious);
2172        assert_eq!(Impact::Critical, Impact::Critical);
2173        assert_ne!(Impact::Minor, Impact::Critical);
2174    }
2175
2176    // =============================================================================
2177    // Form Accessibility Tests
2178    // =============================================================================
2179
2180    #[test]
2181    fn test_form_field_passing() {
2182        let form = FormAccessibility::new().with_name("Login Form").field(
2183            FormFieldA11y::new("email")
2184                .with_label("Email Address")
2185                .with_type(InputType::Email)
2186                .with_autocomplete(AutocompleteValue::Email),
2187        );
2188
2189        let report = FormA11yChecker::check(&form);
2190        assert!(report.is_passing());
2191    }
2192
2193    #[test]
2194    fn test_form_field_missing_label() {
2195        let form = FormAccessibility::new()
2196            .with_name("Test Form")
2197            .field(FormFieldA11y::new("email").with_type(InputType::Email));
2198
2199        let report = FormA11yChecker::check(&form);
2200        assert!(!report.is_passing());
2201        assert!(report
2202            .violations
2203            .iter()
2204            .any(|v| v.rule == FormA11yRule::MissingLabel));
2205    }
2206
2207    #[test]
2208    fn test_form_field_aria_label_counts() {
2209        let form = FormAccessibility::new().with_name("Test Form").field(
2210            FormFieldA11y::new("search")
2211                .with_type(InputType::Search)
2212                .with_aria_label("Search products"),
2213        );
2214
2215        let report = FormA11yChecker::check(&form);
2216        // aria-label should satisfy label requirement
2217        assert!(!report
2218            .violations
2219            .iter()
2220            .any(|v| v.rule == FormA11yRule::MissingLabel));
2221    }
2222
2223    #[test]
2224    fn test_form_required_missing_aria() {
2225        let form = FormAccessibility::new().with_name("Test Form").field(
2226            FormFieldA11y::new("name")
2227                .with_label("Full Name")
2228                .with_required(true, false), // Visual but no aria
2229        );
2230
2231        let report = FormA11yChecker::check(&form);
2232        assert!(report
2233            .violations
2234            .iter()
2235            .any(|v| v.rule == FormA11yRule::MissingRequiredIndicator));
2236    }
2237
2238    #[test]
2239    fn test_form_required_missing_visual() {
2240        let form = FormAccessibility::new().with_name("Test Form").field({
2241            let mut field = FormFieldA11y::new("name").with_label("Full Name");
2242            field.required = true;
2243            field.aria_required = true;
2244            field.has_visual_required_indicator = false;
2245            field
2246        });
2247
2248        let report = FormA11yChecker::check(&form);
2249        assert!(report
2250            .violations
2251            .iter()
2252            .any(|v| v.rule == FormA11yRule::MissingVisualRequired));
2253    }
2254
2255    #[test]
2256    fn test_form_required_proper() {
2257        let form = FormAccessibility::new().with_name("Test Form").field(
2258            FormFieldA11y::new("name")
2259                .with_label("Full Name")
2260                .required(), // Sets both visual and aria
2261        );
2262
2263        let report = FormA11yChecker::check(&form);
2264        // Should not have required-related violations
2265        assert!(!report.violations.iter().any(|v| matches!(
2266            v.rule,
2267            FormA11yRule::MissingRequiredIndicator | FormA11yRule::MissingVisualRequired
2268        )));
2269    }
2270
2271    #[test]
2272    fn test_form_error_without_aria_invalid() {
2273        let form = FormAccessibility::new().with_name("Test Form").field({
2274            let mut field = FormFieldA11y::new("email")
2275                .with_label("Email")
2276                .with_type(InputType::Email);
2277            field.has_error = true;
2278            field.aria_invalid = false;
2279            field.error_message = Some("Invalid email".to_string());
2280            field
2281        });
2282
2283        let report = FormA11yChecker::check(&form);
2284        assert!(report
2285            .violations
2286            .iter()
2287            .any(|v| v.rule == FormA11yRule::MissingErrorState));
2288    }
2289
2290    #[test]
2291    fn test_form_error_without_message() {
2292        let form = FormAccessibility::new().with_name("Test Form").field({
2293            let mut field = FormFieldA11y::new("email")
2294                .with_label("Email")
2295                .with_type(InputType::Email);
2296            field.has_error = true;
2297            field.aria_invalid = true;
2298            // No error message
2299            field
2300        });
2301
2302        let report = FormA11yChecker::check(&form);
2303        assert!(report
2304            .violations
2305            .iter()
2306            .any(|v| v.rule == FormA11yRule::MissingErrorMessage));
2307    }
2308
2309    #[test]
2310    fn test_form_error_not_associated() {
2311        let form = FormAccessibility::new().with_name("Test Form").field({
2312            let mut field = FormFieldA11y::new("email")
2313                .with_label("Email")
2314                .with_type(InputType::Email);
2315            field.has_error = true;
2316            field.aria_invalid = true;
2317            field.error_message = Some("Invalid email".to_string());
2318            // No aria-describedby or aria-errormessage
2319            field
2320        });
2321
2322        let report = FormA11yChecker::check(&form);
2323        assert!(report
2324            .violations
2325            .iter()
2326            .any(|v| v.rule == FormA11yRule::ErrorNotAssociated));
2327    }
2328
2329    #[test]
2330    fn test_form_error_properly_associated() {
2331        let form = FormAccessibility::new().with_name("Test Form").field(
2332            FormFieldA11y::new("email")
2333                .with_label("Email")
2334                .with_type(InputType::Email)
2335                .with_autocomplete(AutocompleteValue::Email)
2336                .with_error("Please enter a valid email address", true),
2337        );
2338
2339        let report = FormA11yChecker::check(&form);
2340        // Should not have error-related violations
2341        assert!(!report.violations.iter().any(|v| matches!(
2342            v.rule,
2343            FormA11yRule::MissingErrorState
2344                | FormA11yRule::MissingErrorMessage
2345                | FormA11yRule::ErrorNotAssociated
2346        )));
2347    }
2348
2349    #[test]
2350    fn test_form_missing_autocomplete() {
2351        let form = FormAccessibility::new().with_name("Test Form").field(
2352            FormFieldA11y::new("email")
2353                .with_label("Email")
2354                .with_type(InputType::Email),
2355            // No autocomplete
2356        );
2357
2358        let report = FormA11yChecker::check(&form);
2359        assert!(report
2360            .violations
2361            .iter()
2362            .any(|v| v.rule == FormA11yRule::MissingAutocomplete));
2363    }
2364
2365    #[test]
2366    fn test_form_autocomplete_not_needed_for_checkbox() {
2367        let form = FormAccessibility::new().with_name("Test Form").field(
2368            FormFieldA11y::new("terms")
2369                .with_label("I agree to terms")
2370                .with_type(InputType::Checkbox),
2371        );
2372
2373        let report = FormA11yChecker::check(&form);
2374        // Checkbox doesn't need autocomplete
2375        assert!(!report
2376            .violations
2377            .iter()
2378            .any(|v| v.rule == FormA11yRule::MissingAutocomplete));
2379    }
2380
2381    #[test]
2382    fn test_form_placeholder_as_label() {
2383        let form = FormAccessibility::new().with_name("Test Form").field(
2384            FormFieldA11y::new("email")
2385                .with_type(InputType::Email)
2386                .with_placeholder("Enter your email"),
2387        );
2388
2389        let report = FormA11yChecker::check(&form);
2390        assert!(report
2391            .violations
2392            .iter()
2393            .any(|v| v.rule == FormA11yRule::PlaceholderAsLabel));
2394    }
2395
2396    #[test]
2397    fn test_form_placeholder_with_label_ok() {
2398        let form = FormAccessibility::new().with_name("Test Form").field(
2399            FormFieldA11y::new("email")
2400                .with_label("Email")
2401                .with_type(InputType::Email)
2402                .with_autocomplete(AutocompleteValue::Email)
2403                .with_placeholder("e.g., user@example.com"),
2404        );
2405
2406        let report = FormA11yChecker::check(&form);
2407        // Placeholder with label is fine
2408        assert!(!report
2409            .violations
2410            .iter()
2411            .any(|v| v.rule == FormA11yRule::PlaceholderAsLabel));
2412    }
2413
2414    #[test]
2415    fn test_form_radio_buttons_not_grouped() {
2416        let form = FormAccessibility::new()
2417            .with_name("Test Form")
2418            .field(
2419                FormFieldA11y::new("option1")
2420                    .with_label("Option 1")
2421                    .with_type(InputType::Radio),
2422            )
2423            .field(
2424                FormFieldA11y::new("option2")
2425                    .with_label("Option 2")
2426                    .with_type(InputType::Radio),
2427            );
2428
2429        let report = FormA11yChecker::check(&form);
2430        assert!(report
2431            .violations
2432            .iter()
2433            .any(|v| v.rule == FormA11yRule::RelatedFieldsNotGrouped));
2434    }
2435
2436    #[test]
2437    fn test_form_radio_buttons_properly_grouped() {
2438        let form = FormAccessibility::new()
2439            .with_name("Test Form")
2440            .field(
2441                FormFieldA11y::new("option1")
2442                    .with_label("Option 1")
2443                    .with_type(InputType::Radio),
2444            )
2445            .field(
2446                FormFieldA11y::new("option2")
2447                    .with_label("Option 2")
2448                    .with_type(InputType::Radio),
2449            )
2450            .group(
2451                FormFieldGroup::new("options")
2452                    .with_legend("Choose an option")
2453                    .with_field("option1")
2454                    .with_field("option2"),
2455            );
2456
2457        let report = FormA11yChecker::check(&form);
2458        assert!(!report
2459            .violations
2460            .iter()
2461            .any(|v| v.rule == FormA11yRule::RelatedFieldsNotGrouped));
2462    }
2463
2464    #[test]
2465    fn test_form_group_missing_legend() {
2466        let form = FormAccessibility::new().with_name("Test Form").group(
2467            FormFieldGroup::new("address")
2468                .with_field("street")
2469                .with_field("city"),
2470        );
2471
2472        let report = FormA11yChecker::check(&form);
2473        assert!(report
2474            .violations
2475            .iter()
2476            .any(|v| v.rule == FormA11yRule::GroupMissingLegend));
2477    }
2478
2479    #[test]
2480    fn test_form_missing_accessible_name() {
2481        let form = FormAccessibility::new().field(
2482            FormFieldA11y::new("email")
2483                .with_label("Email")
2484                .with_type(InputType::Email)
2485                .with_autocomplete(AutocompleteValue::Email),
2486        );
2487
2488        let report = FormA11yChecker::check(&form);
2489        assert!(report
2490            .violations
2491            .iter()
2492            .any(|v| v.rule == FormA11yRule::FormMissingName));
2493    }
2494
2495    #[test]
2496    fn test_form_report_violations_for_field() {
2497        let form = FormAccessibility::new()
2498            .with_name("Test Form")
2499            .field(FormFieldA11y::new("bad_field").with_type(InputType::Email))
2500            .field(
2501                FormFieldA11y::new("good_field")
2502                    .with_label("Good Field")
2503                    .with_type(InputType::Text),
2504            );
2505
2506        let report = FormA11yChecker::check(&form);
2507        let bad_violations = report.violations_for_field("bad_field");
2508        assert!(!bad_violations.is_empty());
2509
2510        let good_violations = report.violations_for_field("good_field");
2511        // good_field should only have autocomplete warning (if text type)
2512        assert!(good_violations.len() <= 1);
2513    }
2514
2515    #[test]
2516    fn test_form_report_is_acceptable() {
2517        // Form with only moderate violations should be acceptable
2518        let form = FormAccessibility::new().with_name("Test Form").field(
2519            FormFieldA11y::new("email")
2520                .with_label("Email")
2521                .with_type(InputType::Email),
2522            // Missing autocomplete is Moderate impact
2523        );
2524
2525        let report = FormA11yChecker::check(&form);
2526        assert!(report.is_acceptable()); // Only Moderate violations
2527    }
2528
2529    #[test]
2530    fn test_input_type_should_have_autocomplete() {
2531        assert!(InputType::Email.should_have_autocomplete());
2532        assert!(InputType::Password.should_have_autocomplete());
2533        assert!(InputType::Tel.should_have_autocomplete());
2534        assert!(InputType::Text.should_have_autocomplete());
2535        assert!(!InputType::Checkbox.should_have_autocomplete());
2536        assert!(!InputType::Radio.should_have_autocomplete());
2537        assert!(!InputType::Date.should_have_autocomplete());
2538    }
2539
2540    #[test]
2541    fn test_autocomplete_value_as_str() {
2542        assert_eq!(AutocompleteValue::Email.as_str(), "email");
2543        assert_eq!(AutocompleteValue::GivenName.as_str(), "given-name");
2544        assert_eq!(
2545            AutocompleteValue::CurrentPassword.as_str(),
2546            "current-password"
2547        );
2548        assert_eq!(AutocompleteValue::Off.as_str(), "off");
2549    }
2550
2551    #[test]
2552    fn test_form_a11y_rule_name() {
2553        assert_eq!(FormA11yRule::MissingLabel.name(), "missing-label");
2554        assert_eq!(
2555            FormA11yRule::MissingRequiredIndicator.name(),
2556            "missing-required-indicator"
2557        );
2558        assert_eq!(
2559            FormA11yRule::PlaceholderAsLabel.name(),
2560            "placeholder-as-label"
2561        );
2562    }
2563
2564    #[test]
2565    fn test_form_violations_for_rule() {
2566        let form = FormAccessibility::new()
2567            .with_name("Test Form")
2568            .field(FormFieldA11y::new("field1").with_type(InputType::Email))
2569            .field(FormFieldA11y::new("field2").with_type(InputType::Email));
2570
2571        let report = FormA11yChecker::check(&form);
2572        let missing_labels = report.violations_for_rule(FormA11yRule::MissingLabel);
2573        assert_eq!(missing_labels.len(), 2); // Both fields missing labels
2574    }
2575
2576    #[test]
2577    fn test_form_complete_signup_form() {
2578        // Test a complete, accessible signup form
2579        let form = FormAccessibility::new()
2580            .with_name("Create Account")
2581            .field(
2582                FormFieldA11y::new("first_name")
2583                    .with_label("First Name")
2584                    .with_type(InputType::Text)
2585                    .with_autocomplete(AutocompleteValue::GivenName)
2586                    .required(),
2587            )
2588            .field(
2589                FormFieldA11y::new("last_name")
2590                    .with_label("Last Name")
2591                    .with_type(InputType::Text)
2592                    .with_autocomplete(AutocompleteValue::FamilyName)
2593                    .required(),
2594            )
2595            .field(
2596                FormFieldA11y::new("email")
2597                    .with_label("Email Address")
2598                    .with_type(InputType::Email)
2599                    .with_autocomplete(AutocompleteValue::Email)
2600                    .required(),
2601            )
2602            .field(
2603                FormFieldA11y::new("password")
2604                    .with_label("Password")
2605                    .with_type(InputType::Password)
2606                    .with_autocomplete(AutocompleteValue::NewPassword)
2607                    .required(),
2608            )
2609            .field(
2610                FormFieldA11y::new("terms")
2611                    .with_label("I agree to the Terms of Service")
2612                    .with_type(InputType::Checkbox)
2613                    .required(),
2614            );
2615
2616        let report = FormA11yChecker::check(&form);
2617        assert!(
2618            report.is_passing(),
2619            "Complete signup form should pass: {:?}",
2620            report.violations
2621        );
2622    }
2623
2624    // ===== Additional Coverage Tests =====
2625
2626    #[test]
2627    fn test_aria_attributes_with_range() {
2628        let attrs = AriaAttributes::new().with_range(0.0, 100.0, 50.0);
2629        assert_eq!(attrs.value_min, Some(0.0));
2630        assert_eq!(attrs.value_max, Some(100.0));
2631        assert_eq!(attrs.value_now, Some(50.0));
2632    }
2633
2634    #[test]
2635    fn test_aria_attributes_with_pos_in_set() {
2636        let attrs = AriaAttributes::new().with_pos_in_set(3, 10);
2637        assert_eq!(attrs.pos_in_set, Some(3));
2638        assert_eq!(attrs.set_size, Some(10));
2639    }
2640
2641    #[test]
2642    fn test_to_html_attrs_pos_in_set() {
2643        let attrs = AriaAttributes::new().with_pos_in_set(2, 5);
2644        let html_attrs = attrs.to_html_attrs();
2645        assert!(html_attrs.contains(&("aria-posinset".to_string(), "2".to_string())));
2646        assert!(html_attrs.contains(&("aria-setsize".to_string(), "5".to_string())));
2647    }
2648
2649    #[test]
2650    fn test_to_html_attrs_level() {
2651        let attrs = AriaAttributes::new().with_level(3);
2652        let html_attrs = attrs.to_html_attrs();
2653        assert!(html_attrs.contains(&("aria-level".to_string(), "3".to_string())));
2654    }
2655
2656    #[test]
2657    fn test_to_html_attrs_selected() {
2658        let attrs = AriaAttributes::new().with_selected(true);
2659        let html_attrs = attrs.to_html_attrs();
2660        assert!(html_attrs.contains(&("aria-selected".to_string(), "true".to_string())));
2661    }
2662
2663    #[test]
2664    fn test_to_html_attrs_pressed() {
2665        let attrs = AriaAttributes::new().with_pressed(AriaChecked::True);
2666        let html_attrs = attrs.to_html_attrs();
2667        assert!(html_attrs.contains(&("aria-pressed".to_string(), "true".to_string())));
2668    }
2669
2670    #[test]
2671    fn test_to_html_attrs_busy() {
2672        let attrs = AriaAttributes::new().with_busy(true);
2673        let html_attrs = attrs.to_html_attrs();
2674        assert!(html_attrs.contains(&("aria-busy".to_string(), "true".to_string())));
2675    }
2676
2677    #[test]
2678    fn test_to_html_attrs_atomic() {
2679        let attrs = AriaAttributes::new().with_atomic(true);
2680        let html_attrs = attrs.to_html_attrs();
2681        assert!(html_attrs.contains(&("aria-atomic".to_string(), "true".to_string())));
2682    }
2683
2684    #[test]
2685    fn test_to_html_attrs_controls() {
2686        let attrs = AriaAttributes::new().with_controls("panel-2");
2687        let html_attrs = attrs.to_html_attrs();
2688        assert!(html_attrs.contains(&("aria-controls".to_string(), "panel-2".to_string())));
2689    }
2690
2691    #[test]
2692    fn test_to_html_attrs_describedby() {
2693        let attrs = AriaAttributes::new().with_described_by("desc-id");
2694        let html_attrs = attrs.to_html_attrs();
2695        assert!(html_attrs.contains(&("aria-describedby".to_string(), "desc-id".to_string())));
2696    }
2697
2698    #[test]
2699    fn test_to_html_attrs_haspopup() {
2700        let attrs = AriaAttributes::new().with_has_popup("dialog");
2701        let html_attrs = attrs.to_html_attrs();
2702        assert!(html_attrs.contains(&("aria-haspopup".to_string(), "dialog".to_string())));
2703    }
2704
2705    #[test]
2706    fn test_to_html_string_escapes_ampersand() {
2707        let attrs = AriaAttributes::new().with_label("Terms & Conditions");
2708        let html = attrs.to_html_string();
2709        assert!(html.contains("aria-label=\"Terms &amp; Conditions\""));
2710    }
2711
2712    #[test]
2713    fn test_to_html_string_escapes_less_than() {
2714        let attrs = AriaAttributes::new().with_label("Value < 5");
2715        let html = attrs.to_html_string();
2716        assert!(html.contains("aria-label=\"Value &lt; 5\""));
2717    }
2718
2719    #[test]
2720    fn test_to_html_string_escapes_greater_than() {
2721        let attrs = AriaAttributes::new().with_label("Value > 5");
2722        let html = attrs.to_html_string();
2723        assert!(html.contains("aria-label=\"Value &gt; 5\""));
2724    }
2725
2726    #[test]
2727    fn test_to_html_attrs_required() {
2728        let mut attrs = AriaAttributes::new();
2729        attrs.required = true;
2730        let html_attrs = attrs.to_html_attrs();
2731        assert!(html_attrs.contains(&("aria-required".to_string(), "true".to_string())));
2732    }
2733
2734    #[test]
2735    fn test_to_html_attrs_invalid() {
2736        let mut attrs = AriaAttributes::new();
2737        attrs.invalid = true;
2738        let html_attrs = attrs.to_html_attrs();
2739        assert!(html_attrs.contains(&("aria-invalid".to_string(), "true".to_string())));
2740    }
2741
2742    #[test]
2743    fn test_to_html_attrs_value_text() {
2744        let mut attrs = AriaAttributes::new();
2745        attrs.value_text = Some("50%".to_string());
2746        let html_attrs = attrs.to_html_attrs();
2747        assert!(html_attrs.contains(&("aria-valuetext".to_string(), "50%".to_string())));
2748    }
2749
2750    // ===== Additional AutocompleteValue Tests =====
2751
2752    #[test]
2753    fn test_autocomplete_value_all_variants() {
2754        assert_eq!(AutocompleteValue::Name.as_str(), "name");
2755        assert_eq!(AutocompleteValue::FamilyName.as_str(), "family-name");
2756        assert_eq!(AutocompleteValue::Tel.as_str(), "tel");
2757        assert_eq!(AutocompleteValue::StreetAddress.as_str(), "street-address");
2758        assert_eq!(AutocompleteValue::AddressLevel1.as_str(), "address-level1");
2759        assert_eq!(AutocompleteValue::AddressLevel2.as_str(), "address-level2");
2760        assert_eq!(AutocompleteValue::PostalCode.as_str(), "postal-code");
2761        assert_eq!(AutocompleteValue::Country.as_str(), "country");
2762        assert_eq!(AutocompleteValue::Organization.as_str(), "organization");
2763        assert_eq!(AutocompleteValue::Username.as_str(), "username");
2764        assert_eq!(AutocompleteValue::NewPassword.as_str(), "new-password");
2765        assert_eq!(AutocompleteValue::CcNumber.as_str(), "cc-number");
2766        assert_eq!(AutocompleteValue::CcExp.as_str(), "cc-exp");
2767        assert_eq!(AutocompleteValue::CcCsc.as_str(), "cc-csc");
2768        assert_eq!(AutocompleteValue::OneTimeCode.as_str(), "one-time-code");
2769    }
2770
2771    // ===== Additional FormA11yRule Tests =====
2772
2773    #[test]
2774    fn test_form_a11y_rule_all_names() {
2775        assert_eq!(
2776            FormA11yRule::MissingVisualRequired.name(),
2777            "missing-visual-required"
2778        );
2779        assert_eq!(
2780            FormA11yRule::MissingErrorState.name(),
2781            "missing-error-state"
2782        );
2783        assert_eq!(
2784            FormA11yRule::MissingErrorMessage.name(),
2785            "missing-error-message"
2786        );
2787        assert_eq!(
2788            FormA11yRule::ErrorNotAssociated.name(),
2789            "error-not-associated"
2790        );
2791        assert_eq!(
2792            FormA11yRule::MissingAutocomplete.name(),
2793            "missing-autocomplete"
2794        );
2795        assert_eq!(
2796            FormA11yRule::RelatedFieldsNotGrouped.name(),
2797            "related-fields-not-grouped"
2798        );
2799        assert_eq!(
2800            FormA11yRule::GroupMissingLegend.name(),
2801            "group-missing-legend"
2802        );
2803        assert_eq!(FormA11yRule::FormMissingName.name(), "form-missing-name");
2804    }
2805
2806    // ===== Additional InputType Tests =====
2807
2808    #[test]
2809    fn test_input_type_autocomplete_coverage() {
2810        assert!(InputType::Url.should_have_autocomplete());
2811        assert!(InputType::Number.should_have_autocomplete());
2812        assert!(!InputType::Time.should_have_autocomplete());
2813        assert!(!InputType::Search.should_have_autocomplete());
2814        assert!(!InputType::Select.should_have_autocomplete());
2815        assert!(!InputType::Textarea.should_have_autocomplete());
2816        assert!(!InputType::Hidden.should_have_autocomplete());
2817    }
2818
2819    // ===== FormA11yReport assert_pass Test =====
2820
2821    #[test]
2822    #[should_panic(expected = "Form accessibility check failed")]
2823    fn test_form_report_assert_pass_fails() {
2824        let form = FormAccessibility::new()
2825            .with_name("Test Form")
2826            .field(FormFieldA11y::new("email").with_type(InputType::Email));
2827
2828        let report = FormA11yChecker::check(&form);
2829        report.assert_pass();
2830    }
2831
2832    // ===== Heading Level Tests =====
2833
2834    #[test]
2835    fn test_heading_level_h1() {
2836        let widget = MockLabel::new("h1 Main Title");
2837        // Heading level extraction should find 'h1' pattern
2838        let level = A11yChecker::heading_level(&widget);
2839        assert_eq!(level, Some(1));
2840    }
2841
2842    #[test]
2843    fn test_heading_level_h3() {
2844        let widget = MockLabel::new("h3 Subsection");
2845        let level = A11yChecker::heading_level(&widget);
2846        assert_eq!(level, Some(3));
2847    }
2848
2849    #[test]
2850    fn test_heading_level_capital_h() {
2851        let widget = MockLabel::new("H2 Section");
2852        let level = A11yChecker::heading_level(&widget);
2853        assert_eq!(level, Some(2));
2854    }
2855
2856    #[test]
2857    fn test_heading_level_invalid_number() {
2858        let widget = MockLabel::new("h9 Invalid Level");
2859        let level = A11yChecker::heading_level(&widget);
2860        // Falls back to default level 2 for invalid levels
2861        assert_eq!(level, Some(2));
2862    }
2863
2864    // ===== Touch Target Size Tests =====
2865
2866    struct MockTouchTarget {
2867        bounds: Rect,
2868        name: String,
2869    }
2870
2871    impl MockTouchTarget {
2872        fn new(width: f32, height: f32, name: &str) -> Self {
2873            Self {
2874                bounds: Rect::new(0.0, 0.0, width, height),
2875                name: name.to_string(),
2876            }
2877        }
2878    }
2879
2880    impl Brick for MockTouchTarget {
2881        fn brick_name(&self) -> &'static str {
2882            "MockTouchTarget"
2883        }
2884
2885        fn assertions(&self) -> &[BrickAssertion] {
2886            &[]
2887        }
2888
2889        fn budget(&self) -> BrickBudget {
2890            BrickBudget::uniform(16)
2891        }
2892
2893        fn verify(&self) -> BrickVerification {
2894            BrickVerification {
2895                passed: vec![],
2896                failed: vec![],
2897                verification_time: Duration::from_micros(1),
2898            }
2899        }
2900
2901        fn to_html(&self) -> String {
2902            String::new()
2903        }
2904
2905        fn to_css(&self) -> String {
2906            String::new()
2907        }
2908    }
2909
2910    impl Widget for MockTouchTarget {
2911        fn type_id(&self) -> TypeId {
2912            TypeId::of::<Self>()
2913        }
2914        fn measure(&self, _: Constraints) -> Size {
2915            self.bounds.size()
2916        }
2917        fn layout(&mut self, _: Rect) -> LayoutResult {
2918            LayoutResult {
2919                size: self.bounds.size(),
2920            }
2921        }
2922        fn paint(&self, _: &mut dyn Canvas) {}
2923        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
2924            None
2925        }
2926        fn children(&self) -> &[Box<dyn Widget>] {
2927            &[]
2928        }
2929        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
2930            &mut []
2931        }
2932        fn is_interactive(&self) -> bool {
2933            true
2934        }
2935        fn is_focusable(&self) -> bool {
2936            true
2937        }
2938        fn accessible_name(&self) -> Option<&str> {
2939            Some(&self.name)
2940        }
2941        fn accessible_role(&self) -> AccessibleRole {
2942            AccessibleRole::Button
2943        }
2944        fn bounds(&self) -> Rect {
2945            self.bounds
2946        }
2947    }
2948
2949    #[test]
2950    fn test_touch_target_too_small() {
2951        let widget = MockTouchTarget::new(30.0, 30.0, "Small Button");
2952        let config = A11yConfig {
2953            check_touch_targets: true,
2954            check_heading_hierarchy: false,
2955            check_focus_indicators: false,
2956            min_contrast_normal: 4.5,
2957            min_contrast_large: 3.0,
2958        };
2959        let report = A11yChecker::check_with_config(&widget, &config);
2960        assert!(report.violations.iter().any(|v| v.rule == "touch-target"));
2961    }
2962
2963    #[test]
2964    fn test_touch_target_sufficient() {
2965        let widget = MockTouchTarget::new(48.0, 48.0, "Good Button");
2966        let config = A11yConfig {
2967            check_touch_targets: true,
2968            check_heading_hierarchy: false,
2969            check_focus_indicators: false,
2970            min_contrast_normal: 4.5,
2971            min_contrast_large: 3.0,
2972        };
2973        let report = A11yChecker::check_with_config(&widget, &config);
2974        assert!(!report.violations.iter().any(|v| v.rule == "touch-target"));
2975    }
2976
2977    // ===== Group aria-label Test =====
2978
2979    #[test]
2980    fn test_form_group_with_aria_label() {
2981        let mut group = FormFieldGroup::new("address");
2982        group.aria_label = Some("Shipping Address".to_string());
2983        group.field_ids = vec!["street".to_string(), "city".to_string()];
2984
2985        let form = FormAccessibility::new().with_name("Test Form").group(group);
2986
2987        let report = FormA11yChecker::check(&form);
2988        // Group with aria-label should not trigger missing legend
2989        assert!(!report
2990            .violations
2991            .iter()
2992            .any(|v| v.rule == FormA11yRule::GroupMissingLegend));
2993    }
2994
2995    // ===== aria_from_widget Additional Roles =====
2996
2997    struct MockCheckbox {
2998        label: String,
2999    }
3000
3001    impl Brick for MockCheckbox {
3002        fn brick_name(&self) -> &'static str {
3003            "MockCheckbox"
3004        }
3005        fn assertions(&self) -> &[BrickAssertion] {
3006            &[]
3007        }
3008        fn budget(&self) -> BrickBudget {
3009            BrickBudget::uniform(16)
3010        }
3011        fn verify(&self) -> BrickVerification {
3012            BrickVerification {
3013                passed: vec![],
3014                failed: vec![],
3015                verification_time: Duration::from_micros(1),
3016            }
3017        }
3018        fn to_html(&self) -> String {
3019            String::new()
3020        }
3021        fn to_css(&self) -> String {
3022            String::new()
3023        }
3024    }
3025
3026    impl Widget for MockCheckbox {
3027        fn type_id(&self) -> TypeId {
3028            TypeId::of::<Self>()
3029        }
3030        fn measure(&self, c: Constraints) -> Size {
3031            c.smallest()
3032        }
3033        fn layout(&mut self, b: Rect) -> LayoutResult {
3034            LayoutResult { size: b.size() }
3035        }
3036        fn paint(&self, _: &mut dyn Canvas) {}
3037        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
3038            None
3039        }
3040        fn children(&self) -> &[Box<dyn Widget>] {
3041            &[]
3042        }
3043        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
3044            &mut []
3045        }
3046        fn is_interactive(&self) -> bool {
3047            true
3048        }
3049        fn is_focusable(&self) -> bool {
3050            true
3051        }
3052        fn accessible_name(&self) -> Option<&str> {
3053            Some(&self.label)
3054        }
3055        fn accessible_role(&self) -> AccessibleRole {
3056            AccessibleRole::Checkbox
3057        }
3058    }
3059
3060    #[test]
3061    fn test_aria_from_widget_checkbox() {
3062        let widget = MockCheckbox {
3063            label: "Accept terms".to_string(),
3064        };
3065        let attrs = aria_from_widget(&widget);
3066        assert_eq!(attrs.role, Some("checkbox".to_string()));
3067        assert_eq!(attrs.label, Some("Accept terms".to_string()));
3068        assert!(!attrs.disabled);
3069    }
3070
3071    // ===== FormFieldA11y aria_labelledby Test =====
3072
3073    #[test]
3074    fn test_form_field_aria_labelledby() {
3075        let mut field = FormFieldA11y::new("search");
3076        field.aria_labelledby = Some("search-label".to_string());
3077
3078        let form = FormAccessibility::new().with_name("Test Form").field(field);
3079
3080        let report = FormA11yChecker::check(&form);
3081        // aria-labelledby should satisfy label requirement
3082        assert!(!report
3083            .violations
3084            .iter()
3085            .any(|v| v.rule == FormA11yRule::MissingLabel));
3086    }
3087
3088    // ===== FormFieldA11y aria_errormessage Test =====
3089
3090    #[test]
3091    fn test_form_error_with_aria_errormessage() {
3092        let mut field = FormFieldA11y::new("email")
3093            .with_label("Email")
3094            .with_type(InputType::Email)
3095            .with_autocomplete(AutocompleteValue::Email);
3096        field.has_error = true;
3097        field.aria_invalid = true;
3098        field.error_message = Some("Invalid email".to_string());
3099        field.aria_errormessage = Some("email-error".to_string());
3100
3101        let form = FormAccessibility::new().with_name("Test Form").field(field);
3102
3103        let report = FormA11yChecker::check(&form);
3104        // aria-errormessage should satisfy error association
3105        assert!(!report
3106            .violations
3107            .iter()
3108            .any(|v| v.rule == FormA11yRule::ErrorNotAssociated));
3109    }
3110
3111    // ===== Additional Coverage Tests =====
3112
3113    // Test all AccessibleRole mappings in aria_from_widget
3114    macro_rules! test_role_widget {
3115        ($name:ident, $role:expr, $expected:expr) => {
3116            struct $name;
3117            impl Brick for $name {
3118                fn brick_name(&self) -> &'static str {
3119                    stringify!($name)
3120                }
3121                fn assertions(&self) -> &[BrickAssertion] {
3122                    &[]
3123                }
3124                fn budget(&self) -> BrickBudget {
3125                    BrickBudget::uniform(16)
3126                }
3127                fn verify(&self) -> BrickVerification {
3128                    BrickVerification {
3129                        passed: vec![],
3130                        failed: vec![],
3131                        verification_time: Duration::from_micros(1),
3132                    }
3133                }
3134                fn to_html(&self) -> String {
3135                    String::new()
3136                }
3137                fn to_css(&self) -> String {
3138                    String::new()
3139                }
3140            }
3141            impl Widget for $name {
3142                fn type_id(&self) -> TypeId {
3143                    TypeId::of::<Self>()
3144                }
3145                fn measure(&self, c: Constraints) -> Size {
3146                    c.smallest()
3147                }
3148                fn layout(&mut self, b: Rect) -> LayoutResult {
3149                    LayoutResult { size: b.size() }
3150                }
3151                fn paint(&self, _: &mut dyn Canvas) {}
3152                fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
3153                    None
3154                }
3155                fn children(&self) -> &[Box<dyn Widget>] {
3156                    &[]
3157                }
3158                fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
3159                    &mut []
3160                }
3161                fn is_interactive(&self) -> bool {
3162                    true
3163                }
3164                fn is_focusable(&self) -> bool {
3165                    true
3166                }
3167                fn accessible_name(&self) -> Option<&str> {
3168                    Some("test")
3169                }
3170                fn accessible_role(&self) -> AccessibleRole {
3171                    $role
3172                }
3173            }
3174        };
3175    }
3176
3177    test_role_widget!(MockTextInput, AccessibleRole::TextInput, "textbox");
3178    test_role_widget!(MockLink, AccessibleRole::Link, "link");
3179    test_role_widget!(MockList, AccessibleRole::List, "list");
3180    test_role_widget!(MockListItem, AccessibleRole::ListItem, "listitem");
3181    test_role_widget!(MockTable, AccessibleRole::Table, "table");
3182    test_role_widget!(MockTableRow, AccessibleRole::TableRow, "row");
3183    test_role_widget!(MockTableCell, AccessibleRole::TableCell, "cell");
3184    test_role_widget!(MockMenu, AccessibleRole::Menu, "menu");
3185    test_role_widget!(MockMenuItem, AccessibleRole::MenuItem, "menuitem");
3186    test_role_widget!(MockComboBox, AccessibleRole::ComboBox, "combobox");
3187    test_role_widget!(MockSlider, AccessibleRole::Slider, "slider");
3188    test_role_widget!(MockProgressBar, AccessibleRole::ProgressBar, "progressbar");
3189    test_role_widget!(MockTab, AccessibleRole::Tab, "tab");
3190    test_role_widget!(MockTabPanel, AccessibleRole::TabPanel, "tabpanel");
3191    test_role_widget!(MockRadioGroup, AccessibleRole::RadioGroup, "radiogroup");
3192    test_role_widget!(MockRadio, AccessibleRole::Radio, "radio");
3193
3194    #[test]
3195    fn test_aria_from_widget_textinput() {
3196        let attrs = aria_from_widget(&MockTextInput);
3197        assert_eq!(attrs.role, Some("textbox".to_string()));
3198    }
3199
3200    #[test]
3201    fn test_aria_from_widget_link() {
3202        let attrs = aria_from_widget(&MockLink);
3203        assert_eq!(attrs.role, Some("link".to_string()));
3204    }
3205
3206    #[test]
3207    fn test_aria_from_widget_list() {
3208        let attrs = aria_from_widget(&MockList);
3209        assert_eq!(attrs.role, Some("list".to_string()));
3210    }
3211
3212    #[test]
3213    fn test_aria_from_widget_listitem() {
3214        let attrs = aria_from_widget(&MockListItem);
3215        assert_eq!(attrs.role, Some("listitem".to_string()));
3216    }
3217
3218    #[test]
3219    fn test_aria_from_widget_table() {
3220        let attrs = aria_from_widget(&MockTable);
3221        assert_eq!(attrs.role, Some("table".to_string()));
3222    }
3223
3224    #[test]
3225    fn test_aria_from_widget_tablerow() {
3226        let attrs = aria_from_widget(&MockTableRow);
3227        assert_eq!(attrs.role, Some("row".to_string()));
3228    }
3229
3230    #[test]
3231    fn test_aria_from_widget_tablecell() {
3232        let attrs = aria_from_widget(&MockTableCell);
3233        assert_eq!(attrs.role, Some("cell".to_string()));
3234    }
3235
3236    #[test]
3237    fn test_aria_from_widget_menu() {
3238        let attrs = aria_from_widget(&MockMenu);
3239        assert_eq!(attrs.role, Some("menu".to_string()));
3240    }
3241
3242    #[test]
3243    fn test_aria_from_widget_menuitem() {
3244        let attrs = aria_from_widget(&MockMenuItem);
3245        assert_eq!(attrs.role, Some("menuitem".to_string()));
3246    }
3247
3248    #[test]
3249    fn test_aria_from_widget_combobox() {
3250        let attrs = aria_from_widget(&MockComboBox);
3251        assert_eq!(attrs.role, Some("combobox".to_string()));
3252    }
3253
3254    #[test]
3255    fn test_aria_from_widget_slider() {
3256        let attrs = aria_from_widget(&MockSlider);
3257        assert_eq!(attrs.role, Some("slider".to_string()));
3258    }
3259
3260    #[test]
3261    fn test_aria_from_widget_progressbar() {
3262        let attrs = aria_from_widget(&MockProgressBar);
3263        assert_eq!(attrs.role, Some("progressbar".to_string()));
3264    }
3265
3266    #[test]
3267    fn test_aria_from_widget_tab() {
3268        let attrs = aria_from_widget(&MockTab);
3269        assert_eq!(attrs.role, Some("tab".to_string()));
3270    }
3271
3272    #[test]
3273    fn test_aria_from_widget_tabpanel() {
3274        let attrs = aria_from_widget(&MockTabPanel);
3275        assert_eq!(attrs.role, Some("tabpanel".to_string()));
3276    }
3277
3278    #[test]
3279    fn test_aria_from_widget_radiogroup() {
3280        let attrs = aria_from_widget(&MockRadioGroup);
3281        assert_eq!(attrs.role, Some("radiogroup".to_string()));
3282    }
3283
3284    #[test]
3285    fn test_aria_from_widget_radio() {
3286        let attrs = aria_from_widget(&MockRadio);
3287        assert_eq!(attrs.role, Some("radio".to_string()));
3288    }
3289
3290    #[test]
3291    fn test_aria_from_widget_image() {
3292        let attrs = aria_from_widget(&MockImage::new().with_alt("Test Image"));
3293        assert_eq!(attrs.role, Some("img".to_string()));
3294    }
3295
3296    // Test heading hierarchy check
3297    struct MockHeadingWidget {
3298        children: Vec<Box<dyn Widget>>,
3299        name: String,
3300    }
3301
3302    impl MockHeadingWidget {
3303        fn new(name: &str) -> Self {
3304            Self {
3305                children: Vec::new(),
3306                name: name.to_string(),
3307            }
3308        }
3309
3310        fn with_child(mut self, child: impl Widget + 'static) -> Self {
3311            self.children.push(Box::new(child));
3312            self
3313        }
3314    }
3315
3316    impl Brick for MockHeadingWidget {
3317        fn brick_name(&self) -> &'static str {
3318            "MockHeadingWidget"
3319        }
3320        fn assertions(&self) -> &[BrickAssertion] {
3321            &[]
3322        }
3323        fn budget(&self) -> BrickBudget {
3324            BrickBudget::uniform(16)
3325        }
3326        fn verify(&self) -> BrickVerification {
3327            BrickVerification {
3328                passed: vec![],
3329                failed: vec![],
3330                verification_time: Duration::from_micros(1),
3331            }
3332        }
3333        fn to_html(&self) -> String {
3334            String::new()
3335        }
3336        fn to_css(&self) -> String {
3337            String::new()
3338        }
3339    }
3340
3341    impl Widget for MockHeadingWidget {
3342        fn type_id(&self) -> TypeId {
3343            TypeId::of::<Self>()
3344        }
3345        fn measure(&self, c: Constraints) -> Size {
3346            c.smallest()
3347        }
3348        fn layout(&mut self, b: Rect) -> LayoutResult {
3349            LayoutResult { size: b.size() }
3350        }
3351        fn paint(&self, _: &mut dyn Canvas) {}
3352        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
3353            None
3354        }
3355        fn children(&self) -> &[Box<dyn Widget>] {
3356            &self.children
3357        }
3358        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
3359            &mut self.children
3360        }
3361        fn is_interactive(&self) -> bool {
3362            false
3363        }
3364        fn is_focusable(&self) -> bool {
3365            false
3366        }
3367        fn accessible_name(&self) -> Option<&str> {
3368            Some(&self.name)
3369        }
3370        fn accessible_role(&self) -> AccessibleRole {
3371            AccessibleRole::Heading
3372        }
3373    }
3374
3375    #[test]
3376    fn test_heading_hierarchy_skipped() {
3377        // h1 followed by h3 should trigger violation
3378        let h1 = MockHeadingWidget::new("h1 Main");
3379        let h3 = MockHeadingWidget::new("h3 Skipped");
3380
3381        // Create a container that has h1 then h3
3382        struct MockContainer {
3383            children: Vec<Box<dyn Widget>>,
3384        }
3385        impl Brick for MockContainer {
3386            fn brick_name(&self) -> &'static str {
3387                "MockContainer"
3388            }
3389            fn assertions(&self) -> &[BrickAssertion] {
3390                &[]
3391            }
3392            fn budget(&self) -> BrickBudget {
3393                BrickBudget::uniform(16)
3394            }
3395            fn verify(&self) -> BrickVerification {
3396                BrickVerification {
3397                    passed: vec![],
3398                    failed: vec![],
3399                    verification_time: Duration::from_micros(1),
3400                }
3401            }
3402            fn to_html(&self) -> String {
3403                String::new()
3404            }
3405            fn to_css(&self) -> String {
3406                String::new()
3407            }
3408        }
3409        impl Widget for MockContainer {
3410            fn type_id(&self) -> TypeId {
3411                TypeId::of::<Self>()
3412            }
3413            fn measure(&self, c: Constraints) -> Size {
3414                c.smallest()
3415            }
3416            fn layout(&mut self, b: Rect) -> LayoutResult {
3417                LayoutResult { size: b.size() }
3418            }
3419            fn paint(&self, _: &mut dyn Canvas) {}
3420            fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
3421                None
3422            }
3423            fn children(&self) -> &[Box<dyn Widget>] {
3424                &self.children
3425            }
3426            fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
3427                &mut self.children
3428            }
3429            fn is_interactive(&self) -> bool {
3430                false
3431            }
3432            fn is_focusable(&self) -> bool {
3433                false
3434            }
3435            fn accessible_name(&self) -> Option<&str> {
3436                None
3437            }
3438            fn accessible_role(&self) -> AccessibleRole {
3439                AccessibleRole::Generic
3440            }
3441        }
3442
3443        let container = MockContainer {
3444            children: vec![Box::new(h1), Box::new(h3)],
3445        };
3446
3447        let config = A11yConfig {
3448            check_touch_targets: false,
3449            check_heading_hierarchy: true,
3450            check_focus_indicators: false,
3451            min_contrast_normal: 4.5,
3452            min_contrast_large: 3.0,
3453        };
3454        let report = A11yChecker::check_with_config(&container, &config);
3455        assert!(report.violations.iter().any(|v| v.rule == "heading-order"));
3456    }
3457
3458    #[test]
3459    fn test_heading_level_no_name() {
3460        // Widget with no accessible_name
3461        let level = A11yChecker::heading_level(&MockGenericWidget);
3462        // Should default to level 2
3463        assert_eq!(level, Some(2));
3464    }
3465
3466    #[test]
3467    fn test_heading_level_non_heading_pattern() {
3468        // Widget with name that doesn't start with 'h' or 'H'
3469        let widget = MockLabel::new("Welcome Section");
3470        let level = A11yChecker::heading_level(&widget);
3471        // Should default to level 2
3472        assert_eq!(level, Some(2));
3473    }
3474
3475    // Test FormAccessibility with aria_labelledby
3476    #[test]
3477    fn test_form_with_aria_labelledby() {
3478        let mut form = FormAccessibility::new();
3479        form.aria_labelledby = Some("form-title".to_string());
3480        form.fields.push(
3481            FormFieldA11y::new("email")
3482                .with_label("Email")
3483                .with_type(InputType::Email)
3484                .with_autocomplete(AutocompleteValue::Email),
3485        );
3486
3487        let report = FormA11yChecker::check(&form);
3488        // aria-labelledby should satisfy form name requirement
3489        assert!(!report
3490            .violations
3491            .iter()
3492            .any(|v| v.rule == FormA11yRule::FormMissingName));
3493    }
3494
3495    // Test focus indicator check
3496    #[test]
3497    fn test_focus_indicator_check() {
3498        let widget = MockButton::new().with_name("Test Button");
3499        let config = A11yConfig {
3500            check_touch_targets: false,
3501            check_heading_hierarchy: false,
3502            check_focus_indicators: true,
3503            min_contrast_normal: 4.5,
3504            min_contrast_large: 3.0,
3505        };
3506        let report = A11yChecker::check_with_config(&widget, &config);
3507        // MockButton returns true for is_focusable, so has_visible_focus_indicator returns true
3508        assert!(!report.violations.iter().any(|v| v.rule == "focus-visible"));
3509    }
3510
3511    // Test default CheckContext values
3512    #[test]
3513    fn test_check_default_context() {
3514        let widget = MockButton::new().with_name("Test Button");
3515        // Default check (no config) should not check touch targets by default
3516        // because CheckContext::default() has check_touch_targets: false
3517        let report = A11yChecker::check(&widget);
3518        // Just verify it runs without error
3519        assert!(report.is_passing());
3520    }
3521
3522    // Test A11yViolation clone
3523    #[test]
3524    fn test_violation_clone() {
3525        let violation = A11yViolation {
3526            rule: "test".to_string(),
3527            message: "test message".to_string(),
3528            wcag: "1.1.1".to_string(),
3529            impact: Impact::Minor,
3530        };
3531        let cloned = violation.clone();
3532        assert_eq!(cloned.rule, "test");
3533        assert_eq!(cloned.impact, Impact::Minor);
3534    }
3535
3536    // Test FormViolation clone
3537    #[test]
3538    fn test_form_violation_clone() {
3539        let violation = FormViolation {
3540            field_id: "test".to_string(),
3541            rule: FormA11yRule::MissingLabel,
3542            message: "test".to_string(),
3543            wcag: "1.3.1".to_string(),
3544            impact: Impact::Critical,
3545        };
3546        let cloned = violation.clone();
3547        assert_eq!(cloned.field_id, "test");
3548        assert_eq!(cloned.rule, FormA11yRule::MissingLabel);
3549    }
3550
3551    // Test AriaAttributes Default
3552    #[test]
3553    fn test_aria_attributes_default() {
3554        let attrs = AriaAttributes::default();
3555        assert!(attrs.role.is_none());
3556        assert!(attrs.label.is_none());
3557        assert!(!attrs.hidden);
3558        assert!(!attrs.disabled);
3559        assert!(!attrs.required);
3560        assert!(!attrs.invalid);
3561        assert!(!attrs.busy);
3562        assert!(!attrs.atomic);
3563    }
3564
3565    // Test ContrastResult fields
3566    #[test]
3567    fn test_contrast_result_clone() {
3568        let result = ContrastResult {
3569            ratio: 4.5,
3570            passes_aa: true,
3571            passes_aaa: false,
3572        };
3573        let cloned = result.clone();
3574        assert!((cloned.ratio - 4.5).abs() < f32::EPSILON);
3575        assert!(cloned.passes_aa);
3576        assert!(!cloned.passes_aaa);
3577    }
3578
3579    // Test FormFieldGroup builder
3580    #[test]
3581    fn test_form_field_group_builder() {
3582        let group = FormFieldGroup::new("personal-info")
3583            .with_legend("Personal Information")
3584            .with_field("first_name")
3585            .with_field("last_name");
3586
3587        assert_eq!(group.id, "personal-info");
3588        assert_eq!(group.legend, Some("Personal Information".to_string()));
3589        assert_eq!(group.field_ids.len(), 2);
3590        assert!(group.field_ids.contains(&"first_name".to_string()));
3591        assert!(group.field_ids.contains(&"last_name".to_string()));
3592    }
3593}