cranpose-testing 0.9.0

Testing utilities and harness for Cranpose
use cranpose_app_shell::AppShell;
use cranpose_testing::{
    robot::TestRenderer,
    robot_assertions::{find_semantics_node as find, semantics_contain_text as contains_text},
};
use cranpose_ui::{LazyListScope, SemanticsNode, SemanticsRole};

fn has_text(shell: &mut AppShell<TestRenderer>, value: &str) -> bool {
    contains_text(shell.semantics_tree().expect("semantics").root(), value)
}

fn transformed_modifier(
    width: f32,
    height: f32,
    scale: impl Fn() -> f32 + 'static,
) -> cranpose_ui::Modifier {
    cranpose_ui::Modifier::empty()
        .size_points(width, height)
        .graphics_layer(move || cranpose_ui::GraphicsLayer {
            scale: scale(),
            translation_x: 20.0,
            translation_y: 30.0,
            transform_origin: cranpose_ui::TransformOrigin::new(0.0, 0.0),
            ..cranpose_ui::GraphicsLayer::default()
        })
}

#[test]
fn reported_window_bounds_reach_a_scaled_hover_target() {
    use std::{
        cell::{Cell, RefCell},
        rc::Rc,
    };

    use cranpose_ui::{
        Box, BoxSpec, Modifier, Rect, Text, TextStyle, collect_is_hovered_as_state,
        rememberMutableInteractionSource,
    };
    let bounds = Rc::new(Cell::new(Rect::from_size(cranpose_ui::Size::default())));
    let report = bounds.clone();
    let mouse = Rc::new(RefCell::new(None));
    let mouse_slot = mouse.clone();
    let scale = Rc::new(Cell::new(None::<cranpose_core::MutableState<f32>>));
    let scale_slot = scale.clone();
    let mut shell = AppShell::new_with_size(
        TestRenderer::default(),
        cranpose_core::location_key(file!(), line!(), column!()),
        move || {
            let report = report.clone();
            let current_scale = cranpose_core::rememberMutableStateOf(|| 0.5);
            scale_slot.set(Some(current_scale));
            *mouse_slot.borrow_mut() = Some(cranpose_ui::mouse_input::local_mouse_input());
            Box(
                transformed_modifier(200.0, 200.0, move || current_scale.get()),
                BoxSpec::default(),
                move || {
                    let interaction = rememberMutableInteractionSource();
                    let hovered = collect_is_hovered_as_state(&interaction);
                    Box(
                        Modifier::empty()
                            .offset(40.0, 20.0)
                            .size_points(40.0, 40.0)
                            .report_window_rect(report.clone())
                            .hoverable(interaction, true),
                        BoxSpec::default(),
                        move || {
                            Text(
                                if hovered.get() { "hovered" } else { "idle" },
                                Modifier::empty(),
                                TextStyle::default(),
                            );
                        },
                    );
                },
            );
        },
        (200, 200),
        (200.0, 200.0),
    );
    shell.set_semantics_enabled(true);
    shell.update();
    let mouse = mouse.borrow().as_ref().expect("app mouse input").clone();
    for value in [0.5, 1.0] {
        shell.app_context().enter(|| {
            scale.get().expect("layer scale").set(value);
        });
        shell.update();
        let rect = bounds.get();
        mouse.move_to(
            cranpose_ui::mouse_input::MouseInputTarget::Primary,
            cranpose_ui::Point {
                x: rect.x + rect.width / 2.0,
                y: rect.y + rect.height / 2.0,
            },
        );
        shell.update();
        assert!(
            has_text(&mut shell, "hovered"),
            "window coordinates reach the displayed target at scale {value}"
        );
        mouse.move_to(
            cranpose_ui::mouse_input::MouseInputTarget::Primary,
            cranpose_ui::Point { x: 190.0, y: 190.0 },
        );
        shell.update();
        assert!(
            has_text(&mut shell, "idle"),
            "injected mouse movement also dispatches exit"
        );
    }
}

fn focused_field(scale: f32, decorated: bool) -> AppShell<TestRenderer> {
    use cranpose_foundation::text::TextFieldState;
    use cranpose_ui::{
        BasicTextField, BasicTextFieldDecorated, BasicTextFieldOptions, Box, BoxSpec, Modifier,
        TextStyle,
    };
    let mut shell = AppShell::new_with_size(
        TestRenderer::default(),
        cranpose_core::location_key(file!(), line!(), column!()),
        move || {
            Box(
                transformed_modifier(200.0, 100.0, move || scale),
                BoxSpec::default(),
                move || {
                    let state =
                        cranpose_core::remember(|| TextFieldState::new("ABC")).with(|value| *value);
                    let modifier = Modifier::empty().size_points(180.0, 40.0);
                    if decorated {
                        BasicTextFieldDecorated(
                            state,
                            modifier,
                            BasicTextFieldOptions::default(),
                            |scope| {
                                scope.inner_text_field();
                            },
                        );
                    } else {
                        BasicTextField(state, modifier, TextStyle::default());
                    }
                },
            );
        },
        (300, 200),
        (300.0, 200.0),
    );
    shell.set_semantics_enabled(true);
    shell.update();
    let field = find(
        shell.semantics_tree().expect("field semantics").root(),
        &|node| node.details().editable_text,
    )
    .expect("editable field")
    .node_id;
    assert!(shell.accessibility_activate(field, None));
    shell.update();
    shell
}

