1
//! Centralized GPU state management.
2
//!
3
//! This module provides management of GPU property keys
4
//! (opacity, transforms, etc.), fade-in/fade-out animations
5
//! for scrollbar opacity - as a single source of truth for
6
//! the GPU cache.
7

            
8
use crate::solver3::layout_tree::LayoutNodeId;
9
use alloc::collections::BTreeMap;
10

            
11
#[cfg(feature = "std")]
12
use std::collections::HashMap;
13
#[cfg(not(feature = "std"))]
14
use alloc::collections::BTreeMap as HashMap;
15

            
16
use azul_core::{
17
    dom::{DomId, NodeId},
18
    dom::ScrollbarOrientation,
19
    geom::{LogicalPosition, LogicalRect, LogicalSize},
20
    gpu::{GpuEventChanges, GpuTransformKeyEvent, GpuValueCache},
21
    resources::TransformKey,
22
    task::{Duration, SystemTimeDiff},
23
    transform::ComputedTransform3D,
24
};
25

            
26
use crate::{
27
    managers::scroll_state::ScrollManager,
28
    solver3::{
29
        fc::DEFAULT_SCROLLBAR_WIDTH_PX,
30
        layout_tree::LayoutTree,
31
        scrollbar::compute_scrollbar_geometry_with_button_size,
32
    },
33
};
34

            
35
/// Default delay before scrollbars start fading out (500ms)
36
pub const DEFAULT_FADE_DELAY_MS: u64 = 500;
37
/// Default duration of scrollbar fade-out animation (200ms)
38
pub const DEFAULT_FADE_DURATION_MS: u64 = 200;
39

            
40
/// Manages GPU-accelerated properties across all DOMs.
41
///
42
/// The `GpuStateManager` maintains caches for transform and opacity keys
43
/// that are used by the GPU renderer. It handles:
44
///
45
/// - Scrollbar thumb position transforms (updated on scroll)
46
/// - Opacity fading for scrollbars (fade in on activity, fade out after delay)
47
/// - Per-DOM GPU value caches for efficient rendering
48
#[derive(Debug, Clone)]
49
pub struct GpuStateManager {
50
    /// GPU value caches indexed by DOM ID
51
    pub caches: BTreeMap<DomId, GpuValueCache>,
52
    /// Delay before scrollbars start fading out after last activity
53
    pub fade_delay: Duration,
54
    /// Duration of the fade-out animation
55
    pub fade_duration: Duration,
56
    /// Whether any scrollbar has non-zero opacity and needs continued frame
57
    /// generation. Set during both the `fade_delay` period (opacity == 1.0)
58
    /// and the active fade-out phase (0 < opacity < 1).
59
    /// Set by `LayoutWindow::synchronize_scrollbar_opacity`, read by the platform render loop.
60
    pub scrollbar_fade_active: bool,
61
    /// GPU events produced during layout (CSS transform / opacity synchronization,
62
    /// scrollbar transform / opacity updates) that have not yet been pushed to
63
    /// the renderer. Drained by the platform render path when a transaction is
64
    /// built.
65
    pub pending_changes: GpuEventChanges,
66
}
67

            
68
impl Default for GpuStateManager {
69
36
    fn default() -> Self {
70
36
        Self::new(
71
36
            Duration::System(SystemTimeDiff::from_millis(DEFAULT_FADE_DELAY_MS)),
72
36
            Duration::System(SystemTimeDiff::from_millis(DEFAULT_FADE_DURATION_MS)),
73
        )
74
36
    }
75
}
76

            
77
impl GpuStateManager {
78
    /// Creates a new GPU state manager with specified fade timing.
79
5618
    #[must_use] pub fn new(fade_delay: Duration, fade_duration: Duration) -> Self {
80
5618
        Self {
81
5618
            caches: BTreeMap::new(),
82
5618
            fade_delay,
83
5618
            fade_duration,
84
5618
            scrollbar_fade_active: false,
85
5618
            pending_changes: GpuEventChanges::empty(),
86
5618
        }
87
5618
    }
88

            
89
    /// Take any queued transform / opacity events that have been accumulated
90
    /// during layout. Clears the internal buffer.
91
4597
    pub fn take_pending_changes(&mut self) -> GpuEventChanges {
92
4597
        core::mem::take(&mut self.pending_changes)
93
4597
    }
94

            
95
    // NOTE: the per-frame scrollbar-fade interpolation lives in
96
    // `LayoutWindow::synchronize_scrollbar_opacity` (layout/src/window.rs),
97
    // which reads the last-activity time straight from the `ScrollManager` and
98
    // is the single source of truth for fade opacity. An earlier duplicate
99
    // tick-based subsystem here (`tick` / `record_scroll_activity` /
100
    // `calculate_fade_opacity` + a `fade_states` map) was never wired into any
101
    // render loop and has been removed.
102

            
103
    /// Gets or creates the GPU cache for a specific DOM.
104
16967
    #[must_use] pub fn get_cache(&self, dom_id: DomId) -> Option<&GpuValueCache> {
105
16967
        self.caches.get(&dom_id)
106
16967
    }
107

            
108
37765
    pub fn get_or_create_cache(&mut self, dom_id: DomId) -> &mut GpuValueCache {
109
37765
        self.caches.entry(dom_id).or_default()
110
37765
    }
111

            
112
    /// Updates scrollbar thumb transforms based on current scroll positions.
113
    ///
114
    /// Calculates the transform needed to position scrollbar thumbs correctly
115
    /// based on the scroll offset and content/container sizes. Returns the
116
    /// GPU event changes that need to be applied by the renderer.
117
21273
    pub fn update_scrollbar_transforms(
118
21273
        &mut self,
119
21273
        dom_id: DomId,
120
21273
        scroll_manager: &ScrollManager,
121
21273
        layout_tree: &LayoutTree,
122
21273
    ) -> GpuEventChanges {
123
21273
        let mut changes = GpuEventChanges::empty();
124
21273
        let gpu_cache = self.get_or_create_cache(dom_id);
125

            
126
396706
        for (node_idx, node) in layout_tree.nodes.iter().enumerate() {
127
396706
            let warm = layout_tree.warm(LayoutNodeId::new(node_idx));
128
            // The necessity flags are the layout pass's, amended after layout by
129
            // `cache::apply_virtual_scroll_necessity` (via `register_scroll_nodes`)
130
            // for a `VirtualView`, whose scrollable extent layout cannot see. This
131
            // path deliberately re-derives nothing: it reads the one stored answer,
132
            // so a bar that `paint_scrollbars` drew always gets its thumb moved.
133
396706
            let Some(scrollbar_info) = warm.and_then(|w| w.scrollbar_info.as_ref()) else {
134
67206
                continue;
135
            };
136
329500
            let Some(node_id) = node.dom_node_id else {
137
124
                continue;
138
            };
139

            
140
329376
            let scroll_offset = scroll_manager
141
329376
                .get_current_offset(dom_id, node_id)
142
329376
                .unwrap_or_default();
143

            
144
            // Compute inner_rect (padding-box) by subtracting borders from used_size
145
329376
            let border_box_size = node.used_size.unwrap_or_default();
146
329376
            let nbp = node.box_props.unpack();
147
329376
            let border = &nbp.border;
148
329376
            let inner_size = LogicalSize {
149
329376
                width: (border_box_size.width - border.left - border.right).max(0.0),
150
329376
                height: (border_box_size.height - border.top - border.bottom).max(0.0),
151
329376
            };
152
            // Use zero origin since we only need the geometry ratios, not absolute position
153
329376
            let inner_rect = LogicalRect {
154
329376
                origin: LogicalPosition::new(0.0, 0.0),
155
329376
                size: inner_size,
156
329376
            };
157

            
158
            // `display_list::paint_scrollbars` sizes the thumb from
159
            // `ScrollPosition::children_rect.size` — the `VirtualView` virtual size
160
            // when the callback reported one, else the laid-out content size. This
161
            // path OVERWRITES the transform key that path seeds, so it has to size
162
            // from the same number: a `VirtualView` is a replaced element with no
163
            // flow content, so `get_content_size` returns roughly the viewport and
164
            // the thumb would snap to the full track on the first live scroll.
165
329376
            let content_size = scroll_manager
166
329376
                .get_scroll_state(dom_id, node_id)
167
329376
                .and_then(|s| s.virtual_scroll_size)
168
329376
                .unwrap_or_else(|| layout_tree.get_content_size(LayoutNodeId::new(node_idx)));
169

            
170
329376
            if scrollbar_info.needs_vertical {
171
                // Use the visual width from the scrollbar style — same value used
172
                // by display_list.rs to paint the scrollbar. For overlay scrollbars,
173
                // visual_width_px is non-zero (e.g. 8.0) even though the layout-
174
                // reserved width (scrollbar_height) is 0.0.
175
17823
                let is_overlay = scrollbar_info.scrollbar_height == 0.0;
176
17823
                let scrollbar_width_px = if scrollbar_info.visual_width_px > 0.0 {
177
17821
                    scrollbar_info.visual_width_px
178
2
                } else if !is_overlay {
179
                    scrollbar_info.scrollbar_height
180
                } else {
181
2
                    DEFAULT_SCROLLBAR_WIDTH_PX
182
                };
183
                // Overlay scrollbars (macOS-style) have no arrow buttons
184
17823
                let button_size = if is_overlay { 0.0 } else { scrollbar_width_px };
185

            
186
17823
                let v_geom = compute_scrollbar_geometry_with_button_size(
187
17823
                    ScrollbarOrientation::Vertical,
188
17823
                    inner_rect,
189
17823
                    content_size,
190
17823
                    scroll_offset.y,
191
17823
                    scrollbar_width_px,
192
17823
                    scrollbar_info.needs_horizontal,
193
17823
                    button_size,
194
                );
195

            
196
                // Quantised to whole logical pixels: this value is the only
197
                // channel that can damage the bar, and a sub-pixel thumb move
198
                // repaints a gutter that did not visibly change. See
199
                // `quantize_thumb_offset` — `paint_scrollbars` bakes the
200
                // display list's initial value through the same rounding.
201
17823
                let transform = ComputedTransform3D::new_translation(
202
                    0.0,
203
17823
                    crate::solver3::scrollbar::quantize_thumb_offset(v_geom.thumb_offset),
204
                    0.0,
205
                );
206
17823
                update_scrollbar_transform_key(gpu_cache, &mut changes, node_id, transform, ScrollbarOrientation::Vertical);
207
311553
            }
208

            
209
329376
            if scrollbar_info.needs_horizontal {
210
102
                let is_overlay = scrollbar_info.scrollbar_width == 0.0;
211
102
                let scrollbar_width_px = if scrollbar_info.visual_width_px > 0.0 {
212
101
                    scrollbar_info.visual_width_px
213
1
                } else if !is_overlay {
214
                    scrollbar_info.scrollbar_width
215
                } else {
216
1
                    DEFAULT_SCROLLBAR_WIDTH_PX
217
                };
218
102
                let button_size = if is_overlay { 0.0 } else { scrollbar_width_px };
219

            
220
102
                let h_geom = compute_scrollbar_geometry_with_button_size(
221
102
                    ScrollbarOrientation::Horizontal,
222
102
                    inner_rect,
223
102
                    content_size,
224
102
                    scroll_offset.x,
225
102
                    scrollbar_width_px,
226
102
                    scrollbar_info.needs_vertical,
227
102
                    button_size,
228
                );
229

            
230
102
                let transform = ComputedTransform3D::new_translation(
231
102
                    crate::solver3::scrollbar::quantize_thumb_offset(h_geom.thumb_offset),
232
                    0.0,
233
                    0.0,
234
                );
235
102
                update_scrollbar_transform_key(gpu_cache, &mut changes, node_id, transform, ScrollbarOrientation::Horizontal);
236
329274
            }
237
        }
238

            
239
21273
        changes
240
21273
    }
