1use crate::utils::accessibility::{AccessibilityManager, AccessibleNode, Role};
19use std::collections::HashMap;
20
21pub struct A11yTestRunner {
23 manager: AccessibilityManager,
25 widgets: HashMap<String, AccessibleNode>,
27 announcements: Vec<String>,
29}
30
31impl A11yTestRunner {
32 pub fn new() -> Self {
34 Self {
35 manager: AccessibilityManager::new(),
36 widgets: HashMap::new(),
37 announcements: Vec::new(),
38 }
39 }
40
41 pub fn register_widget(&mut self, id: impl Into<String>, node: AccessibleNode) -> &mut Self {
43 let id = id.into();
44 self.manager.add_node(node.clone());
45 self.widgets.insert(id, node);
46 self
47 }
48
49 pub fn assert_focus_order(&self, expected_ids: &[&str]) {
59 let mut actual_order = Vec::new();
60
61 let interactive: Vec<_> = self
63 .widgets
64 .iter()
65 .filter(|(_, node)| node.is_focusable())
66 .collect();
67
68 let mut sorted: Vec<_> = interactive.into_iter().collect();
70 sorted.sort_by_key(|(id, node)| {
71 node.state.pos_in_set.unwrap_or_else(|| {
72 let digits: Vec<_> = id.chars().filter_map(|c| c.to_digit(10)).collect();
74 digits.first().copied().unwrap_or(0) as usize
75 })
76 });
77
78 for (id, _) in sorted {
79 actual_order.push(id.clone());
80 }
81
82 if actual_order != expected_ids {
83 panic!(
84 "Focus order mismatch:\nExpected: {:?}\nActual: {:?}",
85 expected_ids, actual_order
86 );
87 }
88 }
89
90 pub fn assert_aria_label(&self, widget_id: &str, expected_label: &str) {
100 if let Some(node) = self.widgets.get(widget_id) {
101 let label = node.label.as_deref().unwrap_or("");
102 assert_eq!(
103 label, expected_label,
104 "Widget '{}' has wrong aria-label: expected '{}', got '{}'",
105 widget_id, expected_label, label
106 );
107 } else {
108 panic!("Widget '{}' not found", widget_id);
109 }
110 }
111
112 pub fn assert_role(&self, widget_id: &str, expected_role: Role) {
123 if let Some(node) = self.widgets.get(widget_id) {
124 assert_eq!(
125 node.role, expected_role,
126 "Widget '{}' has wrong role: expected {:?}, got {:?}",
127 widget_id, expected_role, node.role
128 );
129 } else {
130 panic!("Widget '{}' not found", widget_id);
131 }
132 }
133
134 pub fn assert_required(&self, widget_id: &str) {
145 if let Some(node) = self.widgets.get(widget_id) {
146 if let Some(required) = node.properties.get("aria-required") {
147 assert_eq!(
148 required, "true",
149 "Widget '{}' should be required but aria-required={}",
150 widget_id, required
151 );
152 } else {
153 panic!("Widget '{}' is missing aria-required attribute", widget_id);
154 }
155 } else {
156 panic!("Widget '{}' not found", widget_id);
157 }
158 }
159
160 pub fn assert_not_required(&self, widget_id: &str) {
162 if let Some(node) = self.widgets.get(widget_id) {
163 if let Some(required) = node.properties.get("aria-required") {
164 assert_ne!(
165 required, "true",
166 "Widget '{}' should NOT be required but aria-required={}",
167 widget_id, required
168 );
169 }
170 } else {
171 panic!("Widget '{}' not found", widget_id);
172 }
173 }
174
175 pub fn assert_contrast_ratio(&self, widget_id: &str, min_ratio: f32) {
189 if let Some(node) = self.widgets.get(widget_id) {
190 let fg = node
192 .properties
193 .get("color-fg")
194 .and_then(|c| c.parse::<u8>().ok());
195
196 let bg = node
197 .properties
198 .get("color-bg")
199 .and_then(|c| c.parse::<u8>().ok());
200
201 if let (Some(fg), Some(bg)) = (fg, bg) {
202 let ratio = calculate_contrast_ratio(fg, bg).unwrap_or(1.0);
203 if ratio < min_ratio {
204 panic!(
205 "Widget '{}' fails contrast ratio: {:.2} < {:.1} (WCAG requirement)",
206 widget_id, ratio, min_ratio
207 );
208 }
209 }
210 }
211 }
212
213 pub fn assert_focusable(&self, widget_id: &str) {
215 if let Some(node) = self.widgets.get(widget_id) {
216 assert!(
217 node.is_focusable(),
218 "Widget '{}' should be focusable but is not",
219 widget_id
220 );
221 } else {
222 panic!("Widget '{}' not found", widget_id);
223 }
224 }
225
226 pub fn assert_not_focusable(&self, widget_id: &str) {
228 if let Some(node) = self.widgets.get(widget_id) {
229 assert!(
230 !node.is_focusable(),
231 "Widget '{}' should NOT be focusable but is",
232 widget_id
233 );
234 } else {
235 panic!("Widget '{}' not found", widget_id);
236 }
237 }
238
239 pub fn assert_disabled(&self, widget_id: &str) {
241 if let Some(node) = self.widgets.get(widget_id) {
242 assert!(
243 node.state.disabled,
244 "Widget '{}' should be disabled but is not",
245 widget_id
246 );
247 } else {
248 panic!("Widget '{}' not found", widget_id);
249 }
250 }
251
252 pub fn assert_enabled(&self, widget_id: &str) {
254 if let Some(node) = self.widgets.get(widget_id) {
255 assert!(
256 !node.state.disabled,
257 "Widget '{}' should be enabled but is disabled",
258 widget_id
259 );
260 } else {
261 panic!("Widget '{}' not found", widget_id);
262 }
263 }
264
265 pub fn accessible_name(&self, widget_id: &str) -> String {
267 if let Some(node) = self.widgets.get(widget_id) {
268 node.accessible_name().to_string()
269 } else {
270 panic!("Widget '{}' not found", widget_id);
271 }
272 }
273
274 pub fn screen_reader_description(&self, widget_id: &str) -> String {
276 if let Some(node) = self.widgets.get(widget_id) {
277 node.describe()
278 } else {
279 panic!("Widget '{}' not found", widget_id);
280 }
281 }
282
283 pub fn assert_announced(&self, expected_message: &str) {
285 let found = self
286 .announcements
287 .iter()
288 .any(|msg| msg.contains(expected_message));
289
290 assert!(
291 found,
292 "Expected announcement '{}' not found. Made: {:?}",
293 expected_message, self.announcements
294 );
295 }
296
297 pub fn announcements(&self) -> &[String] {
299 &self.announcements
300 }
301
302 pub fn clear_announcements(&mut self) {
304 self.announcements.clear();
305 }
306}
307
308impl Default for A11yTestRunner {
309 fn default() -> Self {
310 Self::new()
311 }
312}
313
314pub struct KeyboardNavigator {
316 focus_index: usize,
318 tab_order: Vec<String>,
320}
321
322impl KeyboardNavigator {
323 pub fn new(tab_order: Vec<String>) -> Self {
325 Self {
326 focus_index: 0,
327 tab_order,
328 }
329 }
330
331 pub fn current_focus(&self) -> Option<&str> {
333 self.tab_order.get(self.focus_index).map(|s| s.as_str())
334 }
335
336 pub fn tab(&mut self) -> Option<&str> {
338 if !self.tab_order.is_empty() {
339 self.focus_index = (self.focus_index + 1) % self.tab_order.len();
340 self.current_focus()
341 } else {
342 None
343 }
344 }
345
346 pub fn shift_tab(&mut self) -> Option<&str> {
348 if !self.tab_order.is_empty() {
349 self.focus_index = if self.focus_index == 0 {
350 self.tab_order.len() - 1
351 } else {
352 self.focus_index - 1
353 };
354 self.current_focus()
355 } else {
356 None
357 }
358 }
359
360 pub fn jump_to(&mut self, widget_id: &str) -> bool {
362 if let Some(pos) = self.tab_order.iter().position(|id| id == widget_id) {
363 self.focus_index = pos;
364 true
365 } else {
366 false
367 }
368 }
369}
370
371fn calculate_contrast_ratio(fg: u8, bg: u8) -> Option<f32> {
375 let (l1, l2) = if fg > bg {
378 (fg as f32, bg as f32)
379 } else {
380 (bg as f32, fg as f32)
381 };
382
383 let ratio = (l1 + 5.0) / (l2 + 5.0);
384 Some(ratio)
385}
386
387#[macro_export]
389macro_rules! a11y_assert {
390 (focus_order: $runner:expr, [$($expected:expr),* $(,)?]) => {
392 $runner.assert_focus_order(&[$($expected),*])
393 };
394
395 (aria_label: $runner:expr, $widget:expr, $label:expr) => {
397 $runner.assert_aria_label($widget, $label)
398 };
399
400 (role: $runner:expr, $widget:expr, $role:expr) => {
402 $runner.assert_role($widget, $role)
403 };
404
405 (required: $runner:expr, $widget:expr) => {
407 $runner.assert_required($widget)
408 };
409
410 (focusable: $runner:expr, $widget:expr) => {
412 $runner.assert_focusable($widget)
413 };
414
415 (disabled: $runner:expr, $widget:expr) => {
417 $runner.assert_disabled($widget)
418 };
419
420 (enabled: $runner:expr, $widget:expr) => {
422 $runner.assert_enabled($widget)
423 };
424}
425
426#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn test_focus_order_assertion() {
434 let mut runner = A11yTestRunner::new();
435
436 runner
437 .register_widget("btn1", AccessibleNode::with_id("btn1", Role::Button))
438 .register_widget("btn2", AccessibleNode::with_id("btn2", Role::Button))
439 .register_widget("btn3", AccessibleNode::with_id("btn3", Role::Button));
440
441 runner.assert_focus_order(&["btn1", "btn2", "btn3"]);
443 }
444
445 #[test]
446 fn test_aria_label_assertion() {
447 let mut runner = A11yTestRunner::new();
448
449 runner.register_widget(
450 "submit",
451 AccessibleNode::with_id("submit", Role::Button).label("Submit Form"),
452 );
453
454 runner.assert_aria_label("submit", "Submit Form");
455 }
456
457 #[test]
458 fn test_role_assertion() {
459 let mut runner = A11yTestRunner::new();
460
461 runner.register_widget("btn", AccessibleNode::with_id("btn", Role::Button));
462
463 runner.assert_role("btn", Role::Button);
464 }
465
466 #[test]
467 fn test_keyboard_navigator() {
468 let mut nav = KeyboardNavigator::new(vec![
469 "username".to_string(),
470 "password".to_string(),
471 "submit".to_string(),
472 ]);
473
474 assert_eq!(nav.current_focus(), Some("username"));
475
476 nav.tab();
477 assert_eq!(nav.current_focus(), Some("password"));
478
479 nav.tab();
480 assert_eq!(nav.current_focus(), Some("submit"));
481
482 nav.tab(); assert_eq!(nav.current_focus(), Some("username"));
484
485 nav.shift_tab();
486 assert_eq!(nav.current_focus(), Some("submit"));
487 }
488
489 #[test]
490 fn test_accessible_name() {
491 let mut runner = A11yTestRunner::new();
492
493 runner.register_widget(
494 "btn",
495 AccessibleNode::with_id("btn", Role::Button).label("Click Me"),
496 );
497
498 assert_eq!(runner.accessible_name("btn"), "Click Me");
499 }
500
501 #[test]
502 fn test_screen_reader_description() {
503 let mut runner = A11yTestRunner::new();
504
505 runner.register_widget(
506 "checkbox",
507 AccessibleNode::with_id("checkbox", Role::Checkbox)
508 .label("Agree to terms")
509 .state(crate::utils::accessibility::AccessibleState::new().checked(true)),
510 );
511
512 let desc = runner.screen_reader_description("checkbox");
513 assert!(desc.contains("Agree to terms"));
514 assert!(desc.contains("checked"));
515 }
516
517 #[test]
518 fn test_focusable_assertion() {
519 let mut runner = A11yTestRunner::new();
520
521 runner.register_widget("btn", AccessibleNode::with_id("btn", Role::Button));
522 runner.register_widget(
523 "disabled-btn",
524 AccessibleNode::with_id("disabled-btn", Role::Button)
525 .state(crate::utils::accessibility::AccessibleState::new().disabled(true)),
526 );
527
528 runner.assert_focusable("btn");
529 runner.assert_not_focusable("disabled-btn");
530 }
531
532 #[test]
533 fn test_disabled_assertion() {
534 let mut runner = A11yTestRunner::new();
535
536 runner.register_widget(
537 "input",
538 AccessibleNode::with_id("input", Role::TextInput)
539 .state(crate::utils::accessibility::AccessibleState::new().disabled(true)),
540 );
541
542 runner.assert_disabled("input");
543 }
544
545 #[test]
546 fn test_keyboard_navigator_jump_to() {
547 let mut nav = KeyboardNavigator::new(vec![
548 "field1".to_string(),
549 "field2".to_string(),
550 "field3".to_string(),
551 ]);
552
553 assert!(nav.jump_to("field3"));
554 assert_eq!(nav.current_focus(), Some("field3"));
555
556 assert!(!nav.jump_to("nonexistent"));
557 assert_eq!(nav.current_focus(), Some("field3")); }
559
560 #[test]
561 fn test_a11y_test_runner_new() {
562 let runner = A11yTestRunner::new();
563 assert!(runner.widgets.is_empty());
564 assert!(runner.announcements.is_empty());
565 }
566
567 #[test]
568 fn test_a11y_test_runner_default() {
569 let runner = A11yTestRunner::default();
570 assert!(runner.widgets.is_empty());
571 }
572
573 #[test]
574 fn test_a11y_test_runner_announcements() {
575 let runner = A11yTestRunner::new();
576 assert_eq!(runner.announcements().len(), 0);
577
578 let _ = runner.announcements();
580 }
581
582 #[test]
583 fn test_a11y_test_runner_clear_announcements() {
584 let mut runner = A11yTestRunner::new();
585 runner.clear_announcements();
587 assert_eq!(runner.announcements().len(), 0);
588 }
589
590 #[test]
591 fn test_keyboard_navigator_empty() {
592 let mut nav = KeyboardNavigator::new(vec![]);
593 assert_eq!(nav.current_focus(), None);
594 assert_eq!(nav.tab(), None);
595 assert_eq!(nav.shift_tab(), None);
596 assert!(!nav.jump_to("anything"));
597 }
598
599 #[test]
600 fn test_keyboard_navigator_single_item() {
601 let mut nav = KeyboardNavigator::new(vec!["only".to_string()]);
602 assert_eq!(nav.current_focus(), Some("only"));
603
604 assert_eq!(nav.tab(), Some("only"));
606 assert_eq!(nav.tab(), Some("only"));
607
608 assert_eq!(nav.shift_tab(), Some("only"));
610 }
611
612 #[test]
613 fn test_assert_not_focusable() {
614 let mut runner = A11yTestRunner::new();
615
616 runner.register_widget(
617 "disabled-btn",
618 AccessibleNode::with_id("disabled-btn", Role::Button)
619 .state(crate::utils::accessibility::AccessibleState::new().disabled(true)),
620 );
621
622 runner.assert_not_focusable("disabled-btn");
623 }
624
625 #[test]
626 fn test_assert_announced_not_found() {
627 let runner = A11yTestRunner::new();
628 let result = std::panic::catch_unwind(|| {
630 runner.assert_announced("test message");
631 });
632 assert!(result.is_err());
633 }
634}