Skip to main content

azul_layout/managers/
virtual_view.rs

1//! `VirtualView` lifecycle management for layout
2//!
3//! This module provides:
4//! - `VirtualView` re-invocation logic for lazy loading
5//! - Nested DOM ID management
6
7use alloc::collections::BTreeMap;
8
9use azul_core::{
10    callbacks::{EdgeType, VirtualViewCallbackReason},
11    dom::{DomId, NodeId},
12    geom::{LogicalPosition, LogicalRect, LogicalSize},
13};
14
15use crate::managers::scroll_state::ScrollManager;
16
17/// Distance in pixels from edge that triggers edge-scrolled callback
18const EDGE_THRESHOLD: f32 = 200.0;
19
20/// Manages `VirtualView` lifecycle, including re-invocation
21///
22/// Tracks which `VirtualViews` have been invoked, assigns unique DOM IDs to nested
23/// virtual views, and determines when `VirtualViews` need to be re-invoked (e.g., when
24/// the container bounds expand or the user scrolls near an edge).
25#[derive(Debug, Clone, Default)]
26pub struct VirtualViewManager {
27    /// Per-VirtualView state keyed by (parent `DomId`, `NodeId` of virtualized view element)
28    states: BTreeMap<(DomId, NodeId), VirtualViewState>,
29    /// Counter for generating unique nested DOM IDs
30    next_dom_id: usize,
31    /// MWA-C-virtual_view: queue-time callback reasons, consumed by the very
32    /// next `check_reinvoke` for the same view (set by
33    /// `process_virtual_view_updates` right before the invoke). Replaces the
34    /// `force_reinvoke` clear-flag trick that collapsed every delivered
35    /// reason to `InitialRender`.
36    reason_overrides: Vec<((DomId, NodeId), VirtualViewCallbackReason)>,
37}
38
39/// Internal state for a single `VirtualView` instance
40///
41/// Tracks invocation status, content dimensions, and edge triggers
42/// to determine when the `VirtualView` callback needs to be re-invoked.
43#[derive(Debug, Clone)]
44struct VirtualViewState {
45    /// Content size reported by `VirtualView` callback (actual rendered size)
46    virtual_view_scroll_size: Option<LogicalSize>,
47    /// Virtual scroll size for infinite scroll scenarios
48    virtual_view_virtual_scroll_size: Option<LogicalSize>,
49    /// Whether the `VirtualView` has ever been invoked
50    virtual_view_was_invoked: bool,
51    /// Whether invoked for current container expansion
52    invoked_for_current_expansion: bool,
53    /// Whether invoked for current edge scroll event
54    invoked_for_current_edge: bool,
55    /// Which edges have already triggered callbacks
56    last_edge_triggered: EdgeFlags,
57    /// Unique DOM ID assigned to this `VirtualView`'s content
58    nested_dom_id: DomId,
59    /// Last known layout bounds of the `VirtualView` container
60    last_bounds: LogicalRect,
61    /// Scroll offset captured at `InitialRender`. Edge-scroll callbacks only fire
62    /// once the user has scrolled away from this resting position — being at an
63    /// edge from the very start (e.g. the top/left edge at offset 0) is the
64    /// initial position, not a scroll-to-edge event.
65    initial_scroll_offset: LogicalPosition,
66}
67
68/// Flags indicating which scroll edges have been triggered
69///
70/// Used to prevent repeated edge-scroll callbacks for the same edge
71/// until the user scrolls away and back.
72#[derive(Debug, Clone, Copy, PartialEq, Default)]
73#[allow(clippy::struct_excessive_bools)] // one independent bool per box edge (top/bottom/left/right)
74struct EdgeFlags {
75    /// Near top edge
76    top: bool,
77    /// Near bottom edge
78    bottom: bool,
79    /// Near left edge
80    left: bool,
81    /// Near right edge
82    right: bool,
83}
84
85impl VirtualViewManager {
86    /// Creates a new `VirtualViewManager` with no tracked `VirtualViews`
87    #[must_use] pub fn new() -> Self {
88        Self {
89            next_dom_id: 1, // 0 is root
90            ..Default::default()
91        }
92    }
93
94    /// Number of tracked `VirtualView` states. Used by `AZ_E2E_TEST` to watch growth.
95    #[must_use] pub fn debug_counts(&self) -> usize {
96        self.states.len()
97    }
98
99    /// MWA-C-virtual_view: stage the reason the next invoke of this view
100    /// should deliver to the user callback (consumed by `check_reinvoke`).
101    pub fn set_reason_override(
102        &mut self,
103        dom_id: DomId,
104        node_id: NodeId,
105        reason: VirtualViewCallbackReason,
106    ) {
107        self.reason_overrides
108            .retain(|((d, n), _)| !(*d == dom_id && *n == node_id));
109        self.reason_overrides.push(((dom_id, node_id), reason));
110    }
111
112    /// Gets or creates a unique nested DOM ID for a `VirtualView`
113    ///
114    /// Returns the existing DOM ID if the `VirtualView` was previously registered,
115    /// otherwise allocates a new unique ID and initializes the `VirtualView` state.
116    pub fn get_or_create_nested_dom_id(&mut self, dom_id: DomId, node_id: NodeId) -> DomId {
117        let key = (dom_id, node_id);
118
119        // Check if already exists
120        if let Some(state) = self.states.get(&key) {
121            return state.nested_dom_id;
122        }
123
124        // Create new nested DOM ID
125        let nested_dom_id = DomId {
126            inner: self.next_dom_id,
127        };
128        self.next_dom_id += 1;
129
130        self.states.insert(key, VirtualViewState::new(nested_dom_id));
131        nested_dom_id
132    }
133
134    /// Gets the nested DOM ID for a `VirtualView` if it exists
135    #[must_use] pub fn get_nested_dom_id(&self, dom_id: DomId, node_id: NodeId) -> Option<DomId> {
136        self.states.get(&(dom_id, node_id)).map(|s| s.nested_dom_id)
137    }
138
139    /// Returns whether the `VirtualView` has ever been invoked
140    #[must_use] pub fn was_virtual_view_invoked(&self, dom_id: DomId, node_id: NodeId) -> bool {
141        self.states
142            .get(&(dom_id, node_id))
143            .is_some_and(|s| s.virtual_view_was_invoked)
144    }
145
146    /// Updates the `VirtualView`'s content size information
147    ///
148    /// Called after the `VirtualView` callback returns to record the actual content
149    /// dimensions. If the new size is larger than previously recorded, clears
150    /// the expansion flag to allow `BoundsExpanded` re-invocation.
151    pub fn update_virtual_view_info(
152        &mut self,
153        dom_id: DomId,
154        node_id: NodeId,
155        scroll_size: LogicalSize,
156        virtual_scroll_size: LogicalSize,
157    ) -> Option<()> {
158        let state = self.states.get_mut(&(dom_id, node_id))?;
159
160        // Reset expansion flag if content grew
161        if let Some(old_size) = state.virtual_view_scroll_size {
162            if scroll_size.width > old_size.width || scroll_size.height > old_size.height {
163                state.invoked_for_current_expansion = false;
164            }
165        }
166        state.virtual_view_scroll_size = Some(scroll_size);
167        state.virtual_view_virtual_scroll_size = Some(virtual_scroll_size);
168
169        Some(())
170    }
171
172    /// Marks a `VirtualView` as invoked for a specific reason
173    ///
174    /// Updates internal state flags based on the callback reason to prevent
175    /// duplicate callbacks for the same trigger condition.
176    pub fn mark_invoked(
177        &mut self,
178        dom_id: DomId,
179        node_id: NodeId,
180        reason: VirtualViewCallbackReason,
181    ) -> Option<()> {
182        let state = self.states.get_mut(&(dom_id, node_id))?;
183
184        state.virtual_view_was_invoked = true;
185        match reason {
186            VirtualViewCallbackReason::BoundsExpanded => state.invoked_for_current_expansion = true,
187            VirtualViewCallbackReason::EdgeScrolled(edge) => {
188                state.invoked_for_current_edge = true;
189                state.last_edge_triggered = edge.into();
190            }
191            _ => {}
192        }
193
194        Some(())
195    }
196
197    /// Reset invocation flags for ALL tracked `VirtualViews`
198    ///
199    /// After `layout_results.clear()`, the child DOMs no longer exist in memory.
200    /// This method ensures `check_reinvoke()` returns `InitialRender` for every
201    /// `VirtualView`, so the callbacks re-run and re-populate `layout_results`.
202    ///
203    /// Called from `layout_and_generate_display_list()` after clearing layout results.
204    pub fn reset_all_invocation_flags(&mut self) {
205        for state in self.states.values_mut() {
206            state.virtual_view_was_invoked = false;
207            state.invoked_for_current_expansion = false;
208            state.invoked_for_current_edge = false;
209            state.last_edge_triggered = EdgeFlags::default();
210        }
211    }
212
213    /// Force a `VirtualView` to be re-invoked on the next layout pass
214    ///
215    /// Clears all invocation flags, causing `check_reinvoke()` to return `InitialRender`.
216    /// Used by `trigger_virtual_view_rerender()` to manually refresh `VirtualView` content.
217    pub fn force_reinvoke(&mut self, dom_id: DomId, node_id: NodeId) -> Option<()> {
218        let state = self.states.get_mut(&(dom_id, node_id))?;
219
220        state.virtual_view_was_invoked = false;
221        state.invoked_for_current_expansion = false;
222        state.invoked_for_current_edge = false;
223
224        Some(())
225    }
226
227    /// `(DomId, NodeId)` of every `VirtualView` registered so far (invoked at
228    /// least once). Used to re-invoke *all* views after a shared-dataset change
229    /// arrives out-of-band (e.g. a background tile-fetch writeback) without
230    /// needing to know which node the data belongs to.
231    #[must_use] pub fn all_view_keys(&self) -> Vec<(DomId, NodeId)> {
232        self.states.keys().copied().collect()
233    }
234
235    /// Checks whether a `VirtualView` needs to be re-invoked and returns the reason
236    ///
237    /// Returns `Some(reason)` if the `VirtualView` callback should be invoked:
238    /// - `InitialRender`: `VirtualView` has never been invoked
239    /// - `BoundsExpanded`: Container grew larger than content
240    /// - `EdgeScrolled`: User scrolled near an edge (for lazy loading)
241    ///
242    /// Returns `None` if no re-invocation is needed.
243    pub fn check_reinvoke(
244        &mut self,
245        dom_id: DomId,
246        node_id: NodeId,
247        scroll_manager: &ScrollManager,
248        layout_bounds: LogicalRect,
249    ) -> Option<VirtualViewCallbackReason> {
250        // MWA-C-virtual_view: a staged reason override wins (set by
251        // process_virtual_view_updates immediately before the invoke). The
252        // old force_reinvoke path cleared was_invoked instead, which
253        // collapsed EVERY queued re-invocation to InitialRender at delivery
254        // time — user callbacks could never see EdgeScrolled/BoundsExpanded/
255        // DomRecreated (the latter had zero producers at all).
256        if let Some(pos) = self
257            .reason_overrides
258            .iter()
259            .position(|((d, n), _)| *d == dom_id && *n == node_id)
260        {
261            let (_, reason) = self.reason_overrides.remove(pos);
262            return Some(reason);
263        }
264
265        let state = self.states.entry((dom_id, node_id)).or_insert_with(|| {
266            let nested_dom_id = DomId {
267                inner: self.next_dom_id,
268            };
269            self.next_dom_id += 1;
270            VirtualViewState::new(nested_dom_id)
271        });
272
273        if !state.virtual_view_was_invoked {
274            // Remember where we started, so edge callbacks fire on scroll-to-edge,
275            // not for the edge we happen to rest on at the initial position.
276            state.initial_scroll_offset = scroll_manager
277                .get_current_offset(dom_id, node_id)
278                .unwrap_or_default();
279            return Some(VirtualViewCallbackReason::InitialRender);
280        }
281
282        // Check for bounds expansion
283        if layout_bounds.size.width > state.last_bounds.size.width
284            || layout_bounds.size.height > state.last_bounds.size.height
285        {
286            state.invoked_for_current_expansion = false;
287        }
288        state.last_bounds = layout_bounds;
289
290        let scroll_offset = scroll_manager
291            .get_current_offset(dom_id, node_id)
292            .unwrap_or_default();
293
294        state.check_reinvoke_condition(scroll_offset, layout_bounds.size)
295    }
296
297    /// Returns debug info for all tracked `VirtualViews`
298    ///
299    /// Each entry contains: (`parent_dom_id`, `parent_node_id`, `nested_dom_id`,
300    /// `scroll_size`, `virtual_scroll_size`, `was_invoked`, `last_bounds`)
301    #[must_use] pub fn get_all_virtual_view_infos(&self) -> Vec<VirtualViewDebugInfo> {
302        self.states
303            .iter()
304            .map(|((dom_id, node_id), state)| VirtualViewDebugInfo {
305                parent_dom_id: dom_id.inner,
306                parent_node_id: node_id.index(),
307                nested_dom_id: state.nested_dom_id.inner,
308                scroll_size_width: state.virtual_view_scroll_size.map(|s| s.width),
309                scroll_size_height: state.virtual_view_scroll_size.map(|s| s.height),
310                virtual_scroll_size_width: state.virtual_view_virtual_scroll_size.map(|s| s.width),
311                virtual_scroll_size_height: state.virtual_view_virtual_scroll_size.map(|s| s.height),
312                was_invoked: state.virtual_view_was_invoked,
313                last_bounds_x: state.last_bounds.origin.x,
314                last_bounds_y: state.last_bounds.origin.y,
315                last_bounds_width: state.last_bounds.size.width,
316                last_bounds_height: state.last_bounds.size.height,
317            })
318            .collect()
319    }
320}
321
322/// Debug info for a single `VirtualView`, returned by `get_all_virtual_view_infos`
323#[derive(Copy, Debug, Clone)]
324pub struct VirtualViewDebugInfo {
325    pub parent_dom_id: usize,
326    pub parent_node_id: usize,
327    pub nested_dom_id: usize,
328    pub scroll_size_width: Option<f32>,
329    pub scroll_size_height: Option<f32>,
330    pub virtual_scroll_size_width: Option<f32>,
331    pub virtual_scroll_size_height: Option<f32>,
332    pub was_invoked: bool,
333    pub last_bounds_x: f32,
334    pub last_bounds_y: f32,
335    pub last_bounds_width: f32,
336    pub last_bounds_height: f32,
337}
338
339impl VirtualViewState {
340    /// Creates a new `VirtualViewState` with the given nested DOM ID
341    fn new(nested_dom_id: DomId) -> Self {
342        Self {
343            virtual_view_scroll_size: None,
344            virtual_view_virtual_scroll_size: None,
345            virtual_view_was_invoked: false,
346            invoked_for_current_expansion: false,
347            invoked_for_current_edge: false,
348            last_edge_triggered: EdgeFlags::default(),
349            nested_dom_id,
350            last_bounds: LogicalRect::zero(),
351            initial_scroll_offset: LogicalPosition::zero(),
352        }
353    }
354
355    /// Determines if the `VirtualView` callback should be re-invoked based on
356    /// scroll position
357    ///
358    /// Checks two conditions:
359    /// 1. Container bounds expanded beyond content size
360    /// 2. User scrolled within `EDGE_THRESHOLD` pixels of an edge (for lazy loading)
361    fn check_reinvoke_condition(
362        &self,
363        current_offset: LogicalPosition,
364        container_size: LogicalSize,
365    ) -> Option<VirtualViewCallbackReason> {
366        // Need scroll_size to determine if we can scroll at all
367        let scroll_size = self.virtual_view_scroll_size?;
368
369        // Check 1: Container grew larger than content - need more content
370        if !self.invoked_for_current_expansion
371            && (container_size.width > scroll_size.width
372                || container_size.height > scroll_size.height)
373        {
374            return Some(VirtualViewCallbackReason::BoundsExpanded);
375        }
376
377        // Check 2: Edge-based lazy loading
378        // Determine if scrolling is possible in each direction
379        let scrollable_width = scroll_size.width > container_size.width;
380        let scrollable_height = scroll_size.height > container_size.height;
381
382        // Calculate which edges the user is currently near
383        let current_edges = EdgeFlags {
384            top: scrollable_height && current_offset.y <= EDGE_THRESHOLD,
385            bottom: scrollable_height
386                && (scroll_size.height - container_size.height - current_offset.y)
387                    <= EDGE_THRESHOLD,
388            left: scrollable_width && current_offset.x <= EDGE_THRESHOLD,
389            right: scrollable_width
390                && (scroll_size.width - container_size.width - current_offset.x) <= EDGE_THRESHOLD,
391        };
392
393        // Only treat an edge as "scrolled to" once the user has actually moved
394        // from the resting position captured at InitialRender — sitting at the
395        // initial top/left edge from the start is not an edge-scroll event.
396        let has_scrolled = current_offset != self.initial_scroll_offset;
397
398        // Trigger edge callback if near an edge that hasn't been triggered yet
399        // Prioritize bottom/right edges (common infinite scroll directions)
400        if has_scrolled && !self.invoked_for_current_edge && current_edges.any() {
401            if current_edges.bottom && !self.last_edge_triggered.bottom {
402                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom));
403            }
404            if current_edges.right && !self.last_edge_triggered.right {
405                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Right));
406            }
407            if current_edges.top && !self.last_edge_triggered.top {
408                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Top));
409            }
410            if current_edges.left && !self.last_edge_triggered.left {
411                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Left));
412            }
413        }
414
415        None
416    }
417}
418
419impl EdgeFlags {
420    /// Returns true if any edge flag is set
421    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
422    const fn any(&self) -> bool {
423        self.top || self.bottom || self.left || self.right
424    }
425}
426
427impl From<EdgeType> for EdgeFlags {
428    fn from(edge: EdgeType) -> Self {
429        match edge {
430            EdgeType::Top => Self {
431                top: true,
432                ..Default::default()
433            },
434            EdgeType::Bottom => Self {
435                bottom: true,
436                ..Default::default()
437            },
438            EdgeType::Left => Self {
439                left: true,
440                ..Default::default()
441            },
442            EdgeType::Right => Self {
443                right: true,
444                ..Default::default()
445            },
446        }
447    }
448}
449
450impl crate::managers::NodeIdRemap for VirtualViewManager {
451    /// Remap the `(DomId, NodeId)` keys of every tracked `VirtualView`.
452    ///
453    /// A `VirtualView` whose host node was unmounted has its state dropped —
454    /// including the `nested_dom_id` binding, which would otherwise resurface
455    /// on whatever node inherited the index (rendering the *wrong* nested DOM
456    /// into it) and leak forever.
457    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
458        crate::managers::remap_dom_keys(&mut self.states, dom, map);
459
460        self.reason_overrides.retain_mut(|((d, node_id), _)| {
461            if *d != dom {
462                return true;
463            }
464            map.resolve(*node_id).is_some_and(|new_id| {
465                *node_id = new_id;
466                true
467            })
468        });
469    }
470}
471
472// ============================================================================
473// Adversarial unit tests (autotest fleet)
474//
475// Hostile inputs for every category in the task file: constructors (extreme
476// args + post-construction invariants), getters/predicates (defined value on a
477// default/empty instance), and the numeric decision functions
478// (`check_reinvoke` / `check_reinvoke_condition` / `update_virtual_view_info`)
479// under NaN / ±inf / f32::MAX / negative-overscroll / zero and at the exact
480// EDGE_THRESHOLD boundary.
481//
482// An inline module can reach the private `states` / `next_dom_id` /
483// `reason_overrides` fields and the private `VirtualViewState`, so the flag
484// invariants are asserted directly rather than inferred.
485//
486// Every assertion documents the *actual* behavior — nothing is weakened to
487// make it pass. Where the actual behavior looks wrong (stale `last_edge_triggered`
488// suppressing repeat bottom-edge loads; NaN poisoning the growth check;
489// `Default` handing out DomId 0), the test pins the current behavior and says so
490// in a comment.
491// ============================================================================
492#[cfg(all(test, feature = "std"))]
493mod autotest_generated {
494    #![allow(clippy::float_cmp)] // deterministic inputs: exact float compares are intended
495
496    use std::collections::BTreeSet;
497
498    use azul_core::task::{Instant, SystemTick};
499
500    use super::*;
501
502    // ---------------------------------------------------------------- helpers
503
504    const DOM: DomId = DomId::ROOT_ID;
505    const DOM1: DomId = DomId { inner: 1 };
506    const DOM_MAX: DomId = DomId {
507        inner: usize::MAX,
508    };
509
510    fn n(i: usize) -> NodeId {
511        NodeId::new(i)
512    }
513
514    fn sz(width: f32, height: f32) -> LogicalSize {
515        LogicalSize::new(width, height)
516    }
517
518    fn pos(x: f32, y: f32) -> LogicalPosition {
519        LogicalPosition::new(x, y)
520    }
521
522    fn rect(width: f32, height: f32) -> LogicalRect {
523        LogicalRect::new(LogicalPosition::zero(), sz(width, height))
524    }
525
526    /// Deterministic tick-clock instant — no wall clock, no flakiness.
527    fn at(t: u64) -> Instant {
528        Instant::Tick(SystemTick::new(t))
529    }
530
531    /// A `ScrollManager` reporting exactly `(x, y)` for `(dom, node)`.
532    /// Unclamped, so overscroll / absurd offsets survive to `check_reinvoke`.
533    fn scrolled(dom: DomId, node: NodeId, x: f32, y: f32) -> ScrollManager {
534        let mut sm = ScrollManager::new();
535        sm.set_scroll_position_unclamped(dom, node, pos(x, y), at(0));
536        sm
537    }
538
539    fn st(m: &VirtualViewManager, dom: DomId, node: NodeId) -> &VirtualViewState {
540        m.states.get(&(dom, node)).expect("state must exist")
541    }
542
543    /// Steady state: view `(DOM, n(1))` created, invoked once, content size known.
544    /// `initial_scroll_offset` stays at the (0, 0) default, so any nonzero offset
545    /// counts as "the user has scrolled".
546    fn ready_view(scroll: LogicalSize) -> VirtualViewManager {
547        let mut m = VirtualViewManager::new();
548        m.get_or_create_nested_dom_id(DOM, n(1));
549        m.mark_invoked(DOM, n(1), VirtualViewCallbackReason::InitialRender)
550            .expect("view exists");
551        m.update_virtual_view_info(DOM, n(1), scroll, scroll)
552            .expect("view exists");
553        m
554    }
555
556    /// A bare invoked `VirtualViewState` with a known content size, for driving
557    /// the private `check_reinvoke_condition` directly.
558    fn invoked_state(scroll: LogicalSize) -> VirtualViewState {
559        let mut s = VirtualViewState::new(DomId { inner: 7 });
560        s.virtual_view_was_invoked = true;
561        s.virtual_view_scroll_size = Some(scroll);
562        s
563    }
564
565    // `Option`-returning mutators (the crate denies `unused_must_use`): these
566    // wrappers also assert that the view actually existed.
567    fn mark(m: &mut VirtualViewManager, dom: DomId, node: NodeId, r: VirtualViewCallbackReason) {
568        m.mark_invoked(dom, node, r).expect("view exists");
569    }
570
571    fn set_sizes(
572        m: &mut VirtualViewManager,
573        dom: DomId,
574        node: NodeId,
575        scroll: LogicalSize,
576        virt: LogicalSize,
577    ) {
578        m.update_virtual_view_info(dom, node, scroll, virt)
579            .expect("view exists");
580    }
581
582    // ------------------------------------------------------- constructors
583
584    #[test]
585    fn new_is_empty_and_reserves_dom_id_zero_for_root() {
586        let m = VirtualViewManager::new();
587
588        assert_eq!(m.debug_counts(), 0);
589        assert!(m.all_view_keys().is_empty());
590        assert!(m.get_all_virtual_view_infos().is_empty());
591        assert!(m.reason_overrides.is_empty());
592        // 0 is the root DOM — nested ids start at 1.
593        assert_eq!(m.next_dom_id, 1);
594
595        // Getters on the empty instance are defined, not panicking.
596        assert_eq!(m.get_nested_dom_id(DOM, n(0)), None);
597        assert_eq!(m.get_nested_dom_id(DOM_MAX, n(usize::MAX)), None);
598        assert!(!m.was_virtual_view_invoked(DOM, n(0)));
599        assert!(!m.was_virtual_view_invoked(DOM_MAX, n(usize::MAX)));
600    }
601
602    #[test]
603    fn derived_default_hands_out_root_dom_id_unlike_new() {
604        // HAZARD (pinned, not a live bug): `new()` skips 0 because "0 is root",
605        // but the derived `Default` starts the counter at 0, so a Default-built
606        // manager hands out DomId::ROOT_ID as its first *nested* DOM id. Every
607        // production site builds via `new()` (LayoutWindow does not derive
608        // Default), so this is only reachable by a future caller.
609        assert_eq!(VirtualViewManager::default().next_dom_id, 0);
610        assert_eq!(VirtualViewManager::new().next_dom_id, 1);
611
612        let mut d = VirtualViewManager::default();
613        assert_eq!(d.get_or_create_nested_dom_id(DOM, n(0)), DomId::ROOT_ID);
614
615        let mut fresh = VirtualViewManager::new();
616        assert_ne!(fresh.get_or_create_nested_dom_id(DOM, n(0)), DomId::ROOT_ID);
617    }
618
619    #[test]
620    fn virtual_view_state_new_invariants_at_extreme_dom_id() {
621        let s = VirtualViewState::new(DomId { inner: usize::MAX });
622
623        assert_eq!(s.nested_dom_id.inner, usize::MAX);
624        assert!(s.virtual_view_scroll_size.is_none());
625        assert!(s.virtual_view_virtual_scroll_size.is_none());
626        assert!(!s.virtual_view_was_invoked);
627        assert!(!s.invoked_for_current_expansion);
628        assert!(!s.invoked_for_current_edge);
629        assert_eq!(s.last_edge_triggered, EdgeFlags::default());
630        assert!(!s.last_edge_triggered.any());
631        assert_eq!(s.last_bounds, LogicalRect::zero());
632        assert_eq!(s.initial_scroll_offset, LogicalPosition::zero());
633
634        // A brand-new state has no content size, so it can never ask to be
635        // re-invoked, however absurd the container.
636        assert_eq!(
637            s.check_reinvoke_condition(pos(0.0, 0.0), sz(f32::INFINITY, f32::INFINITY)),
638            None
639        );
640    }
641
642    // --------------------------------------------- nested DOM id allocation
643
644    #[test]
645    fn get_or_create_is_idempotent_and_unique_per_key() {
646        let mut m = VirtualViewManager::new();
647
648        let a = m.get_or_create_nested_dom_id(DOM, n(0));
649        let a_again = m.get_or_create_nested_dom_id(DOM, n(0));
650        assert_eq!(a, a_again, "re-registering a view must not re-allocate");
651        assert_eq!(a, DomId { inner: 1 });
652        assert_eq!(m.debug_counts(), 1);
653
654        // Saturated key components must not panic and must get a fresh id.
655        let b = m.get_or_create_nested_dom_id(DOM_MAX, n(usize::MAX));
656        assert_eq!(b, DomId { inner: 2 });
657        assert_ne!(a, b);
658        assert_eq!(m.debug_counts(), 2);
659
660        assert_eq!(m.get_nested_dom_id(DOM, n(0)), Some(a));
661        assert_eq!(m.get_nested_dom_id(DOM_MAX, n(usize::MAX)), Some(b));
662        assert_eq!(m.get_nested_dom_id(DOM, n(1)), None);
663        assert_eq!(m.get_nested_dom_id(DOM1, n(0)), None);
664    }
665
666    #[test]
667    fn nested_dom_ids_are_unique_across_many_views() {
668        let mut m = VirtualViewManager::new();
669        let mut seen = BTreeSet::new();
670
671        for dom in 0..8_usize {
672            for node in 0..32_usize {
673                let id = m.get_or_create_nested_dom_id(DomId { inner: dom }, n(node));
674                assert!(id.inner >= 1, "nested id must never collide with the root");
675                assert!(seen.insert(id.inner), "nested DOM id {id:?} handed out twice");
676            }
677        }
678
679        assert_eq!(seen.len(), 8 * 32);
680        assert_eq!(m.debug_counts(), 8 * 32);
681        assert_eq!(m.next_dom_id, 8 * 32 + 1);
682    }
683
684    #[test]
685    fn all_view_keys_is_sorted_and_matches_the_tracked_states() {
686        let mut m = VirtualViewManager::new();
687        assert!(m.all_view_keys().is_empty());
688
689        // Insert in deliberately reversed order — BTreeMap must still yield
690        // ascending (DomId, NodeId).
691        m.get_or_create_nested_dom_id(DOM1, n(9));
692        m.get_or_create_nested_dom_id(DOM1, n(2));
693        m.get_or_create_nested_dom_id(DOM, n(7));
694
695        let keys = m.all_view_keys();
696        assert_eq!(keys, vec![(DOM, n(7)), (DOM1, n(2)), (DOM1, n(9))]);
697        assert_eq!(keys.len(), m.debug_counts());
698
699        let mut sorted = keys.clone();
700        sorted.sort_unstable();
701        assert_eq!(keys, sorted);
702    }
703
704    // ------------------------------------------------ Option-returning mutators
705
706    #[test]
707    fn mutators_return_none_for_unknown_view_and_never_insert() {
708        let mut m = VirtualViewManager::new();
709
710        assert_eq!(
711            m.update_virtual_view_info(DOM, n(3), sz(1.0, 1.0), sz(1.0, 1.0)),
712            None
713        );
714        assert_eq!(
715            m.mark_invoked(DOM, n(3), VirtualViewCallbackReason::InitialRender),
716            None
717        );
718        assert_eq!(m.force_reinvoke(DOM, n(3)), None);
719        assert_eq!(
720            m.update_virtual_view_info(DOM_MAX, n(usize::MAX), sz(0.0, 0.0), sz(0.0, 0.0)),
721            None
722        );
723
724        // Unlike check_reinvoke, none of these may lazily create a state.
725        assert_eq!(m.debug_counts(), 0);
726        assert_eq!(m.next_dom_id, 1);
727    }
728
729    // ------------------------------------------------------ reason overrides
730
731    #[test]
732    fn set_reason_override_keeps_only_the_latest_per_key() {
733        let mut m = VirtualViewManager::new();
734
735        for _ in 0..1_000 {
736            m.set_reason_override(DOM, n(2), VirtualViewCallbackReason::DomRecreated);
737        }
738        m.set_reason_override(DOM, n(2), VirtualViewCallbackReason::BoundsExpanded);
739
740        // Re-staging must overwrite, not accumulate.
741        assert_eq!(m.reason_overrides.len(), 1);
742
743        let sm = ScrollManager::new();
744        assert_eq!(
745            m.check_reinvoke(DOM, n(2), &sm, rect(10.0, 10.0)),
746            Some(VirtualViewCallbackReason::BoundsExpanded)
747        );
748    }
749
750    #[test]
751    fn reason_override_is_consumed_exactly_once_and_does_not_create_state() {
752        let mut m = VirtualViewManager::new();
753        let sm = ScrollManager::new();
754
755        m.set_reason_override(DOM, n(2), VirtualViewCallbackReason::ScrollBeyondContent);
756        assert_eq!(
757            m.check_reinvoke(DOM, n(2), &sm, rect(10.0, 10.0)),
758            Some(VirtualViewCallbackReason::ScrollBeyondContent)
759        );
760
761        // The override short-circuits before the entry() call, so no state yet.
762        assert!(m.reason_overrides.is_empty());
763        assert_eq!(m.debug_counts(), 0);
764
765        // Second call falls through to the normal path, which *does* create it.
766        assert_eq!(
767            m.check_reinvoke(DOM, n(2), &sm, rect(10.0, 10.0)),
768            Some(VirtualViewCallbackReason::InitialRender)
769        );
770        assert_eq!(m.debug_counts(), 1);
771        assert_eq!(m.get_nested_dom_id(DOM, n(2)), Some(DomId { inner: 1 }));
772    }
773
774    #[test]
775    fn reason_overrides_do_not_leak_across_keys() {
776        let mut m = VirtualViewManager::new();
777        let sm = ScrollManager::new();
778
779        m.set_reason_override(
780            DOM,
781            n(1),
782            VirtualViewCallbackReason::EdgeScrolled(EdgeType::Left),
783        );
784        m.set_reason_override(DOM1, n(1), VirtualViewCallbackReason::DomRecreated);
785        m.set_reason_override(DOM, n(2), VirtualViewCallbackReason::BoundsExpanded);
786        assert_eq!(m.reason_overrides.len(), 3);
787
788        // A different node of the same DOM must not steal DOM/n(1)'s override.
789        assert_eq!(
790            m.check_reinvoke(DOM, n(2), &sm, rect(1.0, 1.0)),
791            Some(VirtualViewCallbackReason::BoundsExpanded)
792        );
793        assert_eq!(
794            m.check_reinvoke(DOM1, n(1), &sm, rect(1.0, 1.0)),
795            Some(VirtualViewCallbackReason::DomRecreated)
796        );
797        assert_eq!(
798            m.check_reinvoke(DOM, n(1), &sm, rect(1.0, 1.0)),
799            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Left))
800        );
801        assert!(m.reason_overrides.is_empty());
802    }
803
804    // ------------------------------------------- update_virtual_view_info (numeric)
805
806    #[test]
807    fn update_virtual_view_info_zero_and_extreme_sizes_do_not_panic() {
808        let mut m = VirtualViewManager::new();
809        m.get_or_create_nested_dom_id(DOM, n(1));
810
811        for size in [
812            sz(0.0, 0.0),
813            sz(-0.0, -0.0),
814            sz(f32::MAX, f32::MAX),
815            sz(f32::MIN, f32::MIN),
816            sz(f32::INFINITY, f32::NEG_INFINITY),
817            sz(-1.0e30, 1.0e30),
818            sz(f32::MIN_POSITIVE, f32::EPSILON),
819        ] {
820            assert_eq!(
821                m.update_virtual_view_info(DOM, n(1), size, size),
822                Some(()),
823                "size {size:?} must be recorded without panicking"
824            );
825            assert_eq!(st(&m, DOM, n(1)).virtual_view_scroll_size, Some(size));
826            assert_eq!(
827                st(&m, DOM, n(1)).virtual_view_virtual_scroll_size,
828                Some(size)
829            );
830        }
831
832        // NaN is stored verbatim (no normalization, no panic).
833        assert_eq!(
834            m.update_virtual_view_info(DOM, n(1), sz(f32::NAN, f32::NAN), sz(f32::NAN, 1.0)),
835            Some(())
836        );
837        let stored = st(&m, DOM, n(1)).virtual_view_scroll_size.unwrap();
838        assert!(stored.width.is_nan() && stored.height.is_nan());
839    }
840
841    #[test]
842    fn update_virtual_view_info_clears_expansion_flag_only_when_content_grows() {
843        let mut m = ready_view(sz(100.0, 100.0));
844        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::BoundsExpanded);
845        assert!(st(&m, DOM, n(1)).invoked_for_current_expansion);
846
847        // Shrink: not a growth, flag survives.
848        set_sizes(&mut m, DOM, n(1), sz(50.0, 50.0), sz(50.0, 50.0));
849        assert!(st(&m, DOM, n(1)).invoked_for_current_expansion);
850
851        // Same size: still not a growth (strict `>`).
852        set_sizes(&mut m, DOM, n(1), sz(50.0, 50.0), sz(50.0, 50.0));
853        assert!(st(&m, DOM, n(1)).invoked_for_current_expansion);
854
855        // One axis grows by an epsilon: flag cleared, BoundsExpanded can re-fire.
856        set_sizes(&mut m, DOM, n(1), sz(50.000_01, 50.0), sz(50.0, 50.0));
857        assert!(!st(&m, DOM, n(1)).invoked_for_current_expansion);
858    }
859
860    #[test]
861    fn update_virtual_view_info_infinite_growth_clears_the_flag() {
862        let mut m = ready_view(sz(100.0, 100.0));
863        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::BoundsExpanded);
864
865        set_sizes(
866            &mut m,
867            DOM,
868            n(1),
869            sz(f32::INFINITY, 100.0),
870            sz(f32::INFINITY, 100.0),
871        );
872        assert!(!st(&m, DOM, n(1)).invoked_for_current_expansion);
873    }
874
875    #[test]
876    fn update_virtual_view_info_nan_size_poisons_the_growth_check() {
877        // PINNED QUIRK: growth is `new > old`, and every comparison against NaN
878        // is false. Once a NaN content size is recorded, *no* later size — not
879        // even 1e9 — is seen as growth, so `invoked_for_current_expansion` can
880        // never be cleared here again. Only force_reinvoke/reset_all recover.
881        let mut m = ready_view(sz(100.0, 100.0));
882        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::BoundsExpanded);
883        assert!(st(&m, DOM, n(1)).invoked_for_current_expansion);
884
885        // NaN is not > 100.0 → no clear (and no panic).
886        set_sizes(
887            &mut m,
888            DOM,
889            n(1),
890            sz(f32::NAN, f32::NAN),
891            sz(f32::NAN, f32::NAN),
892        );
893        assert!(st(&m, DOM, n(1)).invoked_for_current_expansion);
894
895        // 1e9 is not > NaN either → still no clear.
896        set_sizes(&mut m, DOM, n(1), sz(1.0e9, 1.0e9), sz(1.0e9, 1.0e9));
897        assert!(st(&m, DOM, n(1)).invoked_for_current_expansion);
898
899        // The recovery path still works.
900        m.force_reinvoke(DOM, n(1)).expect("view exists");
901        assert!(!st(&m, DOM, n(1)).invoked_for_current_expansion);
902    }
903
904    // ----------------------------------------------------------- mark_invoked
905
906    #[test]
907    fn mark_invoked_sets_only_the_flags_the_reason_owns() {
908        for reason in [
909            VirtualViewCallbackReason::InitialRender,
910            VirtualViewCallbackReason::DomRecreated,
911            VirtualViewCallbackReason::ScrollBeyondContent,
912        ] {
913            let mut m = VirtualViewManager::new();
914            m.get_or_create_nested_dom_id(DOM, n(1));
915            assert_eq!(m.mark_invoked(DOM, n(1), reason), Some(()));
916
917            let s = st(&m, DOM, n(1));
918            assert!(s.virtual_view_was_invoked, "{reason:?} must mark invoked");
919            assert!(!s.invoked_for_current_expansion, "{reason:?}");
920            assert!(!s.invoked_for_current_edge, "{reason:?}");
921            assert_eq!(s.last_edge_triggered, EdgeFlags::default(), "{reason:?}");
922            assert!(m.was_virtual_view_invoked(DOM, n(1)));
923        }
924
925        let mut m = VirtualViewManager::new();
926        m.get_or_create_nested_dom_id(DOM, n(1));
927        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::BoundsExpanded);
928        let s = st(&m, DOM, n(1));
929        assert!(s.virtual_view_was_invoked);
930        assert!(s.invoked_for_current_expansion);
931        assert!(!s.invoked_for_current_edge);
932        assert_eq!(s.last_edge_triggered, EdgeFlags::default());
933    }
934
935    #[test]
936    fn edge_type_round_trips_through_mark_invoked_into_edge_flags() {
937        for edge in [
938            EdgeType::Top,
939            EdgeType::Bottom,
940            EdgeType::Left,
941            EdgeType::Right,
942        ] {
943            let mut m = VirtualViewManager::new();
944            m.get_or_create_nested_dom_id(DOM, n(1));
945            mark(
946                &mut m,
947                DOM,
948                n(1),
949                VirtualViewCallbackReason::EdgeScrolled(edge),
950            );
951
952            let s = st(&m, DOM, n(1));
953            assert!(s.virtual_view_was_invoked);
954            assert!(s.invoked_for_current_edge);
955            // encode(edge) == decode: the stored flags are exactly EdgeFlags::from(edge).
956            assert_eq!(s.last_edge_triggered, EdgeFlags::from(edge), "{edge:?}");
957            assert!(s.last_edge_triggered.any(), "{edge:?}");
958
959            let f = s.last_edge_triggered;
960            let set = usize::from(f.top)
961                + usize::from(f.bottom)
962                + usize::from(f.left)
963                + usize::from(f.right);
964            assert_eq!(set, 1, "{edge:?} must set exactly one flag");
965            // Expansion is a different trigger and must stay untouched.
966            assert!(!s.invoked_for_current_expansion, "{edge:?}");
967        }
968    }
969
970    // ---------------------------------------------------- reset / force_reinvoke
971
972    #[test]
973    fn reset_all_invocation_flags_on_empty_manager_is_a_noop() {
974        let mut m = VirtualViewManager::new();
975        m.reset_all_invocation_flags();
976        assert_eq!(m.debug_counts(), 0);
977        assert_eq!(m.next_dom_id, 1);
978        assert!(m.all_view_keys().is_empty());
979    }
980
981    #[test]
982    fn reset_all_clears_every_flag_but_preserves_identity_sizes_and_bounds() {
983        let mut m = ready_view(sz(100.0, 1000.0));
984        let nested = m.get_nested_dom_id(DOM, n(1)).expect("view exists");
985        m.get_or_create_nested_dom_id(DOM1, n(4));
986        mark(
987            &mut m,
988            DOM,
989            n(1),
990            VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom),
991        );
992        mark(&mut m, DOM1, n(4), VirtualViewCallbackReason::BoundsExpanded);
993
994        // Record a non-zero last_bounds through the normal path.
995        let sm = scrolled(DOM, n(1), 0.0, 900.0);
996        let _ = m.check_reinvoke(DOM, n(1), &sm, rect(100.0, 100.0));
997        // Overrides are a separate queue: reset must not touch them.
998        m.set_reason_override(DOM, n(1), VirtualViewCallbackReason::DomRecreated);
999
1000        m.reset_all_invocation_flags();
1001
1002        for (dom, node) in [(DOM, n(1)), (DOM1, n(4))] {
1003            let s = st(&m, dom, node);
1004            assert!(!s.virtual_view_was_invoked);
1005            assert!(!s.invoked_for_current_expansion);
1006            assert!(!s.invoked_for_current_edge);
1007            assert_eq!(s.last_edge_triggered, EdgeFlags::default());
1008            assert!(!m.was_virtual_view_invoked(dom, node));
1009        }
1010
1011        // Identity, content size and bounds survive — only the flags reset.
1012        let s = st(&m, DOM, n(1));
1013        assert_eq!(s.nested_dom_id, nested);
1014        assert_eq!(s.virtual_view_scroll_size, Some(sz(100.0, 1000.0)));
1015        assert_eq!(s.last_bounds, rect(100.0, 100.0));
1016        assert_eq!(m.debug_counts(), 2);
1017        assert_eq!(m.reason_overrides.len(), 1);
1018    }
1019
1020    #[test]
1021    fn force_reinvoke_yields_initial_render_but_leaves_last_edge_triggered_set() {
1022        let mut m = ready_view(sz(100.0, 1000.0));
1023        mark(
1024            &mut m,
1025            DOM,
1026            n(1),
1027            VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom),
1028        );
1029
1030        assert_eq!(m.force_reinvoke(DOM, n(1)), Some(()));
1031        let s = st(&m, DOM, n(1));
1032        assert!(!s.virtual_view_was_invoked);
1033        assert!(!s.invoked_for_current_expansion);
1034        assert!(!s.invoked_for_current_edge);
1035        // ASYMMETRY (pinned): unlike reset_all_invocation_flags, force_reinvoke
1036        // does NOT clear last_edge_triggered — see the bottom-edge suppression
1037        // test below for the consequence.
1038        assert_eq!(s.last_edge_triggered, EdgeFlags::from(EdgeType::Bottom));
1039
1040        // The documented effect still holds: the next check is an InitialRender.
1041        let sm = scrolled(DOM, n(1), 0.0, 900.0);
1042        assert_eq!(
1043            m.check_reinvoke(DOM, n(1), &sm, rect(100.0, 100.0)),
1044            Some(VirtualViewCallbackReason::InitialRender)
1045        );
1046    }
1047
1048    // -------------------------------------------------- check_reinvoke (numeric)
1049
1050    #[test]
1051    fn check_reinvoke_creates_the_state_for_an_unknown_view() {
1052        let mut m = VirtualViewManager::new();
1053        let sm = ScrollManager::new();
1054
1055        assert_eq!(
1056            m.check_reinvoke(DOM, n(3), &sm, rect(100.0, 100.0)),
1057            Some(VirtualViewCallbackReason::InitialRender)
1058        );
1059        assert_eq!(m.debug_counts(), 1);
1060        assert_eq!(m.get_nested_dom_id(DOM, n(3)), Some(DomId { inner: 1 }));
1061        assert!(!m.was_virtual_view_invoked(DOM, n(3)));
1062
1063        // Re-checking without marking must keep returning InitialRender and must
1064        // NOT keep allocating states/ids (unbounded growth guard).
1065        for _ in 0..16 {
1066            assert_eq!(
1067                m.check_reinvoke(DOM, n(3), &sm, rect(100.0, 100.0)),
1068                Some(VirtualViewCallbackReason::InitialRender)
1069            );
1070        }
1071        assert_eq!(m.debug_counts(), 1);
1072        assert_eq!(m.next_dom_id, 2);
1073
1074        // Saturated key: no panic, fresh id.
1075        assert_eq!(
1076            m.check_reinvoke(DOM_MAX, n(usize::MAX), &sm, rect(0.0, 0.0)),
1077            Some(VirtualViewCallbackReason::InitialRender)
1078        );
1079        assert_eq!(
1080            m.get_nested_dom_id(DOM_MAX, n(usize::MAX)),
1081            Some(DomId { inner: 2 })
1082        );
1083    }
1084
1085    #[test]
1086    fn check_reinvoke_is_none_while_no_content_size_is_known() {
1087        let mut m = VirtualViewManager::new();
1088        m.get_or_create_nested_dom_id(DOM, n(1));
1089        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::InitialRender);
1090
1091        let sm = scrolled(DOM, n(1), 0.0, 5_000.0);
1092        // scroll_size is still None → the `?` bails out, whatever the bounds.
1093        assert_eq!(
1094            m.check_reinvoke(DOM, n(1), &sm, rect(f32::MAX, f32::MAX)),
1095            None
1096        );
1097        assert_eq!(m.check_reinvoke(DOM, n(1), &sm, rect(0.0, 0.0)), None);
1098    }
1099
1100    #[test]
1101    fn check_reinvoke_bounds_expanded_fires_once_per_growth() {
1102        let mut m = ready_view(sz(100.0, 100.0));
1103        let sm = ScrollManager::new();
1104
1105        assert_eq!(
1106            m.check_reinvoke(DOM, n(1), &sm, rect(200.0, 200.0)),
1107            Some(VirtualViewCallbackReason::BoundsExpanded)
1108        );
1109        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::BoundsExpanded);
1110
1111        // Same bounds again → already invoked for this expansion → quiet.
1112        assert_eq!(m.check_reinvoke(DOM, n(1), &sm, rect(200.0, 200.0)), None);
1113        // Shrinking is never a re-invoke trigger.
1114        assert_eq!(m.check_reinvoke(DOM, n(1), &sm, rect(150.0, 150.0)), None);
1115        // Growing past the last bounds re-arms it.
1116        assert_eq!(
1117            m.check_reinvoke(DOM, n(1), &sm, rect(300.0, 300.0)),
1118            Some(VirtualViewCallbackReason::BoundsExpanded)
1119        );
1120        assert_eq!(st(&m, DOM, n(1)).last_bounds, rect(300.0, 300.0));
1121    }
1122
1123    #[test]
1124    fn check_reinvoke_does_not_fire_an_edge_for_the_resting_start_position() {
1125        // Regression guard for the initial_scroll_offset rule: a view that
1126        // starts at offset 0 is *at* the top edge, but that is the initial
1127        // position, not a scroll-to-edge event.
1128        let mut m = VirtualViewManager::new();
1129        m.get_or_create_nested_dom_id(DOM, n(1));
1130        let sm = scrolled(DOM, n(1), 0.0, 0.0);
1131
1132        assert_eq!(
1133            m.check_reinvoke(DOM, n(1), &sm, rect(100.0, 100.0)),
1134            Some(VirtualViewCallbackReason::InitialRender)
1135        );
1136        assert_eq!(st(&m, DOM, n(1)).initial_scroll_offset, pos(0.0, 0.0));
1137        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::InitialRender);
1138        set_sizes(&mut m, DOM, n(1), sz(100.0, 1000.0), sz(100.0, 1000.0));
1139
1140        // Still parked at the top edge, hasn't moved → no EdgeScrolled(Top).
1141        assert_eq!(m.check_reinvoke(DOM, n(1), &sm, rect(100.0, 100.0)), None);
1142    }
1143
1144    #[test]
1145    fn check_reinvoke_edge_scrolled_bottom_then_stays_quiet() {
1146        let mut m = ready_view(sz(100.0, 1000.0));
1147        let sm = scrolled(DOM, n(1), 0.0, 900.0);
1148
1149        assert_eq!(
1150            m.check_reinvoke(DOM, n(1), &sm, rect(100.0, 100.0)),
1151            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom))
1152        );
1153        mark(
1154            &mut m,
1155            DOM,
1156            n(1),
1157            VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom),
1158        );
1159
1160        // invoked_for_current_edge gates the whole edge block → no duplicate.
1161        assert_eq!(m.check_reinvoke(DOM, n(1), &sm, rect(100.0, 100.0)), None);
1162    }
1163
1164    #[test]
1165    fn bottom_edge_is_suppressed_after_a_force_reinvoke_but_not_after_a_reset() {
1166        // PINNED BUG-SHAPED BEHAVIOR: force_reinvoke clears invoked_for_current_edge
1167        // but NOT last_edge_triggered, so a second genuine scroll-to-bottom produces
1168        // no EdgeScrolled(Bottom) — an infinite-scroll list stops lazy-loading after
1169        // the first page. reset_all_invocation_flags (which does clear the edge
1170        // memory) re-arms it; the two halves below are identical except for that
1171        // one call, which isolates the stale flag as the cause.
1172        let bottom = scrolled(DOM, n(1), 0.0, 900.0);
1173        let middle = scrolled(DOM, n(1), 0.0, 400.0);
1174        let bounds = rect(100.0, 100.0);
1175
1176        let mut m = ready_view(sz(100.0, 1000.0));
1177        assert_eq!(
1178            m.check_reinvoke(DOM, n(1), &bottom, bounds),
1179            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom))
1180        );
1181        mark(
1182            &mut m,
1183            DOM,
1184            n(1),
1185            VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom),
1186        );
1187
1188        // --- half 1: the trigger_virtual_view_rerender() path -----------------
1189        m.force_reinvoke(DOM, n(1)).expect("view exists");
1190        // Re-invoked while the user sits mid-list, so the resting position is 400.
1191        assert_eq!(
1192            m.check_reinvoke(DOM, n(1), &middle, bounds),
1193            Some(VirtualViewCallbackReason::InitialRender)
1194        );
1195        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::InitialRender);
1196        assert_eq!(st(&m, DOM, n(1)).initial_scroll_offset, pos(0.0, 400.0));
1197
1198        // The user now really scrolls 400 → 900 (a scroll-to-edge, and the
1199        // invoked_for_current_edge gate is open), yet nothing fires.
1200        assert_eq!(
1201            m.check_reinvoke(DOM, n(1), &bottom, bounds),
1202            None,
1203            "stale last_edge_triggered.bottom suppresses the second bottom-edge load"
1204        );
1205        assert!(st(&m, DOM, n(1)).last_edge_triggered.bottom);
1206
1207        // --- half 2: the same sequence, but through reset_all -----------------
1208        m.reset_all_invocation_flags();
1209        assert_eq!(
1210            m.check_reinvoke(DOM, n(1), &middle, bounds),
1211            Some(VirtualViewCallbackReason::InitialRender)
1212        );
1213        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::InitialRender);
1214        assert_eq!(
1215            m.check_reinvoke(DOM, n(1), &bottom, bounds),
1216            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom)),
1217            "reset_all clears the edge memory, so the identical scroll does fire"
1218        );
1219    }
1220
1221    #[test]
1222    fn check_reinvoke_with_nan_bounds_is_quiet_and_stores_nan() {
1223        let mut m = ready_view(sz(100.0, 100.0));
1224        let sm = ScrollManager::new();
1225        let nan_rect = LogicalRect::new(pos(f32::NAN, f32::NAN), sz(f32::NAN, f32::NAN));
1226
1227        // Every NaN comparison is false → no expansion, no scrollable axis.
1228        assert_eq!(m.check_reinvoke(DOM, n(1), &sm, nan_rect), None);
1229
1230        let info = m.get_all_virtual_view_infos();
1231        assert_eq!(info.len(), 1);
1232        assert!(info[0].last_bounds_x.is_nan());
1233        assert!(info[0].last_bounds_width.is_nan());
1234        assert!(info[0].last_bounds_height.is_nan());
1235    }
1236
1237    #[test]
1238    fn check_reinvoke_with_infinite_bounds_reports_bounds_expanded() {
1239        let mut m = ready_view(sz(100.0, 100.0));
1240        let sm = ScrollManager::new();
1241
1242        assert_eq!(
1243            m.check_reinvoke(
1244                DOM,
1245                n(1),
1246                &sm,
1247                rect(f32::INFINITY, f32::INFINITY)
1248            ),
1249            Some(VirtualViewCallbackReason::BoundsExpanded)
1250        );
1251    }
1252
1253    // ------------------------------- check_reinvoke_condition (private, numeric)
1254
1255    #[test]
1256    fn edge_threshold_is_exactly_200_px_and_inclusive() {
1257        assert_eq!(EDGE_THRESHOLD, 200.0);
1258
1259        let s = invoked_state(sz(100.0, 1000.0));
1260        let container = sz(100.0, 100.0); // max scroll = 900
1261
1262        // Bottom edge: distance == EDGE_THRESHOLD → inclusive hit.
1263        assert_eq!(
1264            s.check_reinvoke_condition(pos(0.0, 700.0), container),
1265            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom))
1266        );
1267        // One px further from the bottom (201) and not near the top → quiet.
1268        assert_eq!(s.check_reinvoke_condition(pos(0.0, 699.0), container), None);
1269
1270        // Top edge: offset == EDGE_THRESHOLD → inclusive hit.
1271        assert_eq!(
1272            s.check_reinvoke_condition(pos(0.0, 200.0), container),
1273            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Top))
1274        );
1275        // Just past it, and still 699 px from the bottom → quiet.
1276        assert_eq!(s.check_reinvoke_condition(pos(0.0, 201.0), container), None);
1277    }
1278
1279    #[test]
1280    fn edge_priority_is_bottom_right_top_left_and_drains() {
1281        // A container smaller than the content in both axes, parked 100 px in:
1282        // every one of the four edges is within EDGE_THRESHOLD at once.
1283        let mut s = invoked_state(sz(1000.0, 1000.0));
1284        let container = sz(900.0, 900.0);
1285        let offset = pos(100.0, 100.0);
1286
1287        assert_eq!(
1288            s.check_reinvoke_condition(offset, container),
1289            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom))
1290        );
1291
1292        s.last_edge_triggered.bottom = true;
1293        assert_eq!(
1294            s.check_reinvoke_condition(offset, container),
1295            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Right))
1296        );
1297
1298        s.last_edge_triggered.right = true;
1299        assert_eq!(
1300            s.check_reinvoke_condition(offset, container),
1301            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Top))
1302        );
1303
1304        s.last_edge_triggered.top = true;
1305        assert_eq!(
1306            s.check_reinvoke_condition(offset, container),
1307            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Left))
1308        );
1309
1310        // All four drained: near an edge, but nothing left to report.
1311        s.last_edge_triggered.left = true;
1312        assert_eq!(s.check_reinvoke_condition(offset, container), None);
1313    }
1314
1315    #[test]
1316    fn check_reinvoke_condition_at_zero_is_quiet() {
1317        // Zero content, zero container, zero offset: nothing is scrollable and
1318        // 0 > 0 is false, so there is nothing to report.
1319        let s = invoked_state(sz(0.0, 0.0));
1320        assert_eq!(s.check_reinvoke_condition(pos(0.0, 0.0), sz(0.0, 0.0)), None);
1321
1322        // Zero-size content inside a real container *is* an expansion.
1323        assert_eq!(
1324            s.check_reinvoke_condition(pos(0.0, 0.0), sz(1.0, 1.0)),
1325            Some(VirtualViewCallbackReason::BoundsExpanded)
1326        );
1327    }
1328
1329    #[test]
1330    fn check_reinvoke_condition_handles_nan_offset_and_nan_sizes() {
1331        let nan = f32::NAN;
1332
1333        // NaN content size: no expansion (NaN comparisons are false), nothing
1334        // scrollable, no edges → None, and crucially no panic.
1335        let s = invoked_state(sz(nan, nan));
1336        assert_eq!(s.check_reinvoke_condition(pos(0.0, 0.0), sz(100.0, 100.0)), None);
1337        assert_eq!(s.check_reinvoke_condition(pos(nan, nan), sz(nan, nan)), None);
1338
1339        // NaN container size against a real content size.
1340        let s = invoked_state(sz(100.0, 1000.0));
1341        assert_eq!(s.check_reinvoke_condition(pos(0.0, 0.0), sz(nan, nan)), None);
1342
1343        // NaN scroll offset: every edge predicate is false, so no edge fires
1344        // even though `has_scrolled` is true (NaN != 0 under the quantized Eq).
1345        assert_eq!(
1346            s.check_reinvoke_condition(pos(nan, nan), sz(100.0, 100.0)),
1347            None
1348        );
1349    }
1350
1351    #[test]
1352    fn check_reinvoke_condition_handles_negative_overscroll_offsets() {
1353        let s = invoked_state(sz(100.0, 1000.0));
1354        let container = sz(100.0, 100.0);
1355
1356        // Rubber-band overscroll far above the top: deterministically the top edge
1357        // (the bottom is ~1e9 px away, and the x axis is not scrollable).
1358        assert_eq!(
1359            s.check_reinvoke_condition(pos(0.0, -1.0e9), container),
1360            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Top))
1361        );
1362        assert_eq!(
1363            s.check_reinvoke_condition(pos(-50.0, -1.0), container),
1364            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Top))
1365        );
1366
1367        // Overscrolled past the bottom: still the bottom edge, no panic.
1368        assert_eq!(
1369            s.check_reinvoke_condition(pos(0.0, 1.0e9), container),
1370            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom))
1371        );
1372
1373        // Negative container/content sizes must not panic either.
1374        let s = invoked_state(sz(-100.0, -100.0));
1375        assert_eq!(
1376            s.check_reinvoke_condition(pos(0.0, 0.0), sz(-200.0, -200.0)),
1377            None
1378        );
1379    }
1380
1381    #[test]
1382    fn check_reinvoke_condition_saturates_at_f32_extremes() {
1383        // MAX content in a zero container, scrolled to the far end: the bottom
1384        // distance is MAX - 0 - MAX == 0, so this is a bottom-edge hit, not an
1385        // overflow panic.
1386        let s = invoked_state(sz(f32::MAX, f32::MAX));
1387        assert_eq!(
1388            s.check_reinvoke_condition(pos(f32::MAX, f32::MAX), sz(0.0, 0.0)),
1389            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom))
1390        );
1391
1392        // The mirrored extreme: -MAX offset puts the bottom/right distance at
1393        // +inf, so the top edge wins.
1394        assert_eq!(
1395            s.check_reinvoke_condition(pos(-f32::MAX, -f32::MAX), sz(0.0, 0.0)),
1396            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Top))
1397        );
1398
1399        // MAX content in a MAX container: nothing grew, nothing is scrollable.
1400        assert_eq!(
1401            s.check_reinvoke_condition(pos(0.0, 0.0), sz(f32::MAX, f32::MAX)),
1402            None
1403        );
1404
1405        // Infinite container over finite content is an expansion...
1406        let s = invoked_state(sz(100.0, 100.0));
1407        assert_eq!(
1408            s.check_reinvoke_condition(pos(0.0, 0.0), sz(f32::INFINITY, f32::INFINITY)),
1409            Some(VirtualViewCallbackReason::BoundsExpanded)
1410        );
1411        // ...and once that expansion has been served, an infinite container makes
1412        // nothing scrollable, so it goes quiet instead of looping.
1413        let mut s = invoked_state(sz(100.0, 100.0));
1414        s.invoked_for_current_expansion = true;
1415        assert_eq!(
1416            s.check_reinvoke_condition(pos(0.0, 0.0), sz(f32::INFINITY, f32::INFINITY)),
1417            None
1418        );
1419    }
1420
1421    #[test]
1422    fn check_reinvoke_condition_needs_a_real_scroll_before_any_edge_fires() {
1423        let mut s = invoked_state(sz(100.0, 1000.0));
1424        s.initial_scroll_offset = pos(0.0, 900.0);
1425        let container = sz(100.0, 100.0);
1426
1427        // Parked exactly where it started (the bottom): not a scroll-to-edge.
1428        assert_eq!(s.check_reinvoke_condition(pos(0.0, 900.0), container), None);
1429
1430        // One pixel of real movement, still within the bottom threshold → fires.
1431        assert_eq!(
1432            s.check_reinvoke_condition(pos(0.0, 899.0), container),
1433            Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom))
1434        );
1435
1436        // Already invoked for this edge event → quiet regardless of movement.
1437        s.invoked_for_current_edge = true;
1438        assert_eq!(s.check_reinvoke_condition(pos(0.0, 899.0), container), None);
1439    }
1440
1441    // ------------------------------------------------------- getters / predicates
1442
1443    #[test]
1444    fn debug_counts_matches_the_number_of_tracked_views() {
1445        let mut m = VirtualViewManager::new();
1446        assert_eq!(m.debug_counts(), 0);
1447
1448        for i in 0..10_usize {
1449            m.get_or_create_nested_dom_id(DOM, n(i));
1450            assert_eq!(m.debug_counts(), i + 1);
1451        }
1452        // Re-registering the same keys must not grow the map.
1453        for i in 0..10_usize {
1454            m.get_or_create_nested_dom_id(DOM, n(i));
1455        }
1456        assert_eq!(m.debug_counts(), 10);
1457        assert_eq!(m.debug_counts(), m.all_view_keys().len());
1458        assert_eq!(m.debug_counts(), m.get_all_virtual_view_infos().len());
1459    }
1460
1461    #[test]
1462    fn was_virtual_view_invoked_is_false_until_marked() {
1463        let mut m = VirtualViewManager::new();
1464        assert!(!m.was_virtual_view_invoked(DOM, n(1)));
1465
1466        m.get_or_create_nested_dom_id(DOM, n(1));
1467        assert!(
1468            !m.was_virtual_view_invoked(DOM, n(1)),
1469            "registration alone is not an invocation"
1470        );
1471
1472        mark(&mut m, DOM, n(1), VirtualViewCallbackReason::InitialRender);
1473        assert!(m.was_virtual_view_invoked(DOM, n(1)));
1474        // A sibling node must not inherit the flag.
1475        assert!(!m.was_virtual_view_invoked(DOM, n(2)));
1476        assert!(!m.was_virtual_view_invoked(DOM1, n(1)));
1477
1478        assert_eq!(m.force_reinvoke(DOM, n(1)), Some(()));
1479        assert!(!m.was_virtual_view_invoked(DOM, n(1)));
1480    }
1481
1482    #[test]
1483    fn get_all_virtual_view_infos_reports_every_field() {
1484        let m = VirtualViewManager::new();
1485        assert!(m.get_all_virtual_view_infos().is_empty());
1486
1487        let mut m = VirtualViewManager::new();
1488        let nested = m.get_or_create_nested_dom_id(DOM1, n(5));
1489
1490        // Before any callback: sizes are None, not 0.0.
1491        let info = m.get_all_virtual_view_infos();
1492        assert_eq!(info.len(), 1);
1493        assert_eq!(info[0].parent_dom_id, 1);
1494        assert_eq!(info[0].parent_node_id, 5);
1495        assert_eq!(info[0].nested_dom_id, nested.inner);
1496        assert!(info[0].scroll_size_width.is_none());
1497        assert!(info[0].scroll_size_height.is_none());
1498        assert!(info[0].virtual_scroll_size_width.is_none());
1499        assert!(info[0].virtual_scroll_size_height.is_none());
1500        assert!(!info[0].was_invoked);
1501        assert_eq!(info[0].last_bounds_x, 0.0);
1502        assert_eq!(info[0].last_bounds_y, 0.0);
1503        assert_eq!(info[0].last_bounds_width, 0.0);
1504        assert_eq!(info[0].last_bounds_height, 0.0);
1505
1506        set_sizes(&mut m, DOM1, n(5), sz(3.0, 4.0), sz(5.0, 6.0));
1507        mark(&mut m, DOM1, n(5), VirtualViewCallbackReason::InitialRender);
1508
1509        let info = m.get_all_virtual_view_infos();
1510        assert_eq!(info[0].scroll_size_width, Some(3.0));
1511        assert_eq!(info[0].scroll_size_height, Some(4.0));
1512        assert_eq!(info[0].virtual_scroll_size_width, Some(5.0));
1513        assert_eq!(info[0].virtual_scroll_size_height, Some(6.0));
1514        assert!(info[0].was_invoked);
1515
1516        // Infos are emitted in the same (sorted) order as all_view_keys.
1517        m.get_or_create_nested_dom_id(DOM, n(9));
1518        let keys = m.all_view_keys();
1519        let infos = m.get_all_virtual_view_infos();
1520        assert_eq!(keys.len(), infos.len());
1521        for (k, i) in keys.iter().zip(infos.iter()) {
1522            assert_eq!(k.0.inner, i.parent_dom_id);
1523            assert_eq!(k.1.index(), i.parent_node_id);
1524        }
1525    }
1526
1527    #[test]
1528    fn edge_flags_any_is_the_or_of_all_four_edges() {
1529        assert!(!EdgeFlags::default().any());
1530
1531        let mut all = EdgeFlags::default();
1532        for edge in [
1533            EdgeType::Top,
1534            EdgeType::Bottom,
1535            EdgeType::Left,
1536            EdgeType::Right,
1537        ] {
1538            let f = EdgeFlags::from(edge);
1539            assert!(f.any(), "{edge:?} alone must satisfy any()");
1540            all.top |= f.top;
1541            all.bottom |= f.bottom;
1542            all.left |= f.left;
1543            all.right |= f.right;
1544        }
1545
1546        assert_eq!(
1547            all,
1548            EdgeFlags {
1549                top: true,
1550                bottom: true,
1551                left: true,
1552                right: true,
1553            }
1554        );
1555        assert!(all.any());
1556    }
1557
1558    #[test]
1559    fn edge_flags_from_edge_type_sets_exactly_that_edge() {
1560        assert_eq!(
1561            EdgeFlags::from(EdgeType::Top),
1562            EdgeFlags {
1563                top: true,
1564                ..Default::default()
1565            }
1566        );
1567        assert_eq!(
1568            EdgeFlags::from(EdgeType::Bottom),
1569            EdgeFlags {
1570                bottom: true,
1571                ..Default::default()
1572            }
1573        );
1574        assert_eq!(
1575            EdgeFlags::from(EdgeType::Left),
1576            EdgeFlags {
1577                left: true,
1578                ..Default::default()
1579            }
1580        );
1581        assert_eq!(
1582            EdgeFlags::from(EdgeType::Right),
1583            EdgeFlags {
1584                right: true,
1585                ..Default::default()
1586            }
1587        );
1588    }
1589}