use super::{test_environment, test_renderer};
use crate::renderer::{ContainerNode, RenderContext, RenderNode, TextNode};
use nami::Computed;
#[cfg(feature = "accessibility")]
use std::rc::Rc;
use vello::kurbo::{Affine, Rect};
use waterui::ViewExt as _;
use waterui_controls::button::button;
use waterui_core::layout::{HorizontalAlignment, Size};
use waterui_core::{AnyView, SignalExt as _};
use waterui_layout::stack::{VStackLayout, vstack};
use waterui_text::styled::StyledStr;
fn text_node(content: &'static str) -> RenderNode {
RenderNode::Text(Box::new(TextNode {
#[cfg(feature = "accessibility")]
accessibility_identity: Rc::new(()),
content: Computed::constant(StyledStr::plain(content)),
alignment: Computed::constant(HorizontalAlignment::Leading),
line_limit: None,
}))
}
#[test]
fn render_node_container_lays_out_and_flushes_text() {
let env = test_environment();
let mut renderer = test_renderer();
let mut node = RenderNode::Container(Box::new(ContainerNode {
#[cfg(feature = "accessibility")]
accessibility_identity: Rc::new(()),
layout: Box::new(VStackLayout {
alignment: HorizontalAlignment::Center,
spacing: Computed::constant(8.0),
}),
children: vec![text_node("Hello"), text_node("World")],
#[cfg(feature = "accessibility")]
accessibility_child_env: None,
placed: Vec::new(),
_guards: Vec::new(),
}));
let window = Size::new(200.0, 120.0);
let bounds = Rect::new(0.0, 0.0, 200.0, 120.0);
renderer.reset_scene();
renderer.begin_rebuild_frame();
node.layout(&mut renderer, &env, window);
match &node {
RenderNode::Container(container) => {
assert_eq!(
container.placed.len(),
2,
"the container must cache one frame per child"
);
assert!(
container.placed[1].y() > container.placed[0].y(),
"a VStack must stack its children top-to-bottom, got {:?}",
container.placed
);
}
_ => panic!("expected a container node"),
}
let ctx = RenderContext::with_transforms(bounds, Affine::IDENTITY, Affine::IDENTITY);
node.flush(&mut renderer, ctx, &env);
assert!(
!renderer.scene_is_empty(),
"flushing two text nodes must draw glyphs into the scene"
);
renderer.finish_rebuild_frame();
}
#[test]
fn geometry_static_flush_reuses_cached_placement() {
let env = test_environment();
let mut renderer = test_renderer();
let mut node = RenderNode::Container(Box::new(ContainerNode {
#[cfg(feature = "accessibility")]
accessibility_identity: Rc::new(()),
layout: Box::new(VStackLayout {
alignment: HorizontalAlignment::Center,
spacing: Computed::constant(8.0),
}),
children: vec![text_node("Cached")],
#[cfg(feature = "accessibility")]
accessibility_child_env: None,
placed: Vec::new(),
_guards: Vec::new(),
}));
let window = Size::new(200.0, 120.0);
let bounds = Rect::new(0.0, 0.0, 200.0, 120.0);
renderer.begin_rebuild_frame();
node.layout(&mut renderer, &env, window);
let placed_after_layout = match &node {
RenderNode::Container(container) => container.placed.clone(),
_ => panic!("expected a container node"),
};
let ctx = RenderContext::with_transforms(bounds, Affine::IDENTITY, Affine::IDENTITY);
renderer.reset_scene();
node.flush(&mut renderer, ctx, &env);
renderer.reset_scene();
node.flush(&mut renderer, ctx, &env);
renderer.finish_rebuild_frame();
match &node {
RenderNode::Container(container) => {
assert_eq!(
container.placed, placed_after_layout,
"flush must not mutate cached placements"
);
}
_ => panic!("expected a container node"),
}
}
#[test]
fn opacity_wrapper_builds_and_flushes_via_dsl() {
let env = test_environment();
let mut renderer = test_renderer();
let view = AnyView::new(waterui_text::text("Faded").opacity(0.5));
let mut node = RenderNode::build(view, &env, &mut renderer);
assert!(
matches!(node, RenderNode::Opacity(_)),
"opacity-wrapped text must build an Opacity wrapper node"
);
let window = Size::new(200.0, 80.0);
let bounds = Rect::new(0.0, 0.0, 200.0, 80.0);
renderer.begin_rebuild_frame();
node.layout(&mut renderer, &env, window);
let ctx = RenderContext::with_transforms(bounds, Affine::IDENTITY, Affine::IDENTITY);
node.flush(&mut renderer, ctx, &env);
assert!(
!renderer.scene_is_empty(),
"an opacity-wrapped text must still draw glyphs"
);
renderer.finish_rebuild_frame();
}
#[test]
fn capture_window_tree_renders_mixed_widgets() {
let env = test_environment();
let mut renderer = test_renderer();
let view = AnyView::new(vstack((
waterui_text::text("Title"),
button("Tap").action(|| {}),
)));
let bounds = Rect::new(0.0, 0.0, 220.0, 200.0);
renderer.begin_rebuild_frame();
renderer.set_window_bounds(bounds);
renderer.capture_window_tree(view, &env, bounds, Affine::IDENTITY, Affine::IDENTITY);
assert!(
!renderer.scene_is_empty(),
"the render-tree path must draw a mixed text + widget view"
);
renderer.finish_rebuild_frame();
}
#[test]
fn flush_window_tree_reuses_retained_tree() {
let env = test_environment();
let mut renderer = test_renderer();
let bounds = Rect::new(0.0, 0.0, 220.0, 200.0);
let view = AnyView::new(vstack((
waterui_text::text("Title"),
button("Tap").action(|| {}),
)));
renderer.begin_rebuild_frame();
renderer.set_window_bounds(bounds);
renderer.capture_window_tree(view, &env, bounds, Affine::IDENTITY, Affine::IDENTITY);
renderer.finish_rebuild_frame();
let flushed = renderer.flush_window_tree(&env, bounds, Affine::IDENTITY, Affine::IDENTITY);
assert!(flushed, "a retained tree must be present to flush");
let (_, vello_layers, _) = renderer.render_layer_stats();
assert!(
vello_layers > 0,
"re-flushing the retained tree must produce a Vello scene layer"
);
}
#[test]
fn widget_reactive_label_stays_live() {
use core::time::Duration;
use std::time::Instant;
use waterui::reactive::binding;
use waterui::text;
use waterui_core::handler::AnyViewBuilder;
let n = binding(0i32);
let builder = {
let n = n.clone();
AnyViewBuilder::<AnyView>::new(move || {
let n = n.clone();
AnyView::new(button(text!("N={n}", n = n.clone())).action(|| {}))
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 200, 120);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame snapshot");
n.set(7);
let after = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("post-change snapshot");
assert!(
before.rgba8 != after.rgba8,
"a reactive button label must update on the render-tree path (the persistent \
Widget node must re-render from the live config, not freeze a captured bake)"
);
}
#[test]
fn reactive_size_change_reflows_via_refresh_not_rebuild() {
use core::time::Duration;
use std::time::Instant;
use waterui::prelude::Color;
use waterui::reactive::binding;
use waterui::text;
use waterui_core::Str;
use waterui_core::handler::AnyViewBuilder;
use waterui_layout::stack::hstack;
let label = binding(Str::from("x"));
let builder = {
let label = label.clone();
AnyViewBuilder::<AnyView>::new(move || {
let label = label.clone();
AnyView::new(hstack((
text!("{label}", label = label.clone()),
().size(40.0, 40.0).background(Color::srgb_hex("#2563EB")),
)))
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 320, 80);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame snapshot");
label.set(Str::from("a very wide label that grows"));
let result = rt.pump_at(true, start + Duration::from_millis(16));
assert!(
!result.rebuilt,
"a reactive value change must reflow through the refresh pump, not a structural \
body() rebuild"
);
let after = result.snapshot.expect("post-change snapshot");
assert_eq!(
(before.width, before.height),
(after.width, after.height),
"snapshots must share dimensions to compare pixel-for-pixel"
);
assert_ne!(
before.rgba8, after.rgba8,
"a widening reactive text must reflow its sibling through full per-frame layout"
);
}
#[test]
fn widget_reactive_value_stays_live() {
use core::time::Duration;
use std::time::Instant;
use waterui::component::progress::progress;
use waterui::reactive::binding;
use waterui_core::handler::AnyViewBuilder;
let value = binding(0.0f64);
let builder = {
let value = value.clone();
AnyViewBuilder::<AnyView>::new(move || {
let value = value.clone();
AnyView::new(progress(value))
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 240, 120);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame snapshot");
value.set(0.8);
let after = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("post-change snapshot");
assert_eq!(
(before.width, before.height),
(after.width, after.height),
"snapshots must share dimensions to compare pixel-for-pixel"
);
assert_ne!(
before.rgba8, after.rgba8,
"a reactive progress value must update on the render-tree path (the persistent \
Widget node must re-render its value-label from the live binding, not freeze a \
captured bake)"
);
}
#[test]
fn text_field_value_display_stays_live() {
use core::time::Duration;
use std::time::Instant;
use waterui::reactive::binding;
use waterui_controls::text_field::TextField;
use waterui_core::Str;
use waterui_core::handler::AnyViewBuilder;
let value = binding(Str::from("AAA"));
let builder = {
let value = value.clone();
AnyViewBuilder::<AnyView>::new(move || {
AnyView::new(TextField::new("Value", &value).hide_label())
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 240, 120);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame snapshot");
value.set(Str::from("ZZZ"));
let after = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("post-change snapshot");
assert_eq!(
(before.width, before.height),
(after.width, after.height),
"snapshots must share dimensions to compare pixel-for-pixel"
);
assert_ne!(
before.rgba8, after.rgba8,
"a reactive text field value must update on the render-tree path (the persistent \
Widget node must re-render its displayed value from the live binding, not freeze a \
captured bake)"
);
}
#[test]
fn render_tree_live_path_processes_watch_switch() {
use core::time::Duration;
use std::time::Instant;
use waterui::reactive::binding;
use waterui_core::dynamic::watch;
use waterui_core::handler::AnyViewBuilder;
let mode = binding(false);
let builder = {
let mode = mode.clone();
AnyViewBuilder::<AnyView>::new(move || {
let mode = mode.clone();
AnyView::new(watch(mode, |selected| {
if selected {
AnyView::new(waterui_text::text("Mode B"))
} else {
AnyView::new(waterui_text::text("Mode A"))
}
}))
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 200, 120);
let start = Instant::now();
let first = rt.pump_at(false, start);
assert!(
first.rebuilt,
"the initial frame must build the render tree"
);
mode.set(true);
let switched = rt.pump_at(false, start + Duration::from_millis(16));
assert!(
switched.profile.counters.rendered,
"a watch-driven switch must render a frame on the render-tree path"
);
}
#[test]
fn body_dispatched_once_then_every_frame_refreshes() {
use core::time::Duration;
use std::time::Instant;
use waterui::reactive::binding;
use waterui::text;
use waterui_core::dynamic::watch;
use waterui_core::handler::AnyViewBuilder;
use waterui_layout::stack::vstack;
let label = binding(0i32);
let mode = binding(false);
let builder = {
let label = label.clone();
let mode = mode.clone();
AnyViewBuilder::<AnyView>::new(move || {
let label = label.clone();
let mode = mode.clone();
AnyView::new(vstack((
text!("N={label}", label = label.clone()),
watch(mode, |selected| {
if selected {
AnyView::new(waterui_text::text("B"))
} else {
AnyView::new(waterui_text::text("A"))
}
}),
)))
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 200, 160);
let start = Instant::now();
assert!(
rt.pump_at(false, start).rebuilt,
"the first frame must build the persistent tree (recursive body() once)"
);
label.set(7);
assert!(
!rt.pump_at(false, start + Duration::from_millis(16)).rebuilt,
"a reactive value change must refresh the persistent tree, not re-run body()"
);
mode.set(true);
assert!(
!rt.pump_at(false, start + Duration::from_millis(32)).rebuilt,
"a structural watch switch must patch + refresh, not rebuild the whole tree"
);
label.set(9);
assert!(
!rt.pump_at(false, start + Duration::from_millis(48)).rebuilt,
"subsequent reactive changes keep refreshing the one persistent tree"
);
}
#[test]
fn render_tree_chart_switch_snapshot() {
use core::time::Duration;
use std::time::Instant;
use waterui::reactive::binding;
use waterui_core::dynamic::watch;
use waterui_core::handler::AnyViewBuilder;
fn write_png(path: &str, width: u32, height: u32, rgba: Vec<u8>) {
let image = image::RgbaImage::from_raw(width, height, rgba)
.expect("snapshot dimensions must match the rgba buffer");
image.save(path).expect("snapshot png must be writable");
}
let mode = binding(false);
let builder = {
let mode = mode.clone();
AnyViewBuilder::<AnyView>::new(move || {
let mode = mode.clone();
AnyView::new(watch(mode, |selected| {
use waterui::prelude::*;
if selected {
AnyView::new(().size(140.0, 140.0).background(Color::srgb_hex("#2563EB")))
} else {
AnyView::new(().size(140.0, 140.0).background(Color::srgb_hex("#DC2626")))
}
}))
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 160, 160);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame must capture a snapshot");
write_png(
"/tmp/waterui_tree_switch_before.png",
before.width,
before.height,
before.rgba8,
);
mode.set(true);
let after = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("switched frame must capture a snapshot");
write_png(
"/tmp/waterui_tree_switch_after.png",
after.width,
after.height,
after.rgba8,
);
eprintln!("wrote /tmp/waterui_tree_switch_before.png and /tmp/waterui_tree_switch_after.png");
}
#[test]
fn render_tree_scene_view_switch_snapshot() {
use core::time::Duration;
use std::time::Instant;
use waterui::ViewExt as _;
use waterui::reactive::binding;
use waterui_canvas::Canvas;
use waterui_core::dynamic::watch;
use waterui_core::handler::AnyViewBuilder;
use waterui_core::layout::{Point, Rect, Size as LayoutSize};
use waterui_graphics::color::Srgb;
fn write_png(path: &str, width: u32, height: u32, rgba: Vec<u8>) {
let image = image::RgbaImage::from_raw(width, height, rgba)
.expect("snapshot dimensions must match the rgba buffer");
image.save(path).expect("snapshot png must be writable");
}
fn canvas_box(r: u8, g: u8, b: u8) -> AnyView {
AnyView::new(
Canvas::new(move |ctx| {
ctx.set_fill_style(Srgb::new_u8(r, g, b));
ctx.fill_rect(Rect::new(Point::zero(), LayoutSize::new(150.0, 150.0)));
})
.size(150.0, 150.0),
)
}
let mode = binding(false);
let builder = {
let mode = mode.clone();
AnyViewBuilder::<AnyView>::new(move || {
let mode = mode.clone();
AnyView::new(watch(mode, |selected| {
if selected {
canvas_box(0x25, 0x63, 0xEB)
} else {
canvas_box(0xDC, 0x26, 0x26)
}
}))
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 160, 160);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame must capture a snapshot");
write_png(
"/tmp/waterui_tree_sceneview_before.png",
before.width,
before.height,
before.rgba8,
);
mode.set(true);
let after = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("switched frame must capture a snapshot");
write_png(
"/tmp/waterui_tree_sceneview_after.png",
after.width,
after.height,
after.rgba8,
);
eprintln!(
"wrote /tmp/waterui_tree_sceneview_before.png and /tmp/waterui_tree_sceneview_after.png"
);
}
#[test]
fn render_tree_scroll_snapshot() {
use core::time::Duration;
use std::time::Instant;
use waterui_core::handler::AnyViewBuilder;
fn write_png(path: &str, width: u32, height: u32, rgba: Vec<u8>) {
let image = image::RgbaImage::from_raw(width, height, rgba)
.expect("snapshot dimensions must match the rgba buffer");
image.save(path).expect("snapshot png must be writable");
}
fn block(hex: &'static str) -> AnyView {
use waterui::prelude::*;
AnyView::new(().size(160.0, 60.0).background(Color::srgb_hex(hex)))
}
fn screen() -> AnyView {
use waterui::prelude::*;
AnyView::new(scroll(vstack((
block("#DC2626"),
block("#16A34A"),
block("#2563EB"),
block("#CA8A04"),
block("#9333EA"),
))))
}
let builder = AnyViewBuilder::<AnyView>::new(screen);
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 160, 160);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame must capture a snapshot");
write_png(
"/tmp/waterui_tree_scroll_before.png",
before.width,
before.height,
before.rgba8,
);
rt.push_input_event(crate::platform::InputEvent::Scroll {
x: 80.0,
y: 80.0,
dx: 0.0,
dy: -150.0,
is_line_delta: false,
});
let after = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("scrolled frame must capture a snapshot");
write_png(
"/tmp/waterui_tree_scroll_after.png",
after.width,
after.height,
after.rgba8,
);
eprintln!("wrote /tmp/waterui_tree_scroll_before.png and /tmp/waterui_tree_scroll_after.png");
}
#[test]
fn scroll_offset_persists_across_refresh() {
use core::time::Duration;
use std::time::Instant;
use waterui_core::handler::AnyViewBuilder;
fn block(hex: &'static str) -> AnyView {
use waterui::prelude::*;
AnyView::new(().size(160.0, 60.0).background(Color::srgb_hex(hex)))
}
fn screen() -> AnyView {
use waterui::prelude::*;
AnyView::new(scroll(vstack((
block("#DC2626"),
block("#16A34A"),
block("#2563EB"),
block("#CA8A04"),
block("#9333EA"),
))))
}
let builder = AnyViewBuilder::<AnyView>::new(screen);
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 160, 160);
let start = Instant::now();
let unscrolled = rt
.pump_at(true, start)
.snapshot
.expect("first frame snapshot");
rt.push_input_event(crate::platform::InputEvent::Scroll {
x: 80.0,
y: 80.0,
dx: 0.0,
dy: -150.0,
is_line_delta: false,
});
let scrolled = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("scrolled frame snapshot");
assert_ne!(
unscrolled.rgba8, scrolled.rgba8,
"scrolling must change the rendered content — the offset must apply through the refresh pump"
);
let still_scrolled = rt
.pump_at(true, start + Duration::from_millis(32))
.snapshot
.expect("re-pumped frame snapshot");
assert_eq!(
scrolled.rgba8, still_scrolled.rgba8,
"a no-op refresh must preserve the scroll offset, not reset or drift it"
);
}
#[test]
fn render_tree_collection_snapshot() {
use waterui_core::handler::AnyViewBuilder;
use waterui_core::id::SelfId;
fn write_png(path: &str, width: u32, height: u32, rgba: Vec<u8>) {
let image = image::RgbaImage::from_raw(width, height, rgba)
.expect("snapshot dimensions must match the rgba buffer");
image.save(path).expect("snapshot png must be writable");
}
fn screen() -> AnyView {
use waterui::prelude::*;
let colors = ["#DC2626", "#16A34A", "#2563EB", "#CA8A04", "#9333EA"];
let data: Vec<_> = (0..5).map(SelfId::new).collect();
AnyView::new(scroll(VStack::for_each(data, move |item| {
let index = item.into_inner();
().size(160.0, 40.0)
.background(Color::srgb_hex(colors[index % colors.len()]))
})))
}
let builder = AnyViewBuilder::<AnyView>::new(screen);
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 160, 160);
let snapshot = rt
.pump_at(true, std::time::Instant::now())
.snapshot
.expect("collection frame must capture a snapshot");
write_png(
"/tmp/waterui_tree_collection.png",
snapshot.width,
snapshot.height,
snapshot.rgba8,
);
eprintln!("wrote /tmp/waterui_tree_collection.png");
}
#[test]
fn wrapper_keeps_reactive_descendant_live() {
use core::time::Duration;
use std::time::Instant;
use waterui::reactive::binding;
use waterui_core::dynamic::watch;
use waterui_core::handler::AnyViewBuilder;
let mode = binding(false);
let builder = {
let mode = mode.clone();
AnyViewBuilder::<AnyView>::new(move || {
let mode = mode.clone();
AnyView::new(
watch(mode, |selected| {
use waterui::prelude::*;
if selected {
AnyView::new(().size(120.0, 120.0).background(Color::srgb_hex("#2563EB")))
} else {
AnyView::new(().size(120.0, 120.0).background(Color::srgb_hex("#DC2626")))
}
})
.border(waterui::prelude::Color::srgb_hex("#000000"), 4.0),
)
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 160, 160);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame must capture a snapshot");
mode.set(true);
let after = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("switched frame must capture a snapshot");
assert_eq!(
(before.width, before.height),
(after.width, after.height),
"snapshots must share dimensions to compare pixel-for-pixel"
);
assert_ne!(
before.rgba8, after.rgba8,
"the reactive box must update through the .border() wrapper: a Captured \
wrapper would freeze the descendant and produce an identical frame"
);
}
#[test]
fn gesture_wrapper_keeps_reactive_descendant_live() {
use core::time::Duration;
use std::time::Instant;
use waterui::reactive::binding;
use waterui_core::dynamic::watch;
use waterui_core::handler::AnyViewBuilder;
let mode = binding(false);
let builder = {
let mode = mode.clone();
AnyViewBuilder::<AnyView>::new(move || {
let mode = mode.clone();
AnyView::new(
watch(mode, |selected| {
use waterui::prelude::*;
if selected {
AnyView::new(().size(120.0, 120.0).background(Color::srgb_hex("#2563EB")))
} else {
AnyView::new(().size(120.0, 120.0).background(Color::srgb_hex("#DC2626")))
}
})
.on_tap(|| {}),
)
})
};
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 160, 160);
let start = Instant::now();
let before = rt
.pump_at(true, start)
.snapshot
.expect("first frame must capture a snapshot");
mode.set(true);
let after = rt
.pump_at(true, start + Duration::from_millis(16))
.snapshot
.expect("switched frame must capture a snapshot");
assert_eq!(
(before.width, before.height),
(after.width, after.height),
"snapshots must share dimensions to compare pixel-for-pixel"
);
assert_ne!(
before.rgba8, after.rgba8,
"the reactive box must update through the .on_tap() gesture wrapper: a \
Captured wrapper would freeze the descendant and produce an identical frame"
);
}
#[test]
fn render_tree_grid_snapshot() {
use waterui_core::handler::AnyViewBuilder;
fn write_png(path: &str, width: u32, height: u32, rgba: Vec<u8>) {
let image = image::RgbaImage::from_raw(width, height, rgba)
.expect("snapshot dimensions must match the rgba buffer");
image.save(path).expect("snapshot png must be writable");
}
fn screen() -> AnyView {
use waterui::layout::grid::{grid, row};
use waterui::prelude::*;
fn cell(label: &str) -> impl View {
button(label.to_owned()).width(92.0)
}
let controls = grid(
3,
[
row((cell("Bar"), cell("Line"), cell("Pie"))),
row((cell("Scatter"), cell("Candle"), cell("Depth"))),
row((cell("Heatmap"), cell("Contour"), cell("Radar"))),
],
)
.spacing(10.0);
AnyView::new(vstack((
controls,
spacer(),
().size(200.0, 100.0).background(Color::srgb_hex("#2563EB")),
spacer(),
)))
}
let builder = AnyViewBuilder::<AnyView>::new(screen);
let env = test_environment();
let mut rt = crate::HeadlessRuntime::new_for_tests(env, builder, 440, 920);
let snapshot = rt
.pump_at(true, std::time::Instant::now())
.snapshot
.expect("grid frame must capture a snapshot");
write_png(
"/tmp/waterui_tree_grid.png",
snapshot.width,
snapshot.height,
snapshot.rgba8,
);
eprintln!("wrote /tmp/waterui_tree_grid.png");
}
#[test]
fn lifecycle_hooks_fire_after_first_flush_and_on_drop() {
use core::cell::Cell;
use std::rc::Rc;
let appeared = Rc::new(Cell::new(false));
let disappeared = Rc::new(Cell::new(false));
let view = {
let appeared = Rc::clone(&appeared);
let disappeared = Rc::clone(&disappeared);
AnyView::new(
waterui_text::text("Lifecycle")
.on_appear(move || appeared.set(true))
.on_disappear(move || disappeared.set(true)),
)
};
let env = test_environment();
let mut renderer = test_renderer();
let bounds = Rect::new(0.0, 0.0, 200.0, 120.0);
renderer.prepare_window_tree(view, &env);
assert!(
!appeared.get(),
"building the retained node must not fire on_appear before its child is flushed"
);
renderer.reset_scene();
renderer.begin_rebuild_frame();
renderer.set_window_bounds(bounds);
renderer.capture_window_tree(
AnyView::new(()),
&env,
bounds,
Affine::IDENTITY,
Affine::IDENTITY,
);
renderer.finish_rebuild_frame();
assert!(
appeared.get(),
"on_appear must fire after the lifecycle wrapper's child first flushes"
);
assert!(
!disappeared.get(),
"on_disappear must not fire while the node is retained in the tree"
);
drop(renderer);
assert!(
disappeared.get(),
"on_disappear must fire when the retained tree (the lifecycle node) is dropped"
);
}
#[test]
fn lifecycle_appear_updates_animate_after_initial_signal_binding() {
use core::time::Duration;
use std::time::Instant;
use waterui::animation::Animation;
use waterui::reactive::binding;
let opacity = binding(0.0f32);
let opacity_for_appear = opacity.clone();
let view = AnyView::new(
().size(40.0, 40.0)
.on_appear(move || opacity_for_appear.set(1.0))
.opacity(opacity.with(Animation::linear(Duration::from_millis(250)))),
);
let env = test_environment();
let mut renderer = test_renderer();
let bounds = Rect::new(0.0, 0.0, 120.0, 120.0);
let start = Instant::now();
renderer.set_frame_instant(start);
renderer.prepare_window_tree(view, &env);
assert_eq!(
opacity.get(),
0.0,
"the entrance target must remain hidden until the child first flushes"
);
assert!(
!renderer.animations_active(),
"building alone must not start an entrance animation"
);
renderer.reset_scene();
renderer.begin_rebuild_frame();
renderer.set_window_bounds(bounds);
renderer.capture_window_tree(
AnyView::new(()),
&env,
bounds,
Affine::IDENTITY,
Affine::IDENTITY,
);
renderer.finish_rebuild_frame();
assert_eq!(
opacity.get(),
1.0,
"on_appear must update the entrance target after the initial sample"
);
assert!(
renderer.animations_active(),
"the post-bind target update must leave the entrance animation active"
);
}
#[test]
fn applied_filter_renders_through_retained_tree() {
fn blurred_box() -> AnyView {
use waterui::prelude::*;
AnyView::new(
().size(48.0, 48.0)
.background(Color::srgb_hex("#DC2626"))
.blur(6.0f32),
)
}
let env = test_environment();
let mut renderer = test_renderer();
let bounds = Rect::new(0.0, 0.0, 120.0, 120.0);
renderer.begin_rebuild_frame();
renderer.set_window_bounds(bounds);
renderer.capture_window_tree(
blurred_box(),
&env,
bounds,
Affine::IDENTITY,
Affine::IDENTITY,
);
renderer.finish_rebuild_frame();
assert!(
!renderer.node_applied_filters.is_empty(),
"a .blur() view must build a node-owned AppliedFilter on the retained tree \
(registered at build), not fall through to the dispatch/capture path"
);
let flushed = renderer.flush_window_tree(&env, bounds, Affine::IDENTITY, Affine::IDENTITY);
assert!(flushed, "the retained tree must re-flush");
assert!(
!renderer.node_applied_filters.is_empty(),
"the node-owned filter runtime must survive a geometry-static re-flush \
(the retained AppliedFilter node keeps owning it across frames)"
);
}