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;
30use cranpose_ui_graphics::{Point, Rect, Size};
31
32/// Main robot testing rule that provides programmatic control over a real app.
33///
34/// This is similar to Jetpack Compose's `ComposeTestRule` but for full app testing
35/// with real rendering and input simulation.
36pub 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    /// Create a new robot test rule with the given viewport size and app content.
49    ///
50    /// The app will be launched immediately with the provided dimensions.
51    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    /// Get the current viewport size.
61    pub fn viewport_size(&self) -> (u32, u32) {
62        self.shell.buffer_size()
63    }
64
65    /// Resize the viewport (simulates window resize).
66    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    /// Advance frame time by the given duration in nanoseconds.
72    ///
73    /// This is useful for testing animations and time-based behaviors.
74    pub fn advance_time(&mut self, _nanos: u64) {
75        // TODO: Actually advance time using the provided nanos parameter
76        self.shell.update();
77    }
78
79    /// Pump the app until it's idle (no pending updates).
80    ///
81    /// This ensures all compositions, layouts, and renders have completed.
82    pub fn wait_for_idle(&mut self) {
83        // Update multiple times to ensure everything settles
84        for _ in 0..10 {
85            self.shell.update();
86            if !self.shell.needs_redraw() {
87                break;
88            }
89        }
90    }
91
92    /// Perform a click at the given coordinates.
93    ///
94    /// Returns true if the click hit a UI element, false otherwise.
95    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    /// Move the cursor to the given coordinates.
104    ///
105    /// Returns true if the move hit a UI element, false otherwise.
106    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    /// Perform a drag from one point to another.
113    ///
114    /// This simulates a pointer down, move, and up sequence.
115    pub fn drag(&mut self, from_x: f32, from_y: f32, to_x: f32, to_y: f32) {
116        // Move to start position
117        self.shell.set_cursor(from_x, from_y);
118
119        // Press
120        self.shell.pointer_pressed();
121
122        // Move in steps to simulate smooth drag
123        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        // Release
133        self.shell.pointer_released();
134        self.wait_for_idle();
135    }
136
137    /// Move the mouse cursor to the given coordinates.
138    pub fn mouse_move(&mut self, x: f32, y: f32) {
139        self.shell.set_cursor(x, y);
140        self.shell.update();
141    }
142
143    /// Press the mouse button.
144    pub fn mouse_down(&mut self) {
145        self.shell.pointer_pressed();
146        self.shell.update();
147    }
148
149    /// Release the mouse button.
150    pub fn mouse_up(&mut self) {
151        self.shell.pointer_released();
152        self.shell.update();
153    }
154
155    /// Find an element by text content.
156    ///
157    /// Returns a finder that can be used to interact with or assert on the element.
158    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    /// Find an element at the given position.
167    ///
168    /// Returns a finder for the topmost element at that position.
169    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    /// Find all clickable elements.
178    ///
179    /// Returns a finder that matches all elements with clickable semantics.
180    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    /// Get all text content currently visible on screen.
189    ///
190    /// This is useful for debugging or asserting on overall screen state.
191    pub fn get_all_text(&mut self) -> Vec<String> {
192        self.wait_for_idle();
193
194        // Use HeadlessRenderer to extract text from the layout tree
195        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    /// Get all rectangles (bounds) of UI elements on screen.
203    ///
204    /// Returns a list of (bounds, optional_text) tuples.
205    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    /// Print debug information about the current screen state.
216    ///
217    /// This outputs the layout tree and render scene for debugging.
218    pub fn dump_screen(&mut self) {
219        self.shell.log_debug_info();
220    }
221
222    /// Get access to the underlying app shell for advanced scenarios.
223    pub fn shell_mut(&mut self) -> &mut AppShell<R> {
224        &mut self.shell
225    }
226
227    /// Get the layout tree if available.
228    fn get_layout_tree(&mut self) -> Option<&LayoutTree> {
229        self.shell.layout_tree()
230    }
231
232    /// Get the render scene for hit testing and queries.
233    fn get_scene(&self) -> &R::Scene {
234        self.shell.scene()
235    }
236}
237
238/// A query for finding UI elements.
239#[derive(Clone, Debug)]
240enum FinderQuery {
241    Text(String),
242    Position(f32, f32),
243    Clickable,
244}
245
246/// A finder for locating and interacting with UI elements.
247///
248/// Finders are created by calling methods on `RobotTestRule` like
249/// `find_by_text()` or `find_at_position()`.
250pub 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    /// Check if an element matching this query exists.
264    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                // Check if there are any clickable elements
273                // This would require semantics traversal
274                true // Placeholder
275            }
276        }
277    }
278
279    /// Get the bounds of the found element.
280    ///
281    /// Returns None if the element doesn't exist or doesn't have bounds.
282    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                // Get bounds from hit test
293                None // Placeholder
294            }
295            FinderQuery::Clickable => None,
296        }
297    }
298
299    /// Get the center point of the element.
300    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    /// Get the width of the element.
308    pub fn width(&mut self) -> Option<f32> {
309        self.bounds().map(|rect| rect.width)
310    }
311
312    /// Get the height of the element.
313    pub fn height(&mut self) -> Option<f32> {
314        self.bounds().map(|rect| rect.height)
315    }
316
317    /// Click on this element at its center.
318    ///
319    /// Returns true if the element was found and clicked.
320    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    /// Perform a long press on this element.
330    ///
331    /// This holds the pointer down for a duration before releasing.
332    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            // Hold for 500ms (simulated by multiple updates)
338            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    /// Assert that this element exists.
351    ///
352    /// Panics if the element is not found.
353    pub fn assert_exists(&mut self) {
354        assert!(self.exists(), "Element not found: {:?}", self.query);
355    }
356
357    /// Assert that this element does not exist.
358    ///
359    /// Panics if the element is found.
360    pub fn assert_not_exists(&mut self) {
361        assert!(
362            !self.exists(),
363            "Element unexpectedly found: {:?}",
364            self.query
365        );
366    }
367}
368
369/// Extract all text content from a layout tree.
370fn 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
385/// Extract all rectangles with optional text from a layout tree.
386fn 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        // Get the text content if present in modifier slices
389        let text = node
390            .node_data
391            .modifier_slices()
392            .text_content()
393            .map(|s| s.to_string());
394
395        // Get the rect, including content_offset for proper positioning
396        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        // Recurse into children
406        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/// A simple test renderer for robot tests.
417///
418/// This renderer doesn't actually render anything, but provides the
419/// Renderer trait implementation needed for testing.
420#[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/// The scene used by TestRenderer.
456#[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/// A hit target used by TestScene.
474#[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
485/// Create a headless robot test rule for testing without a real renderer.
486///
487/// This is useful for fast unit tests that don't need actual rendering.
488pub 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        // Create a simple headless robot test
506        let robot = create_headless_robot_test(800, 600, || {
507            // Empty app for testing
508        });
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            // Empty app
517        });
518
519        // Should not panic
520        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            // Empty app
527        });
528
529        // Should not panic
530        robot.drag(0.0, 0.0, 100.0, 100.0);
531    }
532}