Skip to main content

revue/a11y/
testing.rs

1//! Accessibility testing utilities
2//!
3//! This module provides testing helpers for validating accessibility features.
4//!
5//! # Example
6//!
7//! ```rust,ignore
8//! use revue::a11y::testing::A11yTestRunner;
9//! use revue::widget::{Text, Button};
10//! use revue::utils::Role;
11//!
12//! let mut runner = A11yTestRunner::new();
13//! runner.assert_focus_order(&["button1", "button2", "input1"]);
14//! runner.assert_aria_label("submit-btn", "Submit Form");
15//! runner.assert_contrast_ratio("button-text", 4.5); // WCAG AA standard
16//! ```
17
18use crate::utils::accessibility::{AccessibilityManager, AccessibleNode, Role};
19use std::collections::HashMap;
20
21/// Test runner for accessibility validation
22pub struct A11yTestRunner {
23    /// Accessibility manager instance
24    manager: AccessibilityManager,
25    /// Registered widgets by ID
26    widgets: HashMap<String, AccessibleNode>,
27    /// Announcements made during test
28    announcements: Vec<String>,
29}
30
31impl A11yTestRunner {
32    /// Create new test runner
33    pub fn new() -> Self {
34        Self {
35            manager: AccessibilityManager::new(),
36            widgets: HashMap::new(),
37            announcements: Vec::new(),
38        }
39    }
40
41    /// Register a widget for testing
42    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    /// Assert focus order matches expected sequence
50    ///
51    /// # Example
52    ///
53    /// ```rust,ignore
54    /// # use revue::a11y::testing::A11yTestRunner;
55    /// # let mut runner = A11yTestRunner::new();
56    /// runner.assert_focus_order(&["username", "password", "submit-btn"]);
57    /// ```
58    pub fn assert_focus_order(&self, expected_ids: &[&str]) {
59        let mut actual_order = Vec::new();
60
61        // Simulate tab navigation through interactive widgets
62        let interactive: Vec<_> = self
63            .widgets
64            .iter()
65            .filter(|(_, node)| node.is_focusable())
66            .collect();
67
68        // Sort by document order (using position in set as hint)
69        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                // Try to infer position from ID if not set
73                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    /// Assert widget has aria-label
91    ///
92    /// # Example
93    ///
94    /// ```rust,ignore
95    /// # use revue::a11y::testing::A11yTestRunner;
96    /// # let mut runner = A11yTestRunner::new();
97    /// runner.assert_aria_label("submit-btn", "Submit Form");
98    /// ```
99    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    /// Assert widget has specific role
113    ///
114    /// # Example
115    ///
116    /// ```rust,ignore
117    /// # use revue::a11y::testing::A11yTestRunner;
118    /// # use revue::utils::Role;
119    /// # let mut runner = A11yTestRunner::new();
120    /// runner.assert_role("submit-btn", Role::Button);
121    /// ```
122    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    /// Assert widget has required state
135    ///
136    /// # Example
137    ///
138    /// ```rust,ignore
139    /// # use revue::a11y::testing::A11yTestRunner;
140    /// # let mut runner = A11yTestRunner::new();
141    /// runner.assert_required("email-input");
142    /// runner.assert_not_required("optional-input");
143    /// ```
144    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    /// Assert widget is NOT required
161    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    /// Assert minimum color contrast ratio (WCAG 2.1)
176    ///
177    /// - 4.5:1 for normal text (AA)
178    /// - 7:1 for large text (AAA)
179    /// - 3:1 for large text or UI components (A)
180    ///
181    /// # Example
182    ///
183    /// ```rust,ignore
184    /// # use revue::a11y::testing::A11yTestRunner;
185    /// # let mut runner = A11yTestRunner::new();
186    /// runner.assert_contrast_ratio("button-text", 4.5); // WCAG AA
187    /// ```
188    pub fn assert_contrast_ratio(&self, widget_id: &str, min_ratio: f32) {
189        if let Some(node) = self.widgets.get(widget_id) {
190            // Get foreground and background colors from properties
191            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    /// Assert widget is focusable
214    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    /// Assert widget is NOT focusable
227    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    /// Assert widget is disabled
240    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    /// Assert widget is enabled
253    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    /// Get accessible name of a widget
266    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    /// Get screen reader description of a widget
275    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    /// Assert announcement was made
284    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    /// Get all announcements made
298    pub fn announcements(&self) -> &[String] {
299        &self.announcements
300    }
301
302    /// Clear announcements
303    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
314/// Keyboard-only navigation simulator
315pub struct KeyboardNavigator {
316    /// Current focus index
317    focus_index: usize,
318    /// Interactive widget IDs in tab order
319    tab_order: Vec<String>,
320}
321
322impl KeyboardNavigator {
323    /// Create new navigator with tab order
324    pub fn new(tab_order: Vec<String>) -> Self {
325        Self {
326            focus_index: 0,
327            tab_order,
328        }
329    }
330
331    /// Get current focused widget
332    pub fn current_focus(&self) -> Option<&str> {
333        self.tab_order.get(self.focus_index).map(|s| s.as_str())
334    }
335
336    /// Simulate Tab key (move to next)
337    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    /// Simulate Shift+Tab (move to previous)
347    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    /// Jump to specific widget
361    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
371/// Calculate contrast ratio between two colors (simplified)
372///
373/// Returns contrast ratio from 1-21, where 21 is highest contrast
374fn calculate_contrast_ratio(fg: u8, bg: u8) -> Option<f32> {
375    // Simplified calculation - in real implementation would use
376    // proper RGB color space calculations
377    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/// Convenience macro for accessibility assertions
388#[macro_export]
389macro_rules! a11y_assert {
390    // Assert focus order
391    (focus_order: $runner:expr, [$($expected:expr),* $(,)?]) => {
392        $runner.assert_focus_order(&[$($expected),*])
393    };
394
395    // Assert aria label
396    (aria_label: $runner:expr, $widget:expr, $label:expr) => {
397        $runner.assert_aria_label($widget, $label)
398    };
399
400    // Assert role
401    (role: $runner:expr, $widget:expr, $role:expr) => {
402        $runner.assert_role($widget, $role)
403    };
404
405    // Assert required
406    (required: $runner:expr, $widget:expr) => {
407        $runner.assert_required($widget)
408    };
409
410    // Assert focusable
411    (focusable: $runner:expr, $widget:expr) => {
412        $runner.assert_focusable($widget)
413    };
414
415    // Assert disabled
416    (disabled: $runner:expr, $widget:expr) => {
417        $runner.assert_disabled($widget)
418    };
419
420    // Assert enabled
421    (enabled: $runner:expr, $widget:expr) => {
422        $runner.assert_enabled($widget)
423    };
424}
425
426// KEEP HERE - Tests access private fields (A11yTestRunner.widgets, announcements, etc.)
427
428#[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        // This should pass with inferred order
442        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(); // Wraps around
483        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")); // Focus unchanged
558    }
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        // Since we can't easily make announcements, test the accessor
579        let _ = runner.announcements();
580    }
581
582    #[test]
583    fn test_a11y_test_runner_clear_announcements() {
584        let mut runner = A11yTestRunner::new();
585        // Can't add announcements easily, so just test it doesn't panic
586        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        // Tab wraps around to same element
605        assert_eq!(nav.tab(), Some("only"));
606        assert_eq!(nav.tab(), Some("only"));
607
608        // Shift+Tab also stays on same element
609        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        // Should panic since announcement wasn't made
629        let result = std::panic::catch_unwind(|| {
630            runner.assert_announced("test message");
631        });
632        assert!(result.is_err());
633    }
634}