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, TextMeasurer};
30use cranpose_ui_graphics::{Point, Rect, Size};
31use std::rc::Rc;
32
33pub struct RobotTestRule<R>
38where
39 R: Renderer,
40{
41 shell: AppShell<R>,
42 frame_time_nanos: u64,
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 {
60 shell,
61 frame_time_nanos: 0,
62 }
63 }
64
65 pub fn viewport_size(&self) -> (u32, u32) {
67 self.shell.buffer_size()
68 }
69
70 pub fn set_viewport(&mut self, width: u32, height: u32) {
72 self.shell.set_viewport(width as f32, height as f32);
73 self.shell.set_buffer_size(width, height);
74 }
75
76 pub fn advance_time(&mut self, nanos: u64) {
80 self.frame_time_nanos = self.frame_time_nanos.saturating_add(nanos);
81 self.shell.update_at_frame_time_nanos(self.frame_time_nanos);
82 }
83
84 pub fn frame_time_nanos(&self) -> u64 {
86 self.frame_time_nanos
87 }
88
89 pub fn wait_for_idle(&mut self) {
93 for _ in 0..10 {
95 self.shell.update();
96 if !self.shell.needs_redraw() {
97 break;
98 }
99 }
100 }
101
102 pub fn click_at(&mut self, x: f32, y: f32) -> bool {
106 self.shell.set_cursor(x, y);
107 self.shell.pointer_pressed();
108 self.shell.pointer_released();
109 self.wait_for_idle();
110 true
111 }
112
113 pub fn move_to(&mut self, x: f32, y: f32) -> bool {
117 let hit = self.shell.set_cursor(x, y);
118 self.wait_for_idle();
119 hit
120 }
121
122 pub fn drag(&mut self, from_x: f32, from_y: f32, to_x: f32, to_y: f32) {
126 self.shell.set_cursor(from_x, from_y);
128
129 self.shell.pointer_pressed();
131
132 let steps = 10;
134 for i in 1..=steps {
135 let t = i as f32 / steps as f32;
136 let x = from_x + (to_x - from_x) * t;
137 let y = from_y + (to_y - from_y) * t;
138 self.shell.set_cursor(x, y);
139 self.shell.update();
140 }
141
142 self.shell.pointer_released();
144 self.wait_for_idle();
145 }
146
147 pub fn mouse_move(&mut self, x: f32, y: f32) {
149 self.shell.set_cursor(x, y);
150 self.shell.update();
151 }
152
153 pub fn mouse_down(&mut self) {
155 self.shell.pointer_pressed();
156 self.shell.update();
157 }
158
159 pub fn mouse_up(&mut self) {
161 self.shell.pointer_released();
162 self.shell.update();
163 }
164
165 pub fn find_by_text(&mut self, text: &str) -> ElementFinder<'_, R> {
169 self.wait_for_idle();
170 ElementFinder {
171 robot: self,
172 query: FinderQuery::Text(text.to_string()),
173 }
174 }
175
176 pub fn find_at_position(&mut self, x: f32, y: f32) -> ElementFinder<'_, R> {
180 self.wait_for_idle();
181 ElementFinder {
182 robot: self,
183 query: FinderQuery::Position(x, y),
184 }
185 }
186
187 pub fn find_clickable(&mut self) -> ElementFinder<'_, R> {
191 self.wait_for_idle();
192 ElementFinder {
193 robot: self,
194 query: FinderQuery::Clickable,
195 }
196 }
197
198 pub fn get_all_text(&mut self) -> Vec<String> {
202 self.wait_for_idle();
203
204 self.shell.with_layout_tree(|layout_tree| {
205 layout_tree
206 .map(extract_text_from_layout)
207 .unwrap_or_default()
208 })
209 }
210
211 pub fn get_all_rects(&mut self) -> Vec<(Rect, Option<String>)> {
215 self.wait_for_idle();
216
217 self.shell.with_layout_tree(|layout_tree| {
218 layout_tree
219 .map(extract_rects_from_layout)
220 .unwrap_or_default()
221 })
222 }
223
224 pub fn dump_screen(&mut self) {
228 self.shell.log_debug_info();
229 }
230
231 pub fn shell_mut(&mut self) -> &mut AppShell<R> {
233 &mut self.shell
234 }
235
236 fn get_scene(&self) -> &R::Scene {
238 self.shell.scene()
239 }
240}
241
242#[derive(Clone, Debug)]
244enum FinderQuery {
245 Text(String),
246 Position(f32, f32),
247 Clickable,
248}
249
250pub struct ElementFinder<'a, R>
255where
256 R: Renderer,
257{
258 robot: &'a mut RobotTestRule<R>,
259 query: FinderQuery,
260}
261
262impl<'a, R> ElementFinder<'a, R>
263where
264 R: Renderer,
265 R::Error: std::fmt::Debug,
266{
267 pub fn exists(&mut self) -> bool {
269 match &self.query {
270 FinderQuery::Text(text) => {
271 let all_text = self.robot.get_all_text();
272 all_text.iter().any(|t| t.contains(text))
273 }
274 FinderQuery::Position(x, y) => !self.robot.get_scene().hit_test(*x, *y).is_empty(),
275 FinderQuery::Clickable => {
276 true }
280 }
281 }
282
283 pub fn bounds(&mut self) -> Option<Rect> {
287 match &self.query {
288 FinderQuery::Text(text) => {
289 let rects = self.robot.get_all_rects();
290 rects
291 .into_iter()
292 .find(|(_, txt)| txt.as_ref().is_some_and(|t| t.contains(text)))
293 .map(|(rect, _)| rect)
294 }
295 FinderQuery::Position(_x, _y) => {
296 None }
299 FinderQuery::Clickable => None,
300 }
301 }
302
303 pub fn center(&mut self) -> Option<Point> {
305 self.bounds().map(|rect| Point {
306 x: rect.x + rect.width / 2.0,
307 y: rect.y + rect.height / 2.0,
308 })
309 }
310
311 pub fn width(&mut self) -> Option<f32> {
313 self.bounds().map(|rect| rect.width)
314 }
315
316 pub fn height(&mut self) -> Option<f32> {
318 self.bounds().map(|rect| rect.height)
319 }
320
321 pub fn click(&mut self) -> bool {
325 if let Some(center) = self.center() {
326 self.robot.click_at(center.x, center.y);
327 true
328 } else {
329 false
330 }
331 }
332
333 pub fn long_press(&mut self) -> bool {
337 if let Some(center) = self.center() {
338 self.robot.shell_mut().set_cursor(center.x, center.y);
339 self.robot.shell_mut().pointer_pressed();
340
341 for _ in 0..50 {
343 self.robot.shell_mut().update();
344 }
345
346 self.robot.shell_mut().pointer_released();
347 self.robot.wait_for_idle();
348 true
349 } else {
350 false
351 }
352 }
353
354 pub fn assert_exists(&mut self) {
358 assert!(self.exists(), "Element not found: {:?}", self.query);
359 }
360
361 pub fn assert_not_exists(&mut self) {
365 assert!(
366 !self.exists(),
367 "Element unexpectedly found: {:?}",
368 self.query
369 );
370 }
371}
372
373fn extract_text_from_layout(layout: &LayoutTree) -> Vec<String> {
375 fn collect_text(node: &cranpose_ui::LayoutBox, results: &mut Vec<String>) {
376 if let Some(text) = node.node_data.modifier_slices().text_content() {
377 results.push(text.to_string());
378 }
379 for child in &node.children {
380 collect_text(child, results);
381 }
382 }
383
384 let mut results = Vec::new();
385 collect_text(layout.root(), &mut results);
386 results
387}
388
389fn extract_rects_from_layout(layout: &LayoutTree) -> Vec<(Rect, Option<String>)> {
391 fn collect_rects(node: &cranpose_ui::LayoutBox, results: &mut Vec<(Rect, Option<String>)>) {
392 let text = node
394 .node_data
395 .modifier_slices()
396 .text_content()
397 .map(|s| s.to_string());
398
399 let rect = Rect {
401 x: node.rect.x,
402 y: node.rect.y,
403 width: node.rect.width,
404 height: node.rect.height,
405 };
406
407 results.push((rect, text));
408
409 for child in &node.children {
411 collect_rects(child, results);
412 }
413 }
414
415 let mut results = Vec::new();
416 collect_rects(layout.root(), &mut results);
417 results
418}
419
420#[derive(Default)]
425pub struct TestRenderer {
426 scene: TestScene,
427 text_measurer: Option<Rc<dyn TextMeasurer>>,
428}
429
430impl TestRenderer {
431 pub fn with_text_measurer(text_measurer: Rc<dyn TextMeasurer>) -> Self {
432 Self {
433 scene: TestScene,
434 text_measurer: Some(text_measurer),
435 }
436 }
437}
438
439impl Renderer for TestRenderer {
440 type Scene = TestScene;
441 type Error = ();
442
443 fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
444 if let Some(text_measurer) = &self.text_measurer {
445 app_context.set_text_measurer_rc(Rc::clone(text_measurer));
446 }
447 }
448
449 fn scene(&self) -> &Self::Scene {
450 &self.scene
451 }
452
453 fn scene_mut(&mut self) -> &mut Self::Scene {
454 &mut self.scene
455 }
456
457 fn rebuild_scene(
458 &mut self,
459 _layout_tree: &LayoutTree,
460 _viewport: Size,
461 ) -> Result<(), Self::Error> {
462 Ok(())
463 }
464
465 fn rebuild_scene_from_applier(
466 &mut self,
467 _applier: &mut cranpose_core::MemoryApplier,
468 _root: cranpose_core::NodeId,
469 _viewport: Size,
470 ) -> Result<(), Self::Error> {
471 Ok(())
472 }
473}
474
475#[derive(Default)]
477pub struct TestScene;
478
479impl RenderScene for TestScene {
480 type HitTarget = TestHitTarget;
481
482 fn clear(&mut self) {}
483
484 fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
485 vec![TestHitTarget]
486 }
487
488 fn find_target(&self, _node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
489 None
490 }
491}
492
493#[derive(Default, Clone)]
495pub struct TestHitTarget;
496
497impl HitTestTarget for TestHitTarget {
498 fn dispatch(&self, _event: PointerEvent) {}
499
500 fn node_id(&self) -> cranpose_core::NodeId {
501 0
502 }
503}
504
505pub fn create_headless_robot_test<F>(
509 width: u32,
510 height: u32,
511 content: F,
512) -> RobotTestRule<TestRenderer>
513where
514 F: FnMut() + 'static,
515{
516 RobotTestRule::new(width, height, TestRenderer::default(), content)
517}
518
519pub fn create_headless_robot_test_with_text_measurer<F>(
521 width: u32,
522 height: u32,
523 text_measurer: Rc<dyn TextMeasurer>,
524 content: F,
525) -> RobotTestRule<TestRenderer>
526where
527 F: FnMut() + 'static,
528{
529 RobotTestRule::new(
530 width,
531 height,
532 TestRenderer::with_text_measurer(text_measurer),
533 content,
534 )
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn test_robot_creation() {
543 let robot = create_headless_robot_test(800, 600, || {
545 });
547
548 assert_eq!(robot.viewport_size(), (800, 600));
549 }
550
551 #[test]
552 fn test_robot_click() {
553 let mut robot = create_headless_robot_test(800, 600, || {
554 });
556
557 robot.click_at(100.0, 100.0);
559 }
560
561 #[test]
562 fn test_robot_drag() {
563 let mut robot = create_headless_robot_test(800, 600, || {
564 });
566
567 robot.drag(0.0, 0.0, 100.0, 100.0);
569 }
570
571 #[test]
572 fn robot_advance_time_uses_supplied_frame_delta() {
573 let mut robot = create_headless_robot_test(800, 600, || {});
574
575 robot.advance_time(16_000_000);
576 assert_eq!(robot.frame_time_nanos(), 16_000_000);
577
578 robot.advance_time(8_000_000);
579 assert_eq!(robot.frame_time_nanos(), 24_000_000);
580 }
581}