241
}
242

            
243
/// Updates or creates a scrollbar transform key in the GPU cache for the given orientation.
244
17949
fn update_scrollbar_transform_key(
245
17949
    gpu_cache: &mut GpuValueCache,
246
17949
    changes: &mut GpuEventChanges,
247
17949
    node_id: NodeId,
248
17949
    transform: ComputedTransform3D,
249
17949
    orientation: ScrollbarOrientation,
250
17949
) {
251
17949
    let (keys, values) = match orientation {
252
17846
        ScrollbarOrientation::Vertical => (
253
17846
            &mut gpu_cache.transform_keys,
254
17846
            &mut gpu_cache.current_transform_values,
255
17846
        ),
256
103
        ScrollbarOrientation::Horizontal => (
257
103
            &mut gpu_cache.h_transform_keys,
258
103
            &mut gpu_cache.h_current_transform_values,
259
103
        ),
260
    };
261

            
262
17949
    if let Some(existing_transform) = values.get(&node_id) {
263
16918
        if *existing_transform != transform {
264
24
            let Some(&transform_key) = keys.get(&node_id) else {
265
1
                return;
266
            };
267
23
            changes
268
23
                .transform_key_changes
269
23
                .push(GpuTransformKeyEvent::Changed(
270
23
                    node_id,
271
23
                    transform_key,
272
23
                    *existing_transform,
273
23
                    transform,
274
23
                ));
275
23
            values.insert(node_id, transform);
276
16894
        }
277
1031
    } else {
278
1031
        let transform_key = TransformKey::unique();
279
1031
        keys.insert(node_id, transform_key);
280
1031
        values.insert(node_id, transform);
281
1031
        changes
282
1031
            .transform_key_changes
283
1031
            .push(GpuTransformKeyEvent::Added(
284
1031
                node_id,
285
1031
                transform_key,
286
1031
                transform,
287
1031
            ));
288
1031
    }
289
17949
}
290

            
291
impl crate::managers::NodeIdRemap for GpuStateManager {
292
    /// Remap the per-node GPU caches (transform / opacity keys and values).
293
    ///
294
    /// Without this, a rebuild that shifts `NodeIds` left the scrollbar/CSS
295
    /// transform + opacity keys attached to the wrong node — the visible symptom
296
    /// being a scrollbar thumb (or an animated element) that keeps painting at a
297
    /// stale offset, plus `scrollbar_fade_active` never settling because the
298
    /// platform loop keeps generating frames for a node that is not the one
299
    /// actually scrolling.
300
28
    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
301
28
        let Some(cache) = self.caches.get_mut(&dom) else {
302
1
            return;
303
        };
304

            
305
27
        remap_hashmap(&mut cache.transform_keys, map);
306
27
        remap_hashmap(&mut cache.current_transform_values, map);
307
27
        remap_hashmap(&mut cache.h_transform_keys, map);
308
27
        remap_hashmap(&mut cache.h_current_transform_values, map);
309
27
        remap_hashmap(&mut cache.css_transform_keys, map);
310
27
        remap_hashmap(&mut cache.css_current_transform_values, map);
311
27
        remap_hashmap(&mut cache.opacity_keys, map);
312
27
        remap_hashmap(&mut cache.current_opacity_values, map);
313
27
        remap_dom_hashmap(&mut cache.scrollbar_v_opacity_keys, dom, map);
314
27
        remap_dom_hashmap(&mut cache.scrollbar_h_opacity_keys, dom, map);
315
27
        remap_dom_hashmap(&mut cache.scrollbar_v_opacity_values, dom, map);
316
27
        remap_dom_hashmap(&mut cache.scrollbar_h_opacity_values, dom, map);
317
28
    }
