Skip to main content

a11y_perception/
tree.rs

1//! Accessibility Tree (AXTree) data structures
2//!
3//! Represents Chrome's Accessibility Tree as extracted via CDP.
4//! The AXTree provides semantic information about page elements.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// The complete Accessibility Tree for a page
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AXTree {
12    /// All nodes in the tree, indexed by node_id
13    pub nodes: HashMap<String, AXNode>,
14    /// The root node ID
15    pub root_id: Option<String>,
16    /// Node ids in document order.
17    ///
18    /// `nodes` is a `HashMap`, and Rust randomises hash order per process, so
19    /// iterating it directly gave a different sequence on every run of the
20    /// same page. Detectors that read the tree as a document — heading-skip
21    /// detection, "the first N nodes are the top of the page" — therefore
22    /// disagreed with themselves: two consecutive audits of the same URL, one
23    /// reported a heading skip, the next did not (plan 49).
24    ///
25    /// CDP's `getFullAXTree` returns nodes in document order, so preserving
26    /// the order they arrived in *is* document order. Hand-built trees in
27    /// tests keep the order the test wrote them in, which is what their
28    /// author means by it.
29    #[serde(default)]
30    order: Vec<String>,
31}
32
33impl AXTree {
34    /// Create a new empty AXTree
35    pub fn new() -> Self {
36        Self {
37            nodes: HashMap::new(),
38            root_id: None,
39            order: Vec::new(),
40        }
41    }
42
43    /// Build tree from a list of nodes
44    pub fn from_nodes(nodes: Vec<AXNode>) -> Self {
45        let mut tree = Self::new();
46
47        for node in nodes {
48            // First node is typically the root
49            if tree.root_id.is_none() {
50                tree.root_id = Some(node.node_id.clone());
51            }
52            if tree
53                .nodes
54                .insert(node.node_id.clone(), node.clone())
55                .is_none()
56            {
57                tree.order.push(node.node_id);
58            }
59        }
60
61        tree
62    }
63
64    /// All nodes in document order, browser-generated artifacts included.
65    ///
66    /// Normally this is just `order`. It falls back to reconstructing the
67    /// order from the tree structure when `order` does not cover `nodes` —
68    /// a snapshot cached before this field existed, or a direct write to the
69    /// public `nodes` map. The fallback walks `child_ids` depth-first from
70    /// the root and appends whatever that does not reach, sorted by id, so it
71    /// is at least deterministic.
72    fn ordered_nodes(&self) -> Vec<&AXNode> {
73        if self.order.len() == self.nodes.len() {
74            return self
75                .order
76                .iter()
77                .filter_map(|id| self.nodes.get(id))
78                .collect();
79        }
80        self.reconstruct_order()
81    }
82
83    fn reconstruct_order(&self) -> Vec<&AXNode> {
84        let mut out: Vec<&AXNode> = Vec::with_capacity(self.nodes.len());
85        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
86
87        if let Some(root) = self.root_id.as_deref() {
88            let mut stack = vec![root];
89            while let Some(id) = stack.pop() {
90                if !seen.insert(id) {
91                    continue;
92                }
93                let Some(node) = self.nodes.get(id) else {
94                    continue;
95                };
96                out.push(node);
97                // Reversed, so the first child is visited first.
98                for child in node.child_ids.iter().rev() {
99                    stack.push(child.as_str());
100                }
101            }
102        }
103
104        let mut rest: Vec<&AXNode> = self
105            .nodes
106            .values()
107            .filter(|n| !seen.contains(n.node_id.as_str()))
108            .collect();
109        rest.sort_by(|a, b| a.node_id.cmp(&b.node_id));
110        out.extend(rest);
111        out
112    }
113
114    /// Get a node by ID
115    pub fn get_node(&self, node_id: &str) -> Option<&AXNode> {
116        self.nodes.get(node_id)
117    }
118
119    /// Den Knoten zu einer Backend-Node-ID finden.
120    ///
121    /// Die Backend-ID zeigt auf den DOM-Knoten und bleibt über mehrere
122    /// `getFullAXTree`-Aufrufe stabil, anders als `node_id`. Sie ist deshalb
123    /// der Schlüssel, mit dem der Journey-Layer ein Element wiederfindet, das
124    /// er selbst bedient hat.
125    pub fn node_by_backend_id(&self, backend_id: i64) -> Option<&AXNode> {
126        self.iter_all()
127            .find(|n| n.backend_dom_node_id == Some(backend_id))
128    }
129
130    /// Ob `backend_id` im Baum unterhalb von `ancestor_backend_id` liegt
131    /// (oder dieser Knoten selbst ist).
132    ///
133    /// Beantwortet „steht der Fokus innerhalb des Dialogs?" am
134    /// Accessibility-Tree statt an `element.contains()` im DOM — und damit
135    /// auch über Shadow Roots hinweg, die `querySelector` nicht durchdringt.
136    pub fn is_within(&self, backend_id: i64, ancestor_backend_id: i64) -> bool {
137        let Some(mut node) = self.node_by_backend_id(backend_id) else {
138            return false;
139        };
140        // Die Kette kommt aus einem fremden Prozess; sie wird nicht länger als
141        // der Baum, sonst zeigt sie im Kreis.
142        for _ in 0..self.nodes.len() {
143            if node.backend_dom_node_id == Some(ancestor_backend_id) {
144                return true;
145            }
146            match node.parent_id.as_ref().and_then(|id| self.get_node(id)) {
147                Some(parent) => node = parent,
148                None => return false,
149            }
150        }
151        false
152    }
153
154    /// Get the root node
155    pub fn root(&self) -> Option<&AXNode> {
156        self.root_id.as_ref().and_then(|id| self.nodes.get(id))
157    }
158
159    /// Iterate over all auditable nodes (excludes browser-generated artifacts).
160    /// This is the default iterator that all WCAG rules should use.
161    ///
162    /// Also excluded: everything below a `Video` or `Audio` node. With
163    /// `controls`, Chrome exposes the player's own interface there — play,
164    /// mute and fullscreen buttons, a timeline `slider` without `valuenow` —
165    /// from the browser's shadow DOM. No author wrote it or can fix it, and
166    /// ARIA rules reported the timeline as a Critical "missing
167    /// aria-valuenow" on every page with a `<video controls>`. Fallback
168    /// content inside `<video>` is only rendered where video is unsupported,
169    /// so the subtree is never author content. The media node itself stays.
170    pub fn iter(&self) -> impl Iterator<Item = &AXNode> {
171        let media_ui = self.media_controls();
172        self.ordered_nodes()
173            .into_iter()
174            .filter(move |n| !n.is_browser_generated() && !media_ui.contains(n.node_id.as_str()))
175    }
176
177    /// Ids of all nodes below a `Video` or `Audio` node — the browser's
178    /// player interface. See [`iter`](Self::iter).
179    fn media_controls(&self) -> std::collections::HashSet<&str> {
180        let mut out = std::collections::HashSet::new();
181        let mut stack: Vec<&str> = self
182            .nodes
183            .values()
184            .filter(|n| matches!(n.role.as_deref(), Some("Video") | Some("Audio")))
185            .flat_map(|n| n.child_ids.iter().map(String::as_str))
186            .collect();
187        while let Some(id) = stack.pop() {
188            if !out.insert(id) {
189                continue;
190            }
191            if let Some(node) = self.nodes.get(id) {
192                stack.extend(node.child_ids.iter().map(String::as_str));
193            }
194        }
195        out
196    }
197
198    /// Iterate over ALL nodes including browser-generated artifacts.
199    /// Only use this for tree traversal, parent/child lookups, or non-WCAG analysis.
200    pub fn iter_all(&self) -> impl Iterator<Item = &AXNode> {
201        self.ordered_nodes().into_iter()
202    }
203
204    /// The page's visible text, each run counted once.
205    ///
206    /// Chrome puts a text run's characters on a `StaticText` node and repeats
207    /// them on that node's `InlineTextBox` children, one per line box. The
208    /// accessible name of a `heading` or a `link` is computed from the same
209    /// `StaticText` descendants. So the page's text has to be read from
210    /// `StaticText` and nowhere else, or it is counted two or three times.
211    ///
212    /// Measured on two cached trees (plan 50): of all characters carried in a
213    /// `name`, 72-76 % sit on browser-generated roles; `StaticText` and
214    /// `InlineTextBox` hold near-identical totals spread over different node
215    /// counts; 30 of 44 heading names and 71 of 87 link names repeat a
216    /// `StaticText` verbatim. `paragraph` nodes exist but never carry a name
217    /// of their own, so a role filter naming `paragraph` matches nothing.
218    ///
219    /// [`iter`](Self::iter) excludes all of this deliberately — a WCAG rule
220    /// must not report a finding against a node the author never wrote.
221    /// Anything *measuring* text wants this instead.
222    pub fn text_nodes(&self) -> impl Iterator<Item = &AXNode> {
223        self.ordered_nodes().into_iter().filter(|n| n.is_text())
224    }
225
226    /// Total length of the page's visible text. See [`text_nodes`](Self::text_nodes).
227    pub fn visible_text_len(&self) -> usize {
228        self.text_nodes()
229            .filter_map(|n| n.name.as_ref())
230            .map(|name| name.len())
231            .sum()
232    }
233
234    /// Get all nodes with a specific role (excludes browser-generated nodes)
235    pub fn nodes_with_role(&self, role: &str) -> Vec<&AXNode> {
236        self.iter()
237            .filter(|n| n.role.as_deref() == Some(role))
238            .collect()
239    }
240
241    /// Get all image nodes
242    pub fn images(&self) -> Vec<&AXNode> {
243        self.iter()
244            .filter(|n| matches!(n.role.as_deref(), Some("image") | Some("img")))
245            .collect()
246    }
247
248    /// Get all heading nodes
249    pub fn headings(&self) -> Vec<&AXNode> {
250        self.iter()
251            .filter(|n| matches!(n.role.as_deref(), Some("heading")))
252            .collect()
253    }
254
255    /// Get all form control nodes (excluding buttons, which are checked separately)
256    pub fn form_controls(&self) -> Vec<&AXNode> {
257        self.iter()
258            .filter(|n| {
259                matches!(
260                    n.role.as_deref(),
261                    Some("textbox")
262                        | Some("checkbox")
263                        | Some("radio")
264                        | Some("combobox")
265                        | Some("listbox")
266                        | Some("spinbutton")
267                        | Some("slider")
268                        | Some("searchbox")
269                )
270            })
271            .collect()
272    }
273
274    /// Get all link nodes
275    pub fn links(&self) -> Vec<&AXNode> {
276        self.nodes_with_role("link")
277    }
278
279    /// Count total nodes
280    pub fn len(&self) -> usize {
281        self.nodes.len()
282    }
283
284    /// Check if tree is empty
285    pub fn is_empty(&self) -> bool {
286        self.nodes.is_empty()
287    }
288}
289
290impl Default for AXTree {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296/// A single node in the Accessibility Tree
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298pub struct AXNode {
299    /// Unique identifier for this node
300    pub node_id: String,
301    /// Whether this node is ignored for accessibility
302    #[serde(default)]
303    pub ignored: bool,
304    /// Reasons why this node is ignored
305    #[serde(default)]
306    pub ignored_reasons: Vec<AXProperty>,
307    /// The accessibility role (e.g., "button", "heading", "image")
308    pub role: Option<String>,
309    /// The accessible name (what screen readers announce)
310    pub name: Option<String>,
311    /// Source of the accessible name
312    pub name_source: Option<NameSource>,
313    /// The accessible description
314    pub description: Option<String>,
315    /// The accessible value (for form controls)
316    pub value: Option<String>,
317    /// Additional properties
318    #[serde(default)]
319    pub properties: Vec<AXProperty>,
320    /// Child node IDs
321    #[serde(default)]
322    pub child_ids: Vec<String>,
323    /// Parent node ID
324    pub parent_id: Option<String>,
325    /// Backend DOM node ID (for correlation with DOM)
326    pub backend_dom_node_id: Option<i64>,
327}
328
329impl AXNode {
330    /// Check if this node has an accessible name
331    pub fn has_name(&self) -> bool {
332        self.name.as_ref().is_some_and(|n| !n.trim().is_empty())
333    }
334
335    /// Check if this node is focusable
336    pub fn is_focusable(&self) -> bool {
337        self.get_property_bool("focusable").unwrap_or(false)
338    }
339
340    /// Check if this node is interactive
341    pub fn is_interactive(&self) -> bool {
342        matches!(
343            self.role.as_deref(),
344            Some("button")
345                | Some("link")
346                | Some("textbox")
347                | Some("checkbox")
348                | Some("radio")
349                | Some("combobox")
350                | Some("menuitem")
351                | Some("tab")
352        ) || self.is_focusable()
353    }
354
355    /// Get heading level (1-6) if this is a heading
356    pub fn heading_level(&self) -> Option<u8> {
357        if self.role.as_deref() != Some("heading") {
358            return None;
359        }
360
361        self.get_property_int("level").map(|l| l.clamp(1, 6) as u8)
362    }
363
364    /// Get a boolean property value
365    pub fn get_property_bool(&self, name: &str) -> Option<bool> {
366        self.properties
367            .iter()
368            .find(|p| p.name == name)
369            .and_then(|p| p.value.as_bool())
370    }
371
372    /// Get an integer property value
373    pub fn get_property_int(&self, name: &str) -> Option<i64> {
374        self.properties
375            .iter()
376            .find(|p| p.name == name)
377            .and_then(|p| p.value.as_int())
378    }
379
380    /// Get a string property value
381    pub fn get_property_str(&self, name: &str) -> Option<&str> {
382        self.properties
383            .iter()
384            .find(|p| p.name == name)
385            .and_then(|p| p.value.as_str())
386    }
387
388    /// `aria-haspopup`, so wie CDP die Eigenschaft benennt.
389    ///
390    /// Die Kennung im Protokoll ist `hasPopup`, mit großem P. Ein
391    /// `get_property_str("haspopup")` trifft sie nie — und tat es an zwei
392    /// Stellen nicht: `patterns::modal_dialog` und `patterns::disclosure_menu`
393    /// fragten so nach dem Auslöser und bekamen immer `None`. Beide Journeys
394    /// haben dadurch nie einen Kandidaten gesehen; über 171 gelaufene Seiten
395    /// lief keine einzige Modal- oder Menü-Journey.
396    ///
397    /// Deshalb steht der Name genau einmal im Code, hier.
398    pub fn haspopup(&self) -> Option<&str> {
399        self.get_property_str("hasPopup")
400    }
401
402    /// Der Wert einer Eigenschaft als Text, unabhängig von seinem CDP-Typ.
403    ///
404    /// Nötig, weil ARIA-Zustände in Chromes Baum uneinheitlich ankommen:
405    /// `expanded` als `Bool`, `invalid` als Token-`String` ("false" / "true" /
406    /// "grammar" / "spelling"), `level` als `Int`. Wer sie einzeln über
407    /// `get_property_bool` liest, übersieht die Token-Fälle stillschweigend —
408    /// genau daran hing #566, und im Diff hing `invalid` daran ein zweites Mal.
409    ///
410    /// Knotenverweise und Listen haben keinen sinnvollen Skalarwert und
411    /// liefern `None`; für sie ist [`has_property`](Self::has_property) da.
412    pub fn property_value_str(&self, name: &str) -> Option<String> {
413        let value = &self.properties.iter().find(|p| p.name == name)?.value;
414        match value {
415            AXValue::Bool(b) => Some(b.to_string()),
416            AXValue::Int(i) => Some(i.to_string()),
417            AXValue::Float(f) => Some(f.to_string()),
418            AXValue::String(s) => Some(s.clone()),
419            AXValue::Node { .. } | AXValue::List(_) => None,
420        }
421    }
422
423    /// Returns true if a property with this name exists, regardless of value type.
424    /// Use this for relationship attributes (controls, owns, …) whose CDP values are
425    /// node references rather than plain strings.
426    pub fn has_property(&self, name: &str) -> bool {
427        self.properties.iter().any(|p| p.name == name)
428    }
429
430    /// Get the first idref from a relationship property.
431    /// Handles both `AXValue::String` (test fixtures / enriched properties) and
432    /// `AXValue::Node` (real CDP idref/idrefList traffic).
433    pub fn get_property_idref(&self, name: &str) -> Option<&str> {
434        self.properties
435            .iter()
436            .find(|p| p.name == name)
437            .and_then(|p| match &p.value {
438                AXValue::String(s) => Some(s.as_str()),
439                AXValue::Node { related_nodes } => related_nodes.first()?.idref.as_deref(),
440                _ => None,
441            })
442    }
443
444    /// Get all idrefs from a multi-target relationship property (e.g. aria-owns).
445    /// Handles both `AXValue::String` (space-separated IDs) and `AXValue::Node`.
446    pub fn get_property_idrefs(&self, name: &str) -> Vec<&str> {
447        self.properties
448            .iter()
449            .find(|p| p.name == name)
450            .map(|p| match &p.value {
451                AXValue::String(s) => s.split_whitespace().collect(),
452                AXValue::Node { related_nodes } => related_nodes
453                    .iter()
454                    .filter_map(|n| n.idref.as_deref())
455                    .collect(),
456                _ => vec![],
457            })
458            .unwrap_or_default()
459    }
460
461    /// Check if the node has a specific role
462    pub fn has_role(&self, role: &str) -> bool {
463        self.role.as_deref() == Some(role)
464    }
465
466    /// Returns true if this node is a browser-generated artifact that no WCAG
467    /// rule should evaluate. These roles are injected by the rendering engine
468    /// and do not represent author content — flagging them is always a false
469    /// positive.
470    /// Whether this node carries a run of the page's visible text.
471    ///
472    /// The single definition of "text" for anything measuring how much a page
473    /// says — see [`AXTree::text_nodes`]
474    /// for why it is `StaticText` and nothing else.
475    pub fn is_text(&self) -> bool {
476        self.role.as_deref() == Some("StaticText")
477            && self.name.as_deref().is_some_and(|name| !name.is_empty())
478    }
479
480    pub fn is_browser_generated(&self) -> bool {
481        matches!(
482            self.role.as_deref(),
483            Some("StaticText")
484                | Some("InlineTextBox")
485                | Some("LineBreak")
486                | Some("ListMarker")
487                | Some("LayoutTable")
488                | Some("LayoutTableRow")
489                | Some("LayoutTableCell")
490        )
491    }
492
493    /// Get the required property (for form validation)
494    pub fn is_required(&self) -> bool {
495        self.get_property_bool("required").unwrap_or(false)
496    }
497
498    /// Get the invalid property (for form validation).
499    ///
500    /// `aria-invalid` is an ARIA *token* attribute ("false" / "true" /
501    /// "grammar" / "spelling"), not a boolean one — confirmed live, real CDP
502    /// traffic carries it as `AXValue::String`, never `AXValue::Bool`, so
503    /// `get_property_bool("invalid")` always returned `None` and this always
504    /// reported `false` regardless of the actual attribute (#566). Any value
505    /// other than the explicit "false" (or the property being absent, i.e.
506    /// aria-invalid never set) counts as invalid.
507    pub fn is_invalid(&self) -> bool {
508        self.get_property_str("invalid")
509            .map(|v| v != "false")
510            .unwrap_or(false)
511    }
512}
513
514/// Source of an accessible name
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "camelCase")]
517pub enum NameSource {
518    /// Name from attribute (aria-label, alt, title)
519    Attribute,
520    /// Name from associated label element
521    RelatedElement,
522    /// Name from content/children
523    Contents,
524    /// Name from placeholder
525    Placeholder,
526    /// Name from title attribute
527    Title,
528}
529
530/// A property of an AXNode
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct AXProperty {
533    /// Property name
534    pub name: String,
535    /// Property value
536    pub value: AXValue,
537}
538
539/// Value of an AXProperty
540#[derive(Debug, Clone, Serialize, Deserialize)]
541#[serde(untagged)]
542pub enum AXValue {
543    /// Boolean value
544    Bool(bool),
545    /// Integer value
546    Int(i64),
547    /// Float value
548    Float(f64),
549    /// String value
550    String(String),
551    /// Related node
552    Node { related_nodes: Vec<RelatedNode> },
553    /// List of values
554    List(Vec<AXValue>),
555}
556
557impl AXValue {
558    /// Get as boolean if applicable
559    pub fn as_bool(&self) -> Option<bool> {
560        match self {
561            AXValue::Bool(b) => Some(*b),
562            _ => None,
563        }
564    }
565
566    /// Get as integer if applicable
567    pub fn as_int(&self) -> Option<i64> {
568        match self {
569            AXValue::Int(i) => Some(*i),
570            AXValue::Float(f) => Some(*f as i64),
571            _ => None,
572        }
573    }
574
575    /// Get as string if applicable
576    pub fn as_str(&self) -> Option<&str> {
577        match self {
578            AXValue::String(s) => Some(s),
579            _ => None,
580        }
581    }
582}
583
584/// A related node reference
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct RelatedNode {
587    /// The related node's backend DOM node ID
588    pub backend_dom_node_id: Option<i64>,
589    /// The related node's IDREF
590    pub idref: Option<String>,
591    /// Text content of the related node
592    pub text: Option<String>,
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    fn create_test_node(id: &str, role: &str, name: Option<&str>) -> AXNode {
600        AXNode {
601            node_id: id.to_string(),
602            ignored: false,
603            ignored_reasons: vec![],
604            role: Some(role.to_string()),
605            name: name.map(String::from),
606            name_source: None,
607            description: None,
608            value: None,
609            properties: vec![],
610            child_ids: vec![],
611            parent_id: None,
612            backend_dom_node_id: None,
613        }
614    }
615
616    /// Plan 49: `nodes` is a `HashMap`, and two maps in one process do not
617    /// even agree with each other, so iterating it directly made the tree a
618    /// different document on every run. Built twice, iterated twice — the
619    /// sequence has to be the same all four times.
620    #[test]
621    fn iteration_follows_document_order_not_hash_order() {
622        let ids: Vec<String> = (0..64).map(|i| format!("n{i}")).collect();
623        let build = || {
624            AXTree::from_nodes(
625                ids.iter()
626                    .map(|id| create_test_node(id, "heading", Some("x")))
627                    .collect(),
628            )
629        };
630
631        let first = build();
632        let second = build();
633        let seq = |t: &AXTree| -> Vec<String> { t.iter_all().map(|n| n.node_id.clone()).collect() };
634
635        assert_eq!(seq(&first), ids, "construction order is document order");
636        assert_eq!(seq(&first), seq(&second), "two builds must agree");
637        assert_eq!(seq(&first), seq(&first), "one tree must agree with itself");
638    }
639
640    /// A tree that lost its order — an artifact cached before the field
641    /// existed, or a direct write to the public `nodes` map — is rebuilt from
642    /// `child_ids` rather than falling back to hash order.
643    #[test]
644    fn a_tree_without_stored_order_is_rebuilt_from_its_children() {
645        let mut root = create_test_node("root", "WebArea", Some("Page"));
646        root.child_ids = vec!["a".into(), "b".into(), "c".into()];
647        let nodes = vec![
648            root,
649            create_test_node("a", "heading", Some("A")),
650            create_test_node("b", "heading", Some("B")),
651            create_test_node("c", "heading", Some("C")),
652        ];
653
654        let mut tree = AXTree::from_nodes(nodes);
655        // Simulate the lost order.
656        tree.order.clear();
657        let seq: Vec<String> = tree.iter_all().map(|n| n.node_id.clone()).collect();
658        assert_eq!(seq, vec!["root", "a", "b", "c"]);
659
660        // And an unreachable node still lands in a defined place.
661        tree.nodes.insert(
662            "zz".to_string(),
663            create_test_node("zz", "heading", Some("Z")),
664        );
665        tree.order.clear();
666        let seq: Vec<String> = tree.iter_all().map(|n| n.node_id.clone()).collect();
667        assert_eq!(seq, vec!["root", "a", "b", "c", "zz"]);
668    }
669
670    /// Plan 50: text has to be counted once. Chrome repeats a `StaticText`
671    /// run on its `InlineTextBox` children, and a heading's or link's name is
672    /// computed from the same `StaticText` descendants, so any wider filter
673    /// counts the same words two or three times.
674    #[test]
675    fn text_is_counted_once_per_run() {
676        let mut heading = create_test_node("h", "heading", Some("Ueberschrift"));
677        heading.child_ids = vec!["h-text".into()];
678        let mut link = create_test_node("l", "link", Some("Mehr erfahren"));
679        link.child_ids = vec!["l-text".into()];
680
681        let tree = AXTree::from_nodes(vec![
682            create_test_node("root", "RootWebArea", Some("Seite")),
683            heading,
684            create_test_node("h-text", "StaticText", Some("Ueberschrift")),
685            create_test_node("h-box", "InlineTextBox", Some("Ueberschrift")),
686            link,
687            create_test_node("l-text", "StaticText", Some("Mehr erfahren")),
688            create_test_node("p-text", "StaticText", Some("Fliesstext.")),
689        ]);
690
691        let texts: Vec<&str> = tree
692            .text_nodes()
693            .map(|n| n.name.as_deref().unwrap_or(""))
694            .collect();
695        assert_eq!(texts, vec!["Ueberschrift", "Mehr erfahren", "Fliesstext."]);
696        assert_eq!(
697            tree.visible_text_len(),
698            "Ueberschrift".len() + "Mehr erfahren".len() + "Fliesstext.".len(),
699        );
700    }
701
702    /// An empty name is not text — an unnamed `StaticText` node contributes
703    /// nothing and must not make a page look like it says something.
704    #[test]
705    fn an_unnamed_static_text_node_is_not_text() {
706        let tree = AXTree::from_nodes(vec![
707            create_test_node("root", "RootWebArea", Some("Seite")),
708            create_test_node("empty", "StaticText", Some("")),
709            create_test_node("none", "StaticText", None),
710        ]);
711        assert_eq!(tree.text_nodes().count(), 0);
712        assert_eq!(tree.visible_text_len(), 0);
713    }
714
715    #[test]
716    fn test_axtree_from_nodes() {
717        let nodes = vec![
718            create_test_node("1", "WebArea", Some("Page")),
719            create_test_node("2", "heading", Some("Title")),
720            create_test_node("3", "image", None),
721        ];
722
723        let tree = AXTree::from_nodes(nodes);
724        assert_eq!(tree.len(), 3);
725        assert_eq!(tree.root_id, Some("1".to_string()));
726    }
727
728    #[test]
729    fn test_axtree_images() {
730        let nodes = vec![
731            create_test_node("1", "WebArea", Some("Page")),
732            create_test_node("2", "image", Some("Logo")),
733            create_test_node("3", "image", None),
734            create_test_node("4", "heading", Some("Title")),
735        ];
736
737        let tree = AXTree::from_nodes(nodes);
738        let images = tree.images();
739        assert_eq!(images.len(), 2);
740    }
741
742    #[test]
743    fn test_axtree_headings() {
744        let nodes = vec![
745            create_test_node("1", "WebArea", Some("Page")),
746            create_test_node("2", "heading", Some("Title")),
747            create_test_node("3", "heading", Some("Subtitle")),
748        ];
749
750        let tree = AXTree::from_nodes(nodes);
751        let headings = tree.headings();
752        assert_eq!(headings.len(), 2);
753    }
754
755    #[test]
756    fn test_axnode_has_name() {
757        let node_with_name = create_test_node("1", "image", Some("Logo"));
758        let node_without_name = create_test_node("2", "image", None);
759        let node_empty_name = create_test_node("3", "image", Some("  "));
760
761        assert!(node_with_name.has_name());
762        assert!(!node_without_name.has_name());
763        assert!(!node_empty_name.has_name());
764    }
765
766    #[test]
767    fn test_axnode_heading_level() {
768        let mut heading = create_test_node("1", "heading", Some("Title"));
769        heading.properties.push(AXProperty {
770            name: "level".to_string(),
771            value: AXValue::Int(2),
772        });
773
774        assert_eq!(heading.heading_level(), Some(2));
775
776        let non_heading = create_test_node("2", "paragraph", Some("Text"));
777        assert_eq!(non_heading.heading_level(), None);
778    }
779
780    fn with_children(mut node: AXNode, children: &[&str]) -> AXNode {
781        node.child_ids = children.iter().map(|c| c.to_string()).collect();
782        node
783    }
784
785    /// The player interface Chrome exposes under `<video controls>` is not
786    /// author content: rules iterating the tree must not see it. The media
787    /// node itself and everything outside it stay.
788    #[test]
789    fn media_player_controls_are_not_auditable() {
790        let tree = AXTree::from_nodes(vec![
791            with_children(create_test_node("1", "RootWebArea", None), &["2", "3", "7"]),
792            create_test_node("2", "heading", Some("Clip")),
793            with_children(create_test_node("3", "Video", None), &["4"]),
794            with_children(create_test_node("4", "generic", None), &["5", "6"]),
795            create_test_node("5", "button", Some("Wiedergeben")),
796            create_test_node("6", "slider", Some("Video-Zeitachse")),
797            create_test_node("7", "slider", Some("Author volume")),
798        ]);
799        let roles: Vec<(&str, &str)> = tree
800            .iter()
801            .map(|n| (n.node_id.as_str(), n.role.as_deref().unwrap_or("")))
802            .collect();
803        assert_eq!(
804            roles,
805            vec![
806                ("1", "RootWebArea"),
807                ("2", "heading"),
808                ("3", "Video"),
809                ("7", "slider")
810            ]
811        );
812        assert_eq!(tree.form_controls().len(), 1, "only the author's slider");
813        // Nothing is dropped from the full traversal.
814        assert_eq!(tree.iter_all().count(), 7);
815    }
816
817    #[test]
818    fn audio_player_controls_are_not_auditable() {
819        let tree = AXTree::from_nodes(vec![
820            with_children(create_test_node("1", "RootWebArea", None), &["2"]),
821            with_children(create_test_node("2", "Audio", None), &["3"]),
822            create_test_node("3", "button", Some("Stumm")),
823        ]);
824        assert_eq!(tree.iter().count(), 2);
825    }
826}