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

            
8
use std::collections::{BTreeMap, VecDeque};
9

            
10
use crate::hit_test::FullHitTest;
11

            
12
/// Maximum number of frames to keep in hover history
13
const 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`].
20
82
fn deepest_node_across_doms(ht: &FullHitTest) -> Option<azul_core::dom::DomNodeId> {
21
82
    for (dom_id, hit) in ht.hovered_nodes.iter().rev() {
22
81
        if let Some(node_id) = hit.regular_hit_test_nodes.keys().last().copied() {
23
77
            return Some(azul_core::dom::DomNodeId {
24
77
                dom: *dom_id,
25
77
                node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
26
77
                    node_id,
27
77
                )),
28
77
            });
29
4
        }
30
    }
31
5
    None
32
82
}
33

            
34
/// Identifier for an input point (mouse, touch, pen, etc.)
35
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
36
pub 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)]
53
pub 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

            
59
impl HoverManager {
60
    /// Create a new empty `HoverManager`
61
5791
    #[must_use] pub const fn new() -> Self {
62
5791
        Self {
63
5791
            hover_histories: BTreeMap::new(),
64
5791
        }
65
5791
    }
66

            
67
    /// (input points, total history entries across all points). Used by
68
    /// `AZ_E2E_TEST` to watch for unbounded growth.
69
18
    #[must_use] pub fn debug_counts(&self) -> (usize, usize) {
70
18
        let points = self.hover_histories.len();
71
18
        let total: usize = self.hover_histories.values().map(VecDeque::len).sum();
72
18
        (points, total)
73
18
    }
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
6264
    pub fn push_hit_test(&mut self, input_id: InputPointId, hit_test: FullHitTest) {
80
6264
        let history = self
81
6264
            .hover_histories
82
6264
            .entry(input_id)
83
6264
            .or_insert_with(|| VecDeque::with_capacity(MAX_HOVER_HISTORY));
84

            
85
        // Add to front (most recent)
86
6264
        history.push_front(hit_test);
87

            
88
        // Remove oldest if we exceed the limit
89
6264
        if history.len() > MAX_HOVER_HISTORY {
90
5526
            history.pop_back();
91
5624
        }
92
6264
    }
93

            
94
    /// Remove an input point's history (e.g., when touch ends)
95
13
    pub fn remove_input_point(&mut self, input_id: &InputPointId) {
96
13
        self.hover_histories.remove(input_id);
97
13
    }
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
1532
    #[must_use] pub fn get_current(&self, input_id: &InputPointId) -> Option<&FullHitTest> {
103
1532
        self.hover_histories
104
1532
            .get(input_id)
105
1532
            .and_then(|history| history.front())
106
1532
    }
107

            
108
    /// Get the most recent mouse cursor hit test (convenience method)
109
1341
    #[must_use] pub fn get_current_mouse(&self) -> Option<&FullHitTest> {
110
1341
        self.get_current(&InputPointId::Mouse)
111
1341
    }
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
392
    #[must_use] pub fn get_frame(&self, input_id: &InputPointId, frames_ago: usize) -> Option<&FullHitTest> {
118
392
        self.hover_histories
119
392
            .get(input_id)
120
392
            .and_then(|history| history.get(frames_ago))
121
392
    }
122

            
123
    /// Get the entire hover history for an input point (most recent first)
124
8
    #[must_use] pub fn get_history(&self, input_id: &InputPointId) -> Option<&VecDeque<FullHitTest>> {
125
8
        self.hover_histories.get(input_id)
126
8
    }
127

            
128
    /// Get all currently tracked input points
129
574
    #[must_use] pub fn get_active_input_points(&self) -> Vec<InputPointId> {
130
574
        self.hover_histories.keys().copied().collect()
131
574
    }
132

            
133
    /// Get the number of frames in history for an input point
134
92
    #[must_use] pub fn frame_count(&self, input_id: &InputPointId) -> usize {
135
92
        self.hover_histories
136
92
            .get(input_id)
137
92
            .map_or(0, VecDeque::len)
138
92
    }
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
7
    pub fn purge_dom(&mut self, dom_id: &azul_core::dom::DomId) {
153
7
        for history in self.hover_histories.values_mut() {
154
9
            for frame in history.iter_mut() {
155
9
                frame.hovered_nodes.remove(dom_id);
156
9
            }
157
        }
158
7
    }
159

            
160
    /// Clear all hover history for all input points
161
2
    pub fn clear(&mut self) {
162
2
        self.hover_histories.clear();
163
2
    }
164

            
165
    /// Clear history for a specific input point
166
3
    pub(crate) fn clear_input_point(&mut self, input_id: &InputPointId) {
167
3
        if let Some(history) = self.hover_histories.get_mut(input_id) {
168
2
            history.clear();
169
2
        }
170
3
    }
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
36
    #[must_use] pub fn has_sufficient_history_for_gestures(&self, input_id: &InputPointId) -> bool {
177
36
        self.frame_count(input_id) >= 2
178
36
    }
179

            
180
    /// Check if any input point has enough history for gesture detection
181
25
    #[must_use] pub fn any_has_sufficient_history_for_gestures(&self) -> bool {
182
25
        self.hover_histories
183
25
            .iter()
184
29
            .any(|(_, history)| history.len() >= 2)
185
25
    }
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
16
    #[must_use] pub fn current_hover_node(&self) -> Option<azul_core::id::NodeId> {
194
16
        let current = self.get_current_mouse()?;
195
13
        let dom_id = azul_core::dom::DomId { inner: 0 };
196
13
        let ht = current.hovered_nodes.get(&dom_id)?;
197
10
        ht.regular_hit_test_nodes.keys().last().copied()
198
16
    }
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
9
    #[must_use] pub fn previous_hover_node(&self) -> Option<azul_core::id::NodeId> {
207
9
        let history = self.hover_histories.get(&InputPointId::Mouse)?;
208
7
        let previous = history.get(1)?; // index 1 = one frame ago
209
4
        let dom_id = azul_core::dom::DomId { inner: 0 };
210
4
        let ht = previous.hovered_nodes.get(&dom_id)?;
211
3
        ht.regular_hit_test_nodes.keys().last().copied()
212
9
    }
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
1302
    #[must_use] pub fn current_hover_node_full(&self) -> Option<azul_core::dom::DomNodeId> {
227
1302
        deepest_node_across_doms(self.get_current_mouse()?)
228
1302
    }
229

            
230
    /// Multi-DOM aware counterpart of [`previous_hover_node`] (one frame ago).
231
16
    #[must_use] pub fn previous_hover_node_full(&self) -> Option<azul_core::dom::DomNodeId> {
232
16
        let history = self.hover_histories.get(&InputPointId::Mouse)?;
233
11
        deepest_node_across_doms(history.get(1)?)
234
16
    }
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
6
    #[must_use] pub fn hover_node_full_for(
243
6
        &self,
244
6
        input_id: &InputPointId,
245
6
    ) -> Option<azul_core::dom::DomNodeId> {
246
6
        deepest_node_across_doms(self.get_current(input_id)?)
247
6
    }
248
}
249

            
250
impl 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
37
    fn remap_node_ids(&mut self, dom_id: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
257
37
        let node_id_map = map.as_btree_map();
258
37
        for history in self.hover_histories.values_mut() {
259
15
            for hit_test in history.iter_mut() {
260
15
                if let Some(ht) = hit_test.hovered_nodes.get_mut(&dom_id) {
261
14
                    crate::managers::remap_keys(&mut ht.regular_hit_test_nodes, map);
262
14
                    crate::managers::remap_keys(&mut ht.scroll_hit_test_nodes, map);
263
14
                    crate::managers::remap_keys(&mut ht.cursor_hit_test_nodes, map);
264

            
265
                    // Remap scrollbar_hit_test_nodes (ScrollbarHitId contains NodeId)
266
14
                    let old_sb: Vec<_> = ht.scrollbar_hit_test_nodes.keys().copied().collect();
267
14
                    let mut new_sb = BTreeMap::new();
268
20
                    for old_key in old_sb {
269
6
                        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
1
                            ht.scrollbar_hit_test_nodes.remove(&old_key);
273
1
                            continue;
274
                        };
275
5
                        if let Some(item) = ht.scrollbar_hit_test_nodes.remove(&old_key) {
276
5
                            new_sb.insert(new_key, item);
277
5
                        }
278
                    }
279
14
                    ht.scrollbar_hit_test_nodes = new_sb;
280
1
                }
281
            }
282
        }
283
37
    }
284
}
285

            
286
impl Default for HoverManager {
287
1
    fn default() -> Self {
288
1
        Self::new()
289
1
    }
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.
295
15
fn remap_scrollbar_hit_id(
296
15
    id: &azul_core::hit_test::ScrollbarHitId,
297
15
    dom_id: azul_core::dom::DomId,
298
15
    node_id_map: &BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
299
15
) -> Option<azul_core::hit_test::ScrollbarHitId> {
300
    use azul_core::hit_test::ScrollbarHitId;
301
4
    Some(match id {
302
4
        ScrollbarHitId::VerticalTrack(d, n) if *d == dom_id => {
303
3
            ScrollbarHitId::VerticalTrack(*d, *node_id_map.get(n)?)
304
        }
305
7
        ScrollbarHitId::VerticalThumb(d, n) if *d == dom_id => {
306
7
            ScrollbarHitId::VerticalThumb(*d, *node_id_map.get(n)?)
307
        }
308
3
        ScrollbarHitId::HorizontalTrack(d, n) if *d == dom_id => {
309
1
            ScrollbarHitId::HorizontalTrack(*d, *node_id_map.get(n)?)
310
        }
311
1
        ScrollbarHitId::HorizontalThumb(d, n) if *d == dom_id => {
312
1
            ScrollbarHitId::HorizontalThumb(*d, *node_id_map.get(n)?)
313
        }
314
3
        other => *other,
315
    })
316
15
}
317

            
318
#[cfg(test)]
319
mod 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
        );
        assert_eq!(
            ht.cursor_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
            vec![NodeId::new(11)]
        );
        assert_eq!(
            ht.scrollbar_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
            vec![ScrollbarHitId::VerticalThumb(dom(0), NodeId::new(11))]
        );
        assert_eq!(hm.current_hover_node(), Some(NodeId::new(11)));
    }
    #[test]
    fn remap_node_ids_with_an_empty_map_drops_every_hit_of_that_dom() {
        let mut hm = mouse_history(vec![all_maps_hit(0, 3)]);
        // Empty map = nothing matched = every node was unmounted.
        hm.remap_node_ids(dom(0), &NodeIdMap::default());
        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
        assert!(ht.regular_hit_test_nodes.is_empty());
        assert!(ht.scroll_hit_test_nodes.is_empty());
        assert!(ht.cursor_hit_test_nodes.is_empty());
        assert!(ht.scrollbar_hit_test_nodes.is_empty());
        assert_eq!(hm.current_hover_node(), None);
        // The (now empty) DOM entry itself is kept — only purge_dom removes it.
        assert!(hm
            .get_current_mouse()
            .expect("frame exists")
            .hovered_nodes
            .contains_key(&dom(0)));
    }
    #[test]
    fn remap_node_ids_drops_unmounted_but_keeps_survivors() {
        // Nodes 1 and 4 hit; only 4 survives the rebuild (as node 0).
        let mut hm = mouse_history(vec![hits(&[(0, &[1, 4])])]);
        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(4), NodeId::ZERO)]));
        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
        assert_eq!(
            ht.regular_hit_test_nodes.keys().copied().collect::<Vec<_>>(),
            vec![NodeId::ZERO]
        );
        assert_eq!(hm.current_hover_node(), Some(NodeId::ZERO));
    }
    #[test]
    fn remap_node_ids_swap_does_not_lose_or_alias_entries() {
        // 1 -> 2 and 2 -> 1 in the same pass: the naive in-place rewrite would
        // clobber one of them. Both must survive with their items swapped.
        let mut full = FullHitTest::empty(None);
        let mut ht = HitTest::empty();
        ht.regular_hit_test_nodes
            .insert(NodeId::new(1), hit_item(10));
        ht.regular_hit_test_nodes
            .insert(NodeId::new(2), hit_item(20));
        full.hovered_nodes.insert(dom(0), ht);
        let mut hm = mouse_history(vec![full]);
        hm.remap_node_ids(
            dom(0),
            &NodeIdMap::from_pairs([
                (NodeId::new(1), NodeId::new(2)),
                (NodeId::new(2), NodeId::new(1)),
            ]),
        );
        let ht = &hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)];
        assert_eq!(ht.regular_hit_test_nodes.len(), 2);
        assert_eq!(ht.regular_hit_test_nodes[&NodeId::new(2)].hit_depth, 10);
        assert_eq!(ht.regular_hit_test_nodes[&NodeId::new(1)].hit_depth, 20);
    }
    #[test]
    fn remap_node_ids_can_change_which_node_is_deepest() {
        // Old order: 7 is deepest. The rebuild renumbers 3 -> 9 and 7 -> 2,
        // so the deepest hit must be recomputed (9), not carried over.
        let mut hm = mouse_history(vec![hits(&[(0, &[3, 7])])]);
        assert_eq!(hm.current_hover_node(), Some(NodeId::new(7)));
        hm.remap_node_ids(
            dom(0),
            &NodeIdMap::from_pairs([
                (NodeId::new(3), NodeId::new(9)),
                (NodeId::new(7), NodeId::new(2)),
            ]),
        );
        assert_eq!(hm.current_hover_node(), Some(NodeId::new(9)));
        assert_eq!(hm.current_hover_node_full(), Some(dom_node(0, 9)));
    }
    #[test]
    fn remap_node_ids_leaves_other_doms_alone() {
        let mut hm = mouse_history(vec![hits(&[(0, &[1]), (1, &[1])])]);
        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(5))]));
        let frame = hm.get_current_mouse().expect("frame exists");
        assert_eq!(
            frame.hovered_nodes[&dom(0)]
                .regular_hit_test_nodes
                .keys()
                .copied()
                .collect::<Vec<_>>(),
            vec![NodeId::new(5)],
            "DOM 0 is remapped"
        );
        assert_eq!(
            frame.hovered_nodes[&dom(1)]
                .regular_hit_test_nodes
                .keys()
                .copied()
                .collect::<Vec<_>>(),
            vec![NodeId::new(1)],
            "DOM 1 must be untouched by DOM 0's reconciliation"
        );
    }
    #[test]
    fn remap_node_ids_keeps_foreign_dom_scrollbar_ids_stored_under_the_target_dom() {
        // A scrollbar hit recorded under DOM 0's HitTest but whose ScrollbarHitId
        // names DOM 1: remap_scrollbar_hit_id must pass it through, not drop it.
        let mut full = FullHitTest::empty(None);
        let mut ht = HitTest::empty();
        ht.scrollbar_hit_test_nodes.insert(
            ScrollbarHitId::VerticalTrack(dom(1), NodeId::new(1)),
            scrollbar_item(),
        );
        ht.scrollbar_hit_test_nodes.insert(
            ScrollbarHitId::VerticalTrack(dom(0), NodeId::new(1)),
            scrollbar_item(),
        );
        full.hovered_nodes.insert(dom(0), ht);
        let mut hm = mouse_history(vec![full]);
        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(8))]));
        let keys: Vec<_> = hm.get_current_mouse().expect("frame exists").hovered_nodes[&dom(0)]
            .scrollbar_hit_test_nodes
            .keys()
            .copied()
            .collect();
        assert!(
            keys.contains(&ScrollbarHitId::VerticalTrack(dom(1), NodeId::new(1))),
            "foreign-DOM scrollbar id must survive unchanged, got {keys:?}"
        );
        assert!(
            keys.contains(&ScrollbarHitId::VerticalTrack(dom(0), NodeId::new(8))),
            "target-DOM scrollbar id must be rewritten, got {keys:?}"
        );
        assert_eq!(keys.len(), 2);
    }
    #[test]
    fn remap_node_ids_applies_to_every_frame_and_every_input_point() {
        let mut hm = HoverManager::new();
        for id in [InputPointId::Mouse, InputPointId::Touch(1)] {
            hm.push_hit_test(id, hits(&[(0, &[2])]));
            hm.push_hit_test(id, hits(&[(0, &[2])]));
        }
        hm.remap_node_ids(dom(0), &NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(6))]));
        for id in [InputPointId::Mouse, InputPointId::Touch(1)] {
            for frame in hm.get_history(&id).expect("history exists") {
                assert_eq!(
                    frame.hovered_nodes[&dom(0)]
                        .regular_hit_test_nodes
                        .keys()
                        .copied()
                        .collect::<Vec<_>>(),
                    vec![NodeId::new(6)]
                );
            }
        }
        assert_eq!(hm.previous_hover_node(), Some(NodeId::new(6)));
    }
    #[test]
    fn remap_node_ids_on_an_empty_manager_or_unknown_dom_does_not_panic() {
        let mut empty = HoverManager::new();
        empty.remap_node_ids(dom(usize::MAX), &NodeIdMap::default());
        assert_eq!(empty, HoverManager::new());
        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
        let before = hm.clone();
        // Reconciliation for a DOM that was never hit changes nothing.
        hm.remap_node_ids(dom(3), &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(2))]));
        assert_eq!(hm, before);
    }
    #[test]
    fn remap_node_ids_identity_map_is_idempotent() {
        let mut hm = mouse_history(vec![all_maps_hit(0, 3)]);
        let before = hm.clone();
        let identity = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(3))]);
        hm.remap_node_ids(dom(0), &identity);
        assert_eq!(hm, before, "identity remap must not change anything");
        hm.remap_node_ids(dom(0), &identity);
        assert_eq!(hm, before, "and applying it twice must not either");
    }
    // ------------------------------------------------------------- misc invariants
    #[test]
    fn clone_is_equal_and_independent_of_the_original() {
        let mut hm = mouse_history(vec![hits(&[(0, &[1])])]);
        let snapshot = hm.clone();
        assert_eq!(hm, snapshot);
        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[2])]));
        assert_ne!(hm, snapshot, "the clone must not observe later pushes");
        assert_eq!(snapshot.frame_count(&InputPointId::Mouse), 1);
        assert_eq!(hm.frame_count(&InputPointId::Mouse), 2);
    }
    #[test]
    fn debug_counts_agrees_with_frame_count_and_active_points() {
        let mut hm = HoverManager::new();
        hm.push_hit_test(InputPointId::Mouse, hits(&[(0, &[1])]));
        hm.push_hit_test(InputPointId::Touch(7), hits(&[(0, &[1])]));
        hm.push_hit_test(InputPointId::Touch(7), hits(&[(0, &[2])]));
        let (points, total) = hm.debug_counts();
        let active = hm.get_active_input_points();
        assert_eq!(points, active.len());
        assert_eq!(
            total,
            active.iter().map(|id| hm.frame_count(id)).sum::<usize>()
        );
        assert_eq!((points, total), (2, 3));
    }
}