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/// Declare a reader/writer struct pair from one field list.
13///
14/// `#[non_exhaustive]` gives *readers* a stability guarantee: adding a field
15/// does not break a consumer that only reads one. *Writers* need the opposite
16/// — a new field should stop their build until they have decided what it
17/// means. Two types satisfy both, but only if their field sets cannot drift,
18/// which is what this macro enforces: there is one list, so a field cannot be
19/// added to one type and defaulted in the other.
20///
21/// The reader gets the doc comments, serde attributes, and `#[non_exhaustive]`.
22/// The writer gets the bare fields and stays exhaustive, so a struct literal
23/// in another crate fails to compile the moment the list grows.
24macro_rules! reader_writer_pair {
25    (
26        $(#[$reader_meta:meta])*
27        pub struct $reader:ident;
28
29        $(#[$writer_meta:meta])*
30        pub struct $writer:ident;
31
32        fields {
33            $(
34                $(#[$field_meta:meta])*
35                pub $field:ident : $ty:ty,
36            )*
37        }
38    ) => {
39        $(#[$reader_meta])*
40        #[non_exhaustive]
41        pub struct $reader {
42            $(
43                $(#[$field_meta])*
44                pub $field: $ty,
45            )*
46        }
47
48        $(#[$writer_meta])*
49        #[doc(hidden)]
50        pub struct $writer {
51            $(pub $field: $ty,)*
52        }
53
54        impl From<$writer> for $reader {
55            fn from(parts: $writer) -> Self {
56                // Destructured, not field-by-field: both halves come from the
57                // macro's single field list, so neither can gain a field the
58                // other silently defaults.
59                let $writer { $($field,)* } = parts;
60                Self { $($field,)* }
61            }
62        }
63    };
64}
65
66// Importable by path from sibling modules (`Event` uses it too).
67pub(crate) use reader_writer_pair;
68
69reader_writer_pair! {
70    /// The raw data for a single element in an accessibility tree.
71    ///
72    /// This is the underlying data struct. Most consumers should use
73    /// [`Element`], which wraps `ElementData` with a provider reference for
74    /// lazy navigation. `ElementData` is used directly by provider
75    /// implementors.
76    ///
77    /// `#[non_exhaustive]`: this is the type that grows every time the
78    /// normalized element model learns a new property, so adding a field must
79    /// not break the consumers that only ever *read* one. Providers, which
80    /// *write* one, get the opposite guarantee from [`ElementParts`].
81    ///
82    /// Build a partial element (an event target, a test fixture) with
83    /// [`ElementData::for_role`] and assign what you have:
84    ///
85    /// ```
86    /// # use xa11y_core::{ElementData, Role};
87    /// let mut data = ElementData::for_role(Role::Button);
88    /// data.name = Some("Submit".to_string());
89    /// ```
90    #[derive(Debug, Clone, Serialize, Deserialize)]
91    pub struct ElementData;
92
93    /// Every field a provider must decide on when it builds a *complete*
94    /// element from a platform node.
95    ///
96    /// Deliberately exhaustive: a struct literal in `xa11y-linux`,
97    /// `xa11y-macos`, or `xa11y-windows` stops compiling the moment a field
98    /// is added, which is the only thing that forces a per-platform decision
99    /// instead of a silent `None` on every backend.
100    ///
101    /// Paths that are partial by nature — an event target with no bounds, a
102    /// test fixture — should use [`ElementData::for_role`] instead and accept
103    /// that a new field arrives there as its default.
104    ///
105    /// Not public API (`#[doc(hidden)]`). Because a new field here is a
106    /// compile error in the sibling provider crates, they pin `xa11y-core`
107    /// with `=` rather than a caret requirement — see the workspace
108    /// `Cargo.toml`.
109    #[allow(
110        clippy::exhaustive_structs,
111        reason = "This type IS the completeness guard. Literal construction \
112                  from the provider crates is exactly what makes a new \
113                  ElementData field fail their build until each platform maps \
114                  it; #[non_exhaustive] here would delete the property it \
115                  exists for."
116    )]
117    #[derive(Debug, Clone)]
118    pub struct ElementParts;
119
120    fields {
121        /// Element role
122        pub role: Role,
123
124        /// Human-readable name (title, label).
125        ///
126        /// Stripped of Unicode bidi format controls (LRM, RLM, embeddings,
127        /// overrides, isolates) so equality assertions match the logical text.
128        /// The unstripped platform string is preserved in [`Self::raw`] under the
129        /// platform-native key (e.g. `AXTitle` on macOS, `atspi_name` on Linux,
130        /// `uia_name` on Windows). See [`crate::text::strip_bidi`].
131        pub name: Option<String>,
132
133        /// Current value (text content, slider position, etc.).
134        ///
135        /// Stripped of Unicode bidi format controls. The unstripped platform
136        /// string is preserved in [`Self::raw`] (`AXValue` on macOS, `atspi_value`
137        /// on Linux, `uia_value` on Windows). See [`crate::text::strip_bidi`].
138        pub value: Option<String>,
139
140        /// Supplementary description (tooltip, help text).
141        ///
142        /// Stripped of Unicode bidi format controls. The unstripped platform
143        /// string is preserved in [`Self::raw`] (`AXDescription`/`AXHelp` on
144        /// macOS, `atspi_description` on Linux, `uia_help_text` on Windows).
145        /// See [`crate::text::strip_bidi`].
146        pub description: Option<String>,
147
148        /// Bounding rectangle in desktop coordinates: physical pixels on
149        /// Windows and logical points on macOS and Linux. The origin is the
150        /// top-left of the primary display. This is the space accepted by
151        /// [`crate::ScreenshotProvider::capture_region`] and by the input layer's
152        /// [`crate::input::Point`], so bounds can be fed directly to
153        /// `screenshot_element` / `click` without conversion.
154        ///
155        /// To map into an actual captured image, use
156        /// [`crate::Screenshot::desktop_rect_to_image`]. One scalar scale is
157        /// insufficient when bounds cross displays or the image was resized.
158        pub bounds: Option<Rect>,
159
160        /// Available actions reported by the platform.
161        ///
162        /// Names are `snake_case` strings — well-known actions use their standard
163        /// names (`"press"`, `"toggle"`, `"expand"`, etc.) and platform-specific
164        /// actions use their converted names (e.g. macOS `AXCustomThing` →
165        /// `"custom_thing"`).
166        pub actions: Vec<String>,
167
168        /// Current state flags
169        pub states: StateSet,
170
171        /// Numeric value for range controls (sliders, progress bars, spinners).
172        pub numeric_value: Option<f64>,
173
174        /// Minimum value for range controls.
175        pub min_value: Option<f64>,
176
177        /// Maximum value for range controls.
178        pub max_value: Option<f64>,
179
180        /// Platform-assigned stable identifier for cross-snapshot correlation.
181        /// - macOS: `AXIdentifier`
182        /// - Windows: native window handle (`hwnd:0x…`) for top-level windows,
183        ///   `AutomationId` for the elements that have one — UIA excludes
184        ///   top-level windows from the AutomationId contract, and the handle
185        ///   is session-scoped (like the Linux object path; HWNDs are reused
186        ///   after a window closes)
187        /// - Linux: D-Bus `object_path`
188        ///
189        /// Not all elements have one.
190        pub stable_id: Option<String>,
191
192        /// Process ID of the application that owns this element.
193        pub pid: Option<u32>,
194
195        /// Platform-specific raw data
196        pub raw: RawPlatformData,
197
198        /// Opaque handle for the provider to look up the platform object.
199        /// Not serialized — only valid within the provider that created it.
200        #[serde(skip, default)]
201        pub handle: u64,
202    }
203}
204
205impl ElementData {
206    /// An element with the given role and every other field empty.
207    ///
208    /// `states` starts at [`StateSet::default`] (enabled and visible, nothing
209    /// else), and `handle` at `0` — providers assign their own.
210    ///
211    /// This is the *partial* construction path. A provider translating a real
212    /// platform node should use [`ElementParts`] instead, so that a new field
213    /// fails its build rather than arriving as a default.
214    ///
215    /// Named `for_role` rather than `new` because `ElementData` is flattened
216    /// onto `Element` for the bindings-parity check, where a member called
217    /// `new` would collide with the existing [`Element::new`].
218    pub fn for_role(role: Role) -> Self {
219        // Struct literal, not a builder: this lives in the defining crate, so
220        // the compiler still checks it for completeness when a field is added.
221        Self {
222            role,
223            name: None,
224            value: None,
225            description: None,
226            bounds: None,
227            actions: Vec::new(),
228            states: StateSet::default(),
229            numeric_value: None,
230            min_value: None,
231            max_value: None,
232            stable_id: None,
233            pid: None,
234            raw: RawPlatformData::new(),
235            handle: 0,
236        }
237    }
238}
239
240impl Default for ElementData {
241    /// A [`Role::Unknown`] element with no properties.
242    fn default() -> Self {
243        Self::for_role(Role::Unknown)
244    }
245}
246
247/// A live element with lazy navigation via a provider reference.
248///
249/// `Element` dereferences to [`ElementData`], so all properties (`role`, `name`,
250/// `value`, `states`, etc.) are accessible via field access. Navigation
251/// methods (`parent()`, `children()`) call the provider on demand.
252///
253/// Elements are cheap to clone (they share the provider via `Arc`).
254#[derive(Clone)]
255pub struct Element {
256    data: ElementData,
257    provider: Arc<dyn Provider>,
258}
259
260impl Deref for Element {
261    type Target = ElementData;
262
263    fn deref(&self) -> &ElementData {
264        &self.data
265    }
266}
267
268impl fmt::Debug for Element {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        fmt::Debug::fmt(&self.data, f)
271    }
272}
273
274impl fmt::Display for Element {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        let name_part = self
277            .data
278            .name
279            .as_ref()
280            .map(|n| format!(" \"{}\"", n))
281            .unwrap_or_default();
282        let value_part = self
283            .data
284            .value
285            .as_ref()
286            .map(|v| format!(" value=\"{}\"", v))
287            .unwrap_or_default();
288        write!(
289            f,
290            "{}{}{}",
291            self.data.role.to_snake_case(),
292            name_part,
293            value_part,
294        )
295    }
296}
297
298impl Serialize for Element {
299    fn serialize<S: serde::Serializer>(
300        &self,
301        serializer: S,
302    ) -> std::result::Result<S::Ok, S::Error> {
303        self.data.serialize(serializer)
304    }
305}
306
307impl Element {
308    /// Create an Element from raw data and a provider reference.
309    pub fn new(data: ElementData, provider: Arc<dyn Provider>) -> Self {
310        Self { data, provider }
311    }
312
313    /// Get the underlying ElementData.
314    pub fn data(&self) -> &ElementData {
315        &self.data
316    }
317
318    /// Get the provider reference.
319    pub fn provider(&self) -> &Arc<dyn Provider> {
320        &self.provider
321    }
322
323    /// Get direct children of this element.
324    ///
325    /// Each call queries the provider — results are not cached.
326    pub fn children(&self) -> crate::error::Result<Vec<Element>> {
327        let children = self.provider.get_children(Some(&self.data))?;
328        Ok(children
329            .into_iter()
330            .map(|d| Element::new(d, Arc::clone(&self.provider)))
331            .collect())
332    }
333
334    /// Get the parent element, if any (root-level elements have no parent).
335    ///
336    /// Each call queries the provider — results are not cached.
337    pub fn parent(&self) -> crate::error::Result<Option<Element>> {
338        let parent = self.provider.get_parent(&self.data)?;
339        Ok(parent.map(|d| Element::new(d, Arc::clone(&self.provider))))
340    }
341
342    /// Get the process ID from the element data.
343    pub fn pid(&self) -> Option<u32> {
344        self.data.pid
345    }
346
347    /// Capture the subtree rooted at this element as a recursive snapshot.
348    ///
349    /// `max_depth` limits traversal depth: `0` = only this node (no children),
350    /// `1` = node + direct children, and so on. `None` traverses the full subtree.
351    pub fn tree(&self, max_depth: Option<usize>) -> crate::error::Result<TreeNode> {
352        build_tree_node(self, max_depth, 0)
353    }
354
355    /// Render the subtree rooted at this element as an indented string.
356    ///
357    /// Each line is `{indent}{role} "{name}" [value="{value}"]`. Returns the
358    /// string without printing it. Same depth semantics as [`Element::tree`].
359    pub fn dump(&self, max_depth: Option<usize>) -> crate::error::Result<String> {
360        let node = self.tree(max_depth)?;
361        let mut out = String::new();
362        write_tree_node(&node, 0, &mut out);
363        Ok(out)
364    }
365
366    // ── Actions ─────────────────────────────────────────────────────
367    //
368    // Element actions invoke the platform via the captured provider handle —
369    // they do **not** re-resolve the selector. If the underlying element has
370    // been destroyed since this snapshot was taken, the provider returns a
371    // platform-specific "gone" error. For resilient retry-on-change semantics,
372    // use the equivalent method on [`crate::Locator`] instead.
373
374    /// Click / invoke this element via the accessibility action layer.
375    pub fn press(&self) -> crate::error::Result<()> {
376        self.provider.press(&self.data)
377    }
378
379    /// Set keyboard focus to this element.
380    pub fn focus(&self) -> crate::error::Result<()> {
381        self.provider.focus(&self.data)
382    }
383
384    /// Remove keyboard focus from this element.
385    pub fn blur(&self) -> crate::error::Result<()> {
386        self.provider.blur(&self.data)
387    }
388
389    /// Toggle a two- or three-state control (checkbox, switch).
390    pub fn toggle(&self) -> crate::error::Result<()> {
391        self.provider.toggle(&self.data)
392    }
393
394    /// Select this element (list item, tab, row).
395    pub fn select(&self) -> crate::error::Result<()> {
396        self.provider.select(&self.data)
397    }
398
399    /// Expand a disclosure, menu, combo box, or tree item.
400    pub fn expand(&self) -> crate::error::Result<()> {
401        self.provider.expand(&self.data)
402    }
403
404    /// Collapse an expanded element.
405    pub fn collapse(&self) -> crate::error::Result<()> {
406        self.provider.collapse(&self.data)
407    }
408
409    /// Open this element's context menu or dropdown.
410    pub fn show_menu(&self) -> crate::error::Result<()> {
411        self.provider.show_menu(&self.data)
412    }
413
414    /// Increment a numeric control (slider, spinner) by its platform step.
415    pub fn increment(&self) -> crate::error::Result<()> {
416        self.provider.increment(&self.data)
417    }
418
419    /// Decrement a numeric control (slider, spinner) by its platform step.
420    pub fn decrement(&self) -> crate::error::Result<()> {
421        self.provider.decrement(&self.data)
422    }
423
424    /// Scroll this element into the visible area.
425    ///
426    /// No-op on macOS — the macOS accessibility API has no equivalent.
427    pub fn scroll_into_view(&self) -> crate::error::Result<()> {
428        self.provider.scroll_into_view(&self.data)
429    }
430
431    /// Set the text value of this element. Replaces the entire value rather
432    /// than inserting at the caret — use [`Element::type_text`] for insertion.
433    pub fn set_value(&self, value: &str) -> crate::error::Result<()> {
434        self.provider.set_value(&self.data, value)
435    }
436
437    /// Set the numeric value of this element (slider, spinner).
438    ///
439    /// Returns [`Error::InvalidActionData`] if `value` is NaN or infinite.
440    pub fn set_numeric_value(&self, value: f64) -> crate::error::Result<()> {
441        if !value.is_finite() {
442            return Err(Error::InvalidActionData {
443                message: format!("set_numeric_value requires a finite value, got {}", value),
444            });
445        }
446        self.provider.set_numeric_value(&self.data, value)
447    }
448
449    /// Insert text at the current cursor position.
450    ///
451    /// Uses the platform accessibility API — never simulates keyboard events.
452    pub fn type_text(&self, text: &str) -> crate::error::Result<()> {
453        self.provider.type_text(&self.data, text)
454    }
455
456    /// Select the text range from `start` to `end` (0-based character offsets).
457    ///
458    /// Returns [`Error::InvalidActionData`] if `start > end`.
459    pub fn select_text(&self, start: u32, end: u32) -> crate::error::Result<()> {
460        if start > end {
461            return Err(Error::InvalidActionData {
462                message: format!("select_text start ({}) must be <= end ({})", start, end),
463            });
464        }
465        self.provider.set_text_selection(&self.data, start, end)
466    }
467
468    /// Perform an action by its `snake_case` name.
469    ///
470    /// Use this for actions the element advertises in its [`actions`](ElementData::actions)
471    /// list that don't have a dedicated method. Well-known names (`"press"`,
472    /// `"focus"`, etc.) also work — providers delegate to the named methods.
473    /// The nullary window verbs are routed through the typed methods so the
474    /// shared role guard applies on this path too: `perform_action("activate")`
475    /// on a non-window element must fail exactly like `activate()` does, however
476    /// the generic escape hatch is reached.
477    pub fn perform_action(&self, action: &str) -> crate::error::Result<()> {
478        match action {
479            "activate" => self.activate(),
480            "minimize" => self.minimize(),
481            "maximize" => self.maximize(),
482            "enter_fullscreen" => self.enter_fullscreen(),
483            "restore" => self.restore(),
484            "close" => self.close(),
485            // "move_to"/"resize_to" need payloads the generic path cannot
486            // carry; the providers reject them surfaceably (InvalidActionData),
487            // before any OS call.
488            _ => self.provider.perform_action(&self.data, action),
489        }
490    }
491
492    // ── Window management ──────────────────────────────────────────
493    //
494    // These verbs operate on top-level window targets. The shared layer keeps
495    // the obvious non-window roles out before delegation: inside a provider
496    // the same call on a button has platform-dependent semantics (Linux
497    // `activate` would GrabFocus it and report success; the mock would accept any
498    // live node). Providers then enforce the stronger identity check for the
499    // window-like roles whose meaning is broader than a real OS window (for
500    // example `Role::Dialog` can be an in-page ARIA dialog).
501    //
502    // Multiple windows can be managed from any window element, not just the
503    // app root. The platform semantics are:
504    // - `minimize`/`maximize`/`enter_fullscreen`/`restore`/`close`: window
505    //   state operations (`maximize` and `enter_fullscreen` are distinct: the
506    //   first targets the platform's maximized state, the second native
507    //   fullscreen).
508    // - `activate`: bring the window to the foreground (activation).
509    // - `move_to`/`resize_to`: geometry operations in logical coordinates.
510
511    /// Reject a window verb whose target is not a plausible top-level window.
512    ///
513    /// The shared check is intentionally stricter than role alone: web/ARIA
514    /// descendants can map to `Role::Dialog` without being an OS window. A
515    /// candidate must therefore carry at least one window-only signal
516    /// (advertised window action or readable window state). This is a
517    /// first-pass filter, not the authoritative boundary: providers make the
518    /// final platform-specific top-level check.
519    fn require_window_like(&self, action: &str) -> crate::error::Result<()> {
520        let has_window_signal = self.data.actions.iter().any(|a| {
521            matches!(
522                a.as_str(),
523                "activate"
524                    | "minimize"
525                    | "maximize"
526                    | "enter_fullscreen"
527                    | "restore"
528                    | "close"
529                    | "move_to"
530                    | "resize_to"
531            )
532        }) || self.data.states.minimized.is_some()
533            || self.data.states.maximized.is_some()
534            || self.data.states.fullscreen.is_some();
535        if matches!(self.data.role, Role::Window)
536            || (self.data.role == Role::Dialog && has_window_signal)
537        {
538            Ok(())
539        } else {
540            Err(Error::ActionNotSupported {
541                action: action.to_string(),
542                role: self.data.role,
543            })
544        }
545    }
546
547    /// Activate this window: bring it to the foreground and give it focus.
548    pub fn activate(&self) -> crate::error::Result<()> {
549        self.require_window_like("activate")?;
550        self.provider.activate(&self.data)
551    }
552
553    /// Minimize this window.
554    pub fn minimize(&self) -> crate::error::Result<()> {
555        self.require_window_like("minimize")?;
556        self.provider.minimize(&self.data)
557    }
558
559    /// Maximize this window.
560    ///
561    /// Distinct from [`Self::enter_fullscreen`], which targets native
562    /// fullscreen: `maximize` drives the platform's maximized state, and a
563    /// platform without an accessible maximize (macOS exposes no readable or
564    /// writable zoom state) reports it as unsupported instead of substituting
565    /// fullscreen.
566    pub fn maximize(&self) -> crate::error::Result<()> {
567        self.require_window_like("maximize")?;
568        self.provider.maximize(&self.data)
569    }
570
571    /// Put this window in native fullscreen.
572    ///
573    /// Distinct from [`Self::maximize`]: fullscreen is the platform's native
574    /// fullscreen state (macOS `AXFullScreen`), and [`Self::restore`] leaves
575    /// it. Callers should consult the element's `actions` list before calling:
576    /// the platforms that cannot enter fullscreen through an accessibility API
577    /// (Windows/Linux) report it as unsupported.
578    pub fn enter_fullscreen(&self) -> crate::error::Result<()> {
579        self.require_window_like("enter_fullscreen")?;
580        self.provider.enter_fullscreen(&self.data)
581    }
582
583    /// Restore this window to its normal state (from minimized/maximized/
584    /// fullscreen).
585    pub fn restore(&self) -> crate::error::Result<()> {
586        self.require_window_like("restore")?;
587        self.provider.restore(&self.data)
588    }
589
590    /// Close this window.
591    pub fn close(&self) -> crate::error::Result<()> {
592        self.require_window_like("close")?;
593        self.provider.close(&self.data)
594    }
595
596    /// Move this window in the coordinates of [`ElementData::bounds`]
597    /// (physical pixels on Windows, logical points elsewhere).
598    pub fn move_to(&self, x: i32, y: i32) -> crate::error::Result<()> {
599        self.require_window_like("move_to")?;
600        self.provider.move_to(&self.data, x, y)
601    }
602
603    /// Resize this window in the units of [`ElementData::bounds`].
604    ///
605    /// Returns [`Error::InvalidActionData`] if either dimension is 0.
606    pub fn resize_to(&self, width: u32, height: u32) -> crate::error::Result<()> {
607        if width == 0 || height == 0 {
608            return Err(Error::InvalidActionData {
609                message: format!(
610                    "resize_to requires positive width and height, got {width}x{height}"
611                ),
612            });
613        }
614        self.require_window_like("resize_to")?;
615        self.provider.resize_to(&self.data, width, height)
616    }
617}
618
619fn build_tree_node(
620    element: &Element,
621    max_depth: Option<usize>,
622    depth: usize,
623) -> crate::error::Result<TreeNode> {
624    let children = if max_depth.is_none_or(|d| depth < d) {
625        element
626            .children()?
627            .into_iter()
628            .map(|child| build_tree_node(&child, max_depth, depth + 1))
629            .collect::<crate::error::Result<Vec<_>>>()?
630    } else {
631        vec![]
632    };
633    Ok(TreeNode {
634        role: element.data.role.to_snake_case().to_string(),
635        name: element.data.name.clone(),
636        value: element.data.value.clone(),
637        children,
638    })
639}
640
641pub(crate) fn write_tree_node(node: &TreeNode, depth: usize, out: &mut String) {
642    use fmt::Write as _;
643    let indent = "  ".repeat(depth);
644    write!(out, "{}{}", indent, node.role).unwrap();
645    if let Some(ref n) = node.name {
646        write!(out, " \"{}\"", n).unwrap();
647    }
648    if let Some(ref v) = node.value {
649        write!(out, " value=\"{}\"", v).unwrap();
650    }
651    out.push('\n');
652    for child in &node.children {
653        write_tree_node(child, depth + 1, out);
654    }
655}
656
657reader_writer_pair! {
658    /// Boolean state flags for an element.
659    ///
660    /// **Semantics for non-applicable states:** When a state doesn't apply to
661    /// an element's role, the backend uses the platform's reported value or
662    /// defaults:
663    /// - `enabled`: `true` (elements are enabled unless explicitly disabled)
664    /// - `visible`: `true` (elements are visible unless explicitly hidden/offscreen)
665    /// - `focused`, `active`, `focusable`, `modal`, `selected`, `editable`, `required`, `busy`: `false`
666    ///
667    /// States that are inherently inapplicable use `Option`: `checked` is
668    /// `None` for non-checkable elements, `expanded` is `None` for
669    /// non-expandable elements. The window states (`minimized`, `maximized`,
670    /// `fullscreen`) are `Option` for a second reason: `None` also means
671    /// *unknown* — a platform that cannot read the state reports `None`
672    /// rather than a guessed `false` (Linux cannot report maximized or
673    /// fullscreen at all, and Windows cannot report fullscreen).
674    ///
675    /// `#[non_exhaustive]`: more states arrive in compatible releases and must
676    /// not break readers. Providers building a complete state set use
677    /// [`StateParts`], which is exhaustive — the documented defaults above are
678    /// what a *partial* construction falls back to, not a licence for a
679    /// backend to skip deciding.
680    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
681    pub struct StateSet;
682
683    /// Every state a provider must decide on when it translates a platform
684    /// node's state bits.
685    ///
686    /// Deliberately exhaustive, for the same reason as [`ElementParts`]: a new
687    /// state must fail each backend's build rather than silently inherit
688    /// [`StateSet::default`]. That matters more here than the defaults
689    /// suggest — the parity check requires every state to surface as a binding
690    /// getter, so a silently-defaulted state ships as a documented API that no
691    /// platform populates.
692    ///
693    /// Not public API (`#[doc(hidden)]`).
694    #[allow(
695        clippy::exhaustive_structs,
696        reason = "This type IS the completeness guard for element state. See \
697                  ElementParts; the same reasoning applies."
698    )]
699    #[derive(Debug, Clone)]
700    pub struct StateParts;
701
702    fields {
703        pub enabled: bool,
704        pub visible: bool,
705        pub focused: bool,
706        /// Whether this element is the active (foreground) window — the window that
707        /// currently receives the user's input. Only meaningful for window-like
708        /// elements (windows, dialogs); `false` elsewhere. Distinct from `focused`,
709        /// which is element-level keyboard focus. Platform mappings: the AT-SPI
710        /// `ACTIVE` state (Linux), `AXMain` (macOS), and the foreground `HWND`
711        /// (Windows).
712        #[serde(default)]
713        pub active: bool,
714        /// Whether the window is minimized (iconified).
715        ///
716        /// `None` = unknown or not a window. Unlike `enabled`/`visible`, the
717        /// three window states are `Option<bool>` because `None` also means
718        /// "this platform cannot report the state": Linux cannot read
719        /// maximized or fullscreen at all, and Windows cannot read fullscreen
720        /// (it reads `minimized` and `maximized` from
721        /// `WindowPattern.CurrentWindowVisualState`), so a hard-coded `false`
722        /// would be a silent guess.
723        pub minimized: Option<bool>,
724        /// Whether the window is maximized. `None` = unknown / not a window.
725        pub maximized: Option<bool>,
726        /// Whether the window is in fullscreen. `None` = unknown / not a window.
727        pub fullscreen: Option<bool>,
728        /// None = not checkable
729        pub checked: Option<Toggled>,
730        pub selected: bool,
731        /// None = not expandable
732        pub expanded: Option<bool>,
733        pub editable: bool,
734        /// Whether the element can receive keyboard focus
735        pub focusable: bool,
736        /// Whether the element is a modal dialog
737        pub modal: bool,
738        /// Form field required
739        pub required: bool,
740        /// Async operation in progress
741        pub busy: bool,
742    }
743}
744
745impl Default for StateSet {
746    fn default() -> Self {
747        Self {
748            enabled: true,
749            visible: true,
750            focused: false,
751            active: false,
752            minimized: None,
753            maximized: None,
754            fullscreen: None,
755            checked: None,
756            selected: false,
757            expanded: None,
758            editable: false,
759            focusable: false,
760            modal: false,
761            required: false,
762            busy: false,
763        }
764    }
765}
766
767/// Tri-state toggle value.
768#[allow(
769    clippy::exhaustive_enums,
770    reason = "Closed domain: a toggle is off, on, or indeterminate. Every \
771              platform's tri-state checkbox is exactly these three values, \
772              and a fourth would not be a toggle."
773)]
774#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
775pub enum Toggled {
776    Off,
777    On,
778    /// Indeterminate / tri-state
779    Mixed,
780}
781
782/// Screen-pixel bounding rectangle (origin + size).
783/// `x`/`y` are signed to support negative multi-monitor coordinates.
784/// `width`/`height` are unsigned (always non-negative).
785#[allow(
786    clippy::exhaustive_structs,
787    reason = "Closed domain: an axis-aligned rectangle is fully described by \
788              an origin and a size. Literal construction is the point of the \
789              type, and it will not gain a fifth field."
790)]
791#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
792pub struct Rect {
793    pub x: i32,
794    pub y: i32,
795    pub width: u32,
796    pub height: u32,
797}
798
799impl Rect {
800    /// Convert a **logical** rectangle to **physical** device pixels by
801    /// multiplying every component by `scale` (the physical-to-logical ratio,
802    /// e.g. `1.5` at 150% or `2.0` on a typical Retina display).
803    ///
804    /// This is the inverse of [`Rect::to_logical`]. Each field is rounded to
805    /// the nearest integer independently; for a single rectangle the position
806    /// and size therefore round separately, which can differ by 1px from
807    /// scaling the far edge — acceptable for capture/hit-test use where a 1px
808    /// slack is expected on fractional scales.
809    ///
810    /// A non-finite or non-positive `scale` is treated as `1.0` (identity):
811    /// callers on platforms without a known scale factor pass `1.0`, and a
812    /// bogus value must never produce garbage coordinates.
813    #[must_use]
814    pub fn to_physical(self, scale: f64) -> Rect {
815        let s = sane_scale(scale);
816        Rect {
817            x: scale_i32(self.x, s),
818            y: scale_i32(self.y, s),
819            width: scale_u32(self.width, s),
820            height: scale_u32(self.height, s),
821        }
822    }
823
824    /// Convert a **physical** rectangle (device pixels) to **logical**
825    /// coordinates by dividing every component by `scale`. Inverse of
826    /// [`Rect::to_physical`]. See that method for rounding and `scale`
827    /// validity semantics.
828    #[must_use]
829    pub fn to_logical(self, scale: f64) -> Rect {
830        self.to_physical(1.0 / sane_scale(scale))
831    }
832}
833
834/// Clamp a scale factor to a usable positive, finite value. Non-finite or
835/// non-positive inputs collapse to `1.0` so a bad platform reading degrades
836/// to identity rather than producing nonsense coordinates.
837pub(crate) fn sane_scale(scale: f64) -> f64 {
838    if scale.is_finite() && scale > 0.0 {
839        scale
840    } else {
841        1.0
842    }
843}
844
845fn scale_i32(v: i32, scale: f64) -> i32 {
846    (f64::from(v) * scale).round() as i32
847}
848
849fn scale_u32(v: u32, scale: f64) -> u32 {
850    let scaled = (f64::from(v) * scale).round();
851    if scaled < 0.0 {
852        0
853    } else {
854        scaled as u32
855    }
856}
857
858#[cfg(test)]
859mod rect_scale_tests {
860    use super::Rect;
861
862    const R: Rect = Rect {
863        x: 100,
864        y: 200,
865        width: 300,
866        height: 40,
867    };
868
869    #[test]
870    fn scale_one_is_identity() {
871        assert_eq!(R.to_physical(1.0), R);
872        assert_eq!(R.to_logical(1.0), R);
873    }
874
875    #[test]
876    fn to_physical_multiplies_all_fields() {
877        assert_eq!(
878            R.to_physical(2.0),
879            Rect {
880                x: 200,
881                y: 400,
882                width: 600,
883                height: 80
884            }
885        );
886    }
887
888    #[test]
889    fn to_logical_divides_all_fields() {
890        // Physical bounds on a 150% display -> logical points.
891        let physical = Rect {
892            x: 150,
893            y: 300,
894            width: 450,
895            height: 60,
896        };
897        assert_eq!(
898            physical.to_logical(1.5),
899            Rect {
900                x: 100,
901                y: 200,
902                width: 300,
903                height: 40
904            }
905        );
906    }
907
908    #[test]
909    fn round_trip_preserves_within_one_px() {
910        for &scale in &[1.25_f64, 1.5, 1.75, 2.0] {
911            let back = R.to_physical(scale).to_logical(scale);
912            assert!((back.x - R.x).abs() <= 1, "x drift at {scale}");
913            assert!((back.y - R.y).abs() <= 1, "y drift at {scale}");
914            assert!(
915                (back.width as i64 - R.width as i64).abs() <= 1,
916                "w drift at {scale}"
917            );
918            assert!(
919                (back.height as i64 - R.height as i64).abs() <= 1,
920                "h drift at {scale}"
921            );
922        }
923    }
924
925    #[test]
926    fn negative_origin_scales_correctly() {
927        // Multi-monitor: a window on a display left of the primary.
928        let r = Rect {
929            x: -1920,
930            y: -100,
931            width: 200,
932            height: 100,
933        };
934        assert_eq!(
935            r.to_physical(2.0),
936            Rect {
937                x: -3840,
938                y: -200,
939                width: 400,
940                height: 200
941            }
942        );
943    }
944
945    #[test]
946    fn fractional_scale_rounds_to_nearest() {
947        let r = Rect {
948            x: 3,
949            y: 3,
950            width: 5,
951            height: 5,
952        };
953        // 3 * 1.5 = 4.5 -> 5 (round half away from zero via f64::round);
954        // 5 * 1.5 = 7.5 -> 8.
955        assert_eq!(
956            r.to_physical(1.5),
957            Rect {
958                x: 5,
959                y: 5,
960                width: 8,
961                height: 8
962            }
963        );
964    }
965
966    #[test]
967    fn bad_scale_degrades_to_identity() {
968        assert_eq!(R.to_physical(0.0), R);
969        assert_eq!(R.to_physical(-2.0), R);
970        assert_eq!(R.to_physical(f64::NAN), R);
971        assert_eq!(R.to_physical(f64::INFINITY), R);
972        assert_eq!(R.to_logical(0.0), R);
973    }
974}
975
976/// Platform-specific raw data attached to every element.
977///
978/// An untyped key-value map containing the original platform-specific data
979/// exactly as the platform reported it. Keys use `snake_case` naming. This is
980/// the escape hatch for consumers who need full platform fidelity.
981pub type RawPlatformData = HashMap<String, serde_json::Value>;
982
983/// A node in a recursive snapshot of the accessibility subtree.
984///
985/// Returned by [`Element::tree`] and [`Locator::tree`]. Each node carries the
986/// role, display name, and value of one element, plus its children recursively.
987/// `children` is empty when `max_depth` was reached or the element is a leaf.
988///
989/// `#[non_exhaustive]`: a dump node grows alongside [`ElementData`] — bounds
990/// and stable ids are both plausible additions. Build one with
991/// [`TreeNode::new`].
992#[derive(Debug, Clone, Serialize, Deserialize)]
993#[non_exhaustive]
994pub struct TreeNode {
995    pub role: String,
996    pub name: Option<String>,
997    pub value: Option<String>,
998    pub children: Vec<TreeNode>,
999}
1000
1001impl TreeNode {
1002    /// A leaf node with the given role and no name, value, or children.
1003    pub fn new(role: impl Into<String>) -> Self {
1004        Self {
1005            role: role.into(),
1006            name: None,
1007            value: None,
1008            children: Vec::new(),
1009        }
1010    }
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    //! Unit tests for `Element` action methods. Verifies each action records the
1016    //! expected entry in the mock provider's action log and that validation
1017    //! errors fire before the provider is ever called.
1018
1019    use super::*;
1020    use crate::mock::{build_provider, MockProvider};
1021    use crate::selector::Selector;
1022
1023    /// Resolve `selector` against the mock tree and return the first match
1024    /// wrapped in an `Element`. Panics on no match — these are unit tests, not
1025    /// production paths.
1026    fn find_element(provider: &Arc<MockProvider>, selector: &str) -> Element {
1027        let parsed = Selector::parse(selector).expect("selector must parse");
1028        let provider_dyn: Arc<dyn Provider> = provider.clone();
1029        let root = provider_dyn
1030            .list_apps()
1031            .expect("list_apps must succeed")
1032            .into_iter()
1033            .next()
1034            .expect("mock provider must expose an application root");
1035        let mut matches = provider_dyn
1036            .find_elements(&root, &parsed, Some(1), None)
1037            .expect("find_elements must succeed");
1038        let data = matches.pop().expect("selector matched no elements");
1039        Element::new(data, provider_dyn)
1040    }
1041
1042    fn last_action(provider: &Arc<MockProvider>) -> (u64, String, Option<String>) {
1043        provider
1044            .actions()
1045            .last()
1046            .cloned()
1047            .expect("expected at least one recorded action")
1048    }
1049
1050    #[test]
1051    fn nullary_actions_record_correct_name() {
1052        let provider = build_provider();
1053        let cases = [
1054            (r#"button[name="Back"]"#, "press" as &str),
1055            (r#"button[name="Back"]"#, "focus"),
1056            (r#"button[name="Back"]"#, "blur"),
1057            (r#"check_box[name="Agree"]"#, "toggle"),
1058            (r#"list_item[name="Item 1"]"#, "select"),
1059            (r#"list[name="Items"]"#, "expand"),
1060            (r#"list[name="Items"]"#, "collapse"),
1061            (r#"button[name="Back"]"#, "show_menu"),
1062            (r#"slider[name="Volume"]"#, "increment"),
1063            (r#"slider[name="Volume"]"#, "decrement"),
1064            (r#"button[name="Back"]"#, "scroll_into_view"),
1065        ];
1066        for (selector, action) in cases {
1067            provider.clear_actions();
1068            let el = find_element(&provider, selector);
1069            match action {
1070                "press" => el.press().unwrap(),
1071                "focus" => el.focus().unwrap(),
1072                "blur" => el.blur().unwrap(),
1073                "toggle" => el.toggle().unwrap(),
1074                "select" => el.select().unwrap(),
1075                "expand" => el.expand().unwrap(),
1076                "collapse" => el.collapse().unwrap(),
1077                "show_menu" => el.show_menu().unwrap(),
1078                "increment" => el.increment().unwrap(),
1079                "decrement" => el.decrement().unwrap(),
1080                "scroll_into_view" => el.scroll_into_view().unwrap(),
1081                _ => unreachable!(),
1082            }
1083            let (handle, name, data) = last_action(&provider);
1084            assert_eq!(
1085                name, action,
1086                "wrong action recorded for selector {selector}"
1087            );
1088            assert_eq!(data, None, "nullary action should not carry data");
1089            assert_eq!(handle, el.data.handle);
1090        }
1091    }
1092
1093    #[test]
1094    fn set_value_records_text_payload() {
1095        let provider = build_provider();
1096        let el = find_element(&provider, r#"text_field[name="Search"]"#);
1097        el.set_value("world").unwrap();
1098        let (handle, name, data) = last_action(&provider);
1099        assert_eq!(handle, el.data.handle);
1100        assert_eq!(name, "set_value");
1101        assert_eq!(data.as_deref(), Some("world"));
1102    }
1103
1104    #[test]
1105    fn set_numeric_value_records_payload() {
1106        let provider = build_provider();
1107        let el = find_element(&provider, r#"slider[name="Volume"]"#);
1108        el.set_numeric_value(42.0).unwrap();
1109        let (_, name, data) = last_action(&provider);
1110        assert_eq!(name, "set_numeric_value");
1111        assert_eq!(data.as_deref(), Some("42"));
1112    }
1113
1114    #[test]
1115    fn set_numeric_value_rejects_non_finite() {
1116        let provider = build_provider();
1117        let el = find_element(&provider, r#"slider[name="Volume"]"#);
1118        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1119            assert!(matches!(
1120                el.set_numeric_value(bad),
1121                Err(Error::InvalidActionData { .. })
1122            ));
1123        }
1124        // None of the validation failures should have reached the provider.
1125        assert!(provider.actions().is_empty());
1126    }
1127
1128    #[test]
1129    fn type_text_records_payload() {
1130        let provider = build_provider();
1131        let el = find_element(&provider, r#"text_field[name="Search"]"#);
1132        el.type_text("abc").unwrap();
1133        let (_, name, data) = last_action(&provider);
1134        assert_eq!(name, "type_text");
1135        assert_eq!(data.as_deref(), Some("abc"));
1136    }
1137
1138    #[test]
1139    fn select_text_records_range() {
1140        let provider = build_provider();
1141        let el = find_element(&provider, r#"text_field[name="Search"]"#);
1142        el.select_text(1, 4).unwrap();
1143        let (_, name, data) = last_action(&provider);
1144        assert_eq!(name, "set_text_selection");
1145        assert_eq!(data.as_deref(), Some("1..4"));
1146    }
1147
1148    #[test]
1149    fn select_text_rejects_inverted_range() {
1150        let provider = build_provider();
1151        let el = find_element(&provider, r#"text_field[name="Search"]"#);
1152        assert!(matches!(
1153            el.select_text(5, 2),
1154            Err(Error::InvalidActionData { .. })
1155        ));
1156        assert!(provider.actions().is_empty());
1157    }
1158
1159    #[test]
1160    fn perform_action_records_arbitrary_name() {
1161        let provider = build_provider();
1162        let el = find_element(&provider, r#"button[name="Back"]"#);
1163        el.perform_action("custom_swipe").unwrap();
1164        let (_, name, _) = last_action(&provider);
1165        assert_eq!(name, "custom_swipe");
1166    }
1167
1168    // ── Window management verbs ───────────────────────────────────
1169
1170    #[test]
1171    fn window_verbs_record_correct_name() {
1172        let provider = build_provider();
1173        let cases = [
1174            ("window", "activate" as &str),
1175            ("window", "minimize"),
1176            ("window", "maximize"),
1177            ("window", "enter_fullscreen"),
1178            ("window", "restore"),
1179            ("window", "close"),
1180        ];
1181        for (selector, action) in cases {
1182            provider.clear_actions();
1183            let el = find_element(&provider, selector);
1184            match action {
1185                "activate" => el.activate().unwrap(),
1186                "minimize" => el.minimize().unwrap(),
1187                "maximize" => el.maximize().unwrap(),
1188                "enter_fullscreen" => el.enter_fullscreen().unwrap(),
1189                "restore" => el.restore().unwrap(),
1190                "close" => el.close().unwrap(),
1191                _ => unreachable!(),
1192            }
1193            let (handle, name, data) = last_action(&provider);
1194            assert_eq!(name, action, "wrong action recorded for {selector}");
1195            assert_eq!(data, None, "nullary action should not carry data");
1196            assert_eq!(handle, el.data.handle);
1197        }
1198    }
1199
1200    #[test]
1201    fn move_to_records_coordinate_payload() {
1202        let provider = build_provider();
1203        let el = find_element(&provider, "window");
1204        el.move_to(10, 20).unwrap();
1205        let (_, name, data) = last_action(&provider);
1206        assert_eq!(name, "move_to");
1207        assert_eq!(data.as_deref(), Some("10,20"));
1208    }
1209
1210    #[test]
1211    fn resize_to_records_size_payload() {
1212        let provider = build_provider();
1213        let el = find_element(&provider, "window");
1214        el.resize_to(640, 480).unwrap();
1215        let (_, name, data) = last_action(&provider);
1216        assert_eq!(name, "resize_to");
1217        assert_eq!(data.as_deref(), Some("640x480"));
1218    }
1219
1220    #[test]
1221    fn window_verbs_reject_non_window_targets() {
1222        // The role guard lives in the shared Element layer so behavior is
1223        // identical across platforms: Linux `activate` would otherwise reach a
1224        // button's GrabFocus and report success, and the mock would accept
1225        // any live node — a test that passed here could fail everywhere.
1226        let provider = build_provider();
1227        let button = find_element(&provider, r#"button[name="Back"]"#);
1228        let errors = [
1229            button.activate(),
1230            button.minimize(),
1231            button.maximize(),
1232            button.enter_fullscreen(),
1233            button.restore(),
1234            button.close(),
1235            button.move_to(0, 0),
1236            button.resize_to(100, 100),
1237        ];
1238        for err in errors {
1239            assert!(
1240                matches!(err, Err(Error::ActionNotSupported { .. })),
1241                "a window verb on a button must be ActionNotSupported, got {err:?}"
1242            );
1243        }
1244        // The generic escape hatch routes the nullary window verbs through
1245        // the typed methods, so it cannot dodge the role guard.
1246        assert!(
1247            matches!(
1248                button.perform_action("activate"),
1249                Err(Error::ActionNotSupported { .. })
1250            ),
1251            "perform_action(\"activate\") on a button must fail like activate() does"
1252        );
1253        assert!(
1254            matches!(
1255                button.perform_action("close"),
1256                Err(Error::ActionNotSupported { .. })
1257            ),
1258            "perform_action(\"close\") on a button must fail like close() does"
1259        );
1260        assert!(
1261            matches!(
1262                button.perform_action("enter_fullscreen"),
1263                Err(Error::ActionNotSupported { .. })
1264            ),
1265            "perform_action(\"enter_fullscreen\") on a button must fail like enter_fullscreen() does"
1266        );
1267        assert!(
1268            provider.actions().is_empty(),
1269            "rejected window verbs must not reach the provider"
1270        );
1271    }
1272
1273    #[test]
1274    fn window_verbs_reject_dialogs_without_window_signals() {
1275        let provider = build_provider();
1276        let provider_dyn: Arc<dyn Provider> = provider.clone();
1277        let dialog = Element::new(ElementData::for_role(Role::Dialog), provider_dyn);
1278        let errors = [
1279            dialog.activate(),
1280            dialog.minimize(),
1281            dialog.maximize(),
1282            dialog.enter_fullscreen(),
1283            dialog.restore(),
1284            dialog.close(),
1285            dialog.move_to(0, 0),
1286            dialog.resize_to(100, 100),
1287        ];
1288        for err in errors {
1289            assert!(
1290                matches!(err, Err(Error::ActionNotSupported { .. })),
1291                "a dialog with no window evidence must be rejected, got {err:?}"
1292            );
1293        }
1294        assert!(
1295            matches!(
1296                dialog.perform_action("activate"),
1297                Err(Error::ActionNotSupported { .. })
1298            ),
1299            "perform_action(\"activate\") on a non-window dialog must fail like activate() does"
1300        );
1301        assert!(
1302            provider.actions().is_empty(),
1303            "rejected dialog window verbs must not reach the provider"
1304        );
1305    }
1306
1307    #[test]
1308    fn resize_to_rejects_zero_dimensions() {
1309        let provider = build_provider();
1310        let el = find_element(&provider, "window");
1311        for (w, h) in [(0u32, 100u32), (100, 0), (0, 0)] {
1312            assert!(matches!(
1313                el.resize_to(w, h),
1314                Err(Error::InvalidActionData { .. })
1315            ));
1316        }
1317        assert!(
1318            provider.actions().is_empty(),
1319            "validation failures must not reach the provider"
1320        );
1321    }
1322
1323    #[test]
1324    fn minimize_then_restore_roundtrip_updates_state() {
1325        let provider = build_provider();
1326        let el = find_element(&provider, "window");
1327        el.minimize().unwrap();
1328        // Re-resolve: the window must now report minimized and be off-screen.
1329        let after = find_element(&provider, "window");
1330        assert_eq!(after.states.minimized, Some(true));
1331        // The mock decided the window is not maximized, so the real-provider
1332        // tri-state (UIA WindowVisualState_Minimized → (true, false)) must be
1333        // reported; `None` would mean "unknown", which it is not.
1334        assert_eq!(after.states.maximized, Some(false));
1335        assert_eq!(after.states.fullscreen, Some(false));
1336        assert!(!after.states.visible);
1337        el.restore().unwrap();
1338        let restored = find_element(&provider, "window");
1339        assert_eq!(restored.states.minimized, Some(false));
1340        assert_eq!(restored.states.maximized, Some(false));
1341        assert_eq!(restored.states.fullscreen, Some(false));
1342        assert!(restored.states.visible);
1343    }
1344
1345    #[test]
1346    fn enter_fullscreen_then_restore_roundtrip_updates_state() {
1347        let provider = build_provider();
1348        let el = find_element(&provider, "window");
1349        el.enter_fullscreen().unwrap();
1350        let after = find_element(&provider, "window");
1351        assert_eq!(after.states.fullscreen, Some(true));
1352        // Fullscreen supersedes the other states, and the mock decided them:
1353        // a window entering fullscreen is not minimized or maximized.
1354        assert_eq!(after.states.minimized, Some(false));
1355        assert_eq!(after.states.maximized, Some(false));
1356        assert!(after.states.visible);
1357        el.restore().unwrap();
1358        let restored = find_element(&provider, "window");
1359        assert_eq!(restored.states.fullscreen, Some(false));
1360        assert!(restored.states.visible);
1361    }
1362
1363    #[test]
1364    fn fullscreen_minimize_sequence_keeps_every_step_honest() {
1365        // The `max, min, max, min` shape the integration suites drive
1366        // back-to-back: each verb must land the state the next one reads,
1367        // with no transition left half-applied (macOS exits fullscreen before
1368        // minimizing, and the mock models exactly that).
1369        let provider = build_provider();
1370        let el = find_element(&provider, "window");
1371        el.enter_fullscreen().unwrap();
1372        assert_eq!(
1373            find_element(&provider, "window").states.fullscreen,
1374            Some(true)
1375        );
1376        el.minimize().unwrap();
1377        let minimized = find_element(&provider, "window");
1378        assert_eq!(minimized.states.minimized, Some(true));
1379        assert_eq!(minimized.states.fullscreen, Some(false));
1380        el.enter_fullscreen().unwrap();
1381        assert_eq!(
1382            find_element(&provider, "window").states.fullscreen,
1383            Some(true)
1384        );
1385        el.minimize().unwrap();
1386        assert_eq!(
1387            find_element(&provider, "window").states.minimized,
1388            Some(true)
1389        );
1390        el.restore().unwrap();
1391        let restored = find_element(&provider, "window");
1392        assert_eq!(restored.states.minimized, Some(false));
1393        assert_eq!(restored.states.fullscreen, Some(false));
1394        assert!(restored.states.visible);
1395    }
1396
1397    #[test]
1398    fn locator_actions_desugar_to_element_actions() {
1399        // Locator's auto-wait wraps the resolved data in an Element and calls
1400        // its action — no duplication at the provider call site. This test
1401        // pins that behavior: pressing via the Locator should record exactly
1402        // the same entry as pressing via the Element it resolves to.
1403        let provider = build_provider();
1404        let provider_dyn: Arc<dyn Provider> = provider.clone();
1405        let locator = crate::locator::Locator::new(provider_dyn, None, r#"button[name="Back"]"#);
1406        locator.press().unwrap();
1407        let (_, name, data) = last_action(&provider);
1408        assert_eq!(name, "press");
1409        assert_eq!(data, None);
1410    }
1411
1412    #[test]
1413    fn locator_validation_runs_before_auto_wait() {
1414        // Locator validates payloads before entering its 5s auto-wait poll.
1415        // We verify by passing invalid input against a never-matching selector:
1416        // if validation fired first we get InvalidActionData immediately, not
1417        // a Timeout 5 seconds later.
1418        let provider = build_provider();
1419        let provider_dyn: Arc<dyn Provider> = provider.clone();
1420        let locator =
1421            crate::locator::Locator::new(provider_dyn, None, r#"button[name="never-matches"]"#);
1422        let started = std::time::Instant::now();
1423        let err = locator.set_numeric_value(f64::NAN).unwrap_err();
1424        assert!(matches!(err, Error::InvalidActionData { .. }));
1425        assert!(
1426            started.elapsed() < std::time::Duration::from_secs(1),
1427            "validation must short-circuit auto-wait",
1428        );
1429    }
1430
1431    #[test]
1432    fn state_set_payload_without_window_fields_still_deserializes() {
1433        // A `states` payload serialized before the window-management release
1434        // has no `minimized` / `maximized` / `fullscreen` keys at all. Those
1435        // are `Option<bool>`, and serde's derived `Deserialize` treats a
1436        // missing `Option` field as `None` implicitly — unlike a plain field
1437        // such as `active`, which is why `active` carries `#[serde(default)]`
1438        // while the three window fields need nothing. This test pins that
1439        // backward-compat guarantee so a payload from a previous release stays
1440        // readable.
1441        let json = r#"{
1442            "enabled": true, "visible": true, "focused": false, "active": false,
1443            "checked": null, "selected": false, "expanded": null, "editable": false,
1444            "focusable": false, "modal": false, "required": false, "busy": false
1445        }"#;
1446        let states: StateSet = serde_json::from_str(json)
1447            .expect("payloads without the window state fields must still deserialize");
1448        assert_eq!(states.minimized, None);
1449        assert_eq!(states.maximized, None);
1450        assert_eq!(states.fullscreen, None);
1451
1452        // And a payload that does carry a window state round-trips untouched.
1453        let mut data = ElementData::for_role(Role::Window);
1454        data.states.minimized = Some(true);
1455        let json = serde_json::to_string(&data.states).unwrap();
1456        assert!(json.contains("\"minimized\":true"));
1457        let round: StateSet = serde_json::from_str(&json).unwrap();
1458        assert_eq!(round.minimized, Some(true));
1459        assert_eq!(round.maximized, None);
1460    }
1461}