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} 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
52pub 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
75pub 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
105pub 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
119pub 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
132pub 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
145pub 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#[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 pub fn center(&self) -> (f32, f32) {
173 (self.x + self.width / 2.0, self.y + self.height / 2.0)
174 }
175}
176
177pub 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
189pub 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
217pub 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
241pub 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
273pub 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}