use core::time::Duration;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use waterui_core::handler::AnyViewBuilder;
use waterui_core::id::SelfId;
use waterui_core::layout::Point;
use waterui_core::{AnyView, Dynamic};
use waterui_layout::scroll::{ScrollController, scroll};
use waterui_layout::stack::{VStack, vstack};
#[cfg(feature = "accessibility")]
use waterui::Binding;
use waterui::Str;
use waterui::ViewExt as _;
use waterui::component::list::{List, ListItem};
use waterui::component::text;
use waterui::prelude::{FlowAnimationPreset, flow_markdown};
use super::test_environment;
use crate::HeadlessRuntime;
use crate::platform::InputEvent;
const WINDOW_WIDTH: u32 = 400;
const WINDOW_HEIGHT: u32 = 640;
const ROW_HEIGHT: f32 = 44.0;
const SCROLL_FRAMES: u32 = 24;
const FRAMES_TO_SETTLE_JUMP: u32 = 60;
fn lazy_list(rows: usize) -> AnyView {
let data = (0..rows).map(SelfId::new).collect::<Vec<_>>();
AnyView::new(scroll(VStack::for_each(data, |_| {
().size(360.0, ROW_HEIGHT)
})))
}
fn runtime_for(rows: usize) -> HeadlessRuntime {
let builder = AnyViewBuilder::<AnyView>::new(move || lazy_list(rows));
let env = test_environment();
HeadlessRuntime::new_for_tests(env, builder, WINDOW_WIDTH, WINDOW_HEIGHT)
}
#[derive(Debug, Clone, Copy)]
struct ScrollCost {
initial_misses: u32,
scroll_misses: u32,
}
fn measure_scroll_cost(rows: usize) -> ScrollCost {
let mut runtime = runtime_for(rows);
let start = Instant::now();
let initial = runtime.pump_at(false, start);
let initial_misses = initial.profile.counters.measurement_cache_misses;
let mut scroll_misses = 0;
for frame in 1..=SCROLL_FRAMES {
runtime.push_input_event(InputEvent::Scroll {
x: (WINDOW_WIDTH / 2) as f32,
y: (WINDOW_HEIGHT / 2) as f32,
dx: 0.0,
dy: -200.0,
is_line_delta: false,
});
let result = runtime.pump_at(false, start + Duration::from_millis(u64::from(frame) * 16));
scroll_misses += result.profile.counters.measurement_cache_misses;
}
ScrollCost {
initial_misses,
scroll_misses,
}
}
#[test]
fn lazy_scroll_cost_is_independent_of_total_rows() {
let small = measure_scroll_cost(500);
let large = measure_scroll_cost(20_000);
eprintln!("perf_scroll small(500)={small:?}");
eprintln!("perf_scroll large(20000)={large:?}");
assert!(
large.initial_misses <= small.initial_misses + 64,
"initial build cost scaled with total rows (virtualization regression): {small:?} vs {large:?}"
);
assert!(
large.scroll_misses <= small.scroll_misses * 2 + 64,
"scroll cost scaled with total rows (virtualization regression): {small:?} vs {large:?}"
);
}
#[test]
fn coordinate_jump_over_lazy_stack_materializes_only_the_target_window() {
const ROWS: usize = 100_000;
const TARGET_ROW: usize = 90_000;
let materialized = Arc::new(AtomicUsize::new(0));
let controller = ScrollController::new(Point::zero());
let builder = {
let materialized = Arc::clone(&materialized);
let controller = controller.clone();
AnyViewBuilder::<AnyView>::new(move || {
let rows = (0..ROWS).map(SelfId::new).collect::<Vec<_>>();
let materialized = Arc::clone(&materialized);
AnyView::new(
scroll(VStack::for_each(rows, move |_| {
materialized.fetch_add(1, Ordering::Relaxed);
().size(360.0, ROW_HEIGHT)
}))
.scroll_controller(&controller),
)
})
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, WINDOW_WIDTH, WINDOW_HEIGHT);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
let initial_materialized = materialized.load(Ordering::Relaxed);
controller.scroll_to(Point::new(0.0, TARGET_ROW as f32 * ROW_HEIGHT));
let _ = runtime.pump_at(false, start + Duration::from_millis(16));
let jump_materialized = materialized.load(Ordering::Relaxed) - initial_materialized;
assert!(
initial_materialized < 64,
"initial lazy viewport materialized {initial_materialized} rows"
);
assert!(
jump_materialized < 64,
"deep coordinate jump materialized {jump_materialized} rows instead of one viewport"
);
}
#[test]
fn indexed_list_jump_materializes_only_the_target_window() {
const ROWS: usize = 100_000;
const TARGET_ROW: usize = 90_000;
let materialized = Arc::new(AtomicUsize::new(0));
let controller = ScrollController::new(0usize);
let builder = {
let materialized = Arc::clone(&materialized);
let controller = controller.clone();
AnyViewBuilder::<AnyView>::new(move || {
let rows = (0..ROWS).map(SelfId::new).collect::<Vec<_>>();
let materialized = Arc::clone(&materialized);
AnyView::new(
List::for_each(rows, move |row| {
let index = row.into_inner();
materialized.fetch_add(1, Ordering::Relaxed);
let label = if index == TARGET_ROW {
"Target row"
} else {
"Dataset row"
};
ListItem::new(vstack((text(label),)).size(360.0, ROW_HEIGHT))
})
.scroll_controller(&controller),
)
})
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, WINDOW_WIDTH, WINDOW_HEIGHT);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
let initial_materialized = materialized.load(Ordering::Relaxed);
controller.scroll_to(TARGET_ROW);
#[cfg_attr(
not(feature = "accessibility"),
allow(unused_mut, unused_variables, reason = "labels need the a11y tree")
)]
let mut saw_target = false;
let mut elapsed = Duration::ZERO;
let mut previous = initial_materialized;
let mut worst_frame = 0usize;
for _ in 0..FRAMES_TO_SETTLE_JUMP {
elapsed += Duration::from_millis(16);
let frame = runtime.pump_at(false, start + elapsed);
let total = materialized.load(Ordering::Relaxed);
worst_frame = worst_frame.max(total - previous);
previous = total;
#[cfg(feature = "accessibility")]
{
saw_target |= frame
.tree_update
.into_iter()
.flat_map(|update| update.nodes)
.any(|(_, node)| node.label() == Some("Target row"));
}
#[cfg(not(feature = "accessibility"))]
let _ = frame;
}
assert!(
initial_materialized < 128,
"initial List viewport materialized {initial_materialized} rows"
);
assert!(
worst_frame < 128,
"a frame of the indexed List jump materialized {worst_frame} rows instead of one viewport"
);
#[cfg(feature = "accessibility")]
assert!(
saw_target,
"the indexed target row must intersect the viewport once the jump settles"
);
}
#[test]
fn visible_lazy_item_remeasures_connected_dynamic_through_retained_node() {
let (handler, dynamic) = Dynamic::new();
handler.set(text("initial"));
let builder = AnyViewBuilder::<AnyView>::new(move || {
let dynamic = dynamic.clone();
AnyView::new(scroll(VStack::for_each(
vec![SelfId::new(0_u64)],
move |_| dynamic.clone(),
)))
});
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, WINDOW_WIDTH, WINDOW_HEIGHT);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
handler.set(text("updated dynamic row"));
let updated = runtime.pump_at(false, start + Duration::from_millis(16));
assert_eq!(
updated.profile.counters.rebuild_iterations, 0,
"a connected Dynamic inside a visible lazy item must patch and remeasure through \
its retained node without rebuilding the window"
);
#[cfg(feature = "accessibility")]
assert!(
updated
.tree_update
.into_iter()
.flat_map(|update| update.nodes)
.any(|(_, node)| node.label() == Some("updated dynamic row")),
"the patched lazy item must lay out and publish its replacement subtree"
);
}
#[test]
fn flow_markdown_blocks_reconnect_after_lazy_eviction() {
let markdown = (0..64)
.map(|index| {
format!(
"# Section {index}\n\nParagraph {index} keeps the document tall enough to recycle blocks.\n\n"
)
})
.collect::<String>();
let controller = ScrollController::new(Point::zero());
let builder = {
let controller = controller.clone();
AnyViewBuilder::<AnyView>::new(move || {
AnyView::new(
scroll(
flow_markdown(Str::from(markdown.clone())).preset(FlowAnimationPreset::None),
)
.scroll_controller(&controller),
)
})
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, WINDOW_WIDTH, WINDOW_HEIGHT);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
controller.scroll_to(Point::new(0.0, 3_000.0));
let _ = runtime.pump_at(false, start + Duration::from_millis(16));
controller.scroll_to(Point::zero());
let returned = runtime.pump_at(false, start + Duration::from_millis(32));
#[cfg(not(feature = "accessibility"))]
let _ = returned;
#[cfg(feature = "accessibility")]
assert!(
returned
.tree_update
.into_iter()
.flat_map(|update| update.nodes)
.any(|(_, node)| node.label() == Some("Section 0")),
"a FlowMarkdown block rematerialized after lazy eviction must reconnect with fresh retained content"
);
}
#[cfg(feature = "accessibility")]
#[test]
fn flow_markdown_append_preserves_user_scroll_offset() {
fn scroll_y(result: crate::HeadlessPumpResult) -> f64 {
result
.tree_update
.expect("FlowMarkdown pump must publish an accessibility update")
.nodes
.into_iter()
.find_map(|(_, node)| node.scroll_y())
.expect("FlowMarkdown scroll view must publish its vertical offset")
}
let initial = (0..64)
.map(|index| {
format!(
"# Section {index}\n\nParagraph {index} keeps the document tall enough to retain a user offset.\n\n"
)
})
.collect::<String>();
let source = Binding::container(Str::from(initial.clone()));
let builder = {
let source = source.clone();
AnyViewBuilder::<AnyView>::new(move || {
AnyView::new(scroll(
flow_markdown(source.clone()).preset(FlowAnimationPreset::None),
))
})
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, WINDOW_WIDTH, WINDOW_HEIGHT);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
runtime.push_input_event(InputEvent::Scroll {
x: WINDOW_WIDTH as f32 / 2.0,
y: WINDOW_HEIGHT as f32 / 2.0,
dx: 0.0,
dy: -120.0,
is_line_delta: false,
});
let before_append = scroll_y(runtime.pump_at(false, start + Duration::from_millis(16)));
assert!(
before_append > 0.0,
"the physical scroll input must establish a non-zero user offset"
);
source.set(Str::from(format!("{initial}\n\nAppended tail block.")));
let after_append = scroll_y(runtime.pump_at(false, start + Duration::from_millis(32)));
assert_eq!(
before_append, after_append,
"incremental FlowMarkdown growth must not reset the user's scroll offset"
);
}