hydrolysis 0.1.0

A modern UI framework for Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
//! Frame-economy regression harness for the Hydrolysis retained-scene refactor.
//!
//! These tests drive the full headless frame pump over representative animated
//! content and assert on [`FrameCounters`]. They lock in the frame economy so
//! that parametric updates (animation ticks, scroll offset changes) stop forcing
//! whole-window re-dispatch and re-measurement.
//!
//! A capture-and-replay engine only avoids a full rebuild for one narrow case: an animated
//! *transform* (scale/rotation/offset) inside a single window-filling scroll view,
//! which it captures as a replayable `DynamicTransformDraw`. Every other animation
//! — a non-transform property such as opacity, or any animation at the window root
//! with no retained scroll frame — falls back to a full scene rebuild (and full
//! re-measure) on every frame. That is the regression this refactor eliminates.

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;

/// Aggregated frame-economy metrics over a run of parametric (post-trigger) frames.
#[derive(Debug, Clone, Copy, Default)]
struct ScenarioMetrics {
    /// Number of frames pumped while the animation was active.
    parametric_frames: u32,
    /// How many of those frames performed at least one full scene rebuild.
    frames_rebuilt: u32,
    /// Total measurement-cache misses across the parametric frames (re-measure cost).
    measurement_misses: u32,
}

/// A long scrollable list of fixed-size rows, used to pad scenarios so the scroll
/// view is the exclusive window-filling root that a whole-window fast path requires.
fn padding_list() -> impl waterui::View {
    let rows = (0..40).map(SelfId::new).collect::<Vec<_>>();
    VStack::for_each(rows, |_| ().size(360.0, 44.0))
}

/// Case A — animated **opacity at the window root** (no scroll frame at all).
/// Legacy: rebuilds the whole scene every animation frame.
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),
    )))
}

/// Case B — animated **opacity inside a scroll view**. The opacity is baked into
/// the captured scroll content, so a capture cache is `animation_dependent` and
/// bails to a full rebuild every frame.
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()))))
}

/// Case A' — animated **transform (scale) at the window root** (no scroll frame).
/// Legacy bakes root transforms and rebuilds every frame; the window retained frame
/// must replay it without a rebuild.
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),
    )))
}

/// Case C — animated **transform (scale) inside a scroll view**. This is the one
/// case a captured-scroll path can already replay without a rebuild; it
/// exists here as a no-regression guard for the refactor.
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)
    }
}

/// Pumps one initial structural frame, triggers an animation by moving `value`,
/// then pumps `parametric_frames` further frames while the animation is active and
/// records the frame economy.
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();
    // Initial structural build.
    let _ = runtime.pump_at(false, start);

    // Start an animation: the bound value animates from 1.0 -> 0.25 over 1s.
    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
}

/// A `Dynamic` node whose content changes color (but keeps a fixed size) with `value`,
/// sitting above a long static list. A content change here keeps the surrounding layout
/// stable, so it must be applied as an isolated reactive patch.
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)
}

/// Phase 3: a same-size content change to one `Dynamic` node is applied as an isolated
/// reactive patch — only that node is re-dispatched, and the frame is re-composited
/// without any structural rebuild of the rest of the window.
#[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"
    );
}

/// A `Dynamic` whose content changes *height* and paints a visible colour, above a
/// long list of visible rows, so a reflow is observable in pixels.
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)))
}

/// The retained tree reflows a size-changing `Dynamic` content change incrementally
/// — it relays out the persistent tree in place (the surrounding rows shift down)
/// without a whole-window structural rebuild. Resetting the scene and re-dispatching
/// the whole window instead is visible as a layout flicker: that engine re-ran the whole
/// window for any size-affecting change, which flashed. The reflowed frame must be
/// pixel-identical to composing the same content statically.
#[test]
fn dynamic_size_change_reflows_without_rebuild() {
    // Ground truth: the same composition built statically at the final value.
    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"
    );
}

/// A reactive collection (`ForEach`/`List`) in a full-window `AbsoluteLayout`
/// overlay, mirroring the snackbar overlay: each item is a fixed-size box keyed
/// by id. Membership changes must reconcile in isolation (the collection's size
/// is constant — `AbsoluteLayout` fills the window regardless of item count — so
/// no surrounding reflow is needed).
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>| {
                // Colour by id so the topmost (last) item — and thus the composited
                // pixels — changes when membership changes. `AbsoluteLayout` stacks
                // every item at the same origin, so the last opaque item shows.
                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)
}

/// Adding an item to a reactive collection in a constant-size overlay reconciles
/// as an isolated patch — only the new item is dispatched, and the window is
/// re-composited without any structural rebuild of the rest of the tree.
#[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)"
    );
}