318
}
319

            
320
/// Rewrite `NodeId` keys, dropping entries for unmounted nodes.
321
221
fn remap_hashmap<V>(map: &mut HashMap<NodeId, V>, node_map: &crate::managers::NodeIdMap) {
322
221
    let old = core::mem::take(map);
323
249
    for (old_id, v) in old {
324
28
        if let Some(new_id) = node_map.resolve(old_id) {
325
20
            map.insert(new_id, v);
326
20
        }
327
    }
328
221
}
329

            
330
/// Rewrite `(DomId, NodeId)` keys for `dom` only, dropping unmounted nodes.
331
112
fn remap_dom_hashmap<V>(
332
112
    map: &mut HashMap<(DomId, NodeId), V>,
333
112
    dom: DomId,
334
112
    node_map: &crate::managers::NodeIdMap,
335
112
) {
336
112
    let old = core::mem::take(map);
337
128
    for ((d, old_id), v) in old {
338
16
        if d != dom {
339
3
            map.insert((d, old_id), v);
340
13
        } else if let Some(new_id) = node_map.resolve(old_id) {
341
9
            map.insert((d, new_id), v);
342
11
        }
343
    }
344
112
}
345

            
346
#[cfg(test)]
347
mod autotest_generated {
348
    use azul_core::{
349
        dom::{Dom, FormattingContext, IdOrClass, NodeType},
350
        resources::OpacityKey,
351
        styled_dom::StyledDom,
352
        task::{Instant, SystemTick, SystemTickDiff},
353
    };
354

            
355
    use super::*;
356
    use crate::{
357
        managers::{NodeIdMap, NodeIdRemap},
358
        solver3::{
359
            cache::apply_virtual_scroll_necessity,
360
            geometry::PackedBoxProps,
361
            layout_tree::{LayoutNodeCold, LayoutNodeHot, LayoutNodeWarm},
362
            scrollbar::ScrollbarRequirements,
363
        },
364
    };
365

            
366
    // ------------------------------------------------------------------
367
    // Fixtures
368
    // ------------------------------------------------------------------
369

            
370
    fn dom(inner: usize) -> DomId {
371
        DomId { inner }
372
    }
373

            
374
    fn t0() -> Instant {
375
        Instant::Tick(SystemTick::new(0))
376
    }
377

            
378
    fn millis(ms: u64) -> Duration {
379
        Duration::System(SystemTimeDiff::from_millis(ms))
380
    }
381

            
382
    /// Translation transform — the only shape `update_scrollbar_transforms`
383
    /// ever produces.
384
    fn tx(x: f32, y: f32) -> ComputedTransform3D {
385
        ComputedTransform3D::new_translation(x, y, 0.0)
386
    }
387

            
388
    fn hot(dom_node_id: Option<NodeId>, used: Option<LogicalSize>) -> LayoutNodeHot {
389
        LayoutNodeHot {
390
            box_props: PackedBoxProps::default(),
391
            dom_node_id,
392
            used_size: used,
393
            formatting_context: FormattingContext::Block {
394
                establishes_new_context: true,
395
            },
396
            parent: None,
397
        }
398
    }
399

            
400
    fn warm_node(
401
        scrollbar_info: Option<ScrollbarRequirements>,
402
        content: Option<LogicalSize>,
403
    ) -> LayoutNodeWarm {
404
        LayoutNodeWarm {
405
            scrollbar_info,
406
            overflow_content_size: content,
407
            ..Default::default()
408
        }
409
    }
410

            
411
    /// What layout computes for an `overflow-y: auto` VirtualView: nothing.
412
    /// The node is a replaced element with no flow content, so its laid-out
413
    /// content never exceeds its box and `check_scrollbar_necessity` finds no
414
    /// reason for a bar — only `visual_width_px` survives from the CSS style.
415
    fn auto_no_scrollbar() -> ScrollbarRequirements {
416
        ScrollbarRequirements {
417
            needs_horizontal: false,
418
            needs_vertical: false,
419
            scrollbar_width: 0.0,
420
            scrollbar_height: 0.0,
421
            visual_width_px: 16.0,
422
        }
423
    }
424

            
425
    /// `body(0) > VirtualView(1)` with `overflow-x: hidden; overflow-y: auto`.
426
    /// NodeId 1 is the node every fixture in this module registers.
427
    fn auto_virtual_view_dom() -> StyledDom {
428
        let mut dom = Dom::create_body().with_child(
429
            Dom::create_node(NodeType::VirtualView)
430
                .with_ids_and_classes(vec![IdOrClass::Class("vv".into())].into()),
431
        );
432
        let (css, _warnings) =
433
            azul_css::parser2::new_from_str(".vv { overflow-x: hidden; overflow-y: auto; }");
434
        StyledDom::create(&mut dom, css)
435
    }
436

            
437
    /// A classic (space-reserving) vertical scrollbar with an explicit visual
438
    /// width — the un-ambiguous case where no field-fallback logic kicks in.
439
    fn v_scrollbar() -> ScrollbarRequirements {
440
        ScrollbarRequirements {
441
            needs_horizontal: false,
442
            needs_vertical: true,
443
            scrollbar_width: 16.0,
444
            scrollbar_height: 16.0,
445
            visual_width_px: 16.0,
446
        }
447
    }
448

            
449
    fn h_scrollbar() -> ScrollbarRequirements {
450
        ScrollbarRequirements {
451
            needs_horizontal: true,
452
            needs_vertical: false,
453
            scrollbar_width: 16.0,
454
            scrollbar_height: 16.0,
455
            visual_width_px: 16.0,
456
        }
457
    }
458

            
459
    fn tree(nodes: Vec<LayoutNodeHot>, warm: Vec<LayoutNodeWarm>) -> LayoutTree {
460
        let n = nodes.len();
461
        LayoutTree {
462
            nodes,
463
            warm,
464
            cold: vec![LayoutNodeCold::default(); n],
465
            root: 0,
466
            dom_to_layout: BTreeMap::new(),
467
            children_arena: Vec::new(),
468
            children_offsets: vec![(0, 0); n],
469
            subtree_needs_intrinsic: Vec::new(),
470
        }
471
    }
472

            
473
    /// Single scrollable node: 100×100 border-box, `content` content-box.
474
    fn one_node_tree(sb: ScrollbarRequirements, content: LogicalSize) -> LayoutTree {
475
        tree(
476
            vec![hot(
477
                Some(NodeId::new(1)),
478
                Some(LogicalSize::new(100.0, 100.0)),
479
            )],
480
            vec![warm_node(Some(sb), Some(content))],
481
        )
482
    }
483

            
484
    /// The y-translation of the single transform event emitted, if any.
485
    fn sole_added_y(changes: &GpuEventChanges) -> f32 {
486
        assert_eq!(changes.transform_key_changes.len(), 1);
487
        match changes.transform_key_changes[0] {
488
            GpuTransformKeyEvent::Added(_, _, t) => t.m[3][1],
489
            ref other => panic!("expected Added, got {other:?}"),
490
        }
491
    }
492

            
493
    fn sole_added_x(changes: &GpuEventChanges) -> f32 {
494
        assert_eq!(changes.transform_key_changes.len(), 1);
495
        match changes.transform_key_changes[0] {
496
            GpuTransformKeyEvent::Added(_, _, t) => t.m[3][0],
497
            ref other => panic!("expected Added, got {other:?}"),
498
        }
499
    }
500

            
501
    // ------------------------------------------------------------------
502
    // GpuStateManager::new / Default — constructor invariants
503
    // ------------------------------------------------------------------
504

            
505
    #[test]
506
    fn new_stores_both_durations_and_starts_empty_and_idle() {
507
        let m = GpuStateManager::new(millis(1), millis(2));
508
        assert_eq!(m.fade_delay, millis(1));
509
        assert_eq!(m.fade_duration, millis(2));
510
        assert!(m.caches.is_empty());
511
        assert!(!m.scrollbar_fade_active);
512
        assert!(m.pending_changes.is_empty());
513
    }
514

            
515
    #[test]
516
    fn new_survives_zero_and_u64_max_durations_without_panicking() {
517
        // Zero fade window: the fade math elsewhere divides by fade_duration,
518
        // so a 0 duration must at least be constructible without panicking here.
519
        let zero = GpuStateManager::new(millis(0), millis(0));
520
        assert_eq!(zero.fade_delay, millis(0));
521
        assert_eq!(zero.fade_duration, millis(0));
522

            
523
        // from_millis(u64::MAX) is ~584 million years — no overflow, no panic.
524
        let huge = GpuStateManager::new(millis(u64::MAX), millis(u64::MAX));
525
        assert_eq!(huge.fade_delay, millis(u64::MAX));
526
        assert!(huge.caches.is_empty());
527
    }
528

            
529
    #[test]
530
    fn new_accepts_tick_durations_not_just_system_durations() {
531
        // Duration is an enum; the constructor must not assume the System variant.
532
        let tick = Duration::Tick(SystemTickDiff { tick_diff: u64::MAX });
533
        let m = GpuStateManager::new(tick, tick);
534
        assert_eq!(m.fade_delay, tick);
535
        assert_eq!(m.fade_duration, tick);
536
    }
537

            
538
    #[test]
539
    fn default_matches_the_documented_fade_constants() {
540
        let m = GpuStateManager::default();
541
        assert_eq!(m.fade_delay, millis(DEFAULT_FADE_DELAY_MS));
542
        assert_eq!(m.fade_duration, millis(DEFAULT_FADE_DURATION_MS));
543
        assert_eq!(DEFAULT_FADE_DELAY_MS, 500);
544
        assert_eq!(DEFAULT_FADE_DURATION_MS, 200);
545
    }
546

            
547
    // ------------------------------------------------------------------
548
    // take_pending_changes
549
    // ------------------------------------------------------------------
550

            
551
    #[test]
552
    fn take_pending_changes_drains_the_buffer_and_the_second_take_is_empty() {
553
        let mut m = GpuStateManager::default();
554
        m.pending_changes
555
            .transform_key_changes
556
            .push(GpuTransformKeyEvent::Added(
557
                NodeId::new(1),
558
                TransformKey::unique(),
559
                tx(0.0, 4.0),
560
            ));
561

            
562
        let taken = m.take_pending_changes();
563
        assert_eq!(taken.transform_key_changes.len(), 1);
564
        // The whole point of `take`: the buffer must not replay next frame.
565
        assert!(m.pending_changes.is_empty());
566
        assert!(m.take_pending_changes().is_empty());
567
    }
568

            
569
    #[test]
570
    fn take_pending_changes_on_a_fresh_manager_is_an_empty_no_panic() {
571
        let mut m = GpuStateManager::default();
572
        for _ in 0..3 {
573
            assert_eq!(m.take_pending_changes(), GpuEventChanges::empty());
574
        }
575
    }
576

            
577
    // ------------------------------------------------------------------
578
    // get_cache / get_or_create_cache
579
    // ------------------------------------------------------------------
580

            
581
    #[test]
582
    fn get_cache_on_an_unknown_dom_returns_none_and_does_not_create_it() {
583
        let m = GpuStateManager::default();
584
        assert!(m.get_cache(dom(0)).is_none());
585
        assert!(m.get_cache(dom(usize::MAX)).is_none());
586
        assert!(m.caches.is_empty(), "get_cache must not mutate");
587
    }
588

            
589
    #[test]
590
    fn get_or_create_cache_is_idempotent_and_never_clobbers_existing_state() {
591
        let mut m = GpuStateManager::default();
592
        let node = NodeId::new(7);
593
        let key = TransformKey::unique();
594

            
595
        m.get_or_create_cache(dom(0)).transform_keys.insert(node, key);
596
        assert_eq!(m.caches.len(), 1);
597

            
598
        // Second call must hand back the *same* cache, not a fresh default one —
599
        // otherwise every frame would mint new GPU keys and leak them.
600
        let again = m.get_or_create_cache(dom(0));
601
        assert_eq!(again.transform_keys.get(&node), Some(&key));
602
        assert_eq!(m.caches.len(), 1);
603
        assert!(m.get_cache(dom(0)).is_some());
604
    }
605

            
606
    #[test]
607
    fn caches_for_distinct_dom_ids_including_usize_max_do_not_alias() {
608
        let mut m = GpuStateManager::default();
609
        let node = NodeId::new(0);
610

            
611
        m.get_or_create_cache(dom(0))
612
            .current_opacity_values
613
            .insert(node, 0.25);
614
        m.get_or_create_cache(dom(usize::MAX))
615
            .current_opacity_values
616
            .insert(node, 0.75);
617

            
618
        assert_eq!(m.caches.len(), 2);
619
        assert_eq!(
620
            m.get_cache(dom(0)).unwrap().current_opacity_values.get(&node),
621
            Some(&0.25)
622
        );
623
        assert_eq!(
624
            m.get_cache(dom(usize::MAX))
625
                .unwrap()
626
                .current_opacity_values
627
                .get(&node),
628
            Some(&0.75)
629
        );
630
    }
631

            
632
    // ------------------------------------------------------------------
633
    // update_scrollbar_transform_key (private)
634
    // ------------------------------------------------------------------
635

            
636
    #[test]
637
    fn first_update_emits_added_and_populates_both_the_key_and_value_map() {
638
        let mut cache = GpuValueCache::default();
639
        let mut changes = GpuEventChanges::empty();
640
        let node = NodeId::new(3);
641
        let t = tx(0.0, 12.0);
642

            
643
        update_scrollbar_transform_key(
644
            &mut cache,
645
            &mut changes,
646
            node,
647
            t,
648
            ScrollbarOrientation::Vertical,
649
        );
650

            
651
        let key = cache.transform_keys.get(&node).copied().expect("key stored");
652
        assert_eq!(cache.current_transform_values.get(&node), Some(&t));
653
        assert_eq!(
654
            changes.transform_key_changes,
655
            vec![GpuTransformKeyEvent::Added(node, key, t)]
656
        );
657
    }
658

            
659
    #[test]
660
    fn re_updating_with_an_identical_transform_emits_nothing() {
661
        // The cache exists to suppress redundant GPU traffic: a node that did not
662
        // move must produce zero events, forever.
663
        let mut cache = GpuValueCache::default();
664
        let node = NodeId::new(3);
665
        let t = tx(0.0, 12.0);
666

            
667
        let mut first = GpuEventChanges::empty();
668
        update_scrollbar_transform_key(
669
            &mut cache,
670
            &mut first,
671
            node,
672
            t,
673
            ScrollbarOrientation::Vertical,
674
        );
675
        let key = cache.transform_keys.get(&node).copied().unwrap();
676

            
677
        for _ in 0..10 {
678
            let mut again = GpuEventChanges::empty();
679
            update_scrollbar_transform_key(
680
                &mut cache,
681
                &mut again,
682
                node,
683
                t,
684
                ScrollbarOrientation::Vertical,
685
            );
686
            assert!(again.is_empty(), "unchanged transform must not re-emit");
687
        }
688
        // ...and the key must be stable across those no-op frames.
689
        assert_eq!(cache.transform_keys.get(&node).copied(), Some(key));
690
    }
691

            
692
    #[test]
693
    fn changing_the_transform_emits_changed_with_old_and_new_and_reuses_the_key() {
694
        let mut cache = GpuValueCache::default();
695
        let node = NodeId::new(3);
696
        let old = tx(0.0, 12.0);
697
        let new = tx(0.0, 40.0);
698

            
699
        let mut changes = GpuEventChanges::empty();
700
        update_scrollbar_transform_key(
701
            &mut cache,
702
            &mut changes,
703
            node,
704
            old,
705
            ScrollbarOrientation::Vertical,
706
        );
707
        let key = cache.transform_keys.get(&node).copied().unwrap();
708

            
709
        let mut changes = GpuEventChanges::empty();
710
        update_scrollbar_transform_key(
711
            &mut cache,
712
            &mut changes,
713
            node,
714
            new,
715
            ScrollbarOrientation::Vertical,
716
        );
717

            
718
        assert_eq!(
719
            changes.transform_key_changes,
720
            vec![GpuTransformKeyEvent::Changed(node, key, old, new)]
721
        );
722
        // Key is reused (not re-minted) and the stored value advances.
723
        assert_eq!(cache.transform_keys.get(&node).copied(), Some(key));
724
        assert_eq!(cache.current_transform_values.get(&node), Some(&new));
725
    }
726

            
727
    #[test]
728
    fn vertical_and_horizontal_keys_for_the_same_node_are_fully_independent() {
729
        // Both orientations key off the same NodeId but must land in disjoint
730
        // maps — otherwise a node with both scrollbars would have its vertical
731
        // thumb overwritten by its horizontal one (SpatialTreeItemKey collision).
732
        let mut cache = GpuValueCache::default();
733
        let mut changes = GpuEventChanges::empty();
734
        let node = NodeId::new(5);
735
        let v = tx(0.0, 10.0);
736
        let h = tx(20.0, 0.0);
737

            
738
        update_scrollbar_transform_key(
739
            &mut cache,
740
            &mut changes,
741
            node,
742
            v,
743
            ScrollbarOrientation::Vertical,
744
        );
745
        update_scrollbar_transform_key(
746
            &mut cache,
747
            &mut changes,
748
            node,
749
            h,
750
            ScrollbarOrientation::Horizontal,
751
        );
752

            
753
        assert_eq!(changes.transform_key_changes.len(), 2);
754
        assert_eq!(cache.current_transform_values.get(&node), Some(&v));
755
        assert_eq!(cache.h_current_transform_values.get(&node), Some(&h));
756
        assert_ne!(
757
            cache.transform_keys.get(&node),
758
            cache.h_transform_keys.get(&node),
759
            "the two orientations must not share a TransformKey"
760
        );
761
    }
762

            
763
    #[test]
764
    fn a_value_without_its_key_silently_drops_the_update_and_stays_stale() {
765
        // Desync #1: current_transform_values has an entry but transform_keys does
766
        // not. The `let Some(&transform_key) = keys.get(..) else { return }` bails
767
        // out *before* writing the new value, so the node is stuck at the stale
768
        // transform and never self-heals — no event, no repair, forever.
769
        let mut cache = GpuValueCache::default();
770
        let node = NodeId::new(3);
771
        let stale = tx(0.0, 5.0);
772
        cache.current_transform_values.insert(node, stale);
773

            
774
        let mut changes = GpuEventChanges::empty();
775
        update_scrollbar_transform_key(
776
            &mut cache,
777
            &mut changes,
778
            node,
779
            tx(0.0, 99.0),
780
            ScrollbarOrientation::Vertical,
781
        );
782

            
783
        assert!(changes.is_empty(), "no event is emitted for a keyless value");
784
        assert_eq!(
785
            cache.current_transform_values.get(&node),
786
            Some(&stale),
787
            "the value is left stale rather than repaired"
788
        );
789
        assert!(
790
            !cache.transform_keys.contains_key(&node),
791
            "and no key is minted to recover"
792
        );
793
    }
794

            
795
    #[test]
796
    fn a_key_without_its_value_mints_a_fresh_key_and_orphans_the_old_one() {
797
        // Desync #2 (the mirror image): transform_keys has an entry but
798
        // current_transform_values does not. The else-branch unconditionally
799
        // overwrites the key, so the previously-published TransformKey is
800
        // orphaned — the renderer still holds it, nothing ever removes it.
801
        let mut cache = GpuValueCache::default();
802
        let node = NodeId::new(3);
803
        let orphan = TransformKey::unique();
804
        cache.transform_keys.insert(node, orphan);
805

            
806
        let mut changes = GpuEventChanges::empty();
807
        let t = tx(0.0, 7.0);
808
        update_scrollbar_transform_key(
809
            &mut cache,
810
            &mut changes,
811
            node,
812
            t,
813
            ScrollbarOrientation::Vertical,
814
        );
815

            
816
        let fresh = cache.transform_keys.get(&node).copied().unwrap();
817
        assert_ne!(fresh, orphan, "a brand-new key replaces the orphan");
818
        assert_eq!(
819
            changes.transform_key_changes,
820
            vec![GpuTransformKeyEvent::Added(node, fresh, t)],
821
            "and it is announced as Added, never as Removed(orphan)"
822
        );
823
    }
824

            
825
    #[test]
826
    fn a_nan_transform_never_converges_and_re_emits_changed_every_single_call() {
827
        // ComputedTransform3D derives PartialEq over f32s, so NaN != NaN. Once a
828
        // NaN thumb offset lands in the cache, `*existing != transform` is true on
829
        // every subsequent call *even for the bit-identical transform* — the cache
830
        // can never converge and the renderer gets an unbounded stream of Changed
831
        // events, one per frame, for a node that is not moving.
832
        let mut cache = GpuValueCache::default();
833
        let node = NodeId::new(3);
834
        let nan = tx(0.0, f32::NAN);
835

            
836
        let mut changes = GpuEventChanges::empty();
837
        update_scrollbar_transform_key(
838
            &mut cache,
839
            &mut changes,
840
            node,
841
            nan,
842
            ScrollbarOrientation::Vertical,
843
        );
844
        assert_eq!(changes.transform_key_changes.len(), 1, "Added");
845

            
846
        // Feed the exact same NaN transform back in 5 more times.
847
        for _ in 0..5 {
848
            update_scrollbar_transform_key(
849
                &mut cache,
850
                &mut changes,
851
                node,
852
                nan,
853
                ScrollbarOrientation::Vertical,
854
            );
855
        }
856
        assert_eq!(
857
            changes.transform_key_changes.len(),
858
            6,
859
            "NaN re-emits Changed on every call instead of settling"
860
        );
861
        assert!(cache.current_transform_values[&node].m[3][1].is_nan());
862
    }
863

            
864
    // ------------------------------------------------------------------
865
    // remap_hashmap
866
    // ------------------------------------------------------------------
867

            
868
    /// `NodeIdMap` in which nothing survived the rebuild.
869
    fn no_survivors() -> NodeIdMap {
870
        NodeIdMap::from_pairs(Vec::<(NodeId, NodeId)>::new())
871
    }
872

            
873
    #[test]
874
    fn remap_hashmap_on_empty_inputs_is_a_no_op() {
875
        let mut map: HashMap<NodeId, u32> = HashMap::new();
876
        remap_hashmap(&mut map, &no_survivors());
877
        assert!(map.is_empty());
878
    }
879

            
880
    #[test]
881
    fn remap_hashmap_drops_entries_for_unmounted_nodes() {
882
        let mut map: HashMap<NodeId, u32> = HashMap::new();
883
        map.insert(NodeId::new(1), 10);
884
        map.insert(NodeId::new(2), 20);
885
        map.insert(NodeId::new(3), 30);
886

            
887
        // Only node 2 survives the rebuild (as node 9).
888
        remap_hashmap(
889
            &mut map,
890
            &NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(9))]),
