Skip to main content

cranpose_testing/
robot_assertions.rs

1//! Assertion utilities for robot testing
2//!
3//! This module provides assertion helpers specifically designed for
4//! validating UI state in robot tests.
5
6use cranpose_ui_graphics::Rect;
7
8/// Assert that a value is within an expected range.
9///
10/// This is useful for fuzzy matching of positions and sizes that might
11/// vary slightly due to rendering.
12///
13/// # Example
14/// ```
15/// use cranpose_testing::robot_assertions::assert_approx_eq;
16/// assert_approx_eq(100.05, 100.0, 0.1, "Width should be approx 100");
17/// ```
18pub fn assert_approx_eq(actual: f32, expected: f32, tolerance: f32, msg: &str) {
19    let diff = (actual - expected).abs();
20    assert!(
21        diff <= tolerance,
22        "{}: expected {} (±{}), got {} (diff: {})",
23        msg,
24        expected,
25        tolerance,
26        actual,
27        diff
28    );
29}
30
31/// Assert that a rectangle is approximately equal to another.
32///
33/// Checks x, y, width, and height individually with the given tolerance.
34///
35/// # Example
36/// ```
37/// use cranpose_testing::robot_assertions::assert_rect_approx_eq;
38/// use cranpose_ui_graphics::Rect;
39///
40/// let r1 = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
41/// let r2 = Rect { x: 0.1, y: 0.0, width: 100.0, height: 99.9 };
42/// assert_rect_approx_eq(r1, r2, 0.2, "Rects should match");
43/// ```
44pub fn assert_rect_approx_eq(actual: Rect, expected: Rect, tolerance: f32, msg: &str) {
45    assert_approx_eq(actual.x, expected.x, tolerance, &format!("{} - x", msg));
46    assert_approx_eq(actual.y, expected.y, tolerance, &format!("{} - y", msg));
47    assert_approx_eq(
48        actual.width,
49        expected.width,
50        tolerance,
51        &format!("{} - width", msg),
52    );
53    assert_approx_eq(
54        actual.height,
55        expected.height,
56        tolerance,
57        &format!("{} - height", msg),
58    );
59}
60
61/// Assert that a rectangle contains a point.
62///
63/// Useful for verifying that a click coordinate falls within an element.
64pub fn assert_rect_contains_point(rect: Rect, x: f32, y: f32, msg: &str) {
65    assert!(
66        x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height,
67        "{}: point ({}, {}) not in rect {:?}",
68        msg,
69        x,
70        y,
71        rect
72    );
73}
74
75/// Assert that a list contains a specific text fragment.
76///
77/// Often used with `robot.get_all_text()` to verify content presence.
78pub fn assert_contains_text(texts: &[String], fragment: &str, msg: &str) {
79    assert!(
80        texts.iter().any(|t| t.contains(fragment)),
81        "{}: text '{}' not found in {:?}",
82        msg,
83        fragment,
84        texts
85    );
86}
87
88/// Assert that a list does not contain a specific text fragment.
89///
90/// Verifies absence of content (e.g. after deletion or filtering).
91pub fn assert_not_contains_text(texts: &[String], fragment: &str, msg: &str) {
92    assert!(
93        !texts.iter().any(|t| t.contains(fragment)),
94        "{}: text '{}' unexpectedly found in {:?}",
95        msg,
96        fragment,
97        texts
98    );
99}
100
101/// Assert that a collection has an expected count.
102pub fn assert_count<T>(items: &[T], expected: usize, msg: &str) {
103    assert_eq!(
104        items.len(),
105        expected,
106        "{}: expected {} items, got {}",
107        msg,
108        expected,
109        items.len()
110    );
111}
112
113// ============================================================================
114// Semantic Tree Helpers
115// ============================================================================
116
117/// Bounds of a UI element (x, y, width, height)
118#[derive(Clone, Copy, Debug)]
119pub struct Bounds {
120    pub x: f32,
121    pub y: f32,
122    pub width: f32,
123    pub height: f32,
124}
125
126impl Bounds {
127    /// Get the center point of these bounds
128    pub fn center(&self) -> (f32, f32) {
129        (self.x + self.width / 2.0, self.y + self.height / 2.0)
130    }
131}
132
133/// Generic semantic element trait for tree traversal
134/// This allows the helpers to work with both cranpose::SemanticElement and similar types
135pub trait SemanticElementLike {
136    fn text(&self) -> Option<&str>;
137    fn role(&self) -> &str;
138    fn clickable(&self) -> bool;
139    fn bounds(&self) -> Bounds;
140    fn children(&self) -> &[Self]
141    where
142        Self: Sized;
143}
144
145/// Find an element by text content in the semantic tree.
146/// Returns the center coordinates (x, y) if found.
147///
148/// # Type Parameters
149/// * `E`: A type implementing `SemanticElementLike` (e.g. `SemanticElement`)
150pub fn find_text_center<E: SemanticElementLike>(elements: &[E], text: &str) -> Option<(f32, f32)> {
151    fn search<E: SemanticElementLike>(elem: &E, text: &str) -> Option<(f32, f32)> {
152        if let Some(t) = elem.text() {
153            if t.contains(text) {
154                return Some(elem.bounds().center());
155            }
156        }
157        for child in elem.children() {
158            if let Some(pos) = search(child, text) {
159                return Some(pos);
160            }
161        }
162        None
163    }
164
165    for elem in elements {
166        if let Some(pos) = search(elem, text) {
167            return Some(pos);
168        }
169    }
170    None
171}
172
173/// Find an element by text content and return full bounds.
174pub fn find_text_bounds<E: SemanticElementLike>(elements: &[E], text: &str) -> Option<Bounds> {
175    fn search<E: SemanticElementLike>(elem: &E, text: &str) -> Option<Bounds> {
176        if let Some(t) = elem.text() {
177            if t.contains(text) {
178                return Some(elem.bounds());
179            }
180        }
181        for child in elem.children() {
182            if let Some(bounds) = search(child, text) {
183                return Some(bounds);
184            }
185        }
186        None
187    }
188
189    for elem in elements {
190        if let Some(bounds) = search(elem, text) {
191            return Some(bounds);
192        }
193    }
194    None
195}
196
197/// Find a clickable element (button) containing the specified text.
198/// Returns the bounds (x, y, width, height) if found.
199pub fn find_button_bounds<E: SemanticElementLike>(elements: &[E], text: &str) -> Option<Bounds> {
200    fn has_text<E: SemanticElementLike>(elem: &E, text: &str) -> bool {
201        if let Some(t) = elem.text() {
202            if t.contains(text) {
203                return true;
204            }
205        }
206        elem.children().iter().any(|c| has_text(c, text))
207    }
208
209    fn search<E: SemanticElementLike>(elem: &E, text: &str) -> Option<Bounds> {
210        if elem.clickable() && has_text(elem, text) {
211            return Some(elem.bounds());
212        }
213        for child in elem.children() {
214            if let Some(bounds) = search(child, text) {
215                return Some(bounds);
216            }
217        }
218        None
219    }
220
221    for elem in elements {
222        if let Some(bounds) = search(elem, text) {
223            return Some(bounds);
224        }
225    }
226    None
227}
228
229/// Find all elements matching a role (e.g., "Layout", "Text").
230pub fn find_elements_by_role<E: SemanticElementLike>(elements: &[E], role: &str) -> Vec<Bounds> {
231    fn search<E: SemanticElementLike>(elem: &E, role: &str, results: &mut Vec<Bounds>) {
232        if elem.role() == role {
233            results.push(elem.bounds());
234        }
235        for child in elem.children() {
236            search(child, role, results);
237        }
238    }
239
240    let mut results = Vec::new();
241    for elem in elements {
242        search(elem, role, &mut results);
243    }
244    results
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_approx_eq() {
253        assert_approx_eq(100.0, 100.0, 0.1, "exact match");
254        assert_approx_eq(100.05, 100.0, 0.1, "within tolerance");
255    }
256
257    #[test]
258    #[should_panic]
259    fn test_approx_eq_fails() {
260        assert_approx_eq(100.5, 100.0, 0.1, "should fail");
261    }
262
263    #[test]
264    fn test_rect_approx_eq() {
265        let rect1 = Rect {
266            x: 10.0,
267            y: 20.0,
268            width: 100.0,
269            height: 50.0,
270        };
271        let rect2 = Rect {
272            x: 10.05,
273            y: 20.05,
274            width: 100.05,
275            height: 50.05,
276        };
277        assert_rect_approx_eq(rect1, rect2, 0.1, "nearly equal rects");
278    }
279
280    #[test]
281    fn test_rect_contains_point() {
282        let rect = Rect {
283            x: 10.0,
284            y: 20.0,
285            width: 100.0,
286            height: 50.0,
287        };
288        assert_rect_contains_point(rect, 50.0, 30.0, "center point");
289        assert_rect_contains_point(rect, 10.0, 20.0, "top-left corner");
290        assert_rect_contains_point(rect, 110.0, 70.0, "bottom-right corner");
291    }
292
293    #[test]
294    fn test_contains_text() {
295        let texts = vec!["Hello".to_string(), "World".to_string()];
296        assert_contains_text(&texts, "Hello", "exact match");
297        assert_contains_text(&texts, "Wor", "partial match");
298        assert_not_contains_text(&texts, "Goodbye", "not present");
299    }
300
301    #[test]
302    fn test_count() {
303        let items = vec![1, 2, 3];
304        assert_count(&items, 3, "correct count");
305    }
306
307    #[test]
308    fn test_bounds_center() {
309        let bounds = Bounds {
310            x: 10.0,
311            y: 20.0,
312            width: 100.0,
313            height: 50.0,
314        };
315        let (cx, cy) = bounds.center();
316        assert_eq!(cx, 60.0);
317        assert_eq!(cy, 45.0);
318    }
319}