1
//! Manager types responsible for stateful input and UI concerns.
2
//!
3
//! This module collects managers for accessibility, clipboard, drag-and-drop,
4
//! focus/cursor, gestures, hover, scroll state, selection, text editing,
5
//! text input, undo/redo, and virtual views. These managers are consumed
6
//! primarily by `layout/src/window.rs` and `layout/src/event_determination.rs`.
7
//!
8
//! # `NodeId` staleness — read this before adding a manager
9
//!
10
//! A `NodeId` is an INDEX into the current DOM arena, not a stable identity.
11
//! Every DOM rebuild (virtual-view re-invocation, window resize, route switch,
12
//! any `regenerate_layout`) renumbers them. Reconciliation
13
//! (`azul_core::diff::reconcile_dom`) tells us how: `node_moves` maps every
14
//! MATCHED old `NodeId` to its new one. An old `NodeId` that is absent from
15
//! that map was UNMOUNTED.
16
//!
17
//! Any manager that keys state by `NodeId` and does not participate in that
18
//! remap ends up pointing at a *live but wrong* node — deleting a preceding
19
//! sibling shifts every following index down by one, so the state silently
20
//! re-attaches to a different element (no dangling id, no panic, no error).
21
//! Unmapped keys also leak forever.
22
//!
23
//! The fix is structural: every node-keyed manager implements
24
//! [`NodeIdRemap`], and [`crate::window::LayoutWindow::remap_node_ids`]
25
//! (exhaustively destructured, so a NEW FIELD IS A COMPILE ERROR until it is
26
//! classified) drives all of them from one place.
27

            
28
pub mod a11y;
29
/// Platform-neutral a11y element list for the shells `accesskit` does not
30
/// cover (iOS / Android). Gated with the same feature as `a11y` itself.
31
#[cfg(feature = "a11y")]
32
pub mod a11y_snapshot;
33
pub mod biometric;
34
pub mod changeset;
35
pub mod clipboard;
36
pub mod drag_drop;
37
pub mod file_drop;
38
pub mod focus_cursor;
39
pub mod gamepad;
40
pub mod geolocation;
41
pub mod gesture;
42
pub mod gpu_state;
43
pub mod hover;
44
pub mod keyring;
45
pub mod permission;
46
pub mod virtual_view;
47
pub mod scroll_into_view;
48
pub mod scroll_registration;
49
pub mod scroll_state;
50
pub mod selection;
51
pub mod sensors;
52
pub mod text_edit;
53
pub mod text_input;
54
pub mod undo_redo;
55

            
56
use alloc::collections::BTreeMap;
57

            
58
use azul_core::dom::{DomId, DomNodeId, NodeId};
59
use azul_core::styled_dom::NodeHierarchyItemId;
60

            
61
/// The result of a DOM reconciliation, from the point of view of anyone holding
62
/// `NodeId`-keyed state for a single DOM.
63
///
64
/// Built from `azul_core::diff::DiffResult::node_moves`, which contains an entry
65
/// for EVERY matched node (including nodes that kept their index). The absence
66
/// of an old `NodeId` from the map therefore has a precise meaning: that node was
67
/// **unmounted**. This is what makes GC possible without a second "alive" set.
68
///
69
/// The contract for consumers is a single rule:
70
///
71
/// * [`NodeIdMap::resolve`] returns `Some(new_id)` — the node survived, rewrite the key.
72
/// * [`NodeIdMap::resolve`] returns `None` — the node is GONE, **drop the state**.
73
///
74
/// Never "keep it, just in case": a kept key is a key that now denotes a
75
/// different node.
76
#[derive(Debug, Clone, Default, PartialEq, Eq)]
77
pub struct NodeIdMap {
78
    moves: BTreeMap<NodeId, NodeId>,
79
}
80

            
81
impl NodeIdMap {
82
    /// Build from reconciliation output (`DiffResult::node_moves`).
83
    #[must_use]
84
29
    pub fn from_node_moves(node_moves: &[azul_core::diff::NodeMove]) -> Self {
85
        Self {
86
29
            moves: node_moves
87
29
                .iter()
88
215
                .map(|m| (m.old_node_id, m.new_node_id))
89
29
                .collect(),
90
        }
91
29
    }
92

            
93
    /// Build from raw `(old, new)` pairs — used by tests and by callers that
94
    /// already computed a migration map.
95
    #[must_use]
96
114
    pub fn from_pairs<I: IntoIterator<Item = (NodeId, NodeId)>>(pairs: I) -> Self {
97
114
        Self {
98
114
            moves: pairs.into_iter().collect(),
99
114
        }
100
114
    }
101

            
102
    /// `Some(new_id)` if the node survived the rebuild, `None` if it was unmounted.
103
    #[must_use]
104
2659
    pub fn resolve(&self, old: NodeId) -> Option<NodeId> {
105
2659
        self.moves.get(&old).copied()
106
2659
    }
107

            
108
    /// `true` if `old` no longer exists in the new DOM.
109
    #[must_use]
110
2068
    pub fn is_unmounted(&self, old: NodeId) -> bool {
111
2068
        !self.moves.contains_key(&old)
112
2068
    }
113

            
114
    /// Resolve a full `DomNodeId`. Ids belonging to a *different* DOM are passed
115
    /// through untouched (this reconciliation says nothing about them).
116
    #[must_use]
117
48
    pub fn resolve_dom_node_id(&self, dom: DomId, id: DomNodeId) -> Option<DomNodeId> {
118
48
        if id.dom != dom {
119
11
            return Some(id);
120
37
        }
121
37
        let old = id.node.into_crate_internal()?;
122
34
        let new = self.resolve(old)?;
123
18
        Some(DomNodeId {
124
18
            dom,
125
18
            node: NodeHierarchyItemId::from_crate_internal(Some(new)),
126
18
        })
127
48
    }
128

            
129
    /// The raw old→new map, for `azul_core` APIs that take a `BTreeMap`
130
    /// (`DragContext::remap_node_ids`, `MultiCursorState::remap_node_ids`).
131
    #[must_use]
132
54
    pub const fn as_btree_map(&self) -> &BTreeMap<NodeId, NodeId> {
133
54
        &self.moves
134
54
    }
135

            
136
    /// No matched nodes at all (everything was unmounted / the DOM is brand new).
137
    #[must_use]
138
10
    pub fn is_empty(&self) -> bool {
139
10
        self.moves.is_empty()
140
10
    }
141
}
142

            
143
/// Implemented by EVERY manager (or cache) that keys state by `NodeId`.
144
///
145
/// One method on purpose: remapping and GC are the same pass, so it is
146
/// impossible to do one and forget the other. Implementors MUST, for state
147
/// belonging to `dom`:
148
///
149
/// 1. rewrite each key/field `old` to `map.resolve(old)`, and
150
/// 2. **drop** the state whenever `resolve` returns `None` (unmounted node).
151
///
152
/// State belonging to any *other* `DomId` must be left alone.
153
pub trait NodeIdRemap {
154
    /// Rewrite all `NodeId`s for `dom` and drop state for unmounted nodes.
155
    fn remap_node_ids(&mut self, dom: DomId, map: &NodeIdMap);
156
}
157

            
158
/// Remap the keys of a `BTreeMap<NodeId, V>` in place, dropping unmounted nodes.
159
50
pub(crate) fn remap_keys<V>(map: &mut BTreeMap<NodeId, V>, node_map: &NodeIdMap) {
160
50
    let old = core::mem::take(map);
161
407
    for (old_id, v) in old {
162
357
        if let Some(new_id) = node_map.resolve(old_id) {
163
347
            map.insert(new_id, v);
164
347
        }
165
    }
166
50
}
167

            
168
/// Remap the keys of a `BTreeMap<(DomId, NodeId), V>` in place: entries for
169
/// `dom` are rewritten (or dropped if unmounted), entries for other DOMs are
170
/// left untouched.
171
135
pub(crate) fn remap_dom_keys<V>(
172
135
    map: &mut BTreeMap<(DomId, NodeId), V>,
173
135
    dom: DomId,
174
135
    node_map: &NodeIdMap,
175
135
) {
176
135
    let old = core::mem::take(map);
177
227
    for ((d, old_id), v) in old {
178
92
        if d != dom {
179
11
            map.insert((d, old_id), v);
180
81
        } else if let Some(new_id) = node_map.resolve(old_id) {
181
73
            map.insert((d, new_id), v);
182
74
        }
183
    }
184
135
}
185

            
186
// ============================================================================
187
// THE PRECEDING-SIBLING TEST
188
// ============================================================================
189
//
190
// These tests encode the failure mode that motivated `NodeIdRemap`. They do NOT
191
// assert "no panic" — an unremapped manager never panics, that is exactly what
192
// made this bug survive. They assert LOGICAL IDENTITY: after the rebuild, every
193
// manager's state must still describe the SAME ELEMENT it described before.
194
//
195
// Scenario (one DOM, four nodes):
196
//
197
//     before:  0=root  1=A   2=B   3=C
198
//     delete A
199
//     after:   0=root        1=B   2=C          map = {0→0, 2→1, 3→2}
200
//
201
// State is seeded on B(2) and C(3) with DISTINGUISHABLE payloads, and on the
202
// doomed A(1). A manager that skips the remap keeps C's state at key 3 and
203
// leaves B's state at key 2 — but index 2 now denotes C. So the state is not
204
// dangling, it is MISATTACHED: "give me C's state" silently answers with B's.
205
// Asserting `state_at(2) == C_payload` is what catches that; a null/panic check
206
// does not.
207
#[cfg(all(test, feature = "std"))]
208
mod preceding_sibling_remap_tests {
209
    use alloc::collections::BTreeMap;
210

            
211
    use azul_core::{
212
        dom::{DomId, DomNodeId, NodeId},
213
        drag::{DragContext, DragData},
214
        geom::LogicalPosition,
215
        hit_test::{FullHitTest, HitTest, HitTestItem},
216
        selection::{CursorAffinity, GraphemeClusterId, MultiCursorState, TextCursor},
217
        styled_dom::NodeHierarchyItemId,
218
        task::{Instant, SystemTick},
219
    };
220

            
221
    use super::{
222
        changeset::{TextChangeset, TextOpInsertText, TextOperation},
223
        focus_cursor::FocusManager,
224
        gesture::GestureAndDragManager,
225
        gpu_state::GpuStateManager,
226
        hover::{HoverManager, InputPointId},
227
        scroll_state::ScrollManager,
228
        text_edit::TextEditManager,
229
        text_input::{TextInputManager, TextInputSource},
230
        undo_redo::{NodeStateSnapshot, UndoRedoManager},
231
        virtual_view::VirtualViewManager,
232
        NodeIdMap, NodeIdRemap,
233
    };
234

            
235
    const ROOT: DomId = DomId { inner: 0 };
236
    /// The node that gets deleted.
237
    const A: NodeId = NodeId::new(1);
238
    /// Surviving sibling, index 2 → 1.
239
    const B_OLD: NodeId = NodeId::new(2);
240
    const B_NEW: NodeId = NodeId::new(1);
241
    /// Surviving sibling, index 3 → 2.
242
    const C_OLD: NodeId = NodeId::new(3);
243
    const C_NEW: NodeId = NodeId::new(2);
244

            
245
    /// Exactly what `reconcile_dom` produces when the preceding sibling A is
246
    /// deleted: every MATCHED node, with A absent (= unmounted).
247
16
    fn delete_a() -> NodeIdMap {
248
16
        NodeIdMap::from_pairs([
249
16
            (NodeId::new(0), NodeId::new(0)),
250
16
            (B_OLD, B_NEW),
251
16
            (C_OLD, C_NEW),
252
16
        ])
253
16
    }
254

            
255
10
    fn now() -> Instant {
256
10
        Instant::Tick(SystemTick { tick_counter: 0 })
257
10
    }
258

            
259
9
    fn dom_node(node: NodeId) -> DomNodeId {
260
9
        DomNodeId {
261
9
            dom: ROOT,
262
9
            node: NodeHierarchyItemId::from_crate_internal(Some(node)),
263
9
        }
264
9
    }
265

            
266
    // ---------------------------------------------------------------- scroll
267

            
268
    #[test]
269
1
    fn scroll_offsets_follow_their_node_across_a_preceding_sibling_delete() {
270
1
        let mut m = ScrollManager::new();
271
        // Distinguishable payloads: y = 10 for A, 20 for B, 30 for C.
272
1
        m.set_scroll_position_unclamped(ROOT, A, LogicalPosition::new(0.0, 10.0), now());
273
1
        m.set_scroll_position_unclamped(ROOT, B_OLD, LogicalPosition::new(0.0, 20.0), now());
274
1
        m.set_scroll_position_unclamped(ROOT, C_OLD, LogicalPosition::new(0.0, 30.0), now());
275

            
276
1
        m.remap_node_ids(ROOT, &delete_a());
277

            
278
        // C is now node 2 and MUST still have C's offset (30) — not B's (20),
279
        // which is what an unremapped manager would answer here.
280
1
        assert_eq!(
281
1
            m.get_scroll_state(ROOT, C_NEW).map(|s| s.current_offset.y),
282
            Some(30.0),
283
            "C's scroll offset must follow C to its new NodeId"
284
        );
285
1
        assert_eq!(
286
1
            m.get_scroll_state(ROOT, B_NEW).map(|s| s.current_offset.y),
287
            Some(20.0),
288
            "B's scroll offset must follow B to its new NodeId"
289
        );
290
        // GC: the deleted node's state must not linger. NodeId(3) no longer exists.
291
1
        assert!(
292
1
            m.get_scroll_state(ROOT, NodeId::new(3)).is_none(),
293
            "no state may remain at a NodeId that no longer exists"
294
        );
295
1
        assert_eq!(m.get_scroll_states_for_dom(ROOT).len(), 2, "A's state must be GC'd");
296
1
    }
297

            
298
    // ------------------------------------------------------------- undo/redo
299

            
300
3
    fn undo_op(changeset_id: usize, node: NodeId, text: &str) -> super::undo_redo::UndoableOperation {
301
3
        super::undo_redo::UndoableOperation {
302
3
            changeset: TextChangeset {
303
3
                id: changeset_id,
304
3
                target: dom_node(node),
305
3
                operation: TextOperation::InsertText(TextOpInsertText {
306
3
                    text: text.into(),
307
3
                    position: azul_core::window::CursorPosition::Uninitialized,
308
3
                    new_cursor: azul_core::window::CursorPosition::Uninitialized,
309
3
                }),
310
3
                timestamp: now(),
311
3
            },
312
3
            pre_state: NodeStateSnapshot {
313
3
                node_id: node,
314
3
                text_content: text.into(),
315
3
                cursor_position: None.into(),
316
3
                selection_range: None.into(),
317
3
                timestamp: now(),
318
3
            },
319
3
        }
320
3
    }
321

            
322
    #[test]
323
1
    fn undo_history_stays_attached_to_the_same_element() {
324
1
        let mut m = UndoRedoManager::new();
325
1
        let a = undo_op(1, A, "typed-into-A");
326
1
        let b = undo_op(2, B_OLD, "typed-into-B");
327
1
        let c = undo_op(3, C_OLD, "typed-into-C");
328
1
        m.record_operation(a.changeset.clone(), a.pre_state.clone());
329
1
        m.record_operation(b.changeset.clone(), b.pre_state.clone());
330
1
        m.record_operation(c.changeset.clone(), c.pre_state.clone());
331

            
332
1
        m.remap_node_ids(ROOT, &delete_a());
333

            
334
        // THE bug: undoing "on C" must revert C's edit, not B's.
335
1
        let undo_on_c = m.peek_undo(C_NEW).expect("C must still have undo history");
336
1
        assert_eq!(
337
1
            undo_on_c.pre_state.text_content.as_str(),
338
            "typed-into-C",
339
            "undo on C must revert C's edit — an unremapped Vec re-attaches B's history here"
340
        );
341
1
        let undo_on_b = m.peek_undo(B_NEW).expect("B must still have undo history");
342
1
        assert_eq!(undo_on_b.pre_state.text_content.as_str(), "typed-into-B");
343

            
344
        // The embedded NodeIds must be rewritten too, or the *replay* targets the wrong node.
345
1
        assert_eq!(
346
1
            undo_on_c.changeset.target.node.into_crate_internal(),
347
            Some(C_NEW)
348
        );
349
1
        assert_eq!(undo_on_c.pre_state.node_id, C_NEW);
350

            
351
        // GC: A is gone, its history must be gone.
352
1
        assert_eq!(m.node_stacks.len(), 2, "the deleted node's undo stack must be GC'd");
353
1
        assert!(!m.can_undo(NodeId::new(3)), "no history at a NodeId that no longer exists");
354
1
    }
355

            
356
    // ----------------------------------------------------------- virtual view
357

            
358
    #[test]
359
1
    fn virtual_view_nested_doms_stay_with_their_host_node() {
360
1
        let mut m = VirtualViewManager::new();
361
1
        let dom_a = m.get_or_create_nested_dom_id(ROOT, A);
362
1
        let dom_b = m.get_or_create_nested_dom_id(ROOT, B_OLD);
363
1
        let dom_c = m.get_or_create_nested_dom_id(ROOT, C_OLD);
364
1
        assert_ne!(dom_b, dom_c);
365

            
366
1
        m.remap_node_ids(ROOT, &delete_a());
367

            
368
1
        assert_eq!(
369
1
            m.get_nested_dom_id(ROOT, C_NEW),
370
1
            Some(dom_c),
371
            "C's nested DOM must follow C — otherwise C renders B's virtual view"
372
        );
373
1
        assert_eq!(m.get_nested_dom_id(ROOT, B_NEW), Some(dom_b));
374
1
        assert_eq!(m.debug_counts(), 2, "the deleted view's state must be GC'd");
375
2
        assert!(!m.all_view_keys().iter().any(|(_, n)| *n == C_OLD));
376
1
        assert_ne!(m.get_nested_dom_id(ROOT, B_NEW), Some(dom_a));
377
1
    }
378

            
379
    // -------------------------------------------------------------- gpu state
380

            
381
    #[test]
382
1
    fn gpu_transform_keys_stay_with_their_node() {
383
        use azul_core::resources::{OpacityKey, TransformKey};
384
1
        let mut m = GpuStateManager::default();
385
1
        {
386
1
            let cache = m.get_or_create_cache(ROOT);
387
1
            cache.opacity_keys.insert(A, OpacityKey::unique());
388
1
            cache.current_opacity_values.insert(A, 0.1);
389
1
            cache.current_opacity_values.insert(B_OLD, 0.2);
390
1
            cache.current_opacity_values.insert(C_OLD, 0.3);
391
1
            cache.css_transform_keys.insert(C_OLD, TransformKey::unique());
392
1
        }
393
1
        let c_key = m.get_cache(ROOT).unwrap().css_transform_keys[&C_OLD];
394

            
395
1
        m.remap_node_ids(ROOT, &delete_a());
396

            
397
1
        let cache = m.get_cache(ROOT).unwrap();
398
1
        assert_eq!(
399
1
            cache.current_opacity_values.get(&C_NEW).copied(),
400
            Some(0.3),
401
            "C's opacity must follow C, not be inherited from B"
402
        );
403
1
        assert_eq!(cache.current_opacity_values.get(&B_NEW).copied(), Some(0.2));
404
1
        assert_eq!(
405
1
            cache.css_transform_keys.get(&C_NEW).copied(),
406
1
            Some(c_key),
407
            "C's GPU transform key must follow C"
408
        );
409
1
        assert!(cache.opacity_keys.is_empty(), "the deleted node's GPU keys must be GC'd");
410
1
        assert_eq!(cache.current_opacity_values.len(), 2);
411
1
    }
412

            
413
    // ------------------------------------------------------------------ focus
414

            
415
    #[test]
416
1
    fn focus_follows_its_node_and_is_cleared_when_the_node_dies() {
417
1
        let mut m = FocusManager::new();
418
1
        m.set_focused_node(Some(dom_node(C_OLD)));
419
1
        m.remap_node_ids(ROOT, &delete_a());
420
1
        assert_eq!(
421
1
            m.get_focused_node().and_then(|f| f.node.into_crate_internal()),
422
            Some(C_NEW),
423
            "focus must follow the focused element, not stay on a recycled index"
424
        );
425

            
426
1
        let mut m = FocusManager::new();
427
1
        m.set_focused_node(Some(dom_node(A)));
428
1
        m.remap_node_ids(ROOT, &delete_a());
429
1
        assert!(
430
1
            m.get_focused_node().is_none(),
431
            "focus on an unmounted node must be cleared, never retargeted"
432
        );
433
1
    }
434

            
435
    // -------------------------------------------------------------- text edit
436

            
437
    #[test]
438
1
    fn a_live_selection_stays_on_the_edited_element() {
439
1
        let cursor = TextCursor {
440
1
            cluster_id: GraphemeClusterId {
441
1
                source_run: 0,
442
1
                start_byte_in_run: 0,
443
1
            },
444
1
            affinity: CursorAffinity::Leading,
445
1
        };
446
1
        let mut m = TextEditManager::new();
447
1
        m.multi_cursor = Some(MultiCursorState::new_with_cursor(cursor, dom_node(C_OLD), 0));
448

            
449
1
        m.remap_node_ids(ROOT, &delete_a());
450

            
451
1
        let mc = m.multi_cursor.as_ref().expect("the editing session survives");
452
1
        assert_eq!(
453
1
            mc.node_id.node.into_crate_internal(),
454
            Some(C_NEW),
455
            "the caret must stay in the element the user is editing"
456
        );
457
1
        assert_eq!(mc.selections.len(), 1, "surviving node keeps its selections");
458

            
459
        // Editing a node that gets deleted ends the session (no retarget).
460
1
        let mut m = TextEditManager::new();
461
1
        m.multi_cursor = Some(MultiCursorState::new_with_cursor(cursor, dom_node(A), 0));
462
1
        m.remap_node_ids(ROOT, &delete_a());
463
1
        assert!(m.multi_cursor.is_none(), "editing an unmounted node must end the session");
464
1
    }
465

            
466
    // ----------------------------------------------------------------- drag
467

            
468
2
    fn node_drag(node: NodeId) -> DragContext {
469
2
        DragContext::node_drag(ROOT, node, LogicalPosition::zero(), DragData::default(), 1)
470
2
    }
471

            
472
    #[test]
473
1
    fn a_live_drag_keeps_dragging_the_same_element() {
474
1
        let mut m = GestureAndDragManager::new();
475
1
        m.active_drag = Some(node_drag(C_OLD));
476

            
477
1
        m.remap_node_ids(ROOT, &delete_a());
478

            
479
1
        assert!(
480
1
            m.is_node_dragging(ROOT, C_NEW),
481
            "the dragged element must still be the dragged element after the rebuild"
482
        );
483
1
        assert!(
484
1
            !m.is_node_dragging(ROOT, B_NEW),
485
            "the drag must NOT jump onto the sibling that inherited the old index"
486
        );
487

            
488
        // Dragging a node that gets deleted cancels the drag.
489
1
        let mut m = GestureAndDragManager::new();
490
1
        m.active_drag = Some(node_drag(A));
491
1
        m.remap_node_ids(ROOT, &delete_a());
492
1
        assert!(m.get_drag_context().is_none(), "a drag whose source vanished is cancelled");
493
1
    }
494

            
495
    // ----------------------------------------------------------- text input
496

            
497
    #[test]
498
1
    fn a_pending_text_edit_is_not_applied_to_the_wrong_node() {
499
1
        let mut m = TextInputManager::new();
500
1
        m.record_input(dom_node(C_OLD), "x".into(), String::new(), TextInputSource::Keyboard);
501
1
        m.remap_node_ids(ROOT, &delete_a());
502
1
        assert_eq!(
503
1
            m.get_pending_changeset()
504
1
                .and_then(|p| p.node.node.into_crate_internal()),
505
            Some(C_NEW),
506
            "the recorded edit must apply to the node it was recorded on"
507
        );
508

            
509
1
        let mut m = TextInputManager::new();
510
1
        m.record_input(dom_node(A), "x".into(), String::new(), TextInputSource::Keyboard);
511
1
        m.remap_node_ids(ROOT, &delete_a());
512
1
        assert!(
513
1
            m.get_pending_changeset().is_none(),
514
            "an edit recorded on an unmounted node must be dropped, not applied elsewhere"
515
        );
516
1
    }
517

            
518
    // ---------------------------------------------------------------- hover
519

            
520
    #[test]
521
1
    fn hover_history_hits_follow_their_nodes() {
522
3
        fn hit(depth: u32) -> HitTestItem {
523
3
            HitTestItem {
524
3
                point_in_viewport: LogicalPosition::zero(),
525
3
                point_relative_to_item: LogicalPosition::zero(),
526
3
                is_focusable: false,
527
3
                is_virtual_view_hit: None,
528
3
                hit_depth: depth,
529
3
            }
530
3
        }
531
1
        let mut ht = HitTest::empty();
532
1
        ht.regular_hit_test_nodes.insert(A, hit(1));
533
1
        ht.regular_hit_test_nodes.insert(B_OLD, hit(2));
534
1
        ht.regular_hit_test_nodes.insert(C_OLD, hit(3));
535
1
        let mut full = FullHitTest::empty(None);
536
1
        full.hovered_nodes.insert(ROOT, ht);
537

            
538
1
        let mut m = HoverManager::new();
539
1
        m.push_hit_test(InputPointId::Mouse, full);
540

            
541
1
        m.remap_node_ids(ROOT, &delete_a());
542

            
543
1
        let nodes = &m
544
1
            .get_current(&InputPointId::Mouse)
545
1
            .unwrap()
546
1
            .hovered_nodes[&ROOT]
547
1
            .regular_hit_test_nodes;
548
1
        assert_eq!(
549
1
            nodes.get(&C_NEW).map(|h| h.hit_depth),
550
            Some(3),
551
            "C's hit must follow C (an unremapped history hands B's hit back for C)"
552
        );
553
1
        assert_eq!(nodes.get(&B_NEW).map(|h| h.hit_depth), Some(2));
554
1
        assert_eq!(nodes.len(), 2, "the deleted node's hit must be GC'd");
555
1
    }
556

            
557
    // ------------------------------------------------------ cross-DOM safety
558

            
559
    #[test]
560
1
    fn state_belonging_to_another_dom_is_never_touched() {
561
1
        let other = DomId { inner: 7 };
562
1
        let mut m = ScrollManager::new();
563
1
        m.set_scroll_position_unclamped(other, C_OLD, LogicalPosition::new(0.0, 99.0), now());
564
1
        m.remap_node_ids(ROOT, &delete_a());
565
1
        assert_eq!(
566
1
            m.get_scroll_state(other, C_OLD).map(|s| s.current_offset.y),
567
            Some(99.0),
568
            "a reconciliation of DOM 0 says nothing about DOM 7"
569
        );
570

            
571
1
        let mut vv = VirtualViewManager::new();
572
1
        let nested = vv.get_or_create_nested_dom_id(other, C_OLD);
573
1
        vv.remap_node_ids(ROOT, &delete_a());
574
1
        assert_eq!(vv.get_nested_dom_id(other, C_OLD), Some(nested));
575
1
    }
576

            
577
    /// The map itself is the GC oracle: `node_moves` lists EVERY matched node, so
578
    /// an id missing from it is unmounted (not merely "unmoved").
579
    #[test]
580
1
    fn node_id_map_semantics() {
581
1
        let map = delete_a();
582
1
        assert_eq!(map.resolve(NodeId::new(0)), Some(NodeId::new(0)));
583
1
        assert_eq!(map.resolve(C_OLD), Some(C_NEW));
584
1
        assert!(map.is_unmounted(A));
585
1
        assert!(map.resolve(A).is_none());
586
1
        let _unused: &BTreeMap<NodeId, NodeId> = map.as_btree_map();
587
1
    }
588
}
589

            
590
// ============================================================================
591
// AUTOTEST: adversarial tests for `NodeIdMap`, `remap_keys`, `remap_dom_keys`
592
// ============================================================================
593
#[cfg(test)]
594
mod autotest_generated {
595
    use azul_core::diff::NodeMove;
596

            
597
    use super::*;
598

            
599
    const DOM0: DomId = DomId { inner: 0 };
600
    const DOM1: DomId = DomId { inner: 1 };
601
    /// A `DomId` at the top of the `usize` range — must be handled like any other.
602
    const DOM_MAX: DomId = DomId { inner: usize::MAX };
603

            
604
    fn nid(i: usize) -> NodeId {
605
        NodeId::new(i)
606
    }
607

            
608
    fn mv(old: usize, new: usize) -> NodeMove {
609
        NodeMove {
610
            old_node_id: nid(old),
611
            new_node_id: nid(new),
612
        }
613
    }
614

            
615
    /// `NodeHierarchyItemId` uses a 1-based encoding, so the largest node index
616
    /// that can round-trip through a `DomNodeId` is `usize::MAX - 1`
617
    /// (`from_crate_internal` computes `inner + 1`). Anything above that is not
618
    /// representable and is deliberately not exercised through `DomNodeId`.
619
    const MAX_ENCODABLE: usize = usize::MAX - 1;
620

            
621
    fn dom_node_at(dom: DomId, node: NodeId) -> DomNodeId {
622
        DomNodeId {
623
            dom,
624
            node: NodeHierarchyItemId::from_crate_internal(Some(node)),
625
        }
626
    }
627

            
628
    // ------------------------------------------------------ constructors
629

            
630
    #[test]
631
    fn from_node_moves_on_an_empty_slice_yields_an_empty_map() {
632
        let map = NodeIdMap::from_node_moves(&[]);
633
        assert!(map.is_empty());
634
        assert!(map.as_btree_map().is_empty());
635
        assert_eq!(map, NodeIdMap::default());
636
    }
637

            
638
    #[test]
639
    fn from_node_moves_agrees_with_from_pairs_on_the_same_data() {
640
        let moves = [mv(0, 0), mv(5, 3), mv(9, 9)];
641
        let from_moves = NodeIdMap::from_node_moves(&moves);
642
        let from_pairs =
643
            NodeIdMap::from_pairs([(nid(0), nid(0)), (nid(5), nid(3)), (nid(9), nid(9))]);
644
        assert_eq!(
645
            from_moves, from_pairs,
646
            "the two constructors must produce identical maps for identical data"
647
        );
648
    }
649

            
650
    /// A malformed `node_moves` slice (the same old id listed twice) must not
651
    /// panic and must resolve deterministically — `BTreeMap::collect` keeps the
652
    /// LAST entry.
653
    #[test]
654
    fn from_node_moves_with_a_duplicated_old_id_keeps_the_last_entry() {
655
        let map = NodeIdMap::from_node_moves(&[mv(1, 10), mv(1, 20), mv(1, 30)]);
656
        assert_eq!(map.as_btree_map().len(), 1, "duplicates collapse to one key");
657
        assert_eq!(
658
            map.resolve(nid(1)),
659
            Some(nid(30)),
660
            "the last NodeMove for an old id wins"
661
        );
662
    }
663

            
664
    /// Two old nodes mapped onto the SAME new id is nonsense input, but it must
665
    /// still build a well-formed (if lossy) map rather than panic.
666
    #[test]
667
    fn from_node_moves_with_a_non_injective_mapping_does_not_panic() {
668
        let map = NodeIdMap::from_node_moves(&[mv(1, 7), mv(2, 7)]);
669
        assert_eq!(map.as_btree_map().len(), 2, "both old ids are retained as keys");
670
        assert_eq!(map.resolve(nid(1)), Some(nid(7)));
671
        assert_eq!(map.resolve(nid(2)), Some(nid(7)));
672
    }
673

            
674
    /// `NodeId` wraps a `usize`; ids at the very top of the range are just
675
    /// indices as far as the map is concerned — no arithmetic, no overflow.
676
    #[test]
677
    fn from_node_moves_handles_extreme_node_ids() {
678
        let map = NodeIdMap::from_node_moves(&[
679
            mv(usize::MAX, usize::MAX),
680
            mv(usize::MAX - 1, 0),
681
            mv(0, usize::MAX),
682
        ]);
683
        assert_eq!(map.resolve(nid(usize::MAX)), Some(nid(usize::MAX)));
684
        assert_eq!(map.resolve(nid(usize::MAX - 1)), Some(nid(0)));
685
        assert_eq!(map.resolve(nid(0)), Some(nid(usize::MAX)));
686
        assert!(!map.is_empty());
687
    }
688

            
689
    #[test]
690
    fn from_pairs_on_an_empty_iterator_yields_an_empty_map() {
691
        let map = NodeIdMap::from_pairs(Vec::new());
692
        assert!(map.is_empty());
693
        assert!(map.resolve(NodeId::ZERO).is_none());
694
        assert!(map.is_unmounted(NodeId::ZERO));
695
    }
696

            
697
    #[test]
698
    fn from_pairs_with_a_duplicated_old_id_keeps_the_last_entry() {
699
        let map = NodeIdMap::from_pairs([(nid(4), nid(1)), (nid(4), nid(2))]);
700
        assert_eq!(map.as_btree_map().len(), 1);
701
        assert_eq!(map.resolve(nid(4)), Some(nid(2)));
702
    }
703

            
704
    /// Post-construction invariants at volume: every pair fed in resolves back
705
    /// out, the length matches the number of distinct old ids, and nothing that
706
    /// was never inserted resolves.
707
    #[test]
708
    fn from_pairs_invariants_hold_at_volume() {
709
        let n = 2048usize;
710
        let pairs: Vec<(NodeId, NodeId)> = (0..n).map(|i| (nid(i), nid(n - 1 - i))).collect();
711
        let map = NodeIdMap::from_pairs(pairs);
712

            
713
        assert_eq!(map.as_btree_map().len(), n);
714
        assert!(!map.is_empty());
715
        for i in 0..n {
716
            assert_eq!(map.resolve(nid(i)), Some(nid(n - 1 - i)));
717
            assert!(!map.is_unmounted(nid(i)));
718
        }
719
        assert!(map.resolve(nid(n)).is_none(), "an id never inserted is unmounted");
720
        assert!(map.is_unmounted(nid(n)));
721
    }
722

            
723
    /// Round-trip: `as_btree_map` is a faithful encoding of what went in, and
724
    /// feeding it back through `from_pairs` reproduces the map exactly.
725
    #[test]
726
    fn as_btree_map_round_trips_through_from_pairs() {
727
        let original = NodeIdMap::from_pairs([
728
            (nid(0), nid(0)),
729
            (nid(2), nid(1)),
730
            (nid(3), nid(2)),
731
            (nid(usize::MAX), nid(4)),
732
        ]);
733
        let decoded = NodeIdMap::from_pairs(
734
            original
735
                .as_btree_map()
736
                .iter()
737
                .map(|(old, new)| (*old, *new))
738
                .collect::<Vec<_>>(),
739
        );
740
        assert_eq!(decoded, original, "encode == decode");
741
        assert_eq!(decoded.as_btree_map(), original.as_btree_map());
742
    }
743

            
744
    #[test]
745
    fn a_default_map_unmounts_everything() {
746
        let map = NodeIdMap::default();
747
        assert!(map.is_empty());
748
        assert!(map.as_btree_map().is_empty());
749
        for i in [0usize, 1, 2, 1024, usize::MAX - 1, usize::MAX] {
750
            assert!(map.resolve(nid(i)).is_none());
751
            assert!(
752
                map.is_unmounted(nid(i)),
753
                "an empty reconciliation means every old node was unmounted"
754
            );
755
        }
756
    }
757

            
758
    // --------------------------------------------- resolve / is_unmounted
759

            
760
    /// The two accessors are two views of the same fact — they must never
761
    /// disagree, for any id, on any map.
762
    #[test]
763
    fn resolve_and_is_unmounted_never_disagree() {
764
        let map = NodeIdMap::from_pairs([(nid(0), nid(0)), (nid(2), nid(1)), (nid(3), nid(2))]);
765
        for i in [0usize, 1, 2, 3, 4, 100, usize::MAX - 1, usize::MAX] {
766
            assert_eq!(
767
                map.resolve(nid(i)).is_none(),
768
                map.is_unmounted(nid(i)),
769
                "is_unmounted({i}) must be exactly !resolve({i}).is_some()"
770
            );
771
        }
772
    }
773

            
774
    #[test]
775
    fn resolve_is_pure_repeated_calls_return_the_same_answer() {
776
        let map = NodeIdMap::from_pairs([(nid(3), nid(2))]);
777
        let first = map.resolve(nid(3));
778
        assert_eq!(first, map.resolve(nid(3)));
779
        assert_eq!(first, map.resolve(nid(3)));
780
        assert_eq!(first, Some(nid(2)));
781
    }
782

            
783
    /// `resolve` is a single lookup, NOT a transitive closure. If it chased
784
    /// chains, `1 → 2 → 3` would collapse and every remap would be wrong for
785
    /// any map whose new ids overlap its old ids (which is the normal case).
786
    #[test]
787
    fn resolve_does_not_chase_chains() {
788
        let map = NodeIdMap::from_pairs([(nid(1), nid(2)), (nid(2), nid(3))]);
789
        assert_eq!(
790
            map.resolve(nid(1)),
791
            Some(nid(2)),
792
            "resolve must apply exactly one hop"
793
        );
794
        assert_eq!(map.resolve(nid(2)), Some(nid(3)));
795
    }
796

            
797
    /// A node that kept its index is MATCHED, not unmounted — the whole GC rule
798
    /// depends on identity entries being present and meaningful.
799
    #[test]
800
    fn an_identity_entry_means_matched_not_unmounted() {
801
        let map = NodeIdMap::from_pairs([(nid(7), nid(7))]);
802
        assert!(!map.is_unmounted(nid(7)));
803
        assert_eq!(map.resolve(nid(7)), Some(nid(7)));
804
        assert!(map.is_unmounted(nid(6)));
805
        assert!(map.is_unmounted(nid(8)));
806
    }
807

            
808
    #[test]
809
    fn is_empty_is_exactly_the_btree_maps_emptiness() {
810
        let empty = NodeIdMap::from_pairs(Vec::new());
811
        assert_eq!(empty.is_empty(), empty.as_btree_map().is_empty());
812
        assert!(empty.is_empty());
813

            
814
        let full = NodeIdMap::from_pairs([(nid(0), nid(0))]);
815
        assert_eq!(full.is_empty(), full.as_btree_map().is_empty());
816
        assert!(!full.is_empty());
817
    }
818

            
819
    // ------------------------------------------------- resolve_dom_node_id
820

            
821
    /// The documented pass-through rule: a reconciliation of DOM 0 says NOTHING
822
    /// about DOM 1, so an id from another DOM must come back byte-identical —
823
    /// even when its node index happens to be unmounted in *this* map.
824
    #[test]
825
    fn a_foreign_dom_node_id_is_passed_through_untouched() {
826
        let map = NodeIdMap::from_pairs([(nid(2), nid(1))]);
827
        // Node 5 is "unmounted" as far as this map is concerned...
828
        let foreign = dom_node_at(DOM1, nid(5));
829
        assert_eq!(
830
            map.resolve_dom_node_id(DOM0, foreign),
831
            Some(foreign),
832
            "an id in another DOM must never be dropped by this DOM's reconciliation"
833
        );
834
        // ...and a foreign id whose index IS in the map must not be rewritten either.
835
        let foreign_colliding = dom_node_at(DOM1, nid(2));
836
        assert_eq!(
837
            map.resolve_dom_node_id(DOM0, foreign_colliding),
838
            Some(foreign_colliding),
839
            "a foreign id must not be remapped just because its index appears in the map"
840
        );
841
    }
842

            
843
    #[test]
844
    fn resolve_dom_node_id_rewrites_matched_nodes_and_drops_unmounted_ones() {
845
        let map = NodeIdMap::from_pairs([(nid(2), nid(1))]);
846
        assert_eq!(
847
            map.resolve_dom_node_id(DOM0, dom_node_at(DOM0, nid(2))),
848
            Some(dom_node_at(DOM0, nid(1)))
849
        );
850
        assert_eq!(
851
            map.resolve_dom_node_id(DOM0, dom_node_at(DOM0, nid(1))),
852
            None,
853
            "a node absent from the map is unmounted, so the id must be dropped"
854
        );
855
    }
856

            
857
    /// `NodeHierarchyItemId::NONE` decodes to `None`. For the reconciled DOM
858
    /// that means "no node" → `None`; for a foreign DOM the pass-through branch
859
    /// fires first, so it survives unchanged. Both are deterministic.
860
    #[test]
861
    fn a_none_node_id_is_handled_without_panicking() {
862
        let map = NodeIdMap::from_pairs([(nid(0), nid(0))]);
863
        let none_here = DomNodeId {
864
            dom: DOM0,
865
            node: NodeHierarchyItemId::NONE,
866
        };
867
        assert_eq!(map.resolve_dom_node_id(DOM0, none_here), None);
868

            
869
        let none_elsewhere = DomNodeId {
870
            dom: DOM1,
871
            node: NodeHierarchyItemId::NONE,
872
        };
873
        assert_eq!(
874
            map.resolve_dom_node_id(DOM0, none_elsewhere),
875
            Some(none_elsewhere),
876
            "the foreign-DOM pass-through happens before the node is decoded"
877
        );
878
    }
879

            
880
    /// Boundary ids on both axes: the largest encodable node index and the
881
    /// largest `DomId`. `MAX_ENCODABLE` maps to raw `usize::MAX` in the 1-based
882
    /// encoding, i.e. the last value that fits.
883
    #[test]
884
    fn resolve_dom_node_id_survives_boundary_ids() {
885
        let map = NodeIdMap::from_pairs([
886
            (nid(MAX_ENCODABLE), nid(0)),
887
            (nid(0), nid(MAX_ENCODABLE)),
888
        ]);
889

            
890
        // Largest encodable index as the OLD id.
891
        let big_old = dom_node_at(DOM0, nid(MAX_ENCODABLE));
892
        assert_eq!(big_old.node.into_raw(), usize::MAX, "1-based encoding is saturated");
893
        assert_eq!(
894
            map.resolve_dom_node_id(DOM0, big_old),
895
            Some(dom_node_at(DOM0, nid(0)))
896
        );
897

            
898
        // Largest encodable index as the NEW id (re-encoding must not overflow).
899
        assert_eq!(
900
            map.resolve_dom_node_id(DOM0, dom_node_at(DOM0, nid(0))),
901
            Some(dom_node_at(DOM0, nid(MAX_ENCODABLE)))
902
        );
903

            
904
        // An extreme DomId is still just a DomId.
905
        let far_dom = dom_node_at(DOM_MAX, nid(0));
906
        assert_eq!(
907
            map.resolve_dom_node_id(DOM0, far_dom),
908
            Some(far_dom),
909
            "DomId::MAX is foreign to DOM 0 and passes through"
910
        );
911
        assert_eq!(
912
            map.resolve_dom_node_id(DOM_MAX, far_dom),
913
            Some(dom_node_at(DOM_MAX, nid(MAX_ENCODABLE))),
914
            "when DomId::MAX *is* the reconciled DOM, its nodes are remapped"
915
        );
916
    }
917

            
918
    #[test]
919
    fn resolve_dom_node_id_on_an_empty_map_drops_own_dom_and_keeps_foreign() {
920
        let map = NodeIdMap::default();
921
        assert_eq!(map.resolve_dom_node_id(DOM0, dom_node_at(DOM0, nid(0))), None);
922
        let foreign = dom_node_at(DOM1, nid(0));
923
        assert_eq!(map.resolve_dom_node_id(DOM0, foreign), Some(foreign));
924
    }
925

            
926
    // ------------------------------------------------------- remap_keys
927

            
928
    /// The reason `remap_keys` takes the map out before rebuilding it: a SWAP
929
    /// (`1 → 2`, `2 → 1`) is a legal reconciliation, and an in-place rewrite
930
    /// would overwrite one payload with the other. Both payloads must survive,
931
    /// on the correct keys.
932
    #[test]
933
    fn remap_keys_handles_a_swap_without_clobbering_payloads() {
934
        let mut map: BTreeMap<NodeId, &str> = BTreeMap::new();
935
        map.insert(nid(1), "one");
936
        map.insert(nid(2), "two");
937
        let node_map = NodeIdMap::from_pairs([(nid(1), nid(2)), (nid(2), nid(1))]);
938

            
939
        remap_keys(&mut map, &node_map);
940

            
941
        assert_eq!(map.len(), 2, "a swap must not lose an entry");
942
        assert_eq!(map.get(&nid(1)).copied(), Some("two"));
943
        assert_eq!(map.get(&nid(2)).copied(), Some("one"));
944
    }
945

            
946
    /// The GC half of the contract: keys absent from the map are unmounted and
947
    /// must be dropped, never kept "just in case".
948
    #[test]
949
    fn remap_keys_drops_state_for_unmounted_nodes() {
950
        let mut map: BTreeMap<NodeId, u32> = BTreeMap::new();
951
        map.insert(nid(1), 10);
952
        map.insert(nid(2), 20);
953
        map.insert(nid(3), 30);
954
        let node_map = NodeIdMap::from_pairs([(nid(2), nid(1)), (nid(3), nid(2))]);
955

            
956
        remap_keys(&mut map, &node_map);
957

            
958
        assert_eq!(map.len(), 2, "node 1's state must be GC'd");
959
        assert_eq!(map.get(&nid(1)).copied(), Some(20));
960
        assert_eq!(map.get(&nid(2)).copied(), Some(30));
961
        assert!(!map.contains_key(&nid(3)), "no state may remain at a dead index");
962
    }
963

            
964
    #[test]
965
    fn remap_keys_with_an_empty_node_map_clears_all_state() {
966
        let mut map: BTreeMap<NodeId, u32> = BTreeMap::new();
967
        map.insert(nid(0), 1);
968
        map.insert(nid(9), 2);
969

            
970
        remap_keys(&mut map, &NodeIdMap::default());
971

            
972
        assert!(
973
            map.is_empty(),
974
            "an empty reconciliation unmounts everything, so all state is dropped"
975
        );
976
    }
977

            
978
    #[test]
979
    fn remap_keys_with_an_identity_map_is_a_no_op() {
980
        let mut map: BTreeMap<NodeId, u32> = (0..64).map(|i| (nid(i), i as u32)).collect();
981
        let before = map.clone();
982
        let node_map = NodeIdMap::from_pairs((0..64).map(|i| (nid(i), nid(i))));
983

            
984
        remap_keys(&mut map, &node_map);
985

            
986
        assert_eq!(map, before);
987
    }
988

            
989
    #[test]
990
    fn remap_keys_on_an_empty_map_does_not_panic() {
991
        let mut map: BTreeMap<NodeId, u32> = BTreeMap::new();
992
        remap_keys(&mut map, &NodeIdMap::from_pairs([(nid(1), nid(0))]));
993
        assert!(map.is_empty());
994
    }
995

            
996
    /// A non-injective reconciliation (`1 → 5` and `2 → 5`) cannot be
997
    /// represented by a map keyed on `NodeId` — one entry must win. Assert the
998
    /// outcome is deterministic (source keys are visited in ascending order, so
999
    /// the HIGHEST old id lands last) rather than a panic or a silent duplicate.
    #[test]
    fn remap_keys_collision_is_lossy_but_deterministic() {
        let mut map: BTreeMap<NodeId, &str> = BTreeMap::new();
        map.insert(nid(1), "from-1");
        map.insert(nid(2), "from-2");
        let node_map = NodeIdMap::from_pairs([(nid(1), nid(5)), (nid(2), nid(5))]);
        remap_keys(&mut map, &node_map);
        assert_eq!(map.len(), 1, "two old keys collapsing onto one new key lose one entry");
        assert_eq!(
            map.get(&nid(5)).copied(),
            Some("from-2"),
            "the last-visited (highest) old id wins — deterministic, not arbitrary"
        );
    }
    /// The preceding-sibling delete at scale: deleting node 1 of 256 shifts every
    /// later index down by one. Every payload must land on exactly the key that
    /// now denotes its element — an off-by-one here is the misattachment bug the
    /// module doc-comment describes.
    #[test]
    fn remap_keys_preserves_payload_identity_under_a_shift_down() {
        let n = 256usize;
        let mut map: BTreeMap<NodeId, usize> = (0..n).map(|i| (nid(i), i * 1000)).collect();
        // 0 stays, 1 is deleted, 2..n shift down by one.
        let node_map = NodeIdMap::from_pairs(
            core::iter::once((nid(0), nid(0))).chain((2..n).map(|i| (nid(i), nid(i - 1)))),
        );
        remap_keys(&mut map, &node_map);
        assert_eq!(map.len(), n - 1, "exactly the deleted node's state is GC'd");
        assert_eq!(map.get(&nid(0)).copied(), Some(0));
        for i in 2..n {
            assert_eq!(
                map.get(&nid(i - 1)).copied(),
                Some(i * 1000),
                "node {i}'s payload must follow it to index {}",
                i - 1
            );
        }
        assert!(!map.contains_key(&nid(n - 1)), "the vacated tail index is empty");
    }
    /// Ordering trap: an entry is rewritten ONTO an index that another (about to
    /// be dropped) entry currently occupies. Because the source map is taken out
    /// first, the survivor must not be eaten by the corpse.
    #[test]
    fn remap_keys_survivor_moving_onto_a_dead_index_is_not_dropped() {
        let mut map: BTreeMap<NodeId, &str> = BTreeMap::new();
        map.insert(nid(1), "doomed");
        map.insert(nid(2), "survivor");
        // Node 1 is unmounted; node 2 moves into its slot.
        let node_map = NodeIdMap::from_pairs([(nid(2), nid(1))]);
        remap_keys(&mut map, &node_map);
        assert_eq!(map.len(), 1);
        assert_eq!(
            map.get(&nid(1)).copied(),
            Some("survivor"),
            "the surviving payload occupies the recycled index — the dead one is gone"
        );
    }
    // --------------------------------------------------- remap_dom_keys
    #[test]
    fn remap_dom_keys_never_touches_another_dom() {
        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
        map.insert((DOM0, nid(2)), 20);
        // Same node index, different DOM — must survive verbatim.
        map.insert((DOM1, nid(2)), 99);
        // A foreign entry at an index that is unmounted in DOM 0.
        map.insert((DOM1, nid(1)), 98);
        let node_map = NodeIdMap::from_pairs([(nid(2), nid(1))]);
        remap_dom_keys(&mut map, DOM0, &node_map);
        assert_eq!(map.get(&(DOM0, nid(1))).copied(), Some(20), "DOM 0's entry is remapped");
        assert!(!map.contains_key(&(DOM0, nid(2))), "the old DOM 0 key is gone");
        assert_eq!(
            map.get(&(DOM1, nid(2))).copied(),
            Some(99),
            "DOM 1 is untouched by a DOM 0 reconciliation"
        );
        assert_eq!(map.get(&(DOM1, nid(1))).copied(), Some(98));
        assert_eq!(map.len(), 3);
    }
    #[test]
    fn remap_dom_keys_drops_unmounted_entries_of_the_target_dom_only() {
        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
        map.insert((DOM0, nid(1)), 10); // unmounted in DOM 0 -> dropped
        map.insert((DOM1, nid(1)), 11); // same index, other DOM -> kept
        let node_map = NodeIdMap::from_pairs([(nid(0), nid(0))]);
        remap_dom_keys(&mut map, DOM0, &node_map);
        assert!(!map.contains_key(&(DOM0, nid(1))));
        assert_eq!(map.get(&(DOM1, nid(1))).copied(), Some(11));
        assert_eq!(map.len(), 1);
    }
    #[test]
    fn remap_dom_keys_handles_a_swap_within_the_target_dom() {
        let mut map: BTreeMap<(DomId, NodeId), &str> = BTreeMap::new();
        map.insert((DOM0, nid(1)), "one");
        map.insert((DOM0, nid(2)), "two");
        map.insert((DOM1, nid(1)), "other-dom");
        let node_map = NodeIdMap::from_pairs([(nid(1), nid(2)), (nid(2), nid(1))]);
        remap_dom_keys(&mut map, DOM0, &node_map);
        assert_eq!(map.get(&(DOM0, nid(1))).copied(), Some("two"));
        assert_eq!(map.get(&(DOM0, nid(2))).copied(), Some("one"));
        assert_eq!(map.get(&(DOM1, nid(1))).copied(), Some("other-dom"));
        assert_eq!(map.len(), 3, "a swap plus a bystander DOM loses nothing");
    }
    #[test]
    fn remap_dom_keys_with_an_empty_node_map_clears_only_the_target_dom() {
        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
        map.insert((DOM0, nid(0)), 1);
        map.insert((DOM0, nid(5)), 2);
        map.insert((DOM1, nid(0)), 3);
        remap_dom_keys(&mut map, DOM0, &NodeIdMap::default());
        assert_eq!(map.len(), 1, "every DOM 0 node was unmounted");
        assert_eq!(map.get(&(DOM1, nid(0))).copied(), Some(3));
    }
    #[test]
    fn remap_dom_keys_on_an_empty_map_does_not_panic() {
        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
        remap_dom_keys(&mut map, DOM0, &NodeIdMap::from_pairs([(nid(1), nid(0))]));
        assert!(map.is_empty());
    }
    #[test]
    fn remap_dom_keys_with_a_target_dom_that_has_no_entries_is_a_no_op() {
        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
        map.insert((DOM1, nid(1)), 1);
        map.insert((DOM_MAX, nid(2)), 2);
        let before = map.clone();
        remap_dom_keys(&mut map, DOM0, &NodeIdMap::from_pairs([(nid(1), nid(9))]));
        assert_eq!(map, before);
    }
    #[test]
    fn remap_dom_keys_handles_boundary_dom_and_node_ids() {
        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
        map.insert((DOM_MAX, nid(usize::MAX)), 1);
        map.insert((DOM_MAX, nid(usize::MAX - 1)), 2);
        map.insert((DOM0, nid(usize::MAX)), 3);
        let node_map = NodeIdMap::from_pairs([(nid(usize::MAX), nid(0))]);
        remap_dom_keys(&mut map, DOM_MAX, &node_map);
        assert_eq!(
            map.get(&(DOM_MAX, nid(0))).copied(),
            Some(1),
            "usize::MAX remaps like any other index"
        );
        assert!(
            !map.contains_key(&(DOM_MAX, nid(usize::MAX - 1))),
            "unmounted in DOM_MAX -> dropped"
        );
        assert_eq!(
            map.get(&(DOM0, nid(usize::MAX))).copied(),
            Some(3),
            "DOM 0 is a bystander here"
        );
        assert_eq!(map.len(), 2);
    }
    /// Applying the SAME reconciliation twice is not idempotent in general (the
    /// second pass re-reads already-new ids as if they were old), which is why
    /// callers must run it exactly once per rebuild. Pin the one case that IS
    /// safe — the identity map — so the no-op guarantee cannot regress.
    #[test]
    fn remap_dom_keys_with_an_identity_map_is_a_no_op_even_when_repeated() {
        let mut map: BTreeMap<(DomId, NodeId), u32> =
            (0..32).map(|i| ((DOM0, nid(i)), i as u32)).collect();
        let before = map.clone();
        let node_map = NodeIdMap::from_pairs((0..32).map(|i| (nid(i), nid(i))));
        remap_dom_keys(&mut map, DOM0, &node_map);
        remap_dom_keys(&mut map, DOM0, &node_map);
        assert_eq!(map, before);
    }
}