Skip to main content

azul_layout/managers/
undo_redo.rs

1//! Undo/Redo Manager for text editing operations
2//!
3//! This module implements a per-node undo/redo stack that records text changesets
4//! and the state before they were applied. This allows reverting changes with Ctrl+Z
5//! and re-applying them with Ctrl+Y/Ctrl+Shift+Z.
6//!
7//! ## Architecture
8//!
9//! - **Per-Node Tracking**: Each text node has its own undo/redo stack
10//! - **Changeset-Based**: Records `TextChangesets` from changeset.rs
11//! - **State Snapshots**: Saves node state BEFORE changeset application (for revert)
12//! - **Bounded History**: Keeps last 10 operations per node (configurable)
13//! - **Callback Integration**: User can intercept via `preventDefault()`
14//!
15//! ## Usage Flow
16//!
17//! 1. User types text → `TextChangeset` created
18//! 2. Pre-callback: Record current node state
19//! 3. User callback: Can query/modify via `CallbackInfo`
20//! 4. Apply changeset (if !preventDefault)
21//! 5. Post-callback: Push changeset + pre-state to undo stack
22//!
23//! 6. User presses Ctrl+Z → Undo event detected
24//! 7. Pre-callback: Pop undo stack, create revert changeset
25//! 8. User callback: Can preventDefault or inspect
26//! 9. Apply revert (if !preventDefault)
27//! 10. Post-callback: Push original changeset to redo stack
28
29use alloc::{collections::VecDeque, vec::Vec};
30
31use azul_core::{
32    dom::{DomId, NodeId},
33    selection::{OptionSelectionRange, OptionTextCursor},
34    task::Instant,
35};
36use azul_css::{impl_option, impl_option_inner, AzString};
37
38use super::changeset::TextChangeset;
39
40/// Maximum number of undo operations to keep per node
41pub const MAX_UNDO_HISTORY: usize = 10;
42
43/// Maximum number of redo operations to keep per node
44pub const MAX_REDO_HISTORY: usize = 10;
45
46/// Snapshot of a text node's state before a changeset was applied.
47///
48/// This contains enough information to fully revert a text operation.
49#[derive(Debug, Clone)]
50#[repr(C)]
51pub struct NodeStateSnapshot {
52    /// The node this snapshot belongs to
53    pub node_id: NodeId,
54    /// Full text content before changeset
55    pub text_content: AzString,
56    /// Cursor position before changeset (if applicable)
57    /// For now, we store the logical position, not the `TextCursor`
58    pub cursor_position: OptionTextCursor,
59    /// Selection range before changeset (if applicable)
60    pub selection_range: OptionSelectionRange,
61    /// When this snapshot was taken
62    pub timestamp: Instant,
63}
64
65/// A recorded operation that can be undone/redone.
66///
67/// Combines the changeset that was applied with the state before application.
68#[derive(Debug, Clone)]
69#[repr(C)]
70pub struct UndoableOperation {
71    /// The changeset that was applied
72    pub changeset: TextChangeset,
73    /// Node state BEFORE the changeset was applied
74    pub pre_state: NodeStateSnapshot,
75}
76
77impl_option!(
78    UndoableOperation,
79    OptionUndoableOperation,
80    copy = false,
81    [Debug, Clone]
82);
83
84/// Per-node undo/redo stack
85#[derive(Debug, Clone)]
86pub struct NodeUndoRedoStack {
87    /// Node ID this stack belongs to
88    pub node_id: NodeId,
89    /// Undo stack (most recent at back)
90    pub undo_stack: VecDeque<UndoableOperation>,
91    /// Redo stack (most recent at back)
92    pub redo_stack: VecDeque<UndoableOperation>,
93}
94
95impl NodeUndoRedoStack {
96    #[must_use] pub fn new(node_id: NodeId) -> Self {
97        Self {
98            node_id,
99            undo_stack: VecDeque::with_capacity(MAX_UNDO_HISTORY),
100            redo_stack: VecDeque::with_capacity(MAX_REDO_HISTORY),
101        }
102    }
103
104    /// Push a new operation to the undo stack
105    pub fn push_undo(&mut self, operation: UndoableOperation) {
106        // Clear redo stack when new operation is performed
107        self.redo_stack.clear();
108
109        // Add to undo stack
110        self.undo_stack.push_back(operation);
111
112        // Limit stack size
113        if self.undo_stack.len() > MAX_UNDO_HISTORY {
114            self.undo_stack.pop_front();
115        }
116    }
117
118    /// MWA-C-undo_redo: move a REDONE operation back onto the undo stack
119    /// WITHOUT clearing the redo stack — `push_undo` clears it (correct for
120    /// fresh user edits), which made every redo destroy the remaining redo
121    /// history.
122    pub fn push_undo_preserving_redo(&mut self, operation: UndoableOperation) {
123        self.undo_stack.push_back(operation);
124        if self.undo_stack.len() > MAX_UNDO_HISTORY {
125            self.undo_stack.pop_front();
126        }
127    }
128
129    /// Pop the most recent operation from undo stack
130    pub fn pop_undo(&mut self) -> Option<UndoableOperation> {
131        self.undo_stack.pop_back()
132    }
133
134    /// Push an operation to the redo stack (after undo)
135    pub fn push_redo(&mut self, operation: UndoableOperation) {
136        self.redo_stack.push_back(operation);
137
138        // Limit stack size
139        if self.redo_stack.len() > MAX_REDO_HISTORY {
140            self.redo_stack.pop_front();
141        }
142    }
143
144    /// Pop the most recent operation from redo stack
145    pub fn pop_redo(&mut self) -> Option<UndoableOperation> {
146        self.redo_stack.pop_back()
147    }
148
149    /// Check if undo is available
150    #[must_use] pub fn can_undo(&self) -> bool {
151        !self.undo_stack.is_empty()
152    }
153
154    /// Check if redo is available
155    #[must_use] pub fn can_redo(&self) -> bool {
156        !self.redo_stack.is_empty()
157    }
158
159    /// Peek at the most recent undo operation without removing it
160    #[must_use] pub fn peek_undo(&self) -> Option<&UndoableOperation> {
161        self.undo_stack.back()
162    }
163
164    /// Peek at the most recent redo operation without removing it
165    #[must_use] pub fn peek_redo(&self) -> Option<&UndoableOperation> {
166        self.redo_stack.back()
167    }
168}
169
170/// MWA-C-undo_redo: styled-content snapshots for an operation.
171///
172/// Kept OUT of the FFI-exposed `UndoableOperation` (which crosses the C API
173/// via `inspect_undo_operation`) and keyed by `TextChangeset.id`. Undo restores
174/// `pre`, redo restores `post` — previously both rebuilt the text with
175/// `StyleProperties::default()`, discarding all styling, and redo re-entered
176/// the recording pipeline (double-recording + clearing the redo stack).
177#[derive(Debug, Clone)]
178pub struct ContentSnapshot {
179    /// Full styled inline content BEFORE the operation
180    pub pre: Vec<crate::text3::cache::InlineContent>,
181    /// Full styled inline content AFTER the operation
182    pub post: Vec<crate::text3::cache::InlineContent>,
183}
184
185/// Bound on the styled-snapshot side table (undo+redo stacks hold at most
186/// 10+10 per node; 64 gives headroom across a handful of editable nodes —
187/// lookup misses fall back to the plain-text restore).
188const MAX_CONTENT_SNAPSHOTS: usize = 64;
189
190/// Manager for undo/redo operations across all text nodes
191#[derive(Debug, Clone, Default)]
192pub struct UndoRedoManager {
193    /// Per-node undo/redo stacks
194    /// Using Vec instead of `HashMap` for `no_std` compatibility
195    pub node_stacks: Vec<NodeUndoRedoStack>,
196    /// Styled-content snapshots keyed by `TextChangeset.id` (see
197    /// [`ContentSnapshot`]); FIFO-capped at [`MAX_CONTENT_SNAPSHOTS`].
198    pub content_snapshots: Vec<(super::changeset::ChangesetId, ContentSnapshot)>,
199}
200
201impl UndoRedoManager {
202    /// Create a new empty undo/redo manager
203    #[must_use] pub const fn new() -> Self {
204        Self {
205            node_stacks: Vec::new(),
206            content_snapshots: Vec::new(),
207        }
208    }
209
210    /// Store the styled pre/post content for a changeset (see [`ContentSnapshot`]).
211    pub fn store_content_snapshot(
212        &mut self,
213        id: super::changeset::ChangesetId,
214        pre: Vec<crate::text3::cache::InlineContent>,
215        post: Vec<crate::text3::cache::InlineContent>,
216    ) {
217        self.content_snapshots.retain(|(existing, _)| *existing != id);
218        self.content_snapshots.push((id, ContentSnapshot { pre, post }));
219        if self.content_snapshots.len() > MAX_CONTENT_SNAPSHOTS {
220            self.content_snapshots.remove(0);
221        }
222    }
223
224    /// Look up the styled snapshot for a changeset id.
225    #[must_use] pub fn get_content_snapshot(
226        &self,
227        id: super::changeset::ChangesetId,
228    ) -> Option<&ContentSnapshot> {
229        self.content_snapshots
230            .iter()
231            .find(|(existing, _)| *existing == id)
232            .map(|(_, snap)| snap)
233    }
234
235    /// MWA-C-undo_redo: put a redone operation back on the undo stack
236    /// WITHOUT clearing the redo stack (see
237    /// [`NodeUndoRedoStack::push_undo_preserving_redo`]).
238    /// # Panics
239    /// Panics if the operation's changeset target node is None.
240    pub fn reinstate_undo(&mut self, operation: UndoableOperation) {
241        let node_id = operation
242            .changeset
243            .target
244            .node
245            .into_crate_internal()
246            .expect("TextChangeset target node should not be None");
247        let stack = self.get_or_create_stack_mut(node_id);
248        stack.push_undo_preserving_redo(operation);
249    }
250
251    /// Get or create a stack for a specific node
252    /// # Panics
253    ///
254    /// Panics if the per-node stack list is unexpectedly empty after insertion.
255    pub fn get_or_create_stack_mut(&mut self, node_id: NodeId) -> &mut NodeUndoRedoStack {
256        if let Some(pos) = self.node_stacks.iter().position(|s| s.node_id == node_id) {
257            &mut self.node_stacks[pos]
258        } else {
259            self.node_stacks.push(NodeUndoRedoStack::new(node_id));
260            self.node_stacks.last_mut().unwrap()
261        }
262    }
263
264    /// Get a stack for a specific node (immutable)
265    #[must_use] pub fn get_stack(&self, node_id: NodeId) -> Option<&NodeUndoRedoStack> {
266        self.node_stacks.iter().find(|s| s.node_id == node_id)
267    }
268
269    /// Get a stack for a specific node (mutable)
270    fn get_stack_mut(&mut self, node_id: NodeId) -> Option<&mut NodeUndoRedoStack> {
271        self.node_stacks.iter_mut().find(|s| s.node_id == node_id)
272    }
273
274    /// Record a text operation (push to undo stack)
275    ///
276    /// This should be called AFTER a changeset has been successfully applied.
277    /// The `pre_state` should contain the node state BEFORE the changeset was applied.
278    ///
279    /// ## Arguments
280    /// * `changeset` - The changeset that was applied
281    /// * `pre_state` - Node state before the changeset
282    /// # Panics
283    ///
284    /// Panics if the changeset's target node is None.
285    pub fn record_operation(&mut self, changeset: TextChangeset, pre_state: NodeStateSnapshot) {
286        // Convert DomNodeId to NodeId for indexing
287        // NodeHierarchyItemId.into_crate_internal() decodes the 1-based encoding to Option<NodeId>
288        let node_id = changeset
289            .target
290            .node
291            .into_crate_internal()
292            .expect("TextChangeset target node should not be None");
293        let stack = self.get_or_create_stack_mut(node_id);
294
295        let operation = UndoableOperation {
296            changeset,
297            pre_state,
298        };
299
300        stack.push_undo(operation);
301    }
302
303    /// Check if undo is available for a node
304    #[must_use] pub fn can_undo(&self, node_id: NodeId) -> bool {
305        self.get_stack(node_id)
306            .is_some_and(NodeUndoRedoStack::can_undo)
307    }
308
309    /// Check if redo is available for a node
310    #[must_use] pub fn can_redo(&self, node_id: NodeId) -> bool {
311        self.get_stack(node_id)
312            .is_some_and(NodeUndoRedoStack::can_redo)
313    }
314
315    /// Peek at the next undo operation for a node (without removing it)
316    ///
317    /// This allows user callbacks to inspect what would be undone.
318    #[must_use] pub fn peek_undo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
319        self.get_stack(node_id).and_then(|s| s.peek_undo())
320    }
321
322    /// Peek at the next redo operation for a node (without removing it)
323    ///
324    /// This allows user callbacks to inspect what would be redone.
325    #[must_use] pub fn peek_redo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
326        self.get_stack(node_id).and_then(|s| s.peek_redo())
327    }
328
329    /// Pop an operation from the undo stack
330    ///
331    /// This should be called during undo processing to get the operation to revert.
332    /// After reverting, the operation should be pushed to the redo stack.
333    ///
334    /// ## Returns
335    /// * `Some(operation)` - The operation to undo
336    /// * `None` - No undo history available
337    pub fn pop_undo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
338        self.get_stack_mut(node_id)?.pop_undo()
339    }
340
341    /// Pop an operation from the redo stack
342    ///
343    /// This should be called during redo processing to get the operation to re-apply.
344    /// After re-applying, the operation should be pushed to the undo stack.
345    ///
346    /// ## Returns
347    /// * `Some(operation)` - The operation to redo
348    /// * `None` - No redo history available
349    pub fn pop_redo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
350        self.get_stack_mut(node_id)?.pop_redo()
351    }
352
353    /// Push an operation to the redo stack (after successful undo)
354    ///
355    /// This should be called AFTER an undo operation has been successfully applied.
356    /// # Panics
357    ///
358    /// Panics if the operation's changeset target node is None.
359    pub fn push_redo(&mut self, operation: UndoableOperation) {
360        let node_id = operation
361            .changeset
362            .target
363            .node
364            .into_crate_internal()
365            .expect("TextChangeset target node should not be None");
366        let stack = self.get_or_create_stack_mut(node_id);
367        stack.push_redo(operation);
368    }
369
370    /// Push an operation to the undo stack (after successful redo)
371    ///
372    /// This should be called AFTER a redo operation has been successfully applied.
373    /// # Panics
374    ///
375    /// Panics if the operation's changeset target node is None.
376    pub fn push_undo(&mut self, operation: UndoableOperation) {
377        let node_id = operation
378            .changeset
379            .target
380            .node
381            .into_crate_internal()
382            .expect("TextChangeset target node should not be None");
383        let stack = self.get_or_create_stack_mut(node_id);
384        stack.push_undo(operation);
385    }
386
387}
388
389impl crate::managers::NodeIdRemap for UndoRedoManager {
390    /// Remap the per-node undo/redo stacks after a DOM rebuild.
391    ///
392    /// This is the worst offender of the "stale manager" family: the stacks are
393    /// keyed by a bare `NodeId`, so deleting a PRECEDING SIBLING (which shifts
394    /// every following index down by one) used to leave the whole undo history
395    /// silently re-attached to a DIFFERENT, still-live element — undo would edit
396    /// the wrong node, with no panic and no error.
397    ///
398    /// A stack is attributed to a DOM through the `DomNodeId` target of its
399    /// operations (an empty stack carries no information and is treated as
400    /// belonging to the DOM being reconciled). Stacks for unmounted nodes are
401    /// dropped, and the content snapshots they referenced are GC'd with them.
402    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
403        let old_stacks = core::mem::take(&mut self.node_stacks);
404
405        for mut stack in old_stacks {
406            // Which DOM does this stack belong to? Derived from its operations.
407            let stack_dom = stack
408                .undo_stack
409                .front()
410                .or_else(|| stack.redo_stack.front())
411                .map(|op| op.changeset.target.dom);
412
413            if stack_dom.is_some_and(|d| d != dom) {
414                // Belongs to a different DOM — this reconciliation says nothing about it.
415                self.node_stacks.push(stack);
416                continue;
417            }
418
419            let Some(new_node_id) = map.resolve(stack.node_id) else {
420                // Node unmounted — drop the whole history (GC). Keeping it would
421                // re-attach this history to whichever node inherits the index.
422                continue;
423            };
424
425            stack.node_id = new_node_id;
426            for op in stack.undo_stack.iter_mut().chain(stack.redo_stack.iter_mut()) {
427                remap_operation(op, dom, map, new_node_id);
428            }
429            self.node_stacks.push(stack);
430        }
431
432        // GC content snapshots whose changesets no longer exist in any stack.
433        let live: alloc::collections::BTreeSet<_> = self
434            .node_stacks
435            .iter()
436            .flat_map(|s| s.undo_stack.iter().chain(s.redo_stack.iter()))
437            .map(|op| op.changeset.id)
438            .collect();
439        self.content_snapshots.retain(|(id, _)| live.contains(id));
440    }
441}
442
443/// Rewrite the `NodeIds` embedded inside a single undoable operation.
444fn remap_operation(
445    op: &mut UndoableOperation,
446    dom: DomId,
447    map: &crate::managers::NodeIdMap,
448    new_node_id: NodeId,
449) {
450    use azul_core::styled_dom::NodeHierarchyItemId;
451    if op.changeset.target.dom == dom {
452        op.changeset.target.node = NodeHierarchyItemId::from_crate_internal(Some(new_node_id));
453    }
454    if let Some(remapped) = map.resolve(op.pre_state.node_id) {
455        op.pre_state.node_id = remapped;
456    } else {
457        // The snapshot's node vanished but the stack's node survived: keep the
458        // stack coherent by pointing the snapshot at the (surviving) stack node.
459        op.pre_state.node_id = new_node_id;
460    }
461}
462
463
464#[cfg(test)]
465mod undo_redo_tests {
466    use super::*;
467    use crate::managers::changeset::{TextChangeset, TextOpInsertText, TextOperation};
468    use azul_core::dom::{DomId, DomNodeId};
469    use azul_core::styled_dom::NodeHierarchyItemId;
470    use azul_core::task::SystemTick;
471    use azul_core::window::CursorPosition;
472
473    fn ts() -> Instant {
474        Instant::Tick(SystemTick { tick_counter: 0 })
475    }
476
477    fn op(id: usize, node: usize) -> UndoableOperation {
478        UndoableOperation {
479            changeset: TextChangeset {
480                id,
481                target: DomNodeId {
482                    dom: DomId { inner: 0 },
483                    node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
484                },
485                operation: TextOperation::InsertText(TextOpInsertText {
486                    text: "x".into(),
487                    position: CursorPosition::Uninitialized,
488                    new_cursor: CursorPosition::Uninitialized,
489                }),
490                timestamp: ts(),
491            },
492            pre_state: NodeStateSnapshot {
493                node_id: NodeId::new(node),
494                text_content: "".into(),
495                cursor_position: None.into(),
496                selection_range: None.into(),
497                timestamp: ts(),
498            },
499        }
500    }
501
502    #[test]
503    fn push_undo_clears_redo_but_reinstate_preserves_it() {
504        let mut stack = NodeUndoRedoStack::new(NodeId::new(1));
505        stack.push_redo(op(1, 1));
506        stack.push_redo(op(2, 1));
507        assert_eq!(stack.redo_stack.len(), 2);
508
509        // Fresh user edit: redo history is invalidated.
510        stack.push_undo(op(3, 1));
511        assert_eq!(stack.redo_stack.len(), 0);
512
513        // Redone operation moving back to undo: remaining redos survive.
514        stack.push_redo(op(4, 1));
515        stack.push_redo(op(5, 1));
516        stack.push_undo_preserving_redo(op(6, 1));
517        assert_eq!(stack.redo_stack.len(), 2);
518        assert!(stack.can_undo());
519    }
520
521    #[test]
522    fn content_snapshots_replace_lookup_and_evict() {
523        let mut mgr = UndoRedoManager::new();
524        mgr.store_content_snapshot(7, Vec::new(), Vec::new());
525        assert!(mgr.get_content_snapshot(7).is_some());
526        assert!(mgr.get_content_snapshot(8).is_none());
527
528        // Same id replaces, no duplicate entries.
529        mgr.store_content_snapshot(7, Vec::new(), Vec::new());
530        assert_eq!(mgr.content_snapshots.len(), 1);
531
532        // FIFO cap: oldest evicted once over MAX_CONTENT_SNAPSHOTS.
533        for id in 100..(100 + MAX_CONTENT_SNAPSHOTS) {
534            mgr.store_content_snapshot(id, Vec::new(), Vec::new());
535        }
536        assert!(mgr.content_snapshots.len() <= MAX_CONTENT_SNAPSHOTS);
537        assert!(mgr.get_content_snapshot(7).is_none(), "oldest entry evicted");
538        assert!(mgr
539            .get_content_snapshot(100 + MAX_CONTENT_SNAPSHOTS - 1)
540            .is_some());
541    }
542
543    #[test]
544    fn reinstate_undo_keeps_manager_redo_stack() {
545        let mut mgr = UndoRedoManager::new();
546        mgr.record_operation(op(1, 3).changeset, op(1, 3).pre_state);
547        let popped = mgr.pop_undo(NodeId::new(3)).unwrap();
548        mgr.push_redo(popped);
549        let redone = mgr.pop_redo(NodeId::new(3)).unwrap();
550        // Second redo entry that must survive the reinstate:
551        mgr.push_redo(op(2, 3));
552        mgr.reinstate_undo(redone);
553        assert!(mgr.can_undo(NodeId::new(3)));
554        assert!(mgr.can_redo(NodeId::new(3)), "redo stack preserved");
555    }
556}
557
558#[cfg(test)]
559mod autotest_generated {
560    use azul_core::{
561        dom::DomNodeId,
562        geom::LogicalPosition,
563        selection::{CursorAffinity, GraphemeClusterId, TextCursor},
564        styled_dom::NodeHierarchyItemId,
565        task::SystemTick,
566        window::CursorPosition,
567    };
568
569    use super::*;
570    use crate::{
571        managers::{
572            changeset::{TextOpInsertText, TextOperation},
573            NodeIdMap, NodeIdRemap,
574        },
575        text3::cache::{InlineContent, InlineSpace},
576    };
577
578    // ---------------------------------------------------------------------
579    // helpers
580    // ---------------------------------------------------------------------
581
582    fn tick(t: u64) -> Instant {
583        Instant::Tick(SystemTick { tick_counter: t })
584    }
585
586    fn cursor(run: u32, byte: u32) -> TextCursor {
587        TextCursor {
588            cluster_id: GraphemeClusterId {
589                source_run: run,
590                start_byte_in_run: byte,
591            },
592            affinity: CursorAffinity::Leading,
593        }
594    }
595
596    /// Full operation with explicitly-chosen dom / node / changeset id / text.
597    fn op_full(id: usize, dom: usize, node: usize, text: &str) -> UndoableOperation {
598        UndoableOperation {
599            changeset: TextChangeset {
600                id,
601                target: DomNodeId {
602                    dom: DomId { inner: dom },
603                    node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
604                },
605                operation: TextOperation::InsertText(TextOpInsertText {
606                    text: text.into(),
607                    position: CursorPosition::Uninitialized,
608                    new_cursor: CursorPosition::Uninitialized,
609                }),
610                timestamp: tick(u64::from(id as u32)),
611            },
612            pre_state: NodeStateSnapshot {
613                node_id: NodeId::new(node),
614                text_content: text.into(),
615                cursor_position: Some(cursor(0, 0)).into(),
616                selection_range: None.into(),
617                timestamp: tick(u64::from(id as u32)),
618            },
619        }
620    }
621
622    /// Operation on DOM 0 (the common case).
623    fn op(id: usize, node: usize) -> UndoableOperation {
624        op_full(id, 0, node, "x")
625    }
626
627    /// Operation whose changeset target node is `None` — the input every
628    /// `expect()` in this module is documented to panic on.
629    fn op_with_none_target(id: usize) -> UndoableOperation {
630        let mut o = op(id, 0);
631        o.changeset.target.node = NodeHierarchyItemId::from_crate_internal(None);
632        o
633    }
634
635    fn text_of(o: &UndoableOperation) -> &str {
636        match &o.changeset.operation {
637            TextOperation::InsertText(i) => i.text.as_str(),
638            _ => panic!("helper only builds InsertText operations"),
639        }
640    }
641
642    fn target_node(o: &UndoableOperation) -> Option<NodeId> {
643        o.changeset.target.node.into_crate_internal()
644    }
645
646    fn space(width: f32) -> InlineContent {
647        InlineContent::Space(InlineSpace {
648            width,
649            is_breaking: false,
650            is_stretchy: false,
651        })
652    }
653
654    fn space_width(c: &InlineContent) -> f32 {
655        match c {
656            InlineContent::Space(s) => s.width,
657            _ => panic!("expected an InlineContent::Space"),
658        }
659    }
660
661    fn one(c: InlineContent) -> Vec<InlineContent> {
662        vec![c]
663    }
664
665    // ---------------------------------------------------------------------
666    // NodeUndoRedoStack — constructor + invariants
667    // ---------------------------------------------------------------------
668
669    #[test]
670    fn stack_new_holds_invariants_for_extreme_node_ids() {
671        // NodeId::ZERO, a normal id, and the largest id that still survives the
672        // 1-based FFI encoding (`usize::MAX` would overflow `into_raw`).
673        for node in [0usize, 7, usize::MAX - 1] {
674            let s = NodeUndoRedoStack::new(NodeId::new(node));
675            assert_eq!(s.node_id.index(), node);
676            assert!(s.undo_stack.is_empty());
677            assert!(s.redo_stack.is_empty());
678            assert!(!s.can_undo());
679            assert!(!s.can_redo());
680            assert!(s.peek_undo().is_none());
681            assert!(s.peek_redo().is_none());
682            assert!(s.undo_stack.capacity() >= MAX_UNDO_HISTORY);
683            assert!(s.redo_stack.capacity() >= MAX_REDO_HISTORY);
684        }
685    }
686
687    #[test]
688    fn stack_draining_an_empty_stack_never_panics() {
689        let mut s = NodeUndoRedoStack::new(NodeId::new(0));
690        for _ in 0..100 {
691            assert!(s.pop_undo().is_none());
692            assert!(s.pop_redo().is_none());
693            assert!(!s.can_undo());
694            assert!(!s.can_redo());
695        }
696    }
697
698    // ---------------------------------------------------------------------
699    // NodeUndoRedoStack — bounded history (saturation, not growth)
700    // ---------------------------------------------------------------------
701
702    #[test]
703    fn undo_stack_saturates_at_max_history_and_evicts_oldest() {
704        let mut s = NodeUndoRedoStack::new(NodeId::new(1));
705        let total = MAX_UNDO_HISTORY * 3 + 7;
706        for id in 0..total {
707            s.push_undo(op(id, 1));
708        }
709        assert_eq!(s.undo_stack.len(), MAX_UNDO_HISTORY, "history is bounded");
710        // FIFO eviction: the surviving window is the LAST MAX_UNDO_HISTORY pushes.
711        assert_eq!(s.undo_stack.front().unwrap().changeset.id, total - MAX_UNDO_HISTORY);
712        assert_eq!(s.peek_undo().unwrap().changeset.id, total - 1);
713    }
714
715    #[test]
716    fn redo_stack_saturates_at_max_history_and_evicts_oldest() {
717        let mut s = NodeUndoRedoStack::new(NodeId::new(1));
718        let total = MAX_REDO_HISTORY * 2 + 3;
719        for id in 0..total {
720            s.push_redo(op(id, 1));
721        }
722        assert_eq!(s.redo_stack.len(), MAX_REDO_HISTORY);
723        assert_eq!(s.redo_stack.front().unwrap().changeset.id, total - MAX_REDO_HISTORY);
724        assert_eq!(s.peek_redo().unwrap().changeset.id, total - 1);
725    }
726
727    #[test]
728    fn push_undo_preserving_redo_is_bounded_too_and_leaves_redo_alone() {
729        let mut s = NodeUndoRedoStack::new(NodeId::new(1));
730        for id in 0..MAX_REDO_HISTORY {
731            s.push_redo(op(id, 1));
732        }
733        for id in 1000..(1000 + MAX_UNDO_HISTORY * 4) {
734            s.push_undo_preserving_redo(op(id, 1));
735        }
736        assert_eq!(s.undo_stack.len(), MAX_UNDO_HISTORY);
737        assert_eq!(
738            s.redo_stack.len(),
739            MAX_REDO_HISTORY,
740            "preserving variant must not touch the redo stack"
741        );
742        assert_eq!(s.peek_redo().unwrap().changeset.id, MAX_REDO_HISTORY - 1);
743    }
744
745    #[test]
746    fn push_undo_clears_a_full_redo_stack() {
747        let mut s = NodeUndoRedoStack::new(NodeId::new(1));
748        for id in 0..MAX_REDO_HISTORY {
749            s.push_redo(op(id, 1));
750        }
751        assert!(s.can_redo());
752        s.push_undo(op(999, 1));
753        assert!(!s.can_redo(), "a fresh edit invalidates the whole redo branch");
754        assert!(s.redo_stack.is_empty());
755        assert!(s.peek_redo().is_none());
756        assert!(s.can_undo());
757    }
758
759    // ---------------------------------------------------------------------
760    // NodeUndoRedoStack — LIFO ordering + peek is non-destructive
761    // ---------------------------------------------------------------------
762
763    #[test]
764    fn pop_undo_and_pop_redo_are_lifo_then_drain_to_none() {
765        let mut s = NodeUndoRedoStack::new(NodeId::new(2));
766        for id in 0..5 {
767            s.push_undo(op(id, 2));
768        }
769        for expected in (0..5).rev() {
770            assert_eq!(s.pop_undo().unwrap().changeset.id, expected);
771        }
772        assert!(s.pop_undo().is_none());
773
774        for id in 0..5 {
775            s.push_redo(op(id, 2));
776        }
777        for expected in (0..5).rev() {
778            assert_eq!(s.pop_redo().unwrap().changeset.id, expected);
779        }
780        assert!(s.pop_redo().is_none());
781    }
782
783    #[test]
784    fn peek_is_non_destructive_and_agrees_with_pop() {
785        let mut s = NodeUndoRedoStack::new(NodeId::new(2));
786        s.push_undo(op(1, 2));
787        s.push_undo(op(2, 2));
788        s.push_redo(op(3, 2));
789
790        for _ in 0..10 {
791            assert_eq!(s.peek_undo().unwrap().changeset.id, 2);
792            assert_eq!(s.peek_redo().unwrap().changeset.id, 3);
793        }
794        assert_eq!(s.undo_stack.len(), 2, "peek must not consume");
795        assert_eq!(s.redo_stack.len(), 1);
796        assert_eq!(s.pop_undo().unwrap().changeset.id, 2, "peek == next pop");
797        assert_eq!(s.pop_redo().unwrap().changeset.id, 3);
798    }
799
800    #[test]
801    fn can_undo_can_redo_track_emptiness_exactly() {
802        let mut s = NodeUndoRedoStack::new(NodeId::new(2));
803        assert!(!s.can_undo() && !s.can_redo());
804
805        s.push_undo(op(1, 2));
806        assert!(s.can_undo() && !s.can_redo());
807        assert_eq!(s.can_undo(), !s.undo_stack.is_empty());
808
809        s.push_redo(op(2, 2));
810        assert!(s.can_undo() && s.can_redo());
811
812        assert!(s.pop_undo().is_some());
813        assert!(!s.can_undo() && s.can_redo());
814        assert!(s.pop_redo().is_some());
815        assert!(!s.can_undo() && !s.can_redo());
816    }
817
818    // ---------------------------------------------------------------------
819    // Round-trip: what goes onto a stack comes back off byte-identical
820    // ---------------------------------------------------------------------
821
822    #[test]
823    fn undo_redo_round_trip_preserves_unicode_payload_exactly() {
824        // Combining mark, ZWJ emoji sequence, RTL override, astral plane, and a
825        // NUL byte in the middle — none of which the stacks may normalize.
826        let nasty = "a\u{0301}👩\u{200D}👩\u{200D}👧\u{202E}rtl\u{0000}end\u{FFFD}";
827        let mut s = NodeUndoRedoStack::new(NodeId::new(4));
828        s.push_undo(op_full(usize::MAX, 0, 4, nasty));
829
830        let undone = s.pop_undo().unwrap();
831        assert_eq!(text_of(&undone), nasty);
832        assert_eq!(undone.pre_state.text_content.as_str(), nasty);
833        assert_eq!(undone.changeset.id, usize::MAX, "ChangesetId::MAX survives");
834
835        s.push_redo(undone);
836        let redone = s.pop_redo().unwrap();
837        assert_eq!(text_of(&redone), nasty, "encode == decode across undo→redo");
838        assert_eq!(redone.pre_state.text_content.as_str(), nasty);
839        assert_eq!(target_node(&redone), Some(NodeId::new(4)));
840        assert_eq!(redone.pre_state.node_id, NodeId::new(4));
841    }
842
843    #[test]
844    fn round_trip_preserves_huge_text_and_extreme_timestamps() {
845        let huge: AzString = "é".repeat(50_000).into();
846        assert_eq!(huge.as_str().len(), 100_000, "2 bytes per 'é'");
847
848        let mut o = op(1, 6);
849        o.pre_state.text_content = huge.clone();
850        o.pre_state.timestamp = tick(u64::MAX);
851        o.changeset.timestamp = tick(u64::MAX);
852
853        let mut s = NodeUndoRedoStack::new(NodeId::new(6));
854        s.push_undo(o);
855        let back = s.pop_undo().unwrap();
856        assert_eq!(back.pre_state.text_content.as_str().len(), 100_000);
857        assert_eq!(back.pre_state.text_content.as_str(), huge.as_str());
858        match back.pre_state.timestamp {
859            Instant::Tick(t) => assert_eq!(t.tick_counter, u64::MAX, "u64::MAX tick unclamped"),
860            Instant::System(_) => panic!("helper builds Tick instants"),
861        }
862    }
863
864    #[test]
865    fn round_trip_preserves_nan_and_infinite_cursor_coordinates() {
866        let mut o = op(1, 6);
867        o.changeset.operation = TextOperation::InsertText(TextOpInsertText {
868            text: "q".into(),
869            position: CursorPosition::InWindow(LogicalPosition {
870                x: f32::NAN,
871                y: f32::NEG_INFINITY,
872            }),
873            new_cursor: CursorPosition::OutOfWindow(LogicalPosition {
874                x: f32::INFINITY,
875                y: -0.0,
876            }),
877        });
878
879        let mut s = NodeUndoRedoStack::new(NodeId::new(6));
880        s.push_undo(o);
881        let back = s.pop_undo().unwrap();
882        match back.changeset.operation {
883            TextOperation::InsertText(i) => {
884                match i.position {
885                    CursorPosition::InWindow(p) => {
886                        assert!(p.x.is_nan(), "NaN must not be normalized away");
887                        assert_eq!(p.y, f32::NEG_INFINITY);
888                    }
889                    _ => panic!("position variant changed across the stack"),
890                }
891                match i.new_cursor {
892                    CursorPosition::OutOfWindow(p) => {
893                        assert_eq!(p.x, f32::INFINITY);
894                        assert!(
895                            p.y.is_sign_negative(),
896                            "-0.0 must keep its sign bit through the stack"
897                        );
898                    }
899                    _ => panic!("new_cursor variant changed across the stack"),
900                }
901            }
902            _ => panic!("operation variant changed across the stack"),
903        }
904    }
905
906    // ---------------------------------------------------------------------
907    // UndoRedoManager — constructor + queries on unknown nodes
908    // ---------------------------------------------------------------------
909
910    #[test]
911    fn manager_new_and_default_are_empty_and_agree() {
912        let a = UndoRedoManager::new();
913        let b = UndoRedoManager::default();
914        for mgr in [&a, &b] {
915            assert!(mgr.node_stacks.is_empty());
916            assert!(mgr.content_snapshots.is_empty());
917        }
918        for node in [0usize, 1, usize::MAX - 1] {
919            assert!(!a.can_undo(NodeId::new(node)));
920            assert!(!a.can_redo(NodeId::new(node)));
921            assert!(a.get_stack(NodeId::new(node)).is_none());
922            assert!(a.peek_undo(NodeId::new(node)).is_none());
923            assert!(a.peek_redo(NodeId::new(node)).is_none());
924        }
925        assert!(a.get_content_snapshot(0).is_none());
926        assert!(a.get_content_snapshot(usize::MAX).is_none());
927    }
928
929    #[test]
930    fn manager_queries_on_unknown_node_return_none_without_allocating_a_stack() {
931        let mut mgr = UndoRedoManager::new();
932        for node in [0usize, 42, usize::MAX - 1] {
933            assert!(mgr.pop_undo(NodeId::new(node)).is_none());
934            assert!(mgr.pop_redo(NodeId::new(node)).is_none());
935        }
936        assert!(
937            mgr.node_stacks.is_empty(),
938            "read/pop paths must not silently create per-node stacks"
939        );
940    }
941
942    #[test]
943    fn get_or_create_stack_mut_is_idempotent_and_mutations_persist() {
944        let mut mgr = UndoRedoManager::new();
945        let boundary = NodeId::new(usize::MAX - 1);
946
947        mgr.get_or_create_stack_mut(NodeId::new(3)).push_undo(op(1, 3));
948        // Second call for the same node must reuse, not duplicate.
949        mgr.get_or_create_stack_mut(NodeId::new(3)).push_undo(op(2, 3));
950        assert_eq!(mgr.node_stacks.len(), 1);
951        assert_eq!(mgr.get_stack(NodeId::new(3)).unwrap().undo_stack.len(), 2);
952
953        mgr.get_or_create_stack_mut(boundary).push_undo(op(3, 0));
954        assert_eq!(mgr.node_stacks.len(), 2);
955        assert_eq!(mgr.get_or_create_stack_mut(boundary).node_id, boundary);
956        assert_eq!(mgr.node_stacks.len(), 2, "no duplicate for the boundary id");
957        assert!(mgr.can_undo(boundary));
958    }
959
960    // ---------------------------------------------------------------------
961    // UndoRedoManager — recording, per-node isolation, bounds
962    // ---------------------------------------------------------------------
963
964    #[test]
965    fn record_operation_is_bounded_and_isolated_per_node() {
966        let mut mgr = UndoRedoManager::new();
967        for id in 0..(MAX_UNDO_HISTORY * 2 + 5) {
968            let o = op(id, 1);
969            mgr.record_operation(o.changeset, o.pre_state);
970        }
971        for id in 500..503 {
972            let o = op(id, 2);
973            mgr.record_operation(o.changeset, o.pre_state);
974        }
975
976        assert_eq!(mgr.node_stacks.len(), 2);
977        assert_eq!(
978            mgr.get_stack(NodeId::new(1)).unwrap().undo_stack.len(),
979            MAX_UNDO_HISTORY
980        );
981        assert_eq!(mgr.get_stack(NodeId::new(2)).unwrap().undo_stack.len(), 3);
982
983        // Draining node 2 must leave node 1 untouched.
984        while mgr.pop_undo(NodeId::new(2)).is_some() {}
985        assert!(!mgr.can_undo(NodeId::new(2)));
986        assert!(mgr.can_undo(NodeId::new(1)));
987        assert_eq!(mgr.peek_undo(NodeId::new(1)).unwrap().changeset.id, MAX_UNDO_HISTORY * 2 + 4);
988    }
989
990    #[test]
991    fn manager_push_undo_clears_only_the_target_nodes_redo_stack() {
992        let mut mgr = UndoRedoManager::new();
993        mgr.push_redo(op(1, 1));
994        mgr.push_redo(op(2, 2));
995        assert!(mgr.can_redo(NodeId::new(1)) && mgr.can_redo(NodeId::new(2)));
996
997        mgr.push_undo(op(3, 1));
998        assert!(!mgr.can_redo(NodeId::new(1)), "fresh edit drops node 1's redo branch");
999        assert!(mgr.can_redo(NodeId::new(2)), "node 2 is an independent history");
1000    }
1001
1002    #[test]
1003    fn full_undo_then_redo_cycle_returns_the_same_operation() {
1004        let mut mgr = UndoRedoManager::new();
1005        let original = op_full(11, 0, 5, "hello");
1006        mgr.record_operation(original.changeset.clone(), original.pre_state.clone());
1007
1008        // Undo: pop from undo, push to redo.
1009        let undone = mgr.pop_undo(NodeId::new(5)).unwrap();
1010        assert_eq!(undone.changeset.id, 11);
1011        mgr.push_redo(undone);
1012        assert!(!mgr.can_undo(NodeId::new(5)));
1013        assert!(mgr.can_redo(NodeId::new(5)));
1014
1015        // Redo: pop from redo, reinstate onto undo.
1016        let redone = mgr.pop_redo(NodeId::new(5)).unwrap();
1017        assert_eq!(text_of(&redone), "hello", "payload survives undo→redo");
1018        mgr.reinstate_undo(redone);
1019        assert!(mgr.can_undo(NodeId::new(5)));
1020        assert!(!mgr.can_redo(NodeId::new(5)));
1021        assert_eq!(mgr.peek_undo(NodeId::new(5)).unwrap().changeset.id, 11);
1022    }
1023
1024    #[test]
1025    fn reinstate_undo_preserves_a_full_redo_stack() {
1026        let mut mgr = UndoRedoManager::new();
1027        for id in 0..MAX_REDO_HISTORY {
1028            mgr.push_redo(op(id, 9));
1029        }
1030        let redone = mgr.pop_redo(NodeId::new(9)).unwrap();
1031        mgr.reinstate_undo(redone);
1032        assert_eq!(
1033            mgr.get_stack(NodeId::new(9)).unwrap().redo_stack.len(),
1034            MAX_REDO_HISTORY - 1,
1035            "reinstating one redo must not wipe the remaining redo branch"
1036        );
1037        assert!(mgr.can_undo(NodeId::new(9)));
1038    }
1039
1040    // ---------------------------------------------------------------------
1041    // UndoRedoManager — documented panics on a None changeset target
1042    // ---------------------------------------------------------------------
1043
1044    #[test]
1045    #[should_panic(expected = "TextChangeset target node should not be None")]
1046    fn record_operation_panics_on_none_target() {
1047        let mut mgr = UndoRedoManager::new();
1048        let o = op_with_none_target(1);
1049        mgr.record_operation(o.changeset, o.pre_state);
1050    }
1051
1052    #[test]
1053    #[should_panic(expected = "TextChangeset target node should not be None")]
1054    fn manager_push_undo_panics_on_none_target() {
1055        UndoRedoManager::new().push_undo(op_with_none_target(1));
1056    }
1057
1058    #[test]
1059    #[should_panic(expected = "TextChangeset target node should not be None")]
1060    fn manager_push_redo_panics_on_none_target() {
1061        UndoRedoManager::new().push_redo(op_with_none_target(1));
1062    }
1063
1064    #[test]
1065    #[should_panic(expected = "TextChangeset target node should not be None")]
1066    fn reinstate_undo_panics_on_none_target() {
1067        UndoRedoManager::new().reinstate_undo(op_with_none_target(1));
1068    }
1069
1070    // ---------------------------------------------------------------------
1071    // Content snapshots — lookup, replacement, FIFO cap, float payloads
1072    // ---------------------------------------------------------------------
1073
1074    #[test]
1075    fn content_snapshot_does_not_swap_pre_and_post() {
1076        let mut mgr = UndoRedoManager::new();
1077        mgr.store_content_snapshot(3, one(space(1.0)), one(space(2.0)));
1078        let snap = mgr.get_content_snapshot(3).expect("stored id must be found");
1079        assert_eq!(space_width(&snap.pre[0]), 1.0, "pre must stay pre");
1080        assert_eq!(space_width(&snap.post[0]), 2.0, "post must stay post");
1081    }
1082
1083    #[test]
1084    fn content_snapshot_preserves_nan_and_infinite_widths() {
1085        let mut mgr = UndoRedoManager::new();
1086        mgr.store_content_snapshot(1, one(space(f32::NAN)), one(space(f32::INFINITY)));
1087        let snap = mgr.get_content_snapshot(1).unwrap();
1088        assert!(space_width(&snap.pre[0]).is_nan(), "NaN width round-trips");
1089        assert_eq!(space_width(&snap.post[0]), f32::INFINITY);
1090    }
1091
1092    #[test]
1093    fn content_snapshot_boundary_ids_and_misses() {
1094        let mut mgr = UndoRedoManager::new();
1095        mgr.store_content_snapshot(0, Vec::new(), Vec::new());
1096        mgr.store_content_snapshot(usize::MAX, one(space(0.0)), Vec::new());
1097
1098        assert!(mgr.get_content_snapshot(0).is_some(), "id 0 is a real key, not a sentinel");
1099        assert!(mgr.get_content_snapshot(usize::MAX).is_some());
1100        assert!(mgr.get_content_snapshot(1).is_none());
1101        assert!(mgr.get_content_snapshot(usize::MAX - 1).is_none());
1102        assert_eq!(mgr.content_snapshots.len(), 2);
1103    }
1104
1105    #[test]
1106    fn storing_the_same_id_replaces_the_entry_and_refreshes_its_recency() {
1107        let mut mgr = UndoRedoManager::new();
1108        for id in 0..MAX_CONTENT_SNAPSHOTS {
1109            mgr.store_content_snapshot(id, one(space(0.0)), Vec::new());
1110        }
1111        assert_eq!(mgr.content_snapshots.len(), MAX_CONTENT_SNAPSHOTS);
1112
1113        // Re-store the OLDEST id with a new payload: it is replaced (no duplicate)
1114        // and moves to the back of the FIFO.
1115        mgr.store_content_snapshot(0, one(space(42.0)), Vec::new());
1116        assert_eq!(mgr.content_snapshots.len(), MAX_CONTENT_SNAPSHOTS, "no duplicate key");
1117        assert_eq!(space_width(&mgr.get_content_snapshot(0).unwrap().pre[0]), 42.0);
1118
1119        // One more insert evicts id 1 (now the oldest), NOT the refreshed id 0.
1120        mgr.store_content_snapshot(9_999, Vec::new(), Vec::new());
1121        assert_eq!(mgr.content_snapshots.len(), MAX_CONTENT_SNAPSHOTS, "cap holds");
1122        assert!(mgr.get_content_snapshot(0).is_some(), "refreshed entry survived");
1123        assert!(mgr.get_content_snapshot(1).is_none(), "second-oldest evicted");
1124        assert!(mgr.get_content_snapshot(9_999).is_some());
1125    }
1126
1127    #[test]
1128    fn content_snapshot_table_never_exceeds_its_cap_under_heavy_churn() {
1129        let mut mgr = UndoRedoManager::new();
1130        for id in 0..(MAX_CONTENT_SNAPSHOTS * 10) {
1131            mgr.store_content_snapshot(id, one(space(id as f32)), one(space(0.0)));
1132            assert!(mgr.content_snapshots.len() <= MAX_CONTENT_SNAPSHOTS);
1133        }
1134        assert_eq!(mgr.content_snapshots.len(), MAX_CONTENT_SNAPSHOTS);
1135        // The surviving window is the newest MAX_CONTENT_SNAPSHOTS ids.
1136        let newest = MAX_CONTENT_SNAPSHOTS * 10 - 1;
1137        assert!(mgr.get_content_snapshot(newest).is_some());
1138        assert!(mgr.get_content_snapshot(newest - MAX_CONTENT_SNAPSHOTS).is_none());
1139    }
1140
1141    // ---------------------------------------------------------------------
1142    // remap_node_ids — the stale-manager failure mode
1143    // ---------------------------------------------------------------------
1144
1145    #[test]
1146    fn remap_shifts_stack_and_every_embedded_node_id() {
1147        let mut mgr = UndoRedoManager::new();
1148        let o = op_full(1, 0, 3, "a");
1149        mgr.record_operation(o.changeset, o.pre_state);
1150        mgr.push_redo(op_full(2, 0, 3, "b"));
1151        mgr.store_content_snapshot(1, one(space(1.0)), Vec::new());
1152
1153        // Preceding sibling deleted: node 3 becomes node 2.
1154        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
1155        mgr.remap_node_ids(DomId { inner: 0 }, &map);
1156
1157        assert!(mgr.get_stack(NodeId::new(3)).is_none(), "old key must be gone");
1158        let stack = mgr.get_stack(NodeId::new(2)).expect("stack re-keyed to the new id");
1159        assert_eq!(stack.node_id, NodeId::new(2));
1160
1161        let undo = stack.peek_undo().unwrap();
1162        assert_eq!(target_node(undo), Some(NodeId::new(2)), "changeset target remapped");
1163        assert_eq!(undo.pre_state.node_id, NodeId::new(2), "snapshot node remapped");
1164        let redo = stack.peek_redo().unwrap();
1165        assert_eq!(target_node(redo), Some(NodeId::new(2)), "redo stack remapped too");
1166        assert_eq!(redo.pre_state.node_id, NodeId::new(2));
1167
1168        assert!(
1169            mgr.get_content_snapshot(1).is_some(),
1170            "snapshot of a still-live changeset must survive the GC"
1171        );
1172    }
1173
1174    #[test]
1175    fn remap_drops_unmounted_history_and_gcs_its_snapshots() {
1176        let mut mgr = UndoRedoManager::new();
1177        let o = op_full(1, 0, 3, "a");
1178        mgr.record_operation(o.changeset, o.pre_state);
1179        mgr.store_content_snapshot(1, one(space(1.0)), Vec::new());
1180
1181        // Node 3 is not in the map → it was unmounted.
1182        let map = NodeIdMap::from_pairs([(NodeId::new(0), NodeId::new(0))]);
1183        mgr.remap_node_ids(DomId { inner: 0 }, &map);
1184
1185        assert!(mgr.node_stacks.is_empty(), "history of an unmounted node is dropped");
1186        assert!(!mgr.can_undo(NodeId::new(3)));
1187        assert!(!mgr.can_undo(NodeId::new(2)), "history must NOT re-attach to a neighbour");
1188        assert!(
1189            mgr.content_snapshots.is_empty(),
1190            "snapshots of dropped changesets are GC'd"
1191        );
1192    }
1193
1194    #[test]
1195    fn remap_leaves_stacks_belonging_to_another_dom_untouched() {
1196        let mut mgr = UndoRedoManager::new();
1197        // Stack on node 3 of DOM 1 (a *different* DOM from the one being rebuilt).
1198        let o = op_full(1, 1, 3, "a");
1199        mgr.record_operation(o.changeset, o.pre_state);
1200        mgr.store_content_snapshot(1, one(space(1.0)), Vec::new());
1201
1202        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
1203        mgr.remap_node_ids(DomId { inner: 0 }, &map);
1204
1205        let stack = mgr
1206            .get_stack(NodeId::new(3))
1207            .expect("a foreign DOM's stack keeps its key");
1208        assert_eq!(stack.node_id, NodeId::new(3));
1209        let undo = stack.peek_undo().unwrap();
1210        assert_eq!(target_node(undo), Some(NodeId::new(3)), "foreign target untouched");
1211        assert_eq!(undo.changeset.target.dom, DomId { inner: 1 });
1212        assert_eq!(undo.pre_state.node_id, NodeId::new(3));
1213        assert!(mgr.get_content_snapshot(1).is_some());
1214    }
1215
1216    #[test]
1217    fn remap_of_an_empty_stack_follows_the_map_or_drops_it() {
1218        // An empty stack carries no DOM evidence, so it is treated as belonging to
1219        // the DOM being reconciled: mapped → re-keyed, unmapped → dropped.
1220        let mut mapped = UndoRedoManager::new();
1221        mapped.get_or_create_stack_mut(NodeId::new(7));
1222        mapped.remap_node_ids(
1223            DomId { inner: 0 },
1224            &NodeIdMap::from_pairs([(NodeId::new(7), NodeId::new(9))]),
1225        );
1226        assert!(mapped.get_stack(NodeId::new(9)).is_some());
1227        assert!(mapped.get_stack(NodeId::new(7)).is_none());
1228
1229        let mut dropped = UndoRedoManager::new();
1230        dropped.get_or_create_stack_mut(NodeId::new(7));
1231        dropped.remap_node_ids(DomId { inner: 0 }, &NodeIdMap::from_pairs([]));
1232        assert!(dropped.node_stacks.is_empty());
1233    }
1234
1235    #[test]
1236    fn remap_with_an_empty_map_drops_every_stack_of_that_dom() {
1237        let mut mgr = UndoRedoManager::new();
1238        for node in 0..5 {
1239            let o = op_full(node, 0, node, "a");
1240            mgr.record_operation(o.changeset, o.pre_state);
1241        }
1242        assert_eq!(mgr.node_stacks.len(), 5);
1243
1244        mgr.remap_node_ids(DomId { inner: 0 }, &NodeIdMap::from_pairs([]));
1245        assert!(mgr.node_stacks.is_empty(), "nothing survived the rebuild");
1246        assert!(mgr.content_snapshots.is_empty());
1247    }
1248
1249    #[test]
1250    fn remap_is_idempotent_when_the_map_is_the_identity() {
1251        let mut mgr = UndoRedoManager::new();
1252        let o = op_full(1, 0, 3, "a");
1253        mgr.record_operation(o.changeset, o.pre_state);
1254
1255        let identity = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(3))]);
1256        for _ in 0..3 {
1257            mgr.remap_node_ids(DomId { inner: 0 }, &identity);
1258        }
1259        assert_eq!(mgr.node_stacks.len(), 1);
1260        let stack = mgr.get_stack(NodeId::new(3)).unwrap();
1261        assert_eq!(stack.undo_stack.len(), 1, "repeated remaps must not duplicate ops");
1262        assert_eq!(target_node(stack.peek_undo().unwrap()), Some(NodeId::new(3)));
1263    }
1264
1265    #[test]
1266    fn remap_gcs_a_snapshot_whose_changeset_was_never_recorded() {
1267        // A snapshot stored for an operation that never made it onto a stack is
1268        // collected by the very next remap — documented GC behaviour, pinned here
1269        // so a future change to the GC predicate is a visible test failure.
1270        let mut mgr = UndoRedoManager::new();
1271        mgr.store_content_snapshot(77, one(space(1.0)), Vec::new());
1272        assert!(mgr.get_content_snapshot(77).is_some());
1273
1274        mgr.remap_node_ids(DomId { inner: 0 }, &NodeIdMap::from_pairs([]));
1275        assert!(mgr.get_content_snapshot(77).is_none());
1276    }
1277
1278    // ---------------------------------------------------------------------
1279    // remap_operation (private) — direct unit tests
1280    // ---------------------------------------------------------------------
1281
1282    #[test]
1283    fn remap_operation_rewrites_target_and_snapshot_for_the_matching_dom() {
1284        let mut o = op_full(1, 0, 3, "a");
1285        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
1286        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
1287
1288        assert_eq!(target_node(&o), Some(NodeId::new(2)));
1289        assert_eq!(o.pre_state.node_id, NodeId::new(2));
1290    }
1291
1292    #[test]
1293    fn remap_operation_leaves_a_foreign_doms_target_alone() {
1294        let mut o = op_full(1, 1, 3, "a");
1295        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
1296        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
1297
1298        assert_eq!(
1299            target_node(&o),
1300            Some(NodeId::new(3)),
1301            "target in DOM 1 must not be rewritten by a DOM 0 reconciliation"
1302        );
1303        assert_eq!(o.changeset.target.dom, DomId { inner: 1 });
1304        // The bare pre_state NodeId has no DOM tag, so it still follows the map.
1305        assert_eq!(o.pre_state.node_id, NodeId::new(2));
1306    }
1307
1308    #[test]
1309    fn remap_operation_falls_back_to_the_stack_node_for_a_vanished_snapshot() {
1310        let mut o = op_full(1, 0, 3, "a");
1311        o.pre_state.node_id = NodeId::new(88); // snapshot node not in the map
1312        let map = NodeIdMap::from_pairs([(NodeId::new(3), NodeId::new(2))]);
1313        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
1314
1315        assert_eq!(target_node(&o), Some(NodeId::new(2)));
1316        assert_eq!(
1317            o.pre_state.node_id,
1318            NodeId::new(2),
1319            "unresolvable snapshot node is pinned to the surviving stack node"
1320        );
1321    }
1322
1323    #[test]
1324    fn remap_operation_handles_the_largest_encodable_node_id() {
1325        // NodeId(usize::MAX - 1) is the largest id the 1-based FFI encoding can
1326        // hold (`into_raw` computes n + 1); it must survive a remap round-trip.
1327        let boundary = NodeId::new(usize::MAX - 1);
1328        let mut o = op_full(1, 0, 3, "a");
1329        let map = NodeIdMap::from_pairs([(NodeId::new(3), boundary)]);
1330        remap_operation(&mut o, DomId { inner: 0 }, &map, boundary);
1331
1332        assert_eq!(target_node(&o), Some(boundary), "encode/decode is lossless at the boundary");
1333        assert_eq!(o.pre_state.node_id, boundary);
1334        assert_eq!(target_node(&o).unwrap().index(), usize::MAX - 1);
1335    }
1336
1337    #[test]
1338    fn remap_operation_is_idempotent() {
1339        let mut o = op_full(1, 0, 3, "a");
1340        let map = NodeIdMap::from_pairs([
1341            (NodeId::new(3), NodeId::new(2)),
1342            (NodeId::new(2), NodeId::new(2)),
1343        ]);
1344        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
1345        remap_operation(&mut o, DomId { inner: 0 }, &map, NodeId::new(2));
1346
1347        assert_eq!(target_node(&o), Some(NodeId::new(2)), "no double-shift");
1348        assert_eq!(o.pre_state.node_id, NodeId::new(2));
1349    }
1350}