Skip to main content

cranpose_testing/
robot.rs

1//! Robot testing framework for end-to-end app testing
2//!
3//! This module provides a robot-style testing API that allows developers to:
4//! - Launch real apps in a testing environment
5//! - Perform interactions (clicks, moves, drags)
6//! - Find and validate UI elements
7//! - Test the full app lifecycle
8//!
9//! # Example
10//!
11//! ```
12//! use cranpose_testing::robot::create_headless_robot_test;
13//!
14//! let mut robot = create_headless_robot_test(800, 600, || {
15//!     // Your composable app here
16//! });
17//!
18//! // Find and click a button
19//! robot.click_at(100.0, 100.0);
20//!
21//! // Wait for updates
22//! robot.wait_for_idle();
23//! ```
24
25use 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
33/// Main robot testing rule that provides programmatic control over a real app.
34///
35/// This is similar to Jetpack Compose's `ComposeTestRule` but for full app testing
36/// with real rendering and input simulation.
37pub 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    /// Create a new robot test rule with the given viewport size and app content.
51    ///
52    /// The app will be launched immediately with the provided dimensions.
53    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    /// Get the current viewport size.
66    pub fn viewport_size(&self) -> (u32, u32) {
67        self.shell.buffer_size()
68    }
69
70    /// Resize the viewport (simulates window resize).
71    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    /// Advance frame time by the given duration in nanoseconds.
77    ///
78    /// This is useful for testing animations and time-based behaviors.
79    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    /// Current deterministic frame time used by this robot.
85    pub fn frame_time_nanos(&self) -> u64 {
86        self.frame_time_nanos
87    }
88
89    /// Pump the app until it's idle (no pending updates).
90    ///
91    /// This ensures all compositions, layouts, and renders have completed.
92    pub fn wait_for_idle(&mut self) {
93        // Update multiple times to ensure everything settles
94        for _ in 0..10 {
95            self.shell.update();
96            if !self.shell.needs_redraw() {
97                break;
98            }
99        }
100    }
101
102    /// Perform a click at the given coordinates.
103    ///
104    /// Returns true if the click hit a UI element, false otherwise.
105    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    /// Move the cursor to the given coordinates.
114    ///
115    /// Returns true if the move hit a UI element, false otherwise.
116    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    /// Perform a drag from one point to another.
123    ///
124    /// This simulates a pointer down, move, and up sequence.
125    pub fn drag(&mut self, from_x: f32, from_y: f32, to_x: f32, to_y: f32) {
126        // Move to start position
127        self.shell.set_cursor(from_x, from_y);
128
129        // Press
130        self.shell.pointer_pressed();
131
132        // Move in steps to simulate smooth drag
133        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        // Release
143        self.shell.pointer_released();
144        self.wait_for_idle();
145    }
146
147    /// Move the mouse cursor to the given coordinates.
148    pub fn mouse_move(&mut self, x: f32, y: f32) {
149        self.shell.set_cursor(x, y);
150        self.shell.update();
151    }
152
153    /// Press the mouse button.
154    pub fn mouse_down(&mut self) {
155        self.shell.pointer_pressed();
156        self.shell.update();
157    }
158
159    /// Release the mouse button.
160    pub fn mouse_up(&mut self) {
161        self.shell.pointer_released();
162        self.shell.update();
163    }
164
165    /// Find an element by text content.
166    ///
167    /// Returns a finder that can be used to interact with or assert on the element.
168    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    /// Find an element at the given position.
177    ///
178    /// Returns a finder for the topmost element at that position.
179    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    /// Find all clickable elements.
188    ///
189    /// Returns a finder that matches all elements with clickable semantics.
190    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    /// Get all text content currently visible on screen.
199    ///
200    /// This is useful for debugging or asserting on overall screen state.
201    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    /// Get all rectangles (bounds) of UI elements on screen.
212    ///
213    /// Returns a list of (bounds, optional_text) tuples.
214    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    /// Print debug information about the current screen state.
225    ///
226    /// This outputs the layout tree and render scene for debugging.
227    pub fn dump_screen(&mut self) {
228        self.shell.log_debug_info();
229    }
230
231    /// Get access to the underlying app shell for advanced scenarios.
232    pub fn shell_mut(&mut self) -> &mut AppShell<R> {
233        &mut self.shell
234    }
235
236    /// Get the render scene for hit testing and queries.
237    fn get_scene(&self) -> &R::Scene {
238        self.shell.scene()
239    }
240}
241
242/// A query for finding UI elements.
243#[derive(Clone, Debug)]
244enum FinderQuery {
245    Text(String),
246    Position(f32, f32),
247    Clickable,
248}
249
250/// A finder for locating and interacting with UI elements.
251///
252/// Finders are created by calling methods on `RobotTestRule` like
253/// `find_by_text()` or `find_at_position()`.
254pub 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    /// Check if an element matching this query exists.
268    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                // Check if there are any clickable elements
277                // This would require semantics traversal
278                true // Placeholder
279            }
280        }
281    }
282
283    /// Get the bounds of the found element.
284    ///
285    /// Returns None if the element doesn't exist or doesn't have bounds.
286    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                // Get bounds from hit test
297                None // Placeholder
298            }
299            FinderQuery::Clickable => None,
300        }
301    }
302
303    /// Get the center point of the element.
304    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    /// Get the width of the element.
312    pub fn width(&mut self) -> Option<f32> {
313        self.bounds().map(|rect| rect.width)
314    }
315
316    /// Get the height of the element.
317    pub fn height(&mut self) -> Option<f32> {
318        self.bounds().map(|rect| rect.height)
319    }
320
321    /// Click on this element at its center.
322    ///
323    /// Returns true if the element was found and clicked.
324    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    /// Perform a long press on this element.
334    ///
335    /// This holds the pointer down for a duration before releasing.
336    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            // Hold for 500ms (simulated by multiple updates)
342            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    /// Assert that this element exists.
355    ///
356    /// Panics if the element is not found.
357    pub fn assert_exists(&mut self) {
358        assert!(self.exists(), "Element not found: {:?}", self.query);
359    }
360
361    /// Assert that this element does not exist.
362    ///
363    /// Panics if the element is found.
364    pub fn assert_not_exists(&mut self) {
365        assert!(
366            !self.exists(),
367            "Element unexpectedly found: {:?}",
368            self.query
369        );
370    }
371}
372
373/// Extract all text content from a layout tree.
374fn 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
389/// Extract all rectangles with optional text from a layout tree.
390fn 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        // Get the text content if present in modifier slices
393        let text = node
394            .node_data
395            .modifier_slices()
396            .text_content()
397            .map(|s| s.to_string());
398
399        // Get the rect, including content_offset for proper positioning
400        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        // Recurse into children
410        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/// A simple test renderer for robot tests.
421///
422/// This renderer doesn't actually render anything, but provides the
423/// Renderer trait implementation needed for testing.
424#[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/// The scene used by TestRenderer.
476#[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/// A hit target used by TestScene.
494#[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
505/// Create a headless robot test rule for testing without a real renderer.
506///
507/// This is useful for fast unit tests that don't need actual rendering.
508pub 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
519/// Create a headless robot test rule with an explicit text measurer.
520pub 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        // Create a simple headless robot test
544        let robot = create_headless_robot_test(800, 600, || {
545            // Empty app for testing
546        });
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            // Empty app
555        });
556
557        // Should not panic
558        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            // Empty app
565        });
566
567        // Should not panic
568        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}