cranpose-testing 0.1.14

Testing utilities and harness for Cranpose
Documentation
//! End-to-end test simulating the full pointer input pipeline from
//! composition → layout → rendering → hit-testing → event dispatch

use cranpose_core::MutableState;
use cranpose_foundation::PointerEventKind;
use cranpose_macros::composable;
use cranpose_testing::ComposeTestRule;
use cranpose_ui::*;

#[composable]
fn test_hover_app(position: MutableState<Point>, event_count: MutableState<i32>) {
    Column(
        Modifier::empty()
            .padding(20.0)
            .then(Modifier::empty().size(Size {
                width: 200.0,
                height: 200.0,
            }))
            .then(Modifier::empty().pointer_input((), {
                let pos = position;
                let count = event_count;
                move |scope: PointerInputScope| {
                    async move {
                        // Log that we started
                        count.set(-1); // -1 means "started but no events yet"

                        scope
                            .await_pointer_event_scope(|await_scope| async move {
                                loop {
                                    let event = await_scope.await_pointer_event().await;
                                    if event.kind == PointerEventKind::Move {
                                        pos.set(Point {
                                            x: event.position.x,
                                            y: event.position.y,
                                        });
                                        count.update(|c| {
                                            if *c == -1 {
                                                *c = 1; // First event
                                            } else {
                                                *c += 1;
                                            }
                                        });
                                    }
                                }
                            })
                            .await;
                    }
                }
            })),
        ColumnSpec::default(),
        || {
            Text(
                "Hover area",
                Modifier::empty().padding(8.0),
                TextStyle::default(),
            );
        },
    );
}

#[test]
fn test_pointer_input_async_handler_lifecycle() {
    let _app_context = cranpose_ui::AppContext::new();
    let _app_context_scope = _app_context.enter_scope();
    _app_context.enter(cranpose_ui::reset_render_state_for_tests);
    let mut rule = ComposeTestRule::new();
    let runtime = rule.runtime_handle();

    let position = MutableState::with_runtime(Point { x: 0.0, y: 0.0 }, runtime.clone());
    let event_count = MutableState::with_runtime(0, runtime.clone());

    rule.set_content({
        let pos = position;
        let count = event_count;
        move || {
            test_hover_app(pos, count);
        }
    })
    .expect("initial render succeeds");

    rule.pump_until_idle().expect("pump after initial render");

    assert_eq!(
        event_count.get(),
        -1,
        "async pointer handler should be attached and waiting for input"
    );
}

#[composable]
fn pause_button_app(is_running: MutableState<bool>, click_count: MutableState<i32>) {
    let running = is_running.get();
    let button_color = if running {
        Color(0.5, 0.2, 0.35, 1.0)
    } else {
        Color(0.2, 0.45, 0.9, 1.0)
    };

    Column(
        Modifier::empty().padding(20.0),
        ColumnSpec::default(),
        move || {
            Text(
                format!(
                    "Running: {}, Clicks: {}",
                    is_running.get(),
                    click_count.get()
                ),
                Modifier::empty().padding(8.0),
                TextStyle::default(),
            );

            // Recreate the pause button structure from the demo
            Button(
                Modifier::empty()
                    .rounded_corners(16.0)
                    .then(Modifier::empty().draw_behind({
                        let color = button_color;
                        move |scope| {
                            scope.draw_round_rect(Brush::solid(color), CornerRadii::uniform(16.0));
                        }
                    })),
                ButtonSpec::default(),
                {
                    move || {
                        is_running.set(!is_running.get());
                        click_count.set(click_count.get() + 1);
                    }
                },
                {
                    let label = if running { "Pause" } else { "Resume" };
                    move || {
                        Text(label, Modifier::empty().padding(6.0), TextStyle::default());
                    }
                },
            );
        },
    );
}

#[test]
fn test_pause_button_with_dynamic_content() {
    let _app_context = cranpose_ui::AppContext::new();
    let _app_context_scope = _app_context.enter_scope();
    _app_context.enter(cranpose_ui::reset_render_state_for_tests);
    let mut rule = ComposeTestRule::new();
    let runtime = rule.runtime_handle();

    let is_running = MutableState::with_runtime(true, runtime.clone());
    let click_count = MutableState::with_runtime(0, runtime.clone());

    rule.set_content({
        let running = is_running;
        let count = click_count;
        move || {
            pause_button_app(running, count);
        }
    })
    .expect("initial render succeeds");

    // Verify initial state
    assert!(is_running.get());
    assert_eq!(click_count.get(), 0);

    // The button's closure captures is_running and click_count
    // When the button is clicked (which we can't simulate here),
    // it should toggle is_running and increment click_count

    // Manually simulate what a click would do:
    is_running.set(false);
    click_count.set(1);

    rule.pump_until_idle()
        .expect("recompose after state change");

    // Verify state changed
    assert!(!is_running.get());
    assert_eq!(click_count.get(), 1);

    // Check that recomposition happened
    let node_count_after_first_toggle = rule.applier_mut().len();

    // Toggle again
    is_running.set(true);
    click_count.set(2);

    rule.pump_until_idle()
        .expect("recompose after second toggle");

    assert!(is_running.get());
    assert_eq!(click_count.get(), 2);

    let node_count_after_second_toggle = rule.applier_mut().len();

    // Node count should remain stable across toggles
    assert_eq!(
        node_count_after_first_toggle, node_count_after_second_toggle,
        "Node count should not change when toggling button state"
    );

    println!("✓ Pause button maintains stable structure through state changes");
}