use cranpose_app_shell::AppShell;
use cranpose_core::location_key;
use cranpose_foundation::PointerEvent;
use cranpose_render_common::{HitTestTarget, RenderScene, Renderer};
use cranpose_ui::{LayoutTree, TextMeasurer};
use cranpose_ui_graphics::{Point, Rect, Size};
use std::rc::Rc;
pub struct RobotTestRule<R>
where
R: Renderer,
{
shell: AppShell<R>,
frame_time_nanos: u64,
}
impl<R> RobotTestRule<R>
where
R: Renderer,
R::Error: std::fmt::Debug,
{
pub fn new(width: u32, height: u32, renderer: R, content: impl FnMut() + 'static) -> Self {
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(renderer, root_key, content);
shell.set_viewport(width as f32, height as f32);
shell.set_buffer_size(width, height);
Self {
shell,
frame_time_nanos: 0,
}
}
pub fn viewport_size(&self) -> (u32, u32) {
self.shell.buffer_size()
}
pub fn set_viewport(&mut self, width: u32, height: u32) {
self.shell.set_viewport(width as f32, height as f32);
self.shell.set_buffer_size(width, height);
}
pub fn advance_time(&mut self, nanos: u64) {
self.frame_time_nanos = self.frame_time_nanos.saturating_add(nanos);
self.shell.update_at_frame_time_nanos(self.frame_time_nanos);
}
pub fn frame_time_nanos(&self) -> u64 {
self.frame_time_nanos
}
pub fn wait_for_idle(&mut self) {
for _ in 0..10 {
self.shell.update();
if !self.shell.needs_redraw() {
break;
}
}
}
pub fn click_at(&mut self, x: f32, y: f32) -> bool {
self.shell.set_cursor(x, y);
self.shell.pointer_pressed();
self.shell.pointer_released();
self.wait_for_idle();
true
}
pub fn move_to(&mut self, x: f32, y: f32) -> bool {
let hit = self.shell.set_cursor(x, y);
self.wait_for_idle();
hit
}
pub fn drag(&mut self, from_x: f32, from_y: f32, to_x: f32, to_y: f32) {
self.shell.set_cursor(from_x, from_y);
self.shell.pointer_pressed();
let steps = 10;
for i in 1..=steps {
let t = i as f32 / steps as f32;
let x = from_x + (to_x - from_x) * t;
let y = from_y + (to_y - from_y) * t;
self.shell.set_cursor(x, y);
self.shell.update();
}
self.shell.pointer_released();
self.wait_for_idle();
}
pub fn mouse_move(&mut self, x: f32, y: f32) {
self.shell.set_cursor(x, y);
self.shell.update();
}
pub fn mouse_down(&mut self) {
self.shell.pointer_pressed();
self.shell.update();
}
pub fn mouse_up(&mut self) {
self.shell.pointer_released();
self.shell.update();
}
pub fn find_by_text(&mut self, text: &str) -> ElementFinder<'_, R> {
self.wait_for_idle();
ElementFinder {
robot: self,
query: FinderQuery::Text(text.to_string()),
}
}
pub fn find_at_position(&mut self, x: f32, y: f32) -> ElementFinder<'_, R> {
self.wait_for_idle();
ElementFinder {
robot: self,
query: FinderQuery::Position(x, y),
}
}
pub fn find_clickable(&mut self) -> ElementFinder<'_, R> {
self.wait_for_idle();
ElementFinder {
robot: self,
query: FinderQuery::Clickable,
}
}
pub fn get_all_text(&mut self) -> Vec<String> {
self.wait_for_idle();
self.shell.with_layout_tree(|layout_tree| {
layout_tree
.map(extract_text_from_layout)
.unwrap_or_default()
})
}
pub fn get_all_rects(&mut self) -> Vec<(Rect, Option<String>)> {
self.wait_for_idle();
self.shell.with_layout_tree(|layout_tree| {
layout_tree
.map(extract_rects_from_layout)
.unwrap_or_default()
})
}
pub fn dump_screen(&mut self) {
self.shell.log_debug_info();
}
pub fn shell_mut(&mut self) -> &mut AppShell<R> {
&mut self.shell
}
fn get_scene(&self) -> &R::Scene {
self.shell.scene()
}
}
#[derive(Clone, Debug)]
enum FinderQuery {
Text(String),
Position(f32, f32),
Clickable,
}
pub struct ElementFinder<'a, R>
where
R: Renderer,
{
robot: &'a mut RobotTestRule<R>,
query: FinderQuery,
}
impl<'a, R> ElementFinder<'a, R>
where
R: Renderer,
R::Error: std::fmt::Debug,
{
pub fn exists(&mut self) -> bool {
match &self.query {
FinderQuery::Text(text) => {
let all_text = self.robot.get_all_text();
all_text.iter().any(|t| t.contains(text))
}
FinderQuery::Position(x, y) => !self.robot.get_scene().hit_test(*x, *y).is_empty(),
FinderQuery::Clickable => {
true }
}
}
pub fn bounds(&mut self) -> Option<Rect> {
match &self.query {
FinderQuery::Text(text) => {
let rects = self.robot.get_all_rects();
rects
.into_iter()
.find(|(_, txt)| txt.as_ref().is_some_and(|t| t.contains(text)))
.map(|(rect, _)| rect)
}
FinderQuery::Position(_x, _y) => {
None }
FinderQuery::Clickable => None,
}
}
pub fn center(&mut self) -> Option<Point> {
self.bounds().map(|rect| Point {
x: rect.x + rect.width / 2.0,
y: rect.y + rect.height / 2.0,
})
}
pub fn width(&mut self) -> Option<f32> {
self.bounds().map(|rect| rect.width)
}
pub fn height(&mut self) -> Option<f32> {
self.bounds().map(|rect| rect.height)
}
pub fn click(&mut self) -> bool {
if let Some(center) = self.center() {
self.robot.click_at(center.x, center.y);
true
} else {
false
}
}
pub fn long_press(&mut self) -> bool {
if let Some(center) = self.center() {
self.robot.shell_mut().set_cursor(center.x, center.y);
self.robot.shell_mut().pointer_pressed();
for _ in 0..50 {
self.robot.shell_mut().update();
}
self.robot.shell_mut().pointer_released();
self.robot.wait_for_idle();
true
} else {
false
}
}
pub fn assert_exists(&mut self) {
assert!(self.exists(), "Element not found: {:?}", self.query);
}
pub fn assert_not_exists(&mut self) {
assert!(
!self.exists(),
"Element unexpectedly found: {:?}",
self.query
);
}
}
fn extract_text_from_layout(layout: &LayoutTree) -> Vec<String> {
fn collect_text(node: &cranpose_ui::LayoutBox, results: &mut Vec<String>) {
if let Some(text) = node.node_data.modifier_slices().text_content() {
results.push(text.to_string());
}
for child in &node.children {
collect_text(child, results);
}
}
let mut results = Vec::new();
collect_text(layout.root(), &mut results);
results
}
fn extract_rects_from_layout(layout: &LayoutTree) -> Vec<(Rect, Option<String>)> {
fn collect_rects(node: &cranpose_ui::LayoutBox, results: &mut Vec<(Rect, Option<String>)>) {
let text = node
.node_data
.modifier_slices()
.text_content()
.map(|s| s.to_string());
let rect = Rect {
x: node.rect.x,
y: node.rect.y,
width: node.rect.width,
height: node.rect.height,
};
results.push((rect, text));
for child in &node.children {
collect_rects(child, results);
}
}
let mut results = Vec::new();
collect_rects(layout.root(), &mut results);
results
}
#[derive(Default)]
pub struct TestRenderer {
scene: TestScene,
text_measurer: Option<Rc<dyn TextMeasurer>>,
}
impl TestRenderer {
pub fn with_text_measurer(text_measurer: Rc<dyn TextMeasurer>) -> Self {
Self {
scene: TestScene,
text_measurer: Some(text_measurer),
}
}
}
impl Renderer for TestRenderer {
type Scene = TestScene;
type Error = ();
fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
if let Some(text_measurer) = &self.text_measurer {
app_context.set_text_measurer_rc(Rc::clone(text_measurer));
}
}
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
_layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
_applier: &mut cranpose_core::MemoryApplier,
_root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
}
#[derive(Default)]
pub struct TestScene;
impl RenderScene for TestScene {
type HitTarget = TestHitTarget;
fn clear(&mut self) {}
fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
vec![TestHitTarget]
}
fn find_target(&self, _node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
None
}
}
#[derive(Default, Clone)]
pub struct TestHitTarget;
impl HitTestTarget for TestHitTarget {
fn dispatch(&self, _event: PointerEvent) {}
fn node_id(&self) -> cranpose_core::NodeId {
0
}
}
pub fn create_headless_robot_test<F>(
width: u32,
height: u32,
content: F,
) -> RobotTestRule<TestRenderer>
where
F: FnMut() + 'static,
{
RobotTestRule::new(width, height, TestRenderer::default(), content)
}
pub fn create_headless_robot_test_with_text_measurer<F>(
width: u32,
height: u32,
text_measurer: Rc<dyn TextMeasurer>,
content: F,
) -> RobotTestRule<TestRenderer>
where
F: FnMut() + 'static,
{
RobotTestRule::new(
width,
height,
TestRenderer::with_text_measurer(text_measurer),
content,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_robot_creation() {
let robot = create_headless_robot_test(800, 600, || {
});
assert_eq!(robot.viewport_size(), (800, 600));
}
#[test]
fn test_robot_click() {
let mut robot = create_headless_robot_test(800, 600, || {
});
robot.click_at(100.0, 100.0);
}
#[test]
fn test_robot_drag() {
let mut robot = create_headless_robot_test(800, 600, || {
});
robot.drag(0.0, 0.0, 100.0, 100.0);
}
#[test]
fn robot_advance_time_uses_supplied_frame_delta() {
let mut robot = create_headless_robot_test(800, 600, || {});
robot.advance_time(16_000_000);
assert_eq!(robot.frame_time_nanos(), 16_000_000);
robot.advance_time(8_000_000);
assert_eq!(robot.frame_time_nanos(), 24_000_000);
}
}