1use cranpose_app_shell::AppShell;
26use cranpose_core::{location_key, Key};
27use cranpose_foundation::PointerEvent;
28use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
29use cranpose_ui::LayoutTree;
30use cranpose_ui_graphics::{Point, Rect, Size};
31
32pub struct RobotTestRule<R>
37where
38 R: Renderer,
39{
40 shell: AppShell<R>,
41 #[allow(dead_code)]
42 root_key: Key,
43}
44
45impl<R> RobotTestRule<R>
46where
47 R: Renderer,
48 R::Error: std::fmt::Debug,
49{
50 pub fn new(width: u32, height: u32, renderer: R, content: impl FnMut() + 'static) -> Self {
54 let root_key = location_key(file!(), line!(), column!());
55 let mut shell = AppShell::new(renderer, root_key, content);
56 shell.set_viewport(width as f32, height as f32);
57 shell.set_buffer_size(width, height);
58
59 Self { shell, root_key }
60 }
61
62 pub fn viewport_size(&self) -> (u32, u32) {
64 self.shell.buffer_size()
65 }
66
67 pub fn set_viewport(&mut self, width: u32, height: u32) {
69 self.shell.set_viewport(width as f32, height as f32);
70 self.shell.set_buffer_size(width, height);
71 }
72
73 pub fn advance_time(&mut self, _nanos: u64) {
77 self.shell.update();
79 }
80
81 pub fn wait_for_idle(&mut self) {
85 for _ in 0..10 {
87 self.shell.update();
88 if !self.shell.needs_redraw() {
89 break;
90 }
91 }
92 }
93
94 pub fn click_at(&mut self, x: f32, y: f32) -> bool {
98 self.shell.set_cursor(x, y);
99 self.shell.pointer_pressed();
100 self.shell.pointer_released();
101 self.wait_for_idle();
102 true
103 }
104
105 pub fn move_to(&mut self, x: f32, y: f32) -> bool {
109 let hit = self.shell.set_cursor(x, y);
110 self.wait_for_idle();
111 hit
112 }
113
114 pub fn drag(&mut self, from_x: f32, from_y: f32, to_x: f32, to_y: f32) {
118 self.shell.set_cursor(from_x, from_y);
120
121 self.shell.pointer_pressed();
123
124 let steps = 10;
126 for i in 1..=steps {
127 let t = i as f32 / steps as f32;
128 let x = from_x + (to_x - from_x) * t;
129 let y = from_y + (to_y - from_y) * t;
130 self.shell.set_cursor(x, y);
131 self.shell.update();
132 }
133
134 self.shell.pointer_released();
136 self.wait_for_idle();
137 }
138
139 pub fn mouse_move(&mut self, x: f32, y: f32) {
141 self.shell.set_cursor(x, y);
142 self.shell.update();
143 }
144
145 pub fn mouse_down(&mut self) {
147 self.shell.pointer_pressed();
148 self.shell.update();
149 }
150
151 pub fn mouse_up(&mut self) {
153 self.shell.pointer_released();
154 self.shell.update();
155 }
156
157 pub fn find_by_text(&mut self, text: &str) -> ElementFinder<'_, R> {
161 self.wait_for_idle();
162 ElementFinder {
163 robot: self,
164 query: FinderQuery::Text(text.to_string()),
165 }
166 }
167
168 pub fn find_at_position(&mut self, x: f32, y: f32) -> ElementFinder<'_, R> {
172 self.wait_for_idle();
173 ElementFinder {
174 robot: self,
175 query: FinderQuery::Position(x, y),
176 }
177 }
178
179 pub fn find_clickable(&mut self) -> ElementFinder<'_, R> {
183 self.wait_for_idle();
184 ElementFinder {
185 robot: self,
186 query: FinderQuery::Clickable,
187 }
188 }
189
190 pub fn get_all_text(&mut self) -> Vec<String> {
194 self.wait_for_idle();
195
196 if let Some(layout_tree) = self.get_layout_tree() {
198 extract_text_from_layout(layout_tree)
199 } else {
200 Vec::new()
201 }
202 }
203
204 pub fn get_all_rects(&mut self) -> Vec<(Rect, Option<String>)> {
208 self.wait_for_idle();
209
210 if let Some(layout_tree) = self.get_layout_tree() {
211 extract_rects_from_layout(layout_tree)
212 } else {
213 Vec::new()
214 }
215 }
216
217 pub fn dump_screen(&mut self) {
221 self.shell.log_debug_info();
222 }
223
224 pub fn shell_mut(&mut self) -> &mut AppShell<R> {
226 &mut self.shell
227 }
228
229 fn get_layout_tree(&self) -> Option<&LayoutTree> {
231 self.shell.layout_tree()
232 }
233
234 fn get_scene(&self) -> &R::Scene {
236 self.shell.scene()
237 }
238}
239
240#[derive(Clone, Debug)]
242enum FinderQuery {
243 Text(String),
244 Position(f32, f32),
245 Clickable,
246}
247
248pub struct ElementFinder<'a, R>
253where
254 R: Renderer,
255{
256 robot: &'a mut RobotTestRule<R>,
257 query: FinderQuery,
258}
259
260impl<'a, R> ElementFinder<'a, R>
261where
262 R: Renderer,
263 R::Error: std::fmt::Debug,
264{
265 pub fn exists(&mut self) -> bool {
267 match &self.query {
268 FinderQuery::Text(text) => {
269 let all_text = self.robot.get_all_text();
270 all_text.iter().any(|t| t.contains(text))
271 }
272 FinderQuery::Position(x, y) => !self.robot.get_scene().hit_test(*x, *y).is_empty(),
273 FinderQuery::Clickable => {
274 true }
278 }
279 }
280
281 pub fn bounds(&mut self) -> Option<Rect> {
285 match &self.query {
286 FinderQuery::Text(text) => {
287 let rects = self.robot.get_all_rects();
288 rects
289 .into_iter()
290 .find(|(_, txt)| txt.as_ref().is_some_and(|t| t.contains(text)))
291 .map(|(rect, _)| rect)
292 }
293 FinderQuery::Position(_x, _y) => {
294 None }
297 FinderQuery::Clickable => None,
298 }
299 }
300
301 pub fn center(&mut self) -> Option<Point> {
303 self.bounds().map(|rect| Point {
304 x: rect.x + rect.width / 2.0,
305 y: rect.y + rect.height / 2.0,
306 })
307 }
308
309 pub fn width(&mut self) -> Option<f32> {
311 self.bounds().map(|rect| rect.width)
312 }
313
314 pub fn height(&mut self) -> Option<f32> {
316 self.bounds().map(|rect| rect.height)
317 }
318
319 pub fn click(&mut self) -> bool {
323 if let Some(center) = self.center() {
324 self.robot.click_at(center.x, center.y);
325 true
326 } else {
327 false
328 }
329 }
330
331 pub fn long_press(&mut self) -> bool {
335 if let Some(center) = self.center() {
336 self.robot.shell_mut().set_cursor(center.x, center.y);
337 self.robot.shell_mut().pointer_pressed();
338
339 for _ in 0..50 {
341 self.robot.shell_mut().update();
342 }
343
344 self.robot.shell_mut().pointer_released();
345 self.robot.wait_for_idle();
346 true
347 } else {
348 false
349 }
350 }
351
352 pub fn assert_exists(&mut self) {
356 assert!(self.exists(), "Element not found: {:?}", self.query);
357 }
358
359 pub fn assert_not_exists(&mut self) {
363 assert!(
364 !self.exists(),
365 "Element unexpectedly found: {:?}",
366 self.query
367 );
368 }
369}
370
371fn extract_text_from_layout(layout: &LayoutTree) -> Vec<String> {
373 fn collect_text(node: &cranpose_ui::LayoutBox, results: &mut Vec<String>) {
374 if let Some(text) = node.node_data.modifier_slices().text_content() {
375 results.push(text.to_string());
376 }
377 for child in &node.children {
378 collect_text(child, results);
379 }
380 }
381
382 let mut results = Vec::new();
383 collect_text(layout.root(), &mut results);
384 results
385}
386
387fn extract_rects_from_layout(layout: &LayoutTree) -> Vec<(Rect, Option<String>)> {
389 fn collect_rects(node: &cranpose_ui::LayoutBox, results: &mut Vec<(Rect, Option<String>)>) {
390 let text = node
392 .node_data
393 .modifier_slices()
394 .text_content()
395 .map(|s| s.to_string());
396
397 let rect = Rect {
399 x: node.rect.x,
400 y: node.rect.y,
401 width: node.rect.width,
402 height: node.rect.height,
403 };
404
405 results.push((rect, text));
406
407 for child in &node.children {
409 collect_rects(child, results);
410 }
411 }
412
413 let mut results = Vec::new();
414 collect_rects(layout.root(), &mut results);
415 results
416}
417
418#[derive(Default)]
423pub struct TestRenderer {
424 scene: TestScene,
425}
426
427impl Renderer for TestRenderer {
428 type Scene = TestScene;
429 type Error = ();
430
431 fn scene(&self) -> &Self::Scene {
432 &self.scene
433 }
434
435 fn scene_mut(&mut self) -> &mut Self::Scene {
436 &mut self.scene
437 }
438
439 fn rebuild_scene(
440 &mut self,
441 _layout_tree: &LayoutTree,
442 _viewport: Size,
443 ) -> Result<(), Self::Error> {
444 Ok(())
445 }
446}
447
448#[derive(Default)]
450pub struct TestScene;
451
452impl RenderScene for TestScene {
453 type HitTarget = TestHitTarget;
454
455 fn clear(&mut self) {}
456
457 fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
458 vec![TestHitTarget]
459 }
460
461 fn find_target(&self, _node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
462 None
463 }
464}
465
466#[derive(Default, Clone)]
468pub struct TestHitTarget;
469
470impl HitTestTarget for TestHitTarget {
471 fn dispatch(&self, _event: PointerEvent) {}
472
473 fn node_id(&self) -> cranpose_core::NodeId {
474 0
475 }
476}
477
478pub fn create_headless_robot_test<F>(
482 width: u32,
483 height: u32,
484 content: F,
485) -> RobotTestRule<TestRenderer>
486where
487 F: FnMut() + 'static,
488{
489 RobotTestRule::new(width, height, TestRenderer::default(), content)
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn test_robot_creation() {
498 let robot = create_headless_robot_test(800, 600, || {
500 });
502
503 assert_eq!(robot.viewport_size(), (800, 600));
504 }
505
506 #[test]
507 fn test_robot_click() {
508 let mut robot = create_headless_robot_test(800, 600, || {
509 });
511
512 robot.click_at(100.0, 100.0);
514 }
515
516 #[test]
517 fn test_robot_drag() {
518 let mut robot = create_headless_robot_test(800, 600, || {
519 });
521
522 robot.drag(0.0, 0.0, 100.0, 100.0);
524 }
525}