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