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    /// [`current_hover_node_full`] for ANY input point, not just the mouse.
237    ///
238    /// Touch event determination needs this: a finger is a pointer of its own,
239    /// so a `TouchStart` must target the node under THAT finger. Every getter
240    /// here was mouse-only, which is part of why nothing ever derived a touch
241    /// event from `FullWindowState::touch_state`.
242    #[must_use] pub fn hover_node_full_for(
243        &self,
244        input_id: &InputPointId,
245    ) -> Option<azul_core::dom::DomNodeId> {
246        deepest_node_across_doms(self.get_current(input_id)?)
247    }
248}
249
250impl crate::managers::NodeIdRemap for HoverManager {
251    /// Remap `NodeIds` in all hover histories after DOM reconciliation.
252    ///
253    /// Hits on unmounted nodes are dropped (they cannot be hovered any more) —
254    /// keeping them would make the hover history describe a node that no longer
255    /// exists at that index.
256    fn remap_node_ids(&mut self, dom_id: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
257        let node_id_map = map.as_btree_map();
258        for history in self.hover_histories.values_mut() {
259            for hit_test in history.iter_mut() {
260                if let Some(ht) = hit_test.hovered_nodes.get_mut(&dom_id) {
261                    crate::managers::remap_keys(&mut ht.regular_hit_test_nodes, map);
262                    crate::managers::remap_keys(&mut ht.scroll_hit_test_nodes, map);
263                    crate::managers::remap_keys(&mut ht.cursor_hit_test_nodes, map);
264
265                    // Remap scrollbar_hit_test_nodes (ScrollbarHitId contains NodeId)
266                    let old_sb: Vec<_> = ht.scrollbar_hit_test_nodes.keys().copied().collect();
267                    let mut new_sb = BTreeMap::new();
268                    for old_key in old_sb {
269                        let Some(new_key) = remap_scrollbar_hit_id(&old_key, dom_id, node_id_map)
270                        else {
271                            // node unmounted — drop the scrollbar hit
272                            ht.scrollbar_hit_test_nodes.remove(&old_key);
273                            continue;
274                        };
275                        if let Some(item) = ht.scrollbar_hit_test_nodes.remove(&old_key) {
276                            new_sb.insert(new_key, item);
277                        }
278                    }
279                    ht.scrollbar_hit_test_nodes = new_sb;
280                }
281            }
282        }
283    }
284}
285
286impl Default for HoverManager {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292/// Remap a `ScrollbarHitId`'s `NodeId` using the reconciliation map.
293/// `None` = the node was unmounted, so the hit must be dropped.
294/// A `ScrollbarHitId` for a different `DomId` is returned unchanged.
295fn remap_scrollbar_hit_id(
296    id: &azul_core::hit_test::ScrollbarHitId,
297    dom_id: azul_core::dom::DomId,
298    node_id_map: &BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
299) -> Option<azul_core::hit_test::ScrollbarHitId> {
300    use azul_core::hit_test::ScrollbarHitId;
301    Some(match id {
302        ScrollbarHitId::VerticalTrack(d, n) if *d == dom_id => {
303            ScrollbarHitId::VerticalTrack(*d, *node_id_map.get(n)?)
304        }
305        ScrollbarHitId::VerticalThumb(d, n) if *d == dom_id => {
306            ScrollbarHitId::VerticalThumb(*d, *node_id_map.get(n)?)
307        }
308        ScrollbarHitId::HorizontalTrack(d, n) if *d == dom_id => {
309            ScrollbarHitId::HorizontalTrack(*d, *node_id_map.get(n)?)
310        }
311        ScrollbarHitId::HorizontalThumb(d, n) if *d == dom_id => {
312            ScrollbarHitId::HorizontalThumb(*d, *node_id_map.get(n)?)
313        }
314        other => *other,
315    })
316}
317
318#[cfg(test)]
319mod autotest_generated {
320    use azul_core::{
321        dom::{DomId, DomNodeId, ScrollbarOrientation},
322        geom::LogicalPosition,
323        hit_test::{
324            CursorHitTestItem, CursorType, HitTest, HitTestItem, OverflowingScrollNode,
325            ScrollHitTestItem, ScrollbarHitId, ScrollbarHitTestItem,
326        },
327        id::NodeId,
328        styled_dom::NodeHierarchyItemId,
329    };
330
331    use super::*;
332    use crate::managers::{NodeIdMap, NodeIdRemap};
333
334    // ---------------------------------------------------------------- fixtures
335
336    fn hit_item(depth: u32) -> HitTestItem {
337        HitTestItem {
338            point_in_viewport: LogicalPosition::zero(),
339            point_relative_to_item: LogicalPosition::zero(),
340            is_focusable: false,
341            is_virtual_view_hit: None,
342            hit_depth: depth,
343        }
344    }
345
346    fn scroll_item() -> ScrollHitTestItem {
347        ScrollHitTestItem {
348            point_in_viewport: LogicalPosition::zero(),
349            point_relative_to_item: LogicalPosition::zero(),
350            scroll_node: OverflowingScrollNode::default(),
351        }
352    }
353
354    fn cursor_item() -> CursorHitTestItem {
355        CursorHitTestItem {
356            cursor_type: CursorType::Text,
357            hit_depth: 0,
358            point_in_viewport: LogicalPosition::zero(),
359        }
360    }
361
362    fn scrollbar_item() -> ScrollbarHitTestItem {
363        ScrollbarHitTestItem {
364            point_in_viewport: LogicalPosition::zero(),
365            point_relative_to_item: LogicalPosition::zero(),
366            orientation: ScrollbarOrientation::Vertical,
367        }
368    }
369
370    fn dom(inner: usize) -> DomId {
371        DomId { inner }
372    }
373
374    /// A `FullHitTest` where every `(dom, &[node..])` entry is a set of regular hits.
375    /// Node ids are inserted in the given (deliberately unsorted) order.
376    fn hits(entries: &[(usize, &[usize])]) -> FullHitTest {
377        let mut full = FullHitTest::empty(None);
378        for (dom_inner, nodes) in entries {
379            let ht = full
380                .hovered_nodes
381                .entry(dom(*dom_inner))
382                .or_insert_with(HitTest::empty);
383            for n in *nodes {
384                ht.regular_hit_test_nodes
385                    .insert(NodeId::new(*n), hit_item(0));
386            }
387        }
388        full
389    }
390
391    /// `DomNodeId` for `(dom, node)`, matching what the hover getters return.
392    fn dom_node(dom_inner: usize, node: usize) -> DomNodeId {
393        DomNodeId {
394            dom: dom(dom_inner),
395            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
396        }
397    }
398
399    /// A manager whose mouse history is `frames` (pushed oldest-first, so the
400    /// LAST element ends up at index 0 = current).
401    fn mouse_history(frames: Vec<FullHitTest>) -> HoverManager {
402        let mut hm = HoverManager::new();
403        for f in frames {
404            hm.push_hit_test(InputPointId::Mouse, f);
405        }
406        hm
407    }
408
409    // ------------------------------------------- deepest_node_across_doms (other)
410
411    #[test]
412    fn deepest_node_across_doms_empty_returns_none() {
413        assert_eq!(deepest_node_across_doms(&FullHitTest::empty(None)), None);
414    }
415
416    #[test]
417    fn deepest_node_across_doms_uses_nodeid_order_not_insertion_order() {
418        // Inserted 2, 9, 7 — BTreeMap key order makes 9 the deepest regardless.
419        let ht = hits(&[(0, &[2, 9, 7])]);
420        assert_eq!(deepest_node_across_doms(&ht), Some(dom_node(0, 9)));
421    }
422
423    #[test]
424    fn deepest_node_across_doms_prefers_highest_dom_even_if_its_node_is_shallower() {
425        // dom 0 has the deeper NodeId (99) but dom 3 is composited on top.
426        let ht = hits(&[(0, &[99]), (3, &[1])]);
427        assert_eq!(deepest_node_across_doms(&ht), Some(dom_node(3, 1)));
428    }
429
430    #[test]
431    fn deepest_node_across_doms_skips_dom_with_no_regular_hits() {
432        // dom 5 is "hit" but only in the scroll/cursor/scrollbar maps — the
433        // front-most DOM with a REGULAR hit (dom 0) must win instead.
434        let mut ht = hits(&[(0, &[4])]);
435        let mut empty_regular = HitTest::empty();
436        empty_regular
437            .scroll_hit_test_nodes
438            .insert(NodeId::new(1), scroll_item());
439        empty_regular
440            .cursor_hit_test_nodes
441            .insert(NodeId::new(1), cursor_item());
442        empty_regular.scrollbar_hit_test_nodes.insert(
443            ScrollbarHitId::VerticalThumb(dom(5), NodeId::new(1)),
444            scrollbar_item(),
445        );
446        ht.hovered_nodes.insert(dom(5), empty_regular);
447
448        assert_eq!(deepest_node_across_doms(&ht), Some(dom_node(0, 4)));
449    }
450
451    #[test]
452    fn deepest_node_across_doms_all_doms_empty_returns_none() {
453        let mut ht = FullHitTest::empty(None);
454        ht.hovered_nodes.insert(dom(0), HitTest::empty());
455        ht.hovered_nodes.insert(dom(usize::MAX), HitTest::empty());
456        assert_eq!(deepest_node_across_doms(&ht), None);
457    }
458
459    #[test]
460    fn deepest_node_across_doms_extreme_ids_survive_the_nodeid_encoding() {
461        // usize::MAX - 1 is the largest NodeId that survives the 1-based
462        // `NodeHierarchyItemId` encode (n + 1) without wrapping.
463        let max_node = usize::MAX - 1;
464        let ht = hits(&[(usize::MAX, &[0, max_node])]);
465        let got = deepest_node_across_doms(&ht).expect("a hit exists");
466        assert_eq!(got.dom, dom(usize::MAX));
467        // The DomNodeId must decode back to exactly the NodeId that was hit.
468        assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(max_node)));
469    }
470
471    // ------------------------------------------------- new / Default (constructor)
472
473    #[test]
474    fn new_manager_is_empty_and_every_getter_is_none_or_zero() {
475        let hm = HoverManager::new();
476        let mouse = InputPointId::Mouse;
477
478        assert_eq!(hm.debug_counts(), (0, 0));
479        assert!(hm.get_active_input_points().is_empty());
480        assert!(hm.get_current(&mouse).is_none());
481        assert!(hm.get_current_mouse().is_none());
482        assert!(hm.get_history(&mouse).is_none());
483        assert_eq!(hm.frame_count(&mouse), 0);
484        assert!(!hm.has_sufficient_history_for_gestures(&mouse));
485        assert!(!hm.any_has_sufficient_history_for_gestures());
486        assert!(hm.current_hover_node().is_none());
487        assert!(hm.previous_hover_node().is_none());
488        assert!(hm.current_hover_node_full().is_none());
489        assert!(hm.previous_hover_node_full().is_none());
490        // Frame lookups on an unknown point must not panic at any index.
491        assert!(hm.get_frame(&mouse, 0).is_none());
492        assert!(hm.get_frame(&mouse, usize::MAX).is_none());
493    }
494
495    #[test]
496    fn default_matches_new() {
497        assert_eq!(HoverManager::default(), HoverManager::new());
498    }
499
500    // ------------------------------------------------- push_hit_test (numeric)
501
502    #[test]
503    fn push_hit_test_index_zero_is_the_newest_frame() {
504        let hm = mouse_history(vec![hits(&[(0, &[1])]), hits(&[(0, &[2])])]);
505
506        assert_eq!(hm.frame_count(&InputPointId::Mouse), 2);
507        assert_eq!(hm.get_frame(&InputPointId::Mouse, 0), Some(&hits(&[(0, &[2])])));
508        assert_eq!(hm.get_frame(&InputPointId::Mouse, 1), Some(&hits(&[(0, &[1])])));
509        assert_eq!(hm.get_current_mouse(), Some(&hits(&[(0, &[2])])));
510    }
511
512    #[test]
513    fn push_hit_test_ring_buffer_never_exceeds_max_hover_history() {
514        let mut hm = HoverManager::new();
515        for i in 0..1000_usize {
516            hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[i])]));
517        }
518
519        assert_eq!(hm.frame_count(&InputPointId::Mouse), MAX_HOVER_HISTORY);
520        assert_eq!(hm.debug_counts(), (1, MAX_HOVER_HISTORY));
521        // The retained window is the LAST MAX_HOVER_HISTORY pushes, newest first.
522        for ago in 0..MAX_HOVER_HISTORY {
523            assert_eq!(
524                hm.get_frame(&InputPointId::Mouse, ago),
525                Some(&hits(&[(0, &[999 - ago])])),
526                "frame {ago} frames ago"
527            );
528        }
529        // Anything older was dropped.
530        assert!(hm.get_frame(&InputPointId::Mouse, MAX_HOVER_HISTORY).is_none());
531    }
532
533    #[test]
534    fn get_frame_out_of_range_index_returns_none_without_overflow() {
535        let hm = mouse_history(vec![hits(&[(0, &[1])])]);
536        let mouse = InputPointId::Mouse;
537
538        assert!(hm.get_frame(&mouse, 0).is_some());
539        assert!(hm.get_frame(&mouse, 1).is_none());
540        assert!(hm.get_frame(&mouse, usize::MAX).is_none());
541        assert!(hm.get_frame(&mouse, usize::MAX / 2).is_none());
542        // Unknown input point at a huge index is still just None.
543        assert!(hm.get_frame(&InputPointId::Touch(u64::MAX), usize::MAX).is_none());
544    }
545
546    #[test]
547    fn touch_ids_at_u64_boundaries_are_distinct_histories() {
548        let mut hm = HoverManager::new();
549        hm.push_hit_test(InputPointId::Touch(u64::MIN), hits(&[(0, &[1])]));
550        hm.push_hit_test(InputPointId::Touch(u64::MAX), hits(&[(0, &[2])]));
551        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[3])]));
552
553        assert_eq!(hm.debug_counts(), (3, 3));
554        assert_eq!(
555            hm.get_current(&InputPointId::Touch(u64::MIN)),
556            Some(&hits(&[(0, &[1])]))
557        );
558        assert_eq!(
559            hm.get_current(&InputPointId::Touch(u64::MAX)),
560            Some(&hits(&[(0, &[2])]))
561        );
562        assert_eq!(hm.get_current_mouse(), Some(&hits(&[(0, &[3])])));
563        // Ord derive: Mouse sorts before every Touch, touches sort by id.
564        assert_eq!(
565            hm.get_active_input_points(),
566            vec![
567                InputPointId::Mouse,
568                InputPointId::Touch(0),
569                InputPointId::Touch(u64::MAX),
570            ]
571        );
572    }
573
574    #[test]
575    fn debug_counts_stays_bounded_under_a_flood_of_points_and_frames() {
576        let mut hm = HoverManager::new();
577        for point in 0..100_u64 {
578            for frame in 0..50_usize {
579                hm.push_hit_test(InputPointId::Touch(point), hits(&[(0, &[frame])]));
580            }
581        }
582        // 100 points, each capped at MAX_HOVER_HISTORY frames — no unbounded growth.
583        assert_eq!(hm.debug_counts(), (100, 100 * MAX_HOVER_HISTORY));
584    }
585
586    #[test]
587    fn push_hit_test_stores_the_value_verbatim_including_focused_node() {
588        let focused = dom_node(0, 7);
589        let mut ht = FullHitTest::empty(Some(focused));
590        ht.hovered_nodes.insert(dom(0), HitTest::empty());
591
592        let mut hm = HoverManager::new();
593        hm.push_hit_test(InputPointId::Mouse, ht.clone());
594
595        assert_eq!(hm.get_current_mouse(), Some(&ht));
596        assert_eq!(
597            hm.get_current_mouse().map(|h| h.focused_node),
598            Some(Some(focused).into())
599        );
600        // A hovered DOM with zero hits is still "no hovered node".
601        assert!(hm.current_hover_node().is_none());
602        assert!(hm.current_hover_node_full().is_none());
603    }
604
605    #[test]
606    fn get_history_returns_all_frames_newest_first() {
607        let hm = mouse_history(vec![
608            hits(&[(0, &[1])]),
609            hits(&[(0, &[2])]),
610            hits(&[(0, &[3])]),
611        ]);
612        let history = hm.get_history(&InputPointId::Mouse).expect("history exists");
613
614        assert_eq!(history.len(), 3);
615        assert_eq!(history[0], hits(&[(0, &[3])]));
616        assert_eq!(history[2], hits(&[(0, &[1])]));
617        assert!(hm.get_history(&InputPointId::Touch(0)).is_none());
618    }
619
620    // --------------------------------------- remove / clear / clear_input_point
621
622    #[test]
623    fn remove_absent_input_point_is_a_noop() {
624        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
625        let before = hm.clone();
626
627        hm.remove_input_point(&InputPointId::Touch(0));
628        hm.remove_input_point(&InputPointId::Touch(u64::MAX));
629
630        assert_eq!(hm, before);
631    }
632
633    #[test]
634    fn remove_input_point_only_drops_the_target_point() {
635        let mut hm = HoverManager::new();
636        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[1])]));
637        hm.push_hit_test(InputPointId::Touch(3), hits(&[(0, &[2])]));
638
639        hm.remove_input_point(&InputPointId::Touch(3));
640
641        assert_eq!(hm.debug_counts(), (1, 1));
642        assert_eq!(hm.get_active_input_points(), vec![InputPointId::Mouse]);
643        assert!(hm.get_current(&InputPointId::Touch(3)).is_none());
644        assert_eq!(hm.frame_count(&InputPointId::Touch(3)), 0);
645        assert!(hm.get_current_mouse().is_some());
646    }
647
648    #[test]
649    fn remove_then_push_restarts_the_history_from_scratch() {
650        let mut hm = mouse_history(vec![hits(&[(0, &[1])]), hits(&[(0, &[2])])]);
651        assert!(hm.has_sufficient_history_for_gestures(&InputPointId::Mouse));
652
653        hm.remove_input_point(&InputPointId::Mouse);
654        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[3])]));
655
656        assert_eq!(hm.frame_count(&InputPointId::Mouse), 1);
657        assert!(!hm.has_sufficient_history_for_gestures(&InputPointId::Mouse));
658        assert!(hm.previous_hover_node().is_none());
659    }
660
661    #[test]
662    fn clear_drops_every_point() {
663        let mut hm = HoverManager::new();
664        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[1])]));
665        hm.push_hit_test(InputPointId::Touch(9), hits(&[(0, &[2])]));
666
667        hm.clear();
668
669        assert_eq!(hm, HoverManager::new());
670        assert_eq!(hm.debug_counts(), (0, 0));
671        assert!(!hm.any_has_sufficient_history_for_gestures());
672        // Clearing twice is still fine.
673        hm.clear();
674        assert_eq!(hm.debug_counts(), (0, 0));
675    }
676
677    #[test]
678    fn clear_input_point_empties_history_but_keeps_the_point_registered() {
679        let mut hm = mouse_history(vec![hits(&[(0, &[1])]), hits(&[(0, &[2])])]);
680
681        hm.clear_input_point(&InputPointId::Mouse);
682
683        // The point remains a key with an EMPTY deque (unlike remove_input_point).
684        assert_eq!(hm.debug_counts(), (1, 0));
685        assert_eq!(hm.get_active_input_points(), vec![InputPointId::Mouse]);
686        assert_eq!(hm.frame_count(&InputPointId::Mouse), 0);
687        assert!(hm.get_current_mouse().is_none());
688        assert!(hm.get_history(&InputPointId::Mouse).is_some());
689        assert!(!hm.has_sufficient_history_for_gestures(&InputPointId::Mouse));
690        assert!(!hm.any_has_sufficient_history_for_gestures());
691        assert!(hm.current_hover_node().is_none());
692        assert!(hm.previous_hover_node().is_none());
693    }
694
695    #[test]
696    fn clear_input_point_on_an_absent_point_is_a_noop() {
697        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
698        let before = hm.clone();
699
700        hm.clear_input_point(&InputPointId::Touch(u64::MAX));
701
702        assert_eq!(hm, before);
703    }
704
705    // ------------------------------------------------------------- predicates
706
707    #[test]
708    fn has_sufficient_history_needs_at_least_two_frames() {
709        let mouse = InputPointId::Mouse;
710        let mut hm = HoverManager::new();
711        assert!(!hm.has_sufficient_history_for_gestures(&mouse));
712
713        hm.push_hit_test(mouse, hits(&[(0, &[1])]));
714        assert!(!hm.has_sufficient_history_for_gestures(&mouse), "1 frame is not enough");
715
716        hm.push_hit_test(mouse, hits(&[(0, &[2])]));
717        assert!(hm.has_sufficient_history_for_gestures(&mouse), "2 frames is the threshold");
718
719        for i in 0..10 {
720            hm.push_hit_test(mouse, hits(&[(0, &[i])]));
721        }
722        assert!(hm.has_sufficient_history_for_gestures(&mouse), "stays true when saturated");
723    }
724
725    #[test]
726    fn any_has_sufficient_history_is_an_or_across_points() {
727        let mut hm = HoverManager::new();
728        // Three points with one frame each => still false.
729        for id in [
730            InputPointId::Mouse,
731            InputPointId::Touch(0),
732            InputPointId::Touch(u64::MAX),
733        ] {
734            hm.push_hit_test(id, hits(&[(0, &[1])]));
735        }
736        assert!(!hm.any_has_sufficient_history_for_gestures());
737
738        // A single point reaching 2 frames flips it.
739        hm.push_hit_test(InputPointId::Touch(u64::MAX), hits(&[(0, &[2])]));
740        assert!(hm.any_has_sufficient_history_for_gestures());
741
742        // Emptying that point's history flips it back.
743        hm.clear_input_point(&InputPointId::Touch(u64::MAX));
744        assert!(!hm.any_has_sufficient_history_for_gestures());
745    }
746
747    // ---------------------------------------------------- hover node getters
748
749    #[test]
750    fn current_hover_node_returns_the_deepest_node_of_dom_zero() {
751        let hm = mouse_history(vec![hits(&[(0, &[3, 8, 5])])]);
752
753        assert_eq!(hm.current_hover_node(), Some(NodeId::new(8)));
754        // Single-DOM: the _full variant is the same node wrapped in DomId 0.
755        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 8)));
756    }
757
758    #[test]
759    fn current_hover_node_ignores_non_zero_doms_but_full_does_not() {
760        // Only a child DOM was hit — the single-DOM getter is blind to it.
761        let hm = mouse_history(vec![hits(&[(2, &[4])])]);
762
763        assert_eq!(hm.current_hover_node(), None);
764        assert_eq!(hm.current_hover_node_full(), Some(dom_node(2, 4)));
765    }
766
767    #[test]
768    fn current_hover_node_full_prefers_the_front_most_child_dom() {
769        let hm = mouse_history(vec![hits(&[(0, &[9]), (1, &[2])])]);
770
771        // The root getter still reports the root's deepest node...
772        assert_eq!(hm.current_hover_node(), Some(NodeId::new(9)));
773        // ...while the multi-DOM getter targets the composited-on-top child.
774        assert_eq!(hm.current_hover_node_full(), Some(dom_node(1, 2)));
775    }
776
777    #[test]
778    fn previous_hover_node_is_none_until_a_second_frame_exists() {
779        let hm = mouse_history(vec![hits(&[(0, &[1])])]);
780
781        assert_eq!(hm.current_hover_node(), Some(NodeId::new(1)));
782        assert_eq!(hm.previous_hover_node(), None);
783        assert_eq!(hm.previous_hover_node_full(), None);
784    }
785
786    #[test]
787    fn previous_hover_node_reads_frame_one_not_the_oldest_frame() {
788        // 6 pushes => the oldest (node 0) is evicted; frame 1 is node 4.
789        let hm = mouse_history((0..6).map(|i| hits(&[(0, &[i])])).collect());
790
791        assert_eq!(hm.current_hover_node(), Some(NodeId::new(5)));
792        assert_eq!(hm.previous_hover_node(), Some(NodeId::new(4)));
793        assert_eq!(hm.previous_hover_node_full(), Some(dom_node(0, 4)));
794    }
795
796    #[test]
797    fn previous_hover_node_full_sees_child_doms_of_the_previous_frame() {
798        let hm = mouse_history(vec![hits(&[(0, &[1]), (7, &[3])]), hits(&[(0, &[2])])]);
799
800        assert_eq!(hm.previous_hover_node(), Some(NodeId::new(1)));
801        assert_eq!(hm.previous_hover_node_full(), Some(dom_node(7, 3)));
802        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 2)));
803    }
804
805    #[test]
806    fn hover_node_getters_are_none_when_the_frame_hit_nothing() {
807        let hm = mouse_history(vec![FullHitTest::empty(None), FullHitTest::empty(None)]);
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    }
814
815    #[test]
816    fn hover_node_getters_ignore_touch_history_entirely() {
817        let mut hm = HoverManager::new();
818        hm.push_hit_test(InputPointId::Touch(1), hits(&[(0, &[5])]));
819        hm.push_hit_test(InputPointId::Touch(1), hits(&[(0, &[6])]));
820
821        assert!(hm.current_hover_node().is_none());
822        assert!(hm.previous_hover_node().is_none());
823        assert!(hm.current_hover_node_full().is_none());
824        assert!(hm.previous_hover_node_full().is_none());
825        assert!(hm.any_has_sufficient_history_for_gestures());
826    }
827
828    // ------------------------------------------------------ purge_dom (other)
829
830    #[test]
831    fn purge_dom_removes_that_dom_from_every_frame_of_every_point() {
832        let mut hm = HoverManager::new();
833        for id in [InputPointId::Mouse, InputPointId::Touch(2)] {
834            hm.push_hit_test(id, hits(&[(0, &[1]), (1, &[2])]));
835            hm.push_hit_test(id, hits(&[(0, &[3]), (1, &[4])]));
836        }
837
838        hm.purge_dom(&dom(1));
839
840        // Frames themselves are kept — only DOM 1's hits are forgotten.
841        assert_eq!(hm.debug_counts(), (2, 4));
842        for id in [InputPointId::Mouse, InputPointId::Touch(2)] {
843            let history = hm.get_history(&id).expect("history exists");
844            for frame in history {
845                assert!(!frame.hovered_nodes.contains_key(&dom(1)));
846                assert!(frame.hovered_nodes.contains_key(&dom(0)));
847            }
848        }
849        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 3)));
850        assert_eq!(hm.previous_hover_node_full(), Some(dom_node(0, 1)));
851    }
852
853    #[test]
854    fn purge_dom_zero_leaves_child_dom_hits_intact() {
855        let mut hm = mouse_history(vec![hits(&[(0, &[1]), (4, &[2])])]);
856
857        hm.purge_dom(&dom(0));
858
859        // The single-DOM getter now finds nothing, the multi-DOM one falls back.
860        assert_eq!(hm.current_hover_node(), None);
861        assert_eq!(hm.current_hover_node_full(), Some(dom_node(4, 2)));
862        assert_eq!(hm.frame_count(&InputPointId::Mouse), 1);
863    }
864
865    #[test]
866    fn purge_absent_or_extreme_dom_id_is_a_noop() {
867        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
868        let before = hm.clone();
869
870        hm.purge_dom(&dom(9));
871        hm.purge_dom(&dom(usize::MAX));
872        assert_eq!(hm, before);
873
874        // Purging on an empty manager must not panic either.
875        let mut empty = HoverManager::new();
876        empty.purge_dom(&dom(0));
877        assert_eq!(empty, HoverManager::new());
878    }
879
880    #[test]
881    fn purge_dom_twice_is_idempotent() {
882        let mut hm = mouse_history(vec![hits(&[(0, &[1]), (1, &[2])])]);
883
884        hm.purge_dom(&dom(1));
885        let once = hm.clone();
886        hm.purge_dom(&dom(1));
887
888        assert_eq!(hm, once);
889    }
890
891    // -------------------------------------------- remap_scrollbar_hit_id (other)
892
893    fn sb_map(pairs: &[(usize, usize)]) -> BTreeMap<NodeId, NodeId> {
894        pairs
895            .iter()
896            .map(|(o, n)| (NodeId::new(*o), NodeId::new(*n)))
897            .collect()
898    }
899
900    #[test]
901    fn remap_scrollbar_hit_id_rewrites_every_variant_of_the_target_dom() {
902        let map = sb_map(&[(1, 42)]);
903        let d = dom(0);
904        let old = NodeId::new(1);
905        let new = NodeId::new(42);
906
907        assert_eq!(
908            remap_scrollbar_hit_id(&ScrollbarHitId::VerticalTrack(d, old), d, &map),
909            Some(ScrollbarHitId::VerticalTrack(d, new))
910        );
911        assert_eq!(
912            remap_scrollbar_hit_id(&ScrollbarHitId::VerticalThumb(d, old), d, &map),
913            Some(ScrollbarHitId::VerticalThumb(d, new))
914        );
915        assert_eq!(
916            remap_scrollbar_hit_id(&ScrollbarHitId::HorizontalTrack(d, old), d, &map),
917            Some(ScrollbarHitId::HorizontalTrack(d, new))
918        );
919        assert_eq!(
920            remap_scrollbar_hit_id(&ScrollbarHitId::HorizontalThumb(d, old), d, &map),
921            Some(ScrollbarHitId::HorizontalThumb(d, new))
922        );
923    }
924
925    #[test]
926    fn remap_scrollbar_hit_id_drops_unmounted_nodes() {
927        let map = sb_map(&[(1, 42)]);
928        let d = dom(0);
929        // Node 2 is absent from the map => unmounted => the hit must be dropped.
930        let unmounted = ScrollbarHitId::VerticalThumb(d, NodeId::new(2));
931
932        assert_eq!(remap_scrollbar_hit_id(&unmounted, d, &map), None);
933        // Empty map: everything on the target DOM is unmounted.
934        let hit = ScrollbarHitId::VerticalThumb(d, NodeId::new(1));
935        assert_eq!(remap_scrollbar_hit_id(&hit, d, &BTreeMap::new()), None);
936    }
937
938    #[test]
939    fn remap_scrollbar_hit_id_passes_other_doms_through_untouched() {
940        // The map applies to DOM 0 only; an id naming DOM 1 must NOT be rewritten
941        // even though its NodeId happens to be a key in the map.
942        let map = sb_map(&[(1, 42)]);
943        let other = ScrollbarHitId::HorizontalTrack(dom(1), NodeId::new(1));
944
945        assert_eq!(remap_scrollbar_hit_id(&other, dom(0), &map), Some(other));
946        // ...and it survives an empty map too (no accidental drop).
947        assert_eq!(
948            remap_scrollbar_hit_id(&other, dom(0), &BTreeMap::new()),
949            Some(other)
950        );
951    }
952
953    #[test]
954    fn remap_scrollbar_hit_id_handles_extreme_ids() {
955        let big = usize::MAX - 1;
956        let map = sb_map(&[(big, 0)]);
957        let d = dom(usize::MAX);
958
959        assert_eq!(
960            remap_scrollbar_hit_id(&ScrollbarHitId::VerticalTrack(d, NodeId::new(big)), d, &map),
961            Some(ScrollbarHitId::VerticalTrack(d, NodeId::ZERO))
962        );
963    }
964
965    // ------------------------------------------------ NodeIdRemap::remap_node_ids
966
967    /// A hit test with one regular + scroll + cursor + scrollbar hit on `node`.
968    fn all_maps_hit(dom_inner: usize, node: usize) -> FullHitTest {
969        let mut full = FullHitTest::empty(None);
970        let mut ht = HitTest::empty();
971        ht.regular_hit_test_nodes
972            .insert(NodeId::new(node), hit_item(0));
973        ht.scroll_hit_test_nodes
974            .insert(NodeId::new(node), scroll_item());
975        ht.cursor_hit_test_nodes
976            .insert(NodeId::new(node), cursor_item());
977        ht.scrollbar_hit_test_nodes.insert(
978            ScrollbarHitId::VerticalThumb(dom(dom_inner), NodeId::new(node)),
979            scrollbar_item(),
980        );
981        full.hovered_nodes.insert(dom(dom_inner), ht);
982        full
983    }
984
985    #[test]
986    fn remap_node_ids_rewrites_all_four_hit_maps() {
987        let mut hm = mouse_history(vec![all_maps_hit(0, 3)]);
988
989        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(11))]));
990
991        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
992        assert_eq!(
993            ht.regular_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
994            vec![NodeId::new(11)]
995        );
996        assert_eq!(
997            ht.scroll_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
998            vec![NodeId::new(11)]
999        );
1000        assert_eq!(
1001            ht.cursor_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
1002            vec![NodeId::new(11)]
1003        );
1004        assert_eq!(
1005            ht.scrollbar_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
1006            vec![ScrollbarHitId::VerticalThumb(dom(0), NodeId::new(11))]
1007        );
1008        assert_eq!(hm.current_hover_node(), Some(NodeId::new(11)));
1009    }
1010
1011    #[test]
1012    fn remap_node_ids_with_an_empty_map_drops_every_hit_of_that_dom() {
1013        let mut hm = mouse_history(vec![all_maps_hit(0, 3)]);
1014
1015        // Empty map = nothing matched = every node was unmounted.
1016        hm.remap_node_ids(dom(0), &NodeIdMap::default());
1017
1018        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
1019        assert!(ht.regular_hit_test_nodes.is_empty());
1020        assert!(ht.scroll_hit_test_nodes.is_empty());
1021        assert!(ht.cursor_hit_test_nodes.is_empty());
1022        assert!(ht.scrollbar_hit_test_nodes.is_empty());
1023        assert_eq!(hm.current_hover_node(), None);
1024        // The (now empty) DOM entry itself is kept — only purge_dom removes it.
1025        assert!(hm
1026            .get_current_mouse()
1027            .expect("frame exists")
1028            .hovered_nodes
1029            .contains_key(&dom(0)));
1030    }
1031
1032    #[test]
1033    fn remap_node_ids_drops_unmounted_but_keeps_survivors() {
1034        // Nodes 1 and 4 hit; only 4 survives the rebuild (as node 0).
1035        let mut hm = mouse_history(vec![hits(&[(0, &[1, 4])])]);
1036
1037        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(4), NodeId::ZERO)]));
1038
1039        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
1040        assert_eq!(
1041            ht.regular_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
1042            vec![NodeId::ZERO]
1043        );
1044        assert_eq!(hm.current_hover_node(), Some(NodeId::ZERO));
1045    }
1046
1047    #[test]
1048    fn remap_node_ids_swap_does_not_lose_or_alias_entries() {
1049        // 1 -> 2 and 2 -> 1 in the same pass: the naive in-place rewrite would
1050        // clobber one of them. Both must survive with their items swapped.
1051        let mut full = FullHitTest::empty(None);
1052        let mut ht = HitTest::empty();
1053        ht.regular_hit_test_nodes
1054            .insert(NodeId::new(1), hit_item(10));
1055        ht.regular_hit_test_nodes
1056            .insert(NodeId::new(2), hit_item(20));
1057        full.hovered_nodes.insert(dom(0), ht);
1058        let mut hm = mouse_history(vec![full]);
1059
1060        hm.remap_node_ids(
1061            dom(0),
1062            &NodeIdMap::from_pairs([
1063                (NodeId::new(1), NodeId::new(2)),
1064                (NodeId::new(2), NodeId::new(1)),
1065            ]),
1066        );
1067
1068        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
1069        assert_eq!(ht.regular_hit_test_nodes.len(), 2);
1070        assert_eq!(ht.regular_hit_test_nodes[&NodeId::new(2)].hit_depth, 10);
1071        assert_eq!(ht.regular_hit_test_nodes[&NodeId::new(1)].hit_depth, 20);
1072    }
1073
1074    #[test]
1075    fn remap_node_ids_can_change_which_node_is_deepest() {
1076        // Old order: 7 is deepest. The rebuild renumbers 3 -> 9 and 7 -> 2,
1077        // so the deepest hit must be recomputed (9), not carried over.
1078        let mut hm = mouse_history(vec![hits(&[(0, &[3, 7])])]);
1079        assert_eq!(hm.current_hover_node(), Some(NodeId::new(7)));
1080
1081        hm.remap_node_ids(
1082            dom(0),
1083            &NodeIdMap::from_pairs([
1084                (NodeId::new(3), NodeId::new(9)),
1085                (NodeId::new(7), NodeId::new(2)),
1086            ]),
1087        );
1088
1089        assert_eq!(hm.current_hover_node(), Some(NodeId::new(9)));
1090        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 9)));
1091    }
1092
1093    #[test]
1094    fn remap_node_ids_leaves_other_doms_alone() {
1095        let mut hm = mouse_history(vec![hits(&[(0, &[1]), (1, &[1])])]);
1096
1097        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(5))]));
1098
1099        let frame = hm.get_current_mouse().expect("frame exists");
1100        assert_eq!(
1101            frame.hovered_nodes[&dom(0)]
1102                .regular_hit_test_nodes
1103                .keys()
1104                .copied()
1105                .collect::<Vec<_>>(),
1106            vec![NodeId::new(5)],
1107            "DOM 0 is remapped"
1108        );
1109        assert_eq!(
1110            frame.hovered_nodes[&dom(1)]
1111                .regular_hit_test_nodes
1112                .keys()
1113                .copied()
1114                .collect::<Vec<_>>(),
1115            vec![NodeId::new(1)],
1116            "DOM 1 must be untouched by DOM 0's reconciliation"
1117        );
1118    }
1119
1120    #[test]
1121    fn remap_node_ids_keeps_foreign_dom_scrollbar_ids_stored_under_the_target_dom() {
1122        // A scrollbar hit recorded under DOM 0's HitTest but whose ScrollbarHitId
1123        // names DOM 1: remap_scrollbar_hit_id must pass it through, not drop it.
1124        let mut full = FullHitTest::empty(None);
1125        let mut ht = HitTest::empty();
1126        ht.scrollbar_hit_test_nodes.insert(
1127            ScrollbarHitId::VerticalTrack(dom(1), NodeId::new(1)),
1128            scrollbar_item(),
1129        );
1130        ht.scrollbar_hit_test_nodes.insert(
1131            ScrollbarHitId::VerticalTrack(dom(0), NodeId::new(1)),
1132            scrollbar_item(),
1133        );
1134        full.hovered_nodes.insert(dom(0), ht);
1135        let mut hm = mouse_history(vec![full]);
1136
1137        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(8))]));
1138
1139        let keys: Vec<_> = hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)]
1140            .scrollbar_hit_test_nodes
1141            .keys()
1142            .copied()
1143            .collect();
1144        assert!(
1145            keys.contains(&ScrollbarHitId::VerticalTrack(dom(1), NodeId::new(1))),
1146            "foreign-DOM scrollbar id must survive unchanged, got {keys:?}"
1147        );
1148        assert!(
1149            keys.contains(&ScrollbarHitId::VerticalTrack(dom(0), NodeId::new(8))),
1150            "target-DOM scrollbar id must be rewritten, got {keys:?}"
1151        );
1152        assert_eq!(keys.len(), 2);
1153    }
1154
1155    #[test]
1156    fn remap_node_ids_applies_to_every_frame_and_every_input_point() {
1157        let mut hm = HoverManager::new();
1158        for id in [InputPointId::Mouse, InputPointId::Touch(1)] {
1159            hm.push_hit_test(id, hits(&[(0, &[2])]));
1160            hm.push_hit_test(id, hits(&[(0, &[2])]));
1161        }
1162
1163        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(6))]));
1164
1165        for id in [InputPointId::Mouse, InputPointId::Touch(1)] {
1166            for frame in hm.get_history(&id).expect("history exists") {
1167                assert_eq!(
1168                    frame.hovered_nodes[&dom(0)]
1169                        .regular_hit_test_nodes
1170                        .keys()
1171                        .copied()
1172                        .collect::<Vec<_>>(),
1173                    vec![NodeId::new(6)]
1174                );
1175            }
1176        }
1177        assert_eq!(hm.previous_hover_node(), Some(NodeId::new(6)));
1178    }
1179
1180    #[test]
1181    fn remap_node_ids_on_an_empty_manager_or_unknown_dom_does_not_panic() {
1182        let mut empty = HoverManager::new();
1183        empty.remap_node_ids(dom(usize::MAX), &NodeIdMap::default());
1184        assert_eq!(empty, HoverManager::new());
1185
1186        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
1187        let before = hm.clone();
1188        // Reconciliation for a DOM that was never hit changes nothing.
1189        hm.remap_node_ids(dom(3), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(2))]));
1190        assert_eq!(hm, before);
1191    }
1192
1193    #[test]
1194    fn remap_node_ids_identity_map_is_idempotent() {
1195        let mut hm = mouse_history(vec![all_maps_hit(0, 3)]);
1196        let before = hm.clone();
1197        let identity = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(3))]);
1198
1199        hm.remap_node_ids(dom(0), &identity);
1200        assert_eq!(hm, before, "identity remap must not change anything");
1201
1202        hm.remap_node_ids(dom(0), &identity);
1203        assert_eq!(hm, before, "and applying it twice must not either");
1204    }
1205
1206    // ------------------------------------------------------------- misc invariants
1207
1208    #[test]
1209    fn clone_is_equal_and_independent_of_the_original() {
1210        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
1211        let snapshot = hm.clone();
1212        assert_eq!(hm, snapshot);
1213
1214        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[2])]));
1215
1216        assert_ne!(hm, snapshot, "the clone must not observe later pushes");
1217        assert_eq!(snapshot.frame_count(&InputPointId::Mouse), 1);
1218        assert_eq!(hm.frame_count(&InputPointId::Mouse), 2);
1219    }
1220
1221    #[test]
1222    fn debug_counts_agrees_with_frame_count_and_active_points() {
1223        let mut hm = HoverManager::new();
1224        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[1])]));
1225        hm.push_hit_test(InputPointId::Touch(7), hits(&[(0, &[1])]));
1226        hm.push_hit_test(InputPointId::Touch(7), hits(&[(0, &[2])]));
1227
1228        let (points, total) = hm.debug_counts();
1229        let active = hm.get_active_input_points();
1230        assert_eq!(points, active.len());
1231        assert_eq!(
1232            total,
1233            active.iter().map(|id| hm.frame_count(id)).sum::<usize>()
1234        );
1235        assert_eq!((points, total), (2, 3));
1236    }
1237}