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