1use cranpose_app_shell::AppShell;
26use cranpose_core::location_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}
42
43impl<R> RobotTestRule<R>
44where
45 R: Renderer,
46 R::Error: std::fmt::Debug,
47{
48 pub fn new(width: u32, height: u32, renderer: R, content: impl FnMut() + 'static) -> Self {
52 let root_key = location_key(file!(), line!(), column!());
53 let mut shell = AppShell::new(renderer, root_key, content);
54 shell.set_viewport(width as f32, height as f32);
55 shell.set_buffer_size(width, height);
56
57 Self { shell }
58 }
59
60 pub fn viewport_size(&self) -> (u32, u32) {
62 self.shell.buffer_size()
63 }
64
65 pub fn set_viewport(&mut self, width: u32, height: u32) {
67 self.shell.set_viewport(width as f32, height as f32);
68 self.shell.set_buffer_size(width, height);
69 }
70
71 pub fn advance_time(&mut self, _nanos: u64) {
75 self.shell.update();
77 }
78
79 pub fn wait_for_idle(&mut self) {
83 for _ in 0..10 {
85 self.shell.update();
86 if !self.shell.needs_redraw() {
87 break;
88 }
89 }
90 }
91
92 pub fn click_at(&mut self, x: f32, y: f32) -> bool {
96 self.shell.set_cursor(x, y);
97 self.shell.pointer_pressed();
98 self.shell.pointer_released();
99 self.wait_for_idle();
100 true
101 }
102
103 pub fn move_to(&mut self, x: f32, y: f32) -> bool {
107 let hit = self.shell.set_cursor(x, y);
108 self.wait_for_idle();
109 hit
110 }
111
112 pub fn drag(&mut self, from_x: f32, from_y: f32, to_x: f32, to_y: f32) {
116 self.shell.set_cursor(from_x, from_y);
118
119 self.shell.pointer_pressed();
121
122 let steps = 10;
124 for i in 1..=steps {
125 let t = i as f32 / steps as f32;
126 let x = from_x + (to_x - from_x) * t;
127 let y = from_y + (to_y - from_y) * t;
128 self.shell.set_cursor(x, y);
129 self.shell.update();
130 }
131
132 self.shell.pointer_released();
134 self.wait_for_idle();
135 }
136
137 pub fn mouse_move(&mut self, x: f32, y: f32) {
139 self.shell.set_cursor(x, y);
140 self.shell.update();
141 }
142
143 pub fn mouse_down(&mut self) {
145 self.shell.pointer_pressed();
146 self.shell.update();
147 }
148
149 pub fn mouse_up(&mut self) {
151 self.shell.pointer_released();
152 self.shell.update();
153 }
154
155 pub fn find_by_text(&mut self, text: &str) -> ElementFinder<'_, R> {
159 self.wait_for_idle();
160 ElementFinder {
161 robot: self,
162 query: FinderQuery::Text(text.to_string()),
163 }
164 }
165
166 pub fn find_at_position(&mut self, x: f32, y: f32) -> ElementFinder<'_, R> {
170 self.wait_for_idle();
171 ElementFinder {
172 robot: self,
173 query: FinderQuery::Position(x, y),
174 }
175 }
176
177 pub fn find_clickable(&mut self) -> ElementFinder<'_, R> {
181 self.wait_for_idle();
182 ElementFinder {
183 robot: self,
184 query: FinderQuery::Clickable,
185 }
186 }
187
188 pub fn get_all_text(&mut self) -> Vec<String> {
192 self.wait_for_idle();
193
194 if let Some(layout_tree) = self.get_layout_tree() {
196 extract_text_from_layout(layout_tree)
197 } else {
198 Vec::new()
199 }
200 }
201
202 pub fn get_all_rects(&mut self) -> Vec<(Rect, Option<String>)> {
206 self.wait_for_idle();
207
208 if let Some(layout_tree) = self.get_layout_tree() {
209 extract_rects_from_layout(layout_tree)
210 } else {
211 Vec::new()
212 }
213 }
214
215 pub fn dump_screen(&mut self) {
219 self.shell.log_debug_info();
220 }
221
222 pub fn shell_mut(&mut self) -> &mut AppShell<R> {
224 &mut self.shell
225 }
226
227 fn get_layout_tree(&mut self) -> Option<&LayoutTree> {
229 self.shell.layout_tree()
230 }
231
232 fn get_scene(&self) -> &R::Scene {
234 self.shell.scene()
235 }
236}
237
238#[derive(Clone, Debug)]
240enum FinderQuery {
241 Text(String),
242 Position(f32, f32),
243 Clickable,
244}
245
246pub struct ElementFinder<'a, R>
251where
252 R: Renderer,
253{
254 robot: &'a mut RobotTestRule<R>,
255 query: FinderQuery,
256}
257
258impl<'a, R> ElementFinder<'a, R>
259where
260 R: Renderer,
261 R::Error: std::fmt::Debug,
262{
263 pub fn exists(&mut self) -> bool {
265 match &self.query {
266 FinderQuery::Text(text) => {
267 let all_text = self.robot.get_all_text();
268 all_text.iter().any(|t| t.contains(text))
269 }
270 FinderQuery::Position(x, y) => !self.robot.get_scene().hit_test(*x, *y).is_empty(),
271 FinderQuery::Clickable => {
272 true }
276 }
277 }
278
279 pub fn bounds(&mut self) -> Option<Rect> {
283 match &self.query {
284 FinderQuery::Text(text) => {
285 let rects = self.robot.get_all_rects();
286 rects
287 .into_iter()
288 .find(|(_, txt)| txt.as_ref().is_some_and(|t| t.contains(text)))
289 .map(|(rect, _)| rect)
290 }
291 FinderQuery::Position(_x, _y) => {
292 None }
295 FinderQuery::Clickable => None,
296 }
297 }
298
299 pub fn center(&mut self) -> Option<Point> {
301 self.bounds().map(|rect| Point {
302 x: rect.x + rect.width / 2.0,
303 y: rect.y + rect.height / 2.0,
304 })
305 }
306
307 pub fn width(&mut self) -> Option<f32> {
309 self.bounds().map(|rect| rect.width)
310 }
311
312 pub fn height(&mut self) -> Option<f32> {
314 self.bounds().map(|rect| rect.height)
315 }
316
317 pub fn click(&mut self) -> bool {
321 if let Some(center) = self.center() {
322 self.robot.click_at(center.x, center.y);
323 true
324 } else {
325 false
326 }
327 }
328
329 pub fn long_press(&mut self) -> bool {
333 if let Some(center) = self.center() {
334 self.robot.shell_mut().set_cursor(center.x, center.y);
335 self.robot.shell_mut().pointer_pressed();
336
337 for _ in 0..50 {
339 self.robot.shell_mut().update();
340 }
341
342 self.robot.shell_mut().pointer_released();
343 self.robot.wait_for_idle();
344 true
345 } else {
346 false
347 }
348 }
349
350 pub fn assert_exists(&mut self) {
354 assert!(self.exists(), "Element not found: {:?}", self.query);
355 }
356
357 pub fn assert_not_exists(&mut self) {
361 assert!(
362 !self.exists(),
363 "Element unexpectedly found: {:?}",
364 self.query
365 );
366 }
367}
368
369fn extract_text_from_layout(layout: &LayoutTree) -> Vec<String> {
371 fn collect_text(node: &cranpose_ui::LayoutBox, results: &mut Vec<String>) {
372 if let Some(text) = node.node_data.modifier_slices().text_content() {
373 results.push(text.to_string());
374 }
375 for child in &node.children {
376 collect_text(child, results);
377 }
378 }
379
380 let mut results = Vec::new();
381 collect_text(layout.root(), &mut results);
382 results
383}
384
385fn extract_rects_from_layout(layout: &LayoutTree) -> Vec<(Rect, Option<String>)> {
387 fn collect_rects(node: &cranpose_ui::LayoutBox, results: &mut Vec<(Rect, Option<String>)>) {
388 let text = node
390 .node_data
391 .modifier_slices()
392 .text_content()
393 .map(|s| s.to_string());
394
395 let rect = Rect {
397 x: node.rect.x,
398 y: node.rect.y,
399 width: node.rect.width,
400 height: node.rect.height,
401 };
402
403 results.push((rect, text));
404
405 for child in &node.children {
407 collect_rects(child, results);
408 }
409 }
410
411 let mut results = Vec::new();
412 collect_rects(layout.root(), &mut results);
413 results
414}
415
416#[derive(Default)]
421pub struct TestRenderer {
422 scene: TestScene,
423}
424
425impl Renderer for TestRenderer {
426 type Scene = TestScene;
427 type Error = ();
428
429 fn scene(&self) -> &Self::Scene {
430 &self.scene
431 }
432
433 fn scene_mut(&mut self) -> &mut Self::Scene {
434 &mut self.scene
435 }
436
437 fn rebuild_scene(
438 &mut self,
439 _layout_tree: &LayoutTree,
440 _viewport: Size,
441 ) -> Result<(), Self::Error> {
442 Ok(())
443 }
444
445 fn rebuild_scene_from_applier(
446 &mut self,
447 _applier: &mut cranpose_core::MemoryApplier,
448 _root: cranpose_core::NodeId,
449 _viewport: Size,
450 ) -> Result<(), Self::Error> {
451 Ok(())
452 }
453}
454
455#[derive(Default)]
457pub struct TestScene;
458
459impl RenderScene for TestScene {
460 type HitTarget = TestHitTarget;
461
462 fn clear(&mut self) {}
463
464 fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
465 vec![TestHitTarget]
466 }
467
468 fn find_target(&self, _node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
469 None
470 }
471}
472
473#[derive(Default, Clone)]
475pub struct TestHitTarget;
476
477impl HitTestTarget for TestHitTarget {
478 fn dispatch(&self, _event: PointerEvent) {}
479
480 fn node_id(&self) -> cranpose_core::NodeId {
481 0
482 }
483}
484
485pub fn create_headless_robot_test<F>(
489 width: u32,
490 height: u32,
491 content: F,
492) -> RobotTestRule<TestRenderer>
493where
494 F: FnMut() + 'static,
495{
496 RobotTestRule::new(width, height, TestRenderer::default(), content)
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn test_robot_creation() {
505 let robot = create_headless_robot_test(800, 600, || {
507 });
509
510 assert_eq!(robot.viewport_size(), (800, 600));
511 }
512
513 #[test]
514 fn test_robot_click() {
515 let mut robot = create_headless_robot_test(800, 600, || {
516 });
518
519 robot.click_at(100.0, 100.0);
521 }
522
523 #[test]
524 fn test_robot_drag() {
525 let mut robot = create_headless_robot_test(800, 600, || {
526 });
528
529 robot.drag(0.0, 0.0, 100.0, 100.0);
531 }
532}