viewpoint_test/expect/
locator.rs

1//! Locator assertions for testing element state.
2
3use std::time::Duration;
4
5use viewpoint_core::Locator;
6
7use super::count::CountAssertions;
8use super::state::StateAssertions;
9use super::text::TextAssertions;
10use crate::error::AssertionError;
11
12/// Default timeout for assertions.
13const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
14
15/// Assertions for locators (elements).
16pub struct LocatorAssertions<'a> {
17    locator: &'a Locator<'a>,
18    timeout: Duration,
19    is_negated: bool,
20}
21
22impl<'a> LocatorAssertions<'a> {
23    /// Create a new `LocatorAssertions` for the given locator.
24    pub fn new(locator: &'a Locator<'a>) -> Self {
25        Self {
26            locator,
27            timeout: DEFAULT_TIMEOUT,
28            is_negated: false,
29        }
30    }
31
32    /// Set the timeout for this assertion.
33    #[must_use]
34    pub fn timeout(mut self, timeout: Duration) -> Self {
35        self.timeout = timeout;
36        self
37    }
38
39    /// Negate the assertion.
40    ///
41    /// This is an alias for the `not` method to avoid conflict with `std::ops::Not`.
42    #[must_use]
43    pub fn negated(mut self) -> Self {
44        self.is_negated = !self.is_negated;
45        self
46    }
47
48    /// Negate the assertion.
49    ///
50    /// Note: This method name shadows the `Not` trait's method. Use `negated()` if
51    /// you need to avoid this conflict.
52    #[must_use]
53    #[allow(clippy::should_implement_trait)]
54    pub fn not(self) -> Self {
55        self.negated()
56    }
57
58    /// Assert that the element is visible.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the assertion fails or the element cannot be queried.
63    pub async fn to_be_visible(&self) -> Result<(), AssertionError> {
64        StateAssertions::new(self.locator, self.timeout, self.is_negated)
65            .to_be_visible()
66            .await
67    }
68
69    /// Assert that the element is hidden.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the assertion fails or the element cannot be queried.
74    pub async fn to_be_hidden(&self) -> Result<(), AssertionError> {
75        StateAssertions::new(self.locator, self.timeout, self.is_negated)
76            .to_be_hidden()
77            .await
78    }
79
80    /// Assert that the element has the exact text content.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the assertion fails or the element cannot be queried.
85    pub async fn to_have_text(&self, expected: &str) -> Result<(), AssertionError> {
86        TextAssertions::new(self.locator, self.timeout, self.is_negated)
87            .to_have_text(expected)
88            .await
89    }
90
91    /// Assert that the element contains the specified text.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the assertion fails or the element cannot be queried.
96    pub async fn to_contain_text(&self, expected: &str) -> Result<(), AssertionError> {
97        TextAssertions::new(self.locator, self.timeout, self.is_negated)
98            .to_contain_text(expected)
99            .await
100    }
101
102    /// Assert that the element has the specified attribute value.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if the assertion fails or the element cannot be queried.
107    pub async fn to_have_attribute(&self, name: &str, value: &str) -> Result<(), AssertionError> {
108        let start = std::time::Instant::now();
109
110        loop {
111            let actual = self.get_attribute(name).await?;
112            let matches = actual.as_deref() == Some(value);
113            let expected_match = !self.is_negated;
114
115            if matches == expected_match {
116                return Ok(());
117            }
118
119            if start.elapsed() >= self.timeout {
120                return Err(AssertionError::new(
121                    if self.is_negated {
122                        format!("Element should not have attribute {name}=\"{value}\"")
123                    } else {
124                        format!("Element should have attribute {name}=\"{value}\"")
125                    },
126                    if self.is_negated {
127                        format!("not {name}=\"{value}\"")
128                    } else {
129                        format!("{name}=\"{value}\"")
130                    },
131                    match actual {
132                        Some(v) => format!("{name}=\"{v}\""),
133                        None => format!("{name} not present"),
134                    },
135                ));
136            }
137
138            tokio::time::sleep(Duration::from_millis(100)).await;
139        }
140    }
141
142    /// Assert that the element has the specified class.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the assertion fails or the element cannot be queried.
147    pub async fn to_have_class(&self, class_name: &str) -> Result<(), AssertionError> {
148        let start = std::time::Instant::now();
149
150        loop {
151            let class_attr = self.get_attribute("class").await?;
152            let classes = class_attr.as_deref().unwrap_or("");
153            let has_class = classes.split_whitespace().any(|c| c == class_name);
154            let expected_match = !self.is_negated;
155
156            if has_class == expected_match {
157                return Ok(());
158            }
159
160            if start.elapsed() >= self.timeout {
161                return Err(AssertionError::new(
162                    if self.is_negated {
163                        format!("Element should not have class \"{class_name}\"")
164                    } else {
165                        format!("Element should have class \"{class_name}\"")
166                    },
167                    if self.is_negated {
168                        format!("not containing class \"{class_name}\"")
169                    } else {
170                        format!("class \"{class_name}\"")
171                    },
172                    format!("classes: \"{classes}\""),
173                ));
174            }
175
176            tokio::time::sleep(Duration::from_millis(100)).await;
177        }
178    }
179
180    /// Assert that the element is enabled.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the assertion fails or the element cannot be queried.
185    pub async fn to_be_enabled(&self) -> Result<(), AssertionError> {
186        StateAssertions::new(self.locator, self.timeout, self.is_negated)
187            .to_be_enabled()
188            .await
189    }
190
191    /// Assert that the element is disabled.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the assertion fails or the element cannot be queried.
196    pub async fn to_be_disabled(&self) -> Result<(), AssertionError> {
197        StateAssertions::new(self.locator, self.timeout, self.is_negated)
198            .to_be_disabled()
199            .await
200    }
201
202    /// Assert that the element is checked (for checkboxes/radios).
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the assertion fails or the element cannot be queried.
207    pub async fn to_be_checked(&self) -> Result<(), AssertionError> {
208        StateAssertions::new(self.locator, self.timeout, self.is_negated)
209            .to_be_checked()
210            .await
211    }
212
213    /// Assert that the element has the specified value (for input/textarea/select).
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if the assertion fails or the element cannot be queried.
218    pub async fn to_have_value(&self, expected: &str) -> Result<(), AssertionError> {
219        let start = std::time::Instant::now();
220
221        loop {
222            let actual = self.get_input_value().await?;
223            let matches = actual == expected;
224            let expected_match = !self.is_negated;
225
226            if matches == expected_match {
227                return Ok(());
228            }
229
230            if start.elapsed() >= self.timeout {
231                return Err(AssertionError::new(
232                    if self.is_negated {
233                        "Element should not have value"
234                    } else {
235                        "Element should have value"
236                    },
237                    if self.is_negated {
238                        format!("not \"{expected}\"")
239                    } else {
240                        format!("\"{expected}\"")
241                    },
242                    format!("\"{actual}\""),
243                ));
244            }
245
246            tokio::time::sleep(Duration::from_millis(100)).await;
247        }
248    }
249
250    /// Assert that a multi-select element has the specified values selected.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if the assertion fails or the element cannot be queried.
255    pub async fn to_have_values(&self, expected: &[&str]) -> Result<(), AssertionError> {
256        let start = std::time::Instant::now();
257
258        loop {
259            let actual = self.get_selected_values().await?;
260            let expected_set: std::collections::HashSet<&str> = expected.iter().copied().collect();
261            let actual_set: std::collections::HashSet<&str> = actual.iter().map(std::string::String::as_str).collect();
262            let matches = expected_set == actual_set;
263            let expected_match = !self.is_negated;
264
265            if matches == expected_match {
266                return Ok(());
267            }
268
269            if start.elapsed() >= self.timeout {
270                return Err(AssertionError::new(
271                    if self.is_negated {
272                        "Element should not have values"
273                    } else {
274                        "Element should have values"
275                    },
276                    if self.is_negated {
277                        format!("not {expected:?}")
278                    } else {
279                        format!("{expected:?}")
280                    },
281                    format!("{actual:?}"),
282                ));
283            }
284
285            tokio::time::sleep(Duration::from_millis(100)).await;
286        }
287    }
288
289    /// Assert that the element has the specified id.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if the assertion fails or the element cannot be queried.
294    pub async fn to_have_id(&self, expected: &str) -> Result<(), AssertionError> {
295        self.to_have_attribute("id", expected).await
296    }
297
298    /// Assert that the element has the specified count.
299    ///
300    /// # Errors
301    ///
302    /// Returns an error if the assertion fails or the elements cannot be counted.
303    pub async fn to_have_count(&self, expected: usize) -> Result<(), AssertionError> {
304        CountAssertions::new(self.locator, self.timeout, self.is_negated)
305            .to_have_count(expected)
306            .await
307    }
308
309    /// Assert that the element count is greater than a value.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if the assertion fails or the elements cannot be counted.
314    pub async fn to_have_count_greater_than(&self, n: usize) -> Result<(), AssertionError> {
315        CountAssertions::new(self.locator, self.timeout, self.is_negated)
316            .to_have_count_greater_than(n)
317            .await
318    }
319
320    /// Assert that the element count is less than a value.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if the assertion fails or the elements cannot be counted.
325    pub async fn to_have_count_less_than(&self, n: usize) -> Result<(), AssertionError> {
326        CountAssertions::new(self.locator, self.timeout, self.is_negated)
327            .to_have_count_less_than(n)
328            .await
329    }
330
331    /// Assert that the element count is at least a value (greater than or equal).
332    ///
333    /// # Errors
334    ///
335    /// Returns an error if the assertion fails or the elements cannot be counted.
336    pub async fn to_have_count_at_least(&self, n: usize) -> Result<(), AssertionError> {
337        CountAssertions::new(self.locator, self.timeout, self.is_negated)
338            .to_have_count_at_least(n)
339            .await
340    }
341
342    /// Assert that the element count is at most a value (less than or equal).
343    ///
344    /// # Errors
345    ///
346    /// Returns an error if the assertion fails or the elements cannot be counted.
347    pub async fn to_have_count_at_most(&self, n: usize) -> Result<(), AssertionError> {
348        CountAssertions::new(self.locator, self.timeout, self.is_negated)
349            .to_have_count_at_most(n)
350            .await
351    }
352
353    /// Assert that all elements have the specified texts (in order).
354    ///
355    /// # Errors
356    ///
357    /// Returns an error if the assertion fails or the elements cannot be queried.
358    pub async fn to_have_texts(&self, expected: &[&str]) -> Result<(), AssertionError> {
359        TextAssertions::new(self.locator, self.timeout, self.is_negated)
360            .to_have_texts(expected)
361            .await
362    }
363
364    /// Assert that the element's ARIA snapshot matches the expected structure.
365    ///
366    /// This method compares the accessibility tree of the element against an expected
367    /// snapshot. The expected snapshot can contain regex patterns in name fields
368    /// when enclosed in `/pattern/` syntax.
369    ///
370    /// # Errors
371    ///
372    /// Returns an error if the assertion fails or the element cannot be queried.
373    pub async fn to_match_aria_snapshot(
374        &self,
375        expected: &viewpoint_core::AriaSnapshot,
376    ) -> Result<(), AssertionError> {
377        let start = std::time::Instant::now();
378
379        loop {
380            let actual = self
381                .locator
382                .aria_snapshot()
383                .await
384                .map_err(|e| AssertionError::new("Failed to get ARIA snapshot", "snapshot", e.to_string()))?;
385
386            let matches = actual.matches(expected);
387            let expected_match = !self.is_negated;
388
389            if matches == expected_match {
390                return Ok(());
391            }
392
393            if start.elapsed() >= self.timeout {
394                let diff = actual.diff(expected);
395                return Err(AssertionError::new(
396                    if self.is_negated {
397                        "ARIA snapshot should not match"
398                    } else {
399                        "ARIA snapshot should match"
400                    },
401                    expected.to_yaml(),
402                    format!("{}\n\nDiff:\n{}", actual.to_yaml(), diff),
403                ));
404            }
405
406            tokio::time::sleep(Duration::from_millis(100)).await;
407        }
408    }
409
410    /// Assert that the element's ARIA snapshot matches the expected YAML string.
411    ///
412    /// This is a convenience method that parses the YAML string and delegates to
413    /// `to_match_aria_snapshot`.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if the YAML parsing fails, the assertion fails, or the
418    /// element cannot be queried.
419    pub async fn to_match_aria_snapshot_yaml(&self, expected_yaml: &str) -> Result<(), AssertionError> {
420        let expected = viewpoint_core::AriaSnapshot::from_yaml(expected_yaml)
421            .map_err(|e| AssertionError::new("Failed to parse expected ARIA snapshot", expected_yaml, e.to_string()))?;
422        self.to_match_aria_snapshot(&expected).await
423    }
424
425    /// Assert that all elements contain the specified texts (in order).
426    ///
427    /// # Errors
428    ///
429    /// Returns an error if the assertion fails or the elements cannot be queried.
430    pub async fn to_contain_texts(&self, expected: &[&str]) -> Result<(), AssertionError> {
431        TextAssertions::new(self.locator, self.timeout, self.is_negated)
432            .to_contain_texts(expected)
433            .await
434    }
435
436    /// Assert that the element has all specified classes.
437    ///
438    /// Unlike `to_have_class()` which checks for a single class, this method
439    /// verifies that the element has ALL specified classes.
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if the assertion fails or the element cannot be queried.
444    pub async fn to_have_classes(&self, expected_classes: &[&str]) -> Result<(), AssertionError> {
445        let start = std::time::Instant::now();
446
447        loop {
448            let class_attr = self.get_attribute("class").await?;
449            let actual_classes: std::collections::HashSet<&str> = class_attr
450                .as_deref()
451                .unwrap_or("")
452                .split_whitespace()
453                .collect();
454
455            let has_all = expected_classes.iter().all(|c| actual_classes.contains(c));
456            let expected_match = !self.is_negated;
457
458            if has_all == expected_match {
459                return Ok(());
460            }
461
462            if start.elapsed() >= self.timeout {
463                return Err(AssertionError::new(
464                    if self.is_negated {
465                        format!("Element should not have classes {expected_classes:?}")
466                    } else {
467                        format!("Element should have classes {expected_classes:?}")
468                    },
469                    format!("{expected_classes:?}"),
470                    format!("{:?}", actual_classes.into_iter().collect::<Vec<_>>()),
471                ));
472            }
473
474            tokio::time::sleep(Duration::from_millis(100)).await;
475        }
476    }
477
478    // =========================================================================
479    // Internal helpers (delegated to locator_helpers module)
480    // =========================================================================
481
482    async fn get_input_value(&self) -> Result<String, AssertionError> {
483        super::locator_helpers::get_input_value(self.locator).await
484    }
485
486    async fn get_selected_values(&self) -> Result<Vec<String>, AssertionError> {
487        super::locator_helpers::get_selected_values(self.locator).await
488    }
489
490    async fn get_attribute(&self, name: &str) -> Result<Option<String>, AssertionError> {
491        super::locator_helpers::get_attribute(self.locator, name).await
492    }
493}