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