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, 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    #[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    /// 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 { shell, root_key }
60    }
61
62    /// Get the current viewport size.
63    pub fn viewport_size(&self) -> (u32, u32) {
64        self.shell.buffer_size()
65    }
66
67    /// Resize the viewport (simulates window resize).
68    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    /// Advance frame time by the given duration in nanoseconds.
74    ///
75    /// This is useful for testing animations and time-based behaviors.
76    pub fn advance_time(&mut self, _nanos: u64) {
77        // TODO: Actually advance time using the provided nanos parameter
78        self.shell.update();
79    }
80
81    /// Pump the app until it's idle (no pending updates).
82    ///
83    /// This ensures all compositions, layouts, and renders have completed.
84    pub fn wait_for_idle(&mut self) {
85        // Update multiple times to ensure everything settles
86        for _ in 0..10 {
87            self.shell.update();
88            if !self.shell.needs_redraw() {
89                break;
90            }
91        }
92    }
93
94    /// Perform a click at the given coordinates.
95    ///
96    /// Returns true if the click hit a UI element, false otherwise.
97    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    /// Move the cursor to the given coordinates.
106    ///
107    /// Returns true if the move hit a UI element, false otherwise.
108    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    /// Perform a drag from one point to another.
115    ///
116    /// This simulates a pointer down, move, and up sequence.
117    pub fn drag(&mut self, from_x: f32, from_y: f32, to_x: f32, to_y: f32) {
118        // Move to start position
119        self.shell.set_cursor(from_x, from_y);
120
121        // Press
122        self.shell.pointer_pressed();
123
124        // Move in steps to simulate smooth drag
125        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        // Release
135        self.shell.pointer_released();
136        self.wait_for_idle();
137    }
138
139    /// Move the mouse cursor to the given coordinates.
140    pub fn mouse_move(&mut self, x: f32, y: f32) {
141        self.shell.set_cursor(x, y);
142        self.shell.update();
143    }
144
145    /// Press the mouse button.
146    pub fn mouse_down(&mut self) {
147        self.shell.pointer_pressed();
148        self.shell.update();
149    }
150
151    /// Release the mouse button.
152    pub fn mouse_up(&mut self) {
153        self.shell.pointer_released();
154        self.shell.update();
155    }
156
157    /// Find an element by text content.
158    ///
159    /// Returns a finder that can be used to interact with or assert on the element.
160    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    /// Find an element at the given position.
169    ///
170    /// Returns a finder for the topmost element at that position.
171    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    /// Find all clickable elements.
180    ///
181    /// Returns a finder that matches all elements with clickable semantics.
182    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    /// Get all text content currently visible on screen.
191    ///
192    /// This is useful for debugging or asserting on overall screen state.
193    pub fn get_all_text(&mut self) -> Vec<String> {
194        self.wait_for_idle();
195
196        // Use HeadlessRenderer to extract text from the layout tree
197        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    /// Get all rectangles (bounds) of UI elements on screen.
205    ///
206    /// Returns a list of (bounds, optional_text) tuples.
207    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    /// Print debug information about the current screen state.
218    ///
219    /// This outputs the layout tree and render scene for debugging.
220    pub fn dump_screen(&mut self) {
221        self.shell.log_debug_info();
222    }
223
224    /// Get access to the underlying app shell for advanced scenarios.
225    pub fn shell_mut(&mut self) -> &mut AppShell<R> {
226        &mut self.shell
227    }
228
229    /// Get the layout tree if available.
230    fn get_layout_tree(&self) -> Option<&LayoutTree> {
231        self.shell.layout_tree()
232    }
233
234    /// Get the render scene for hit testing and queries.
235    fn get_scene(&self) -> &R::Scene {
236        self.shell.scene()
237    }
238}
239
240/// A query for finding UI elements.
241#[derive(Clone, Debug)]
242enum FinderQuery {
243    Text(String),
244    Position(f32, f32),
245    Clickable,
246}
247
248/// A finder for locating and interacting with UI elements.
249///
250/// Finders are created by calling methods on `RobotTestRule` like
251/// `find_by_text()` or `find_at_position()`.
252pub 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    /// Check if an element matching this query exists.
266    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                // Check if there are any clickable elements
275                // This would require semantics traversal
276                true // Placeholder
277            }
278        }
279    }
280
281    /// Get the bounds of the found element.
282    ///
283    /// Returns None if the element doesn't exist or doesn't have bounds.
284    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                // Get bounds from hit test
295                None // Placeholder
296            }
297            FinderQuery::Clickable => None,
298        }
299    }
300
301    /// Get the center point of the element.
302    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    /// Get the width of the element.
310    pub fn width(&mut self) -> Option<f32> {
311        self.bounds().map(|rect| rect.width)
312    }
313
314    /// Get the height of the element.
315    pub fn height(&mut self) -> Option<f32> {
316        self.bounds().map(|rect| rect.height)
317    }
318
319    /// Click on this element at its center.
320    ///
321    /// Returns true if the element was found and clicked.
322    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    /// Perform a long press on this element.
332    ///
333    /// This holds the pointer down for a duration before releasing.
334    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            // Hold for 500ms (simulated by multiple updates)
340            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    /// Assert that this element exists.
353    ///
354    /// Panics if the element is not found.
355    pub fn assert_exists(&mut self) {
356        assert!(self.exists(), "Element not found: {:?}", self.query);
357    }
358
359    /// Assert that this element does not exist.
360    ///
361    /// Panics if the element is found.
362    pub fn assert_not_exists(&mut self) {
363        assert!(
364            !self.exists(),
365            "Element unexpectedly found: {:?}",
366            self.query
367        );
368    }
369}
370
371/// Extract all text content from a layout tree.
372fn 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
387/// Extract all rectangles with optional text from a layout tree.
388fn 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        // Get the text content if present in modifier slices
391        let text = node
392            .node_data
393            .modifier_slices()
394            .text_content()
395            .map(|s| s.to_string());
396
397        // Get the rect, including content_offset for proper positioning
398        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        // Recurse into children
408        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/// A simple test renderer for robot tests.
419///
420/// This renderer doesn't actually render anything, but provides the
421/// Renderer trait implementation needed for testing.
422#[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/// The scene used by TestRenderer.
458#[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/// A hit target used by TestScene.
476#[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
487/// Create a headless robot test rule for testing without a real renderer.
488///
489/// This is useful for fast unit tests that don't need actual rendering.
490pub 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        // Create a simple headless robot test
508        let robot = create_headless_robot_test(800, 600, || {
509            // Empty app for testing
510        });
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            // Empty app
519        });
520
521        // Should not panic
522        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            // Empty app
529        });
530
531        // Should not panic
532        robot.drag(0.0, 0.0, 100.0, 100.0);
533    }
534}