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 fn rebuild_scene_from_applier(
448 &mut self,
449 _applier: &mut cranpose_core::MemoryApplier,
450 _root: cranpose_core::NodeId,
451 _viewport: Size,
452 ) -> Result<(), Self::Error> {
453 Ok(())
454 }
455}
456
457#[derive(Default)]
459pub struct TestScene;
460
461impl RenderScene for TestScene {
462 type HitTarget = TestHitTarget;
463
464 fn clear(&mut self) {}
465
466 fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
467 vec![TestHitTarget]
468 }
469
470 fn find_target(&self, _node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
471 None
472 }
473}
474
475#[derive(Default, Clone)]
477pub struct TestHitTarget;
478
479impl HitTestTarget for TestHitTarget {
480 fn dispatch(&self, _event: PointerEvent) {}
481
482 fn node_id(&self) -> cranpose_core::NodeId {
483 0
484 }
485}
486
487pub fn create_headless_robot_test<F>(
491 width: u32,
492 height: u32,
493 content: F,
494) -> RobotTestRule<TestRenderer>
495where
496 F: FnMut() + 'static,
497{
498 RobotTestRule::new(width, height, TestRenderer::default(), content)
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn test_robot_creation() {
507 let robot = create_headless_robot_test(800, 600, || {
509 });
511
512 assert_eq!(robot.viewport_size(), (800, 600));
513 }
514
515 #[test]
516 fn test_robot_click() {
517 let mut robot = create_headless_robot_test(800, 600, || {
518 });
520
521 robot.click_at(100.0, 100.0);
523 }
524
525 #[test]
526 fn test_robot_drag() {
527 let mut robot = create_headless_robot_test(800, 600, || {
528 });
530
531 robot.drag(0.0, 0.0, 100.0, 100.0);
533 }
534}