891
        );
892

            
893
        assert_eq!(map.len(), 1);
894
        assert_eq!(map.get(&NodeId::new(9)), Some(&20));
895
        assert!(!map.contains_key(&NodeId::new(1)));
896
        assert!(!map.contains_key(&NodeId::new(2)), "old id must not linger");
897
    }
898

            
899
    #[test]
900
    fn remap_hashmap_survives_a_full_id_swap_without_losing_entries() {
901
        // 1 -> 2 and 2 -> 1 simultaneously. An in-place rewrite would clobber one
902
        // of them depending on iteration order; the take-then-reinsert must not.
903
        let mut map: HashMap<NodeId, u32> = HashMap::new();
904
        map.insert(NodeId::new(1), 111);
905
        map.insert(NodeId::new(2), 222);
906

            
907
        remap_hashmap(
908
            &mut map,
909
            &NodeIdMap::from_pairs([
910
                (NodeId::new(1), NodeId::new(2)),
911
                (NodeId::new(2), NodeId::new(1)),
912
            ]),
913
        );
914

            
915
        assert_eq!(map.len(), 2, "no entry may be lost to the swap");
916
        assert_eq!(map.get(&NodeId::new(2)), Some(&111));
917
        assert_eq!(map.get(&NodeId::new(1)), Some(&222));
918
    }
