Skip to main content

azul_layout/managers/
hover.rs

1//! Hover state management for tracking mouse and touch hover history
2//!
3//! The `HoverManager` records hit test results for multiple input points
4//! (mouse, touch, pen) over multiple frames to enable gesture detection
5//! (like `DragStart`) that requires analyzing hover patterns over time
6//! rather than just the current frame.
7
8use std::collections::{BTreeMap, VecDeque};
9
10use crate::hit_test::FullHitTest;
11
12/// Maximum number of frames to keep in hover history
13const MAX_HOVER_HISTORY: usize = 5;
14
15/// Pick the front-most deepest hovered node across all hit DOMs.
16///
17/// Iterates DOMs from highest `DomId` (most-nested child, composited on top)
18/// to lowest and returns the deepest node (last in `NodeId` order) of the first
19/// DOM that actually has a regular hit. See [`HoverManager::current_hover_node_full`].
20fn deepest_node_across_doms(ht: &FullHitTest) -> Option<azul_core::dom::DomNodeId> {
21    for (dom_id, hit) in ht.hovered_nodes.iter().rev() {
22        if let Some(node_id) = hit.regular_hit_test_nodes.keys().last().copied() {
23            return Some(azul_core::dom::DomNodeId {
24                dom: *dom_id,
25                node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
26                    node_id,
27                )),
28            });
29        }
30    }
31    None
32}
33
34/// Identifier for an input point (mouse, touch, pen, etc.)
35#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub enum InputPointId {
37    /// Mouse cursor
38    Mouse,
39    /// Touch point with unique ID (from TouchEvent.id)
40    Touch(u64),
41}
42
43/// Manages hover state history for all input points
44///
45/// Records hit test results for mouse and touch inputs over multiple frames:
46/// - `DragStart` detection (requires movement threshold over multiple frames)
47/// - Hover-over event detection
48/// - Multi-touch gesture detection
49/// - Input path analysis
50///
51/// The manager maintains a separate history for each active input point.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct HoverManager {
54    /// Hit test history for each input point
55    /// Each point has its own ring buffer of the last N frames
56    hover_histories: BTreeMap<InputPointId, VecDeque<FullHitTest>>,
57}
58
59impl HoverManager {
60    /// Create a new empty `HoverManager`
61    #[must_use] pub const fn new() -> Self {
62        Self {
63            hover_histories: BTreeMap::new(),
64        }
65    }
66
67    /// (input points, total history entries across all points). Used by
68    /// `AZ_E2E_TEST` to watch for unbounded growth.
69    #[must_use] pub fn debug_counts(&self) -> (usize, usize) {
70        let points = self.hover_histories.len();
71        let total: usize = self.hover_histories.values().map(VecDeque::len).sum();
72        (points, total)
73    }
74
75    /// Push a new hit test result for a specific input point
76    ///
77    /// The most recent result is always at index 0 for that input point.
78    /// If the history is full, the oldest frame is dropped.
79    pub fn push_hit_test(&mut self, input_id: InputPointId, hit_test: FullHitTest) {
80        let history = self
81            .hover_histories
82            .entry(input_id)
83            .or_insert_with(|| VecDeque::with_capacity(MAX_HOVER_HISTORY));
84
85        // Add to front (most recent)
86        history.push_front(hit_test);
87
88        // Remove oldest if we exceed the limit
89        if history.len() > MAX_HOVER_HISTORY {
90            history.pop_back();
91        }
92    }
93
94    /// Remove an input point's history (e.g., when touch ends)
95    pub fn remove_input_point(&mut self, input_id: &InputPointId) {
96        self.hover_histories.remove(input_id);
97    }
98
99    /// Get the most recent hit test result for an input point
100    ///
101    /// Returns None if no hit tests have been recorded for this input point.
102    #[must_use] pub fn get_current(&self, input_id: &InputPointId) -> Option<&FullHitTest> {
103        self.hover_histories
104            .get(input_id)
105            .and_then(|history| history.front())
106    }
107
108    /// Get the most recent mouse cursor hit test (convenience method)
109    #[must_use] pub fn get_current_mouse(&self) -> Option<&FullHitTest> {
110        self.get_current(&InputPointId::Mouse)
111    }
112
113    /// Get the hit test result from N frames ago for an input point
114    /// (0 = current frame)
115    ///
116    /// Returns None if the requested frame is not in history.
117    #[must_use] pub fn get_frame(&self, input_id: &InputPointId, frames_ago: usize) -> Option<&FullHitTest> {
118        self.hover_histories
119            .get(input_id)
120            .and_then(|history| history.get(frames_ago))
121    }
122
123    /// Get the entire hover history for an input point (most recent first)
124    #[must_use] pub fn get_history(&self, input_id: &InputPointId) -> Option<&VecDeque<FullHitTest>> {
125        self.hover_histories.get(input_id)
126    }
127
128    /// Get all currently tracked input points
129    #[must_use] pub fn get_active_input_points(&self) -> Vec<InputPointId> {
130        self.hover_histories.keys().copied().collect()
131    }
132
133    /// Get the number of frames in history for an input point
134    #[must_use] pub fn frame_count(&self, input_id: &InputPointId) -> usize {
135        self.hover_histories
136            .get(input_id)
137            .map_or(0, VecDeque::len)
138    }
139
140    /// Purge every recorded hit-test entry for `dom_id` across all input
141    /// points and all history frames.
142    ///
143    /// Called when a `VirtualView` child DOM is rebuilt IN PLACE (fresh `NodeIds`,
144    /// no reconcile mapping — e.g. a `MapWidget` pan rebuilding the tile grid):
145    /// the recorded hits for that DOM reference the OLD generation's `NodeIds`,
146    /// and consumers that resolve them against the NEW styled DOM read out of
147    /// bounds (the `hit_test.rs` cursor panic: "len is 25 but the index is 27")
148    /// or target the wrong node. Unlike incremental reconciles there is no
149    /// `NodeId` map to `remap` with, so the only safe option is to forget that
150    /// DOM's hits; the next pointer move re-populates them from a fresh
151    /// hit test.
152    pub fn purge_dom(&mut self, dom_id: &azul_core::dom::DomId) {
153        for history in self.hover_histories.values_mut() {
154            for frame in history.iter_mut() {
155                frame.hovered_nodes.remove(dom_id);
156            }
157        }
158    }
159
160    /// Clear all hover history for all input points
161    pub fn clear(&mut self) {
162        self.hover_histories.clear();
163    }
164
165    /// Clear history for a specific input point
166    pub(crate) fn clear_input_point(&mut self, input_id: &InputPointId) {
167        if let Some(history) = self.hover_histories.get_mut(input_id) {
168            history.clear();
169        }
170    }
171
172    /// Check if we have enough frames for gesture detection on an input point
173    ///
174    /// `DragStart` detection requires analyzing movement over multiple frames.
175    /// This returns true if we have at least 2 frames of history.
176    #[must_use] pub fn has_sufficient_history_for_gestures(&self, input_id: &InputPointId) -> bool {
177        self.frame_count(input_id) >= 2
178    }
179
180    /// Check if any input point has enough history for gesture detection
181    #[must_use] pub fn any_has_sufficient_history_for_gestures(&self) -> bool {
182        self.hover_histories
183            .iter()
184            .any(|(_, history)| history.len() >= 2)
185    }
186
187    /// Get the deepest hovered node from the current mouse hit test.
188    ///
189    /// Returns the `NodeId` of the most specific (deepest in DOM tree) node
190    /// that the mouse cursor is currently over, or None if not hovering anything.
191    ///
192    /// NOTE: Assumes single-DOM architecture (uses `DomId { inner: 0 }`).
193    #[must_use] pub fn current_hover_node(&self) -> Option<azul_core::id::NodeId> {
194        let current = self.get_current_mouse()?;
195        let dom_id = azul_core::dom::DomId { inner: 0 };
196        let ht = current.hovered_nodes.get(&dom_id)?;
197        ht.regular_hit_test_nodes.keys().last().copied()
198    }
199
200    /// Get the deepest hovered node from the previous frame's mouse hit test.
201    ///
202    /// Returns the `NodeId` from one frame ago, or None if not hovering anything
203    /// or no previous frame exists.
204    ///
205    /// NOTE: Assumes single-DOM architecture (uses `DomId { inner: 0 }`).
206    #[must_use] pub fn previous_hover_node(&self) -> Option<azul_core::id::NodeId> {
207        let history = self.hover_histories.get(&InputPointId::Mouse)?;
208        let previous = history.get(1)?; // index 1 = one frame ago
209        let dom_id = azul_core::dom::DomId { inner: 0 };
210        let ht = previous.hovered_nodes.get(&dom_id)?;
211        ht.regular_hit_test_nodes.keys().last().copied()
212    }
213
214    /// Multi-DOM aware: the deepest hovered node across ALL hit DOMs (current
215    /// frame). Returns a full `DomNodeId` so events can target `VirtualView` /
216    /// iframe child DOMs, not just the root.
217    ///
218    /// Selection rule: prefer the most-nested DOM that was hit. Child DOMs
219    /// (`VirtualView` / iframe content) always have higher `DomId`s than their
220    /// host and are composited on top of it, so the highest hit `DomId` is the
221    /// front-most surface. Within that DOM the deepest node (last in `NodeId`
222    /// order) is the W3C event target; bubbling then reaches ancestor handlers.
223    ///
224    /// For single-DOM apps only `DomId 0` is ever hit, so this is equivalent to
225    /// [`current_hover_node`] wrapped in `DomId { inner: 0 }`.
226    #[must_use] pub fn current_hover_node_full(&self) -> Option<azul_core::dom::DomNodeId> {
227        deepest_node_across_doms(self.get_current_mouse()?)
228    }
229
230    /// Multi-DOM aware counterpart of [`previous_hover_node`] (one frame ago).
231    #[must_use] pub fn previous_hover_node_full(&self) -> Option<azul_core::dom::DomNodeId> {
232        let history = self.hover_histories.get(&InputPointId::Mouse)?;
233        deepest_node_across_doms(history.get(1)?)
234    }
235
236}
237
238impl crate::managers::NodeIdRemap for HoverManager {
239    /// Remap `NodeIds` in all hover histories after DOM reconciliation.
240    ///
241    /// Hits on unmounted nodes are dropped (they cannot be hovered any more) —
242    /// keeping them would make the hover history describe a node that no longer
243    /// exists at that index.
244    fn remap_node_ids(&mut self, dom_id: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
245        let node_id_map = map.as_btree_map();
246        for history in self.hover_histories.values_mut() {
247            for hit_test in history.iter_mut() {
248                if let Some(ht) = hit_test.hovered_nodes.get_mut(&dom_id) {
249                    crate::managers::remap_keys(&mut ht.regular_hit_test_nodes, map);
250                    crate::managers::remap_keys(&mut ht.scroll_hit_test_nodes, map);
251                    crate::managers::remap_keys(&mut ht.cursor_hit_test_nodes, map);
252
253                    // Remap scrollbar_hit_test_nodes (ScrollbarHitId contains NodeId)
254                    let old_sb: Vec<_> = ht.scrollbar_hit_test_nodes.keys().copied().collect();
255                    let mut new_sb = BTreeMap::new();
256                    for old_key in old_sb {
257                        let Some(new_key) = remap_scrollbar_hit_id(&old_key, dom_id, node_id_map)
258                        else {
259                            // node unmounted — drop the scrollbar hit
260                            ht.scrollbar_hit_test_nodes.remove(&old_key);
261                            continue;
262                        };
263                        if let Some(item) = ht.scrollbar_hit_test_nodes.remove(&old_key) {
264                            new_sb.insert(new_key, item);
265                        }
266                    }
267                    ht.scrollbar_hit_test_nodes = new_sb;
268                }
269            }
270        }
271    }
272}
273
274impl Default for HoverManager {
275    fn default() -> Self {
276        Self::new()
277    }
278}
279
280/// Remap a `ScrollbarHitId`'s `NodeId` using the reconciliation map.
281/// `None` = the node was unmounted, so the hit must be dropped.
282/// A `ScrollbarHitId` for a different `DomId` is returned unchanged.
283fn remap_scrollbar_hit_id(
284    id: &azul_core::hit_test::ScrollbarHitId,
285    dom_id: azul_core::dom::DomId,
286    node_id_map: &BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
287) -> Option<azul_core::hit_test::ScrollbarHitId> {
288    use azul_core::hit_test::ScrollbarHitId;
289    Some(match id {
290        ScrollbarHitId::VerticalTrack(d, n) if *d == dom_id => {
291            ScrollbarHitId::VerticalTrack(*d, *node_id_map.get(n)?)
292        }
293        ScrollbarHitId::VerticalThumb(d, n) if *d == dom_id => {
294            ScrollbarHitId::VerticalThumb(*d, *node_id_map.get(n)?)
295        }
296        ScrollbarHitId::HorizontalTrack(d, n) if *d == dom_id => {
297            ScrollbarHitId::HorizontalTrack(*d, *node_id_map.get(n)?)
298        }
299        ScrollbarHitId::HorizontalThumb(d, n) if *d == dom_id => {
300            ScrollbarHitId::HorizontalThumb(*d, *node_id_map.get(n)?)
301        }
302        other => *other,
303    })
304}
305
306#[cfg(test)]
307mod autotest_generated {
308    use azul_core::{
309        dom::{DomId, DomNodeId, ScrollbarOrientation},
310        geom::LogicalPosition,
311        hit_test::{
312            CursorHitTestItem, CursorType, HitTest, HitTestItem, OverflowingScrollNode,
313            ScrollHitTestItem, ScrollbarHitId, ScrollbarHitTestItem,
314        },
315        id::NodeId,
316        styled_dom::NodeHierarchyItemId,
317    };
318
319    use super::*;
320    use crate::managers::{NodeIdMap, NodeIdRemap};
321
322    // ---------------------------------------------------------------- fixtures
323
324    fn hit_item(depth: u32) -> HitTestItem {
325        HitTestItem {
326            point_in_viewport: LogicalPosition::zero(),
327            point_relative_to_item: LogicalPosition::zero(),
328            is_focusable: false,
329            is_virtual_view_hit: None,
330            hit_depth: depth,
331        }
332    }
333
334    fn scroll_item() -> ScrollHitTestItem {
335        ScrollHitTestItem {
336            point_in_viewport: LogicalPosition::zero(),
337            point_relative_to_item: LogicalPosition::zero(),
338            scroll_node: OverflowingScrollNode::default(),
339        }
340    }
341
342    fn cursor_item() -> CursorHitTestItem {
343        CursorHitTestItem {
344            cursor_type: CursorType::Text,
345            hit_depth: 0,
346            point_in_viewport: LogicalPosition::zero(),
347        }
348    }
349
350    fn scrollbar_item() -> ScrollbarHitTestItem {
351        ScrollbarHitTestItem {
352            point_in_viewport: LogicalPosition::zero(),
353            point_relative_to_item: LogicalPosition::zero(),
354            orientation: ScrollbarOrientation::Vertical,
355        }
356    }
357
358    fn dom(inner: usize) -> DomId {
359        DomId { inner }
360    }
361
362    /// A `FullHitTest` where every `(dom, &[node..])` entry is a set of regular hits.
363    /// Node ids are inserted in the given (deliberately unsorted) order.
364    fn hits(entries: &[(usize, &[usize])]) -> FullHitTest {
365        let mut full = FullHitTest::empty(None);
366        for (dom_inner, nodes) in entries {
367            let ht = full
368                .hovered_nodes
369                .entry(dom(*dom_inner))
370                .or_insert_with(HitTest::empty);
371            for n in *nodes {
372                ht.regular_hit_test_nodes
373                    .insert(NodeId::new(*n), hit_item(0));
374            }
375        }
376        full
377    }
378
379    /// `DomNodeId` for `(dom, node)`, matching what the hover getters return.
380    fn dom_node(dom_inner: usize, node: usize) -> DomNodeId {
381        DomNodeId {
382            dom: dom(dom_inner),
383            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
384        }
385    }
386
387    /// A manager whose mouse history is `frames` (pushed oldest-first, so the
388    /// LAST element ends up at index 0 = current).
389    fn mouse_history(frames: Vec<FullHitTest>) -> HoverManager {
390        let mut hm = HoverManager::new();
391        for f in frames {
392            hm.push_hit_test(InputPointId::Mouse, f);
393        }
394        hm
395    }
396
397    // ------------------------------------------- deepest_node_across_doms (other)
398
399    #[test]
400    fn deepest_node_across_doms_empty_returns_none() {
401        assert_eq!(deepest_node_across_doms(&FullHitTest::empty(None)), None);
402    }
403
404    #[test]
405    fn deepest_node_across_doms_uses_nodeid_order_not_insertion_order() {
406        // Inserted 2, 9, 7 — BTreeMap key order makes 9 the deepest regardless.
407        let ht = hits(&[(0, &[2, 9, 7])]);
408        assert_eq!(deepest_node_across_doms(&ht), Some(dom_node(0, 9)));
409    }
410
411    #[test]
412    fn deepest_node_across_doms_prefers_highest_dom_even_if_its_node_is_shallower() {
413        // dom 0 has the deeper NodeId (99) but dom 3 is composited on top.
414        let ht = hits(&[(0, &[99]), (3, &[1])]);
415        assert_eq!(deepest_node_across_doms(&ht), Some(dom_node(3, 1)));
416    }
417
418    #[test]
419    fn deepest_node_across_doms_skips_dom_with_no_regular_hits() {
420        // dom 5 is "hit" but only in the scroll/cursor/scrollbar maps — the
421        // front-most DOM with a REGULAR hit (dom 0) must win instead.
422        let mut ht = hits(&[(0, &[4])]);
423        let mut empty_regular = HitTest::empty();
424        empty_regular
425            .scroll_hit_test_nodes
426            .insert(NodeId::new(1), scroll_item());
427        empty_regular
428            .cursor_hit_test_nodes
429            .insert(NodeId::new(1), cursor_item());
430        empty_regular.scrollbar_hit_test_nodes.insert(
431            ScrollbarHitId::VerticalThumb(dom(5), NodeId::new(1)),
432            scrollbar_item(),
433        );
434        ht.hovered_nodes.insert(dom(5), empty_regular);
435
436        assert_eq!(deepest_node_across_doms(&ht), Some(dom_node(0, 4)));
437    }
438
439    #[test]
440    fn deepest_node_across_doms_all_doms_empty_returns_none() {
441        let mut ht = FullHitTest::empty(None);
442        ht.hovered_nodes.insert(dom(0), HitTest::empty());
443        ht.hovered_nodes.insert(dom(usize::MAX), HitTest::empty());
444        assert_eq!(deepest_node_across_doms(&ht), None);
445    }
446
447    #[test]
448    fn deepest_node_across_doms_extreme_ids_survive_the_nodeid_encoding() {
449        // usize::MAX - 1 is the largest NodeId that survives the 1-based
450        // `NodeHierarchyItemId` encode (n + 1) without wrapping.
451        let max_node = usize::MAX - 1;
452        let ht = hits(&[(usize::MAX, &[0, max_node])]);
453        let got = deepest_node_across_doms(&ht).expect("a hit exists");
454        assert_eq!(got.dom, dom(usize::MAX));
455        // The DomNodeId must decode back to exactly the NodeId that was hit.
456        assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(max_node)));
457    }
458
459    // ------------------------------------------------- new / Default (constructor)
460
461    #[test]
462    fn new_manager_is_empty_and_every_getter_is_none_or_zero() {
463        let hm = HoverManager::new();
464        let mouse = InputPointId::Mouse;
465
466        assert_eq!(hm.debug_counts(), (0, 0));
467        assert!(hm.get_active_input_points().is_empty());
468        assert!(hm.get_current(&mouse).is_none());
469        assert!(hm.get_current_mouse().is_none());
470        assert!(hm.get_history(&mouse).is_none());
471        assert_eq!(hm.frame_count(&mouse), 0);
472        assert!(!hm.has_sufficient_history_for_gestures(&mouse));
473        assert!(!hm.any_has_sufficient_history_for_gestures());
474        assert!(hm.current_hover_node().is_none());
475        assert!(hm.previous_hover_node().is_none());
476        assert!(hm.current_hover_node_full().is_none());
477        assert!(hm.previous_hover_node_full().is_none());
478        // Frame lookups on an unknown point must not panic at any index.
479        assert!(hm.get_frame(&mouse, 0).is_none());
480        assert!(hm.get_frame(&mouse, usize::MAX).is_none());
481    }
482
483    #[test]
484    fn default_matches_new() {
485        assert_eq!(HoverManager::default(), HoverManager::new());
486    }
487
488    // ------------------------------------------------- push_hit_test (numeric)
489
490    #[test]
491    fn push_hit_test_index_zero_is_the_newest_frame() {
492        let hm = mouse_history(vec![hits(&[(0, &[1])]), hits(&[(0, &[2])])]);
493
494        assert_eq!(hm.frame_count(&InputPointId::Mouse), 2);
495        assert_eq!(hm.get_frame(&InputPointId::Mouse, 0), Some(&hits(&[(0, &[2])])));
496        assert_eq!(hm.get_frame(&InputPointId::Mouse, 1), Some(&hits(&[(0, &[1])])));
497        assert_eq!(hm.get_current_mouse(), Some(&hits(&[(0, &[2])])));
498    }
499
500    #[test]
501    fn push_hit_test_ring_buffer_never_exceeds_max_hover_history() {
502        let mut hm = HoverManager::new();
503        for i in 0..1000_usize {
504            hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[i])]));
505        }
506
507        assert_eq!(hm.frame_count(&InputPointId::Mouse), MAX_HOVER_HISTORY);
508        assert_eq!(hm.debug_counts(), (1, MAX_HOVER_HISTORY));
509        // The retained window is the LAST MAX_HOVER_HISTORY pushes, newest first.
510        for ago in 0..MAX_HOVER_HISTORY {
511            assert_eq!(
512                hm.get_frame(&InputPointId::Mouse, ago),
513                Some(&hits(&[(0, &[999 - ago])])),
514                "frame {ago} frames ago"
515            );
516        }
517        // Anything older was dropped.
518        assert!(hm.get_frame(&InputPointId::Mouse, MAX_HOVER_HISTORY).is_none());
519    }
520
521    #[test]
522    fn get_frame_out_of_range_index_returns_none_without_overflow() {
523        let hm = mouse_history(vec![hits(&[(0, &[1])])]);
524        let mouse = InputPointId::Mouse;
525
526        assert!(hm.get_frame(&mouse, 0).is_some());
527        assert!(hm.get_frame(&mouse, 1).is_none());
528        assert!(hm.get_frame(&mouse, usize::MAX).is_none());
529        assert!(hm.get_frame(&mouse, usize::MAX / 2).is_none());
530        // Unknown input point at a huge index is still just None.
531        assert!(hm.get_frame(&InputPointId::Touch(u64::MAX), usize::MAX).is_none());
532    }
533
534    #[test]
535    fn touch_ids_at_u64_boundaries_are_distinct_histories() {
536        let mut hm = HoverManager::new();
537        hm.push_hit_test(InputPointId::Touch(u64::MIN), hits(&[(0, &[1])]));
538        hm.push_hit_test(InputPointId::Touch(u64::MAX), hits(&[(0, &[2])]));
539        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[3])]));
540
541        assert_eq!(hm.debug_counts(), (3, 3));
542        assert_eq!(
543            hm.get_current(&InputPointId::Touch(u64::MIN)),
544            Some(&hits(&[(0, &[1])]))
545        );
546        assert_eq!(
547            hm.get_current(&InputPointId::Touch(u64::MAX)),
548            Some(&hits(&[(0, &[2])]))
549        );
550        assert_eq!(hm.get_current_mouse(), Some(&hits(&[(0, &[3])])));
551        // Ord derive: Mouse sorts before every Touch, touches sort by id.
552        assert_eq!(
553            hm.get_active_input_points(),
554            vec![
555                InputPointId::Mouse,
556                InputPointId::Touch(0),
557                InputPointId::Touch(u64::MAX),
558            ]
559        );
560    }
561
562    #[test]
563    fn debug_counts_stays_bounded_under_a_flood_of_points_and_frames() {
564        let mut hm = HoverManager::new();
565        for point in 0..100_u64 {
566            for frame in 0..50_usize {
567                hm.push_hit_test(InputPointId::Touch(point), hits(&[(0, &[frame])]));
568            }
569        }
570        // 100 points, each capped at MAX_HOVER_HISTORY frames — no unbounded growth.
571        assert_eq!(hm.debug_counts(), (100, 100 * MAX_HOVER_HISTORY));
572    }
573
574    #[test]
575    fn push_hit_test_stores_the_value_verbatim_including_focused_node() {
576        let focused = dom_node(0, 7);
577        let mut ht = FullHitTest::empty(Some(focused));
578        ht.hovered_nodes.insert(dom(0), HitTest::empty());
579
580        let mut hm = HoverManager::new();
581        hm.push_hit_test(InputPointId::Mouse, ht.clone());
582
583        assert_eq!(hm.get_current_mouse(), Some(&ht));
584        assert_eq!(
585            hm.get_current_mouse().map(|h| h.focused_node),
586            Some(Some(focused).into())
587        );
588        // A hovered DOM with zero hits is still "no hovered node".
589        assert!(hm.current_hover_node().is_none());
590        assert!(hm.current_hover_node_full().is_none());
591    }
592
593    #[test]
594    fn get_history_returns_all_frames_newest_first() {
595        let hm = mouse_history(vec![
596            hits(&[(0, &[1])]),
597            hits(&[(0, &[2])]),
598            hits(&[(0, &[3])]),
599        ]);
600        let history = hm.get_history(&InputPointId::Mouse).expect("history exists");
601
602        assert_eq!(history.len(), 3);
603        assert_eq!(history[0], hits(&[(0, &[3])]));
604        assert_eq!(history[2], hits(&[(0, &[1])]));
605        assert!(hm.get_history(&InputPointId::Touch(0)).is_none());
606    }
607
608    // --------------------------------------- remove / clear / clear_input_point
609
610    #[test]
611    fn remove_absent_input_point_is_a_noop() {
612        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
613        let before = hm.clone();
614
615        hm.remove_input_point(&InputPointId::Touch(0));
616        hm.remove_input_point(&InputPointId::Touch(u64::MAX));
617
618        assert_eq!(hm, before);
619    }
620
621    #[test]
622    fn remove_input_point_only_drops_the_target_point() {
623        let mut hm = HoverManager::new();
624        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[1])]));
625        hm.push_hit_test(InputPointId::Touch(3), hits(&[(0, &[2])]));
626
627        hm.remove_input_point(&InputPointId::Touch(3));
628
629        assert_eq!(hm.debug_counts(), (1, 1));
630        assert_eq!(hm.get_active_input_points(), vec![InputPointId::Mouse]);
631        assert!(hm.get_current(&InputPointId::Touch(3)).is_none());
632        assert_eq!(hm.frame_count(&InputPointId::Touch(3)), 0);
633        assert!(hm.get_current_mouse().is_some());
634    }
635
636    #[test]
637    fn remove_then_push_restarts_the_history_from_scratch() {
638        let mut hm = mouse_history(vec![hits(&[(0, &[1])]), hits(&[(0, &[2])])]);
639        assert!(hm.has_sufficient_history_for_gestures(&InputPointId::Mouse));
640
641        hm.remove_input_point(&InputPointId::Mouse);
642        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[3])]));
643
644        assert_eq!(hm.frame_count(&InputPointId::Mouse), 1);
645        assert!(!hm.has_sufficient_history_for_gestures(&InputPointId::Mouse));
646        assert!(hm.previous_hover_node().is_none());
647    }
648
649    #[test]
650    fn clear_drops_every_point() {
651        let mut hm = HoverManager::new();
652        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[1])]));
653        hm.push_hit_test(InputPointId::Touch(9), hits(&[(0, &[2])]));
654
655        hm.clear();
656
657        assert_eq!(hm, HoverManager::new());
658        assert_eq!(hm.debug_counts(), (0, 0));
659        assert!(!hm.any_has_sufficient_history_for_gestures());
660        // Clearing twice is still fine.
661        hm.clear();
662        assert_eq!(hm.debug_counts(), (0, 0));
663    }
664
665    #[test]
666    fn clear_input_point_empties_history_but_keeps_the_point_registered() {
667        let mut hm = mouse_history(vec![hits(&[(0, &[1])]), hits(&[(0, &[2])])]);
668
669        hm.clear_input_point(&InputPointId::Mouse);
670
671        // The point remains a key with an EMPTY deque (unlike remove_input_point).
672        assert_eq!(hm.debug_counts(), (1, 0));
673        assert_eq!(hm.get_active_input_points(), vec![InputPointId::Mouse]);
674        assert_eq!(hm.frame_count(&InputPointId::Mouse), 0);
675        assert!(hm.get_current_mouse().is_none());
676        assert!(hm.get_history(&InputPointId::Mouse).is_some());
677        assert!(!hm.has_sufficient_history_for_gestures(&InputPointId::Mouse));
678        assert!(!hm.any_has_sufficient_history_for_gestures());
679        assert!(hm.current_hover_node().is_none());
680        assert!(hm.previous_hover_node().is_none());
681    }
682
683    #[test]
684    fn clear_input_point_on_an_absent_point_is_a_noop() {
685        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
686        let before = hm.clone();
687
688        hm.clear_input_point(&InputPointId::Touch(u64::MAX));
689
690        assert_eq!(hm, before);
691    }
692
693    // ------------------------------------------------------------- predicates
694
695    #[test]
696    fn has_sufficient_history_needs_at_least_two_frames() {
697        let mouse = InputPointId::Mouse;
698        let mut hm = HoverManager::new();
699        assert!(!hm.has_sufficient_history_for_gestures(&mouse));
700
701        hm.push_hit_test(mouse, hits(&[(0, &[1])]));
702        assert!(!hm.has_sufficient_history_for_gestures(&mouse), "1 frame is not enough");
703
704        hm.push_hit_test(mouse, hits(&[(0, &[2])]));
705        assert!(hm.has_sufficient_history_for_gestures(&mouse), "2 frames is the threshold");
706
707        for i in 0..10 {
708            hm.push_hit_test(mouse, hits(&[(0, &[i])]));
709        }
710        assert!(hm.has_sufficient_history_for_gestures(&mouse), "stays true when saturated");
711    }
712
713    #[test]
714    fn any_has_sufficient_history_is_an_or_across_points() {
715        let mut hm = HoverManager::new();
716        // Three points with one frame each => still false.
717        for id in [
718            InputPointId::Mouse,
719            InputPointId::Touch(0),
720            InputPointId::Touch(u64::MAX),
721        ] {
722            hm.push_hit_test(id, hits(&[(0, &[1])]));
723        }
724        assert!(!hm.any_has_sufficient_history_for_gestures());
725
726        // A single point reaching 2 frames flips it.
727        hm.push_hit_test(InputPointId::Touch(u64::MAX), hits(&[(0, &[2])]));
728        assert!(hm.any_has_sufficient_history_for_gestures());
729
730        // Emptying that point's history flips it back.
731        hm.clear_input_point(&InputPointId::Touch(u64::MAX));
732        assert!(!hm.any_has_sufficient_history_for_gestures());
733    }
734
735    // ---------------------------------------------------- hover node getters
736
737    #[test]
738    fn current_hover_node_returns_the_deepest_node_of_dom_zero() {
739        let hm = mouse_history(vec![hits(&[(0, &[3, 8, 5])])]);
740
741        assert_eq!(hm.current_hover_node(), Some(NodeId::new(8)));
742        // Single-DOM: the _full variant is the same node wrapped in DomId 0.
743        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 8)));
744    }
745
746    #[test]
747    fn current_hover_node_ignores_non_zero_doms_but_full_does_not() {
748        // Only a child DOM was hit — the single-DOM getter is blind to it.
749        let hm = mouse_history(vec![hits(&[(2, &[4])])]);
750
751        assert_eq!(hm.current_hover_node(), None);
752        assert_eq!(hm.current_hover_node_full(), Some(dom_node(2, 4)));
753    }
754
755    #[test]
756    fn current_hover_node_full_prefers_the_front_most_child_dom() {
757        let hm = mouse_history(vec![hits(&[(0, &[9]), (1, &[2])])]);
758
759        // The root getter still reports the root's deepest node...
760        assert_eq!(hm.current_hover_node(), Some(NodeId::new(9)));
761        // ...while the multi-DOM getter targets the composited-on-top child.
762        assert_eq!(hm.current_hover_node_full(), Some(dom_node(1, 2)));
763    }
764
765    #[test]
766    fn previous_hover_node_is_none_until_a_second_frame_exists() {
767        let hm = mouse_history(vec![hits(&[(0, &[1])])]);
768
769        assert_eq!(hm.current_hover_node(), Some(NodeId::new(1)));
770        assert_eq!(hm.previous_hover_node(), None);
771        assert_eq!(hm.previous_hover_node_full(), None);
772    }
773
774    #[test]
775    fn previous_hover_node_reads_frame_one_not_the_oldest_frame() {
776        // 6 pushes => the oldest (node 0) is evicted; frame 1 is node 4.
777        let hm = mouse_history((0..6).map(|i| hits(&[(0, &[i])])).collect());
778
779        assert_eq!(hm.current_hover_node(), Some(NodeId::new(5)));
780        assert_eq!(hm.previous_hover_node(), Some(NodeId::new(4)));
781        assert_eq!(hm.previous_hover_node_full(), Some(dom_node(0, 4)));
782    }
783
784    #[test]
785    fn previous_hover_node_full_sees_child_doms_of_the_previous_frame() {
786        let hm = mouse_history(vec![hits(&[(0, &[1]), (7, &[3])]), hits(&[(0, &[2])])]);
787
788        assert_eq!(hm.previous_hover_node(), Some(NodeId::new(1)));
789        assert_eq!(hm.previous_hover_node_full(), Some(dom_node(7, 3)));
790        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 2)));
791    }
792
793    #[test]
794    fn hover_node_getters_are_none_when_the_frame_hit_nothing() {
795        let hm = mouse_history(vec![FullHitTest::empty(None), FullHitTest::empty(None)]);
796
797        assert!(hm.current_hover_node().is_none());
798        assert!(hm.previous_hover_node().is_none());
799        assert!(hm.current_hover_node_full().is_none());
800        assert!(hm.previous_hover_node_full().is_none());
801    }
802
803    #[test]
804    fn hover_node_getters_ignore_touch_history_entirely() {
805        let mut hm = HoverManager::new();
806        hm.push_hit_test(InputPointId::Touch(1), hits(&[(0, &[5])]));
807        hm.push_hit_test(InputPointId::Touch(1), hits(&[(0, &[6])]));
808
809        assert!(hm.current_hover_node().is_none());
810        assert!(hm.previous_hover_node().is_none());
811        assert!(hm.current_hover_node_full().is_none());
812        assert!(hm.previous_hover_node_full().is_none());
813        assert!(hm.any_has_sufficient_history_for_gestures());
814    }
815
816    // ------------------------------------------------------ purge_dom (other)
817
818    #[test]
819    fn purge_dom_removes_that_dom_from_every_frame_of_every_point() {
820        let mut hm = HoverManager::new();
821        for id in [InputPointId::Mouse, InputPointId::Touch(2)] {
822            hm.push_hit_test(id, hits(&[(0, &[1]), (1, &[2])]));
823            hm.push_hit_test(id, hits(&[(0, &[3]), (1, &[4])]));
824        }
825
826        hm.purge_dom(&dom(1));
827
828        // Frames themselves are kept — only DOM 1's hits are forgotten.
829        assert_eq!(hm.debug_counts(), (2, 4));
830        for id in [InputPointId::Mouse, InputPointId::Touch(2)] {
831            let history = hm.get_history(&id).expect("history exists");
832            for frame in history {
833                assert!(!frame.hovered_nodes.contains_key(&dom(1)));
834                assert!(frame.hovered_nodes.contains_key(&dom(0)));
835            }
836        }
837        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 3)));
838        assert_eq!(hm.previous_hover_node_full(), Some(dom_node(0, 1)));
839    }
840
841    #[test]
842    fn purge_dom_zero_leaves_child_dom_hits_intact() {
843        let mut hm = mouse_history(vec![hits(&[(0, &[1]), (4, &[2])])]);
844
845        hm.purge_dom(&dom(0));
846
847        // The single-DOM getter now finds nothing, the multi-DOM one falls back.
848        assert_eq!(hm.current_hover_node(), None);
849        assert_eq!(hm.current_hover_node_full(), Some(dom_node(4, 2)));
850        assert_eq!(hm.frame_count(&InputPointId::Mouse), 1);
851    }
852
853    #[test]
854    fn purge_absent_or_extreme_dom_id_is_a_noop() {
855        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
856        let before = hm.clone();
857
858        hm.purge_dom(&dom(9));
859        hm.purge_dom(&dom(usize::MAX));
860        assert_eq!(hm, before);
861
862        // Purging on an empty manager must not panic either.
863        let mut empty = HoverManager::new();
864        empty.purge_dom(&dom(0));
865        assert_eq!(empty, HoverManager::new());
866    }
867
868    #[test]
869    fn purge_dom_twice_is_idempotent() {
870        let mut hm = mouse_history(vec![hits(&[(0, &[1]), (1, &[2])])]);
871
872        hm.purge_dom(&dom(1));
873        let once = hm.clone();
874        hm.purge_dom(&dom(1));
875
876        assert_eq!(hm, once);
877    }
878
879    // -------------------------------------------- remap_scrollbar_hit_id (other)
880
881    fn sb_map(pairs: &[(usize, usize)]) -> BTreeMap<NodeId, NodeId> {
882        pairs
883            .iter()
884            .map(|(o, n)| (NodeId::new(*o), NodeId::new(*n)))
885            .collect()
886    }
887
888    #[test]
889    fn remap_scrollbar_hit_id_rewrites_every_variant_of_the_target_dom() {
890        let map = sb_map(&[(1, 42)]);
891        let d = dom(0);
892        let old = NodeId::new(1);
893        let new = NodeId::new(42);
894
895        assert_eq!(
896            remap_scrollbar_hit_id(&ScrollbarHitId::VerticalTrack(d, old), d, &map),
897            Some(ScrollbarHitId::VerticalTrack(d, new))
898        );
899        assert_eq!(
900            remap_scrollbar_hit_id(&ScrollbarHitId::VerticalThumb(d, old), d, &map),
901            Some(ScrollbarHitId::VerticalThumb(d, new))
902        );
903        assert_eq!(
904            remap_scrollbar_hit_id(&ScrollbarHitId::HorizontalTrack(d, old), d, &map),
905            Some(ScrollbarHitId::HorizontalTrack(d, new))
906        );
907        assert_eq!(
908            remap_scrollbar_hit_id(&ScrollbarHitId::HorizontalThumb(d, old), d, &map),
909            Some(ScrollbarHitId::HorizontalThumb(d, new))
910        );
911    }
912
913    #[test]
914    fn remap_scrollbar_hit_id_drops_unmounted_nodes() {
915        let map = sb_map(&[(1, 42)]);
916        let d = dom(0);
917        // Node 2 is absent from the map => unmounted => the hit must be dropped.
918        let unmounted = ScrollbarHitId::VerticalThumb(d, NodeId::new(2));
919
920        assert_eq!(remap_scrollbar_hit_id(&unmounted, d, &map), None);
921        // Empty map: everything on the target DOM is unmounted.
922        let hit = ScrollbarHitId::VerticalThumb(d, NodeId::new(1));
923        assert_eq!(remap_scrollbar_hit_id(&hit, d, &BTreeMap::new()), None);
924    }
925
926    #[test]
927    fn remap_scrollbar_hit_id_passes_other_doms_through_untouched() {
928        // The map applies to DOM 0 only; an id naming DOM 1 must NOT be rewritten
929        // even though its NodeId happens to be a key in the map.
930        let map = sb_map(&[(1, 42)]);
931        let other = ScrollbarHitId::HorizontalTrack(dom(1), NodeId::new(1));
932
933        assert_eq!(remap_scrollbar_hit_id(&other, dom(0), &map), Some(other));
934        // ...and it survives an empty map too (no accidental drop).
935        assert_eq!(
936            remap_scrollbar_hit_id(&other, dom(0), &BTreeMap::new()),
937            Some(other)
938        );
939    }
940
941    #[test]
942    fn remap_scrollbar_hit_id_handles_extreme_ids() {
943        let big = usize::MAX - 1;
944        let map = sb_map(&[(big, 0)]);
945        let d = dom(usize::MAX);
946
947        assert_eq!(
948            remap_scrollbar_hit_id(&ScrollbarHitId::VerticalTrack(d, NodeId::new(big)), d, &map),
949            Some(ScrollbarHitId::VerticalTrack(d, NodeId::ZERO))
950        );
951    }
952
953    // ------------------------------------------------ NodeIdRemap::remap_node_ids
954
955    /// A hit test with one regular + scroll + cursor + scrollbar hit on `node`.
956    fn all_maps_hit(dom_inner: usize, node: usize) -> FullHitTest {
957        let mut full = FullHitTest::empty(None);
958        let mut ht = HitTest::empty();
959        ht.regular_hit_test_nodes
960            .insert(NodeId::new(node), hit_item(0));
961        ht.scroll_hit_test_nodes
962            .insert(NodeId::new(node), scroll_item());
963        ht.cursor_hit_test_nodes
964            .insert(NodeId::new(node), cursor_item());
965        ht.scrollbar_hit_test_nodes.insert(
966            ScrollbarHitId::VerticalThumb(dom(dom_inner), NodeId::new(node)),
967            scrollbar_item(),
968        );
969        full.hovered_nodes.insert(dom(dom_inner), ht);
970        full
971    }
972
973    #[test]
974    fn remap_node_ids_rewrites_all_four_hit_maps() {
975        let mut hm = mouse_history(vec![all_maps_hit(0, 3)]);
976
977        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(11))]));
978
979        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
980        assert_eq!(
981            ht.regular_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
982            vec![NodeId::new(11)]
983        );
984        assert_eq!(
985            ht.scroll_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
986            vec![NodeId::new(11)]
987        );
988        assert_eq!(
989            ht.cursor_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
990            vec![NodeId::new(11)]
991        );
992        assert_eq!(
993            ht.scrollbar_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
994            vec![ScrollbarHitId::VerticalThumb(dom(0), NodeId::new(11))]
995        );
996        assert_eq!(hm.current_hover_node(), Some(NodeId::new(11)));
997    }
998
999    #[test]
1000    fn remap_node_ids_with_an_empty_map_drops_every_hit_of_that_dom() {
1001        let mut hm = mouse_history(vec![all_maps_hit(0, 3)]);
1002
1003        // Empty map = nothing matched = every node was unmounted.
1004        hm.remap_node_ids(dom(0), &NodeIdMap::default());
1005
1006        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
1007        assert!(ht.regular_hit_test_nodes.is_empty());
1008        assert!(ht.scroll_hit_test_nodes.is_empty());
1009        assert!(ht.cursor_hit_test_nodes.is_empty());
1010        assert!(ht.scrollbar_hit_test_nodes.is_empty());
1011        assert_eq!(hm.current_hover_node(), None);
1012        // The (now empty) DOM entry itself is kept — only purge_dom removes it.
1013        assert!(hm
1014            .get_current_mouse()
1015            .expect("frame exists")
1016            .hovered_nodes
1017            .contains_key(&dom(0)));
1018    }
1019
1020    #[test]
1021    fn remap_node_ids_drops_unmounted_but_keeps_survivors() {
1022        // Nodes 1 and 4 hit; only 4 survives the rebuild (as node 0).
1023        let mut hm = mouse_history(vec![hits(&[(0, &[1, 4])])]);
1024
1025        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(4), NodeId::ZERO)]));
1026
1027        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
1028        assert_eq!(
1029            ht.regular_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
1030            vec![NodeId::ZERO]
1031        );
1032        assert_eq!(hm.current_hover_node(), Some(NodeId::ZERO));
1033    }
1034
1035    #[test]
1036    fn remap_node_ids_swap_does_not_lose_or_alias_entries() {
1037        // 1 -> 2 and 2 -> 1 in the same pass: the naive in-place rewrite would
1038        // clobber one of them. Both must survive with their items swapped.
1039        let mut full = FullHitTest::empty(None);
1040        let mut ht = HitTest::empty();
1041        ht.regular_hit_test_nodes
1042            .insert(NodeId::new(1), hit_item(10));
1043        ht.regular_hit_test_nodes
1044            .insert(NodeId::new(2), hit_item(20));
1045        full.hovered_nodes.insert(dom(0), ht);
1046        let mut hm = mouse_history(vec![full]);
1047
1048        hm.remap_node_ids(
1049            dom(0),
1050            &NodeIdMap::from_pairs([
1051                (NodeId::new(1), NodeId::new(2)),
1052                (NodeId::new(2), NodeId::new(1)),
1053            ]),
1054        );
1055
1056        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
1057        assert_eq!(ht.regular_hit_test_nodes.len(), 2);
1058        assert_eq!(ht.regular_hit_test_nodes[&NodeId::new(2)].hit_depth, 10);
1059        assert_eq!(ht.regular_hit_test_nodes[&NodeId::new(1)].hit_depth, 20);
1060    }
1061
1062    #[test]
1063    fn remap_node_ids_can_change_which_node_is_deepest() {
1064        // Old order: 7 is deepest. The rebuild renumbers 3 -> 9 and 7 -> 2,
1065        // so the deepest hit must be recomputed (9), not carried over.
1066        let mut hm = mouse_history(vec![hits(&[(0, &[3, 7])])]);
1067        assert_eq!(hm.current_hover_node(), Some(NodeId::new(7)));
1068
1069        hm.remap_node_ids(
1070            dom(0),
1071            &NodeIdMap::from_pairs([
1072                (NodeId::new(3), NodeId::new(9)),
1073                (NodeId::new(7), NodeId::new(2)),
1074            ]),
1075        );
1076
1077        assert_eq!(hm.current_hover_node(), Some(NodeId::new(9)));
1078        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 9)));
1079    }
1080
1081    #[test]
1082    fn remap_node_ids_leaves_other_doms_alone() {
1083        let mut hm = mouse_history(vec![hits(&[(0, &[1]), (1, &[1])])]);
1084
1085        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(5))]));
1086
1087        let frame = hm.get_current_mouse().expect("frame exists");
1088        assert_eq!(
1089            frame.hovered_nodes[&dom(0)]
1090                .regular_hit_test_nodes
1091                .keys()
1092                .copied()
1093                .collect::<Vec<_>>(),
1094            vec![NodeId::new(5)],
1095            "DOM 0 is remapped"
1096        );
1097        assert_eq!(
1098            frame.hovered_nodes[&dom(1)]
1099                .regular_hit_test_nodes
1100                .keys()
1101                .copied()
1102                .collect::<Vec<_>>(),
1103            vec![NodeId::new(1)],
1104            "DOM 1 must be untouched by DOM 0's reconciliation"
1105        );
1106    }
1107
1108    #[test]
1109    fn remap_node_ids_keeps_foreign_dom_scrollbar_ids_stored_under_the_target_dom() {
1110        // A scrollbar hit recorded under DOM 0's HitTest but whose ScrollbarHitId
1111        // names DOM 1: remap_scrollbar_hit_id must pass it through, not drop it.
1112        let mut full = FullHitTest::empty(None);
1113        let mut ht = HitTest::empty();
1114        ht.scrollbar_hit_test_nodes.insert(
1115            ScrollbarHitId::VerticalTrack(dom(1), NodeId::new(1)),
1116            scrollbar_item(),
1117        );
1118        ht.scrollbar_hit_test_nodes.insert(
1119            ScrollbarHitId::VerticalTrack(dom(0), NodeId::new(1)),
1120            scrollbar_item(),
1121        );
1122        full.hovered_nodes.insert(dom(0), ht);
1123        let mut hm = mouse_history(vec![full]);
1124
1125        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(8))]));
1126
1127        let keys: Vec<_> = hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)]
1128            .scrollbar_hit_test_nodes
1129            .keys()
1130            .copied()
1131            .collect();
1132        assert!(
1133            keys.contains(&ScrollbarHitId::VerticalTrack(dom(1), NodeId::new(1))),
1134            "foreign-DOM scrollbar id must survive unchanged, got {keys:?}"
1135        );
1136        assert!(
1137            keys.contains(&ScrollbarHitId::VerticalTrack(dom(0), NodeId::new(8))),
1138            "target-DOM scrollbar id must be rewritten, got {keys:?}"
1139        );
1140        assert_eq!(keys.len(), 2);
1141    }
1142
1143    #[test]
1144    fn remap_node_ids_applies_to_every_frame_and_every_input_point() {
1145        let mut hm = HoverManager::new();
1146        for id in [InputPointId::Mouse, InputPointId::Touch(1)] {
1147            hm.push_hit_test(id, hits(&[(0, &[2])]));
1148            hm.push_hit_test(id, hits(&[(0, &[2])]));
1149        }
1150
1151        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(6))]));
1152
1153        for id in [InputPointId::Mouse, InputPointId::Touch(1)] {
1154            for frame in hm.get_history(&id).expect("history exists") {
1155                assert_eq!(
1156                    frame.hovered_nodes[&dom(0)]
1157                        .regular_hit_test_nodes
1158                        .keys()
1159                        .copied()
1160                        .collect::<Vec<_>>(),
1161                    vec![NodeId::new(6)]
1162                );
1163            }
1164        }
1165        assert_eq!(hm.previous_hover_node(), Some(NodeId::new(6)));
1166    }
1167
1168    #[test]
1169    fn remap_node_ids_on_an_empty_manager_or_unknown_dom_does_not_panic() {
1170        let mut empty = HoverManager::new();
1171        empty.remap_node_ids(dom(usize::MAX), &NodeIdMap::default());
1172        assert_eq!(empty, HoverManager::new());
1173
1174        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
1175        let before = hm.clone();
1176        // Reconciliation for a DOM that was never hit changes nothing.
1177        hm.remap_node_ids(dom(3), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(2))]));
1178        assert_eq!(hm, before);
1179    }
1180
1181    #[test]
1182    fn remap_node_ids_identity_map_is_idempotent() {
1183        let mut hm = mouse_history(vec![all_maps_hit(0, 3)]);
1184        let before = hm.clone();
1185        let identity = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(3))]);
1186
1187        hm.remap_node_ids(dom(0), &identity);
1188        assert_eq!(hm, before, "identity remap must not change anything");
1189
1190        hm.remap_node_ids(dom(0), &identity);
1191        assert_eq!(hm, before, "and applying it twice must not either");
1192    }
1193
1194    // ------------------------------------------------------------- misc invariants
1195
1196    #[test]
1197    fn clone_is_equal_and_independent_of_the_original() {
1198        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
1199        let snapshot = hm.clone();
1200        assert_eq!(hm, snapshot);
1201
1202        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[2])]));
1203
1204        assert_ne!(hm, snapshot, "the clone must not observe later pushes");
1205        assert_eq!(snapshot.frame_count(&InputPointId::Mouse), 1);
1206        assert_eq!(hm.frame_count(&InputPointId::Mouse), 2);
1207    }
1208
1209    #[test]
1210    fn debug_counts_agrees_with_frame_count_and_active_points() {
1211        let mut hm = HoverManager::new();
1212        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[1])]));
1213        hm.push_hit_test(InputPointId::Touch(7), hits(&[(0, &[1])]));
1214        hm.push_hit_test(InputPointId::Touch(7), hits(&[(0, &[2])]));
1215
1216        let (points, total) = hm.debug_counts();
1217        let active = hm.get_active_input_points();
1218        assert_eq!(points, active.len());
1219        assert_eq!(
1220            total,
1221            active.iter().map(|id| hm.frame_count(id)).sum::<usize>()
1222        );
1223        assert_eq!((points, total), (2, 3));
1224    }
1225}