Skip to main content

plushie_core/
selector.rs

1//! Widget selector for automation and tree search.
2//!
3//! Selectors identify widgets in the UI tree by various criteria:
4//! ID, visible text, accessibility role, accessibility label, or
5//! focus state. They are the addressing mechanism for the
6//! automation layer, used by both SDK-side tree search and
7//! renderer-side interact handling.
8//!
9//! # Selector formats
10//!
11//! ```ignore
12//! Selector::id("save")              // by widget ID
13//! Selector::id("form/save")         // by scoped ID path
14//! Selector::id("main#save")         // window-qualified ID
15//! Selector::text("Save")            // by visible text content
16//! Selector::role("button")          // by accessibility role
17//! Selector::label("Save document")  // by accessibility label
18//! Selector::focused()               // currently focused widget
19//! ```
20//!
21//! # Wire format
22//!
23//! Over the wire protocol, selectors are JSON objects:
24//!
25//! ```json
26//! {"by": "id", "value": "save"}
27//! {"by": "id", "value": "main#save", "window_id": "main"}
28//! {"by": "text", "value": "Save"}
29//! {"by": "role", "value": "button"}
30//! {"by": "label", "value": "Save document"}
31//! {"by": "focused"}
32//! ```
33
34use serde_json::Value;
35use std::fmt;
36
37use crate::protocol::TreeNode;
38
39/// A selector that identifies a widget in the UI tree.
40///
41/// Used by the automation layer to target interactions (click,
42/// type_text, etc.) and queries (find, assert). The selector is
43/// resolved against the current widget tree to locate the target.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Selector {
46    /// Match a widget by its ID (local or scoped path).
47    ///
48    /// The `widget_id` may be a bare local name (`"save"`), a scoped
49    /// path (`"form/save"`), or a window-qualified ID (`"main#save"`).
50    /// Bare names and partial scoped paths also match as trailing
51    /// segments, so `"form/save"` finds a node with the fully
52    /// qualified id `"main#form/save"`. When `window_id` is set, the
53    /// search is restricted to that window's subtree.
54    Id {
55        /// Target widget ID.
56        widget_id: String,
57        /// Target window ID.
58        window_id: Option<String>,
59    },
60    /// Match a widget by its visible text content.
61    ///
62    /// Searches the `content`, `label`, `value`, and `placeholder`
63    /// props for a matching string.
64    Text(String),
65    /// Match a widget by its accessibility role.
66    Role(String),
67    /// Match a widget by its accessibility label.
68    Label(String),
69    /// Match the widget that currently has keyboard focus.
70    Focused,
71}
72
73impl Selector {
74    /// Create an ID selector.
75    ///
76    /// If the ID contains `#`, the prefix is extracted as the
77    /// window ID for scoped search.
78    pub fn id(id: &str) -> Self {
79        let window_id = id
80            .split_once('#')
81            .filter(|(win, _)| !win.is_empty())
82            .map(|(win, _)| win.to_string());
83        Self::Id {
84            widget_id: id.to_string(),
85            window_id,
86        }
87    }
88
89    /// Create an ID selector with an explicit window scope.
90    pub fn id_in_window(id: &str, window_id: &str) -> Self {
91        Self::Id {
92            widget_id: id.to_string(),
93            window_id: Some(window_id.to_string()),
94        }
95    }
96
97    /// Create a text content selector.
98    pub fn text(text: &str) -> Self {
99        Self::Text(text.to_string())
100    }
101
102    /// Create an accessibility role selector.
103    pub fn role(role: &str) -> Self {
104        Self::Role(role.to_string())
105    }
106
107    /// Create an accessibility label selector.
108    pub fn label(label: &str) -> Self {
109        Self::Label(label.to_string())
110    }
111
112    /// Create a focused widget selector.
113    pub fn focused() -> Self {
114        Self::Focused
115    }
116
117    /// Parse a selector from the wire protocol JSON format.
118    ///
119    /// Expected format: `{"by": "id"|"text"|"role"|"label"|"focused", "value": "...", "window_id": "..."}`
120    pub fn from_wire(value: &Value) -> Option<Self> {
121        let by = value.get("by")?.as_str()?;
122        match by {
123            "focused" => Some(Self::Focused),
124            _ => {
125                let raw_value = value.get("value")?.as_str()?.to_string();
126                let explicit_window = value
127                    .get("window_id")
128                    .and_then(|v| v.as_str())
129                    .map(str::to_string);
130                match by {
131                    "id" => {
132                        let window_id = raw_value
133                            .split_once('#')
134                            .filter(|(win, _)| !win.is_empty())
135                            .map(|(win, _)| win.to_string())
136                            .or(explicit_window);
137                        Some(Self::Id {
138                            widget_id: raw_value,
139                            window_id,
140                        })
141                    }
142                    "text" => Some(Self::Text(raw_value)),
143                    "role" => Some(Self::Role(raw_value)),
144                    "label" => Some(Self::Label(raw_value)),
145                    _ => None,
146                }
147            }
148        }
149    }
150
151    /// Encode this selector to the wire protocol JSON format.
152    pub fn to_wire(&self) -> Value {
153        match self {
154            Self::Id {
155                widget_id,
156                window_id,
157            } => {
158                let mut obj = serde_json::json!({"by": "id", "value": widget_id});
159                if let Some(win) = window_id {
160                    obj["window_id"] = Value::String(win.clone());
161                }
162                obj
163            }
164            Self::Text(text) => serde_json::json!({"by": "text", "value": text}),
165            Self::Role(role) => serde_json::json!({"by": "role", "value": role}),
166            Self::Label(label) => serde_json::json!({"by": "label", "value": label}),
167            Self::Focused => serde_json::json!({"by": "focused"}),
168        }
169    }
170}
171
172/// Convenience conversion from `&str` to `Selector::Id`.
173///
174/// Bare strings are treated as ID selectors. Supports the `#`
175/// syntax for window qualification (`"main#save"`).
176impl From<&str> for Selector {
177    fn from(s: &str) -> Self {
178        Self::id(s)
179    }
180}
181
182impl From<String> for Selector {
183    fn from(s: String) -> Self {
184        Self::id(&s)
185    }
186}
187
188impl fmt::Display for Selector {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        match self {
191            Self::Id {
192                widget_id,
193                window_id: Some(win),
194            } if !widget_id.starts_with(&format!("{win}#")) => {
195                write!(f, "{win}#{widget_id}")
196            }
197            Self::Id { widget_id, .. } => write!(f, "{widget_id}"),
198            Self::Text(text) => write!(f, "{{text: {text:?}}}"),
199            Self::Role(role) => write!(f, "{{role: {role}}}"),
200            Self::Label(label) => write!(f, "{{label: {label:?}}}"),
201            Self::Focused => write!(f, "{{focused}}"),
202        }
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Tree search
208// ---------------------------------------------------------------------------
209
210/// Maximum recursion depth for selector tree traversal.
211pub const MAX_SELECTOR_SEARCH_DEPTH: usize = 256;
212
213impl Selector {
214    /// Find the first matching node in the tree.
215    ///
216    /// Returns a reference to the matching `TreeNode`, or `None` if
217    /// no node matches the selector criteria.
218    pub fn find<'a>(&self, root: &'a TreeNode) -> Option<&'a TreeNode> {
219        match self {
220            Self::Id {
221                widget_id,
222                window_id,
223            } => find_by_id(root, widget_id, window_id.as_deref(), None, 0),
224            Self::Text(text) => search(root, 0, &|n| matches_text(n, text)),
225            Self::Role(role) => search(root, 0, &|n| matches_role(n, role)),
226            Self::Label(label) => search(root, 0, &|n| matches_label(n, label)),
227            Self::Focused => search(root, 0, &is_focused),
228        }
229    }
230
231    /// Find all matching nodes in the tree.
232    ///
233    /// Returns a Vec of references to every `TreeNode` that matches.
234    pub fn find_all<'a>(&self, root: &'a TreeNode) -> Vec<&'a TreeNode> {
235        let mut results = Vec::new();
236        match self {
237            Self::Text(text) => search_all(root, 0, &|n| matches_text(n, text), &mut results),
238            Self::Role(role) => search_all(root, 0, &|n| matches_role(n, role), &mut results),
239            Self::Label(label) => search_all(root, 0, &|n| matches_label(n, label), &mut results),
240            Self::Focused => search_all(root, 0, &is_focused, &mut results),
241            Self::Id {
242                widget_id,
243                window_id,
244            } => {
245                // ID selectors match at most one node.
246                if let Some(node) = find_by_id(root, widget_id, window_id.as_deref(), None, 0) {
247                    results.push(node);
248                }
249            }
250        }
251        results
252    }
253}
254
255// -- Depth-first search helpers ----------------------------------------------
256
257fn search<'a>(
258    node: &'a TreeNode,
259    depth: usize,
260    predicate: &dyn Fn(&TreeNode) -> bool,
261) -> Option<&'a TreeNode> {
262    if depth > MAX_SELECTOR_SEARCH_DEPTH {
263        return None;
264    }
265    if predicate(node) {
266        return Some(node);
267    }
268    node.children
269        .iter()
270        .find_map(|child| search(child, depth + 1, predicate))
271}
272
273fn search_all<'a>(
274    node: &'a TreeNode,
275    depth: usize,
276    predicate: &dyn Fn(&TreeNode) -> bool,
277    results: &mut Vec<&'a TreeNode>,
278) {
279    if depth > MAX_SELECTOR_SEARCH_DEPTH {
280        return;
281    }
282    if predicate(node) {
283        results.push(node);
284    }
285    for child in &node.children {
286        search_all(child, depth + 1, predicate, results);
287    }
288}
289
290/// Find a node by ID, optionally scoped to a specific window.
291///
292/// Matches against the full scoped ID (`main#form/email`), the
293/// local name (the segment after the last `/` or `#`), and any
294/// trailing scoped-path suffix (so target `"todo-1/done"` matches
295/// a node with id `"main#todo-1/done"`). This lets callers use
296/// bare names, partial scoped paths, or fully qualified ids
297/// interchangeably.
298///
299/// The walk is depth-first, pre-order, and returns the first node
300/// that matches. With an unqualified target like `"save"`, a tree
301/// containing both `main#save` and `main#form/checkout/save` will
302/// match whichever appears first in DFS order, which is `main#save`
303/// for the usual top-down layout. Callers that need a specific one
304/// should pass a more qualified path (e.g. `form/checkout/save`) or
305/// scope the search to a window.
306fn find_by_id<'a>(
307    node: &'a TreeNode,
308    target_id: &str,
309    target_window: Option<&str>,
310    current_window: Option<&'a str>,
311    depth: usize,
312) -> Option<&'a TreeNode> {
313    if depth > MAX_SELECTOR_SEARCH_DEPTH {
314        return None;
315    }
316
317    let current_window = if node.type_name == "window" {
318        Some(node.id.as_str())
319    } else {
320        current_window
321    };
322
323    let matches_id = node.id == target_id
324        || local_name(&node.id) == target_id
325        || node.id.ends_with(&format!("/{target_id}"))
326        || node.id.ends_with(&format!("#{target_id}"));
327    if matches_id && target_window.is_none_or(|win| current_window == Some(win)) {
328        return Some(node);
329    }
330
331    node.children
332        .iter()
333        .find_map(|child| find_by_id(child, target_id, target_window, current_window, depth + 1))
334}
335
336/// Extract the local name from a scoped ID.
337///
338/// `"main#form/email"` -> `"email"`
339/// `"form/email"` -> `"email"`
340/// `"email"` -> `"email"`
341fn local_name(id: &str) -> &str {
342    id.rsplit_once('/')
343        .or_else(|| id.rsplit_once('#'))
344        .map(|(_, local)| local)
345        .unwrap_or(id)
346}
347
348// -- Node predicates ---------------------------------------------------------
349
350/// Match against text content in `content`, `label`, `value`, and
351/// `placeholder` props.
352fn matches_text(node: &TreeNode, text: &str) -> bool {
353    for key in &["content", "label", "value", "placeholder"] {
354        if node.props.get_str(key) == Some(text) {
355            return true;
356        }
357    }
358    false
359}
360
361/// Match by explicit `a11y.role`, falling back to `type_name` when
362/// no `a11y` prop is present.
363fn matches_role(node: &TreeNode, role: &str) -> bool {
364    if let Some(a11y) = node.props.get_value("a11y") {
365        a11y.get("role").and_then(|v| v.as_str()) == Some(role)
366    } else {
367        node.type_name == role
368    }
369}
370
371/// Match by explicit `a11y.label`, falling back to `label` and
372/// `content` props.
373fn matches_label(node: &TreeNode, label: &str) -> bool {
374    if let Some(a11y) = node.props.get_value("a11y")
375        && a11y.get("label").and_then(|v| v.as_str()) == Some(label)
376    {
377        return true;
378    }
379    for key in &["label", "content"] {
380        if node.props.get_str(key) == Some(label) {
381            return true;
382        }
383    }
384    false
385}
386
387/// Match nodes with `props.focused == true` or `a11y.focused == true`.
388fn is_focused(node: &TreeNode) -> bool {
389    if node.props.get_bool("focused") == Some(true) {
390        return true;
391    }
392    if let Some(a11y) = node.props.get_value("a11y")
393        && a11y.get("focused").and_then(|v| v.as_bool()) == Some(true)
394    {
395        return true;
396    }
397    false
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::protocol::Props;
404
405    /// Construct a minimal [`TreeNode`] for tree-search tests.
406    fn node(id: &str, type_name: &str) -> TreeNode {
407        TreeNode {
408            id: id.to_string(),
409            type_name: type_name.to_string(),
410            props: Props::default(),
411            children: vec![],
412        }
413    }
414
415    /// Construct a [`TreeNode`] with children.
416    fn node_with_children(id: &str, type_name: &str, children: Vec<TreeNode>) -> TreeNode {
417        TreeNode {
418            id: id.to_string(),
419            type_name: type_name.to_string(),
420            props: Props::default(),
421            children,
422        }
423    }
424
425    fn text_node_at_depth(depth: usize, text: &str) -> TreeNode {
426        let mut target = node("target", "text");
427        target.props = Props::from_json(serde_json::json!({"content": text}));
428
429        for level in (0..depth).rev() {
430            target = node_with_children(&format!("level-{level}"), "column", vec![target]);
431        }
432
433        target
434    }
435
436    #[test]
437    fn find_by_id_matches_exact_id() {
438        let root = node_with_children(
439            "main",
440            "window",
441            vec![node("main#save", "button"), node("main#cancel", "button")],
442        );
443        let sel = Selector::id("main#save");
444        let found = sel.find(&root).expect("exact id match");
445        assert_eq!(found.id, "main#save");
446    }
447
448    #[test]
449    fn find_by_id_matches_local_name() {
450        let root = node_with_children(
451            "main",
452            "window",
453            vec![node("main#save", "button"), node("main#cancel", "button")],
454        );
455        let sel = Selector::id("save");
456        let found = sel.find(&root).expect("local-name match");
457        assert_eq!(found.id, "main#save");
458    }
459
460    #[test]
461    fn find_by_id_matches_scoped_path_suffix() {
462        let root = node_with_children(
463            "main",
464            "window",
465            vec![node_with_children(
466                "main#todos",
467                "column",
468                vec![node("main#todo-1/done", "checkbox")],
469            )],
470        );
471        let sel = Selector::id("todo-1/done");
472        let found = sel
473            .find(&root)
474            .expect("scoped-path suffix should match trailing segments");
475        assert_eq!(found.id, "main#todo-1/done");
476    }
477
478    #[test]
479    fn find_by_id_matches_deeply_nested_scoped_suffix() {
480        let root = node_with_children(
481            "main",
482            "window",
483            vec![node_with_children(
484                "main#page-theme",
485                "column",
486                vec![node_with_children(
487                    "main#page-theme/page",
488                    "column",
489                    vec![node_with_children(
490                        "main#page-theme/page/rating-card",
491                        "column",
492                        vec![node("main#page-theme/page/rating-card/stars", "canvas")],
493                    )],
494                )],
495            )],
496        );
497        let sel = Selector::id("page-theme/page/rating-card/stars");
498        let found = sel
499            .find(&root)
500            .expect("deep scoped-path suffix should match");
501        assert_eq!(found.id, "main#page-theme/page/rating-card/stars");
502    }
503
504    #[test]
505    fn find_by_id_local_name_still_matches_for_bare_target() {
506        // Target "done" should still be resolvable via the local-name
507        // rule even when the only candidate is a sibling subtree whose
508        // scoped suffix does not line up on a `/` boundary.
509        let root = node_with_children(
510            "main",
511            "window",
512            vec![node("main#unrelated/done", "checkbox")],
513        );
514        let sel = Selector::id("done");
515        let found = sel
516            .find(&root)
517            .expect("local-name rule should still hit here");
518        assert_eq!(found.id, "main#unrelated/done");
519    }
520
521    #[test]
522    fn find_by_id_does_not_match_mid_segment_substring() {
523        // The suffix rule requires a `/` or `#` boundary. Target
524        // "ne/done" must not match "main#unrelated/done" just because
525        // it appears as a raw substring.
526        let root = node_with_children(
527            "main",
528            "window",
529            vec![node("main#unrelated/done", "checkbox")],
530        );
531        let sel = Selector::id("ne/done");
532        assert!(
533            sel.find(&root).is_none(),
534            "suffix match must respect segment boundaries"
535        );
536    }
537
538    #[test]
539    fn selector_search_stops_after_max_depth() {
540        let at_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH, "needle");
541        assert!(Selector::text("needle").find(&at_limit).is_some());
542
543        let past_limit = text_node_at_depth(MAX_SELECTOR_SEARCH_DEPTH + 1, "needle");
544        assert!(Selector::text("needle").find(&past_limit).is_none());
545    }
546
547    // -----------------------------------------------------------------------
548    // Wire codec round-trips
549    //
550    // `from_wire` parses the JSON shape the SDK emits; `to_wire` produces
551    // it. Drift between the two would silently misroute interact requests
552    // (e.g. a renamed `by` discriminant), so each variant gets a paired
553    // round-trip pin.
554    // -----------------------------------------------------------------------
555
556    fn selector_wire_round_trip(sel: Selector) {
557        let wire = sel.to_wire();
558        let parsed = Selector::from_wire(&wire).unwrap_or_else(|| {
559            panic!("Selector::from_wire returned None for {sel:?} (wire: {wire})")
560        });
561        assert_eq!(parsed, sel);
562    }
563
564    #[test]
565    fn selector_id_round_trips() {
566        selector_wire_round_trip(Selector::id("save"));
567    }
568
569    #[test]
570    fn selector_id_with_window_qualification_round_trips() {
571        // The `#` syntax is stripped into a separate window_id field
572        // by `id()`; the wire format reflects both.
573        let sel = Selector::id("main#save");
574        selector_wire_round_trip(sel);
575        let parsed = Selector::from_wire(&serde_json::json!({
576            "by": "id",
577            "value": "main#save",
578        }))
579        .unwrap();
580        assert_eq!(
581            parsed,
582            Selector::Id {
583                widget_id: "main#save".into(),
584                window_id: Some("main".into()),
585            }
586        );
587    }
588
589    #[test]
590    fn selector_id_in_window_round_trips() {
591        // `id_in_window` keeps the id local; window_id rides as a
592        // sidecar field. The sidecar must round-trip independently.
593        let sel = Selector::id_in_window("save", "popup");
594        selector_wire_round_trip(sel);
595    }
596
597    #[test]
598    fn selector_text_round_trips() {
599        selector_wire_round_trip(Selector::text("Save document"));
600    }
601
602    #[test]
603    fn selector_role_round_trips() {
604        selector_wire_round_trip(Selector::role("button"));
605    }
606
607    #[test]
608    fn selector_label_round_trips() {
609        selector_wire_round_trip(Selector::label("Save"));
610    }
611
612    #[test]
613    fn selector_focused_round_trips() {
614        selector_wire_round_trip(Selector::focused());
615    }
616
617    #[test]
618    fn selector_unknown_by_returns_none() {
619        // An unknown `by` discriminant is rejected at the wire boundary
620        // rather than papering over with a default selector.
621        assert!(
622            Selector::from_wire(&serde_json::json!({
623                "by": "future_kind",
624                "value": "x",
625            }))
626            .is_none()
627        );
628    }
629
630    #[test]
631    fn selector_missing_value_for_non_focused_returns_none() {
632        // All non-focused selectors require a `value`. Absence is a
633        // protocol violation; surface it instead of constructing an
634        // empty selector.
635        for by in ["id", "text", "role", "label"] {
636            assert!(
637                Selector::from_wire(&serde_json::json!({"by": by})).is_none(),
638                "expected None for missing value on by={by}",
639            );
640        }
641    }
642
643    // -----------------------------------------------------------------------
644    // Tree-search predicates: Role, Label, Focused
645    //
646    // The earlier tests cover Id and Text. Each predicate has its own
647    // resolution rules (with fallbacks); they each get a focused
648    // BDD-style scenario here.
649    // -----------------------------------------------------------------------
650
651    fn node_with_a11y(id: &str, type_name: &str, a11y: serde_json::Value) -> TreeNode {
652        TreeNode {
653            id: id.into(),
654            type_name: type_name.into(),
655            props: Props::from_json(serde_json::json!({"a11y": a11y})),
656            children: vec![],
657        }
658    }
659
660    #[test]
661    fn role_matches_explicit_a11y_role_first() {
662        // When a node has an a11y prop, the `role` predicate reads
663        // from it. The type_name fallback only applies when a11y is
664        // absent.
665        let root = node_with_children(
666            "root",
667            "container",
668            vec![
669                node_with_a11y(
670                    "explicit",
671                    "container",
672                    serde_json::json!({"role": "button"}),
673                ),
674                node("by-type", "button"),
675            ],
676        );
677
678        let found = Selector::role("button").find(&root).unwrap();
679        // First match in DFS order: the explicit a11y role.
680        assert_eq!(found.id, "explicit");
681    }
682
683    #[test]
684    fn role_falls_back_to_type_name_without_a11y() {
685        // Most built-in widgets emit type_name without an explicit
686        // a11y prop. The fallback covers that path.
687        let root = node_with_children("root", "container", vec![node("btn", "button")]);
688        let found = Selector::role("button").find(&root).unwrap();
689        assert_eq!(found.id, "btn");
690    }
691
692    #[test]
693    fn label_prefers_a11y_label_then_label_prop_then_content() {
694        let root = node_with_children(
695            "root",
696            "container",
697            vec![
698                {
699                    let mut n = node("a11y_match", "button");
700                    n.props =
701                        Props::from_json(serde_json::json!({"a11y": {"label": "Save document"}}));
702                    n
703                },
704                {
705                    let mut n = node("label_prop_match", "button");
706                    n.props = Props::from_json(serde_json::json!({"label": "Save"}));
707                    n
708                },
709                {
710                    let mut n = node("content_match", "text");
711                    n.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
712                    n
713                },
714            ],
715        );
716
717        assert_eq!(
718            Selector::label("Save document").find(&root).unwrap().id,
719            "a11y_match",
720        );
721        assert_eq!(
722            Selector::label("Save").find(&root).unwrap().id,
723            "label_prop_match",
724        );
725        assert_eq!(
726            Selector::label("Cancel").find(&root).unwrap().id,
727            "content_match",
728        );
729    }
730
731    #[test]
732    fn focused_matches_props_and_a11y_focused() {
733        // Both `props.focused: true` and `a11y.focused: true` resolve
734        // through the focused predicate; the renderer-side a11y
735        // wrapper writes one or the other depending on the widget.
736        let mut props_focused = node("via-props", "text_input");
737        props_focused.props = Props::from_json(serde_json::json!({"focused": true}));
738        let mut a11y_focused = node("via-a11y", "text_input");
739        a11y_focused.props = Props::from_json(serde_json::json!({"a11y": {"focused": true}}));
740
741        let root = node_with_children("root", "container", vec![props_focused, a11y_focused]);
742
743        // First match wins (DFS, pre-order).
744        let found = Selector::focused().find(&root).unwrap();
745        assert_eq!(found.id, "via-props");
746    }
747
748    #[test]
749    fn focused_returns_none_when_nothing_is_focused() {
750        let root = node_with_children(
751            "root",
752            "container",
753            vec![node("a", "button"), node("b", "button")],
754        );
755        assert!(Selector::focused().find(&root).is_none());
756    }
757
758    // -----------------------------------------------------------------------
759    // find_all
760    //
761    // ID matches yield at most one node; text/role/label/focused yield
762    // every match.
763    // -----------------------------------------------------------------------
764
765    #[test]
766    fn find_all_role_returns_every_match() {
767        let root = node_with_children(
768            "root",
769            "container",
770            vec![
771                node("btn1", "button"),
772                node_with_children("inner", "container", vec![node("btn2", "button")]),
773                node("not_a_button", "text"),
774            ],
775        );
776        let found = Selector::role("button").find_all(&root);
777        let ids: Vec<&str> = found.iter().map(|n| n.id.as_str()).collect();
778        assert_eq!(ids, vec!["btn1", "btn2"]);
779    }
780
781    #[test]
782    fn find_all_text_returns_every_match() {
783        let mut a = node("a", "text");
784        a.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
785        let mut b = node("b", "text");
786        b.props = Props::from_json(serde_json::json!({"content": "Cancel"}));
787        let mut c = node("c", "text");
788        c.props = Props::from_json(serde_json::json!({"content": "Save"}));
789
790        let root = node_with_children("root", "container", vec![a, b, c]);
791        let ids: Vec<&str> = Selector::text("Cancel")
792            .find_all(&root)
793            .iter()
794            .map(|n| n.id.as_str())
795            .collect();
796        assert_eq!(ids, vec!["a", "b"]);
797    }
798
799    #[test]
800    fn find_all_id_returns_at_most_one_match() {
801        // ID selectors short-circuit on the first match by design.
802        // `find_all` mirrors that and still returns a Vec.
803        let root = node_with_children(
804            "root",
805            "container",
806            vec![
807                node("only-once", "button"),
808                node("only-once", "text"), // Same id; second one is unreachable.
809            ],
810        );
811        let found = Selector::id("only-once").find_all(&root);
812        assert_eq!(found.len(), 1);
813        assert_eq!(found[0].type_name, "button");
814    }
815
816    #[test]
817    fn find_all_focused_returns_every_focused_node() {
818        // Multiple focused indicators in the tree (uncommon but
819        // protocol-legal during transient focus shuffles); find_all
820        // returns all of them.
821        let mut a = node("a", "text_input");
822        a.props = Props::from_json(serde_json::json!({"focused": true}));
823        let mut b = node("b", "text_input");
824        b.props = Props::from_json(serde_json::json!({"a11y": {"focused": true}}));
825
826        let root = node_with_children("root", "container", vec![a, b]);
827        let ids: Vec<&str> = Selector::focused()
828            .find_all(&root)
829            .iter()
830            .map(|n| n.id.as_str())
831            .collect();
832        assert_eq!(ids, vec!["a", "b"]);
833    }
834
835    #[test]
836    fn find_all_returns_empty_when_no_match() {
837        let root = node_with_children("root", "container", vec![node("a", "text")]);
838        assert!(Selector::role("button").find_all(&root).is_empty());
839    }
840}