919

            
920
    #[test]
921
    fn remap_hashmap_collapses_two_old_ids_that_alias_onto_one_new_id() {
922
        // A malformed NodeIdMap (two survivors claiming the same new slot) must
923
        // not panic — one entry silently wins. Pinning the *shape* of that loss:
924
        // the map shrinks rather than corrupting.
925
        let mut map: HashMap<NodeId, u32> = HashMap::new();
926
        map.insert(NodeId::new(1), 111);
927
        map.insert(NodeId::new(2), 222);
928

            
929
        remap_hashmap(
930
            &mut map,
931
            &NodeIdMap::from_pairs([
932
                (NodeId::new(1), NodeId::new(5)),
933
                (NodeId::new(2), NodeId::new(5)),
934
            ]),
935
        );
936

            
937
        assert_eq!(map.len(), 1, "the alias collapses both entries into one");
938
        let survivor = map.get(&NodeId::new(5)).copied().unwrap();
939
        assert!(survivor == 111 || survivor == 222);
940
    }
941

            
942
    #[test]
943
    fn remap_hashmap_handles_node_id_max_without_overflowing() {
944
        let mut map: HashMap<NodeId, u32> = HashMap::new();
945
        map.insert(NodeId::new(usize::MAX), 1);
946
        map.insert(NodeId::ZERO, 2);
947

            
948
        remap_hashmap(
949
            &mut map,
950
            &NodeIdMap::from_pairs([
951
                (NodeId::new(usize::MAX), NodeId::ZERO),
952
                (NodeId::ZERO, NodeId::new(usize::MAX)),
953
            ]),
954
        );
955

            
956
        assert_eq!(map.get(&NodeId::ZERO), Some(&1));
957
        assert_eq!(map.get(&NodeId::new(usize::MAX)), Some(&2));
958
    }
959

            
960
    // ------------------------------------------------------------------
961
    // remap_dom_hashmap
962
    // ------------------------------------------------------------------
963

            
964
    #[test]
965
    fn remap_dom_hashmap_leaves_other_doms_completely_untouched() {
966
        let mut map: HashMap<(DomId, NodeId), u32> = HashMap::new();
967
        map.insert((dom(0), NodeId::new(1)), 1);
968
        map.insert((dom(1), NodeId::new(1)), 2);
969

            
970
        // Reconcile DOM 0 only: 1 -> 4. DOM 1's node 1 is *not* mentioned in the
971
        // map, but it must survive anyway — this reconciliation says nothing
972
        // about a different DOM.
973
        remap_dom_hashmap(
974
            &mut map,
975
            dom(0),
976
            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(4))]),
977
        );
978

            
979
        assert_eq!(map.len(), 2);
980
        assert_eq!(map.get(&(dom(0), NodeId::new(4))), Some(&1));
981
        assert_eq!(
982
            map.get(&(dom(1), NodeId::new(1))),
983
            Some(&2),
984
            "a foreign DOM's entry must not be dropped as 'unmounted'"
985
        );
986
    }
987

            
988
    #[test]
989
    fn remap_dom_hashmap_drops_only_the_target_doms_unmounted_nodes() {
990
        let mut map: HashMap<(DomId, NodeId), u32> = HashMap::new();
991
        map.insert((dom(0), NodeId::new(1)), 1); // survives -> 4
992
        map.insert((dom(0), NodeId::new(2)), 2); // unmounted -> dropped
993
        map.insert((dom(1), NodeId::new(2)), 3); // other DOM -> kept as-is
994

            
995
        remap_dom_hashmap(
996
            &mut map,
997
            dom(0),
998
            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(4))]),