#[test]
fn native_caret_geometry_follows_the_rendered_field_scale() {
    let mut normal = focused_field(1.0, false);
    let mut scaled = focused_field(0.5, false);
    let normal = normal.ime_caret_geometry().expect("normal caret geometry");
    let scaled = scaled.ime_caret_geometry().expect("scaled caret geometry");
    assert!(
        (scaled.caret_rect(0).height - normal.caret_rect(0).height * 0.5).abs() < 0.01,
        "native IME caret height follows the displayed text"
    );
}

#[test]
fn scaled_scroll_container_reveals_a_target_in_window_coordinates() {
    use std::{
        cell::{Cell, RefCell},
        rc::Rc,
    };

    use cranpose_ui::{
        Box, BoxSpec, LazyColumn, LazyColumnSpec, LazyItems, Modifier, Rect, Size, Text, TextStyle,
        rememberLazyListState,
    };
    let target = Rc::new(Cell::new(Rect::from_size(Size::default())));
    let responder = Rc::new(RefCell::new(None::<cranpose_ui::BringIntoViewResponder>));
    let content_target = target.clone();
    let content_responder = responder.clone();
    let mut shell = AppShell::new_with_size(
        TestRenderer::default(),
        cranpose_core::location_key(file!(), line!(), column!()),
        move || {
            let target = content_target.clone();
            let responder = content_responder.clone();
            Box(
                transformed_modifier(200.0, 100.0, || 0.5),
                BoxSpec::default(),
                move || {
                    let target = target.clone();
                    let responder = responder.clone();
                    LazyColumn(
                        Modifier::empty().fill_max_size(),
                        rememberLazyListState(),
                        LazyColumnSpec::new(),
                        move |scope| {
                            let target = target.clone();
                            scope.items(LazyItems::new(20), move |index| {
                                *responder.borrow_mut() =
                                    cranpose_ui::local_bring_into_view_responder().current();
                                let modifier = Modifier::empty().height(32.0);
                                let modifier = if index == 2 {
                                    modifier.report_window_rect(target.clone())
                                } else {
                                    modifier
                                };
                                Text(format!("row {index}"), modifier, TextStyle::default());
                            });
                        },
                    );
                },
            );
        },
        (300, 200),
        (300.0, 200.0),
    );
    let context = Rc::clone(shell.app_context());
    context.enter(|| {
        responder
            .borrow()
            .as_ref()
            .expect("scroll responder")
            .bring_into_view(target.get(), 0.0);
    });
    shell.update();
    let target = target.get();
    assert!(
        target.y + target.height <= 80.0 - 12.0 + 0.1,
        "scaled scrolling puts the target above the visible bottom margin: {target:?}"
    );
}

#[test]
fn decorated_field_exposes_pasted_text_to_accessibility() {
    let mut shell = focused_field(0.5, true);
    assert!(shell.on_paste("Z"));
    shell.update();
    let tree = shell.semantics_tree().expect("field semantics");
    let field = find(tree.root(), &|node| node.details().editable_text).expect("editable field");
    assert!(field.text.as_deref().is_some_and(|text| text.contains('Z')));
    assert!(
        !field.actions.is_empty(),
        "the editable field exposes its actions"
    );
    assert_eq!(
        editable_fields(tree.root()),
        1,
        "accessibility exposes one editable field"
    );
}

fn editable_fields(node: &SemanticsNode) -> usize {
    usize::from(node.details().editable_text)
        + node.children.iter().map(editable_fields).sum::<usize>()
}

#[test]
fn text_preserves_its_explicit_accessibility_description() {
    let mut shell = AppShell::new_with_size(
        TestRenderer::default(),
        cranpose_core::location_key(file!(), line!(), column!()),
        || {
            cranpose_ui::Text(
                "264.000",
                cranpose_ui::Modifier::empty().semantics(|config| {
                    config.content_description = Some("Selected quote price".to_string());
                }),
                cranpose_ui::TextStyle::default(),
            );
        },
        (300, 200),
        (300.0, 200.0),
    );
    shell.set_semantics_enabled(true);
    shell.update();
    let tree = shell.semantics_tree().expect("text semantics");
    let text = find(tree.root(), &|node| {
        node.description.as_deref() == Some("Selected quote price")
    })
    .expect("the explicit accessibility label remains available");
    assert!(matches!(&text.role, SemanticsRole::Text { value } if value.as_str() == "264.000"));
}