Skip to main content

azul_layout/managers/
mod.rs

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
28pub mod a11y;
29pub mod biometric;
30pub mod changeset;
31pub mod clipboard;
32pub mod drag_drop;
33pub mod file_drop;
34pub mod focus_cursor;
35pub mod gamepad;
36pub mod geolocation;
37pub mod gesture;
38pub mod gpu_state;
39pub mod hover;
40pub mod keyring;
41pub mod permission;
42pub mod virtual_view;
43pub mod scroll_into_view;
44pub mod scroll_state;
45pub mod selection;
46pub mod sensors;
47pub mod text_edit;
48pub mod text_input;
49pub mod undo_redo;
50
51use alloc::collections::BTreeMap;
52
53use azul_core::dom::{DomId, DomNodeId, NodeId};
54use azul_core::styled_dom::NodeHierarchyItemId;
55
56/// The result of a DOM reconciliation, from the point of view of anyone holding
57/// `NodeId`-keyed state for a single DOM.
58///
59/// Built from `azul_core::diff::DiffResult::node_moves`, which contains an entry
60/// for EVERY matched node (including nodes that kept their index). The absence
61/// of an old `NodeId` from the map therefore has a precise meaning: that node was
62/// **unmounted**. This is what makes GC possible without a second "alive" set.
63///
64/// The contract for consumers is a single rule:
65///
66/// * [`NodeIdMap::resolve`] returns `Some(new_id)` — the node survived, rewrite the key.
67/// * [`NodeIdMap::resolve`] returns `None` — the node is GONE, **drop the state**.
68///
69/// Never "keep it, just in case": a kept key is a key that now denotes a
70/// different node.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct NodeIdMap {
73    moves: BTreeMap<NodeId, NodeId>,
74}
75
76impl NodeIdMap {
77    /// Build from reconciliation output (`DiffResult::node_moves`).
78    #[must_use]
79    pub fn from_node_moves(node_moves: &[azul_core::diff::NodeMove]) -> Self {
80        Self {
81            moves: node_moves
82                .iter()
83                .map(|m| (m.old_node_id, m.new_node_id))
84                .collect(),
85        }
86    }
87
88    /// Build from raw `(old, new)` pairs — used by tests and by callers that
89    /// already computed a migration map.
90    #[must_use]
91    pub fn from_pairs<I: IntoIterator<Item = (NodeId, NodeId)>>(pairs: I) -> Self {
92        Self {
93            moves: pairs.into_iter().collect(),
94        }
95    }
96
97    /// `Some(new_id)` if the node survived the rebuild, `None` if it was unmounted.
98    #[must_use]
99    pub fn resolve(&self, old: NodeId) -> Option<NodeId> {
100        self.moves.get(&old).copied()
101    }
102
103    /// `true` if `old` no longer exists in the new DOM.
104    #[must_use]
105    pub fn is_unmounted(&self, old: NodeId) -> bool {
106        !self.moves.contains_key(&old)
107    }
108
109    /// Resolve a full `DomNodeId`. Ids belonging to a *different* DOM are passed
110    /// through untouched (this reconciliation says nothing about them).
111    #[must_use]
112    pub fn resolve_dom_node_id(&self, dom: DomId, id: DomNodeId) -> Option<DomNodeId> {
113        if id.dom != dom {
114            return Some(id);
115        }
116        let old = id.node.into_crate_internal()?;
117        let new = self.resolve(old)?;
118        Some(DomNodeId {
119            dom,
120            node: NodeHierarchyItemId::from_crate_internal(Some(new)),
121        })
122    }
123
124    /// The raw old→new map, for `azul_core` APIs that take a `BTreeMap`
125    /// (`DragContext::remap_node_ids`, `MultiCursorState::remap_node_ids`).
126    #[must_use]
127    pub const fn as_btree_map(&self) -> &BTreeMap<NodeId, NodeId> {
128        &self.moves
129    }
130
131    /// No matched nodes at all (everything was unmounted / the DOM is brand new).
132    #[must_use]
133    pub fn is_empty(&self) -> bool {
134        self.moves.is_empty()
135    }
136}
137
138/// Implemented by EVERY manager (or cache) that keys state by `NodeId`.
139///
140/// One method on purpose: remapping and GC are the same pass, so it is
141/// impossible to do one and forget the other. Implementors MUST, for state
142/// belonging to `dom`:
143///
144/// 1. rewrite each key/field `old` to `map.resolve(old)`, and
145/// 2. **drop** the state whenever `resolve` returns `None` (unmounted node).
146///
147/// State belonging to any *other* `DomId` must be left alone.
148pub trait NodeIdRemap {
149    /// Rewrite all `NodeId`s for `dom` and drop state for unmounted nodes.
150    fn remap_node_ids(&mut self, dom: DomId, map: &NodeIdMap);
151}
152
153/// Remap the keys of a `BTreeMap<NodeId, V>` in place, dropping unmounted nodes.
154pub(crate) fn remap_keys<V>(map: &mut BTreeMap<NodeId, V>, node_map: &NodeIdMap) {
155    let old = core::mem::take(map);
156    for (old_id, v) in old {
157        if let Some(new_id) = node_map.resolve(old_id) {
158            map.insert(new_id, v);
159        }
160    }
161}
162
163/// Remap the keys of a `BTreeMap<(DomId, NodeId), V>` in place: entries for
164/// `dom` are rewritten (or dropped if unmounted), entries for other DOMs are
165/// left untouched.
166pub(crate) fn remap_dom_keys<V>(
167    map: &mut BTreeMap<(DomId, NodeId), V>,
168    dom: DomId,
169    node_map: &NodeIdMap,
170) {
171    let old = core::mem::take(map);
172    for ((d, old_id), v) in old {
173        if d != dom {
174            map.insert((d, old_id), v);
175        } else if let Some(new_id) = node_map.resolve(old_id) {
176            map.insert((d, new_id), v);
177        }
178    }
179}
180
181// ============================================================================
182// THE PRECEDING-SIBLING TEST
183// ============================================================================
184//
185// These tests encode the failure mode that motivated `NodeIdRemap`. They do NOT
186// assert "no panic" — an unremapped manager never panics, that is exactly what
187// made this bug survive. They assert LOGICAL IDENTITY: after the rebuild, every
188// manager's state must still describe the SAME ELEMENT it described before.
189//
190// Scenario (one DOM, four nodes):
191//
192//     before:  0=root  1=A   2=B   3=C
193//     delete A
194//     after:   0=root        1=B   2=C          map = {0→0, 2→1, 3→2}
195//
196// State is seeded on B(2) and C(3) with DISTINGUISHABLE payloads, and on the
197// doomed A(1). A manager that skips the remap keeps C's state at key 3 and
198// leaves B's state at key 2 — but index 2 now denotes C. So the state is not
199// dangling, it is MISATTACHED: "give me C's state" silently answers with B's.
200// Asserting `state_at(2) == C_payload` is what catches that; a null/panic check
201// does not.
202#[cfg(all(test, feature = "std"))]
203mod preceding_sibling_remap_tests {
204    use alloc::collections::BTreeMap;
205
206    use azul_core::{
207        dom::{DomId, DomNodeId, NodeId},
208        drag::{DragContext, DragData},
209        geom::LogicalPosition,
210        hit_test::{FullHitTest, HitTest, HitTestItem},
211        selection::{CursorAffinity, GraphemeClusterId, MultiCursorState, TextCursor},
212        styled_dom::NodeHierarchyItemId,
213        task::{Instant, SystemTick},
214    };
215
216    use super::{
217        changeset::{TextChangeset, TextOpInsertText, TextOperation},
218        focus_cursor::FocusManager,
219        gesture::GestureAndDragManager,
220        gpu_state::GpuStateManager,
221        hover::{HoverManager, InputPointId},
222        scroll_state::ScrollManager,
223        text_edit::TextEditManager,
224        text_input::{TextInputManager, TextInputSource},
225        undo_redo::{NodeStateSnapshot, UndoRedoManager},
226        virtual_view::VirtualViewManager,
227        NodeIdMap, NodeIdRemap,
228    };
229
230    const ROOT: DomId = DomId { inner: 0 };
231    /// The node that gets deleted.
232    const A: NodeId = NodeId::new(1);
233    /// Surviving sibling, index 2 → 1.
234    const B_OLD: NodeId = NodeId::new(2);
235    const B_NEW: NodeId = NodeId::new(1);
236    /// Surviving sibling, index 3 → 2.
237    const C_OLD: NodeId = NodeId::new(3);
238    const C_NEW: NodeId = NodeId::new(2);
239
240    /// Exactly what `reconcile_dom` produces when the preceding sibling A is
241    /// deleted: every MATCHED node, with A absent (= unmounted).
242    fn delete_a() -> NodeIdMap {
243        NodeIdMap::from_pairs([
244            (NodeId::new(0), NodeId::new(0)),
245            (B_OLD, B_NEW),
246            (C_OLD, C_NEW),
247        ])
248    }
249
250    fn now() -> Instant {
251        Instant::Tick(SystemTick { tick_counter: 0 })
252    }
253
254    fn dom_node(node: NodeId) -> DomNodeId {
255        DomNodeId {
256            dom: ROOT,
257            node: NodeHierarchyItemId::from_crate_internal(Some(node)),
258        }
259    }
260
261    // ---------------------------------------------------------------- scroll
262
263    #[test]
264    fn scroll_offsets_follow_their_node_across_a_preceding_sibling_delete() {
265        let mut m = ScrollManager::new();
266        // Distinguishable payloads: y = 10 for A, 20 for B, 30 for C.
267        m.set_scroll_position_unclamped(ROOT, A, LogicalPosition::new(0.0, 10.0), now());
268        m.set_scroll_position_unclamped(ROOT, B_OLD, LogicalPosition::new(0.0, 20.0), now());
269        m.set_scroll_position_unclamped(ROOT, C_OLD, LogicalPosition::new(0.0, 30.0), now());
270
271        m.remap_node_ids(ROOT, &delete_a());
272
273        // C is now node 2 and MUST still have C's offset (30) — not B's (20),
274        // which is what an unremapped manager would answer here.
275        assert_eq!(
276            m.get_scroll_state(ROOT, C_NEW).map(|s| s.current_offset.y),
277            Some(30.0),
278            "C's scroll offset must follow C to its new NodeId"
279        );
280        assert_eq!(
281            m.get_scroll_state(ROOT, B_NEW).map(|s| s.current_offset.y),
282            Some(20.0),
283            "B's scroll offset must follow B to its new NodeId"
284        );
285        // GC: the deleted node's state must not linger. NodeId(3) no longer exists.
286        assert!(
287            m.get_scroll_state(ROOT, NodeId::new(3)).is_none(),
288            "no state may remain at a NodeId that no longer exists"
289        );
290        assert_eq!(m.get_scroll_states_for_dom(ROOT).len(), 2, "A's state must be GC'd");
291    }
292
293    // ------------------------------------------------------------- undo/redo
294
295    fn undo_op(changeset_id: usize, node: NodeId, text: &str) -> super::undo_redo::UndoableOperation {
296        super::undo_redo::UndoableOperation {
297            changeset: TextChangeset {
298                id: changeset_id,
299                target: dom_node(node),
300                operation: TextOperation::InsertText(TextOpInsertText {
301                    text: text.into(),
302                    position: azul_core::window::CursorPosition::Uninitialized,
303                    new_cursor: azul_core::window::CursorPosition::Uninitialized,
304                }),
305                timestamp: now(),
306            },
307            pre_state: NodeStateSnapshot {
308                node_id: node,
309                text_content: text.into(),
310                cursor_position: None.into(),
311                selection_range: None.into(),
312                timestamp: now(),
313            },
314        }
315    }
316
317    #[test]
318    fn undo_history_stays_attached_to_the_same_element() {
319        let mut m = UndoRedoManager::new();
320        let a = undo_op(1, A, "typed-into-A");
321        let b = undo_op(2, B_OLD, "typed-into-B");
322        let c = undo_op(3, C_OLD, "typed-into-C");
323        m.record_operation(a.changeset.clone(), a.pre_state.clone());
324        m.record_operation(b.changeset.clone(), b.pre_state.clone());
325        m.record_operation(c.changeset.clone(), c.pre_state.clone());
326
327        m.remap_node_ids(ROOT, &delete_a());
328
329        // THE bug: undoing "on C" must revert C's edit, not B's.
330        let undo_on_c = m.peek_undo(C_NEW).expect("C must still have undo history");
331        assert_eq!(
332            undo_on_c.pre_state.text_content.as_str(),
333            "typed-into-C",
334            "undo on C must revert C's edit — an unremapped Vec re-attaches B's history here"
335        );
336        let undo_on_b = m.peek_undo(B_NEW).expect("B must still have undo history");
337        assert_eq!(undo_on_b.pre_state.text_content.as_str(), "typed-into-B");
338
339        // The embedded NodeIds must be rewritten too, or the *replay* targets the wrong node.
340        assert_eq!(
341            undo_on_c.changeset.target.node.into_crate_internal(),
342            Some(C_NEW)
343        );
344        assert_eq!(undo_on_c.pre_state.node_id, C_NEW);
345
346        // GC: A is gone, its history must be gone.
347        assert_eq!(m.node_stacks.len(), 2, "the deleted node's undo stack must be GC'd");
348        assert!(!m.can_undo(NodeId::new(3)), "no history at a NodeId that no longer exists");
349    }
350
351    // ----------------------------------------------------------- virtual view
352
353    #[test]
354    fn virtual_view_nested_doms_stay_with_their_host_node() {
355        let mut m = VirtualViewManager::new();
356        let dom_a = m.get_or_create_nested_dom_id(ROOT, A);
357        let dom_b = m.get_or_create_nested_dom_id(ROOT, B_OLD);
358        let dom_c = m.get_or_create_nested_dom_id(ROOT, C_OLD);
359        assert_ne!(dom_b, dom_c);
360
361        m.remap_node_ids(ROOT, &delete_a());
362
363        assert_eq!(
364            m.get_nested_dom_id(ROOT, C_NEW),
365            Some(dom_c),
366            "C's nested DOM must follow C — otherwise C renders B's virtual view"
367        );
368        assert_eq!(m.get_nested_dom_id(ROOT, B_NEW), Some(dom_b));
369        assert_eq!(m.debug_counts(), 2, "the deleted view's state must be GC'd");
370        assert!(!m.all_view_keys().iter().any(|(_, n)| *n == C_OLD));
371        assert_ne!(m.get_nested_dom_id(ROOT, B_NEW), Some(dom_a));
372    }
373
374    // -------------------------------------------------------------- gpu state
375
376    #[test]
377    fn gpu_transform_keys_stay_with_their_node() {
378        use azul_core::resources::{OpacityKey, TransformKey};
379        let mut m = GpuStateManager::default();
380        {
381            let cache = m.get_or_create_cache(ROOT);
382            cache.opacity_keys.insert(A, OpacityKey::unique());
383            cache.current_opacity_values.insert(A, 0.1);
384            cache.current_opacity_values.insert(B_OLD, 0.2);
385            cache.current_opacity_values.insert(C_OLD, 0.3);
386            cache.css_transform_keys.insert(C_OLD, TransformKey::unique());
387        }
388        let c_key = m.get_cache(ROOT).unwrap().css_transform_keys[&C_OLD];
389
390        m.remap_node_ids(ROOT, &delete_a());
391
392        let cache = m.get_cache(ROOT).unwrap();
393        assert_eq!(
394            cache.current_opacity_values.get(&C_NEW).copied(),
395            Some(0.3),
396            "C's opacity must follow C, not be inherited from B"
397        );
398        assert_eq!(cache.current_opacity_values.get(&B_NEW).copied(), Some(0.2));
399        assert_eq!(
400            cache.css_transform_keys.get(&C_NEW).copied(),
401            Some(c_key),
402            "C's GPU transform key must follow C"
403        );
404        assert!(cache.opacity_keys.is_empty(), "the deleted node's GPU keys must be GC'd");
405        assert_eq!(cache.current_opacity_values.len(), 2);
406    }
407
408    // ------------------------------------------------------------------ focus
409
410    #[test]
411    fn focus_follows_its_node_and_is_cleared_when_the_node_dies() {
412        let mut m = FocusManager::new();
413        m.set_focused_node(Some(dom_node(C_OLD)));
414        m.remap_node_ids(ROOT, &delete_a());
415        assert_eq!(
416            m.get_focused_node().and_then(|f| f.node.into_crate_internal()),
417            Some(C_NEW),
418            "focus must follow the focused element, not stay on a recycled index"
419        );
420
421        let mut m = FocusManager::new();
422        m.set_focused_node(Some(dom_node(A)));
423        m.remap_node_ids(ROOT, &delete_a());
424        assert!(
425            m.get_focused_node().is_none(),
426            "focus on an unmounted node must be cleared, never retargeted"
427        );
428    }
429
430    // -------------------------------------------------------------- text edit
431
432    #[test]
433    fn a_live_selection_stays_on_the_edited_element() {
434        let cursor = TextCursor {
435            cluster_id: GraphemeClusterId {
436                source_run: 0,
437                start_byte_in_run: 0,
438            },
439            affinity: CursorAffinity::Leading,
440        };
441        let mut m = TextEditManager::new();
442        m.multi_cursor = Some(MultiCursorState::new_with_cursor(cursor, dom_node(C_OLD), 0));
443
444        m.remap_node_ids(ROOT, &delete_a());
445
446        let mc = m.multi_cursor.as_ref().expect("the editing session survives");
447        assert_eq!(
448            mc.node_id.node.into_crate_internal(),
449            Some(C_NEW),
450            "the caret must stay in the element the user is editing"
451        );
452        assert_eq!(mc.selections.len(), 1, "surviving node keeps its selections");
453
454        // Editing a node that gets deleted ends the session (no retarget).
455        let mut m = TextEditManager::new();
456        m.multi_cursor = Some(MultiCursorState::new_with_cursor(cursor, dom_node(A), 0));
457        m.remap_node_ids(ROOT, &delete_a());
458        assert!(m.multi_cursor.is_none(), "editing an unmounted node must end the session");
459    }
460
461    // ----------------------------------------------------------------- drag
462
463    fn node_drag(node: NodeId) -> DragContext {
464        DragContext::node_drag(ROOT, node, LogicalPosition::zero(), DragData::default(), 1)
465    }
466
467    #[test]
468    fn a_live_drag_keeps_dragging_the_same_element() {
469        let mut m = GestureAndDragManager::new();
470        m.active_drag = Some(node_drag(C_OLD));
471
472        m.remap_node_ids(ROOT, &delete_a());
473
474        assert!(
475            m.is_node_dragging(ROOT, C_NEW),
476            "the dragged element must still be the dragged element after the rebuild"
477        );
478        assert!(
479            !m.is_node_dragging(ROOT, B_NEW),
480            "the drag must NOT jump onto the sibling that inherited the old index"
481        );
482
483        // Dragging a node that gets deleted cancels the drag.
484        let mut m = GestureAndDragManager::new();
485        m.active_drag = Some(node_drag(A));
486        m.remap_node_ids(ROOT, &delete_a());
487        assert!(m.get_drag_context().is_none(), "a drag whose source vanished is cancelled");
488    }
489
490    // ----------------------------------------------------------- text input
491
492    #[test]
493    fn a_pending_text_edit_is_not_applied_to_the_wrong_node() {
494        let mut m = TextInputManager::new();
495        m.record_input(dom_node(C_OLD), "x".into(), String::new(), TextInputSource::Keyboard);
496        m.remap_node_ids(ROOT, &delete_a());
497        assert_eq!(
498            m.get_pending_changeset()
499                .and_then(|p| p.node.node.into_crate_internal()),
500            Some(C_NEW),
501            "the recorded edit must apply to the node it was recorded on"
502        );
503
504        let mut m = TextInputManager::new();
505        m.record_input(dom_node(A), "x".into(), String::new(), TextInputSource::Keyboard);
506        m.remap_node_ids(ROOT, &delete_a());
507        assert!(
508            m.get_pending_changeset().is_none(),
509            "an edit recorded on an unmounted node must be dropped, not applied elsewhere"
510        );
511    }
512
513    // ---------------------------------------------------------------- hover
514
515    #[test]
516    fn hover_history_hits_follow_their_nodes() {
517        fn hit(depth: u32) -> HitTestItem {
518            HitTestItem {
519                point_in_viewport: LogicalPosition::zero(),
520                point_relative_to_item: LogicalPosition::zero(),
521                is_focusable: false,
522                is_virtual_view_hit: None,
523                hit_depth: depth,
524            }
525        }
526        let mut ht = HitTest::empty();
527        ht.regular_hit_test_nodes.insert(A, hit(1));
528        ht.regular_hit_test_nodes.insert(B_OLD, hit(2));
529        ht.regular_hit_test_nodes.insert(C_OLD, hit(3));
530        let mut full = FullHitTest::empty(None);
531        full.hovered_nodes.insert(ROOT, ht);
532
533        let mut m = HoverManager::new();
534        m.push_hit_test(InputPointId::Mouse, full);
535
536        m.remap_node_ids(ROOT, &delete_a());
537
538        let nodes = &m
539            .get_current(&InputPointId::Mouse)
540            .unwrap()
541            .hovered_nodes[&ROOT]
542            .regular_hit_test_nodes;
543        assert_eq!(
544            nodes.get(&C_NEW).map(|h| h.hit_depth),
545            Some(3),
546            "C's hit must follow C (an unremapped history hands B's hit back for C)"
547        );
548        assert_eq!(nodes.get(&B_NEW).map(|h| h.hit_depth), Some(2));
549        assert_eq!(nodes.len(), 2, "the deleted node's hit must be GC'd");
550    }
551
552    // ------------------------------------------------------ cross-DOM safety
553
554    #[test]
555    fn state_belonging_to_another_dom_is_never_touched() {
556        let other = DomId { inner: 7 };
557        let mut m = ScrollManager::new();
558        m.set_scroll_position_unclamped(other, C_OLD, LogicalPosition::new(0.0, 99.0), now());
559        m.remap_node_ids(ROOT, &delete_a());
560        assert_eq!(
561            m.get_scroll_state(other, C_OLD).map(|s| s.current_offset.y),
562            Some(99.0),
563            "a reconciliation of DOM 0 says nothing about DOM 7"
564        );
565
566        let mut vv = VirtualViewManager::new();
567        let nested = vv.get_or_create_nested_dom_id(other, C_OLD);
568        vv.remap_node_ids(ROOT, &delete_a());
569        assert_eq!(vv.get_nested_dom_id(other, C_OLD), Some(nested));
570    }
571
572    /// The map itself is the GC oracle: `node_moves` lists EVERY matched node, so
573    /// an id missing from it is unmounted (not merely "unmoved").
574    #[test]
575    fn node_id_map_semantics() {
576        let map = delete_a();
577        assert_eq!(map.resolve(NodeId::new(0)), Some(NodeId::new(0)));
578        assert_eq!(map.resolve(C_OLD), Some(C_NEW));
579        assert!(map.is_unmounted(A));
580        assert!(map.resolve(A).is_none());
581        let _unused: &BTreeMap<NodeId, NodeId> = map.as_btree_map();
582    }
583}
584
585// ============================================================================
586// AUTOTEST: adversarial tests for `NodeIdMap`, `remap_keys`, `remap_dom_keys`
587// ============================================================================
588#[cfg(test)]
589mod autotest_generated {
590    use azul_core::diff::NodeMove;
591
592    use super::*;
593
594    const DOM0: DomId = DomId { inner: 0 };
595    const DOM1: DomId = DomId { inner: 1 };
596    /// A `DomId` at the top of the `usize` range — must be handled like any other.
597    const DOM_MAX: DomId = DomId { inner: usize::MAX };
598
599    fn nid(i: usize) -> NodeId {
600        NodeId::new(i)
601    }
602
603    fn mv(old: usize, new: usize) -> NodeMove {
604        NodeMove {
605            old_node_id: nid(old),
606            new_node_id: nid(new),
607        }
608    }
609
610    /// `NodeHierarchyItemId` uses a 1-based encoding, so the largest node index
611    /// that can round-trip through a `DomNodeId` is `usize::MAX - 1`
612    /// (`from_crate_internal` computes `inner + 1`). Anything above that is not
613    /// representable and is deliberately not exercised through `DomNodeId`.
614    const MAX_ENCODABLE: usize = usize::MAX - 1;
615
616    fn dom_node_at(dom: DomId, node: NodeId) -> DomNodeId {
617        DomNodeId {
618            dom,
619            node: NodeHierarchyItemId::from_crate_internal(Some(node)),
620        }
621    }
622
623    // ------------------------------------------------------ constructors
624
625    #[test]
626    fn from_node_moves_on_an_empty_slice_yields_an_empty_map() {
627        let map = NodeIdMap::from_node_moves(&[]);
628        assert!(map.is_empty());
629        assert!(map.as_btree_map().is_empty());
630        assert_eq!(map, NodeIdMap::default());
631    }
632
633    #[test]
634    fn from_node_moves_agrees_with_from_pairs_on_the_same_data() {
635        let moves = [mv(0, 0), mv(5, 3), mv(9, 9)];
636        let from_moves = NodeIdMap::from_node_moves(&moves);
637        let from_pairs =
638            NodeIdMap::from_pairs([(nid(0), nid(0)), (nid(5), nid(3)), (nid(9), nid(9))]);
639        assert_eq!(
640            from_moves, from_pairs,
641            "the two constructors must produce identical maps for identical data"
642        );
643    }
644
645    /// A malformed `node_moves` slice (the same old id listed twice) must not
646    /// panic and must resolve deterministically — `BTreeMap::collect` keeps the
647    /// LAST entry.
648    #[test]
649    fn from_node_moves_with_a_duplicated_old_id_keeps_the_last_entry() {
650        let map = NodeIdMap::from_node_moves(&[mv(1, 10), mv(1, 20), mv(1, 30)]);
651        assert_eq!(map.as_btree_map().len(), 1, "duplicates collapse to one key");
652        assert_eq!(
653            map.resolve(nid(1)),
654            Some(nid(30)),
655            "the last NodeMove for an old id wins"
656        );
657    }
658
659    /// Two old nodes mapped onto the SAME new id is nonsense input, but it must
660    /// still build a well-formed (if lossy) map rather than panic.
661    #[test]
662    fn from_node_moves_with_a_non_injective_mapping_does_not_panic() {
663        let map = NodeIdMap::from_node_moves(&[mv(1, 7), mv(2, 7)]);
664        assert_eq!(map.as_btree_map().len(), 2, "both old ids are retained as keys");
665        assert_eq!(map.resolve(nid(1)), Some(nid(7)));
666        assert_eq!(map.resolve(nid(2)), Some(nid(7)));
667    }
668
669    /// `NodeId` wraps a `usize`; ids at the very top of the range are just
670    /// indices as far as the map is concerned — no arithmetic, no overflow.
671    #[test]
672    fn from_node_moves_handles_extreme_node_ids() {
673        let map = NodeIdMap::from_node_moves(&[
674            mv(usize::MAX, usize::MAX),
675            mv(usize::MAX - 1, 0),
676            mv(0, usize::MAX),
677        ]);
678        assert_eq!(map.resolve(nid(usize::MAX)), Some(nid(usize::MAX)));
679        assert_eq!(map.resolve(nid(usize::MAX - 1)), Some(nid(0)));
680        assert_eq!(map.resolve(nid(0)), Some(nid(usize::MAX)));
681        assert!(!map.is_empty());
682    }
683
684    #[test]
685    fn from_pairs_on_an_empty_iterator_yields_an_empty_map() {
686        let map = NodeIdMap::from_pairs(Vec::new());
687        assert!(map.is_empty());
688        assert!(map.resolve(NodeId::ZERO).is_none());
689        assert!(map.is_unmounted(NodeId::ZERO));
690    }
691
692    #[test]
693    fn from_pairs_with_a_duplicated_old_id_keeps_the_last_entry() {
694        let map = NodeIdMap::from_pairs([(nid(4), nid(1)), (nid(4), nid(2))]);
695        assert_eq!(map.as_btree_map().len(), 1);
696        assert_eq!(map.resolve(nid(4)), Some(nid(2)));
697    }
698
699    /// Post-construction invariants at volume: every pair fed in resolves back
700    /// out, the length matches the number of distinct old ids, and nothing that
701    /// was never inserted resolves.
702    #[test]
703    fn from_pairs_invariants_hold_at_volume() {
704        let n = 2048usize;
705        let pairs: Vec<(NodeId, NodeId)> = (0..n).map(|i| (nid(i), nid(n - 1 - i))).collect();
706        let map = NodeIdMap::from_pairs(pairs);
707
708        assert_eq!(map.as_btree_map().len(), n);
709        assert!(!map.is_empty());
710        for i in 0..n {
711            assert_eq!(map.resolve(nid(i)), Some(nid(n - 1 - i)));
712            assert!(!map.is_unmounted(nid(i)));
713        }
714        assert!(map.resolve(nid(n)).is_none(), "an id never inserted is unmounted");
715        assert!(map.is_unmounted(nid(n)));
716    }
717
718    /// Round-trip: `as_btree_map` is a faithful encoding of what went in, and
719    /// feeding it back through `from_pairs` reproduces the map exactly.
720    #[test]
721    fn as_btree_map_round_trips_through_from_pairs() {
722        let original = NodeIdMap::from_pairs([
723            (nid(0), nid(0)),
724            (nid(2), nid(1)),
725            (nid(3), nid(2)),
726            (nid(usize::MAX), nid(4)),
727        ]);
728        let decoded = NodeIdMap::from_pairs(
729            original
730                .as_btree_map()
731                .iter()
732                .map(|(old, new)| (*old, *new))
733                .collect::<Vec<_>>(),
734        );
735        assert_eq!(decoded, original, "encode == decode");
736        assert_eq!(decoded.as_btree_map(), original.as_btree_map());
737    }
738
739    #[test]
740    fn a_default_map_unmounts_everything() {
741        let map = NodeIdMap::default();
742        assert!(map.is_empty());
743        assert!(map.as_btree_map().is_empty());
744        for i in [0usize, 1, 2, 1024, usize::MAX - 1, usize::MAX] {
745            assert!(map.resolve(nid(i)).is_none());
746            assert!(
747                map.is_unmounted(nid(i)),
748                "an empty reconciliation means every old node was unmounted"
749            );
750        }
751    }
752
753    // --------------------------------------------- resolve / is_unmounted
754
755    /// The two accessors are two views of the same fact — they must never
756    /// disagree, for any id, on any map.
757    #[test]
758    fn resolve_and_is_unmounted_never_disagree() {
759        let map = NodeIdMap::from_pairs([(nid(0), nid(0)), (nid(2), nid(1)), (nid(3), nid(2))]);
760        for i in [0usize, 1, 2, 3, 4, 100, usize::MAX - 1, usize::MAX] {
761            assert_eq!(
762                map.resolve(nid(i)).is_none(),
763                map.is_unmounted(nid(i)),
764                "is_unmounted({i}) must be exactly !resolve({i}).is_some()"
765            );
766        }
767    }
768
769    #[test]
770    fn resolve_is_pure_repeated_calls_return_the_same_answer() {
771        let map = NodeIdMap::from_pairs([(nid(3), nid(2))]);
772        let first = map.resolve(nid(3));
773        assert_eq!(first, map.resolve(nid(3)));
774        assert_eq!(first, map.resolve(nid(3)));
775        assert_eq!(first, Some(nid(2)));
776    }
777
778    /// `resolve` is a single lookup, NOT a transitive closure. If it chased
779    /// chains, `1 → 2 → 3` would collapse and every remap would be wrong for
780    /// any map whose new ids overlap its old ids (which is the normal case).
781    #[test]
782    fn resolve_does_not_chase_chains() {
783        let map = NodeIdMap::from_pairs([(nid(1), nid(2)), (nid(2), nid(3))]);
784        assert_eq!(
785            map.resolve(nid(1)),
786            Some(nid(2)),
787            "resolve must apply exactly one hop"
788        );
789        assert_eq!(map.resolve(nid(2)), Some(nid(3)));
790    }
791
792    /// A node that kept its index is MATCHED, not unmounted — the whole GC rule
793    /// depends on identity entries being present and meaningful.
794    #[test]
795    fn an_identity_entry_means_matched_not_unmounted() {
796        let map = NodeIdMap::from_pairs([(nid(7), nid(7))]);
797        assert!(!map.is_unmounted(nid(7)));
798        assert_eq!(map.resolve(nid(7)), Some(nid(7)));
799        assert!(map.is_unmounted(nid(6)));
800        assert!(map.is_unmounted(nid(8)));
801    }
802
803    #[test]
804    fn is_empty_is_exactly_the_btree_maps_emptiness() {
805        let empty = NodeIdMap::from_pairs(Vec::new());
806        assert_eq!(empty.is_empty(), empty.as_btree_map().is_empty());
807        assert!(empty.is_empty());
808
809        let full = NodeIdMap::from_pairs([(nid(0), nid(0))]);
810        assert_eq!(full.is_empty(), full.as_btree_map().is_empty());
811        assert!(!full.is_empty());
812    }
813
814    // ------------------------------------------------- resolve_dom_node_id
815
816    /// The documented pass-through rule: a reconciliation of DOM 0 says NOTHING
817    /// about DOM 1, so an id from another DOM must come back byte-identical —
818    /// even when its node index happens to be unmounted in *this* map.
819    #[test]
820    fn a_foreign_dom_node_id_is_passed_through_untouched() {
821        let map = NodeIdMap::from_pairs([(nid(2), nid(1))]);
822        // Node 5 is "unmounted" as far as this map is concerned...
823        let foreign = dom_node_at(DOM1, nid(5));
824        assert_eq!(
825            map.resolve_dom_node_id(DOM0, foreign),
826            Some(foreign),
827            "an id in another DOM must never be dropped by this DOM's reconciliation"
828        );
829        // ...and a foreign id whose index IS in the map must not be rewritten either.
830        let foreign_colliding = dom_node_at(DOM1, nid(2));
831        assert_eq!(
832            map.resolve_dom_node_id(DOM0, foreign_colliding),
833            Some(foreign_colliding),
834            "a foreign id must not be remapped just because its index appears in the map"
835        );
836    }
837
838    #[test]
839    fn resolve_dom_node_id_rewrites_matched_nodes_and_drops_unmounted_ones() {
840        let map = NodeIdMap::from_pairs([(nid(2), nid(1))]);
841        assert_eq!(
842            map.resolve_dom_node_id(DOM0, dom_node_at(DOM0, nid(2))),
843            Some(dom_node_at(DOM0, nid(1)))
844        );
845        assert_eq!(
846            map.resolve_dom_node_id(DOM0, dom_node_at(DOM0, nid(1))),
847            None,
848            "a node absent from the map is unmounted, so the id must be dropped"
849        );
850    }
851
852    /// `NodeHierarchyItemId::NONE` decodes to `None`. For the reconciled DOM
853    /// that means "no node" → `None`; for a foreign DOM the pass-through branch
854    /// fires first, so it survives unchanged. Both are deterministic.
855    #[test]
856    fn a_none_node_id_is_handled_without_panicking() {
857        let map = NodeIdMap::from_pairs([(nid(0), nid(0))]);
858        let none_here = DomNodeId {
859            dom: DOM0,
860            node: NodeHierarchyItemId::NONE,
861        };
862        assert_eq!(map.resolve_dom_node_id(DOM0, none_here), None);
863
864        let none_elsewhere = DomNodeId {
865            dom: DOM1,
866            node: NodeHierarchyItemId::NONE,
867        };
868        assert_eq!(
869            map.resolve_dom_node_id(DOM0, none_elsewhere),
870            Some(none_elsewhere),
871            "the foreign-DOM pass-through happens before the node is decoded"
872        );
873    }
874
875    /// Boundary ids on both axes: the largest encodable node index and the
876    /// largest `DomId`. `MAX_ENCODABLE` maps to raw `usize::MAX` in the 1-based
877    /// encoding, i.e. the last value that fits.
878    #[test]
879    fn resolve_dom_node_id_survives_boundary_ids() {
880        let map = NodeIdMap::from_pairs([
881            (nid(MAX_ENCODABLE), nid(0)),
882            (nid(0), nid(MAX_ENCODABLE)),
883        ]);
884
885        // Largest encodable index as the OLD id.
886        let big_old = dom_node_at(DOM0, nid(MAX_ENCODABLE));
887        assert_eq!(big_old.node.into_raw(), usize::MAX, "1-based encoding is saturated");
888        assert_eq!(
889            map.resolve_dom_node_id(DOM0, big_old),
890            Some(dom_node_at(DOM0, nid(0)))
891        );
892
893        // Largest encodable index as the NEW id (re-encoding must not overflow).
894        assert_eq!(
895            map.resolve_dom_node_id(DOM0, dom_node_at(DOM0, nid(0))),
896            Some(dom_node_at(DOM0, nid(MAX_ENCODABLE)))
897        );
898
899        // An extreme DomId is still just a DomId.
900        let far_dom = dom_node_at(DOM_MAX, nid(0));
901        assert_eq!(
902            map.resolve_dom_node_id(DOM0, far_dom),
903            Some(far_dom),
904            "DomId::MAX is foreign to DOM 0 and passes through"
905        );
906        assert_eq!(
907            map.resolve_dom_node_id(DOM_MAX, far_dom),
908            Some(dom_node_at(DOM_MAX, nid(MAX_ENCODABLE))),
909            "when DomId::MAX *is* the reconciled DOM, its nodes are remapped"
910        );
911    }
912
913    #[test]
914    fn resolve_dom_node_id_on_an_empty_map_drops_own_dom_and_keeps_foreign() {
915        let map = NodeIdMap::default();
916        assert_eq!(map.resolve_dom_node_id(DOM0, dom_node_at(DOM0, nid(0))), None);
917        let foreign = dom_node_at(DOM1, nid(0));
918        assert_eq!(map.resolve_dom_node_id(DOM0, foreign), Some(foreign));
919    }
920
921    // ------------------------------------------------------- remap_keys
922
923    /// The reason `remap_keys` takes the map out before rebuilding it: a SWAP
924    /// (`1 → 2`, `2 → 1`) is a legal reconciliation, and an in-place rewrite
925    /// would overwrite one payload with the other. Both payloads must survive,
926    /// on the correct keys.
927    #[test]
928    fn remap_keys_handles_a_swap_without_clobbering_payloads() {
929        let mut map: BTreeMap<NodeId, &str> = BTreeMap::new();
930        map.insert(nid(1), "one");
931        map.insert(nid(2), "two");
932        let node_map = NodeIdMap::from_pairs([(nid(1), nid(2)), (nid(2), nid(1))]);
933
934        remap_keys(&mut map, &node_map);
935
936        assert_eq!(map.len(), 2, "a swap must not lose an entry");
937        assert_eq!(map.get(&nid(1)).copied(), Some("two"));
938        assert_eq!(map.get(&nid(2)).copied(), Some("one"));
939    }
940
941    /// The GC half of the contract: keys absent from the map are unmounted and
942    /// must be dropped, never kept "just in case".
943    #[test]
944    fn remap_keys_drops_state_for_unmounted_nodes() {
945        let mut map: BTreeMap<NodeId, u32> = BTreeMap::new();
946        map.insert(nid(1), 10);
947        map.insert(nid(2), 20);
948        map.insert(nid(3), 30);
949        let node_map = NodeIdMap::from_pairs([(nid(2), nid(1)), (nid(3), nid(2))]);
950
951        remap_keys(&mut map, &node_map);
952
953        assert_eq!(map.len(), 2, "node 1's state must be GC'd");
954        assert_eq!(map.get(&nid(1)).copied(), Some(20));
955        assert_eq!(map.get(&nid(2)).copied(), Some(30));
956        assert!(!map.contains_key(&nid(3)), "no state may remain at a dead index");
957    }
958
959    #[test]
960    fn remap_keys_with_an_empty_node_map_clears_all_state() {
961        let mut map: BTreeMap<NodeId, u32> = BTreeMap::new();
962        map.insert(nid(0), 1);
963        map.insert(nid(9), 2);
964
965        remap_keys(&mut map, &NodeIdMap::default());
966
967        assert!(
968            map.is_empty(),
969            "an empty reconciliation unmounts everything, so all state is dropped"
970        );
971    }
972
973    #[test]
974    fn remap_keys_with_an_identity_map_is_a_no_op() {
975        let mut map: BTreeMap<NodeId, u32> = (0..64).map(|i| (nid(i), i as u32)).collect();
976        let before = map.clone();
977        let node_map = NodeIdMap::from_pairs((0..64).map(|i| (nid(i), nid(i))));
978
979        remap_keys(&mut map, &node_map);
980
981        assert_eq!(map, before);
982    }
983
984    #[test]
985    fn remap_keys_on_an_empty_map_does_not_panic() {
986        let mut map: BTreeMap<NodeId, u32> = BTreeMap::new();
987        remap_keys(&mut map, &NodeIdMap::from_pairs([(nid(1), nid(0))]));
988        assert!(map.is_empty());
989    }
990
991    /// A non-injective reconciliation (`1 → 5` and `2 → 5`) cannot be
992    /// represented by a map keyed on `NodeId` — one entry must win. Assert the
993    /// outcome is deterministic (source keys are visited in ascending order, so
994    /// the HIGHEST old id lands last) rather than a panic or a silent duplicate.
995    #[test]
996    fn remap_keys_collision_is_lossy_but_deterministic() {
997        let mut map: BTreeMap<NodeId, &str> = BTreeMap::new();
998        map.insert(nid(1), "from-1");
999        map.insert(nid(2), "from-2");
1000        let node_map = NodeIdMap::from_pairs([(nid(1), nid(5)), (nid(2), nid(5))]);
1001
1002        remap_keys(&mut map, &node_map);
1003
1004        assert_eq!(map.len(), 1, "two old keys collapsing onto one new key lose one entry");
1005        assert_eq!(
1006            map.get(&nid(5)).copied(),
1007            Some("from-2"),
1008            "the last-visited (highest) old id wins — deterministic, not arbitrary"
1009        );
1010    }
1011
1012    /// The preceding-sibling delete at scale: deleting node 1 of 256 shifts every
1013    /// later index down by one. Every payload must land on exactly the key that
1014    /// now denotes its element — an off-by-one here is the misattachment bug the
1015    /// module doc-comment describes.
1016    #[test]
1017    fn remap_keys_preserves_payload_identity_under_a_shift_down() {
1018        let n = 256usize;
1019        let mut map: BTreeMap<NodeId, usize> = (0..n).map(|i| (nid(i), i * 1000)).collect();
1020        // 0 stays, 1 is deleted, 2..n shift down by one.
1021        let node_map = NodeIdMap::from_pairs(
1022            core::iter::once((nid(0), nid(0))).chain((2..n).map(|i| (nid(i), nid(i - 1)))),
1023        );
1024
1025        remap_keys(&mut map, &node_map);
1026
1027        assert_eq!(map.len(), n - 1, "exactly the deleted node's state is GC'd");
1028        assert_eq!(map.get(&nid(0)).copied(), Some(0));
1029        for i in 2..n {
1030            assert_eq!(
1031                map.get(&nid(i - 1)).copied(),
1032                Some(i * 1000),
1033                "node {i}'s payload must follow it to index {}",
1034                i - 1
1035            );
1036        }
1037        assert!(!map.contains_key(&nid(n - 1)), "the vacated tail index is empty");
1038    }
1039
1040    /// Ordering trap: an entry is rewritten ONTO an index that another (about to
1041    /// be dropped) entry currently occupies. Because the source map is taken out
1042    /// first, the survivor must not be eaten by the corpse.
1043    #[test]
1044    fn remap_keys_survivor_moving_onto_a_dead_index_is_not_dropped() {
1045        let mut map: BTreeMap<NodeId, &str> = BTreeMap::new();
1046        map.insert(nid(1), "doomed");
1047        map.insert(nid(2), "survivor");
1048        // Node 1 is unmounted; node 2 moves into its slot.
1049        let node_map = NodeIdMap::from_pairs([(nid(2), nid(1))]);
1050
1051        remap_keys(&mut map, &node_map);
1052
1053        assert_eq!(map.len(), 1);
1054        assert_eq!(
1055            map.get(&nid(1)).copied(),
1056            Some("survivor"),
1057            "the surviving payload occupies the recycled index — the dead one is gone"
1058        );
1059    }
1060
1061    // --------------------------------------------------- remap_dom_keys
1062
1063    #[test]
1064    fn remap_dom_keys_never_touches_another_dom() {
1065        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
1066        map.insert((DOM0, nid(2)), 20);
1067        // Same node index, different DOM — must survive verbatim.
1068        map.insert((DOM1, nid(2)), 99);
1069        // A foreign entry at an index that is unmounted in DOM 0.
1070        map.insert((DOM1, nid(1)), 98);
1071        let node_map = NodeIdMap::from_pairs([(nid(2), nid(1))]);
1072
1073        remap_dom_keys(&mut map, DOM0, &node_map);
1074
1075        assert_eq!(map.get(&(DOM0, nid(1))).copied(), Some(20), "DOM 0's entry is remapped");
1076        assert!(!map.contains_key(&(DOM0, nid(2))), "the old DOM 0 key is gone");
1077        assert_eq!(
1078            map.get(&(DOM1, nid(2))).copied(),
1079            Some(99),
1080            "DOM 1 is untouched by a DOM 0 reconciliation"
1081        );
1082        assert_eq!(map.get(&(DOM1, nid(1))).copied(), Some(98));
1083        assert_eq!(map.len(), 3);
1084    }
1085
1086    #[test]
1087    fn remap_dom_keys_drops_unmounted_entries_of_the_target_dom_only() {
1088        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
1089        map.insert((DOM0, nid(1)), 10); // unmounted in DOM 0 -> dropped
1090        map.insert((DOM1, nid(1)), 11); // same index, other DOM -> kept
1091        let node_map = NodeIdMap::from_pairs([(nid(0), nid(0))]);
1092
1093        remap_dom_keys(&mut map, DOM0, &node_map);
1094
1095        assert!(!map.contains_key(&(DOM0, nid(1))));
1096        assert_eq!(map.get(&(DOM1, nid(1))).copied(), Some(11));
1097        assert_eq!(map.len(), 1);
1098    }
1099
1100    #[test]
1101    fn remap_dom_keys_handles_a_swap_within_the_target_dom() {
1102        let mut map: BTreeMap<(DomId, NodeId), &str> = BTreeMap::new();
1103        map.insert((DOM0, nid(1)), "one");
1104        map.insert((DOM0, nid(2)), "two");
1105        map.insert((DOM1, nid(1)), "other-dom");
1106        let node_map = NodeIdMap::from_pairs([(nid(1), nid(2)), (nid(2), nid(1))]);
1107
1108        remap_dom_keys(&mut map, DOM0, &node_map);
1109
1110        assert_eq!(map.get(&(DOM0, nid(1))).copied(), Some("two"));
1111        assert_eq!(map.get(&(DOM0, nid(2))).copied(), Some("one"));
1112        assert_eq!(map.get(&(DOM1, nid(1))).copied(), Some("other-dom"));
1113        assert_eq!(map.len(), 3, "a swap plus a bystander DOM loses nothing");
1114    }
1115
1116    #[test]
1117    fn remap_dom_keys_with_an_empty_node_map_clears_only_the_target_dom() {
1118        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
1119        map.insert((DOM0, nid(0)), 1);
1120        map.insert((DOM0, nid(5)), 2);
1121        map.insert((DOM1, nid(0)), 3);
1122
1123        remap_dom_keys(&mut map, DOM0, &NodeIdMap::default());
1124
1125        assert_eq!(map.len(), 1, "every DOM 0 node was unmounted");
1126        assert_eq!(map.get(&(DOM1, nid(0))).copied(), Some(3));
1127    }
1128
1129    #[test]
1130    fn remap_dom_keys_on_an_empty_map_does_not_panic() {
1131        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
1132        remap_dom_keys(&mut map, DOM0, &NodeIdMap::from_pairs([(nid(1), nid(0))]));
1133        assert!(map.is_empty());
1134    }
1135
1136    #[test]
1137    fn remap_dom_keys_with_a_target_dom_that_has_no_entries_is_a_no_op() {
1138        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
1139        map.insert((DOM1, nid(1)), 1);
1140        map.insert((DOM_MAX, nid(2)), 2);
1141        let before = map.clone();
1142
1143        remap_dom_keys(&mut map, DOM0, &NodeIdMap::from_pairs([(nid(1), nid(9))]));
1144
1145        assert_eq!(map, before);
1146    }
1147
1148    #[test]
1149    fn remap_dom_keys_handles_boundary_dom_and_node_ids() {
1150        let mut map: BTreeMap<(DomId, NodeId), u32> = BTreeMap::new();
1151        map.insert((DOM_MAX, nid(usize::MAX)), 1);
1152        map.insert((DOM_MAX, nid(usize::MAX - 1)), 2);
1153        map.insert((DOM0, nid(usize::MAX)), 3);
1154        let node_map = NodeIdMap::from_pairs([(nid(usize::MAX), nid(0))]);
1155
1156        remap_dom_keys(&mut map, DOM_MAX, &node_map);
1157
1158        assert_eq!(
1159            map.get(&(DOM_MAX, nid(0))).copied(),
1160            Some(1),
1161            "usize::MAX remaps like any other index"
1162        );
1163        assert!(
1164            !map.contains_key(&(DOM_MAX, nid(usize::MAX - 1))),
1165            "unmounted in DOM_MAX -> dropped"
1166        );
1167        assert_eq!(
1168            map.get(&(DOM0, nid(usize::MAX))).copied(),
1169            Some(3),
1170            "DOM 0 is a bystander here"
1171        );
1172        assert_eq!(map.len(), 2);
1173    }
1174
1175    /// Applying the SAME reconciliation twice is not idempotent in general (the
1176    /// second pass re-reads already-new ids as if they were old), which is why
1177    /// callers must run it exactly once per rebuild. Pin the one case that IS
1178    /// safe — the identity map — so the no-op guarantee cannot regress.
1179    #[test]
1180    fn remap_dom_keys_with_an_identity_map_is_a_no_op_even_when_repeated() {
1181        let mut map: BTreeMap<(DomId, NodeId), u32> =
1182            (0..32).map(|i| ((DOM0, nid(i)), i as u32)).collect();
1183        let before = map.clone();
1184        let node_map = NodeIdMap::from_pairs((0..32).map(|i| (nid(i), nid(i))));
1185
1186        remap_dom_keys(&mut map, DOM0, &node_map);
1187        remap_dom_keys(&mut map, DOM0, &node_map);
1188
1189        assert_eq!(map, before);
1190    }
1191}