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        Canvas, Constraints, Event, Rect, Size, TypeId,
1360    };
1361    use std::any::Any;
1362
1363    // Mock interactive widget
1364    struct MockButton {
1365        accessible_name: Option<String>,
1366        focusable: bool,
1367    }
1368
1369    impl MockButton {
1370        fn new() -> Self {
1371            Self {
1372                accessible_name: None,
1373                focusable: true,
1374            }
1375        }
1376
1377        fn with_name(mut self, name: &str) -> Self {
1378            self.accessible_name = Some(name.to_string());
1379            self
1380        }
1381
1382        fn not_focusable(mut self) -> Self {
1383            self.focusable = false;
1384            self
1385        }
1386    }
1387
1388    impl Widget for MockButton {
1389        fn type_id(&self) -> TypeId {
1390            TypeId::of::<Self>()
1391        }
1392        fn measure(&self, c: Constraints) -> Size {
1393            c.smallest()
1394        }
1395        fn layout(&mut self, b: Rect) -> LayoutResult {
1396            LayoutResult { size: b.size() }
1397        }
1398        fn paint(&self, _: &mut dyn Canvas) {}
1399        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1400            None
1401        }
1402        fn children(&self) -> &[Box<dyn Widget>] {
1403            &[]
1404        }
1405        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1406            &mut []
1407        }
1408        fn is_interactive(&self) -> bool {
1409            true
1410        }
1411        fn is_focusable(&self) -> bool {
1412            self.focusable
1413        }
1414        fn accessible_name(&self) -> Option<&str> {
1415            self.accessible_name.as_deref()
1416        }
1417        fn accessible_role(&self) -> AccessibleRole {
1418            AccessibleRole::Button
1419        }
1420    }
1421
1422    #[test]
1423    fn test_a11y_passing() {
1424        let widget = MockButton::new().with_name("Submit");
1425        let report = A11yChecker::check(&widget);
1426        assert!(report.is_passing());
1427    }
1428
1429    #[test]
1430    fn test_a11y_missing_name() {
1431        let widget = MockButton::new();
1432        let report = A11yChecker::check(&widget);
1433        assert!(!report.is_passing());
1434        assert_eq!(report.violations.len(), 1);
1435        assert_eq!(report.violations[0].rule, "aria-label");
1436    }
1437
1438    #[test]
1439    fn test_a11y_not_focusable() {
1440        let widget = MockButton::new().with_name("OK").not_focusable();
1441        let report = A11yChecker::check(&widget);
1442        assert!(!report.is_passing());
1443        assert!(report.violations.iter().any(|v| v.rule == "keyboard"));
1444    }
1445
1446    #[test]
1447    fn test_contrast_black_white() {
1448        let result = A11yChecker::check_contrast(&Color::BLACK, &Color::WHITE, false);
1449        assert!(result.passes_aa);
1450        assert!(result.passes_aaa);
1451        assert!((result.ratio - 21.0).abs() < 0.5);
1452    }
1453
1454    #[test]
1455    fn test_contrast_low() {
1456        let light_gray = Color::rgb(0.7, 0.7, 0.7);
1457        let white = Color::WHITE;
1458        let result = A11yChecker::check_contrast(&light_gray, &white, false);
1459        assert!(!result.passes_aa);
1460    }
1461
1462    #[test]
1463    fn test_contrast_large_text_threshold() {
1464        // Gray that passes AA for large text but not for normal text
1465        let gray = Color::rgb(0.5, 0.5, 0.5);
1466        let white = Color::WHITE;
1467
1468        let normal = A11yChecker::check_contrast(&gray, &white, false);
1469        let large = A11yChecker::check_contrast(&gray, &white, true);
1470
1471        // Large text has lower threshold, should pass more easily
1472        assert!(large.passes_aa || large.ratio > normal.ratio - 1.0);
1473    }
1474
1475    #[test]
1476    fn test_report_critical() {
1477        let widget = MockButton::new().not_focusable();
1478        let report = A11yChecker::check(&widget);
1479        let critical = report.critical();
1480        assert!(!critical.is_empty());
1481    }
1482
1483    #[test]
1484    #[should_panic(expected = "Accessibility check failed")]
1485    fn test_assert_pass_fails() {
1486        let widget = MockButton::new();
1487        let report = A11yChecker::check(&widget);
1488        report.assert_pass();
1489    }
1490
1491    // =============================================================================
1492    // AriaAttributes tests
1493    // =============================================================================
1494
1495    #[test]
1496    fn test_aria_attributes_new() {
1497        let attrs = AriaAttributes::new();
1498        assert!(attrs.role.is_none());
1499        assert!(attrs.label.is_none());
1500        assert!(!attrs.disabled);
1501    }
1502
1503    #[test]
1504    fn test_aria_attributes_with_role() {
1505        let attrs = AriaAttributes::new().with_role("button");
1506        assert_eq!(attrs.role, Some("button".to_string()));
1507    }
1508
1509    #[test]
1510    fn test_aria_attributes_with_label() {
1511        let attrs = AriaAttributes::new().with_label("Submit form");
1512        assert_eq!(attrs.label, Some("Submit form".to_string()));
1513    }
1514
1515    #[test]
1516    fn test_aria_attributes_with_expanded() {
1517        let attrs = AriaAttributes::new().with_expanded(true);
1518        assert_eq!(attrs.expanded, Some(true));
1519    }
1520
1521    #[test]
1522    fn test_aria_attributes_with_checked() {
1523        let attrs = AriaAttributes::new().with_checked(AriaChecked::True);
1524        assert_eq!(attrs.checked, Some(AriaChecked::True));
1525    }
1526
1527    #[test]
1528    fn test_aria_attributes_with_disabled() {
1529        let attrs = AriaAttributes::new().with_disabled(true);
1530        assert!(attrs.disabled);
1531    }
1532
1533    #[test]
1534    fn test_aria_attributes_with_value() {
1535        let attrs = AriaAttributes::new()
1536            .with_value_min(0.0)
1537            .with_value_max(100.0)
1538            .with_value_now(50.0);
1539        assert_eq!(attrs.value_min, Some(0.0));
1540        assert_eq!(attrs.value_max, Some(100.0));
1541        assert_eq!(attrs.value_now, Some(50.0));
1542    }
1543
1544    #[test]
1545    fn test_aria_attributes_with_live() {
1546        let attrs = AriaAttributes::new().with_live(AriaLive::Polite);
1547        assert_eq!(attrs.live, Some(AriaLive::Polite));
1548    }
1549
1550    #[test]
1551    fn test_aria_attributes_with_busy() {
1552        let attrs = AriaAttributes::new().with_busy(true);
1553        assert!(attrs.busy);
1554    }
1555
1556    #[test]
1557    fn test_aria_attributes_with_atomic() {
1558        let attrs = AriaAttributes::new().with_atomic(true);
1559        assert!(attrs.atomic);
1560    }
1561
1562    #[test]
1563    fn test_aria_attributes_with_has_popup() {
1564        let attrs = AriaAttributes::new().with_has_popup("menu");
1565        assert_eq!(attrs.has_popup, Some("menu".to_string()));
1566    }
1567
1568    #[test]
1569    fn test_aria_attributes_with_controls() {
1570        let attrs = AriaAttributes::new().with_controls("panel-1");
1571        assert_eq!(attrs.controls, Some("panel-1".to_string()));
1572    }
1573
1574    #[test]
1575    fn test_aria_attributes_with_described_by() {
1576        let attrs = AriaAttributes::new().with_described_by("desc-1");
1577        assert_eq!(attrs.described_by, Some("desc-1".to_string()));
1578    }
1579
1580    #[test]
1581    fn test_aria_attributes_with_hidden() {
1582        let attrs = AriaAttributes::new().with_hidden(true);
1583        assert!(attrs.hidden);
1584    }
1585
1586    #[test]
1587    fn test_aria_attributes_with_pressed() {
1588        let attrs = AriaAttributes::new().with_pressed(AriaChecked::Mixed);
1589        assert_eq!(attrs.pressed, Some(AriaChecked::Mixed));
1590    }
1591
1592    #[test]
1593    fn test_aria_attributes_with_selected() {
1594        let attrs = AriaAttributes::new().with_selected(true);
1595        assert_eq!(attrs.selected, Some(true));
1596    }
1597
1598    #[test]
1599    fn test_aria_attributes_with_level() {
1600        let attrs = AriaAttributes::new().with_level(2);
1601        assert_eq!(attrs.level, Some(2));
1602    }
1603
1604    #[test]
1605    fn test_aria_attributes_chained_builder() {
1606        let attrs = AriaAttributes::new()
1607            .with_role("checkbox")
1608            .with_label("Accept terms")
1609            .with_checked(AriaChecked::False)
1610            .with_disabled(false);
1611
1612        assert_eq!(attrs.role, Some("checkbox".to_string()));
1613        assert_eq!(attrs.label, Some("Accept terms".to_string()));
1614        assert_eq!(attrs.checked, Some(AriaChecked::False));
1615        assert!(!attrs.disabled);
1616    }
1617
1618    // =============================================================================
1619    // AriaChecked tests
1620    // =============================================================================
1621
1622    #[test]
1623    fn test_aria_checked_as_str() {
1624        assert_eq!(AriaChecked::True.as_str(), "true");
1625        assert_eq!(AriaChecked::False.as_str(), "false");
1626        assert_eq!(AriaChecked::Mixed.as_str(), "mixed");
1627    }
1628
1629    // =============================================================================
1630    // AriaLive tests
1631    // =============================================================================
1632
1633    #[test]
1634    fn test_aria_live_as_str() {
1635        assert_eq!(AriaLive::Off.as_str(), "off");
1636        assert_eq!(AriaLive::Polite.as_str(), "polite");
1637        assert_eq!(AriaLive::Assertive.as_str(), "assertive");
1638    }
1639
1640    // =============================================================================
1641    // to_html_attrs tests
1642    // =============================================================================
1643
1644    #[test]
1645    fn test_to_html_attrs_empty() {
1646        let attrs = AriaAttributes::new();
1647        let html_attrs = attrs.to_html_attrs();
1648        assert!(html_attrs.is_empty());
1649    }
1650
1651    #[test]
1652    fn test_to_html_attrs_role() {
1653        let attrs = AriaAttributes::new().with_role("button");
1654        let html_attrs = attrs.to_html_attrs();
1655        assert_eq!(html_attrs.len(), 1);
1656        assert_eq!(html_attrs[0], ("role".to_string(), "button".to_string()));
1657    }
1658
1659    #[test]
1660    fn test_to_html_attrs_label() {
1661        let attrs = AriaAttributes::new().with_label("Submit");
1662        let html_attrs = attrs.to_html_attrs();
1663        assert_eq!(html_attrs.len(), 1);
1664        assert_eq!(
1665            html_attrs[0],
1666            ("aria-label".to_string(), "Submit".to_string())
1667        );
1668    }
1669
1670    #[test]
1671    fn test_to_html_attrs_disabled() {
1672        let attrs = AriaAttributes::new().with_disabled(true);
1673        let html_attrs = attrs.to_html_attrs();
1674        assert_eq!(html_attrs.len(), 1);
1675        assert_eq!(
1676            html_attrs[0],
1677            ("aria-disabled".to_string(), "true".to_string())
1678        );
1679    }
1680
1681    #[test]
1682    fn test_to_html_attrs_checked() {
1683        let attrs = AriaAttributes::new().with_checked(AriaChecked::Mixed);
1684        let html_attrs = attrs.to_html_attrs();
1685        assert_eq!(html_attrs.len(), 1);
1686        assert_eq!(
1687            html_attrs[0],
1688            ("aria-checked".to_string(), "mixed".to_string())
1689        );
1690    }
1691
1692    #[test]
1693    fn test_to_html_attrs_expanded() {
1694        let attrs = AriaAttributes::new().with_expanded(false);
1695        let html_attrs = attrs.to_html_attrs();
1696        assert_eq!(html_attrs.len(), 1);
1697        assert_eq!(
1698            html_attrs[0],
1699            ("aria-expanded".to_string(), "false".to_string())
1700        );
1701    }
1702
1703    #[test]
1704    fn test_to_html_attrs_value_range() {
1705        let attrs = AriaAttributes::new()
1706            .with_value_now(50.0)
1707            .with_value_min(0.0)
1708            .with_value_max(100.0);
1709        let html_attrs = attrs.to_html_attrs();
1710        assert_eq!(html_attrs.len(), 3);
1711        assert!(html_attrs.contains(&("aria-valuenow".to_string(), "50".to_string())));
1712        assert!(html_attrs.contains(&("aria-valuemin".to_string(), "0".to_string())));
1713        assert!(html_attrs.contains(&("aria-valuemax".to_string(), "100".to_string())));
1714    }
1715
1716    #[test]
1717    fn test_to_html_attrs_live() {
1718        let attrs = AriaAttributes::new().with_live(AriaLive::Assertive);
1719        let html_attrs = attrs.to_html_attrs();
1720        assert_eq!(html_attrs.len(), 1);
1721        assert_eq!(
1722            html_attrs[0],
1723            ("aria-live".to_string(), "assertive".to_string())
1724        );
1725    }
1726
1727    #[test]
1728    fn test_to_html_attrs_hidden() {
1729        let attrs = AriaAttributes::new().with_hidden(true);
1730        let html_attrs = attrs.to_html_attrs();
1731        assert_eq!(html_attrs.len(), 1);
1732        assert_eq!(
1733            html_attrs[0],
1734            ("aria-hidden".to_string(), "true".to_string())
1735        );
1736    }
1737
1738    #[test]
1739    fn test_to_html_attrs_multiple() {
1740        let attrs = AriaAttributes::new()
1741            .with_role("slider")
1742            .with_label("Volume")
1743            .with_value_now(75.0)
1744            .with_value_min(0.0)
1745            .with_value_max(100.0);
1746        let html_attrs = attrs.to_html_attrs();
1747        assert_eq!(html_attrs.len(), 5);
1748    }
1749
1750    // =============================================================================
1751    // to_html_string tests
1752    // =============================================================================
1753
1754    #[test]
1755    fn test_to_html_string_empty() {
1756        let attrs = AriaAttributes::new();
1757        let html = attrs.to_html_string();
1758        assert_eq!(html, "");
1759    }
1760
1761    #[test]
1762    fn test_to_html_string_single() {
1763        let attrs = AriaAttributes::new().with_role("button");
1764        let html = attrs.to_html_string();
1765        assert_eq!(html, "role=\"button\"");
1766    }
1767
1768    #[test]
1769    fn test_to_html_string_multiple() {
1770        let attrs = AriaAttributes::new()
1771            .with_role("checkbox")
1772            .with_checked(AriaChecked::True);
1773        let html = attrs.to_html_string();
1774        assert!(html.contains("role=\"checkbox\""));
1775        assert!(html.contains("aria-checked=\"true\""));
1776    }
1777
1778    #[test]
1779    fn test_to_html_string_escapes_quotes() {
1780        let attrs = AriaAttributes::new().with_label("Click \"here\"");
1781        let html = attrs.to_html_string();
1782        assert!(html.contains("aria-label=\"Click &quot;here&quot;\""));
1783    }
1784
1785    // =============================================================================
1786    // aria_from_widget tests
1787    // =============================================================================
1788
1789    #[test]
1790    fn test_aria_from_widget_button() {
1791        let widget = MockButton::new().with_name("Submit");
1792        let attrs = aria_from_widget(&widget);
1793        assert_eq!(attrs.role, Some("button".to_string()));
1794        assert_eq!(attrs.label, Some("Submit".to_string()));
1795        assert!(!attrs.disabled);
1796    }
1797
1798    #[test]
1799    fn test_aria_from_widget_no_name() {
1800        let widget = MockButton::new();
1801        let attrs = aria_from_widget(&widget);
1802        assert_eq!(attrs.role, Some("button".to_string()));
1803        assert!(attrs.label.is_none());
1804    }
1805
1806    // Mock non-interactive widget for testing disabled state
1807    struct MockLabel {
1808        text: String,
1809    }
1810
1811    impl MockLabel {
1812        fn new(text: &str) -> Self {
1813            Self {
1814                text: text.to_string(),
1815            }
1816        }
1817    }
1818
1819    impl Widget for MockLabel {
1820        fn type_id(&self) -> TypeId {
1821            TypeId::of::<Self>()
1822        }
1823        fn measure(&self, c: Constraints) -> Size {
1824            c.smallest()
1825        }
1826        fn layout(&mut self, b: Rect) -> LayoutResult {
1827            LayoutResult { size: b.size() }
1828        }
1829        fn paint(&self, _: &mut dyn Canvas) {}
1830        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1831            None
1832        }
1833        fn children(&self) -> &[Box<dyn Widget>] {
1834            &[]
1835        }
1836        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1837            &mut []
1838        }
1839        fn is_interactive(&self) -> bool {
1840            false
1841        }
1842        fn is_focusable(&self) -> bool {
1843            false
1844        }
1845        fn accessible_name(&self) -> Option<&str> {
1846            Some(&self.text)
1847        }
1848        fn accessible_role(&self) -> AccessibleRole {
1849            AccessibleRole::Heading
1850        }
1851    }
1852
1853    #[test]
1854    fn test_aria_from_widget_non_interactive() {
1855        let widget = MockLabel::new("Welcome");
1856        let attrs = aria_from_widget(&widget);
1857        assert_eq!(attrs.role, Some("heading".to_string()));
1858        assert_eq!(attrs.label, Some("Welcome".to_string()));
1859        assert!(attrs.disabled);
1860    }
1861
1862    // Mock generic widget that returns Generic role
1863    struct MockGenericWidget;
1864
1865    impl Widget for MockGenericWidget {
1866        fn type_id(&self) -> TypeId {
1867            TypeId::of::<Self>()
1868        }
1869        fn measure(&self, c: Constraints) -> Size {
1870            c.smallest()
1871        }
1872        fn layout(&mut self, b: Rect) -> LayoutResult {
1873            LayoutResult { size: b.size() }
1874        }
1875        fn paint(&self, _: &mut dyn Canvas) {}
1876        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1877            None
1878        }
1879        fn children(&self) -> &[Box<dyn Widget>] {
1880            &[]
1881        }
1882        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1883            &mut []
1884        }
1885        fn is_interactive(&self) -> bool {
1886            false
1887        }
1888        fn is_focusable(&self) -> bool {
1889            false
1890        }
1891        fn accessible_name(&self) -> Option<&str> {
1892            None
1893        }
1894        fn accessible_role(&self) -> AccessibleRole {
1895            AccessibleRole::Generic
1896        }
1897    }
1898
1899    #[test]
1900    fn test_aria_from_widget_generic() {
1901        let widget = MockGenericWidget;
1902        let attrs = aria_from_widget(&widget);
1903        assert!(attrs.role.is_none());
1904        assert!(attrs.label.is_none());
1905        // Generic role shouldn't trigger disabled state
1906        assert!(!attrs.disabled);
1907    }
1908
1909    // =============================================================================
1910    // A11yConfig tests
1911    // =============================================================================
1912
1913    #[test]
1914    fn test_a11y_config_default() {
1915        let config = A11yConfig::default();
1916        assert!(config.check_touch_targets);
1917        assert!(config.check_heading_hierarchy);
1918        assert!(!config.check_focus_indicators);
1919        assert!((config.min_contrast_normal - 4.5).abs() < 0.01);
1920    }
1921
1922    #[test]
1923    fn test_a11y_config_strict() {
1924        let config = A11yConfig::strict();
1925        assert!(config.check_touch_targets);
1926        assert!(config.check_heading_hierarchy);
1927        assert!(config.check_focus_indicators);
1928        assert!((config.min_contrast_normal - 7.0).abs() < 0.01);
1929    }
1930
1931    #[test]
1932    fn test_a11y_config_mobile() {
1933        let config = A11yConfig::mobile();
1934        assert!(config.check_touch_targets);
1935        assert!(config.check_heading_hierarchy);
1936        assert!(!config.check_focus_indicators);
1937    }
1938
1939    // =============================================================================
1940    // WCAG constant tests
1941    // =============================================================================
1942
1943    #[test]
1944    fn test_min_touch_target_size() {
1945        assert_eq!(MIN_TOUCH_TARGET_SIZE, 44.0);
1946    }
1947
1948    #[test]
1949    fn test_min_focus_indicator_area() {
1950        assert_eq!(MIN_FOCUS_INDICATOR_AREA, 2.0);
1951    }
1952
1953    // =============================================================================
1954    // Check with config tests
1955    // =============================================================================
1956
1957    #[test]
1958    fn test_check_with_config() {
1959        let widget = MockButton::new().with_name("OK");
1960        // Use config without touch target check since mock widgets have 0x0 bounds
1961        let config = A11yConfig {
1962            check_touch_targets: false,
1963            check_heading_hierarchy: true,
1964            check_focus_indicators: false,
1965            min_contrast_normal: 4.5,
1966            min_contrast_large: 3.0,
1967        };
1968        let report = A11yChecker::check_with_config(&widget, &config);
1969        assert!(report.is_passing());
1970    }
1971
1972    // =============================================================================
1973    // Image alt text tests
1974    // =============================================================================
1975
1976    struct MockImage {
1977        alt_text: Option<String>,
1978    }
1979
1980    impl MockImage {
1981        fn new() -> Self {
1982            Self { alt_text: None }
1983        }
1984
1985        fn with_alt(mut self, alt: &str) -> Self {
1986            self.alt_text = Some(alt.to_string());
1987            self
1988        }
1989    }
1990
1991    impl Widget for MockImage {
1992        fn type_id(&self) -> TypeId {
1993            TypeId::of::<Self>()
1994        }
1995        fn measure(&self, c: Constraints) -> Size {
1996            c.smallest()
1997        }
1998        fn layout(&mut self, b: Rect) -> LayoutResult {
1999            LayoutResult { size: b.size() }
2000        }
2001        fn paint(&self, _: &mut dyn Canvas) {}
2002        fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
2003            None
2004        }
2005        fn children(&self) -> &[Box<dyn Widget>] {
2006            &[]
2007        }
2008        fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
2009            &mut []
2010        }
2011        fn is_interactive(&self) -> bool {
2012            false
2013        }
2014        fn is_focusable(&self) -> bool {
2015            false
2016        }
2017        fn accessible_name(&self) -> Option<&str> {
2018            self.alt_text.as_deref()
2019        }
2020        fn accessible_role(&self) -> AccessibleRole {
2021            AccessibleRole::Image
2022        }
2023    }
2024
2025    #[test]
2026    fn test_image_missing_alt() {
2027        let widget = MockImage::new();
2028        let report = A11yChecker::check(&widget);
2029        assert!(!report.is_passing());
2030        assert!(report.violations.iter().any(|v| v.rule == "image-alt"));
2031    }
2032
2033    #[test]
2034    fn test_image_with_alt() {
2035        let widget = MockImage::new().with_alt("A sunset over the ocean");
2036        let report = A11yChecker::check(&widget);
2037        // Image with alt text should pass the image-alt check
2038        assert!(!report.violations.iter().any(|v| v.rule == "image-alt"));
2039    }
2040
2041    // =============================================================================
2042    // Impact ordering tests
2043    // =============================================================================
2044
2045    #[test]
2046    fn test_impact_equality() {
2047        assert_eq!(Impact::Minor, Impact::Minor);
2048        assert_eq!(Impact::Moderate, Impact::Moderate);
2049        assert_eq!(Impact::Serious, Impact::Serious);
2050        assert_eq!(Impact::Critical, Impact::Critical);
2051        assert_ne!(Impact::Minor, Impact::Critical);
2052    }
2053
2054    // =============================================================================
2055    // Form Accessibility Tests
2056    // =============================================================================
2057
2058    #[test]
2059    fn test_form_field_passing() {
2060        let form = FormAccessibility::new().with_name("Login Form").field(
2061            FormFieldA11y::new("email")
2062                .with_label("Email Address")
2063                .with_type(InputType::Email)
2064                .with_autocomplete(AutocompleteValue::Email),
2065        );
2066
2067        let report = FormA11yChecker::check(&form);
2068        assert!(report.is_passing());
2069    }
2070
2071    #[test]
2072    fn test_form_field_missing_label() {
2073        let form = FormAccessibility::new()
2074            .with_name("Test Form")
2075            .field(FormFieldA11y::new("email").with_type(InputType::Email));
2076
2077        let report = FormA11yChecker::check(&form);
2078        assert!(!report.is_passing());
2079        assert!(report
2080            .violations
2081            .iter()
2082            .any(|v| v.rule == FormA11yRule::MissingLabel));
2083    }
2084
2085    #[test]
2086    fn test_form_field_aria_label_counts() {
2087        let form = FormAccessibility::new().with_name("Test Form").field(
2088            FormFieldA11y::new("search")
2089                .with_type(InputType::Search)
2090                .with_aria_label("Search products"),
2091        );
2092
2093        let report = FormA11yChecker::check(&form);
2094        // aria-label should satisfy label requirement
2095        assert!(!report
2096            .violations
2097            .iter()
2098            .any(|v| v.rule == FormA11yRule::MissingLabel));
2099    }
2100
2101    #[test]
2102    fn test_form_required_missing_aria() {
2103        let form = FormAccessibility::new().with_name("Test Form").field(
2104            FormFieldA11y::new("name")
2105                .with_label("Full Name")
2106                .with_required(true, false), // Visual but no aria
2107        );
2108
2109        let report = FormA11yChecker::check(&form);
2110        assert!(report
2111            .violations
2112            .iter()
2113            .any(|v| v.rule == FormA11yRule::MissingRequiredIndicator));
2114    }
2115
2116    #[test]
2117    fn test_form_required_missing_visual() {
2118        let form = FormAccessibility::new().with_name("Test Form").field({
2119            let mut field = FormFieldA11y::new("name").with_label("Full Name");
2120            field.required = true;
2121            field.aria_required = true;
2122            field.has_visual_required_indicator = false;
2123            field
2124        });
2125
2126        let report = FormA11yChecker::check(&form);
2127        assert!(report
2128            .violations
2129            .iter()
2130            .any(|v| v.rule == FormA11yRule::MissingVisualRequired));
2131    }
2132
2133    #[test]
2134    fn test_form_required_proper() {
2135        let form = FormAccessibility::new().with_name("Test Form").field(
2136            FormFieldA11y::new("name")
2137                .with_label("Full Name")
2138                .required(), // Sets both visual and aria
2139        );
2140
2141        let report = FormA11yChecker::check(&form);
2142        // Should not have required-related violations
2143        assert!(!report.violations.iter().any(|v| matches!(
2144            v.rule,
2145            FormA11yRule::MissingRequiredIndicator | FormA11yRule::MissingVisualRequired
2146        )));
2147    }
2148
2149    #[test]
2150    fn test_form_error_without_aria_invalid() {
2151        let form = FormAccessibility::new().with_name("Test Form").field({
2152            let mut field = FormFieldA11y::new("email")
2153                .with_label("Email")
2154                .with_type(InputType::Email);
2155            field.has_error = true;
2156            field.aria_invalid = false;
2157            field.error_message = Some("Invalid email".to_string());
2158            field
2159        });
2160
2161        let report = FormA11yChecker::check(&form);
2162        assert!(report
2163            .violations
2164            .iter()
2165            .any(|v| v.rule == FormA11yRule::MissingErrorState));
2166    }
2167
2168    #[test]
2169    fn test_form_error_without_message() {
2170        let form = FormAccessibility::new().with_name("Test Form").field({
2171            let mut field = FormFieldA11y::new("email")
2172                .with_label("Email")
2173                .with_type(InputType::Email);
2174            field.has_error = true;
2175            field.aria_invalid = true;
2176            // No error message
2177            field
2178        });
2179
2180        let report = FormA11yChecker::check(&form);
2181        assert!(report
2182            .violations
2183            .iter()
2184            .any(|v| v.rule == FormA11yRule::MissingErrorMessage));
2185    }
2186
2187    #[test]
2188    fn test_form_error_not_associated() {
2189        let form = FormAccessibility::new().with_name("Test Form").field({
2190            let mut field = FormFieldA11y::new("email")
2191                .with_label("Email")
2192                .with_type(InputType::Email);
2193            field.has_error = true;
2194            field.aria_invalid = true;
2195            field.error_message = Some("Invalid email".to_string());
2196            // No aria-describedby or aria-errormessage
2197            field
2198        });
2199
2200        let report = FormA11yChecker::check(&form);
2201        assert!(report
2202            .violations
2203            .iter()
2204            .any(|v| v.rule == FormA11yRule::ErrorNotAssociated));
2205    }
2206
2207    #[test]
2208    fn test_form_error_properly_associated() {
2209        let form = FormAccessibility::new().with_name("Test Form").field(
2210            FormFieldA11y::new("email")
2211                .with_label("Email")
2212                .with_type(InputType::Email)
2213                .with_autocomplete(AutocompleteValue::Email)
2214                .with_error("Please enter a valid email address", true),
2215        );
2216
2217        let report = FormA11yChecker::check(&form);
2218        // Should not have error-related violations
2219        assert!(!report.violations.iter().any(|v| matches!(
2220            v.rule,
2221            FormA11yRule::MissingErrorState
2222                | FormA11yRule::MissingErrorMessage
2223                | FormA11yRule::ErrorNotAssociated
2224        )));
2225    }
2226
2227    #[test]
2228    fn test_form_missing_autocomplete() {
2229        let form = FormAccessibility::new().with_name("Test Form").field(
2230            FormFieldA11y::new("email")
2231                .with_label("Email")
2232                .with_type(InputType::Email),
2233            // No autocomplete
2234        );
2235
2236        let report = FormA11yChecker::check(&form);
2237        assert!(report
2238            .violations
2239            .iter()
2240            .any(|v| v.rule == FormA11yRule::MissingAutocomplete));
2241    }
2242
2243    #[test]
2244    fn test_form_autocomplete_not_needed_for_checkbox() {
2245        let form = FormAccessibility::new().with_name("Test Form").field(
2246            FormFieldA11y::new("terms")
2247                .with_label("I agree to terms")
2248                .with_type(InputType::Checkbox),
2249        );
2250
2251        let report = FormA11yChecker::check(&form);
2252        // Checkbox doesn't need autocomplete
2253        assert!(!report
2254            .violations
2255            .iter()
2256            .any(|v| v.rule == FormA11yRule::MissingAutocomplete));
2257    }
2258
2259    #[test]
2260    fn test_form_placeholder_as_label() {
2261        let form = FormAccessibility::new().with_name("Test Form").field(
2262            FormFieldA11y::new("email")
2263                .with_type(InputType::Email)
2264                .with_placeholder("Enter your email"),
2265        );
2266
2267        let report = FormA11yChecker::check(&form);
2268        assert!(report
2269            .violations
2270            .iter()
2271            .any(|v| v.rule == FormA11yRule::PlaceholderAsLabel));
2272    }
2273
2274    #[test]
2275    fn test_form_placeholder_with_label_ok() {
2276        let form = FormAccessibility::new().with_name("Test Form").field(
2277            FormFieldA11y::new("email")
2278                .with_label("Email")
2279                .with_type(InputType::Email)
2280                .with_autocomplete(AutocompleteValue::Email)
2281                .with_placeholder("e.g., user@example.com"),
2282        );
2283
2284        let report = FormA11yChecker::check(&form);
2285        // Placeholder with label is fine
2286        assert!(!report
2287            .violations
2288            .iter()
2289            .any(|v| v.rule == FormA11yRule::PlaceholderAsLabel));
2290    }
2291
2292    #[test]
2293    fn test_form_radio_buttons_not_grouped() {
2294        let form = FormAccessibility::new()
2295            .with_name("Test Form")
2296            .field(
2297                FormFieldA11y::new("option1")
2298                    .with_label("Option 1")
2299                    .with_type(InputType::Radio),
2300            )
2301            .field(
2302                FormFieldA11y::new("option2")
2303                    .with_label("Option 2")
2304                    .with_type(InputType::Radio),
2305            );
2306
2307        let report = FormA11yChecker::check(&form);
2308        assert!(report
2309            .violations
2310            .iter()
2311            .any(|v| v.rule == FormA11yRule::RelatedFieldsNotGrouped));
2312    }
2313
2314    #[test]
2315    fn test_form_radio_buttons_properly_grouped() {
2316        let form = FormAccessibility::new()
2317            .with_name("Test Form")
2318            .field(
2319                FormFieldA11y::new("option1")
2320                    .with_label("Option 1")
2321                    .with_type(InputType::Radio),
2322            )
2323            .field(
2324                FormFieldA11y::new("option2")
2325                    .with_label("Option 2")
2326                    .with_type(InputType::Radio),
2327            )
2328            .group(
2329                FormFieldGroup::new("options")
2330                    .with_legend("Choose an option")
2331                    .with_field("option1")
2332                    .with_field("option2"),
2333            );
2334
2335        let report = FormA11yChecker::check(&form);
2336        assert!(!report
2337            .violations
2338            .iter()
2339            .any(|v| v.rule == FormA11yRule::RelatedFieldsNotGrouped));
2340    }
2341
2342    #[test]
2343    fn test_form_group_missing_legend() {
2344        let form = FormAccessibility::new().with_name("Test Form").group(
2345            FormFieldGroup::new("address")
2346                .with_field("street")
2347                .with_field("city"),
2348        );
2349
2350        let report = FormA11yChecker::check(&form);
2351        assert!(report
2352            .violations
2353            .iter()
2354            .any(|v| v.rule == FormA11yRule::GroupMissingLegend));
2355    }
2356
2357    #[test]
2358    fn test_form_missing_accessible_name() {
2359        let form = FormAccessibility::new().field(
2360            FormFieldA11y::new("email")
2361                .with_label("Email")
2362                .with_type(InputType::Email)
2363                .with_autocomplete(AutocompleteValue::Email),
2364        );
2365
2366        let report = FormA11yChecker::check(&form);
2367        assert!(report
2368            .violations
2369            .iter()
2370            .any(|v| v.rule == FormA11yRule::FormMissingName));
2371    }
2372
2373    #[test]
2374    fn test_form_report_violations_for_field() {
2375        let form = FormAccessibility::new()
2376            .with_name("Test Form")
2377            .field(FormFieldA11y::new("bad_field").with_type(InputType::Email))
2378            .field(
2379                FormFieldA11y::new("good_field")
2380                    .with_label("Good Field")
2381                    .with_type(InputType::Text),
2382            );
2383
2384        let report = FormA11yChecker::check(&form);
2385        let bad_violations = report.violations_for_field("bad_field");
2386        assert!(!bad_violations.is_empty());
2387
2388        let good_violations = report.violations_for_field("good_field");
2389        // good_field should only have autocomplete warning (if text type)
2390        assert!(good_violations.len() <= 1);
2391    }
2392
2393    #[test]
2394    fn test_form_report_is_acceptable() {
2395        // Form with only moderate violations should be acceptable
2396        let form = FormAccessibility::new().with_name("Test Form").field(
2397            FormFieldA11y::new("email")
2398                .with_label("Email")
2399                .with_type(InputType::Email),
2400            // Missing autocomplete is Moderate impact
2401        );
2402
2403        let report = FormA11yChecker::check(&form);
2404        assert!(report.is_acceptable()); // Only Moderate violations
2405    }
2406
2407    #[test]
2408    fn test_input_type_should_have_autocomplete() {
2409        assert!(InputType::Email.should_have_autocomplete());
2410        assert!(InputType::Password.should_have_autocomplete());
2411        assert!(InputType::Tel.should_have_autocomplete());
2412        assert!(InputType::Text.should_have_autocomplete());
2413        assert!(!InputType::Checkbox.should_have_autocomplete());
2414        assert!(!InputType::Radio.should_have_autocomplete());
2415        assert!(!InputType::Date.should_have_autocomplete());
2416    }
2417
2418    #[test]
2419    fn test_autocomplete_value_as_str() {
2420        assert_eq!(AutocompleteValue::Email.as_str(), "email");
2421        assert_eq!(AutocompleteValue::GivenName.as_str(), "given-name");
2422        assert_eq!(
2423            AutocompleteValue::CurrentPassword.as_str(),
2424            "current-password"
2425        );
2426        assert_eq!(AutocompleteValue::Off.as_str(), "off");
2427    }
2428
2429    #[test]
2430    fn test_form_a11y_rule_name() {
2431        assert_eq!(FormA11yRule::MissingLabel.name(), "missing-label");
2432        assert_eq!(
2433            FormA11yRule::MissingRequiredIndicator.name(),
2434            "missing-required-indicator"
2435        );
2436        assert_eq!(
2437            FormA11yRule::PlaceholderAsLabel.name(),
2438            "placeholder-as-label"
2439        );
2440    }
2441
2442    #[test]
2443    fn test_form_violations_for_rule() {
2444        let form = FormAccessibility::new()
2445            .with_name("Test Form")
2446            .field(FormFieldA11y::new("field1").with_type(InputType::Email))
2447            .field(FormFieldA11y::new("field2").with_type(InputType::Email));
2448
2449        let report = FormA11yChecker::check(&form);
2450        let missing_labels = report.violations_for_rule(FormA11yRule::MissingLabel);
2451        assert_eq!(missing_labels.len(), 2); // Both fields missing labels
2452    }
2453
2454    #[test]
2455    fn test_form_complete_signup_form() {
2456        // Test a complete, accessible signup form
2457        let form = FormAccessibility::new()
2458            .with_name("Create Account")
2459            .field(
2460                FormFieldA11y::new("first_name")
2461                    .with_label("First Name")
2462                    .with_type(InputType::Text)
2463                    .with_autocomplete(AutocompleteValue::GivenName)
2464                    .required(),
2465            )
2466            .field(
2467                FormFieldA11y::new("last_name")
2468                    .with_label("Last Name")
2469                    .with_type(InputType::Text)
2470                    .with_autocomplete(AutocompleteValue::FamilyName)
2471                    .required(),
2472            )
2473            .field(
2474                FormFieldA11y::new("email")
2475                    .with_label("Email Address")
2476                    .with_type(InputType::Email)
2477                    .with_autocomplete(AutocompleteValue::Email)
2478                    .required(),
2479            )
2480            .field(
2481                FormFieldA11y::new("password")
2482                    .with_label("Password")
2483                    .with_type(InputType::Password)
2484                    .with_autocomplete(AutocompleteValue::NewPassword)
2485                    .required(),
2486            )
2487            .field(
2488                FormFieldA11y::new("terms")
2489                    .with_label("I agree to the Terms of Service")
2490                    .with_type(InputType::Checkbox)
2491                    .required(),
2492            );
2493
2494        let report = FormA11yChecker::check(&form);
2495        assert!(
2496            report.is_passing(),
2497            "Complete signup form should pass: {:?}",
2498            report.violations
2499        );
2500    }
2501}