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}