use crate::zoom::ZoomState;
use crate::{collect_modifier_slices, Modifier};
use cranpose_core::{DefaultScheduler, Runtime};
use cranpose_foundation::{
BasicModifierNodeContext, ModifierNodeChain, PointerButton, PointerButtons, PointerEvent,
PointerEventKind,
};
use cranpose_ui_graphics::Point;
use std::rc::Rc;
use std::sync::Arc;
fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
let _runtime = Runtime::new(Arc::new(DefaultScheduler));
f()
}
fn pointer_handler_for(modifier: Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeChain) {
let elements = modifier.elements();
let mut chain = ModifierNodeChain::new();
let mut context = BasicModifierNodeContext::new();
chain.update_from_slice(&elements, &mut context);
let slices = collect_modifier_slices(&chain);
let handler = slices
.pointer_inputs()
.first()
.cloned()
.expect("zoomable modifier should provide pointer input handler");
(handler, chain)
}
fn touch(kind: PointerEventKind, id: u64, x: f32, y: f32) -> PointerEvent {
PointerEvent::new(kind, Point { x, y }, Point { x, y })
.with_buttons(PointerButtons::new().with(PointerButton::Primary))
.with_id(id)
}
#[test]
fn apply_transform_zooms_about_the_centroid() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::with_scale_range(0.5, 8.0);
state.apply_transform(Point { x: 100.0, y: 50.0 }, Point { x: 0.0, y: 0.0 }, 2.0);
assert_eq!(state.scale_non_reactive(), 2.0);
let offset = state.offset_non_reactive();
assert!(
(100.0 * 2.0 + offset.x - 100.0).abs() < 1e-4
&& (50.0 * 2.0 + offset.y - 50.0).abs() < 1e-4,
"focal point must stay fixed, offset={offset:?}"
);
});
}
#[test]
fn apply_transform_focal_point_stays_fixed_across_steps() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::with_scale_range(0.5, 8.0);
let focal = Point { x: 40.0, y: 200.0 };
let content = focal; for zoom_step in [1.3f32, 1.3, 0.9, 1.1] {
state.apply_transform(focal, Point { x: 0.0, y: 0.0 }, zoom_step);
let scale = state.scale_non_reactive();
let offset = state.offset_non_reactive();
let display = Point {
x: content.x * scale + offset.x,
y: content.y * scale + offset.y,
};
assert!(
(display.x - focal.x).abs() < 1e-3 && (display.y - focal.y).abs() < 1e-3,
"focal content point drifted to {display:?} at scale {scale}"
);
}
});
}
#[test]
fn apply_transform_clamps_scale_to_range() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::with_scale_range(1.0, 4.0);
state.apply_transform(Point { x: 0.0, y: 0.0 }, Point { x: 0.0, y: 0.0 }, 100.0);
assert_eq!(state.scale_non_reactive(), 4.0);
state.apply_transform(Point { x: 0.0, y: 0.0 }, Point { x: 0.0, y: 0.0 }, 0.0001);
assert_eq!(state.scale_non_reactive(), 1.0);
});
}
#[test]
fn apply_transform_pans_by_the_gesture_delta() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::with_scale_range(0.5, 8.0);
state.set_scale(2.0);
state.apply_transform(Point { x: 0.0, y: 0.0 }, Point { x: 15.0, y: -7.0 }, 1.0);
assert_eq!(state.offset_non_reactive(), Point { x: 15.0, y: -7.0 });
assert_eq!(state.scale_non_reactive(), 2.0);
});
}
#[test]
fn reset_restores_identity() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::new();
state.apply_transform(Point { x: 10.0, y: 10.0 }, Point { x: 5.0, y: 5.0 }, 3.0);
assert!(state.is_transformed());
state.reset();
assert!(!state.is_transformed());
assert_eq!(state.scale_non_reactive(), 1.0);
assert_eq!(state.offset_non_reactive(), Point { x: 0.0, y: 0.0 });
});
}
#[test]
fn layer_reflects_scale_offset_with_top_left_origin() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::new();
state.set_scale(2.5);
state.set_offset(Point { x: -30.0, y: 12.0 });
let layer = state.layer();
assert_eq!(layer.scale_x, 2.5);
assert_eq!(layer.scale_y, 2.5);
assert_eq!(layer.translation_x, -30.0);
assert_eq!(layer.translation_y, 12.0);
assert_eq!(layer.transform_origin.pivot_fraction_x, 0.0);
assert_eq!(layer.transform_origin.pivot_fraction_y, 0.0);
});
}
#[test]
fn two_finger_pinch_out_scales_up_and_consumes_moves() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::new();
let (handler, _chain) = pointer_handler_for(Modifier::empty().zoomable(state.clone()));
handler(touch(PointerEventKind::Down, 0, 100.0, 100.0));
handler(touch(PointerEventKind::Down, 1, 200.0, 100.0));
let pinch_move = touch(PointerEventKind::Move, 1, 300.0, 100.0);
handler(pinch_move.clone());
assert!(
(state.scale_non_reactive() - 2.0).abs() < 1e-4,
"doubling the spread must double the scale, got {}",
state.scale_non_reactive()
);
assert!(
pinch_move.is_consumed(),
"active pinch must consume move events"
);
handler(touch(PointerEventKind::Up, 1, 300.0, 100.0));
handler(touch(PointerEventKind::Up, 0, 100.0, 100.0));
});
}
#[test]
fn pinch_keeps_content_under_the_fingers() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::with_scale_range(1.0, 8.0);
let (handler, _chain) = pointer_handler_for(Modifier::empty().zoomable(state.clone()));
handler(touch(PointerEventKind::Down, 0, 100.0, 100.0));
handler(touch(PointerEventKind::Down, 1, 200.0, 100.0));
handler(touch(PointerEventKind::Move, 0, 50.0, 100.0));
handler(touch(PointerEventKind::Move, 1, 250.0, 100.0));
let scale = state.scale_non_reactive();
let offset = state.offset_non_reactive();
let display_x = 150.0 * scale + offset.x;
let display_y = 100.0 * scale + offset.y;
assert!(
(display_x - 150.0).abs() < 1.0 && (display_y - 100.0).abs() < 1.0,
"pinch center drifted: ({display_x}, {display_y}) at scale {scale}"
);
assert!(scale > 1.5, "symmetric spread must zoom in, got {scale}");
});
}
#[test]
fn secondary_pointer_down_and_up_are_consumed() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::new();
let (handler, _chain) = pointer_handler_for(Modifier::empty().zoomable(state.clone()));
handler(touch(PointerEventKind::Down, 0, 100.0, 100.0));
let secondary_down = touch(PointerEventKind::Down, 1, 200.0, 100.0);
handler(secondary_down.clone());
assert!(
secondary_down.is_consumed(),
"secondary finger down must not leak to single-pointer handlers"
);
let secondary_up = touch(PointerEventKind::Up, 1, 200.0, 100.0);
handler(secondary_up.clone());
assert!(
secondary_up.is_consumed(),
"secondary finger up must not leak to single-pointer handlers"
);
});
}
#[test]
fn single_finger_drag_pans_only_when_transformed() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::new();
let (handler, _chain) = pointer_handler_for(Modifier::empty().zoomable(state.clone()));
handler(touch(PointerEventKind::Down, 0, 100.0, 100.0));
let plain_drag = touch(PointerEventKind::Move, 0, 100.0, 160.0);
handler(plain_drag.clone());
handler(touch(PointerEventKind::Up, 0, 100.0, 160.0));
assert_eq!(state.offset_non_reactive(), Point { x: 0.0, y: 0.0 });
assert!(
!plain_drag.is_consumed(),
"identity zoomable must not steal plain drags"
);
state.set_scale(2.0);
handler(touch(PointerEventKind::Down, 0, 100.0, 100.0));
handler(touch(PointerEventKind::Move, 0, 100.0, 120.0)); handler(touch(PointerEventKind::Move, 0, 100.0, 160.0));
handler(touch(PointerEventKind::Up, 0, 100.0, 160.0));
let offset = state.offset_non_reactive();
assert!(
offset.y > 30.0,
"zoomed content must pan with the finger, got {offset:?}"
);
});
}
#[test]
fn zoom_events_scale_about_the_cursor() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = ZoomState::with_scale_range(1.0, 8.0);
let (handler, _chain) = pointer_handler_for(Modifier::empty().zoomable(state.clone()));
let cursor = Point { x: 80.0, y: 60.0 };
let zoom_event =
PointerEvent::new(PointerEventKind::Zoom, cursor, cursor).with_zoom_delta(1.5);
handler(zoom_event.clone());
assert!(
(state.scale_non_reactive() - 1.5).abs() < 1e-4,
"ctrl+wheel step must multiply the scale, got {}",
state.scale_non_reactive()
);
assert!(zoom_event.is_consumed(), "zoom steps must be consumed");
let offset = state.offset_non_reactive();
let display_x = cursor.x * 1.5 + offset.x;
let display_y = cursor.y * 1.5 + offset.y;
assert!(
(display_x - cursor.x).abs() < 1e-3 && (display_y - cursor.y).abs() < 1e-3,
"wheel zoom must anchor at the cursor, got ({display_x}, {display_y})"
);
});
}
#[test]
fn scroll_ignores_secondary_pointer_events() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let scroll_state = crate::ScrollState::new(100.0);
scroll_state.set_max_value(400.0);
let (handler, _chain) =
pointer_handler_for(Modifier::empty().vertical_scroll(scroll_state.clone(), false));
handler(touch(PointerEventKind::Down, 0, 0.0, 100.0));
handler(touch(PointerEventKind::Move, 0, 0.0, 80.0)); let before = scroll_state.value_non_reactive();
handler(touch(PointerEventKind::Down, 1, 0.0, 300.0));
handler(touch(PointerEventKind::Move, 1, 0.0, 500.0));
handler(touch(PointerEventKind::Up, 1, 0.0, 500.0));
assert_eq!(
scroll_state.value_non_reactive(),
before,
"secondary pointer events must not scroll"
);
handler(touch(PointerEventKind::Move, 0, 0.0, 60.0));
assert!(
(scroll_state.value_non_reactive() - (before + 20.0)).abs() < 1e-3,
"primary drag must continue from its own last position"
);
handler(touch(PointerEventKind::Up, 0, 0.0, 60.0));
});
}