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