use super::support::*;
use crate::scroll::{ScrollAlignment, ScrollRequest};
use crate::tree::{virtual_list, virtual_list_dyn};
#[test]
fn hit_test_through_scrolled_content() {
let mut tree = scroll([
button("zero").key("b0").height(Size::Fixed(60.0)),
button("one").key("b1").height(Size::Fixed(60.0)),
button("two").key("b2").height(Size::Fixed(60.0)),
])
.key("list")
.height(Size::Fixed(100.0));
let mut state = UiState::new();
assign_ids(&mut tree);
state.scroll.offsets.insert(tree.computed_id.clone(), 60.0);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let r1 = find_rect(&tree, &state, "b1").expect("b1 rect");
let hit = hit_test(&tree, &state, (r1.center_x(), r1.center_y()));
assert_eq!(hit.as_deref(), Some("b1"));
let r0 = find_rect(&tree, &state, "b0").expect("b0 rect");
assert!(
r0.bottom() <= 0.0,
"b0 should be above the viewport, was {:?}",
r0
);
}
#[test]
fn pointer_wheel_routes_to_deepest_scrollable() {
let mut tree = scroll([
button("above").key("above").height(Size::Fixed(40.0)),
scroll([button("inner-row")
.key("inner-row")
.height(Size::Fixed(60.0))])
.key("inner")
.height(Size::Fixed(100.0)),
])
.key("outer")
.height(Size::Fixed(300.0));
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 300.0));
let inner_rect = find_rect(&tree, &state, "inner-row").expect("inner row rect");
let routed = state.pointer_wheel(&tree, (inner_rect.center_x(), inner_rect.center_y()), 30.0);
assert!(routed, "wheel should route to a scrollable");
let inner_id = find_id_for_kind(&tree, "inner").expect("inner id");
assert!(
state.scroll.offsets.contains_key(&inner_id),
"expected inner offset, got {:?}",
state.scroll.offsets.keys().collect::<Vec<_>>()
);
}
fn fixed_list_root(count: usize, row_height: f32) -> El {
virtual_list(count, row_height, |i| {
crate::widgets::text::text(format!("r{i}"))
})
.key("list")
}
fn dyn_list_root(count: usize, est: f32, row_h: f32) -> El {
virtual_list_dyn(count, est, move |i| {
column([crate::widgets::text::text(format!("r{i}"))])
.key(format!("row-{i}"))
.height(Size::Fixed(row_h))
})
.key("dyn-list")
}
#[test]
fn scroll_request_start_aligns_row_top_to_viewport_top() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
state.push_scroll_requests(vec![ScrollRequest::new("list", 10, ScrollAlignment::Start)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
let stored = state.scroll_offset(&tree.computed_id);
assert!(
(stored - 500.0).abs() < 0.5,
"expected offset 500, got {stored}"
);
}
#[test]
fn scroll_request_end_aligns_row_bottom_to_viewport_bottom() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
state.push_scroll_requests(vec![ScrollRequest::new("list", 10, ScrollAlignment::End)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
assert!((state.scroll_offset(&tree.computed_id) - 350.0).abs() < 0.5);
}
#[test]
fn scroll_request_center_centres_row_in_viewport() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
state.push_scroll_requests(vec![ScrollRequest::new(
"list",
10,
ScrollAlignment::Center,
)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
assert!((state.scroll_offset(&tree.computed_id) - 425.0).abs() < 0.5);
}
#[test]
fn scroll_request_visible_is_noop_when_row_already_in_viewport() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
assign_ids(&mut tree);
state.scroll.offsets.insert(tree.computed_id.clone(), 100.0);
state.push_scroll_requests(vec![ScrollRequest::new(
"list",
3,
ScrollAlignment::Visible,
)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
assert!((state.scroll_offset(&tree.computed_id) - 100.0).abs() < 0.5);
}
#[test]
fn scroll_request_visible_scrolls_min_distance_for_offscreen_row() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
assign_ids(&mut tree);
state.scroll.offsets.insert(tree.computed_id.clone(), 100.0);
state.push_scroll_requests(vec![ScrollRequest::new(
"list",
20,
ScrollAlignment::Visible,
)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
assert!((state.scroll_offset(&tree.computed_id) - 850.0).abs() < 0.5);
}
#[test]
fn scroll_request_clamps_to_max_offset() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
state.push_scroll_requests(vec![ScrollRequest::new("list", 49, ScrollAlignment::End)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
assert!((state.scroll_offset(&tree.computed_id) - 2300.0).abs() < 0.5);
}
#[test]
fn scroll_request_unknown_list_drops_silently() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
state.push_scroll_requests(vec![ScrollRequest::new(
"no-such-list",
10,
ScrollAlignment::Start,
)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
state.clear_pending_scroll_requests();
assert!((state.scroll_offset(&tree.computed_id) - 0.0).abs() < 0.5);
assert!(state.scroll.pending_requests.is_empty());
}
#[test]
fn scroll_request_out_of_range_row_drops_silently() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
state.push_scroll_requests(vec![ScrollRequest::new(
"list",
999,
ScrollAlignment::Start,
)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
assert!((state.scroll_offset(&tree.computed_id) - 0.0).abs() < 0.5);
assert!(state.scroll.pending_requests.is_empty());
}
#[test]
fn scroll_request_first_frame_works_without_prior_layout() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
state.push_scroll_requests(vec![ScrollRequest::new("list", 25, ScrollAlignment::Start)]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
assert!((state.scroll_offset(&tree.computed_id) - 1250.0).abs() < 0.5);
}
#[test]
fn scroll_request_resolves_against_dynamic_list_with_warm_cache() {
let mut tree = dyn_list_root(50, 50.0, 30.0);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
let measured_count = state
.scroll
.measured_row_heights
.get(&tree.computed_id)
.map(|m| m.len())
.unwrap_or(0);
assert!(
measured_count >= 7,
"expected first frame to measure ≥7 rows, got {measured_count}"
);
let id = tree.computed_id.clone();
let mut tree2 = dyn_list_root(50, 50.0, 30.0);
state.push_scroll_requests(vec![ScrollRequest::new(
"dyn-list",
10,
ScrollAlignment::Start,
)]);
layout(&mut tree2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
let expected = (measured_count.min(10)) as f32 * 30.0
+ (10_usize.saturating_sub(measured_count)) as f32 * 50.0;
let stored = state.scroll_offset(&id);
assert!(
(stored - expected).abs() < 0.5,
"expected offset {expected}, got {stored}"
);
}
#[test]
fn scroll_request_last_match_wins_when_multiple_target_same_list() {
let mut tree = fixed_list_root(50, 50.0);
let mut state = UiState::new();
state.push_scroll_requests(vec![
ScrollRequest::new("list", 5, ScrollAlignment::Start),
ScrollRequest::new("list", 30, ScrollAlignment::Start),
]);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
assert!((state.scroll_offset(&tree.computed_id) - 1500.0).abs() < 0.5);
}
fn chat_tree(rows: usize, row_h: f32, pin: bool) -> El {
let kids: Vec<El> = (0..rows)
.map(|i| {
button(format!("m{i}"))
.key(format!("m{i}"))
.height(Size::Fixed(row_h))
})
.collect();
let mut s = scroll(kids).key("chat").height(Size::Fixed(100.0));
if pin {
s = s.pin_end();
}
s
}
#[test]
fn pin_end_starts_at_tail_on_first_layout() {
let mut tree = chat_tree(4, 40.0, true);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let offset = state.scroll_offset(&tree.computed_id);
assert!(
(offset - 60.0).abs() < 0.5,
"expected first-frame offset = max_offset 60, got {offset}"
);
}
#[test]
fn without_pin_end_first_layout_starts_at_head() {
let mut tree = chat_tree(4, 40.0, false);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
assert!((state.scroll_offset(&tree.computed_id) - 0.0).abs() < 0.5);
}
#[test]
fn pin_end_follows_content_growth() {
let mut tree = chat_tree(3, 40.0, true);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
assert!((state.scroll_offset(&tree.computed_id) - 20.0).abs() < 0.5);
let mut tree = chat_tree(4, 40.0, true);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let offset = state.scroll_offset(&tree.computed_id);
assert!(
(offset - 60.0).abs() < 0.5,
"expected offset to track new tail 60, got {offset}"
);
}
#[test]
fn pin_end_releases_when_user_scrolls_up() {
let mut tree = chat_tree(5, 40.0, true);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
assert!((state.scroll_offset(&tree.computed_id) - 100.0).abs() < 0.5);
let center = (100.0, 50.0);
state.pointer_wheel(&tree, center, -40.0);
assert!((state.scroll_offset(&tree.computed_id) - 60.0).abs() < 0.5);
let mut tree = chat_tree(6, 40.0, true);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let offset = state.scroll_offset(&tree.computed_id);
assert!(
(offset - 60.0).abs() < 0.5,
"expected pin to release and offset to stay at 60, got {offset}"
);
}
#[test]
fn pin_end_re_engages_when_user_returns_to_tail() {
let mut tree = chat_tree(5, 40.0, true);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
state.pointer_wheel(&tree, (100.0, 50.0), -40.0);
let mut tree = chat_tree(5, 40.0, true);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
assert!((state.scroll_offset(&tree.computed_id) - 60.0).abs() < 0.5);
state.pointer_wheel(&tree, (100.0, 50.0), 40.0);
let mut tree = chat_tree(7, 40.0, true);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let offset = state.scroll_offset(&tree.computed_id);
assert!(
(offset - 180.0).abs() < 0.5,
"expected pin to re-engage and offset to track new tail 180, got {offset}"
);
}
#[test]
fn pin_end_survives_viewport_resize() {
let mut tree = chat_tree(5, 40.0, true);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
assert!((state.scroll_offset(&tree.computed_id) - 100.0).abs() < 0.5);
let mut tree = chat_tree(5, 40.0, true);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 150.0));
let offset = state.scroll_offset(&tree.computed_id);
assert!(
(offset - 50.0).abs() < 0.5,
"expected pin to follow viewport resize to new max 50, got {offset}"
);
}
#[test]
fn pin_end_off_does_not_follow_content_growth() {
let mut tree = chat_tree(3, 40.0, false);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
assert!((state.scroll_offset(&tree.computed_id) - 0.0).abs() < 0.5);
let mut tree = chat_tree(6, 40.0, false);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let offset = state.scroll_offset(&tree.computed_id);
assert!(
(offset - 0.0).abs() < 0.5,
"expected offset to stay at head without pin_end, got {offset}"
);
}
#[test]
fn pin_end_releases_on_ensure_visible_to_non_tail_anchor() {
let mut tree = chat_tree(6, 40.0, true);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
assert!((state.scroll_offset(&tree.computed_id) - 140.0).abs() < 0.5);
state.push_scroll_requests(vec![ScrollRequest::ensure_visible("chat", 0.0, 40.0)]);
let mut tree = chat_tree(6, 40.0, true);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let offset = state.scroll_offset(&tree.computed_id);
assert!(
offset < 60.0,
"EnsureVisible toward the head should release the pin and scroll up; got {offset}"
);
let mut tree = chat_tree(10, 40.0, true);
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let after = state.scroll_offset(&tree.computed_id);
assert!(
after < 100.0,
"expected pin released; growth should not drag offset to new tail, got {after}"
);
}
#[test]
fn pin_end_with_short_content_is_a_no_op_clamp() {
let mut tree = chat_tree(1, 40.0, true);
let mut state = UiState::new();
layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
assert!((state.scroll_offset(&tree.computed_id) - 0.0).abs() < 0.5);
}