999
        );
        assert_eq!(map.len(), 2);
        assert_eq!(map.get(&(dom(0), NodeId::new(4))), Some(&1));
        assert!(!map.contains_key(&(dom(0), NodeId::new(2))));
        assert_eq!(map.get(&(dom(1), NodeId::new(2))), Some(&3));
    }
    #[test]
    fn remap_dom_hashmap_does_not_let_a_remap_collide_across_doms() {
        // (dom0, 1) -> (dom0, 2), while (dom1, 2) already exists. Different DOM,
        // so the tuple keys stay distinct and neither entry is lost.
        let mut map: HashMap<(DomId, NodeId), u32> = HashMap::new();
        map.insert((dom(0), NodeId::new(1)), 11);
        map.insert((dom(1), NodeId::new(2)), 22);
        remap_dom_hashmap(
            &mut map,
            dom(0),
            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(2))]),
        );
        assert_eq!(map.len(), 2);
        assert_eq!(map.get(&(dom(0), NodeId::new(2))), Some(&11));
        assert_eq!(map.get(&(dom(1), NodeId::new(2))), Some(&22));
    }
    #[test]
    fn remap_dom_hashmap_survives_a_shift_chain_within_one_dom() {
        // 1->2 and 2->3 at once. Resolving from the *old* snapshot means the
        // 1->2 insert cannot clobber the entry that used to live at 2.
        let mut map: HashMap<(DomId, NodeId), u32> = HashMap::new();
        map.insert((dom(0), NodeId::new(1)), 11);
        map.insert((dom(0), NodeId::new(2)), 22);
        remap_dom_hashmap(
            &mut map,
            dom(0),
            &NodeIdMap::from_pairs([
                (NodeId::new(1), NodeId::new(2)),
                (NodeId::new(2), NodeId::new(3)),
            ]),
        );
        assert_eq!(map.len(), 2);
        assert_eq!(map.get(&(dom(0), NodeId::new(2))), Some(&11));
        assert_eq!(map.get(&(dom(0), NodeId::new(3))), Some(&22));
    }
    // ------------------------------------------------------------------
    // NodeIdRemap for GpuStateManager
    // ------------------------------------------------------------------
    #[test]
    fn remap_node_ids_for_a_dom_with_no_cache_is_a_no_op() {
        let mut m = GpuStateManager::default();
        m.remap_node_ids(
            dom(3),
            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(2))]),
        );
        assert!(m.caches.is_empty());
    }
    #[test]
    fn remap_node_ids_rewrites_every_one_of_the_twelve_cache_maps() {
        // A single map left un-remapped is a stale scrollbar thumb / stuck
        // animation, so assert all twelve move together.
        let mut m = GpuStateManager::default();
        let old = NodeId::new(1);
        let new = NodeId::new(8);
        let d = dom(0);
        {
            let c = m.get_or_create_cache(d);
            c.transform_keys.insert(old, TransformKey::unique());
            c.current_transform_values.insert(old, tx(0.0, 1.0));
            c.h_transform_keys.insert(old, TransformKey::unique());
            c.h_current_transform_values.insert(old, tx(2.0, 0.0));
            c.css_transform_keys.insert(old, TransformKey::unique());
            c.css_current_transform_values.insert(old, tx(3.0, 3.0));
            c.opacity_keys.insert(old, OpacityKey::unique());
            c.current_opacity_values.insert(old, 0.5);
            c.scrollbar_v_opacity_keys.insert((d, old), OpacityKey::unique());
            c.scrollbar_h_opacity_keys.insert((d, old), OpacityKey::unique());
            c.scrollbar_v_opacity_values.insert((d, old), 0.25);
            c.scrollbar_h_opacity_values.insert((d, old), 0.75);
        }
        m.remap_node_ids(d, &NodeIdMap::from_pairs([(old, new)]));
        let c = m.get_cache(d).unwrap();
        assert!(c.transform_keys.contains_key(&new));
        assert_eq!(c.current_transform_values.get(&new), Some(&tx(0.0, 1.0)));
        assert!(c.h_transform_keys.contains_key(&new));
        assert_eq!(c.h_current_transform_values.get(&new), Some(&tx(2.0, 0.0)));
        assert!(c.css_transform_keys.contains_key(&new));
        assert_eq!(c.css_current_transform_values.get(&new), Some(&tx(3.0, 3.0)));
        assert!(c.opacity_keys.contains_key(&new));
        assert_eq!(c.current_opacity_values.get(&new), Some(&0.5));
        assert!(c.scrollbar_v_opacity_keys.contains_key(&(d, new)));
        assert!(c.scrollbar_h_opacity_keys.contains_key(&(d, new)));
        assert_eq!(c.scrollbar_v_opacity_values.get(&(d, new)), Some(&0.25));
        assert_eq!(c.scrollbar_h_opacity_values.get(&(d, new)), Some(&0.75));
        // ...and nothing is left behind under the old id.
        assert!(!c.transform_keys.contains_key(&old));
        assert!(!c.current_opacity_values.contains_key(&old));
        assert!(!c.scrollbar_v_opacity_values.contains_key(&(d, old)));
    }
    #[test]
    fn remap_node_ids_drops_the_gpu_keys_of_an_unmounted_node() {
        let mut m = GpuStateManager::default();
        let gone = NodeId::new(1);
        let d = dom(0);
        {
            let c = m.get_or_create_cache(d);
            c.transform_keys.insert(gone, TransformKey::unique());
            c.current_transform_values.insert(gone, tx(0.0, 1.0));
            c.scrollbar_v_opacity_values.insert((d, gone), 1.0);
        }
        // Empty map == every node unmounted.
        m.remap_node_ids(d, &no_survivors());
        let c = m.get_cache(d).unwrap();
        assert!(c.transform_keys.is_empty());
        assert!(c.current_transform_values.is_empty());
        assert!(c.scrollbar_v_opacity_values.is_empty());
    }
    // ------------------------------------------------------------------
    // update_scrollbar_transforms
    // ------------------------------------------------------------------
    #[test]
    fn update_scrollbar_transforms_on_an_empty_tree_yields_no_events() {
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = tree(Vec::new(), Vec::new());
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert!(changes.is_empty());
        // ...but the cache is created (get_or_create_cache runs unconditionally).
        assert!(m.get_cache(dom(0)).is_some());
    }
    #[test]
    fn nodes_without_scrollbar_info_or_without_a_dom_node_id_are_skipped() {
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = tree(
            vec![
                // has scrollbar info but is an anonymous box (no dom_node_id)
                hot(None, Some(LogicalSize::new(100.0, 100.0))),
                // has a dom_node_id but no scrollbar info
                hot(Some(NodeId::new(2)), Some(LogicalSize::new(100.0, 100.0))),
            ],
            vec![
                warm_node(Some(v_scrollbar()), Some(LogicalSize::new(100.0, 1000.0))),
                warm_node(None, None),
            ],
        );
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert!(changes.is_empty());
        assert!(m.get_cache(dom(0)).unwrap().transform_keys.is_empty());
    }
    #[test]
    fn a_warm_array_shorter_than_the_node_array_does_not_index_out_of_bounds() {
        // Mismatched SoA lengths: `warm(idx)` returns None for the tail nodes and
        // they must be skipped, not panic.
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = tree(
            vec![
                hot(Some(NodeId::new(1)), Some(LogicalSize::new(100.0, 100.0))),
                hot(Some(NodeId::new(2)), Some(LogicalSize::new(100.0, 100.0))),
            ],
            // only one warm entry for two hot nodes
            vec![warm_node(Some(v_scrollbar()), Some(LogicalSize::new(100.0, 1000.0)))],
        );
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert_eq!(changes.transform_key_changes.len(), 1, "only node 0 is seen");
    }
    #[test]
    fn a_vertical_scrollbar_at_scroll_zero_parks_the_thumb_at_offset_zero() {
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        // No scroll state registered at all -> get_current_offset() is None ->
        // unwrap_or_default() -> (0, 0). Thumb sits at the top.
        assert_eq!(sole_added_y(&changes), 0.0);
    }
    #[test]
    fn scrolling_to_the_bottom_drives_the_thumb_to_the_end_of_the_usable_track() {
        // inner 100x100, content 100x1000, 16px bar with 16px buttons:
        //   usable_track = 100 - 2*16 = 68
        //   thumb        = max(68 * (100/1000), 16*2) = 32
        //   max_scroll   = 1000 - 100 = 900
        // At full scroll the thumb must land exactly at 68 - 32 = 36, never past it.
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(0.0, 900.0),
            t0(),
        );
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!((y - 36.0).abs() < 0.01, "expected thumb at 36.0, got {y}");
    }
    #[test]
    fn an_overscrolled_offset_clamps_the_thumb_instead_of_running_off_the_track() {
        // Rubber-banding pushes the offset far past max_scroll; scroll_ratio is
        // clamped to [0, 1] so the thumb must stop at the same 36.0.
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(0.0, 1.0e9),
            t0(),
        );
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!((y - 36.0).abs() < 0.01, "overscroll must clamp, got {y}");
    }
    #[test]
    fn a_negative_overscroll_offset_parks_the_thumb_at_the_top_of_the_track() {
        // Rubber-banding *above* the top is the only source of a negative offset.
        // The ratio clamp pins the thumb at 0 — it must not mirror the pull into
        // the 36.0 that the equivalent positive offset produces.
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(0.0, -900.0),
            t0(),
        );
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!(y >= 0.0, "thumb offset must never go negative, got {y}");
        assert!(
            y.abs() < 0.01,
            "an overscroll above the top must leave the thumb at the start, got {y}"
        );
    }
    #[test]
    fn a_virtual_view_thumb_is_sized_from_the_virtual_scroll_size_like_paint_scrollbars() {
        // A VirtualView is a replaced element with no flow content, so its laid-out
        // content size IS the viewport and `get_content_size` alone reports "nothing
        // to scroll" (full-length thumb parked at 0). `paint_scrollbars` seeds the
        // transform key from `ScrollPosition::children_rect.size`, which carries the
        // callback's virtual size; this path overwrites that key on every live
        // scroll, so both must land on the same thumb offset.
        const NODE: NodeId = NodeId::new(1);
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.register_or_update_scroll_node(
            dom(0),
            NODE,
            LogicalRect::new(
                LogicalPosition::new(250.0, 120.0),
                LogicalSize::new(100.0, 100.0),
            ),
            LogicalSize::new(100.0, 100.0),
            t0(),
            16.0,
            16.0,
            false,
            true,
        );
        sm.update_virtual_scroll_bounds(dom(0), NODE, LogicalSize::new(100.0, 1000.0), None);
        sm.set_scroll_position(dom(0), NODE, LogicalPosition::new(0.0, 450.0), t0());
        // The layout tree only ever saw the viewport-sized content box.
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 100.0));
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        // ...what display_list::paint_scrollbars computes for the same node.
        let states = sm.get_scroll_states_for_dom(dom(0));
        let pos = states.get(&NODE).expect("the node must report a scroll state");
        assert_eq!(pos.children_rect.size, LogicalSize::new(100.0, 1000.0));
        let painted = compute_scrollbar_geometry_with_button_size(
            ScrollbarOrientation::Vertical,
            LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(100.0, 100.0)),
            pos.children_rect.size,
            pos.children_rect.origin.y,
            16.0,
            false,
            16.0,
        );
        // Both producers of the thumb TRANSFORM quantise (see
        // `quantize_thumb_offset`), so the agreement is asserted against the
        // painter's quantised value — comparing against the raw geometry would
        // make this pass only for fixtures whose offset happens to be integral.
        let painted_transform =
            crate::solver3::scrollbar::quantize_thumb_offset(painted.thumb_offset);
        assert!(
            (y - painted_transform).abs() < 0.01,
            "GPU path put the thumb at {y}, the painter at {painted_transform}"
        );
        // usable = 100 - 2*16 = 68, thumb = max(68 * 100/1000, 32) = 32,
        // max_scroll = 1000 - 100 = 900 -> half scroll = (68 - 32) * 0.5 = 18.
        // Sizing from the laid-out 100x100 instead gives max_scroll 0 and a
        // full-track thumb parked at 0.
        assert!((y - 18.0).abs() < 0.01, "expected the half-track thumb at 18.0, got {y}");
    }
    #[test]
    fn an_auto_virtual_view_agrees_on_the_flag_the_painted_thumb_and_the_gpu_thumb() {
        // The half-landed state this pins: `paint_scrollbars` raised its own
        // booleans locally and drew a bar, while this path kept gating on the
        // layout-computed `warm.scrollbar_info.needs_*` — all-false for an
        // `auto` VirtualView — so the thumb froze at the display list's seed
        // value on every live scroll. One function now decides, and
        // `register_scroll_nodes` stores the decision where this path reads it.
        const NODE: NodeId = NodeId::new(1);
        let styled_dom = auto_virtual_view_dom();
        // 1. Layout ran: the VirtualView's box is 100x100 and its laid-out
        //    content is the same 100x100, so no bar is warranted yet.
        let mut t = one_node_tree(auto_no_scrollbar(), LogicalSize::new(100.0, 100.0));
        // 2. The VirtualView callback published a 100x1000 document.
        let mut sm = ScrollManager::new();
        sm.update_virtual_scroll_bounds(dom(0), NODE, LogicalSize::new(100.0, 1000.0), None);
        // 3. What `register_scroll_nodes` then does: amend the flags from the
        //    virtual size and store the answer back on the node, which is what
        //    makes the node registrable at all (the old gate skipped it, so its
        //    container_rect stayed zero and its clamp bound was the whole
        //    virtual size).
        let states = sm.get_scroll_states_for_dom(dom(0));
        let pos = states.get(&NODE).expect("the callback created a scroll state");
        let mut info = t.warm(LayoutNodeId::new(0)).and_then(|w| w.scrollbar_info).expect("layout stored one");
        assert!(
            apply_virtual_scroll_necessity(
                &styled_dom,
                NODE,
                pos.children_rect.size,
                LogicalSize::new(100.0, 100.0),
                &mut info,
            ),
            "a 1000px document in a 100px auto viewport needs a bar"
        );
        assert!(info.needs_vertical, "the amended flag is the one the GPU path gates on");
        assert!(!info.needs_horizontal, "overflow-x: hidden must not gain a bar");
        assert_eq!(
            (info.scrollbar_width, info.scrollbar_height),
            (0.0, 0.0),
            "no layout gutter is reserved after the fact — the bar overlays"
        );
        t.warm_mut(LayoutNodeId::new(0)).expect("the fixture has a warm node").scrollbar_info = Some(info);
        sm.register_or_update_scroll_node(
            dom(0),
            NODE,
            LogicalRect::new(
                LogicalPosition::new(250.0, 120.0),
                LogicalSize::new(100.0, 100.0),
            ),
            LogicalSize::new(100.0, 100.0),
            t0(),
            info.scrollbar_width.max(info.scrollbar_height),
            info.visual_width_px,
            info.needs_horizontal,
            info.needs_vertical,
        );
        // 4. Half a document down.
        sm.set_scroll_position(dom(0), NODE, LogicalPosition::new(0.0, 450.0), t0());
        let mut m = GpuStateManager::default();
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        // ...and what `paint_scrollbars` computes for the same node from the
        // same ScrollPosition: overlay bar (scrollbar_height == 0) => no arrow
        // buttons on either path.
        let states = sm.get_scroll_states_for_dom(dom(0));
        let pos = states.get(&NODE).expect("the node is registered now");
        let painted = compute_scrollbar_geometry_with_button_size(
            ScrollbarOrientation::Vertical,
            LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(100.0, 100.0)),
            pos.children_rect.size,
            pos.children_rect.origin.y,
            info.visual_width_px,
            info.needs_horizontal,
            0.0,
        );
        // Both producers of the thumb TRANSFORM quantise (see
        // `quantize_thumb_offset`), so the agreement is asserted against the
        // painter's quantised value — comparing against the raw geometry would
        // make this pass only for fixtures whose offset happens to be integral.
        let painted_transform =
            crate::solver3::scrollbar::quantize_thumb_offset(painted.thumb_offset);
        assert!(
            (y - painted_transform).abs() < 0.01,
            "GPU path put the thumb at {y}, the painter at {painted_transform}"
        );
        // usable track = 100 (no buttons), thumb = max(100 * 100/1000, 2*16) = 32,
        // max_scroll = 900 -> half travel = (100 - 32) * 0.5 = 34.
        assert!((y - 34.0).abs() < 0.01, "expected the half-track thumb at 34.0, got {y}");
        // The pre-fix tree — layout's flags, unamended — emits nothing at all,
        // which is exactly the frozen thumb under a painted bar.
        let stale = one_node_tree(auto_no_scrollbar(), LogicalSize::new(100.0, 100.0));
        let mut fresh = GpuStateManager::default();
        assert!(
            fresh.update_scrollbar_transforms(dom(0), &sm, &stale).is_empty(),
            "without the amendment the GPU path never sees the bar"
        );
    }
    #[test]
    fn running_the_same_layout_twice_emits_no_second_event() {
        // The convergence invariant: a static scroll position must not generate
        // GPU traffic every frame.
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
        let first = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert_eq!(first.transform_key_changes.len(), 1);
        for _ in 0..5 {
            let again = m.update_scrollbar_transforms(dom(0), &sm, &t);
            assert!(again.is_empty(), "an idle scrollbar must stay silent");
        }
    }
    #[test]
    fn scrolling_after_a_first_pass_emits_changed_and_reuses_the_key() {
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
        m.update_scrollbar_transforms(dom(0), &sm, &t);
        let key = m
            .get_cache(dom(0))
            .unwrap()
            .transform_keys
            .get(&NodeId::new(1))
            .copied()
            .unwrap();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(0.0, 900.0),
            t0(),
        );
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert_eq!(changes.transform_key_changes.len(), 1);
        match changes.transform_key_changes[0] {
            GpuTransformKeyEvent::Changed(node, k, old, new) => {
                assert_eq!(node, NodeId::new(1));
                assert_eq!(k, key, "the key must be reused across the scroll");
                assert_eq!(old.m[3][1], 0.0);
                assert!((new.m[3][1] - 36.0).abs() < 0.01);
            }
            ref other => panic!("expected Changed, got {other:?}"),
        }
    }
    #[test]
    fn a_horizontal_scrollbar_translates_on_x_and_leaves_y_at_zero() {
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(900.0, 0.0),
            t0(),
        );
        let t = one_node_tree(h_scrollbar(), LogicalSize::new(1000.0, 100.0));
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        let x = sole_added_x(&changes);
        assert!((x - 36.0).abs() < 0.01, "expected thumb at x=36.0, got {x}");
        // The horizontal thumb must be filed under the h_* maps, not the v_* ones.
        let c = m.get_cache(dom(0)).unwrap();
        assert!(c.h_transform_keys.contains_key(&NodeId::new(1)));
        assert!(c.transform_keys.is_empty());
    }
    #[test]
    fn a_node_needing_both_scrollbars_gets_two_independent_keys_and_two_events() {
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let both = ScrollbarRequirements {
            needs_horizontal: true,
            needs_vertical: true,
            scrollbar_width: 16.0,
            scrollbar_height: 16.0,
            visual_width_px: 16.0,
        };
        let t = one_node_tree(both, LogicalSize::new(1000.0, 1000.0));
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert_eq!(changes.transform_key_changes.len(), 2);
        let c = m.get_cache(dom(0)).unwrap();
        let node = NodeId::new(1);
        let v = c.transform_keys.get(&node).copied().unwrap();
        let h = c.h_transform_keys.get(&node).copied().unwrap();
        assert_ne!(v, h);
    }
    #[test]
    fn borders_wider_than_the_border_box_clamp_the_inner_size_to_zero() {
        // 50x50 border-box with a 100px border on every side would give a -150px
        // inner size; the `.max(0.0)` must clamp it, and the geometry must stay
        // finite (no NaN thumb offset from a negative track).
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let mut node = hot(Some(NodeId::new(1)), Some(LogicalSize::new(50.0, 50.0)));
        node.box_props = PackedBoxProps {
            border: [1000, 1000, 1000, 1000], // 100.0 px each, i16 x10 encoding
            ..Default::default()
        };
        let t = tree(
            vec![node],
            vec![warm_node(
                Some(v_scrollbar()),
                Some(LogicalSize::new(100.0, 1000.0)),
            )],
        );
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!(y.is_finite(), "a degenerate inner box must not yield {y}");
        assert_eq!(y, 0.0);
    }
    #[test]
    fn a_zero_sized_node_with_zero_content_does_not_divide_by_zero() {
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = tree(
            vec![hot(Some(NodeId::new(1)), Some(LogicalSize::zero()))],
            vec![warm_node(Some(v_scrollbar()), Some(LogicalSize::zero()))],
        );
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!(y.is_finite());
        assert_eq!(y, 0.0);
    }
    #[test]
    fn a_missing_used_size_defaults_to_zero_rather_than_panicking() {
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = tree(
            vec![hot(Some(NodeId::new(1)), None)],
            vec![warm_node(Some(v_scrollbar()), None)],
        );
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!(y.is_finite());
        assert_eq!(y, 0.0);
    }
    #[test]
    fn an_infinite_used_size_is_sanitised_instead_of_emitting_a_nan_thumb() {
        // An infinite border-box makes both the viewport and the content length
        // +inf, so compute_thumb_geometry ends at
        //   thumb_offset = (inf - inf) * 0.0 = NaN
        // This used to be fed straight into a translation matrix: combined with
        // `a_nan_transform_never_converges_...` above, one infinite used_size
        // meant the scrollbar re-emitted a Changed event on *every* frame,
        // forever, and WebRender was handed a NaN transform. The finite-guard in
        // `quantize_thumb_offset` (which the paint path applies identically) is
        // that future guard the old characterisation test was waiting for.
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = tree(
            vec![hot(
                Some(NodeId::new(1)),
                Some(LogicalSize::new(f32::INFINITY, f32::INFINITY)),
            )],
            vec![warm_node(Some(v_scrollbar()), None)],
        );
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!(y.is_finite(), "a NaN must never reach the transform, got {y}");
        assert_eq!(y, 0.0);
        // And it SETTLES: a second identical pass emits nothing.
        let again = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert!(
            again.transform_key_changes.is_empty(),
            "the sanitised value must converge, not re-emit Changed forever"
        );
    }
    #[test]
    fn a_nan_used_size_is_sanitised_to_zero_by_the_max_clamp() {
        // Unlike infinity, NaN *is* neutralised: f32::max(NaN, 0.0) == 0.0, so the
        // `.max(0.0)` on the inner size scrubs it before it reaches the geometry.
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = tree(
            vec![hot(
                Some(NodeId::new(1)),
                Some(LogicalSize::new(f32::NAN, f32::NAN)),
            )],
            vec![warm_node(Some(v_scrollbar()), None)],
        );
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!(y.is_finite(), "NaN width/height must be clamped, got {y}");
        assert_eq!(y, 0.0);
    }
    #[test]
    fn overlay_vertical_scrollbars_fall_back_to_the_default_width() {
        // visual_width_px == 0 and no reserved space at all -> the DEFAULT_SCROLLBAR
        // _WIDTH_PX (16.0) fallback with button_size 0:
        //   usable = 100, thumb = max(100*0.1, 32) = 32, offset@full = 100 - 32 = 68
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(0.0, 900.0),
            t0(),
        );
        let overlay = ScrollbarRequirements {
            needs_horizontal: false,
            needs_vertical: true,
            scrollbar_width: 0.0,
            scrollbar_height: 0.0,
            visual_width_px: 0.0,
        };
        let t = one_node_tree(overlay, LogicalSize::new(100.0, 1000.0));
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!((y - 68.0).abs() < 0.01, "expected the 16px default, got {y}");
        assert_eq!(DEFAULT_SCROLLBAR_WIDTH_PX, 16.0);
    }
    #[test]
    fn classic_vertical_scrollbar_overlay_check_reads_the_wrong_reserved_field() {
        // BUG (characterisation). ScrollbarRequirements documents:
        //   scrollbar_width  = layout-reserved width  for a *vertical*   scrollbar
        //   scrollbar_height = layout-reserved height for a *horizontal* scrollbar
        // but the needs_vertical branch tests `scrollbar_height == 0.0` to decide
        // whether the *vertical* bar is an overlay, and falls back to
        // `scrollbar_height` for its width. The two fields are swapped.
        //
        // Repro: a classic, space-reserving vertical-only scrollbar --
        //   scrollbar_width  = 16.0  (16px reserved for the vertical bar)
        //   scrollbar_height =  0.0  (no horizontal bar -> nothing reserved)
        //   visual_width_px  =  0.0  (unset, so the fallback actually runs)
        // is misread as an overlay: button_size collapses to 0, so the usable
        // track is 100 instead of 68 and the thumb travels to 68.0 rather than the
        // correct 36.0 -- the thumb overshoots its own track by ~32px.
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(0.0, 900.0),
            t0(),
        );
        let classic_vertical_only = ScrollbarRequirements {
            needs_horizontal: false,
            needs_vertical: true,
            scrollbar_width: 16.0,
            scrollbar_height: 0.0,
            visual_width_px: 0.0,
        };
        let t = one_node_tree(classic_vertical_only, LogicalSize::new(100.0, 1000.0));
        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!(
            (y - 68.0).abs() < 0.01,
            "pinning the buggy value; 36.0 once the field swap is fixed, got {y}"
        );
    }
    #[test]
    fn classic_horizontal_scrollbar_overlay_check_reads_the_wrong_reserved_field() {
        // The mirror image of the above: the needs_horizontal branch tests
        // `scrollbar_width == 0.0` (the *vertical* bar's reserved width) to decide
        // whether the *horizontal* bar is an overlay.
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(900.0, 0.0),
            t0(),
        );
        let classic_horizontal_only = ScrollbarRequirements {
            needs_horizontal: true,
            needs_vertical: false,
            scrollbar_width: 0.0,
            scrollbar_height: 16.0,
            visual_width_px: 0.0,
        };
        let t = one_node_tree(classic_horizontal_only, LogicalSize::new(1000.0, 100.0));
        let x = sole_added_x(&m.update_scrollbar_transforms(dom(0), &sm, &t));
        assert!(
            (x - 68.0).abs() < 0.01,
            "pinning the buggy value; 36.0 once the field swap is fixed, got {x}"
        );
    }
    #[test]
    fn transforms_are_recorded_per_dom_and_do_not_leak_across_caches() {
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
        let a = m.update_scrollbar_transforms(dom(0), &sm, &t);
        let b = m.update_scrollbar_transforms(dom(1), &sm, &t);
        // The same layout tree under a different DomId is a *different* cache, so
        // it must Add (not stay silent) and mint its own key.
        assert_eq!(a.transform_key_changes.len(), 1);
        assert_eq!(b.transform_key_changes.len(), 1);
        assert_eq!(m.caches.len(), 2);
        let node = NodeId::new(1);
        let ka = m.get_cache(dom(0)).unwrap().transform_keys[&node];
        let kb = m.get_cache(dom(1)).unwrap().transform_keys[&node];
        assert_ne!(ka, kb, "each DOM must get its own TransformKey");
    }
    #[test]
    fn update_scrollbar_transforms_does_not_touch_pending_changes() {
        // The function *returns* its changes; it must not also stash them, or the
        // renderer would apply every event twice.
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert!(!changes.is_empty());
        assert!(
            m.pending_changes.is_empty(),
            "returned events must not be double-queued"
        );
    }
    #[test]
    fn a_thousand_scrollable_nodes_each_get_exactly_one_distinct_key() {
        let mut m = GpuStateManager::default();
        let sm = ScrollManager::new();
        let n = 1000;
        let t = tree(
            (0..n)
                .map(|i| hot(Some(NodeId::new(i)), Some(LogicalSize::new(100.0, 100.0))))
                .collect(),
            (0..n)
                .map(|_| warm_node(Some(v_scrollbar()), Some(LogicalSize::new(100.0, 1000.0))))
                .collect(),
        );
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert_eq!(changes.transform_key_changes.len(), n);
        let cache = m.get_cache(dom(0)).unwrap();
        assert_eq!(cache.transform_keys.len(), n);
        let mut keys: Vec<_> = cache.transform_keys.values().map(|k| k.id).collect();
        keys.sort_unstable();
        keys.dedup();
        assert_eq!(keys.len(), n, "every node must get a unique TransformKey");
    }
    #[test]
    fn two_layout_nodes_sharing_one_dom_node_id_fight_over_a_single_key() {
        // Anonymous-box splitting can produce two layout nodes pointing at the same
        // DOM node. The cache keys off dom_node_id, not the layout index, so the
        // second node is misread as a *change* to the first: one key, two events
        // in a single pass, and the last node in tree order silently wins.
        //
        //   node 0: inner 100x100, content 100x1000 -> thumb 36.0
        //   node 1: inner 200x200, content 200x2000 -> thumb 68.0
        let mut m = GpuStateManager::default();
        let mut sm = ScrollManager::new();
        sm.set_scroll_position_unclamped(
            dom(0),
            NodeId::new(1),
            LogicalPosition::new(0.0, 900.0),
            t0(),
        );
        let t = tree(
            vec![
                hot(Some(NodeId::new(1)), Some(LogicalSize::new(100.0, 100.0))),
                hot(Some(NodeId::new(1)), Some(LogicalSize::new(200.0, 200.0))),
            ],
            vec![
                warm_node(Some(v_scrollbar()), Some(LogicalSize::new(100.0, 1000.0))),
                warm_node(Some(v_scrollbar()), Some(LogicalSize::new(200.0, 2000.0))),
            ],
        );
        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
        assert_eq!(
            m.get_cache(dom(0)).unwrap().transform_keys.len(),
            1,
            "both layout nodes collapse onto one TransformKey"
        );
        assert!(matches!(
            changes.transform_key_changes.as_slice(),
            [
                GpuTransformKeyEvent::Added(..),
                GpuTransformKeyEvent::Changed(..)
            ]
        ));
        // The second node overwrote the first within the same pass.
        let stored = m.get_cache(dom(0)).unwrap().current_transform_values[&NodeId::new(1)];
        assert!((stored.m[3][1] - 68.0).abs() < 0.01);
    }
}