1use cranpose_ui_graphics::Rect;
7
8#[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
56pub 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
79pub 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
109pub 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
123pub 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
136pub 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
149pub 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#[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 pub fn center(&self) -> (f32, f32) {
177 (self.x + self.width / 2.0, self.y + self.height / 2.0)
178 }
179}
180
181pub 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
193pub 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
221pub 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
245pub 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
277pub 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}