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