use core::time::Duration;
use std::cell::Cell;
use std::rc::Rc;
use std::time::Instant;
use waterui::animation::Animation;
use waterui::{Binding, SignalExt as _, ViewExt as _};
use waterui_core::AnyView;
use waterui_core::handler::AnyViewBuilder;
use waterui_core::id::SelfId;
use waterui_layout::scroll;
use waterui_layout::stack::{VStack, vstack, zstack};
use nami::collection::List;
use waterui::graphics::Color;
use waterui_core::dynamic::watch;
use waterui_core::layout::{Layout, ProposalSize, Rect, Size, SubView};
use waterui_core::views::ForEach;
use waterui_graphics::color::signal_color;
use waterui_layout::AbsoluteLayout;
use waterui_layout::collection_transition::collection_transition;
use waterui_layout::container::LazyContainer;
use waterui_layout::frame::Frame;
use waterui_layout::stack::ZStackLayout;
use super::test_environment;
use crate::HeadlessRuntime;
#[derive(Debug, Clone, Copy, Default)]
struct ScenarioMetrics {
parametric_frames: u32,
frames_rebuilt: u32,
measurement_misses: u32,
}
fn padding_list() -> impl waterui::View {
let rows = (0..40).map(SelfId::new).collect::<Vec<_>>();
VStack::for_each(rows, |_| ().size(360.0, 44.0))
}
fn opacity_at_root(value: &Binding<f32>) -> AnyView {
let animated = value
.clone()
.with(Animation::linear(Duration::from_millis(1_000)));
AnyView::new(vstack((
().size(120.0, 120.0).opacity(animated),
().size(360.0, 200.0),
)))
}
fn opacity_in_scroll(value: &Binding<f32>) -> AnyView {
let animated = value
.clone()
.with(Animation::linear(Duration::from_millis(1_000)));
let header = ().size(120.0, 120.0).opacity(animated);
AnyView::new(scroll(vstack((header, padding_list()))))
}
fn transform_at_root(value: &Binding<f32>) -> AnyView {
let animated = value
.clone()
.with(Animation::linear(Duration::from_millis(1_000)));
AnyView::new(vstack((
().size(80.0, 80.0).scale(animated.clone(), animated),
().size(360.0, 200.0),
)))
}
fn transform_in_scroll(value: &Binding<f32>) -> AnyView {
let animated = value
.clone()
.with(Animation::linear(Duration::from_millis(1_000)));
let header = ().size(80.0, 80.0).scale(animated.clone(), animated);
AnyView::new(scroll(vstack((header, padding_list()))))
}
#[derive(Debug, Clone)]
struct CountingLayout {
inner: ZStackLayout,
place_calls: Rc<Cell<u32>>,
}
impl Layout for CountingLayout {
fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
self.inner.size_that_fits(proposal, children)
}
fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
self.place_calls.set(
self.place_calls
.get()
.checked_add(1)
.expect("counting layout place-call counter overflow"),
);
self.inner.place(bounds, children)
}
}
fn run_scenario(
make_view: fn(&Binding<f32>) -> AnyView,
parametric_frames: u32,
) -> ScenarioMetrics {
let value = Binding::f32(1.0);
let builder = {
let value = value.clone();
AnyViewBuilder::<AnyView>::new(move || make_view(&value))
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, 400, 640);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
value.set(0.25);
let mut metrics = ScenarioMetrics::default();
for frame in 1..=parametric_frames {
let at = start + Duration::from_millis(u64::from(frame) * 16);
let result = runtime.pump_at(false, at);
let counters = result.profile.counters;
metrics.parametric_frames += 1;
if counters.rebuild_iterations > 0 {
metrics.frames_rebuilt += 1;
}
metrics.measurement_misses += counters.measurement_cache_misses;
}
metrics
}
fn dynamic_same_size(value: &Binding<i32>) -> AnyView {
let watched = watch(value.clone(), |v| {
let channel = if v % 2 == 0 { 40 } else { 200 };
Color::srgb(channel, 90, 160).size(80.0, 40.0)
});
AnyView::new(vstack((watched, padding_list())))
}
fn dynamic_runtime(
make_view: fn(&Binding<i32>) -> AnyView,
value: &Binding<i32>,
) -> HeadlessRuntime {
let builder = {
let value = value.clone();
AnyViewBuilder::<AnyView>::new(move || make_view(&value))
};
let env = test_environment();
HeadlessRuntime::new_for_tests(env, builder, 400, 640)
}
#[test]
fn dynamic_content_change_patches_without_rebuild() {
let value = Binding::container(0_i32);
let mut runtime = dynamic_runtime(dynamic_same_size, &value);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
value.set(1);
let result = runtime.pump_at(false, start + Duration::from_millis(16));
assert_eq!(
result.profile.counters.rebuild_iterations, 0,
"a same-size Dynamic content change must patch in isolation, not rebuild: {:?}",
result.profile.counters
);
assert!(
!result.rebuilt,
"a reactive patch frame must not perform a structural rebuild"
);
}
fn dynamic_changing_size_visible(value: &Binding<i32>) -> AnyView {
let watched = watch(value.clone(), |v| {
Color::srgb(200, 90, 160).size(80.0, 40.0 + (v as f32) * 30.0)
});
let rows = (0..40).map(SelfId::new).collect::<Vec<_>>();
let list = VStack::for_each(rows, |item: SelfId<u64>| {
let channel = u8::try_from(40 + (*item % 8) * 24).unwrap_or(255);
Color::srgb(channel, 120, 80).size(360.0, 44.0)
});
AnyView::new(vstack((watched, list)))
}
#[test]
fn dynamic_size_change_reflows_without_rebuild() {
let truth_value = Binding::container(1_i32);
let truth_builder = {
let value = truth_value.clone();
AnyViewBuilder::<AnyView>::new(move || dynamic_changing_size_visible(&value))
};
let truth_env = test_environment();
let mut truth_runtime = HeadlessRuntime::new_for_tests(truth_env, truth_builder, 400, 640);
let expected = truth_runtime
.pump_at(true, Instant::now())
.snapshot
.expect("ground-truth frame must produce a snapshot");
let value = Binding::container(0_i32);
let builder = {
let value = value.clone();
AnyViewBuilder::<AnyView>::new(move || dynamic_changing_size_visible(&value))
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, 400, 640);
let start = Instant::now();
let _ = runtime.pump_at(true, start);
value.set(1);
let result = runtime.pump_at(true, start + Duration::from_millis(16));
assert_eq!(
result.profile.counters.rebuild_iterations, 0,
"a size-changing Dynamic change must reflow via incremental relayout, not a \
whole-window structural rebuild, which is visible as a flicker: {:?}",
result.profile.counters
);
let snapshot = result
.snapshot
.expect("reflowed frame must produce a snapshot");
assert!(
snapshot.rgba8 == expected.rgba8,
"the incremental reflow must place the grown content and shifted rows exactly \
as a static composition would; a mismatch means the in-place relayout is wrong"
);
}
fn collection_overlay(list: &List<SelfId<u64>>) -> AnyView {
let list = list.clone();
AnyView::new(zstack((
().size(360.0, 600.0),
LazyContainer::new(
AbsoluteLayout,
ForEach::new(list, |item: SelfId<u64>| {
let id = u8::try_from(*item % 4).unwrap_or(0);
Color::srgb(40 + id * 50, 90, 160).size(80.0, 40.0)
}),
),
)))
}
fn collection_runtime(list: &List<SelfId<u64>>) -> HeadlessRuntime {
let builder = {
let list = list.clone();
AnyViewBuilder::<AnyView>::new(move || collection_overlay(&list))
};
let env = test_environment();
HeadlessRuntime::new_for_tests(env, builder, 400, 640)
}
#[test]
fn collection_add_patches_without_rebuild() {
let list: List<SelfId<u64>> = List::new();
let mut runtime = collection_runtime(&list);
let start = Instant::now();
let empty = runtime
.pump_at(true, start)
.snapshot
.expect("empty overlay frame must produce a snapshot");
list.push(SelfId::new(1));
let result = runtime.pump_at(true, start + Duration::from_millis(16));
assert_eq!(
result.profile.counters.rebuild_iterations, 0,
"adding a collection item must patch in isolation, not rebuild: {:?}",
result.profile.counters
);
assert!(
!result.rebuilt,
"a collection add patch frame must not perform a structural rebuild"
);
let added = result
.snapshot
.expect("collection-add frame must produce a snapshot");
assert!(
added.rgba8 != empty.rgba8,
"the added item must actually appear (the reactive collection must reconcile \
the new id into the rendered tree, not just avoid a rebuild)"
);
}
#[test]
fn collection_remove_patches_without_rebuild() {
let list: List<SelfId<u64>> = List::from(vec![SelfId::new(1), SelfId::new(2), SelfId::new(3)]);
let mut runtime = collection_runtime(&list);
let start = Instant::now();
let three = runtime
.pump_at(true, start)
.snapshot
.expect("three-item overlay frame must produce a snapshot");
let _removed = list.remove(2);
let result = runtime.pump_at(true, start + Duration::from_millis(16));
assert_eq!(
result.profile.counters.rebuild_iterations, 0,
"removing a collection item must patch in isolation, not rebuild: {:?}",
result.profile.counters
);
assert!(
!result.rebuilt,
"a collection remove patch frame must not perform a structural rebuild"
);
let two = result
.snapshot
.expect("collection-remove frame must produce a snapshot");
assert!(
two.rgba8 != three.rgba8,
"the removed item must actually disappear (the reactive collection must evict \
the departed id from the rendered tree)"
);
}
#[test]
fn fixed_scroll_refreshes_window_frame_without_rebuild() {
use crate::platform::InputEvent;
let builder = AnyViewBuilder::<AnyView>::new(|| {
AnyView::new(scroll(vstack((
().size(360.0, 1_200.0),
().size(360.0, 200.0),
))))
});
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, 400, 640);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
runtime.push_input_event(InputEvent::Scroll {
x: 200.0,
y: 320.0,
dx: 0.0,
dy: -120.0,
is_line_delta: false,
});
let result = runtime.pump_at(false, start + Duration::from_millis(16));
assert_eq!(
result.profile.counters.rebuild_iterations, 0,
"fixed scroll must re-composite via the window frame, not rebuild: {:?}",
result.profile.counters
);
assert!(
!result.rebuilt,
"a fixed scroll refresh frame must not perform a structural rebuild"
);
}
const PARAMETRIC_FRAMES: u32 = 20;
#[test]
fn opacity_at_root_never_rebuilds() {
let metrics = run_scenario(opacity_at_root, PARAMETRIC_FRAMES);
assert_eq!(metrics.parametric_frames, PARAMETRIC_FRAMES);
assert_eq!(
metrics.frames_rebuilt, 0,
"root opacity animation must replay through the window frame, not rebuild: {metrics:?}"
);
assert_eq!(
metrics.measurement_misses, 0,
"root opacity animation must not re-measure the tree: {metrics:?}"
);
}
#[test]
fn opacity_in_scroll_never_rebuilds() {
let metrics = run_scenario(opacity_in_scroll, PARAMETRIC_FRAMES);
assert_eq!(metrics.parametric_frames, PARAMETRIC_FRAMES);
assert_eq!(
metrics.frames_rebuilt, 0,
"in-scroll opacity animation must replay, not rebuild every frame: {metrics:?}"
);
assert_eq!(
metrics.measurement_misses, 0,
"in-scroll opacity animation must not re-measure the tree: {metrics:?}"
);
}
#[test]
fn transform_at_root_never_rebuilds() {
let metrics = run_scenario(transform_at_root, PARAMETRIC_FRAMES);
assert_eq!(metrics.parametric_frames, PARAMETRIC_FRAMES);
assert_eq!(
metrics.frames_rebuilt, 0,
"root transform animation must replay through the window frame, not rebuild: {metrics:?}"
);
assert_eq!(
metrics.measurement_misses, 0,
"root transform animation must not re-measure the tree: {metrics:?}"
);
}
#[test]
fn transform_in_scroll_never_rebuilds() {
let metrics = run_scenario(transform_in_scroll, PARAMETRIC_FRAMES);
assert_eq!(metrics.parametric_frames, PARAMETRIC_FRAMES);
assert_eq!(
metrics.frames_rebuilt, 0,
"animated transform inside scroll must replay without a full rebuild: {metrics:?}"
);
assert_eq!(
metrics.measurement_misses, 0,
"animated transform inside scroll must not re-measure: {metrics:?}"
);
}
#[test]
fn steady_state_transform_animation_retains_the_tree() {
use waterui_layout::container::FixedContainer;
let value = Binding::f32(1.0);
let place_calls = Rc::new(Cell::new(0));
let builder = {
let value = value.clone();
let place_calls = Rc::clone(&place_calls);
AnyViewBuilder::<AnyView>::new(move || {
let animated = value.with(Animation::linear(Duration::from_millis(1_000)));
AnyView::new(FixedContainer::new(
CountingLayout {
inner: ZStackLayout::default(),
place_calls: Rc::clone(&place_calls),
},
(().size(80.0, 80.0).scale(animated.clone(), animated),),
))
})
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, 400, 640);
let start = Instant::now();
let _ = runtime.pump_at(false, start);
value.set(0.25);
let _ = runtime.pump_at(false, start + Duration::from_millis(16));
for frame in 2..=PARAMETRIC_FRAMES {
let at = start + Duration::from_millis(u64::from(frame) * 16);
let result = runtime.pump_at(false, at);
assert_eq!(
result.profile.counters.rebuild_iterations, 0,
"steady-state animation frame must retain the tree"
);
}
}
#[test]
fn dynamic_growth_from_empty_renders_content_without_rebuild() {
use waterui::component::text;
use waterui_core::Dynamic;
fn overlay_content() -> AnyView {
AnyView::new(text("presented overlay"))
}
let static_builder = AnyViewBuilder::<AnyView>::new(|| {
AnyView::new(zstack((().size(360.0, 600.0), overlay_content())))
});
let static_env = test_environment();
let mut static_runtime = HeadlessRuntime::new_for_tests(static_env, static_builder, 400, 640);
let expected = static_runtime
.pump_at(true, Instant::now())
.snapshot
.expect("static overlay frame must produce a snapshot");
let (handler, dynamic) = Dynamic::new();
handler.set(());
let builder = AnyViewBuilder::<AnyView>::new(move || {
AnyView::new(zstack((().size(360.0, 600.0), dynamic.clone())))
});
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, 400, 640);
let start = Instant::now();
let _ = runtime.pump_at(true, start);
handler.set(overlay_content());
let result = runtime.pump_at(true, start + Duration::from_millis(16));
assert_eq!(
result.profile.counters.rebuild_iterations, 0,
"growing a Dynamic from empty must reflow via incremental relayout of the \
retained tree, not a whole-window structural rebuild: {:?}",
result.profile.counters
);
let snapshot = result
.snapshot
.expect("reflowed overlay frame must produce a snapshot");
assert!(
snapshot.rgba8 == expected.rgba8,
"the reflow must place the overlay content at its grown bounds; a mismatch with \
the statically composed frame means a stale zero-size capture was replayed"
);
}
fn reactive_bg_collection(selected: &Binding<u32>) -> AnyView {
let list: List<SelfId<u64>> = List::from(vec![SelfId::new(0), SelfId::new(1), SelfId::new(2)]);
let sel = selected.clone();
let collection = VStack::for_each(list, move |item: SelfId<u64>| {
let id = *item as u32;
let is_selected = sel.clone().map(move |current| current == id).computed();
let background = is_selected
.select(Color::srgb(200, 90, 160), Color::srgb(228, 224, 236))
.computed();
AnyView::new(().size(120.0, 40.0).background(signal_color(background)))
});
let collection = collection_transition(collection, Animation::linear(Duration::from_millis(1)));
let height = selected.clone().map(|s| 40.0 + s as f32 * 30.0);
let sizer = Frame::new(()).width(80.0).height(height);
AnyView::new(vstack((sizer, collection)))
}
#[test]
fn reused_collection_item_reactive_background_tracks_on_selection() {
let truth_selected = Binding::container(1_u32);
let truth_builder = {
let selected = truth_selected.clone();
AnyViewBuilder::<AnyView>::new(move || reactive_bg_collection(&selected))
};
let truth_env = test_environment();
let mut truth_runtime = HeadlessRuntime::new_for_tests(truth_env, truth_builder, 400, 640);
let expected = truth_runtime
.pump_at(true, Instant::now())
.snapshot
.expect("ground-truth frame must produce a snapshot");
let selected = Binding::container(0_u32);
let builder = {
let selected = selected.clone();
AnyViewBuilder::<AnyView>::new(move || reactive_bg_collection(&selected))
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, 400, 640);
let start = Instant::now();
let _ = runtime.pump_at(true, start);
selected.set(1);
let result = runtime.pump_at(true, start + Duration::from_millis(16));
assert_eq!(
result.profile.counters.rebuild_iterations, 0,
"the size-tracking sibling's reflow must be an incremental relayout of the \
retained tree, not a whole-window structural rebuild: {:?}",
result.profile.counters
);
let snapshot = result
.snapshot
.expect("reflowed frame must produce a snapshot");
assert!(
snapshot.rgba8 == expected.rgba8,
"a retained collection item must apply its nested reactive background update \
on selection; a mismatch means the active-indicator pill stayed on the stale \
(previously selected) item"
);
}
fn transition_color_stack(list: &List<SelfId<u64>>) -> AnyView {
let collection = VStack::for_each(list.clone(), |item: SelfId<u64>| {
#[allow(clippy::cast_possible_truncation)]
let shade = 40 + (*item as u8) * 70;
AnyView::new(().size(120.0, 40.0).background(Color::srgb(shade, 90, 160)))
});
AnyView::new(collection_transition(
collection,
Animation::linear(Duration::from_millis(1_000)),
))
}
#[test]
fn collection_membership_exit_animates_then_settles() {
let truth_list: List<SelfId<u64>> = List::from(vec![SelfId::new(0), SelfId::new(2)]);
let truth_builder = AnyViewBuilder::<AnyView>::new(move || transition_color_stack(&truth_list));
let truth_env = test_environment();
let mut truth_runtime = HeadlessRuntime::new_for_tests(truth_env, truth_builder, 400, 640);
let expected = truth_runtime
.pump_at(true, Instant::now())
.snapshot
.expect("ground-truth frame must produce a snapshot");
let list: List<SelfId<u64>> = List::from(vec![SelfId::new(0), SelfId::new(1), SelfId::new(2)]);
let builder = {
let list = list.clone();
AnyViewBuilder::<AnyView>::new(move || transition_color_stack(&list))
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, 400, 640);
let start = Instant::now();
let before = runtime
.pump_at(true, start)
.snapshot
.expect("initial frame must produce a snapshot");
let _ = list.remove(1);
let _ = runtime.pump_at(false, start + Duration::from_millis(16));
let mid = runtime
.pump_at(true, start + Duration::from_millis(516))
.snapshot
.expect("mid-transition frame must produce a snapshot");
assert!(
mid.rgba8 != before.rgba8,
"~500ms into a 1000ms exit the removed row must be visibly collapsing, \
not still rendered at rest"
);
assert!(
mid.rgba8 != expected.rgba8,
"~500ms into a 1000ms exit the removed row must still be partially \
visible, not already settled"
);
let settled = runtime
.pump_at(true, start + Duration::from_millis(1_616))
.snapshot
.expect("settled frame must produce a snapshot");
assert!(
settled.rgba8 == expected.rgba8,
"after the exit animation completes the collection must render \
pixel-identical to a fresh build of the settled membership"
);
}
#[test]
fn collection_membership_enter_animates_then_settles() {
let truth_list: List<SelfId<u64>> =
List::from(vec![SelfId::new(0), SelfId::new(1), SelfId::new(2)]);
let truth_builder = AnyViewBuilder::<AnyView>::new(move || transition_color_stack(&truth_list));
let truth_env = test_environment();
let mut truth_runtime = HeadlessRuntime::new_for_tests(truth_env, truth_builder, 400, 640);
let expected = truth_runtime
.pump_at(true, Instant::now())
.snapshot
.expect("ground-truth frame must produce a snapshot");
let list: List<SelfId<u64>> = List::from(vec![SelfId::new(0), SelfId::new(2)]);
let builder = {
let list = list.clone();
AnyViewBuilder::<AnyView>::new(move || transition_color_stack(&list))
};
let env = test_environment();
let mut runtime = HeadlessRuntime::new_for_tests(env, builder, 400, 640);
let start = Instant::now();
let before = runtime
.pump_at(true, start)
.snapshot
.expect("initial frame must produce a snapshot");
list.insert(1, SelfId::new(1));
let _ = runtime.pump_at(false, start + Duration::from_millis(16));
let mid = runtime
.pump_at(true, start + Duration::from_millis(516))
.snapshot
.expect("mid-transition frame must produce a snapshot");
assert!(
mid.rgba8 != before.rgba8,
"~500ms into a 1000ms enter the inserted row must be visibly growing in"
);
assert!(
mid.rgba8 != expected.rgba8,
"~500ms into a 1000ms enter the inserted row must not yet be at rest"
);
let settled = runtime
.pump_at(true, start + Duration::from_millis(1_616))
.snapshot
.expect("settled frame must produce a snapshot");
assert!(
settled.rgba8 == expected.rgba8,
"after the enter animation completes the collection must render \
pixel-identical to a fresh build of the grown membership"
);
}