1use cranpose_ui_graphics::Rect;
7
8pub 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
42pub 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
65pub 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
95pub 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
109pub 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
122pub 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
135pub 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#[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 pub fn center(&self) -> (f32, f32) {
163 (self.x + self.width / 2.0, self.y + self.height / 2.0)
164 }
165}
166
167pub 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
179pub 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
207pub 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
231pub 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
263pub 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}