Skip to main content

xa11y_core/
element.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::ops::Deref;
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::Error;
9use crate::provider::Provider;
10use crate::role::Role;
11
12/// The raw data for a single element in an accessibility tree.
13///
14/// This is the underlying data struct. Most consumers should use [`Element`],
15/// which wraps `ElementData` with a provider reference for lazy navigation.
16/// `ElementData` is used directly by provider implementors.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ElementData {
19    /// Element role
20    pub role: Role,
21
22    /// Human-readable name (title, label).
23    ///
24    /// Stripped of Unicode bidi format controls (LRM, RLM, embeddings,
25    /// overrides, isolates) so equality assertions match the logical text.
26    /// The unstripped platform string is preserved in [`Self::raw`] under the
27    /// platform-native key (e.g. `AXTitle` on macOS, `atspi_name` on Linux,
28    /// `uia_name` on Windows). See [`crate::text::strip_bidi`].
29    pub name: Option<String>,
30
31    /// Current value (text content, slider position, etc.).
32    ///
33    /// Stripped of Unicode bidi format controls. The unstripped platform
34    /// string is preserved in [`Self::raw`] (`AXValue` on macOS, `atspi_value`
35    /// on Linux, `uia_value` on Windows). See [`crate::text::strip_bidi`].
36    pub value: Option<String>,
37
38    /// Supplementary description (tooltip, help text).
39    ///
40    /// Stripped of Unicode bidi format controls. The unstripped platform
41    /// string is preserved in [`Self::raw`] (`AXDescription`/`AXHelp` on
42    /// macOS, `atspi_description` on Linux, `uia_help_text` on Windows).
43    /// See [`crate::text::strip_bidi`].
44    pub description: Option<String>,
45
46    /// Bounding rectangle in **logical** screen coordinates
47    /// (device-independent points), origin at the top-left of the primary
48    /// display. This is the same coordinate space accepted by
49    /// [`crate::ScreenshotProvider::capture_region`] and by the input layer's
50    /// [`crate::input::Point`], so bounds can be fed directly to
51    /// `screenshot_element` / `click` without conversion.
52    ///
53    /// To map to physical device pixels (e.g. to index into a captured image),
54    /// multiply by the [`crate::Screenshot::scale`] reported for that display:
55    /// `physical = logical × scale`. See [`Rect::to_physical`] /
56    /// [`Rect::to_logical`].
57    pub bounds: Option<Rect>,
58
59    /// Available actions reported by the platform.
60    ///
61    /// Names are `snake_case` strings — well-known actions use their standard
62    /// names (`"press"`, `"toggle"`, `"expand"`, etc.) and platform-specific
63    /// actions use their converted names (e.g. macOS `AXCustomThing` →
64    /// `"custom_thing"`).
65    pub actions: Vec<String>,
66
67    /// Current state flags
68    pub states: StateSet,
69
70    /// Numeric value for range controls (sliders, progress bars, spinners).
71    pub numeric_value: Option<f64>,
72
73    /// Minimum value for range controls.
74    pub min_value: Option<f64>,
75
76    /// Maximum value for range controls.
77    pub max_value: Option<f64>,
78
79    /// Platform-assigned stable identifier for cross-snapshot correlation.
80    /// - macOS: `AXIdentifier`
81    /// - Windows: `AutomationId`
82    /// - Linux: D-Bus `object_path`
83    ///
84    /// Not all elements have one.
85    pub stable_id: Option<String>,
86
87    /// Process ID of the application that owns this element.
88    pub pid: Option<u32>,
89
90    /// Platform-specific raw data
91    pub raw: RawPlatformData,
92
93    /// Opaque handle for the provider to look up the platform object.
94    /// Not serialized — only valid within the provider that created it.
95    #[serde(skip, default)]
96    pub handle: u64,
97}
98
99/// A live element with lazy navigation via a provider reference.
100///
101/// `Element` dereferences to [`ElementData`], so all properties (`role`, `name`,
102/// `value`, `states`, etc.) are accessible via field access. Navigation
103/// methods (`parent()`, `children()`) call the provider on demand.
104///
105/// Elements are cheap to clone (they share the provider via `Arc`).
106#[derive(Clone)]
107pub struct Element {
108    data: ElementData,
109    provider: Arc<dyn Provider>,
110}
111
112impl Deref for Element {
113    type Target = ElementData;
114
115    fn deref(&self) -> &ElementData {
116        &self.data
117    }
118}
119
120impl fmt::Debug for Element {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        fmt::Debug::fmt(&self.data, f)
123    }
124}
125
126impl fmt::Display for Element {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        let name_part = self
129            .data
130            .name
131            .as_ref()
132            .map(|n| format!(" \"{}\"", n))
133            .unwrap_or_default();
134        let value_part = self
135            .data
136            .value
137            .as_ref()
138            .map(|v| format!(" value=\"{}\"", v))
139            .unwrap_or_default();
140        write!(
141            f,
142            "{}{}{}",
143            self.data.role.to_snake_case(),
144            name_part,
145            value_part,
146        )
147    }
148}
149
150impl Serialize for Element {
151    fn serialize<S: serde::Serializer>(
152        &self,
153        serializer: S,
154    ) -> std::result::Result<S::Ok, S::Error> {
155        self.data.serialize(serializer)
156    }
157}
158
159impl Element {
160    /// Create an Element from raw data and a provider reference.
161    pub fn new(data: ElementData, provider: Arc<dyn Provider>) -> Self {
162        Self { data, provider }
163    }
164
165    /// Get the underlying ElementData.
166    pub fn data(&self) -> &ElementData {
167        &self.data
168    }
169
170    /// Get the provider reference.
171    pub fn provider(&self) -> &Arc<dyn Provider> {
172        &self.provider
173    }
174
175    /// Get direct children of this element.
176    ///
177    /// Each call queries the provider — results are not cached.
178    pub fn children(&self) -> crate::error::Result<Vec<Element>> {
179        let children = self.provider.get_children(Some(&self.data))?;
180        Ok(children
181            .into_iter()
182            .map(|d| Element::new(d, Arc::clone(&self.provider)))
183            .collect())
184    }
185
186    /// Get the parent element, if any (root-level elements have no parent).
187    ///
188    /// Each call queries the provider — results are not cached.
189    pub fn parent(&self) -> crate::error::Result<Option<Element>> {
190        let parent = self.provider.get_parent(&self.data)?;
191        Ok(parent.map(|d| Element::new(d, Arc::clone(&self.provider))))
192    }
193
194    /// Get the process ID from the element data.
195    pub fn pid(&self) -> Option<u32> {
196        self.data.pid
197    }
198
199    /// Capture the subtree rooted at this element as a recursive snapshot.
200    ///
201    /// `max_depth` limits traversal depth: `0` = only this node (no children),
202    /// `1` = node + direct children, and so on. `None` traverses the full subtree.
203    pub fn tree(&self, max_depth: Option<usize>) -> crate::error::Result<TreeNode> {
204        build_tree_node(self, max_depth, 0)
205    }
206
207    /// Render the subtree rooted at this element as an indented string.
208    ///
209    /// Each line is `{indent}{role} "{name}" [value="{value}"]`. Returns the
210    /// string without printing it. Same depth semantics as [`Element::tree`].
211    pub fn dump(&self, max_depth: Option<usize>) -> crate::error::Result<String> {
212        let node = self.tree(max_depth)?;
213        let mut out = String::new();
214        write_tree_node(&node, 0, &mut out);
215        Ok(out)
216    }
217
218    // ── Actions ─────────────────────────────────────────────────────
219    //
220    // Element actions invoke the platform via the captured provider handle —
221    // they do **not** re-resolve the selector. If the underlying element has
222    // been destroyed since this snapshot was taken, the provider returns a
223    // platform-specific "gone" error. For resilient retry-on-change semantics,
224    // use the equivalent method on [`crate::Locator`] instead.
225
226    /// Click / invoke this element via the accessibility action layer.
227    pub fn press(&self) -> crate::error::Result<()> {
228        self.provider.press(&self.data)
229    }
230
231    /// Set keyboard focus to this element.
232    pub fn focus(&self) -> crate::error::Result<()> {
233        self.provider.focus(&self.data)
234    }
235
236    /// Remove keyboard focus from this element.
237    pub fn blur(&self) -> crate::error::Result<()> {
238        self.provider.blur(&self.data)
239    }
240
241    /// Toggle a two- or three-state control (checkbox, switch).
242    pub fn toggle(&self) -> crate::error::Result<()> {
243        self.provider.toggle(&self.data)
244    }
245
246    /// Select this element (list item, tab, row).
247    pub fn select(&self) -> crate::error::Result<()> {
248        self.provider.select(&self.data)
249    }
250
251    /// Expand a disclosure, menu, combo box, or tree item.
252    pub fn expand(&self) -> crate::error::Result<()> {
253        self.provider.expand(&self.data)
254    }
255
256    /// Collapse an expanded element.
257    pub fn collapse(&self) -> crate::error::Result<()> {
258        self.provider.collapse(&self.data)
259    }
260
261    /// Open this element's context menu or dropdown.
262    pub fn show_menu(&self) -> crate::error::Result<()> {
263        self.provider.show_menu(&self.data)
264    }
265
266    /// Increment a numeric control (slider, spinner) by its platform step.
267    pub fn increment(&self) -> crate::error::Result<()> {
268        self.provider.increment(&self.data)
269    }
270
271    /// Decrement a numeric control (slider, spinner) by its platform step.
272    pub fn decrement(&self) -> crate::error::Result<()> {
273        self.provider.decrement(&self.data)
274    }
275
276    /// Scroll this element into the visible area.
277    ///
278    /// No-op on macOS — the macOS accessibility API has no equivalent.
279    pub fn scroll_into_view(&self) -> crate::error::Result<()> {
280        self.provider.scroll_into_view(&self.data)
281    }
282
283    /// Set the text value of this element. Replaces the entire value rather
284    /// than inserting at the caret — use [`Element::type_text`] for insertion.
285    pub fn set_value(&self, value: &str) -> crate::error::Result<()> {
286        self.provider.set_value(&self.data, value)
287    }
288
289    /// Set the numeric value of this element (slider, spinner).
290    ///
291    /// Returns [`Error::InvalidActionData`] if `value` is NaN or infinite.
292    pub fn set_numeric_value(&self, value: f64) -> crate::error::Result<()> {
293        if !value.is_finite() {
294            return Err(Error::InvalidActionData {
295                message: format!("set_numeric_value requires a finite value, got {}", value),
296            });
297        }
298        self.provider.set_numeric_value(&self.data, value)
299    }
300
301    /// Insert text at the current cursor position.
302    ///
303    /// Uses the platform accessibility API — never simulates keyboard events.
304    pub fn type_text(&self, text: &str) -> crate::error::Result<()> {
305        self.provider.type_text(&self.data, text)
306    }
307
308    /// Select the text range from `start` to `end` (0-based character offsets).
309    ///
310    /// Returns [`Error::InvalidActionData`] if `start > end`.
311    pub fn select_text(&self, start: u32, end: u32) -> crate::error::Result<()> {
312        if start > end {
313            return Err(Error::InvalidActionData {
314                message: format!("select_text start ({}) must be <= end ({})", start, end),
315            });
316        }
317        self.provider.set_text_selection(&self.data, start, end)
318    }
319
320    /// Perform an action by its `snake_case` name.
321    ///
322    /// Use this for actions the element advertises in its [`actions`](ElementData::actions)
323    /// list that don't have a dedicated method. Well-known names (`"press"`,
324    /// `"focus"`, etc.) also work — providers delegate to the named methods.
325    pub fn perform_action(&self, action: &str) -> crate::error::Result<()> {
326        self.provider.perform_action(&self.data, action)
327    }
328}
329
330fn build_tree_node(
331    element: &Element,
332    max_depth: Option<usize>,
333    depth: usize,
334) -> crate::error::Result<TreeNode> {
335    let children = if max_depth.is_none_or(|d| depth < d) {
336        element
337            .children()?
338            .into_iter()
339            .map(|child| build_tree_node(&child, max_depth, depth + 1))
340            .collect::<crate::error::Result<Vec<_>>>()?
341    } else {
342        vec![]
343    };
344    Ok(TreeNode {
345        role: element.data.role.to_snake_case().to_string(),
346        name: element.data.name.clone(),
347        value: element.data.value.clone(),
348        children,
349    })
350}
351
352fn write_tree_node(node: &TreeNode, depth: usize, out: &mut String) {
353    use fmt::Write as _;
354    let indent = "  ".repeat(depth);
355    write!(out, "{}{}", indent, node.role).unwrap();
356    if let Some(ref n) = node.name {
357        write!(out, " \"{}\"", n).unwrap();
358    }
359    if let Some(ref v) = node.value {
360        write!(out, " value=\"{}\"", v).unwrap();
361    }
362    out.push('\n');
363    for child in &node.children {
364        write_tree_node(child, depth + 1, out);
365    }
366}
367
368/// Boolean state flags for an element.
369///
370/// **Semantics for non-applicable states:** When a state doesn't apply to an
371/// element's role, the backend uses the platform's reported value or defaults:
372/// - `enabled`: `true` (elements are enabled unless explicitly disabled)
373/// - `visible`: `true` (elements are visible unless explicitly hidden/offscreen)
374/// - `focused`, `active`, `focusable`, `modal`, `selected`, `editable`, `required`, `busy`: `false`
375///
376/// States that are inherently inapplicable use `Option`: `checked` is `None`
377/// for non-checkable elements, `expanded` is `None` for non-expandable elements.
378///
379/// More states may be added in compatible releases, so this struct is
380/// `#[non_exhaustive]`: construct it via [`StateSet::default()`] (or
381/// [`Default::default()`]) and set the fields you need. Struct-literal
382/// construction is reserved to `xa11y-core`.
383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
384#[non_exhaustive]
385pub struct StateSet {
386    pub enabled: bool,
387    pub visible: bool,
388    pub focused: bool,
389    /// Whether this element is the active (foreground) window — the window that
390    /// currently receives the user's input. Only meaningful for window-like
391    /// elements (windows, dialogs); `false` elsewhere. Distinct from `focused`,
392    /// which is element-level keyboard focus. Platform mappings: the AT-SPI
393    /// `ACTIVE` state (Linux), `AXMain` (macOS), and the foreground `HWND`
394    /// (Windows).
395    #[serde(default)]
396    pub active: bool,
397    /// None = not checkable
398    pub checked: Option<Toggled>,
399    pub selected: bool,
400    /// None = not expandable
401    pub expanded: Option<bool>,
402    pub editable: bool,
403    /// Whether the element can receive keyboard focus
404    pub focusable: bool,
405    /// Whether the element is a modal dialog
406    pub modal: bool,
407    /// Form field required
408    pub required: bool,
409    /// Async operation in progress
410    pub busy: bool,
411}
412
413impl Default for StateSet {
414    fn default() -> Self {
415        Self {
416            enabled: true,
417            visible: true,
418            focused: false,
419            active: false,
420            checked: None,
421            selected: false,
422            expanded: None,
423            editable: false,
424            focusable: false,
425            modal: false,
426            required: false,
427            busy: false,
428        }
429    }
430}
431
432/// Tri-state toggle value.
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
434pub enum Toggled {
435    Off,
436    On,
437    /// Indeterminate / tri-state
438    Mixed,
439}
440
441/// Screen-pixel bounding rectangle (origin + size).
442/// `x`/`y` are signed to support negative multi-monitor coordinates.
443/// `width`/`height` are unsigned (always non-negative).
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
445pub struct Rect {
446    pub x: i32,
447    pub y: i32,
448    pub width: u32,
449    pub height: u32,
450}
451
452impl Rect {
453    /// Convert a **logical** rectangle to **physical** device pixels by
454    /// multiplying every component by `scale` (the physical-to-logical ratio,
455    /// e.g. `1.5` at 150% or `2.0` on a typical Retina display).
456    ///
457    /// This is the inverse of [`Rect::to_logical`]. Each field is rounded to
458    /// the nearest integer independently; for a single rectangle the position
459    /// and size therefore round separately, which can differ by 1px from
460    /// scaling the far edge — acceptable for capture/hit-test use where a 1px
461    /// slack is expected on fractional scales.
462    ///
463    /// A non-finite or non-positive `scale` is treated as `1.0` (identity):
464    /// callers on platforms without a known scale factor pass `1.0`, and a
465    /// bogus value must never produce garbage coordinates.
466    #[must_use]
467    pub fn to_physical(self, scale: f64) -> Rect {
468        let s = sane_scale(scale);
469        Rect {
470            x: scale_i32(self.x, s),
471            y: scale_i32(self.y, s),
472            width: scale_u32(self.width, s),
473            height: scale_u32(self.height, s),
474        }
475    }
476
477    /// Convert a **physical** rectangle (device pixels) to **logical**
478    /// coordinates by dividing every component by `scale`. Inverse of
479    /// [`Rect::to_physical`]. See that method for rounding and `scale`
480    /// validity semantics.
481    #[must_use]
482    pub fn to_logical(self, scale: f64) -> Rect {
483        self.to_physical(1.0 / sane_scale(scale))
484    }
485}
486
487/// Clamp a scale factor to a usable positive, finite value. Non-finite or
488/// non-positive inputs collapse to `1.0` so a bad platform reading degrades
489/// to identity rather than producing nonsense coordinates.
490pub(crate) fn sane_scale(scale: f64) -> f64 {
491    if scale.is_finite() && scale > 0.0 {
492        scale
493    } else {
494        1.0
495    }
496}
497
498fn scale_i32(v: i32, scale: f64) -> i32 {
499    (f64::from(v) * scale).round() as i32
500}
501
502fn scale_u32(v: u32, scale: f64) -> u32 {
503    let scaled = (f64::from(v) * scale).round();
504    if scaled < 0.0 {
505        0
506    } else {
507        scaled as u32
508    }
509}
510
511#[cfg(test)]
512mod rect_scale_tests {
513    use super::Rect;
514
515    const R: Rect = Rect {
516        x: 100,
517        y: 200,
518        width: 300,
519        height: 40,
520    };
521
522    #[test]
523    fn scale_one_is_identity() {
524        assert_eq!(R.to_physical(1.0), R);
525        assert_eq!(R.to_logical(1.0), R);
526    }
527
528    #[test]
529    fn to_physical_multiplies_all_fields() {
530        assert_eq!(
531            R.to_physical(2.0),
532            Rect {
533                x: 200,
534                y: 400,
535                width: 600,
536                height: 80
537            }
538        );
539    }
540
541    #[test]
542    fn to_logical_divides_all_fields() {
543        // Physical bounds on a 150% display -> logical points.
544        let physical = Rect {
545            x: 150,
546            y: 300,
547            width: 450,
548            height: 60,
549        };
550        assert_eq!(
551            physical.to_logical(1.5),
552            Rect {
553                x: 100,
554                y: 200,
555                width: 300,
556                height: 40
557            }
558        );
559    }
560
561    #[test]
562    fn round_trip_preserves_within_one_px() {
563        for &scale in &[1.25_f64, 1.5, 1.75, 2.0] {
564            let back = R.to_physical(scale).to_logical(scale);
565            assert!((back.x - R.x).abs() <= 1, "x drift at {scale}");
566            assert!((back.y - R.y).abs() <= 1, "y drift at {scale}");
567            assert!(
568                (back.width as i64 - R.width as i64).abs() <= 1,
569                "w drift at {scale}"
570            );
571            assert!(
572                (back.height as i64 - R.height as i64).abs() <= 1,
573                "h drift at {scale}"
574            );
575        }
576    }
577
578    #[test]
579    fn negative_origin_scales_correctly() {
580        // Multi-monitor: a window on a display left of the primary.
581        let r = Rect {
582            x: -1920,
583            y: -100,
584            width: 200,
585            height: 100,
586        };
587        assert_eq!(
588            r.to_physical(2.0),
589            Rect {
590                x: -3840,
591                y: -200,
592                width: 400,
593                height: 200
594            }
595        );
596    }
597
598    #[test]
599    fn fractional_scale_rounds_to_nearest() {
600        let r = Rect {
601            x: 3,
602            y: 3,
603            width: 5,
604            height: 5,
605        };
606        // 3 * 1.5 = 4.5 -> 5 (round half away from zero via f64::round);
607        // 5 * 1.5 = 7.5 -> 8.
608        assert_eq!(
609            r.to_physical(1.5),
610            Rect {
611                x: 5,
612                y: 5,
613                width: 8,
614                height: 8
615            }
616        );
617    }
618
619    #[test]
620    fn bad_scale_degrades_to_identity() {
621        assert_eq!(R.to_physical(0.0), R);
622        assert_eq!(R.to_physical(-2.0), R);
623        assert_eq!(R.to_physical(f64::NAN), R);
624        assert_eq!(R.to_physical(f64::INFINITY), R);
625        assert_eq!(R.to_logical(0.0), R);
626    }
627}
628
629/// Platform-specific raw data attached to every element.
630///
631/// An untyped key-value map containing the original platform-specific data
632/// exactly as the platform reported it. Keys use `snake_case` naming. This is
633/// the escape hatch for consumers who need full platform fidelity.
634pub type RawPlatformData = HashMap<String, serde_json::Value>;
635
636/// A node in a recursive snapshot of the accessibility subtree.
637///
638/// Returned by [`Element::tree`] and [`Locator::tree`]. Each node carries the
639/// role, display name, and value of one element, plus its children recursively.
640/// `children` is empty when `max_depth` was reached or the element is a leaf.
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct TreeNode {
643    pub role: String,
644    pub name: Option<String>,
645    pub value: Option<String>,
646    pub children: Vec<TreeNode>,
647}
648
649#[cfg(test)]
650mod tests {
651    //! Unit tests for `Element` action methods. Verifies each action records the
652    //! expected entry in the mock provider's action log and that validation
653    //! errors fire before the provider is ever called.
654
655    use super::*;
656    use crate::mock::{build_provider, MockProvider};
657    use crate::selector::Selector;
658
659    /// Resolve `selector` against the mock tree and return the first match
660    /// wrapped in an `Element`. Panics on no match — these are unit tests, not
661    /// production paths.
662    fn find_element(provider: &Arc<MockProvider>, selector: &str) -> Element {
663        let parsed = Selector::parse(selector).expect("selector must parse");
664        let provider_dyn: Arc<dyn Provider> = provider.clone();
665        let root = provider_dyn
666            .list_apps()
667            .expect("list_apps must succeed")
668            .into_iter()
669            .next()
670            .expect("mock provider must expose an application root");
671        let mut matches = provider_dyn
672            .find_elements(&root, &parsed, Some(1), None)
673            .expect("find_elements must succeed");
674        let data = matches.pop().expect("selector matched no elements");
675        Element::new(data, provider_dyn)
676    }
677
678    fn last_action(provider: &Arc<MockProvider>) -> (u64, String, Option<String>) {
679        provider
680            .actions()
681            .last()
682            .cloned()
683            .expect("expected at least one recorded action")
684    }
685
686    #[test]
687    fn nullary_actions_record_correct_name() {
688        let provider = build_provider();
689        let cases = [
690            (r#"button[name="Back"]"#, "press" as &str),
691            (r#"button[name="Back"]"#, "focus"),
692            (r#"button[name="Back"]"#, "blur"),
693            (r#"check_box[name="Agree"]"#, "toggle"),
694            (r#"list_item[name="Item 1"]"#, "select"),
695            (r#"list[name="Items"]"#, "expand"),
696            (r#"list[name="Items"]"#, "collapse"),
697            (r#"button[name="Back"]"#, "show_menu"),
698            (r#"slider[name="Volume"]"#, "increment"),
699            (r#"slider[name="Volume"]"#, "decrement"),
700            (r#"button[name="Back"]"#, "scroll_into_view"),
701        ];
702        for (selector, action) in cases {
703            provider.clear_actions();
704            let el = find_element(&provider, selector);
705            match action {
706                "press" => el.press().unwrap(),
707                "focus" => el.focus().unwrap(),
708                "blur" => el.blur().unwrap(),
709                "toggle" => el.toggle().unwrap(),
710                "select" => el.select().unwrap(),
711                "expand" => el.expand().unwrap(),
712                "collapse" => el.collapse().unwrap(),
713                "show_menu" => el.show_menu().unwrap(),
714                "increment" => el.increment().unwrap(),
715                "decrement" => el.decrement().unwrap(),
716                "scroll_into_view" => el.scroll_into_view().unwrap(),
717                _ => unreachable!(),
718            }
719            let (handle, name, data) = last_action(&provider);
720            assert_eq!(
721                name, action,
722                "wrong action recorded for selector {selector}"
723            );
724            assert_eq!(data, None, "nullary action should not carry data");
725            assert_eq!(handle, el.data.handle);
726        }
727    }
728
729    #[test]
730    fn set_value_records_text_payload() {
731        let provider = build_provider();
732        let el = find_element(&provider, r#"text_field[name="Search"]"#);
733        el.set_value("world").unwrap();
734        let (handle, name, data) = last_action(&provider);
735        assert_eq!(handle, el.data.handle);
736        assert_eq!(name, "set_value");
737        assert_eq!(data.as_deref(), Some("world"));
738    }
739
740    #[test]
741    fn set_numeric_value_records_payload() {
742        let provider = build_provider();
743        let el = find_element(&provider, r#"slider[name="Volume"]"#);
744        el.set_numeric_value(42.0).unwrap();
745        let (_, name, data) = last_action(&provider);
746        assert_eq!(name, "set_numeric_value");
747        assert_eq!(data.as_deref(), Some("42"));
748    }
749
750    #[test]
751    fn set_numeric_value_rejects_non_finite() {
752        let provider = build_provider();
753        let el = find_element(&provider, r#"slider[name="Volume"]"#);
754        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
755            assert!(matches!(
756                el.set_numeric_value(bad),
757                Err(Error::InvalidActionData { .. })
758            ));
759        }
760        // None of the validation failures should have reached the provider.
761        assert!(provider.actions().is_empty());
762    }
763
764    #[test]
765    fn type_text_records_payload() {
766        let provider = build_provider();
767        let el = find_element(&provider, r#"text_field[name="Search"]"#);
768        el.type_text("abc").unwrap();
769        let (_, name, data) = last_action(&provider);
770        assert_eq!(name, "type_text");
771        assert_eq!(data.as_deref(), Some("abc"));
772    }
773
774    #[test]
775    fn select_text_records_range() {
776        let provider = build_provider();
777        let el = find_element(&provider, r#"text_field[name="Search"]"#);
778        el.select_text(1, 4).unwrap();
779        let (_, name, data) = last_action(&provider);
780        assert_eq!(name, "set_text_selection");
781        assert_eq!(data.as_deref(), Some("1..4"));
782    }
783
784    #[test]
785    fn select_text_rejects_inverted_range() {
786        let provider = build_provider();
787        let el = find_element(&provider, r#"text_field[name="Search"]"#);
788        assert!(matches!(
789            el.select_text(5, 2),
790            Err(Error::InvalidActionData { .. })
791        ));
792        assert!(provider.actions().is_empty());
793    }
794
795    #[test]
796    fn perform_action_records_arbitrary_name() {
797        let provider = build_provider();
798        let el = find_element(&provider, r#"button[name="Back"]"#);
799        el.perform_action("raise").unwrap();
800        let (_, name, _) = last_action(&provider);
801        assert_eq!(name, "raise");
802    }
803
804    #[test]
805    fn locator_actions_desugar_to_element_actions() {
806        // Locator's auto-wait wraps the resolved data in an Element and calls
807        // its action — no duplication at the provider call site. This test
808        // pins that behavior: pressing via the Locator should record exactly
809        // the same entry as pressing via the Element it resolves to.
810        let provider = build_provider();
811        let provider_dyn: Arc<dyn Provider> = provider.clone();
812        let locator = crate::locator::Locator::new(provider_dyn, None, r#"button[name="Back"]"#);
813        locator.press().unwrap();
814        let (_, name, data) = last_action(&provider);
815        assert_eq!(name, "press");
816        assert_eq!(data, None);
817    }
818
819    #[test]
820    fn locator_validation_runs_before_auto_wait() {
821        // Locator validates payloads before entering its 5s auto-wait poll.
822        // We verify by passing invalid input against a never-matching selector:
823        // if validation fired first we get InvalidActionData immediately, not
824        // a Timeout 5 seconds later.
825        let provider = build_provider();
826        let provider_dyn: Arc<dyn Provider> = provider.clone();
827        let locator =
828            crate::locator::Locator::new(provider_dyn, None, r#"button[name="never-matches"]"#);
829        let started = std::time::Instant::now();
830        let err = locator.set_numeric_value(f64::NAN).unwrap_err();
831        assert!(matches!(err, Error::InvalidActionData { .. }));
832        assert!(
833            started.elapsed() < std::time::Duration::from_secs(1),
834            "validation must short-circuit auto-wait",
835        );
836    }
837}