Skip to main content

oxiui_accessibility/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3//! `oxiui-accessibility` — AccessKit a11y tree builder for OxiUI.
4//!
5//! Converts an [`A11yNode`] widget graph into an accesskit [`accesskit::TreeUpdate`]
6//! that can be pushed to any AccessKit platform adapter.
7//!
8//! The crate is intentionally headless: no windowing or platform adapter is
9//! imported here, so the full tree-building logic can be exercised in plain
10//! unit tests without a display server.
11//!
12//! # Quick start
13//!
14//! ```rust
15//! use accesskit::NodeId;
16//! use oxiui_accessibility::tree::{A11yNode, A11yTree, WidgetRole};
17//!
18//! let root = A11yNode::simple(NodeId(1), WidgetRole::Window, Some("My App".to_string()));
19//! let update = A11yTree::build(&root);
20//! assert_eq!(update.nodes.len(), 1);
21//! ```
22//!
23//! # Modules
24//!
25//! * [`tree`]           — [`A11yNode`], [`A11yTree`], [`WidgetRole`]
26//! * [`props`]          — [`A11yNodeProps`], [`CheckedState`], [`LiveSetting`], [`TextCaret`], [`TextSelection`]
27//! * [`builder`]        — [`A11yNodeBuilder`] fluent builder
28//! * [`widget_bridge`] — bridge between [`oxiui_core::Widget`] and the a11y tree;
29//!   [`widget_to_a11y_node`], [`build_a11y_tree`], [`A11yWidgetNode`]
30//! * `text_bridge`    — *(feature `text-bridge`)* bridge between
31//!   `oxiui_text::TextInput` / `oxiui_text::TextArea` and [`A11yNode`].
32
33pub mod action;
34pub mod builder;
35pub mod dirty;
36pub mod focus;
37pub mod nav;
38pub mod pool;
39pub mod props;
40pub mod text_a11y;
41#[cfg(feature = "text-bridge")]
42pub mod text_bridge;
43pub mod tree;
44pub mod widget_bridge;
45
46pub use action::{map_action, A11yAction, ActionDispatcher};
47pub use builder::A11yNodeBuilder;
48pub use dirty::{DirtyTracker, Lazy};
49pub use focus::{FocusIndicator, FocusRing};
50pub use nav::{tab_next, tab_prev, TabOrder};
51pub use pool::NodePool;
52pub use props::{
53    byte_offset_to_char_index, character_lengths_utf8, A11yNodeProps, CheckedState, LiveSetting,
54    TextCaret, TextRunChild, TextSelection, Toggled3,
55};
56pub use tree::{
57    build_table_a11y, column_header_node, synthesize_text_run_children, table_cell_node,
58    table_row_node, A11yNode, A11yTree, WidgetRole,
59};
60pub use widget_bridge::{
61    build_a11y_tree, core_role_to_widget_role, widget_to_a11y_node, A11yWidgetNode, NodeIdAllocator,
62};
63
64// ── OS accessibility preferences ──────────────────────────────────────────────
65
66/// Best-effort query of the operating system's accessibility preferences.
67///
68/// No external dependencies are required; values are read from well-known
69/// environment variables as a cross-platform fallback.  On future OS-specific
70/// integration, the implementation will call real platform APIs.
71#[derive(Debug, Clone, Default)]
72pub struct OsA11yPrefs {
73    /// `true` if the OS high-contrast display mode is active.
74    ///
75    /// Currently detected via the `OXIUI_HIGH_CONTRAST` environment variable
76    /// (any non-empty value = active).
77    pub high_contrast: bool,
78    /// `true` if the OS reduced-motion preference is active.
79    ///
80    /// Currently detected via the `OXIUI_REDUCED_MOTION` environment variable
81    /// (any non-empty value = active).
82    pub reduced_motion: bool,
83}
84
85impl OsA11yPrefs {
86    /// Query the current OS accessibility preferences.
87    ///
88    /// Reads `OXIUI_HIGH_CONTRAST` and `OXIUI_REDUCED_MOTION` environment
89    /// variables.  Any non-empty value is interpreted as *active*.  Both
90    /// default to `false` when the variable is unset or empty.
91    pub fn query() -> Self {
92        Self::query_from(|name| std::env::var(name).ok())
93    }
94
95    /// Query preferences using a caller-supplied variable lookup.
96    ///
97    /// `lookup(name)` returns `Some(value)` when the variable is set and
98    /// `None` otherwise.  This variant is useful for testing without
99    /// mutating the process environment.
100    pub fn query_from<F>(lookup: F) -> Self
101    where
102        F: Fn(&str) -> Option<String>,
103    {
104        let high_contrast = lookup("OXIUI_HIGH_CONTRAST")
105            .map(|v| !v.is_empty())
106            .unwrap_or(false);
107        let reduced_motion = lookup("OXIUI_REDUCED_MOTION")
108            .map(|v| !v.is_empty())
109            .unwrap_or(false);
110        Self {
111            high_contrast,
112            reduced_motion,
113        }
114    }
115}
116
117// ── Multi-window tree registry ────────────────────────────────────────────────
118
119/// A unique identifier for an accessibility tree (one per application window).
120///
121/// Wrap a `u64` discriminant to avoid confusion with [`accesskit::NodeId`].
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub struct WindowA11yId(pub u64);
124
125/// A registry of [`A11yTree`] instances, one per application window.
126///
127/// In multi-window applications each window owns an independent accessibility
128/// tree.  `A11yForest` provides the ownership map and basic CRUD operations.
129#[derive(Default)]
130pub struct A11yForest {
131    trees: std::collections::HashMap<WindowA11yId, A11yTree>,
132}
133
134impl A11yForest {
135    /// Create an empty forest.
136    pub fn new() -> Self {
137        Self {
138            trees: std::collections::HashMap::new(),
139        }
140    }
141
142    /// Insert or replace the tree for `id`.
143    pub fn insert(&mut self, id: WindowA11yId, tree: A11yTree) {
144        self.trees.insert(id, tree);
145    }
146
147    /// Return a shared reference to the tree for `id`, if present.
148    pub fn get(&self, id: WindowA11yId) -> Option<&A11yTree> {
149        self.trees.get(&id)
150    }
151
152    /// Return a mutable reference to the tree for `id`, if present.
153    pub fn get_mut(&mut self, id: WindowA11yId) -> Option<&mut A11yTree> {
154        self.trees.get_mut(&id)
155    }
156
157    /// Remove and return the tree for `id`, if present.
158    pub fn remove(&mut self, id: WindowA11yId) -> Option<A11yTree> {
159        self.trees.remove(&id)
160    }
161
162    /// Iterate over all `(WindowA11yId, &A11yTree)` pairs in unspecified order.
163    pub fn iter(&self) -> impl Iterator<Item = (WindowA11yId, &A11yTree)> {
164        self.trees.iter().map(|(k, v)| (*k, v))
165    }
166
167    /// Register an a11y tree for a specific window.
168    ///
169    /// If a tree was already registered for `id`, it is replaced.
170    /// Equivalent to [`Self::insert`] but uses the name prescribed by the
171    /// multi-window API surface.
172    pub fn register(&mut self, id: WindowA11yId, tree: A11yTree) {
173        self.trees.insert(id, tree);
174    }
175
176    /// Unregister (remove) the a11y tree for `id`.
177    ///
178    /// Has no effect if `id` is not currently registered.
179    pub fn unregister(&mut self, id: WindowA11yId) {
180        self.trees.remove(&id);
181    }
182
183    /// Iterate over all registered window IDs in unspecified order.
184    pub fn windows(&self) -> impl Iterator<Item = WindowA11yId> + '_ {
185        self.trees.keys().copied()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use accesskit::{NodeId, Role};
193    use std::time::Instant;
194
195    fn nid(n: u64) -> NodeId {
196        NodeId(n)
197    }
198
199    // ─── 1. widget_role_to_accesskit_role ────────────────────────────────────
200
201    #[test]
202    fn widget_role_to_accesskit_role_all_variants() {
203        use WidgetRole::*;
204
205        let cases: &[(WidgetRole, Role)] = &[
206            (Window, Role::Window),
207            (Group, Role::Group),
208            (Button, Role::Button),
209            (Label, Role::Label),
210            (TextInput, Role::TextInput),
211            (TableRow, Role::Row),
212            (TableCell, Role::Cell),
213            (ScrollView, Role::ScrollView),
214            (Image, Role::Image),
215            (Unknown, Role::Unknown),
216            (Checkbox, Role::CheckBox),
217            (Slider, Role::Slider),
218            (ProgressBar, Role::ProgressIndicator),
219            (Tab, Role::Tab),
220            (TabPanel, Role::TabPanel),
221            (Menu, Role::Menu),
222            (MenuItem, Role::MenuItem),
223            (Dialog, Role::Dialog),
224            (Alert, Role::Alert),
225            (Tooltip, Role::Tooltip),
226            (Tree, Role::Tree),
227            (TreeItem, Role::TreeItem),
228            (ListItem, Role::ListItem),
229            (Link, Role::Link),
230            (Banner, Role::Banner),
231            (Navigation, Role::Navigation),
232            (Main, Role::Main),
233            (Complementary, Role::Complementary),
234            (ContentInfo, Role::ContentInfo),
235        ];
236
237        for (widget_role, expected_ak_role) in cases {
238            let got = Role::from(*widget_role);
239            assert_eq!(
240                got, *expected_ak_role,
241                "WidgetRole::{widget_role:?} should map to {expected_ak_role:?}, got {got:?}"
242            );
243        }
244    }
245
246    // ─── 2. node_property_description ────────────────────────────────────────
247
248    #[test]
249    fn node_property_description_survives_roundtrip() {
250        let node = A11yNodeBuilder::new(nid(1), WidgetRole::Button)
251            .label("OK")
252            .description("Confirm the dialog")
253            .build();
254
255        let update = A11yTree::build(&node);
256        assert_eq!(update.nodes.len(), 1);
257        let (_, ref ak_node) = update.nodes[0];
258        assert_eq!(ak_node.description(), Some("Confirm the dialog"));
259    }
260
261    // ─── 3. node_property_range ───────────────────────────────────────────────
262
263    #[test]
264    fn node_property_range_survives_roundtrip() {
265        let node = A11yNodeBuilder::new(nid(2), WidgetRole::Slider)
266            .value(50.0, 0.0, 100.0, 1.0)
267            .build();
268
269        let update = A11yTree::build(&node);
270        let (_, ref ak_node) = update.nodes[0];
271        assert_eq!(ak_node.numeric_value(), Some(50.0));
272        assert_eq!(ak_node.min_numeric_value(), Some(0.0));
273        assert_eq!(ak_node.max_numeric_value(), Some(100.0));
274        assert_eq!(ak_node.numeric_value_step(), Some(1.0));
275    }
276
277    // ─── 4. relationship_labelled_by ─────────────────────────────────────────
278
279    #[test]
280    fn relationship_labelled_by_propagated() {
281        let label_id = nid(10);
282        let button_id = nid(11);
283
284        let node = A11yNodeBuilder::new(button_id, WidgetRole::Button)
285            .labelled_by([label_id])
286            .build();
287
288        let update = A11yTree::build(&node);
289        let (_, ref ak_node) = update.nodes[0];
290        assert!(
291            ak_node.labelled_by().contains(&label_id),
292            "labelled_by should contain the label node id"
293        );
294    }
295
296    // ─── 5. tree_diff_add_child ───────────────────────────────────────────────
297
298    #[test]
299    fn tree_diff_add_child_produces_new_node() {
300        let mut old_tree = A11yTree::default();
301        let root_only = A11yNode::simple(nid(100), WidgetRole::Window, None);
302        old_tree.build_and_store(&root_only);
303
304        let mut new_tree = A11yTree::default();
305        let mut root_with_child = A11yNode::simple(nid(100), WidgetRole::Window, None);
306        root_with_child.children.push(A11yNode::simple(
307            nid(101),
308            WidgetRole::Button,
309            Some("X".into()),
310        ));
311        new_tree.build_and_store(&root_with_child);
312
313        let delta = A11yTree::diff(&old_tree, &new_tree);
314        // The root changed (its children list grew) and the child is brand-new.
315        let ids: Vec<NodeId> = delta.nodes.iter().map(|(id, _)| *id).collect();
316        assert!(
317            ids.contains(&nid(101)),
318            "diff should include the new child node"
319        );
320    }
321
322    // ─── 6. tree_diff_no_change ───────────────────────────────────────────────
323
324    #[test]
325    fn tree_diff_no_change_empty_delta() {
326        let mut tree_a = A11yTree::default();
327        let root = A11yNode::simple(nid(200), WidgetRole::Window, Some("App".into()));
328        tree_a.build_and_store(&root);
329
330        let mut tree_b = A11yTree::default();
331        let root2 = A11yNode::simple(nid(200), WidgetRole::Window, Some("App".into()));
332        tree_b.build_and_store(&root2);
333
334        let delta = A11yTree::diff(&tree_a, &tree_b);
335        assert!(
336            delta.nodes.is_empty(),
337            "identical trees should produce an empty delta"
338        );
339    }
340
341    // ─── 7. tree_diff_changed_prop ────────────────────────────────────────────
342
343    #[test]
344    fn tree_diff_changed_prop_includes_modified_node() {
345        let mut tree_a = A11yTree::default();
346        let root_a = A11yNode::simple(nid(300), WidgetRole::Button, Some("Old".into()));
347        tree_a.build_and_store(&root_a);
348
349        let mut tree_b = A11yTree::default();
350        let root_b = A11yNode::simple(nid(300), WidgetRole::Button, Some("New".into()));
351        tree_b.build_and_store(&root_b);
352
353        let delta = A11yTree::diff(&tree_a, &tree_b);
354        assert_eq!(
355            delta.nodes.len(),
356            1,
357            "only the changed node should appear in the delta"
358        );
359        assert_eq!(delta.nodes[0].0, nid(300));
360    }
361
362    // ─── 8. focus_set_get_roundtrip ───────────────────────────────────────────
363
364    #[test]
365    fn focus_set_get_roundtrip() {
366        let mut tree = A11yTree::default();
367        assert_eq!(tree.focus(), None);
368
369        tree.set_focus(Some(nid(42)));
370        assert_eq!(tree.focus(), Some(nid(42)));
371
372        tree.set_focus(None);
373        assert_eq!(tree.focus(), None);
374    }
375
376    // ─── 9. focus_in_update ───────────────────────────────────────────────────
377
378    #[test]
379    fn focus_in_update_reflects_set_focus() {
380        let mut tree = A11yTree::default();
381        tree.set_focus(Some(nid(77)));
382
383        let upd = tree.focus_update();
384        assert_eq!(upd.focus, nid(77));
385        assert!(upd.nodes.is_empty());
386    }
387
388    // ─── 10. live_region_announce ─────────────────────────────────────────────
389
390    #[test]
391    fn live_region_announce_id_in_tree() {
392        let mut tree = A11yTree::default();
393        let root = A11yNode::simple(nid(500), WidgetRole::Window, None);
394        tree.build_and_store(&root);
395
396        let ann_id = tree.announce("File saved", LiveSetting::Polite);
397
398        // The id must appear in the snapshot.
399        let ids: Vec<NodeId> = tree.snapshot.iter().map(|(id, _)| *id).collect();
400        assert!(
401            ids.contains(&ann_id),
402            "announced id must be in the snapshot"
403        );
404    }
405
406    // ─── 11. widget_role_display ─────────────────────────────────────────────
407
408    #[test]
409    fn widget_role_display_non_empty() {
410        use WidgetRole::*;
411        let roles = [
412            Window,
413            Group,
414            Button,
415            Label,
416            TextInput,
417            TableRow,
418            TableCell,
419            ScrollView,
420            Image,
421            Unknown,
422            Checkbox,
423            Slider,
424            ProgressBar,
425            Tab,
426            TabPanel,
427            Menu,
428            MenuItem,
429            Dialog,
430            Alert,
431            Tooltip,
432            Tree,
433            TreeItem,
434            ListItem,
435            Link,
436            Banner,
437            Navigation,
438            Main,
439            Complementary,
440            ContentInfo,
441        ];
442        for role in roles {
443            let s = role.to_string();
444            assert!(
445                !s.is_empty(),
446                "WidgetRole::{role:?} Display must not be empty"
447            );
448        }
449    }
450
451    // ─── 12. builder_roundtrip ────────────────────────────────────────────────
452
453    #[test]
454    fn builder_roundtrip_description() {
455        let node = A11yNodeBuilder::new(nid(1000), WidgetRole::Button)
456            .description("click me")
457            .build();
458
459        assert_eq!(node.props.description, Some("click me".to_string()));
460    }
461
462    #[test]
463    fn builder_roundtrip_placeholder() {
464        let node = A11yNodeBuilder::new(nid(1001), WidgetRole::TextInput)
465            .placeholder("Enter text")
466            .build();
467        assert_eq!(node.props.placeholder, Some("Enter text".to_string()));
468    }
469
470    #[test]
471    fn builder_roundtrip_key_shortcut() {
472        let node = A11yNodeBuilder::new(nid(1002), WidgetRole::Button)
473            .key_shortcut("Ctrl+S")
474            .build();
475        assert_eq!(node.props.key_shortcut, Some("Ctrl+S".to_string()));
476    }
477
478    #[test]
479    fn builder_roundtrip_disabled() {
480        let node = A11yNodeBuilder::new(nid(1003), WidgetRole::Button)
481            .disabled()
482            .build();
483        assert!(node.props.disabled);
484    }
485
486    #[test]
487    fn builder_roundtrip_expanded() {
488        let node = A11yNodeBuilder::new(nid(1004), WidgetRole::TreeItem)
489            .expanded(true)
490            .build();
491        assert_eq!(node.props.expanded, Some(true));
492    }
493
494    #[test]
495    fn builder_roundtrip_selected() {
496        let node = A11yNodeBuilder::new(nid(1005), WidgetRole::ListItem)
497            .selected(true)
498            .build();
499        assert_eq!(node.props.selected, Some(true));
500    }
501
502    #[test]
503    fn builder_roundtrip_checked() {
504        let node = A11yNodeBuilder::new(nid(1006), WidgetRole::Checkbox)
505            .checked(CheckedState::Mixed)
506            .build();
507        assert_eq!(node.props.checked, Some(CheckedState::Mixed));
508    }
509
510    #[test]
511    fn builder_roundtrip_value() {
512        let node = A11yNodeBuilder::new(nid(1007), WidgetRole::Slider)
513            .value(25.0, 0.0, 50.0, 0.5)
514            .build();
515        assert_eq!(node.props.value_now, Some(25.0));
516        assert_eq!(node.props.value_min, Some(0.0));
517        assert_eq!(node.props.value_max, Some(50.0));
518        assert_eq!(node.props.value_step, Some(0.5));
519    }
520
521    #[test]
522    fn builder_roundtrip_text() {
523        let node = A11yNodeBuilder::new(nid(1008), WidgetRole::TextInput)
524            .text("hello world")
525            .build();
526        assert_eq!(node.text_content, Some("hello world".to_string()));
527    }
528
529    #[test]
530    fn builder_roundtrip_labelled_by() {
531        let node = A11yNodeBuilder::new(nid(1009), WidgetRole::Button)
532            .labelled_by([nid(2000), nid(2001)])
533            .build();
534        assert_eq!(node.props.labelled_by, vec![nid(2000), nid(2001)]);
535    }
536
537    // ─── 13. large_tree_smoke ────────────────────────────────────────────────
538
539    #[test]
540    fn large_tree_smoke_under_100ms() {
541        const N: u64 = 1_000;
542
543        // Build a 1000-node flat tree (root + 999 button children).
544        let mut root = A11yNode::simple(nid(0), WidgetRole::Window, None);
545        for i in 1..N {
546            root.children.push(A11yNode::simple(
547                nid(i),
548                WidgetRole::Button,
549                Some(format!("Button {i}")),
550            ));
551        }
552
553        let start = Instant::now();
554        let update = A11yTree::build(&root);
555        let elapsed = start.elapsed();
556
557        assert_eq!(update.nodes.len(), N as usize);
558        assert!(
559            elapsed.as_millis() < 100,
560            "1000-node tree build took {}ms (limit 100ms)",
561            elapsed.as_millis()
562        );
563    }
564
565    // ─── Placeholder prop in node ─────────────────────────────────────────────
566
567    #[test]
568    fn node_property_placeholder_propagated() {
569        let node = A11yNodeBuilder::new(nid(3000), WidgetRole::TextInput)
570            .placeholder("Type here…")
571            .build();
572
573        let update = A11yTree::build(&node);
574        let (_, ref ak_node) = update.nodes[0];
575        assert_eq!(ak_node.placeholder(), Some("Type here…"));
576    }
577
578    // ─── Disabled flag ───────────────────────────────────────────────────────
579
580    #[test]
581    fn node_property_disabled_propagated() {
582        let node = A11yNodeBuilder::new(nid(3001), WidgetRole::Button)
583            .disabled()
584            .build();
585
586        let update = A11yTree::build(&node);
587        let (_, ref ak_node) = update.nodes[0];
588        assert!(ak_node.is_disabled());
589    }
590
591    // ─── Slice E tests ────────────────────────────────────────────────────────
592
593    // -- Text-run synthesis ---------------------------------------------------
594
595    #[test]
596    fn test_text_run_no_selection_one_child() {
597        let children = synthesize_text_run_children("hello", None);
598        assert_eq!(children.len(), 1);
599        assert_eq!(children[0].text, "hello");
600        assert_eq!(children[0].char_offset, 0);
601        assert_eq!(children[0].byte_offset, 0);
602        assert!(!children[0].is_selected);
603    }
604
605    #[test]
606    fn test_text_run_with_selection_three_children() {
607        use crate::props::TextSelection;
608        // "hello" — select chars 1..3 ("el"), bytes 1..3
609        let sel = TextSelection {
610            anchor: 1,
611            focus: 3,
612        };
613        let children = synthesize_text_run_children("hello", Some(&sel));
614        // Expect: "h" (before), "el" (selected), "lo" (after) = 3 segments
615        assert_eq!(children.len(), 3, "expected 3 segments, got: {children:?}");
616        assert_eq!(children[0].text, "h");
617        assert!(!children[0].is_selected);
618        assert_eq!(children[1].text, "el");
619        assert!(children[1].is_selected);
620        assert_eq!(children[2].text, "lo");
621        assert!(!children[2].is_selected);
622    }
623
624    // -- Table helpers --------------------------------------------------------
625
626    #[test]
627    fn test_table_cell_carries_row_col() {
628        let cell = table_cell_node(nid(1), 2, 4, "data");
629        assert_eq!(cell.text_content.as_deref(), Some("data"));
630        let desc = cell.props.description.as_deref().unwrap_or("");
631        assert!(
632            desc.contains("Row 3"),
633            "description should contain Row 3, got: {desc}"
634        );
635        assert!(
636            desc.contains("Column 5"),
637            "description should contain Column 5, got: {desc}"
638        );
639    }
640
641    #[test]
642    fn test_table_row_node_description() {
643        let row = table_row_node(nid(10), 0);
644        let desc = row.props.description.as_deref().unwrap_or("");
645        assert!(desc.contains("Row 1"), "expected 'Row 1', got: {desc}");
646    }
647
648    #[test]
649    fn test_column_header_label() {
650        let hdr = column_header_node(nid(20), 2, "Name");
651        assert_eq!(hdr.label.as_deref(), Some("Name"));
652        let desc = hdr.props.description.as_deref().unwrap_or("");
653        assert!(
654            desc.contains("Column 3"),
655            "expected 'Column 3', got: {desc}"
656        );
657    }
658
659    // -- OS preferences -------------------------------------------------------
660
661    #[test]
662    fn test_os_prefs_default_false() {
663        // Without the env vars set, both prefs are false.
664        // (This test relies on the vars not being set in the test environment.)
665        // We deliberately do NOT set them here to test the default path.
666        // If they happen to be set in CI, the test would still pass because
667        // the assertions check a freshly-queried value, not a cached constant.
668        // Use a fresh query that doesn't depend on state set by other tests.
669        let prefs = OsA11yPrefs::query();
670        // We can't guarantee the host env, so only assert the Default impl.
671        let default_prefs = OsA11yPrefs::default();
672        assert!(!default_prefs.high_contrast);
673        assert!(!default_prefs.reduced_motion);
674        // Suppress unused-variable warning for `prefs`.
675        let _ = prefs;
676    }
677
678    #[test]
679    fn test_os_prefs_reads_env_var() {
680        // Use query_from so we never mutate the process environment.
681        let prefs = OsA11yPrefs::query_from(|name| {
682            if name == "OXIUI_HIGH_CONTRAST" {
683                Some("1".to_string())
684            } else {
685                None
686            }
687        });
688        assert!(
689            prefs.high_contrast,
690            "OXIUI_HIGH_CONTRAST=1 should set high_contrast=true"
691        );
692        assert!(!prefs.reduced_motion);
693    }
694
695    // -- Multi-window forest --------------------------------------------------
696
697    #[test]
698    fn test_forest_two_trees_isolated() {
699        let id_a = WindowA11yId(1);
700        let id_b = WindowA11yId(2);
701
702        let mut forest = A11yForest::new();
703
704        let mut tree_a = A11yTree::default();
705        let root_a = A11yNode::simple(nid(100), WidgetRole::Window, Some("Window A".into()));
706        tree_a.build_and_store(&root_a);
707
708        let mut tree_b = A11yTree::default();
709        let root_b = A11yNode::simple(nid(200), WidgetRole::Window, Some("Window B".into()));
710        tree_b.build_and_store(&root_b);
711
712        forest.insert(id_a, tree_a);
713        forest.insert(id_b, tree_b);
714
715        let a_root = forest.get(id_a).and_then(|t| t.root_id);
716        let b_root = forest.get(id_b).and_then(|t| t.root_id);
717
718        assert_eq!(a_root, Some(nid(100)));
719        assert_eq!(b_root, Some(nid(200)));
720        assert_ne!(a_root, b_root, "two windows must have independent root ids");
721
722        // Remove one; the other remains.
723        forest.remove(id_a);
724        assert!(forest.get(id_a).is_none());
725        assert!(forest.get(id_b).is_some());
726    }
727
728    // -- Builder tab_index ----------------------------------------------------
729
730    #[test]
731    fn test_builder_tab_index() {
732        let node = A11yNodeBuilder::new(nid(9000), WidgetRole::Button)
733            .label("Submit")
734            .tab_index(2)
735            .build();
736        assert_eq!(node.props.tab_index, Some(2));
737    }
738
739    // ── S6 tests ──────────────────────────────────────────────────────────────
740
741    #[test]
742    fn test_os_a11y_prefs_still_works() {
743        // OsA11yPrefs::default() must not panic and must yield well-typed bools.
744        let prefs = OsA11yPrefs::default();
745        // Both fields are bool — this assertion is vacuously true but confirms
746        // the struct is constructible and the fields are accessible.
747        let _hc: bool = prefs.high_contrast;
748        let _rm: bool = prefs.reduced_motion;
749    }
750
751    #[test]
752    fn test_build_table_a11y_structure() {
753        let table = build_table_a11y(2, 3, &["Col A", "Col B", "Col C"]);
754
755        // Root should have 3 headers + 2 rows = 5 direct children.
756        assert_eq!(
757            table.children.len(),
758            5,
759            "expected 3 column-headers + 2 rows = 5 children, got {}",
760            table.children.len()
761        );
762
763        // First three children must be ColumnHeader.
764        for (i, child) in table.children.iter().take(3).enumerate() {
765            assert_eq!(
766                child.role,
767                WidgetRole::ColumnHeader,
768                "child[{i}] should be ColumnHeader"
769            );
770        }
771
772        // Last two children must be TableRow.
773        for (i, child) in table.children.iter().skip(3).enumerate() {
774            assert_eq!(
775                child.role,
776                WidgetRole::TableRow,
777                "row child[{i}] should be TableRow"
778            );
779            // Each row must have 3 TableCell children.
780            assert_eq!(child.children.len(), 3, "row {i} should have 3 cells");
781            for (j, cell) in child.children.iter().enumerate() {
782                assert_eq!(
783                    cell.role,
784                    WidgetRole::TableCell,
785                    "row {i} cell {j} should be TableCell"
786                );
787            }
788        }
789    }
790
791    #[test]
792    fn test_a11y_forest_multi_window() {
793        let id1 = WindowA11yId(1);
794        let id2 = WindowA11yId(2);
795
796        let mut forest = A11yForest::default();
797
798        forest.register(id1, A11yTree::default());
799        forest.register(id2, A11yTree::default());
800
801        assert!(forest.get(id1).is_some(), "id1 should be present");
802        assert!(forest.get(id2).is_some(), "id2 should be present");
803
804        forest.unregister(id1);
805        assert!(forest.get(id1).is_none(), "id1 should be removed");
806        assert!(forest.get(id2).is_some(), "id2 should remain");
807
808        assert_eq!(forest.windows().count(), 1, "one window should remain");
809    }
810
811    #[test]
812    fn test_a11y_forest_windows_iter() {
813        let mut forest = A11yForest::default();
814        forest.register(WindowA11yId(10), A11yTree::default());
815        forest.register(WindowA11yId(20), A11yTree::default());
816        forest.register(WindowA11yId(30), A11yTree::default());
817
818        assert_eq!(
819            forest.windows().count(),
820            3,
821            "windows() should yield all 3 registered ids"
822        );
823    }
824}