/// Removing an item from a reactive collection in a constant-size overlay also
/// reconciles as an isolated patch (the removed item's retained subtree is
/// evicted; survivors keep theirs).
#[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");

    // Remove the last (topmost) item so the composited overlay pixels change.
    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)"
    );
}

/// Phase 4: scrolling a fixed (non-lazy) list re-composites the retained window frame at
/// the new offset — the scroll fast-path is subsumed into the window frame — with no
/// structural rebuild.
#[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;

/// Phase 2B: an opacity animation at the window root is captured as a replayable
/// dynamic opacity layer and replays through the window frame without a rebuild or
/// re-measure. Baking the opacity into the capture instead forces a rebuild every frame.
#[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:?}"
    );
}

/// Phase 2B: an opacity animation inside a scroll view replays through the retained
/// scroll cache without a rebuild — the case that previously forced a full rebuild
/// (and the flashing) on every animation/scroll frame.
#[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:?}"
    );
}

/// Phase 2A: an animated transform at the window root replays through the retained
/// window frame without any structural rebuild or re-measure.
#[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:?}"
    );
}

/// NO-REGRESSION GUARD: an animated transform inside a window-filling scroll already
/// replays without a rebuild today. The refactor must keep it at zero rebuilds.
#[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));

    // Every awake frame runs layout, and the animated scale is
    // layout-transparent (placements do not depend on it). The guarantee worth
    // pinning is that no animation tick rebuilds the window: the retained tree,
    // and the component state it owns, survives every frame.
    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"
        );
    }
}

/// A size-changing `Dynamic` patch reflows the new content at its grown bounds — not
/// replay a capture made under the stale placement — without a whole-window rebuild.
/// An overlay-style `Dynamic` that grows from empty (zero-size) to visible content is the
/// canonical case (snackbar presentation): the retained tree rebuilds only that node's
/// child and relays out in place, and the grown frame must render pixel-identical to
/// composing the same content statically.
#[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"))
    }

    // Ground truth: the same composition built statically.
    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");

    // Mirror the runtime window composition: overlay Dynamic layered in a ZStack
    // above the main content, sized by its own (initially zero) measurement.
    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"
    );
}

/// Each item paints a reactive background driven by `selected` (the selected id
/// fills `ACTIVE`, the rest fill `INACTIVE`), inside a `collection_transition`,
/// which routes the `VStack` collection to the retained per-item path — the same
/// path the navigation-drawer active-indicator pill uses. A sibling whose *height*
/// tracks the same `selected` binding forces a selection change to reflow the
/// layout and escalate to a structural rebuild, which reuses the collection's item
/// subtrees rather than re-dispatching them.
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)))
}

/// Regression: a retained collection item's nested reactive content tracks its
/// signal across a sibling-driven reflow. The active-indicator `.background(
/// Computed<Color>)` must repaint when `selected` changes — the navigation drawer's
/// active pill moving on selection — and the size-tracking sibling's reflow must be
/// an incremental relayout of the retained tree, not a whole-window rebuild.
#[test]
fn reused_collection_item_reactive_background_tracks_on_selection() {
    // Ground truth: built fresh with the second item already selected.
    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");

    // Subject: start with the first item selected, then select the second. The
    // size-tracking sibling reflows, escalating the change to a structural rebuild
    // that reuses the collection items.
    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"
    );
}

/// A `collection_transition` stack whose rows are distinct solid colors, so a
/// snapshot uniquely identifies which rows are visible and at what extent.
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)),
    ))
}

/// Removing an item from a `collection_transition` collection animates it out:
/// a mid-flight frame differs from both the before frame (the row is collapsing)
/// and the settled frame (it has not finished), and after the animation the
/// frame is pixel-identical to a fresh build of the final membership — the
/// exiting row's node is dropped and its space fully released.
#[test]
fn collection_membership_exit_animates_then_settles() {
    // Ground truth: built fresh with the settled membership (rows 0 and 2).
    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");

    // Subject: rows 0, 1, 2; remove the middle row and watch it collapse out.
    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);
    // The exit starts on the first frame that processes the membership change.
    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"
    );
}

/// Re-adding an item mid-exit turns the exit into a fresh enter (no pop), and
/// an added item fades/grows in: the settled frame matches a fresh build of the
/// grown membership.
#[test]
fn collection_membership_enter_animates_then_settles() {
    // Ground truth: built fresh with all three rows.
    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");

    // Subject: rows 0 and 2; insert row 1 between them and watch it grow in.
    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));
    // The enter starts on the first frame that processes the membership change.
    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"
    );
}