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> =
262                actual.iter().map(std::string::String::as_str).collect();
263            let matches = expected_set == actual_set;
264            let expected_match = !self.is_negated;
265
266            if matches == expected_match {
267                return Ok(());
268            }
269
270            if start.elapsed() >= self.timeout {
271                return Err(AssertionError::new(
272                    if self.is_negated {
273                        "Element should not have values"
274                    } else {
275                        "Element should have values"
276                    },
277                    if self.is_negated {
278                        format!("not {expected:?}")
279                    } else {
280                        format!("{expected:?}")
281                    },
282                    format!("{actual:?}"),
283                ));
284            }
285
286            tokio::time::sleep(Duration::from_millis(100)).await;
287        }
288    }
289
290    /// Assert that the element has the specified id.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if the assertion fails or the element cannot be queried.
295    pub async fn to_have_id(&self, expected: &str) -> Result<(), AssertionError> {
296        self.to_have_attribute("id", expected).await
297    }
298
299    /// Assert that the element has the specified count.
300    ///
301    /// # Errors
302    ///
303    /// Returns an error if the assertion fails or the elements cannot be counted.
304    pub async fn to_have_count(&self, expected: usize) -> Result<(), AssertionError> {
305        CountAssertions::new(self.locator, self.timeout, self.is_negated)
306            .to_have_count(expected)
307            .await
308    }
309
310    /// Assert that the element count is greater than a value.
311    ///
312    /// # Errors
313    ///
314    /// Returns an error if the assertion fails or the elements cannot be counted.
315    pub async fn to_have_count_greater_than(&self, n: usize) -> Result<(), AssertionError> {
316        CountAssertions::new(self.locator, self.timeout, self.is_negated)
317            .to_have_count_greater_than(n)
318            .await
319    }
320
321    /// Assert that the element count is less than a value.
322    ///
323    /// # Errors
324    ///
325    /// Returns an error if the assertion fails or the elements cannot be counted.
326    pub async fn to_have_count_less_than(&self, n: usize) -> Result<(), AssertionError> {
327        CountAssertions::new(self.locator, self.timeout, self.is_negated)
328            .to_have_count_less_than(n)
329            .await
330    }
331
332    /// Assert that the element count is at least a value (greater than or equal).
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if the assertion fails or the elements cannot be counted.
337    pub async fn to_have_count_at_least(&self, n: usize) -> Result<(), AssertionError> {
338        CountAssertions::new(self.locator, self.timeout, self.is_negated)
339            .to_have_count_at_least(n)
340            .await
341    }
342
343    /// Assert that the element count is at most a value (less than or equal).
344    ///
345    /// # Errors
346    ///
347    /// Returns an error if the assertion fails or the elements cannot be counted.
348    pub async fn to_have_count_at_most(&self, n: usize) -> Result<(), AssertionError> {
349        CountAssertions::new(self.locator, self.timeout, self.is_negated)
350            .to_have_count_at_most(n)
351            .await
352    }
353
354    /// Assert that all elements have the specified texts (in order).
355    ///
356    /// # Errors
357    ///
358    /// Returns an error if the assertion fails or the elements cannot be queried.
359    pub async fn to_have_texts(&self, expected: &[&str]) -> Result<(), AssertionError> {
360        TextAssertions::new(self.locator, self.timeout, self.is_negated)
361            .to_have_texts(expected)
362            .await
363    }
364
365    /// Assert that the element's ARIA snapshot matches the expected structure.
366    ///
367    /// This method compares the accessibility tree of the element against an expected
368    /// snapshot. The expected snapshot can contain regex patterns in name fields
369    /// when enclosed in `/pattern/` syntax.
370    ///
371    /// # Errors
372    ///
373    /// Returns an error if the assertion fails or the element cannot be queried.
374    pub async fn to_match_aria_snapshot(
375        &self,
376        expected: &viewpoint_core::AriaSnapshot,
377    ) -> Result<(), AssertionError> {
378        let start = std::time::Instant::now();
379
380        loop {
381            let actual = self.locator.aria_snapshot().await.map_err(|e| {
382                AssertionError::new("Failed to get ARIA snapshot", "snapshot", e.to_string())
383            })?;
384
385            let matches = actual.matches(expected);
386            let expected_match = !self.is_negated;
387
388            if matches == expected_match {
389                return Ok(());
390            }
391
392            if start.elapsed() >= self.timeout {
393                let diff = actual.diff(expected);
394                return Err(AssertionError::new(
395                    if self.is_negated {
396                        "ARIA snapshot should not match"
397                    } else {
398                        "ARIA snapshot should match"
399                    },
400                    expected.to_yaml(),
401                    format!("{}\n\nDiff:\n{}", actual.to_yaml(), diff),
402                ));
403            }
404
405            tokio::time::sleep(Duration::from_millis(100)).await;
406        }
407    }
408
409    /// Assert that the element's ARIA snapshot matches the expected YAML string.
410    ///
411    /// This is a convenience method that parses the YAML string and delegates to
412    /// `to_match_aria_snapshot`.
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if the YAML parsing fails, the assertion fails, or the
417    /// element cannot be queried.
418    pub async fn to_match_aria_snapshot_yaml(
419        &self,
420        expected_yaml: &str,
421    ) -> Result<(), AssertionError> {
422        let expected = viewpoint_core::AriaSnapshot::from_yaml(expected_yaml).map_err(|e| {
423            AssertionError::new(
424                "Failed to parse expected ARIA snapshot",
425                expected_yaml,
426                e.to_string(),
427            )
428        })?;
429        self.to_match_aria_snapshot(&expected).await
430    }
431
432    /// Assert that all elements contain the specified texts (in order).
433    ///
434    /// # Errors
435    ///
436    /// Returns an error if the assertion fails or the elements cannot be queried.
437    pub async fn to_contain_texts(&self, expected: &[&str]) -> Result<(), AssertionError> {
438        TextAssertions::new(self.locator, self.timeout, self.is_negated)
439            .to_contain_texts(expected)
440            .await
441    }
442
443    /// Assert that the element has all specified classes.
444    ///
445    /// Unlike `to_have_class()` which checks for a single class, this method
446    /// verifies that the element has ALL specified classes.
447    ///
448    /// # Errors
449    ///
450    /// Returns an error if the assertion fails or the element cannot be queried.
451    pub async fn to_have_classes(&self, expected_classes: &[&str]) -> Result<(), AssertionError> {
452        let start = std::time::Instant::now();
453
454        loop {
455            let class_attr = self.get_attribute("class").await?;
456            let actual_classes: std::collections::HashSet<&str> = class_attr
457                .as_deref()
458                .unwrap_or("")
459                .split_whitespace()
460                .collect();
461
462            let has_all = expected_classes.iter().all(|c| actual_classes.contains(c));
463            let expected_match = !self.is_negated;
464
465            if has_all == expected_match {
466                return Ok(());
467            }
468
469            if start.elapsed() >= self.timeout {
470                return Err(AssertionError::new(
471                    if self.is_negated {
472                        format!("Element should not have classes {expected_classes:?}")
473                    } else {
474                        format!("Element should have classes {expected_classes:?}")
475                    },
476                    format!("{expected_classes:?}"),
477                    format!("{:?}", actual_classes.into_iter().collect::<Vec<_>>()),
478                ));
479            }
480
481            tokio::time::sleep(Duration::from_millis(100)).await;
482        }
483    }
484
485    // =========================================================================
486    // Internal helpers (delegated to locator_helpers module)
487    // =========================================================================
488
489    async fn get_input_value(&self) -> Result<String, AssertionError> {
490        super::locator_helpers::get_input_value(self.locator).await
491    }
492
493    async fn get_selected_values(&self) -> Result<Vec<String>, AssertionError> {
494        super::locator_helpers::get_selected_values(self.locator).await
495    }
496
497    async fn get_attribute(&self, name: &str) -> Result<Option<String>, AssertionError> {
498        super::locator_helpers::get_attribute(self.locator, name).await
499    }
500}