Skip to main content

fenestra_core/
query.rs

1//! Semantic queries over a built frame: find widgets the way users and
2//! assistive technology perceive them, instead of by coordinates.
3//!
4//! The query vocabulary follows the priority order the web's Testing
5//! Library proved out: prefer [`by::role`] (with [`Query::name`]), then
6//! [`by::label`], then [`by::value`]; reach for [`by::id`] (the test-id
7//! escape hatch) last. [`Frame::get`] is strict like a Playwright
8//! locator — zero or several matches panic, and the panic message dumps
9//! the accessibility tree so the failure is self-explaining.
10//!
11//! ```
12//! use fenestra_core::{Semantics, by, col};
13//! use fenestra_core::{Fonts, FrameState, Theme, build_frame};
14//! let view: fenestra_core::Element<()> = col().children([
15//!     fenestra_core::text("Hello"),
16//! ]);
17//! let mut fonts = Fonts::embedded();
18//! let mut state = FrameState::new();
19//! let frame = build_frame(&view, &Theme::light(), &mut fonts, &mut state, (200.0, 100.0), 1.0);
20//! assert!(frame.query(&by::label("Hello")).is_some());
21//! assert!(frame.query(&by::role(Semantics::Button)).is_none());
22//! ```
23
24use crate::element::Semantics;
25use crate::frame::{AccessNode, Frame};
26
27/// How a text criterion matches an accessible string.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum TextMatch {
30    /// The whole string, exactly.
31    Exact(String),
32    /// Any substring.
33    Contains(String),
34}
35
36impl TextMatch {
37    fn matches(&self, hay: Option<&str>) -> bool {
38        match (self, hay) {
39            (Self::Exact(needle), Some(hay)) => hay == needle,
40            (Self::Contains(needle), Some(hay)) => hay.contains(needle),
41            (_, None) => false,
42        }
43    }
44}
45
46impl std::fmt::Display for TextMatch {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::Exact(s) => write!(f, "{s:?}"),
50            Self::Contains(s) => write!(f, "*{s:?}*"),
51        }
52    }
53}
54
55/// A semantic query against the frame's accessibility tree. Build one
56/// with the [`by`] constructors; refine role queries with
57/// [`Query::name`]. All set criteria must match (AND).
58#[derive(Debug, Clone)]
59pub struct Query {
60    role: Option<Semantics>,
61    label: Option<TextMatch>,
62    value: Option<TextMatch>,
63    key: Option<String>,
64}
65
66/// Query constructors, in the order you should prefer them.
67pub mod by {
68    use super::{Query, TextMatch};
69    use crate::element::Semantics;
70
71    /// Matches by role: the payload is ignored, so
72    /// `by::role(Semantics::Checkbox { checked: false })` finds every
73    /// checkbox. Refine with [`Query::name`] for the accessible name.
74    pub fn role(role: Semantics) -> Query {
75        Query {
76            role: Some(role),
77            label: None,
78            value: None,
79            key: None,
80        }
81    }
82
83    /// Matches the accessible name exactly. Text leaves expose their
84    /// content as their label, so this also finds static text.
85    pub fn label(label: impl Into<String>) -> Query {
86        Query {
87            role: None,
88            label: Some(TextMatch::Exact(label.into())),
89            value: None,
90            key: None,
91        }
92    }
93
94    /// Matches any substring of the accessible name.
95    pub fn label_contains(label: impl Into<String>) -> Query {
96        Query {
97            role: None,
98            label: Some(TextMatch::Contains(label.into())),
99            value: None,
100            key: None,
101        }
102    }
103
104    /// Matches an input's current value exactly.
105    pub fn value(value: impl Into<String>) -> Query {
106        Query {
107            role: None,
108            label: None,
109            value: Some(TextMatch::Exact(value.into())),
110            key: None,
111        }
112    }
113
114    /// Matches any substring of an input's current value.
115    pub fn value_contains(value: impl Into<String>) -> Query {
116        Query {
117            role: None,
118            label: None,
119            value: Some(TextMatch::Contains(value.into())),
120            key: None,
121        }
122    }
123
124    /// Matches the stable key set with `.id("...")` — the test-id escape
125    /// hatch. Users can't see keys; prefer [`role`] or [`label`].
126    pub fn id(key: impl Into<String>) -> Query {
127        Query {
128            role: None,
129            label: None,
130            value: None,
131            key: Some(key.into()),
132        }
133    }
134}
135
136impl Query {
137    /// Refines the query with an exact accessible name (Playwright's
138    /// `getByRole(role, { name })`).
139    pub fn name(mut self, label: impl Into<String>) -> Self {
140        self.label = Some(TextMatch::Exact(label.into()));
141        self
142    }
143
144    /// Refines the query with an accessible-name substring.
145    pub fn name_contains(mut self, label: impl Into<String>) -> Self {
146        self.label = Some(TextMatch::Contains(label.into()));
147        self
148    }
149
150    fn matches(&self, node: &AccessNode) -> bool {
151        if let Some(role) = &self.role {
152            let Some(semantics) = &node.semantics else {
153                return false;
154            };
155            if std::mem::discriminant(role) != std::mem::discriminant(semantics) {
156                return false;
157            }
158        }
159        if let Some(label) = &self.label
160            && !label.matches(node.label.as_deref())
161        {
162            return false;
163        }
164        if let Some(value) = &self.value
165            && !value.matches(node.value.as_deref())
166        {
167            return false;
168        }
169        if let Some(key) = &self.key
170            && node.key.as_deref() != Some(key.as_str())
171        {
172            return false;
173        }
174        true
175    }
176}
177
178impl std::fmt::Display for Query {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        let mut parts = Vec::new();
181        if let Some(role) = &self.role {
182            parts.push(format!("role={}", role_name(role)));
183        }
184        if let Some(label) = &self.label {
185            parts.push(format!("name={label}"));
186        }
187        if let Some(value) = &self.value {
188            parts.push(format!("value={value}"));
189        }
190        if let Some(key) = &self.key {
191            parts.push(format!("id={key:?}"));
192        }
193        write!(f, "{}", parts.join(" "))
194    }
195}
196
197/// The lowercase role word used in [`Frame::access_yaml`] and query
198/// errors (ARIA vocabulary where one exists).
199pub(crate) fn role_name(semantics: &Semantics) -> &'static str {
200    match semantics {
201        Semantics::Button => "button",
202        Semantics::Checkbox { .. } => "checkbox",
203        Semantics::Switch { .. } => "switch",
204        Semantics::Radio { .. } => "radio",
205        Semantics::Slider { .. } => "slider",
206        Semantics::TextInput { .. } => "textbox",
207        Semantics::ComboBox => "combobox",
208        Semantics::Dialog => "dialog",
209        Semantics::Tab { .. } => "tab",
210        Semantics::Alert => "alert",
211        Semantics::Label => "text",
212        Semantics::Image => "image",
213        Semantics::Spinbutton { .. } => "spinbutton",
214        Semantics::Meter { .. } => "meter",
215        Semantics::ProgressBar { .. } => "progressbar",
216    }
217}
218
219/// Why a strict lookup failed — the non-panicking form used by
220/// machine-driven harnesses (scenario scripts).
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum QueryError {
223    /// Nothing matched.
224    NoMatch,
225    /// Several nodes matched (the count) — refine the query.
226    Ambiguous(usize),
227}
228
229impl std::fmt::Display for QueryError {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        match self {
232            Self::NoMatch => write!(f, "no node matches"),
233            Self::Ambiguous(n) => write!(f, "{n} nodes match (ambiguous)"),
234        }
235    }
236}
237
238fn collect<'t>(node: &'t AccessNode, q: &Query, out: &mut Vec<&'t AccessNode>) {
239    if q.matches(node) {
240        out.push(node);
241    }
242    for child in &node.children {
243        collect(child, q, out);
244    }
245}
246
247impl Frame {
248    /// All nodes matching the query, in tree (paint) order.
249    pub fn get_all(&self, q: &Query) -> Vec<AccessNode> {
250        let root = self.access_tree();
251        let mut out = Vec::new();
252        collect(&root, q, &mut out);
253        out.into_iter().cloned().collect()
254    }
255
256    /// The single matching node, without panicking — scenario runners
257    /// and other machine drivers turn the error into their own report.
258    ///
259    /// # Errors
260    /// [`QueryError::NoMatch`] when nothing matches,
261    /// [`QueryError::Ambiguous`] when several nodes do.
262    pub fn try_get(&self, q: &Query) -> Result<AccessNode, QueryError> {
263        let mut all = self.get_all(q);
264        match all.len() {
265            0 => Err(QueryError::NoMatch),
266            1 => Ok(all.pop().expect("len checked")),
267            n => Err(QueryError::Ambiguous(n)),
268        }
269    }
270
271    /// The single matching node, or `None` when nothing matches. Use to
272    /// assert absence; panics if the query is ambiguous (several
273    /// matches), because a test that silently picks one is lying.
274    ///
275    /// # Panics
276    /// If more than one node matches.
277    pub fn query(&self, q: &Query) -> Option<AccessNode> {
278        match self.try_get(q) {
279            Ok(node) => Some(node),
280            Err(QueryError::NoMatch) => None,
281            Err(QueryError::Ambiguous(n)) => panic!(
282                "query [{q}] is ambiguous: {n} matches\n\
283                 accessibility tree:\n{}",
284                self.access_yaml()
285            ),
286        }
287    }
288
289    /// The single matching node. Strict: zero or several matches panic,
290    /// and the message includes the full accessibility tree, so the
291    /// failure explains itself.
292    ///
293    /// # Panics
294    /// If no node or more than one node matches.
295    pub fn get(&self, q: &Query) -> AccessNode {
296        match self.query(q) {
297            Some(node) => node,
298            None => panic!(
299                "no node matches [{q}]\naccessibility tree:\n{}",
300                self.access_yaml()
301            ),
302        }
303    }
304
305    /// The accessibility tree as indented YAML in Playwright's
306    /// aria-snapshot grammar: `- role "name" [attr=value]`. Containers
307    /// without semantics, label, value, or key are flattened away, so the
308    /// output stays signal-dense and stable. Snapshot it with insta to
309    /// lock a screen's accessible structure.
310    pub fn access_yaml(&self) -> String {
311        fn attrs(node: &AccessNode) -> String {
312            let mut out = String::new();
313            match node.semantics {
314                Some(Semantics::Checkbox { mixed: true, .. }) => out.push_str(" [mixed]"),
315                Some(Semantics::Checkbox { checked: true, .. }) => out.push_str(" [checked]"),
316                Some(Semantics::Switch { on: true }) => out.push_str(" [on]"),
317                Some(Semantics::Radio { selected: true })
318                | Some(Semantics::Tab { selected: true }) => out.push_str(" [selected]"),
319                Some(Semantics::Slider { value, min, max })
320                | Some(Semantics::Spinbutton { value, min, max })
321                | Some(Semantics::Meter { value, min, max }) => {
322                    out.push_str(&format!(" [value={value} min={min} max={max}]"));
323                }
324                Some(Semantics::ProgressBar { value: Some(v) }) => {
325                    out.push_str(&format!(" [value={v}]"));
326                }
327                Some(Semantics::ProgressBar { value: None }) => out.push_str(" [indeterminate]"),
328                Some(Semantics::TextInput { multiline: true }) => out.push_str(" [multiline]"),
329                _ => {}
330            }
331            if let Some(value) = &node.value
332                && !value.is_empty()
333            {
334                out.push_str(&format!(" [value={value:?}]"));
335            }
336            if node.live {
337                out.push_str(" [live]");
338            }
339            if node.invalid {
340                out.push_str(" [invalid]");
341            }
342            out
343        }
344        fn emit(node: &AccessNode, depth: usize, out: &mut String) {
345            let interesting = node.semantics.is_some()
346                || node.label.is_some()
347                || node.value.is_some()
348                || node.key.is_some()
349                || node.live;
350            let child_depth = if interesting {
351                let role = node
352                    .semantics
353                    .as_ref()
354                    .map_or("generic", crate::query::role_name);
355                out.push_str(&"  ".repeat(depth));
356                out.push('-');
357                out.push(' ');
358                out.push_str(role);
359                if let Some(label) = &node.label {
360                    out.push_str(&format!(" {label:?}"));
361                }
362                out.push_str(&attrs(node));
363                if let Some(key) = &node.key {
364                    out.push_str(&format!(" #{key}"));
365                }
366                out.push('\n');
367                depth + 1
368            } else {
369                depth
370            };
371            for child in &node.children {
372                emit(child, child_depth, out);
373            }
374        }
375        let mut out = String::new();
376        emit(&self.access_tree(), 0, &mut out);
377        if out.is_empty() {
378            out.push_str("(empty accessibility tree)\n");
379        }
380        out
381    }
382}