1use presentar_core::widget::AccessibleRole;
12use presentar_core::{Color, Widget};
13
14pub const MIN_TOUCH_TARGET_SIZE: f32 = 44.0;
16
17pub const MIN_FOCUS_INDICATOR_AREA: f32 = 2.0;
19
20pub struct A11yChecker;
22
23impl A11yChecker {
24 #[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 #[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 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 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 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 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 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 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 for child in widget.children() {
133 Self::check_widget(child.as_ref(), violations, context);
134 }
135 }
136
137 fn heading_level(widget: &dyn Widget) -> Option<u8> {
139 if let Some(name) = widget.accessible_name() {
142 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 Some(2)
153 }
154
155 fn has_visible_focus_indicator(widget: &dyn Widget) -> bool {
157 widget.is_focusable()
160 }
161
162 #[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 let (aa_threshold, aaa_threshold) = if large_text {
173 (3.0, 4.5) } else {
175 (4.5, 7.0) };
177
178 ContrastResult {
179 ratio,
180 passes_aa: ratio >= aa_threshold,
181 passes_aaa: ratio >= aaa_threshold,
182 }
183 }
184}
185
186#[derive(Debug)]
188pub struct A11yReport {
189 pub violations: Vec<A11yViolation>,
191}
192
193impl A11yReport {
194 #[must_use]
196 pub fn is_passing(&self) -> bool {
197 self.violations.is_empty()
198 }
199
200 #[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 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#[derive(Debug, Clone)]
238pub struct A11yViolation {
239 pub rule: String,
241 pub message: String,
243 pub wcag: String,
245 pub impact: Impact,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum Impact {
252 Minor,
254 Moderate,
256 Serious,
258 Critical,
260}
261
262#[derive(Debug, Clone)]
264pub struct A11yConfig {
265 pub check_touch_targets: bool,
267 pub check_heading_hierarchy: bool,
269 pub check_focus_indicators: bool,
271 pub min_contrast_normal: f32,
273 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, min_contrast_normal: 4.5,
284 min_contrast_large: 3.0,
285 }
286 }
287}
288
289impl A11yConfig {
290 #[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, min_contrast_large: 4.5, }
300 }
301
302 #[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#[derive(Debug, Default)]
317struct CheckContext {
318 last_heading_level: u8,
320 check_touch_targets: bool,
322 check_heading_hierarchy: bool,
324 check_focus_indicators: bool,
326}
327
328#[derive(Debug, Clone)]
330pub struct ContrastResult {
331 pub ratio: f32,
333 pub passes_aa: bool,
335 pub passes_aaa: bool,
337}
338
339#[derive(Debug, Clone, Default)]
345pub struct AriaAttributes {
346 pub role: Option<String>,
348 pub label: Option<String>,
350 pub described_by: Option<String>,
352 pub hidden: bool,
354 pub expanded: Option<bool>,
356 pub selected: Option<bool>,
358 pub checked: Option<AriaChecked>,
360 pub pressed: Option<AriaChecked>,
362 pub disabled: bool,
364 pub required: bool,
366 pub invalid: bool,
368 pub value_now: Option<f64>,
370 pub value_min: Option<f64>,
372 pub value_max: Option<f64>,
374 pub value_text: Option<String>,
376 pub level: Option<u8>,
378 pub pos_in_set: Option<u32>,
380 pub set_size: Option<u32>,
382 pub controls: Option<String>,
384 pub has_popup: Option<String>,
386 pub busy: bool,
388 pub live: Option<AriaLive>,
390 pub atomic: bool,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum AriaChecked {
397 True,
399 False,
401 Mixed,
403}
404
405impl AriaChecked {
406 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum AriaLive {
420 Polite,
422 Assertive,
424 Off,
426}
427
428impl AriaLive {
429 #[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 #[must_use]
443 pub fn new() -> Self {
444 Self::default()
445 }
446
447 #[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 #[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 #[must_use]
463 pub const fn with_hidden(mut self, hidden: bool) -> Self {
464 self.hidden = hidden;
465 self
466 }
467
468 #[must_use]
470 pub const fn with_expanded(mut self, expanded: bool) -> Self {
471 self.expanded = Some(expanded);
472 self
473 }
474
475 #[must_use]
477 pub const fn with_selected(mut self, selected: bool) -> Self {
478 self.selected = Some(selected);
479 self
480 }
481
482 #[must_use]
484 pub const fn with_checked(mut self, checked: AriaChecked) -> Self {
485 self.checked = Some(checked);
486 self
487 }
488
489 #[must_use]
491 pub const fn with_pressed(mut self, pressed: AriaChecked) -> Self {
492 self.pressed = Some(pressed);
493 self
494 }
495
496 #[must_use]
498 pub const fn with_disabled(mut self, disabled: bool) -> Self {
499 self.disabled = disabled;
500 self
501 }
502
503 #[must_use]
505 pub const fn with_busy(mut self, busy: bool) -> Self {
506 self.busy = busy;
507 self
508 }
509
510 #[must_use]
512 pub const fn with_atomic(mut self, atomic: bool) -> Self {
513 self.atomic = atomic;
514 self
515 }
516
517 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
570 pub const fn with_level(mut self, level: u8) -> Self {
571 self.level = Some(level);
572 self
573 }
574
575 #[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 #[must_use]
585 pub const fn with_live(mut self, live: AriaLive) -> Self {
586 self.live = Some(live);
587 self
588 }
589
590 #[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 #[must_use]
670 pub fn to_html_string(&self) -> String {
671 self.to_html_attrs()
672 .into_iter()
673 .map(|(k, v)| {
674 let escaped = v
676 .replace('&', "&")
677 .replace('"', """)
678 .replace('<', "<")
679 .replace('>', ">");
680 format!("{}=\"{}\"", k, escaped)
681 })
682 .collect::<Vec<_>>()
683 .join(" ")
684 }
685}
686
687pub fn aria_from_widget(widget: &dyn Widget) -> AriaAttributes {
689 use presentar_core::widget::AccessibleRole;
690
691 let mut attrs = AriaAttributes::new();
692
693 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 if let Some(name) = widget.accessible_name() {
724 attrs.label = Some(name.to_string());
725 }
726
727 if !widget.is_interactive() && widget.accessible_role() != AccessibleRole::Generic {
729 attrs.disabled = true;
730 }
731
732 attrs
733}
734
735pub struct FormA11yChecker;
741
742impl FormA11yChecker {
743 #[must_use]
752 pub fn check(form: &FormAccessibility) -> FormA11yReport {
753 let mut violations = Vec::new();
754
755 for field in &form.fields {
757 Self::check_field(field, &mut violations);
758 }
759
760 Self::check_form_level(form, &mut violations);
762
763 FormA11yReport { violations }
764 }
765
766 fn check_field(field: &FormFieldA11y, violations: &mut Vec<FormViolation>) {
768 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 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 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 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 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 fn check_form_level(form: &FormAccessibility, violations: &mut Vec<FormViolation>) {
877 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 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 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 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#[derive(Debug, Clone, Default)]
933pub struct FormAccessibility {
934 pub accessible_name: Option<String>,
936 pub aria_labelledby: Option<String>,
938 pub fields: Vec<FormFieldA11y>,
940 pub field_groups: Vec<FormFieldGroup>,
942}
943
944impl FormAccessibility {
945 #[must_use]
947 pub fn new() -> Self {
948 Self::default()
949 }
950
951 #[must_use]
953 pub fn field(mut self, field: FormFieldA11y) -> Self {
954 self.fields.push(field);
955 self
956 }
957
958 #[must_use]
960 pub fn group(mut self, group: FormFieldGroup) -> Self {
961 self.field_groups.push(group);
962 self
963 }
964
965 #[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#[derive(Debug, Clone, Default)]
975pub struct FormFieldA11y {
976 pub id: String,
978 pub label: Option<String>,
980 pub input_type: Option<InputType>,
982 pub required: bool,
984 pub has_visual_required_indicator: bool,
986 pub aria_required: bool,
988 pub aria_label: Option<String>,
990 pub aria_labelledby: Option<String>,
992 pub aria_describedby: Option<String>,
994 pub has_error: bool,
996 pub aria_invalid: bool,
998 pub aria_errormessage: Option<String>,
1000 pub error_message: Option<String>,
1002 pub autocomplete: Option<AutocompleteValue>,
1004 pub placeholder: Option<String>,
1006}
1007
1008impl FormFieldA11y {
1009 #[must_use]
1011 pub fn new(id: impl Into<String>) -> Self {
1012 Self {
1013 id: id.into(),
1014 ..Default::default()
1015 }
1016 }
1017
1018 #[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 #[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 #[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 #[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 #[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 #[must_use]
1064 pub fn with_autocomplete(mut self, value: AutocompleteValue) -> Self {
1065 self.autocomplete = Some(value);
1066 self
1067 }
1068
1069 #[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 #[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#[derive(Debug, Clone, Default)]
1086pub struct FormFieldGroup {
1087 pub id: String,
1089 pub legend: Option<String>,
1091 pub aria_label: Option<String>,
1093 pub field_ids: Vec<String>,
1095}
1096
1097impl FormFieldGroup {
1098 #[must_use]
1100 pub fn new(id: impl Into<String>) -> Self {
1101 Self {
1102 id: id.into(),
1103 ..Default::default()
1104 }
1105 }
1106
1107 #[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 #[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#[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1154pub enum AutocompleteValue {
1155 Name,
1157 GivenName,
1159 FamilyName,
1161 Email,
1163 Tel,
1165 StreetAddress,
1167 AddressLevel1,
1169 AddressLevel2,
1171 PostalCode,
1173 Country,
1175 Organization,
1177 Username,
1179 CurrentPassword,
1181 NewPassword,
1183 CcNumber,
1185 CcExp,
1187 CcCsc,
1189 OneTimeCode,
1191 Off,
1193}
1194
1195impl AutocompleteValue {
1196 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1225pub enum FormA11yRule {
1226 MissingLabel,
1228 MissingRequiredIndicator,
1230 MissingVisualRequired,
1232 MissingErrorState,
1234 MissingErrorMessage,
1236 ErrorNotAssociated,
1238 MissingAutocomplete,
1240 PlaceholderAsLabel,
1242 RelatedFieldsNotGrouped,
1244 GroupMissingLegend,
1246 FormMissingName,
1248}
1249
1250impl FormA11yRule {
1251 #[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#[derive(Debug, Clone)]
1272pub struct FormViolation {
1273 pub field_id: String,
1275 pub rule: FormA11yRule,
1277 pub message: String,
1279 pub wcag: String,
1281 pub impact: Impact,
1283}
1284
1285#[derive(Debug)]
1287pub struct FormA11yReport {
1288 pub violations: Vec<FormViolation>,
1290}
1291
1292impl FormA11yReport {
1293 #[must_use]
1295 pub fn is_passing(&self) -> bool {
1296 self.violations.is_empty()
1297 }
1298
1299 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 "here"\""));
1815 }
1816
1817 #[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 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 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 assert!(!attrs.disabled);
1999 }
2000
2001 #[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 #[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 #[test]
2050 fn test_check_with_config() {
2051 let widget = MockButton::new().with_name("OK");
2052 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 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 assert!(!report.violations.iter().any(|v| v.rule == "image-alt"));
2161 }
2162
2163 #[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 #[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 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), );
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(), );
2262
2263 let report = FormA11yChecker::check(&form);
2264 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 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 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 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 );
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 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 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 assert!(good_violations.len() <= 1);
2513 }
2514
2515 #[test]
2516 fn test_form_report_is_acceptable() {
2517 let form = FormAccessibility::new().with_name("Test Form").field(
2519 FormFieldA11y::new("email")
2520 .with_label("Email")
2521 .with_type(InputType::Email),
2522 );
2524
2525 let report = FormA11yChecker::check(&form);
2526 assert!(report.is_acceptable()); }
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); }
2575
2576 #[test]
2577 fn test_form_complete_signup_form() {
2578 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 #[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 & 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 < 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 > 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 #[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 #[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 #[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 #[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 #[test]
2835 fn test_heading_level_h1() {
2836 let widget = MockLabel::new("h1 Main Title");
2837 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 assert_eq!(level, Some(2));
2862 }
2863
2864 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 #[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 assert!(!report
2990 .violations
2991 .iter()
2992 .any(|v| v.rule == FormA11yRule::GroupMissingLegend));
2993 }
2994
2995 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 #[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 assert!(!report
3083 .violations
3084 .iter()
3085 .any(|v| v.rule == FormA11yRule::MissingLabel));
3086 }
3087
3088 #[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 assert!(!report
3106 .violations
3107 .iter()
3108 .any(|v| v.rule == FormA11yRule::ErrorNotAssociated));
3109 }
3110
3111 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 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 let h1 = MockHeadingWidget::new("h1 Main");
3379 let h3 = MockHeadingWidget::new("h3 Skipped");
3380
3381 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 let level = A11yChecker::heading_level(&MockGenericWidget);
3462 assert_eq!(level, Some(2));
3464 }
3465
3466 #[test]
3467 fn test_heading_level_non_heading_pattern() {
3468 let widget = MockLabel::new("Welcome Section");
3470 let level = A11yChecker::heading_level(&widget);
3471 assert_eq!(level, Some(2));
3473 }
3474
3475 #[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 assert!(!report
3490 .violations
3491 .iter()
3492 .any(|v| v.rule == FormA11yRule::FormMissingName));
3493 }
3494
3495 #[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 assert!(!report.violations.iter().any(|v| v.rule == "focus-visible"));
3509 }
3510
3511 #[test]
3513 fn test_check_default_context() {
3514 let widget = MockButton::new().with_name("Test Button");
3515 let report = A11yChecker::check(&widget);
3518 assert!(report.is_passing());
3520 }
3521
3522 #[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]
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]
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]
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]
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}