Skip to main content

azul_core/
diff.rs

1//! DOM Reconciliation Module
2//!
3//! This module provides the reconciliation algorithm that compares two DOM trees
4//! and generates lifecycle events. It uses stable keys and content hashing to
5//! identify moves vs. mounts/unmounts.
6//!
7//! The reconciliation strategy is:
8//! 1. **Stable Key Match:** If `.with_key()` is used, it's an absolute match (O(1)).
9//! 2. **CSS ID Match:** If no key, use the CSS ID as key.
10//! 3. **Structural Key Match:** nth-of-type-within-parent + parent's key (recursive).
11//! 4. **Hash Match (Content Match):** Check for identical `DomNodeHash`.
12//! 5. **Structural Hash Match:** For text nodes, match by structural hash (ignoring content).
13//! 6. **Fallback:** Anything not matched is a `Mount` (new) or `Unmount` (old leftovers).
14
15use alloc::{collections::VecDeque, vec::Vec};
16use core::hash::Hash;
17
18use crate::{
19    dom::{DomId, DomNodeHash, DomNodeId, NodeData, IdOrClass},
20    events::{
21        ComponentEventFilter, EventData, EventFilter, EventPhase, EventSource, EventType,
22        LifecycleEventData, LifecycleReason, SyntheticEvent,
23    },
24    geom::LogicalRect,
25    id::NodeId,
26    styled_dom::{NodeHierarchyItemId, NodeHierarchyItem},
27    task::Instant,
28    FastHashMap,
29};
30
31/// Represents a mapping between a node in the old DOM and the new DOM.
32#[derive(Debug, Clone, Copy)]
33pub struct NodeMove {
34    /// The NodeId in the old DOM array
35    pub old_node_id: NodeId,
36    /// The NodeId in the new DOM array
37    pub new_node_id: NodeId,
38}
39
40/// The result of a DOM diff, containing lifecycle events and node mappings.
41#[derive(Debug, Clone)]
42pub struct DiffResult {
43    /// Lifecycle events generated by the diff (Mount, Unmount, Resize, Update)
44    pub events: Vec<SyntheticEvent>,
45    /// Maps Old NodeId -> New NodeId for state migration (focus, scroll, etc.)
46    pub node_moves: Vec<NodeMove>,
47}
48
49impl Default for DiffResult {
50    fn default() -> Self {
51        Self {
52            events: Vec::new(),
53            node_moves: Vec::new(),
54        }
55    }
56}
57
58/// Calculate the reconciliation key for a node using the priority hierarchy:
59/// 1. Explicit key (set via `.with_key()`)
60/// 2. CSS ID (set via `.with_id("my-id")`)
61/// 3. Structural key: nth-of-type-within-parent + parent's reconciliation key
62///
63/// The structural key prevents incorrect matching when nodes are inserted
64/// before existing nodes (e.g., prepending items to a list).
65///
66/// # Arguments
67/// * `node_data` - Slice of all node data
68/// * `hierarchy` - Slice of node hierarchy (parent/child relationships)
69/// * `node_id` - The node to calculate the key for
70///
71/// # Returns
72/// A 64-bit key that uniquely identifies this node's logical position in the tree.
73pub fn calculate_reconciliation_key(
74    node_data: &[NodeData],
75    hierarchy: &[NodeHierarchyItem],
76    node_id: NodeId,
77) -> u64 {
78    use highway::{HighwayHash, HighwayHasher, Key};
79    
80    let node = &node_data[node_id.index()];
81    
82    // Priority 1: Explicit key
83    if let Some(key) = node.get_key() {
84        return key;
85    }
86    
87    // Priority 2: CSS ID
88    for id_or_class in node.ids_and_classes.as_ref().iter() {
89        if let IdOrClass::Id(id) = id_or_class {
90            let mut hasher = HighwayHasher::new(Key([0; 4]));
91            id.as_str().hash(&mut hasher);
92            return hasher.finalize64();
93        }
94    }
95    
96    // Priority 3: Structural key = nth-of-type-within-parent + parent key
97    let mut hasher = HighwayHasher::new(Key([0; 4]));
98    
99    // Hash node type discriminant and classes (nth-of-type logic)
100    core::mem::discriminant(node.get_node_type()).hash(&mut hasher);
101    for id_or_class in node.ids_and_classes.as_ref().iter() {
102        if let IdOrClass::Class(class) = id_or_class {
103            class.as_str().hash(&mut hasher);
104        }
105    }
106    
107    // Calculate sibling index (nth-of-type within parent)
108    if let Some(hierarchy_item) = hierarchy.get(node_id.index()) {
109        if let Some(parent_id) = hierarchy_item.parent_id() {
110            // Count siblings of same type before this node
111            let mut sibling_index: usize = 0;
112            let parent_hierarchy = &hierarchy[parent_id.index()];
113            
114            // Walk siblings from first child to this node
115            let mut current = parent_hierarchy.first_child_id(parent_id);
116            while let Some(sibling_id) = current {
117                if sibling_id == node_id {
118                    break;
119                }
120                // Check if sibling has same type/classes
121                let sibling = &node_data[sibling_id.index()];
122                if core::mem::discriminant(sibling.get_node_type()) 
123                    == core::mem::discriminant(node.get_node_type()) 
124                {
125                    sibling_index += 1;
126                }
127                current = hierarchy[sibling_id.index()].next_sibling_id();
128            }
129            
130            sibling_index.hash(&mut hasher);
131            
132            // Recursively include parent's key
133            let parent_key = calculate_reconciliation_key(node_data, hierarchy, parent_id);
134            parent_key.hash(&mut hasher);
135        }
136    }
137    
138    hasher.finalize64()
139}
140
141/// Precompute reconciliation keys for all nodes in a DOM tree.
142///
143/// This should be called once before reconciliation to compute stable keys
144/// for all nodes. Keys are computed using the hierarchy:
145/// 1. Explicit key → 2. CSS ID → 3. Structural key (nth-of-type + parent key)
146///
147/// # Returns
148/// A map from NodeId to its reconciliation key.
149pub fn precompute_reconciliation_keys(
150    node_data: &[NodeData],
151    hierarchy: &[NodeHierarchyItem],
152) -> FastHashMap<NodeId, u64> {
153    let mut keys = FastHashMap::default();
154    for idx in 0..node_data.len() {
155        let node_id = NodeId::new(idx);
156        let key = calculate_reconciliation_key(node_data, hierarchy, node_id);
157        keys.insert(node_id, key);
158    }
159    keys
160}
161
162/// Calculates the difference between two DOM frames and generates lifecycle events.
163///
164/// This is the main entry point for DOM reconciliation. It compares the old and new
165/// DOM trees and produces:
166/// - Mount events for new nodes
167/// - Unmount events for removed nodes
168/// - Resize events for nodes whose bounds changed
169/// - Update events for nodes whose content changed (when matched by key)
170///
171/// # Arguments
172/// * `old_node_data` - Node data from the previous frame
173/// * `new_node_data` - Node data from the current frame
174/// * `old_layout` - Layout bounds from the previous frame
175/// * `new_layout` - Layout bounds from the current frame
176/// * `dom_id` - The DOM identifier
177/// * `timestamp` - Current timestamp for events
178pub fn reconcile_dom(
179    old_node_data: &[NodeData],
180    new_node_data: &[NodeData],
181    old_layout: &FastHashMap<NodeId, LogicalRect>,
182    new_layout: &FastHashMap<NodeId, LogicalRect>,
183    dom_id: DomId,
184    timestamp: Instant,
185) -> DiffResult {
186    let mut result = DiffResult::default();
187
188    // --- STEP 1: INDEX THE OLD DOM ---
189    // Create lookups to find old nodes by Key or by Hash.
190    // 
191    // IMPORTANT: We use TWO hash indexes:
192    // 1. Content Hash (calculate_node_data_hash) - for exact matching including text content
193    // 2. Structural Hash (calculate_structural_hash) - for text nodes where content may change
194    //
195    // This allows Text("Hello") to match Text("Hello World") as a structural match,
196    // preserving cursor/selection state during text editing.
197
198    let mut old_keyed: FastHashMap<u64, NodeId> = FastHashMap::default();
199    let mut old_hashed: FastHashMap<DomNodeHash, VecDeque<NodeId>> = FastHashMap::default();
200    let mut old_structural: FastHashMap<DomNodeHash, VecDeque<NodeId>> = FastHashMap::default();
201    let mut old_nodes_consumed = vec![false; old_node_data.len()];
202
203    for (idx, node) in old_node_data.iter().enumerate() {
204        let id = NodeId::new(idx);
205
206        if let Some(key) = node.get_key() {
207            // Priority 1: Explicit Key
208            old_keyed.insert(key, id);
209        } else {
210            // Priority 2: Content Hash (exact match)
211            let hash = node.calculate_node_data_hash();
212            old_hashed.entry(hash).or_default().push_back(id);
213            
214            // Priority 3: Structural Hash (for text node matching)
215            let structural_hash = node.calculate_structural_hash();
216            old_structural.entry(structural_hash).or_default().push_back(id);
217        }
218    }
219
220    // --- STEP 2: ITERATE NEW DOM AND CLAIM MATCHES ---
221
222    for (new_idx, new_node) in new_node_data.iter().enumerate() {
223        let new_id = NodeId::new(new_idx);
224        let mut matched_old_id = None;
225
226        // A. Try Match by Key
227        if let Some(key) = new_node.get_key() {
228            if let Some(&old_id) = old_keyed.get(&key) {
229                if !old_nodes_consumed[old_id.index()] {
230                    matched_old_id = Some(old_id);
231                }
232            }
233        }
234        // B. Try Match by Content Hash first (exact match - The "Automagic" Reordering)
235        else {
236            let hash = new_node.calculate_node_data_hash();
237
238            // Get the queue of old nodes with this identical content
239            if let Some(queue) = old_hashed.get_mut(&hash) {
240                // Find first non-consumed node in queue
241                while let Some(old_id) = queue.front() {
242                    if !old_nodes_consumed[old_id.index()] {
243                        matched_old_id = Some(*old_id);
244                        queue.pop_front();
245                        break;
246                    } else {
247                        queue.pop_front();
248                    }
249                }
250            }
251            
252            // C. If no exact match, try Structural Hash (for text nodes with changed content)
253            if matched_old_id.is_none() {
254                let structural_hash = new_node.calculate_structural_hash();
255                if let Some(queue) = old_structural.get_mut(&structural_hash) {
256                    while let Some(old_id) = queue.front() {
257                        if !old_nodes_consumed[old_id.index()] {
258                            matched_old_id = Some(*old_id);
259                            queue.pop_front();
260                            break;
261                        } else {
262                            queue.pop_front();
263                        }
264                    }
265                }
266            }
267        }
268
269        // --- STEP 3: PROCESS MATCH OR MOUNT ---
270
271        if let Some(old_id) = matched_old_id {
272            // FOUND A MATCH (It might be at a different index, but it's the "same" node)
273
274            old_nodes_consumed[old_id.index()] = true;
275            result.node_moves.push(NodeMove {
276                old_node_id: old_id,
277                new_node_id: new_id,
278            });
279
280            // Check for Resize
281            let old_rect = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
282            let new_rect = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
283
284            if old_rect.size != new_rect.size {
285                // Fire Resize Event
286                if has_resize_callback(new_node) {
287                    result.events.push(create_lifecycle_event(
288                        EventType::Resize,
289                        new_id,
290                        dom_id,
291                        &timestamp,
292                        LifecycleEventData {
293                            reason: LifecycleReason::Resize,
294                            previous_bounds: Some(old_rect),
295                            current_bounds: new_rect,
296                        },
297                    ));
298                }
299            }
300
301            // If matched by Key, the content might have changed, so we should check hash equality.
302            if new_node.get_key().is_some() {
303                let old_hash = old_node_data[old_id.index()].calculate_node_data_hash();
304                let new_hash = new_node.calculate_node_data_hash();
305
306                if old_hash != new_hash && has_update_callback(new_node) {
307                    result.events.push(create_lifecycle_event(
308                        EventType::Update,
309                        new_id,
310                        dom_id,
311                        &timestamp,
312                        LifecycleEventData {
313                            reason: LifecycleReason::Update,
314                            previous_bounds: Some(old_rect),
315                            current_bounds: new_rect,
316                        },
317                    ));
318                }
319            }
320        } else {
321            // NO MATCH FOUND -> MOUNT (New Node)
322            if has_mount_callback(new_node) {
323                let bounds = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
324                result.events.push(create_lifecycle_event(
325                    EventType::Mount,
326                    new_id,
327                    dom_id,
328                    &timestamp,
329                    LifecycleEventData {
330                        reason: LifecycleReason::InitialMount,
331                        previous_bounds: None,
332                        current_bounds: bounds,
333                    },
334                ));
335            }
336        }
337    }
338
339    // --- STEP 4: CLEANUP (UNMOUNTS) ---
340    // Any old node that wasn't claimed is effectively destroyed.
341
342    for (old_idx, consumed) in old_nodes_consumed.iter().enumerate() {
343        if !consumed {
344            let old_id = NodeId::new(old_idx);
345            let old_node = &old_node_data[old_idx];
346
347            if has_unmount_callback(old_node) {
348                let bounds = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
349                result.events.push(create_lifecycle_event(
350                    EventType::Unmount,
351                    old_id,
352                    dom_id,
353                    &timestamp,
354                    LifecycleEventData {
355                        reason: LifecycleReason::InitialMount, // Context implies unmount
356                        previous_bounds: Some(bounds),
357                        current_bounds: LogicalRect::zero(),
358                    },
359                ));
360            }
361        }
362    }
363
364    result
365}
366
367/// Creates a lifecycle event with all necessary fields.
368fn create_lifecycle_event(
369    event_type: EventType,
370    node_id: NodeId,
371    dom_id: DomId,
372    timestamp: &Instant,
373    data: LifecycleEventData,
374) -> SyntheticEvent {
375    let dom_node_id = DomNodeId {
376        dom: dom_id,
377        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
378    };
379    SyntheticEvent {
380        event_type,
381        source: EventSource::Lifecycle,
382        phase: EventPhase::Target,
383        target: dom_node_id,
384        current_target: dom_node_id,
385        timestamp: timestamp.clone(),
386        data: EventData::Lifecycle(data),
387        stopped: false,
388        stopped_immediate: false,
389        prevented_default: false,
390    }
391}
392
393/// Check if the node has an AfterMount callback registered.
394fn has_mount_callback(node: &NodeData) -> bool {
395    node.get_callbacks().iter().any(|cb| {
396        matches!(
397            cb.event,
398            EventFilter::Component(ComponentEventFilter::AfterMount)
399        )
400    })
401}
402
403/// Check if the node has a BeforeUnmount callback registered.
404fn has_unmount_callback(node: &NodeData) -> bool {
405    node.get_callbacks().iter().any(|cb| {
406        matches!(
407            cb.event,
408            EventFilter::Component(ComponentEventFilter::BeforeUnmount)
409        )
410    })
411}
412
413/// Check if the node has a NodeResized callback registered.
414fn has_resize_callback(node: &NodeData) -> bool {
415    node.get_callbacks().iter().any(|cb| {
416        matches!(
417            cb.event,
418            EventFilter::Component(ComponentEventFilter::NodeResized)
419        )
420    })
421}
422
423/// Check if the node has any lifecycle callback that would respond to updates.
424fn has_update_callback(node: &NodeData) -> bool {
425    // For now, we use Selected as a placeholder for "update" events
426    // This could be extended to a dedicated UpdateCallback in the future
427    node.get_callbacks().iter().any(|cb| {
428        matches!(
429            cb.event,
430            EventFilter::Component(ComponentEventFilter::Selected)
431        )
432    })
433}
434
435/// Migrate state (focus, scroll, etc.) from old node IDs to new node IDs.
436///
437/// This function should be called after reconciliation to update any state
438/// that references old NodeIds to use the new NodeIds.
439///
440/// # Example
441/// ```rust
442/// let diff = reconcile_dom(...);
443/// let migration_map = create_migration_map(&diff.node_moves);
444/// 
445/// // Migrate focus
446/// if let Some(current_focus) = focus_manager.focused_node {
447///     if let Some(&new_id) = migration_map.get(&current_focus) {
448///         focus_manager.focused_node = Some(new_id);
449///     } else {
450///         // Focused node was unmounted, clear focus
451///         focus_manager.focused_node = None;
452///     }
453/// }
454/// ```
455pub fn create_migration_map(node_moves: &[NodeMove]) -> FastHashMap<NodeId, NodeId> {
456    let mut map = FastHashMap::default();
457    for m in node_moves {
458        map.insert(m.old_node_id, m.new_node_id);
459    }
460    map
461}
462
463/// Executes state migration between the old DOM and the new DOM based on diff results.
464///
465/// This iterates through matched nodes. If a match has BOTH a merge callback AND a dataset,
466/// it executes the callback to transfer state from the old node to the new node.
467///
468/// This must be called **before** the old DOM is dropped, because we need to access its data.
469///
470/// # Arguments
471/// * `old_node_data` - Mutable reference to the old DOM's node data (source of heavy state)
472/// * `new_node_data` - Mutable reference to the new DOM's node data (target for heavy state)
473/// * `node_moves` - The matched nodes from the reconciliation diff
474///
475/// # Example
476/// ```rust,ignore
477/// let diff_result = reconcile_dom(&old_data, &new_data, ...);
478/// 
479/// // Execute state migration BEFORE old_dom is dropped
480/// transfer_states(&mut old_data, &mut new_data, &diff_result.node_moves);
481/// 
482/// // Now safe to drop old_dom - heavy resources have been transferred
483/// drop(old_dom);
484/// ```
485pub fn transfer_states(
486    old_node_data: &mut [NodeData],
487    new_node_data: &mut [NodeData],
488    node_moves: &[NodeMove],
489) {
490    use crate::refany::OptionRefAny;
491
492    for movement in node_moves {
493        let old_idx = movement.old_node_id.index();
494        let new_idx = movement.new_node_id.index();
495
496        // Bounds check
497        if old_idx >= old_node_data.len() || new_idx >= new_node_data.len() {
498            continue;
499        }
500
501        // 1. Check if the NEW node has requested a merge callback
502        let merge_callback = match new_node_data[new_idx].get_merge_callback() {
503            Some(cb) => cb,
504            None => continue, // No merge callback, skip
505        };
506
507        // 2. Check if BOTH nodes have datasets
508        // We need to temporarily take the datasets to satisfy borrow checker
509        let old_dataset = core::mem::replace(
510            &mut old_node_data[old_idx].dataset, 
511            OptionRefAny::None
512        );
513        let new_dataset = core::mem::replace(
514            &mut new_node_data[new_idx].dataset, 
515            OptionRefAny::None
516        );
517
518        match (new_dataset, old_dataset) {
519            (OptionRefAny::Some(new_data), OptionRefAny::Some(old_data)) => {
520                // 3. EXECUTE THE MERGE CALLBACK
521                // The callback receives both datasets and returns the merged result
522                let merged = (merge_callback.cb)(new_data, old_data);
523                
524                // 4. Store the merged result back in the new node
525                new_node_data[new_idx].dataset = OptionRefAny::Some(merged);
526            }
527            (new_ds, old_ds) => {
528                // One or both datasets missing - restore what we had
529                new_node_data[new_idx].dataset = new_ds;
530                old_node_data[old_idx].dataset = old_ds;
531            }
532        }
533    }
534}
535
536/// Calculate a stable key for a contenteditable node using the hierarchy:
537///
538/// 1. **Explicit Key** - If `.with_key()` was called, use that
539/// 2. **CSS ID** - If the node has a CSS ID (e.g., `#my-editor`), hash that
540/// 3. **Structural Key** - Hash of `(nth-of-type, parent_key)` recursively
541///
542/// The structural key prevents shifting when elements are inserted before siblings.
543/// For example, in `<div><p>A</p><p contenteditable>B</p></div>`, if we insert
544/// a new `<p>` at the start, the contenteditable `<p>` becomes nth-child(3) but
545/// its nth-of-type stays stable (it's still the 2nd `<p>`).
546///
547/// # Arguments
548/// * `node_data` - All nodes in the DOM
549/// * `hierarchy` - Parent-child relationships
550/// * `node_id` - The node to calculate the key for
551///
552/// # Returns
553/// A stable u64 key for the node
554pub fn calculate_contenteditable_key(
555    node_data: &[NodeData],
556    hierarchy: &[crate::styled_dom::NodeHierarchyItem],
557    node_id: NodeId,
558) -> u64 {
559    use highway::{HighwayHash, HighwayHasher, Key};
560    use crate::dom::IdOrClass;
561    
562    let node = &node_data[node_id.index()];
563    
564    // Priority 1: Explicit key (from .with_key())
565    if let Some(explicit_key) = node.get_key() {
566        return explicit_key;
567    }
568    
569    // Priority 2: CSS ID
570    for id_or_class in node.get_ids_and_classes().as_ref().iter() {
571        if let IdOrClass::Id(id) = id_or_class {
572            let mut hasher = HighwayHasher::new(Key([1; 4])); // Different seed for ID keys
573            hasher.append(id.as_str().as_bytes());
574            return hasher.finalize64();
575        }
576    }
577    
578    // Priority 3: Structural key = (nth-of-type, classes, parent_key)
579    let mut hasher = HighwayHasher::new(Key([2; 4])); // Different seed for structural keys
580    
581    // Get parent and calculate its key recursively
582    let parent_key = if let Some(parent_id) = hierarchy.get(node_id.index()).and_then(|h| h.parent_id()) {
583        calculate_contenteditable_key(node_data, hierarchy, parent_id)
584    } else {
585        0u64 // Root node
586    };
587    hasher.append(&parent_key.to_le_bytes());
588    
589    // Calculate nth-of-type (count siblings of same node type before this one)
590    // We compare discriminants directly without hashing
591    let node_discriminant = core::mem::discriminant(node.get_node_type());
592    let nth_of_type = if let Some(parent_id) = hierarchy.get(node_id.index()).and_then(|h| h.parent_id()) {
593        // Count siblings with same node type that come before this node
594        let mut count = 0u32;
595        let mut sibling_id = hierarchy.get(parent_id.index()).and_then(|h| h.first_child_id(parent_id));
596        while let Some(sib_id) = sibling_id {
597            if sib_id == node_id {
598                break;
599            }
600            let sibling_discriminant = core::mem::discriminant(node_data[sib_id.index()].get_node_type());
601            if sibling_discriminant == node_discriminant {
602                count += 1;
603            }
604            sibling_id = hierarchy.get(sib_id.index()).and_then(|h| h.next_sibling_id());
605        }
606        count
607    } else {
608        0
609    };
610    
611    hasher.append(&nth_of_type.to_le_bytes());
612    
613    // Hash the node type using its Debug representation as a stable identifier
614    // This works because NodeType implements Debug
615    #[cfg(feature = "std")]
616    {
617        let type_str = format!("{:?}", node_discriminant);
618        hasher.append(type_str.as_bytes());
619    }
620    #[cfg(not(feature = "std"))]
621    {
622        // For no_std, use the memory representation of the discriminant
623        // NodeType variants are numbered 0..N, and discriminant stores this
624        let discriminant_bytes: [u8; core::mem::size_of::<core::mem::Discriminant<crate::dom::NodeType>>()] = 
625            unsafe { core::mem::transmute(node_discriminant) };
626        hasher.append(&discriminant_bytes);
627    }
628    
629    // Also hash the classes for additional stability
630    for id_or_class in node.get_ids_and_classes().as_ref().iter() {
631        if let IdOrClass::Class(class) = id_or_class {
632            hasher.append(class.as_str().as_bytes());
633        }
634    }
635    
636    hasher.finalize64()
637}
638
639/// Reconcile cursor byte position when text content changes.
640///
641/// This function maps a cursor position from old text to new text, preserving
642/// the cursor's logical position as much as possible:
643///
644/// 1. If cursor is in unchanged prefix → stays at same byte offset
645/// 2. If cursor is in unchanged suffix → adjusts by length difference
646/// 3. If cursor is in changed region → places at end of new content
647///
648/// # Arguments
649/// * `old_text` - The previous text content
650/// * `new_text` - The new text content
651/// * `old_cursor_byte` - Cursor byte offset in old text
652///
653/// # Returns
654/// The reconciled cursor byte offset in new text
655///
656/// # Example
657/// ```rust,ignore
658/// let old_text = "Hello";
659/// let new_text = "Hello World";
660/// let old_cursor = 5; // cursor at end of "Hello"
661/// let new_cursor = reconcile_cursor_position(old_text, new_text, old_cursor);
662/// assert_eq!(new_cursor, 5); // cursor stays at same position (prefix unchanged)
663/// ```
664pub fn reconcile_cursor_position(
665    old_text: &str,
666    new_text: &str,
667    old_cursor_byte: usize,
668) -> usize {
669    // If texts are equal, cursor is unchanged
670    if old_text == new_text {
671        return old_cursor_byte;
672    }
673    
674    // Empty old text - place cursor at end of new text
675    if old_text.is_empty() {
676        return new_text.len();
677    }
678    
679    // Empty new text - place cursor at 0
680    if new_text.is_empty() {
681        return 0;
682    }
683    
684    // Find common prefix (how many bytes from the start are identical)
685    let common_prefix_bytes = old_text
686        .bytes()
687        .zip(new_text.bytes())
688        .take_while(|(a, b)| a == b)
689        .count();
690    
691    // If cursor was in the unchanged prefix, it stays at the same byte offset
692    if old_cursor_byte <= common_prefix_bytes {
693        return old_cursor_byte.min(new_text.len());
694    }
695    
696    // Find common suffix (how many bytes from the end are identical)
697    let common_suffix_bytes = old_text
698        .bytes()
699        .rev()
700        .zip(new_text.bytes().rev())
701        .take_while(|(a, b)| a == b)
702        .count();
703    
704    // Calculate where the suffix starts in old and new text
705    let old_suffix_start = old_text.len().saturating_sub(common_suffix_bytes);
706    let new_suffix_start = new_text.len().saturating_sub(common_suffix_bytes);
707    
708    // If cursor was in the unchanged suffix, adjust by length difference
709    if old_cursor_byte >= old_suffix_start {
710        let offset_from_end = old_text.len() - old_cursor_byte;
711        return new_text.len().saturating_sub(offset_from_end);
712    }
713    
714    // Cursor was in the changed region - place at end of inserted content
715    // This handles insertions (cursor moves with new text) and deletions (cursor at edit point)
716    new_suffix_start
717}
718
719/// Get the text content from a NodeData if it's a Text node.
720///
721/// Returns the text string if the node is `NodeType::Text`, otherwise `None`.
722pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
723    if let crate::dom::NodeType::Text(ref text) = node.get_node_type() {
724        Some(text.as_str())
725    } else {
726        None
727    }
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use crate::dom::NodeData;
734
735    #[test]
736    fn test_simple_mount() {
737        let old_data: Vec<NodeData> = vec![];
738        let new_data = vec![NodeData::create_div()];
739        
740        let old_layout = FastHashMap::default();
741        let mut new_layout = FastHashMap::default();
742        new_layout.insert(NodeId::new(0), LogicalRect::zero());
743        
744        let result = reconcile_dom(
745            &old_data,
746            &new_data,
747            &old_layout,
748            &new_layout,
749            DomId { inner: 0 },
750            Instant::now(),
751        );
752        
753        // No mount event because no callback is registered
754        assert!(result.events.is_empty());
755        assert!(result.node_moves.is_empty());
756    }
757
758    #[test]
759    fn test_identical_nodes_match() {
760        let div = NodeData::create_div();
761        let old_data = vec![div.clone()];
762        let new_data = vec![div.clone()];
763        
764        let mut old_layout = FastHashMap::default();
765        old_layout.insert(NodeId::new(0), LogicalRect::zero());
766        let mut new_layout = FastHashMap::default();
767        new_layout.insert(NodeId::new(0), LogicalRect::zero());
768        
769        let result = reconcile_dom(
770            &old_data,
771            &new_data,
772            &old_layout,
773            &new_layout,
774            DomId { inner: 0 },
775            Instant::now(),
776        );
777        
778        // Should match by hash, no lifecycle events
779        assert!(result.events.is_empty());
780        assert_eq!(result.node_moves.len(), 1);
781        assert_eq!(result.node_moves[0].old_node_id, NodeId::new(0));
782        assert_eq!(result.node_moves[0].new_node_id, NodeId::new(0));
783    }
784
785    #[test]
786    fn test_reorder_by_hash() {
787        use azul_css::AzString;
788        
789        let mut div_a = NodeData::create_div();
790        div_a.add_class(AzString::from("a"));
791        let mut div_b = NodeData::create_div();
792        div_b.add_class(AzString::from("b"));
793        
794        // Old: [A, B], New: [B, A]
795        let old_data = vec![div_a.clone(), div_b.clone()];
796        let new_data = vec![div_b.clone(), div_a.clone()];
797        
798        let mut old_layout = FastHashMap::default();
799        old_layout.insert(NodeId::new(0), LogicalRect::zero());
800        old_layout.insert(NodeId::new(1), LogicalRect::zero());
801        
802        let mut new_layout = FastHashMap::default();
803        new_layout.insert(NodeId::new(0), LogicalRect::zero());
804        new_layout.insert(NodeId::new(1), LogicalRect::zero());
805        
806        let result = reconcile_dom(
807            &old_data,
808            &new_data,
809            &old_layout,
810            &new_layout,
811            DomId { inner: 0 },
812            Instant::now(),
813        );
814        
815        // Both should match by hash (reorder detected)
816        assert!(result.events.is_empty());
817        assert_eq!(result.node_moves.len(), 2);
818        
819        // B (old index 1) -> B (new index 0)
820        assert!(result.node_moves.iter().any(|m| 
821            m.old_node_id == NodeId::new(1) && m.new_node_id == NodeId::new(0)
822        ));
823        // A (old index 0) -> A (new index 1)
824        assert!(result.node_moves.iter().any(|m| 
825            m.old_node_id == NodeId::new(0) && m.new_node_id == NodeId::new(1)
826        ));
827    }
828
829    // ========== EDGE CASE TESTS ==========
830
831    #[test]
832    fn test_empty_to_empty() {
833        let old_data: Vec<NodeData> = vec![];
834        let new_data: Vec<NodeData> = vec![];
835        
836        let result = reconcile_dom(
837            &old_data,
838            &new_data,
839            &FastHashMap::default(),
840            &FastHashMap::default(),
841            DomId { inner: 0 },
842            Instant::now(),
843        );
844        
845        assert!(result.events.is_empty());
846        assert!(result.node_moves.is_empty());
847    }
848
849    #[test]
850    fn test_all_nodes_removed() {
851        let old_data = vec![
852            NodeData::create_div(),
853            NodeData::create_div(),
854            NodeData::create_div(),
855        ];
856        let new_data: Vec<NodeData> = vec![];
857        
858        let mut old_layout = FastHashMap::default();
859        for i in 0..3 {
860            old_layout.insert(NodeId::new(i), LogicalRect::zero());
861        }
862        
863        let result = reconcile_dom(
864            &old_data,
865            &new_data,
866            &old_layout,
867            &FastHashMap::default(),
868            DomId { inner: 0 },
869            Instant::now(),
870        );
871        
872        // No events because no callbacks, but no node moves either
873        assert!(result.events.is_empty());
874        assert!(result.node_moves.is_empty());
875    }
876
877    #[test]
878    fn test_all_nodes_added() {
879        let old_data: Vec<NodeData> = vec![];
880        let new_data = vec![
881            NodeData::create_div(),
882            NodeData::create_div(),
883            NodeData::create_div(),
884        ];
885        
886        let mut new_layout = FastHashMap::default();
887        for i in 0..3 {
888            new_layout.insert(NodeId::new(i), LogicalRect::zero());
889        }
890        
891        let result = reconcile_dom(
892            &old_data,
893            &new_data,
894            &FastHashMap::default(),
895            &new_layout,
896            DomId { inner: 0 },
897            Instant::now(),
898        );
899        
900        // No events because no callbacks, but no node moves (all are new)
901        assert!(result.events.is_empty());
902        assert!(result.node_moves.is_empty());
903    }
904
905    #[test]
906    fn test_keyed_node_match() {
907        // Create two nodes with the same key but different content
908        let mut old_node = NodeData::create_div();
909        old_node.set_key("my-key");
910        
911        let mut new_node = NodeData::create_div();
912        new_node.set_key("my-key");
913        new_node.add_class(azul_css::AzString::from("updated"));
914        
915        let old_data = vec![old_node];
916        let new_data = vec![new_node];
917        
918        let mut old_layout = FastHashMap::default();
919        old_layout.insert(NodeId::new(0), LogicalRect::zero());
920        let mut new_layout = FastHashMap::default();
921        new_layout.insert(NodeId::new(0), LogicalRect::zero());
922        
923        let result = reconcile_dom(
924            &old_data,
925            &new_data,
926            &old_layout,
927            &new_layout,
928            DomId { inner: 0 },
929            Instant::now(),
930        );
931        
932        // Should match by key even though hash is different
933        assert_eq!(result.node_moves.len(), 1);
934        assert_eq!(result.node_moves[0].old_node_id, NodeId::new(0));
935        assert_eq!(result.node_moves[0].new_node_id, NodeId::new(0));
936    }
937
938    #[test]
939    fn test_keyed_reorder() {
940        let mut node_a = NodeData::create_div();
941        node_a.set_key("key-a");
942        let mut node_b = NodeData::create_div();
943        node_b.set_key("key-b");
944        let mut node_c = NodeData::create_div();
945        node_c.set_key("key-c");
946        
947        // Old: [A, B, C], New: [C, B, A]
948        let old_data = vec![node_a.clone(), node_b.clone(), node_c.clone()];
949        let new_data = vec![node_c.clone(), node_b.clone(), node_a.clone()];
950        
951        let mut old_layout = FastHashMap::default();
952        let mut new_layout = FastHashMap::default();
953        for i in 0..3 {
954            old_layout.insert(NodeId::new(i), LogicalRect::zero());
955            new_layout.insert(NodeId::new(i), LogicalRect::zero());
956        }
957        
958        let result = reconcile_dom(
959            &old_data,
960            &new_data,
961            &old_layout,
962            &new_layout,
963            DomId { inner: 0 },
964            Instant::now(),
965        );
966        
967        assert_eq!(result.node_moves.len(), 3);
968        
969        // C: old[2] -> new[0]
970        assert!(result.node_moves.iter().any(|m| 
971            m.old_node_id == NodeId::new(2) && m.new_node_id == NodeId::new(0)
972        ));
973        // B: old[1] -> new[1] (unchanged position)
974        assert!(result.node_moves.iter().any(|m| 
975            m.old_node_id == NodeId::new(1) && m.new_node_id == NodeId::new(1)
976        ));
977        // A: old[0] -> new[2]
978        assert!(result.node_moves.iter().any(|m| 
979            m.old_node_id == NodeId::new(0) && m.new_node_id == NodeId::new(2)
980        ));
981    }
982
983    #[test]
984    fn test_identical_nodes_fifo() {
985        // 5 identical divs - should match FIFO
986        let div = NodeData::create_div();
987        let old_data = vec![div.clone(), div.clone(), div.clone()];
988        let new_data = vec![div.clone(), div.clone()]; // Remove last one
989        
990        let mut old_layout = FastHashMap::default();
991        let mut new_layout = FastHashMap::default();
992        for i in 0..3 {
993            old_layout.insert(NodeId::new(i), LogicalRect::zero());
994        }
995        for i in 0..2 {
996            new_layout.insert(NodeId::new(i), LogicalRect::zero());
997        }
998        
999        let result = reconcile_dom(
1000            &old_data,
1001            &new_data,
1002            &old_layout,
1003            &new_layout,
1004            DomId { inner: 0 },
1005            Instant::now(),
1006        );
1007        
1008        // First two should match (FIFO), third is unmounted
1009        assert_eq!(result.node_moves.len(), 2);
1010        assert!(result.node_moves.iter().any(|m| 
1011            m.old_node_id == NodeId::new(0) && m.new_node_id == NodeId::new(0)
1012        ));
1013        assert!(result.node_moves.iter().any(|m| 
1014            m.old_node_id == NodeId::new(1) && m.new_node_id == NodeId::new(1)
1015        ));
1016    }
1017
1018    #[test]
1019    fn test_insert_at_beginning() {
1020        use azul_css::AzString;
1021        
1022        let mut div_a = NodeData::create_div();
1023        div_a.add_class(AzString::from("a"));
1024        let mut div_b = NodeData::create_div();
1025        div_b.add_class(AzString::from("b"));
1026        let mut div_new = NodeData::create_div();
1027        div_new.add_class(AzString::from("new"));
1028        
1029        // Old: [A, B], New: [NEW, A, B]
1030        let old_data = vec![div_a.clone(), div_b.clone()];
1031        let new_data = vec![div_new.clone(), div_a.clone(), div_b.clone()];
1032        
1033        let mut old_layout = FastHashMap::default();
1034        let mut new_layout = FastHashMap::default();
1035        for i in 0..2 {
1036            old_layout.insert(NodeId::new(i), LogicalRect::zero());
1037        }
1038        for i in 0..3 {
1039            new_layout.insert(NodeId::new(i), LogicalRect::zero());
1040        }
1041        
1042        let result = reconcile_dom(
1043            &old_data,
1044            &new_data,
1045            &old_layout,
1046            &new_layout,
1047            DomId { inner: 0 },
1048            Instant::now(),
1049        );
1050        
1051        // A and B should be matched (moved), NEW is mounted (but no callback)
1052        assert_eq!(result.node_moves.len(), 2);
1053        
1054        // A: old[0] -> new[1]
1055        assert!(result.node_moves.iter().any(|m| 
1056            m.old_node_id == NodeId::new(0) && m.new_node_id == NodeId::new(1)
1057        ));
1058        // B: old[1] -> new[2]
1059        assert!(result.node_moves.iter().any(|m| 
1060            m.old_node_id == NodeId::new(1) && m.new_node_id == NodeId::new(2)
1061        ));
1062    }
1063
1064    #[test]
1065    fn test_insert_in_middle() {
1066        use azul_css::AzString;
1067        
1068        let mut div_a = NodeData::create_div();
1069        div_a.add_class(AzString::from("a"));
1070        let mut div_b = NodeData::create_div();
1071        div_b.add_class(AzString::from("b"));
1072        let mut div_new = NodeData::create_div();
1073        div_new.add_class(AzString::from("new"));
1074        
1075        // Old: [A, B], New: [A, NEW, B]
1076        let old_data = vec![div_a.clone(), div_b.clone()];
1077        let new_data = vec![div_a.clone(), div_new.clone(), div_b.clone()];
1078        
1079        let mut old_layout = FastHashMap::default();
1080        let mut new_layout = FastHashMap::default();
1081        for i in 0..2 {
1082            old_layout.insert(NodeId::new(i), LogicalRect::zero());
1083        }
1084        for i in 0..3 {
1085            new_layout.insert(NodeId::new(i), LogicalRect::zero());
1086        }
1087        
1088        let result = reconcile_dom(
1089            &old_data,
1090            &new_data,
1091            &old_layout,
1092            &new_layout,
1093            DomId { inner: 0 },
1094            Instant::now(),
1095        );
1096        
1097        assert_eq!(result.node_moves.len(), 2);
1098        
1099        // A: old[0] -> new[0] (same position)
1100        assert!(result.node_moves.iter().any(|m| 
1101            m.old_node_id == NodeId::new(0) && m.new_node_id == NodeId::new(0)
1102        ));
1103        // B: old[1] -> new[2]
1104        assert!(result.node_moves.iter().any(|m| 
1105            m.old_node_id == NodeId::new(1) && m.new_node_id == NodeId::new(2)
1106        ));
1107    }
1108
1109    #[test]
1110    fn test_remove_from_middle() {
1111        use azul_css::AzString;
1112        
1113        let mut div_a = NodeData::create_div();
1114        div_a.add_class(AzString::from("a"));
1115        let mut div_b = NodeData::create_div();
1116        div_b.add_class(AzString::from("b"));
1117        let mut div_c = NodeData::create_div();
1118        div_c.add_class(AzString::from("c"));
1119        
1120        // Old: [A, B, C], New: [A, C]
1121        let old_data = vec![div_a.clone(), div_b.clone(), div_c.clone()];
1122        let new_data = vec![div_a.clone(), div_c.clone()];
1123        
1124        let mut old_layout = FastHashMap::default();
1125        let mut new_layout = FastHashMap::default();
1126        for i in 0..3 {
1127            old_layout.insert(NodeId::new(i), LogicalRect::zero());
1128        }
1129        for i in 0..2 {
1130            new_layout.insert(NodeId::new(i), LogicalRect::zero());
1131        }
1132        
1133        let result = reconcile_dom(
1134            &old_data,
1135            &new_data,
1136            &old_layout,
1137            &new_layout,
1138            DomId { inner: 0 },
1139            Instant::now(),
1140        );
1141        
1142        // A and C matched, B unmounted (no callback so no event)
1143        assert_eq!(result.node_moves.len(), 2);
1144        
1145        // A: old[0] -> new[0]
1146        assert!(result.node_moves.iter().any(|m| 
1147            m.old_node_id == NodeId::new(0) && m.new_node_id == NodeId::new(0)
1148        ));
1149        // C: old[2] -> new[1]
1150        assert!(result.node_moves.iter().any(|m| 
1151            m.old_node_id == NodeId::new(2) && m.new_node_id == NodeId::new(1)
1152        ));
1153    }
1154
1155    #[test]
1156    fn test_mixed_keyed_and_unkeyed() {
1157        use azul_css::AzString;
1158        
1159        let mut keyed = NodeData::create_div();
1160        keyed.set_key("my-key");
1161        keyed.add_class(AzString::from("keyed"));
1162        
1163        let mut unkeyed = NodeData::create_div();
1164        unkeyed.add_class(AzString::from("unkeyed"));
1165        
1166        // Old: [keyed, unkeyed], New: [unkeyed, keyed]
1167        let old_data = vec![keyed.clone(), unkeyed.clone()];
1168        let new_data = vec![unkeyed.clone(), keyed.clone()];
1169        
1170        let mut old_layout = FastHashMap::default();
1171        let mut new_layout = FastHashMap::default();
1172        for i in 0..2 {
1173            old_layout.insert(NodeId::new(i), LogicalRect::zero());
1174            new_layout.insert(NodeId::new(i), LogicalRect::zero());
1175        }
1176        
1177        let result = reconcile_dom(
1178            &old_data,
1179            &new_data,
1180            &old_layout,
1181            &new_layout,
1182            DomId { inner: 0 },
1183            Instant::now(),
1184        );
1185        
1186        assert_eq!(result.node_moves.len(), 2);
1187        
1188        // keyed: old[0] -> new[1] (matched by key)
1189        assert!(result.node_moves.iter().any(|m| 
1190            m.old_node_id == NodeId::new(0) && m.new_node_id == NodeId::new(1)
1191        ));
1192        // unkeyed: old[1] -> new[0] (matched by hash)
1193        assert!(result.node_moves.iter().any(|m| 
1194            m.old_node_id == NodeId::new(1) && m.new_node_id == NodeId::new(0)
1195        ));
1196    }
1197
1198    #[test]
1199    fn test_duplicate_keys() {
1200        // Two nodes with the same key - first one wins
1201        let mut node1 = NodeData::create_div();
1202        node1.set_key("duplicate");
1203        node1.add_class(azul_css::AzString::from("first"));
1204        
1205        let mut node2 = NodeData::create_div();
1206        node2.set_key("duplicate");
1207        node2.add_class(azul_css::AzString::from("second"));
1208        
1209        let old_data = vec![node1.clone()];
1210        let new_data = vec![node2.clone()];
1211        
1212        let mut old_layout = FastHashMap::default();
1213        old_layout.insert(NodeId::new(0), LogicalRect::zero());
1214        let mut new_layout = FastHashMap::default();
1215        new_layout.insert(NodeId::new(0), LogicalRect::zero());
1216        
1217        let result = reconcile_dom(
1218            &old_data,
1219            &new_data,
1220            &old_layout,
1221            &new_layout,
1222            DomId { inner: 0 },
1223            Instant::now(),
1224        );
1225        
1226        // Should match by key
1227        assert_eq!(result.node_moves.len(), 1);
1228    }
1229
1230    #[test]
1231    fn test_key_not_in_old() {
1232        // New node has a key that didn't exist in old
1233        let old_div = NodeData::create_div();
1234        
1235        let mut new_div = NodeData::create_div();
1236        new_div.set_key("new-key");
1237        
1238        let old_data = vec![old_div];
1239        let new_data = vec![new_div];
1240        
1241        let mut old_layout = FastHashMap::default();
1242        old_layout.insert(NodeId::new(0), LogicalRect::zero());
1243        let mut new_layout = FastHashMap::default();
1244        new_layout.insert(NodeId::new(0), LogicalRect::zero());
1245        
1246        let result = reconcile_dom(
1247            &old_data,
1248            &new_data,
1249            &old_layout,
1250            &new_layout,
1251            DomId { inner: 0 },
1252            Instant::now(),
1253        );
1254        
1255        // Key doesn't match, so new keyed node is mount, old unkeyed is unmount
1256        // No events because no callbacks
1257        assert!(result.node_moves.is_empty()); // No match by key or hash
1258    }
1259
1260    #[test]
1261    fn test_large_list_reorder() {
1262        use azul_css::AzString;
1263        
1264        // Create 100 unique nodes
1265        let nodes: Vec<NodeData> = (0..100).map(|i| {
1266            let mut node = NodeData::create_div();
1267            node.add_class(AzString::from(format!("item-{}", i)));
1268            node
1269        }).collect();
1270        
1271        // Reverse the order
1272        let old_data = nodes.clone();
1273        let new_data: Vec<NodeData> = nodes.into_iter().rev().collect();
1274        
1275        let mut old_layout = FastHashMap::default();
1276        let mut new_layout = FastHashMap::default();
1277        for i in 0..100 {
1278            old_layout.insert(NodeId::new(i), LogicalRect::zero());
1279            new_layout.insert(NodeId::new(i), LogicalRect::zero());
1280        }
1281        
1282        let result = reconcile_dom(
1283            &old_data,
1284            &new_data,
1285            &old_layout,
1286            &new_layout,
1287            DomId { inner: 0 },
1288            Instant::now(),
1289        );
1290        
1291        // All 100 nodes should be matched (just reordered)
1292        assert_eq!(result.node_moves.len(), 100);
1293        assert!(result.events.is_empty());
1294    }
1295
1296    #[test]
1297    fn test_migration_map() {
1298        use azul_css::AzString;
1299        
1300        let mut div_a = NodeData::create_div();
1301        div_a.add_class(AzString::from("a"));
1302        let mut div_b = NodeData::create_div();
1303        div_b.add_class(AzString::from("b"));
1304        
1305        let old_data = vec![div_a.clone(), div_b.clone()];
1306        let new_data = vec![div_b.clone(), div_a.clone()];
1307        
1308        let mut old_layout = FastHashMap::default();
1309        let mut new_layout = FastHashMap::default();
1310        for i in 0..2 {
1311            old_layout.insert(NodeId::new(i), LogicalRect::zero());
1312            new_layout.insert(NodeId::new(i), LogicalRect::zero());
1313        }
1314        
1315        let result = reconcile_dom(
1316            &old_data,
1317            &new_data,
1318            &old_layout,
1319            &new_layout,
1320            DomId { inner: 0 },
1321            Instant::now(),
1322        );
1323        
1324        let migration = create_migration_map(&result.node_moves);
1325        
1326        // old[0] (A) -> new[1]
1327        assert_eq!(migration.get(&NodeId::new(0)), Some(&NodeId::new(1)));
1328        // old[1] (B) -> new[0]
1329        assert_eq!(migration.get(&NodeId::new(1)), Some(&NodeId::new(0)));
1330    }
1331
1332    #[test]
1333    fn test_different_node_types() {
1334        // Different node types should not match by hash
1335        let div = NodeData::create_div();
1336        let span = NodeData::create_node(crate::dom::NodeType::Span);
1337        
1338        let old_data = vec![div];
1339        let new_data = vec![span];
1340        
1341        let mut old_layout = FastHashMap::default();
1342        old_layout.insert(NodeId::new(0), LogicalRect::zero());
1343        let mut new_layout = FastHashMap::default();
1344        new_layout.insert(NodeId::new(0), LogicalRect::zero());
1345        
1346        let result = reconcile_dom(
1347            &old_data,
1348            &new_data,
1349            &old_layout,
1350            &new_layout,
1351            DomId { inner: 0 },
1352            Instant::now(),
1353        );
1354        
1355        // Should not match (different types = different hashes)
1356        assert!(result.node_moves.is_empty());
1357    }
1358
1359    #[test]
1360    fn test_text_nodes() {
1361        use azul_css::AzString;
1362        
1363        let text_a = NodeData::create_text(AzString::from("Hello"));
1364        let text_b = NodeData::create_text(AzString::from("World"));
1365        let text_a_copy = NodeData::create_text(AzString::from("Hello"));
1366        
1367        // Old: ["Hello", "World"], New: ["World", "Hello"]
1368        let old_data = vec![text_a.clone(), text_b.clone()];
1369        let new_data = vec![text_b.clone(), text_a_copy.clone()];
1370        
1371        let mut old_layout = FastHashMap::default();
1372        let mut new_layout = FastHashMap::default();
1373        for i in 0..2 {
1374            old_layout.insert(NodeId::new(i), LogicalRect::zero());
1375            new_layout.insert(NodeId::new(i), LogicalRect::zero());
1376        }
1377        
1378        let result = reconcile_dom(
1379            &old_data,
1380            &new_data,
1381            &old_layout,
1382            &new_layout,
1383            DomId { inner: 0 },
1384            Instant::now(),
1385        );
1386        
1387        // Should match by content hash
1388        assert_eq!(result.node_moves.len(), 2);
1389    }
1390
1391    #[test]
1392    fn test_shuffle_three() {
1393        use azul_css::AzString;
1394        
1395        let mut a = NodeData::create_div();
1396        a.add_class(AzString::from("a"));
1397        let mut b = NodeData::create_div();
1398        b.add_class(AzString::from("b"));
1399        let mut c = NodeData::create_div();
1400        c.add_class(AzString::from("c"));
1401        
1402        // Old: [A, B, C], New: [B, C, A]
1403        let old_data = vec![a.clone(), b.clone(), c.clone()];
1404        let new_data = vec![b.clone(), c.clone(), a.clone()];
1405        
1406        let mut old_layout = FastHashMap::default();
1407        let mut new_layout = FastHashMap::default();
1408        for i in 0..3 {
1409            old_layout.insert(NodeId::new(i), LogicalRect::zero());
1410            new_layout.insert(NodeId::new(i), LogicalRect::zero());
1411        }
1412        
1413        let result = reconcile_dom(
1414            &old_data,
1415            &new_data,
1416            &old_layout,
1417            &new_layout,
1418            DomId { inner: 0 },
1419            Instant::now(),
1420        );
1421        
1422        assert_eq!(result.node_moves.len(), 3);
1423        
1424        // A: old[0] -> new[2]
1425        assert!(result.node_moves.iter().any(|m| 
1426            m.old_node_id == NodeId::new(0) && m.new_node_id == NodeId::new(2)
1427        ));
1428        // B: old[1] -> new[0]
1429        assert!(result.node_moves.iter().any(|m| 
1430            m.old_node_id == NodeId::new(1) && m.new_node_id == NodeId::new(0)
1431        ));
1432        // C: old[2] -> new[1]
1433        assert!(result.node_moves.iter().any(|m| 
1434            m.old_node_id == NodeId::new(2) && m.new_node_id == NodeId::new(1)
1435        ));
1436    }
1437
1438    // =========================================================================
1439    // MERGE CALLBACK / STATE MIGRATION TESTS
1440    // =========================================================================
1441
1442    use crate::refany::{RefAny, OptionRefAny};
1443    use crate::dom::DatasetMergeCallbackType;
1444    use alloc::sync::Arc;
1445    use core::cell::RefCell;
1446    
1447    /// Test data simulating a video player with a heavy decoder handle
1448    struct VideoPlayerState {
1449        url: alloc::string::String,
1450        decoder_handle: Option<u64>, // Simulates heavy resource (e.g., FFmpeg handle)
1451    }
1452
1453    /// Simple merge callback that transfers decoder_handle from old to new
1454    extern "C" fn merge_video_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
1455        // Get mutable access to new, immutable to old
1456        if let Some(mut new_guard) = new_data.downcast_mut::<VideoPlayerState>() {
1457            if let Some(old_guard) = old_data.downcast_ref::<VideoPlayerState>() {
1458                // Transfer heavy resource
1459                new_guard.decoder_handle = old_guard.decoder_handle;
1460            }
1461        }
1462        new_data
1463    }
1464
1465    #[test]
1466    fn test_transfer_states_basic() {
1467        // Scenario: Video player moves from index 0 to index 1
1468        // The decoder handle should be preserved
1469        
1470        let old_state = VideoPlayerState {
1471            url: "movie.mp4".into(),
1472            decoder_handle: Some(12345),
1473        };
1474        let new_state = VideoPlayerState {
1475            url: "movie.mp4".into(),
1476            decoder_handle: None, // Fresh state, no handle yet
1477        };
1478        
1479        let mut old_node = NodeData::create_div();
1480        old_node.dataset = OptionRefAny::Some(RefAny::new(old_state));
1481        
1482        let mut new_node = NodeData::create_div();
1483        new_node.dataset = OptionRefAny::Some(RefAny::new(new_state));
1484        new_node.set_merge_callback(merge_video_state as DatasetMergeCallbackType);
1485        
1486        let mut old_data = vec![old_node];
1487        let mut new_data = vec![new_node];
1488        
1489        let moves = vec![NodeMove {
1490            old_node_id: NodeId::new(0),
1491            new_node_id: NodeId::new(0),
1492        }];
1493        
1494        // Execute state migration
1495        transfer_states(&mut old_data, &mut new_data, &moves);
1496        
1497        // Verify the handle was transferred
1498        if let OptionRefAny::Some(ref mut dataset) = new_data[0].dataset {
1499            let guard = dataset.downcast_ref::<VideoPlayerState>().unwrap();
1500            assert_eq!(guard.decoder_handle, Some(12345));
1501        } else {
1502            panic!("Dataset should exist");
1503        }
1504    }
1505
1506    #[test]
1507    fn test_transfer_states_no_callback_no_transfer() {
1508        // If no merge callback is set, nothing should happen
1509        
1510        let old_state = VideoPlayerState {
1511            url: "movie.mp4".into(),
1512            decoder_handle: Some(99999),
1513        };
1514        let new_state = VideoPlayerState {
1515            url: "movie.mp4".into(),
1516            decoder_handle: None,
1517        };
1518        
1519        let mut old_node = NodeData::create_div();
1520        old_node.dataset = OptionRefAny::Some(RefAny::new(old_state));
1521        
1522        let mut new_node = NodeData::create_div();
1523        new_node.dataset = OptionRefAny::Some(RefAny::new(new_state));
1524        // NO merge callback set!
1525        
1526        let mut old_data = vec![old_node];
1527        let mut new_data = vec![new_node];
1528        
1529        let moves = vec![NodeMove {
1530            old_node_id: NodeId::new(0),
1531            new_node_id: NodeId::new(0),
1532        }];
1533        
1534        transfer_states(&mut old_data, &mut new_data, &moves);
1535        
1536        // Handle should NOT be transferred (no callback)
1537        if let OptionRefAny::Some(ref mut dataset) = new_data[0].dataset {
1538            let guard = dataset.downcast_ref::<VideoPlayerState>().unwrap();
1539            assert_eq!(guard.decoder_handle, None); // Still None!
1540        } else {
1541            panic!("Dataset should exist");
1542        }
1543    }
1544
1545    #[test]
1546    fn test_transfer_states_no_old_dataset() {
1547        // If old node has no dataset, merge should not crash
1548        
1549        let new_state = VideoPlayerState {
1550            url: "movie.mp4".into(),
1551            decoder_handle: None,
1552        };
1553        
1554        let old_node = NodeData::create_div(); // No dataset
1555        
1556        let mut new_node = NodeData::create_div();
1557        new_node.dataset = OptionRefAny::Some(RefAny::new(new_state));
1558        new_node.set_merge_callback(merge_video_state as DatasetMergeCallbackType);
1559        
1560        let mut old_data = vec![old_node];
1561        let mut new_data = vec![new_node];
1562        
1563        let moves = vec![NodeMove {
1564            old_node_id: NodeId::new(0),
1565            new_node_id: NodeId::new(0),
1566        }];
1567        
1568        // Should not panic
1569        transfer_states(&mut old_data, &mut new_data, &moves);
1570        
1571        // New node should still have its dataset (unmodified)
1572        if let OptionRefAny::Some(ref mut dataset) = new_data[0].dataset {
1573            let guard = dataset.downcast_ref::<VideoPlayerState>().unwrap();
1574            assert_eq!(guard.decoder_handle, None);
1575        } else {
1576            panic!("Dataset should still exist");
1577        }
1578    }
1579
1580    #[test]
1581    fn test_transfer_states_no_new_dataset() {
1582        // If new node has merge callback but no dataset, nothing should happen
1583        
1584        let old_state = VideoPlayerState {
1585            url: "movie.mp4".into(),
1586            decoder_handle: Some(77777),
1587        };
1588        
1589        let mut old_node = NodeData::create_div();
1590        old_node.dataset = OptionRefAny::Some(RefAny::new(old_state));
1591        
1592        let mut new_node = NodeData::create_div();
1593        // No dataset on new node!
1594        new_node.set_merge_callback(merge_video_state as DatasetMergeCallbackType);
1595        
1596        let mut old_data = vec![old_node];
1597        let mut new_data = vec![new_node];
1598        
1599        let moves = vec![NodeMove {
1600            old_node_id: NodeId::new(0),
1601            new_node_id: NodeId::new(0),
1602        }];
1603        
1604        // Should not panic
1605        transfer_states(&mut old_data, &mut new_data, &moves);
1606        
1607        // New node should still have no dataset
1608        assert!(matches!(new_data[0].dataset, OptionRefAny::None));
1609    }
1610
1611    #[test]
1612    fn test_transfer_states_reorder_preserves_handles() {
1613        // Scenario: Two video players swap positions
1614        // Each should keep its own decoder handle
1615        
1616        let state_a = VideoPlayerState { url: "a.mp4".into(), decoder_handle: Some(111) };
1617        let state_b = VideoPlayerState { url: "b.mp4".into(), decoder_handle: Some(222) };
1618        
1619        let mut old_a = NodeData::create_div();
1620        old_a.set_key("player-a");
1621        old_a.dataset = OptionRefAny::Some(RefAny::new(state_a));
1622        
1623        let mut old_b = NodeData::create_div();
1624        old_b.set_key("player-b");
1625        old_b.dataset = OptionRefAny::Some(RefAny::new(state_b));
1626        
1627        // New order: B, A (swapped)
1628        let new_state_b = VideoPlayerState { url: "b.mp4".into(), decoder_handle: None };
1629        let new_state_a = VideoPlayerState { url: "a.mp4".into(), decoder_handle: None };
1630        
1631        let mut new_b = NodeData::create_div();
1632        new_b.set_key("player-b");
1633        new_b.dataset = OptionRefAny::Some(RefAny::new(new_state_b));
1634        new_b.set_merge_callback(merge_video_state as DatasetMergeCallbackType);
1635        
1636        let mut new_a = NodeData::create_div();
1637        new_a.set_key("player-a");
1638        new_a.dataset = OptionRefAny::Some(RefAny::new(new_state_a));
1639        new_a.set_merge_callback(merge_video_state as DatasetMergeCallbackType);
1640        
1641        let mut old_data = vec![old_a, old_b];  // [A@0, B@1]
1642        let mut new_data = vec![new_b, new_a];  // [B@0, A@1]
1643        
1644        // Moves from reconciliation: old_a(0)->new(1), old_b(1)->new(0)
1645        let moves = vec![
1646            NodeMove { old_node_id: NodeId::new(0), new_node_id: NodeId::new(1) }, // A
1647            NodeMove { old_node_id: NodeId::new(1), new_node_id: NodeId::new(0) }, // B
1648        ];
1649        
1650        transfer_states(&mut old_data, &mut new_data, &moves);
1651        
1652        // new[0] should be B with handle 222
1653        if let OptionRefAny::Some(ref mut ds) = new_data[0].dataset {
1654            let guard = ds.downcast_ref::<VideoPlayerState>().unwrap();
1655            assert_eq!(guard.url, "b.mp4");
1656            assert_eq!(guard.decoder_handle, Some(222));
1657        } else {
1658            panic!("B dataset missing");
1659        }
1660        
1661        // new[1] should be A with handle 111
1662        if let OptionRefAny::Some(ref mut ds) = new_data[1].dataset {
1663            let guard = ds.downcast_ref::<VideoPlayerState>().unwrap();
1664            assert_eq!(guard.url, "a.mp4");
1665            assert_eq!(guard.decoder_handle, Some(111));
1666        } else {
1667            panic!("A dataset missing");
1668        }
1669    }
1670
1671    #[test]
1672    fn test_transfer_states_out_of_bounds() {
1673        // Invalid node moves should be skipped gracefully
1674        
1675        let state = VideoPlayerState { url: "test.mp4".into(), decoder_handle: Some(123) };
1676        
1677        let mut old_node = NodeData::create_div();
1678        old_node.dataset = OptionRefAny::Some(RefAny::new(state));
1679        
1680        let mut old_data = vec![old_node];
1681        let mut new_data: Vec<NodeData> = vec![]; // Empty!
1682        
1683        let moves = vec![NodeMove {
1684            old_node_id: NodeId::new(0),
1685            new_node_id: NodeId::new(999), // Out of bounds
1686        }];
1687        
1688        // Should not panic
1689        transfer_states(&mut old_data, &mut new_data, &moves);
1690    }
1691
1692    /// Test with a more complex type that uses interior mutability
1693    struct WebGLContext {
1694        texture_ids: alloc::vec::Vec<u32>,
1695        shader_program: Option<u32>,
1696    }
1697
1698    extern "C" fn merge_webgl_context(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
1699        if let Some(mut new_guard) = new_data.downcast_mut::<WebGLContext>() {
1700            if let Some(old_guard) = old_data.downcast_ref::<WebGLContext>() {
1701                // Transfer all GL resources
1702                new_guard.texture_ids = old_guard.texture_ids.clone();
1703                new_guard.shader_program = old_guard.shader_program;
1704            }
1705        }
1706        new_data
1707    }
1708
1709    #[test]
1710    fn test_transfer_states_complex_type() {
1711        let old_ctx = WebGLContext {
1712            texture_ids: vec![1, 2, 3, 4, 5],
1713            shader_program: Some(42),
1714        };
1715        let new_ctx = WebGLContext {
1716            texture_ids: vec![],
1717            shader_program: None,
1718        };
1719        
1720        let mut old_node = NodeData::create_div();
1721        old_node.dataset = OptionRefAny::Some(RefAny::new(old_ctx));
1722        
1723        let mut new_node = NodeData::create_div();
1724        new_node.dataset = OptionRefAny::Some(RefAny::new(new_ctx));
1725        new_node.set_merge_callback(merge_webgl_context as DatasetMergeCallbackType);
1726        
1727        let mut old_data = vec![old_node];
1728        let mut new_data = vec![new_node];
1729        
1730        let moves = vec![NodeMove {
1731            old_node_id: NodeId::new(0),
1732            new_node_id: NodeId::new(0),
1733        }];
1734        
1735        transfer_states(&mut old_data, &mut new_data, &moves);
1736        
1737        if let OptionRefAny::Some(ref mut ds) = new_data[0].dataset {
1738            let guard = ds.downcast_ref::<WebGLContext>().unwrap();
1739            assert_eq!(guard.texture_ids, vec![1, 2, 3, 4, 5]);
1740            assert_eq!(guard.shader_program, Some(42));
1741        } else {
1742            panic!("Dataset missing");
1743        }
1744    }
1745
1746    #[test]
1747    fn test_transfer_states_callback_returns_old_data() {
1748        // Test that callback can choose to return old_data instead of new_data
1749        
1750        struct Counter {
1751            value: u32,
1752        }
1753        
1754        extern "C" fn prefer_old(_new_data: RefAny, old_data: RefAny) -> RefAny {
1755            // Return old_data to preserve the old state entirely
1756            old_data
1757        }
1758        
1759        let old_counter = Counter { value: 100 };
1760        let new_counter = Counter { value: 0 };
1761        
1762        let mut old_node = NodeData::create_div();
1763        old_node.dataset = OptionRefAny::Some(RefAny::new(old_counter));
1764        
1765        let mut new_node = NodeData::create_div();
1766        new_node.dataset = OptionRefAny::Some(RefAny::new(new_counter));
1767        new_node.set_merge_callback(prefer_old as DatasetMergeCallbackType);
1768        
1769        let mut old_data = vec![old_node];
1770        let mut new_data = vec![new_node];
1771        
1772        let moves = vec![NodeMove {
1773            old_node_id: NodeId::new(0),
1774            new_node_id: NodeId::new(0),
1775        }];
1776        
1777        transfer_states(&mut old_data, &mut new_data, &moves);
1778        
1779        // The callback returned old_data, so new node should have value=100
1780        if let OptionRefAny::Some(ref mut ds) = new_data[0].dataset {
1781            let guard = ds.downcast_ref::<Counter>().unwrap();
1782            assert_eq!(guard.value, 100);
1783        } else {
1784            panic!("Dataset missing");
1785        }
1786    }
1787
1788    #[test]
1789    fn test_transfer_states_multiple_nodes_partial_callbacks() {
1790        // Scenario: 3 nodes, only middle one has merge callback
1791        
1792        struct Simple { val: u32 }
1793        
1794        extern "C" fn merge_simple(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
1795            if let Some(mut new_g) = new_data.downcast_mut::<Simple>() {
1796                if let Some(old_g) = old_data.downcast_ref::<Simple>() {
1797                    new_g.val = old_g.val;
1798                }
1799            }
1800            new_data
1801        }
1802        
1803        let mut old_nodes = vec![
1804            {
1805                let mut n = NodeData::create_div();
1806                n.dataset = OptionRefAny::Some(RefAny::new(Simple { val: 1 }));
1807                n
1808            },
1809            {
1810                let mut n = NodeData::create_div();
1811                n.dataset = OptionRefAny::Some(RefAny::new(Simple { val: 2 }));
1812                n
1813            },
1814            {
1815                let mut n = NodeData::create_div();
1816                n.dataset = OptionRefAny::Some(RefAny::new(Simple { val: 3 }));
1817                n
1818            },
1819        ];
1820        
1821        let mut new_nodes = vec![
1822            {
1823                let mut n = NodeData::create_div();
1824                n.dataset = OptionRefAny::Some(RefAny::new(Simple { val: 10 }));
1825                // NO callback
1826                n
1827            },
1828            {
1829                let mut n = NodeData::create_div();
1830                n.dataset = OptionRefAny::Some(RefAny::new(Simple { val: 20 }));
1831                n.set_merge_callback(merge_simple as DatasetMergeCallbackType); // HAS callback
1832                n
1833            },
1834            {
1835                let mut n = NodeData::create_div();
1836                n.dataset = OptionRefAny::Some(RefAny::new(Simple { val: 30 }));
1837                // NO callback
1838                n
1839            },
1840        ];
1841        
1842        let moves = vec![
1843            NodeMove { old_node_id: NodeId::new(0), new_node_id: NodeId::new(0) },
1844            NodeMove { old_node_id: NodeId::new(1), new_node_id: NodeId::new(1) },
1845            NodeMove { old_node_id: NodeId::new(2), new_node_id: NodeId::new(2) },
1846        ];
1847        
1848        transfer_states(&mut old_nodes, &mut new_nodes, &moves);
1849        
1850        // Node 0: no callback, should keep val=10
1851        if let OptionRefAny::Some(ref mut ds) = new_nodes[0].dataset {
1852            let g = ds.downcast_ref::<Simple>().unwrap();
1853            assert_eq!(g.val, 10);
1854        }
1855        
1856        // Node 1: has callback, should get val=2 from old
1857        if let OptionRefAny::Some(ref mut ds) = new_nodes[1].dataset {
1858            let g = ds.downcast_ref::<Simple>().unwrap();
1859            assert_eq!(g.val, 2);
1860        }
1861        
1862        // Node 2: no callback, should keep val=30
1863        if let OptionRefAny::Some(ref mut ds) = new_nodes[2].dataset {
1864            let g = ds.downcast_ref::<Simple>().unwrap();
1865            assert_eq!(g.val, 30);
1866        }
1867    }
1868
1869    #[test]
1870    fn test_transfer_states_empty_moves() {
1871        // No moves = no transfers
1872        let mut old_data: Vec<NodeData> = vec![];
1873        let mut new_data: Vec<NodeData> = vec![];
1874        let moves: Vec<NodeMove> = vec![];
1875        
1876        // Should not panic
1877        transfer_states(&mut old_data, &mut new_data, &moves);
1878    }
1879
1880    #[test]
1881    fn test_reconcile_then_transfer_integration() {
1882        // Full integration test: reconcile DOM, then transfer states
1883        
1884        struct AppState { 
1885            name: alloc::string::String, 
1886            heavy_handle: Option<u64> 
1887        }
1888        
1889        extern "C" fn merge_app(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
1890            if let Some(mut new_g) = new_data.downcast_mut::<AppState>() {
1891                if let Some(old_g) = old_data.downcast_ref::<AppState>() {
1892                    new_g.heavy_handle = old_g.heavy_handle;
1893                }
1894            }
1895            new_data
1896        }
1897        
1898        // OLD DOM: one node with key "main" and handle=999
1899        let old_state = AppState { name: "old".into(), heavy_handle: Some(999) };
1900        let mut old_node = NodeData::create_div();
1901        old_node.set_key("main");
1902        old_node.dataset = OptionRefAny::Some(RefAny::new(old_state));
1903        
1904        // NEW DOM: same key, new state, needs merge
1905        let new_state = AppState { name: "new".into(), heavy_handle: None };
1906        let mut new_node = NodeData::create_div();
1907        new_node.set_key("main");
1908        new_node.dataset = OptionRefAny::Some(RefAny::new(new_state));
1909        new_node.set_merge_callback(merge_app as DatasetMergeCallbackType);
1910        
1911        let mut old_data = vec![old_node];
1912        let mut new_data = vec![new_node];
1913        
1914        let mut old_layout = FastHashMap::default();
1915        old_layout.insert(NodeId::new(0), LogicalRect::zero());
1916        let mut new_layout = FastHashMap::default();
1917        new_layout.insert(NodeId::new(0), LogicalRect::zero());
1918        
1919        // Step 1: Reconcile
1920        let diff = reconcile_dom(
1921            &old_data,
1922            &new_data,
1923            &old_layout,
1924            &new_layout,
1925            DomId { inner: 0 },
1926            Instant::now(),
1927        );
1928        
1929        // Should match by key
1930        assert_eq!(diff.node_moves.len(), 1);
1931        assert_eq!(diff.node_moves[0].old_node_id, NodeId::new(0));
1932        assert_eq!(diff.node_moves[0].new_node_id, NodeId::new(0));
1933        
1934        // Step 2: Transfer states
1935        transfer_states(&mut old_data, &mut new_data, &diff.node_moves);
1936        
1937        // Verify: name should be "new", handle should be 999
1938        if let OptionRefAny::Some(ref mut ds) = new_data[0].dataset {
1939            let g = ds.downcast_ref::<AppState>().unwrap();
1940            assert_eq!(g.name, "new");
1941            assert_eq!(g.heavy_handle, Some(999));
1942        } else {
1943            panic!("Dataset missing");
1944        }
1945    }
1946    
1947    // ========== CURSOR RECONCILIATION TESTS ==========
1948    
1949    #[test]
1950    fn test_cursor_reconcile_identical_text() {
1951        let result = reconcile_cursor_position("Hello", "Hello", 3);
1952        assert_eq!(result, 3);
1953    }
1954    
1955    #[test]
1956    fn test_cursor_reconcile_text_appended() {
1957        // Cursor at end of "Hello", text becomes "Hello World"
1958        let result = reconcile_cursor_position("Hello", "Hello World", 5);
1959        assert_eq!(result, 5); // Cursor stays at same position (in prefix)
1960    }
1961    
1962    #[test]
1963    fn test_cursor_reconcile_text_prepended() {
1964        // Cursor at "H|ello", text becomes "Say Hello"
1965        // The "H" is no longer at position 1, so cursor goes to changed region end
1966        let result = reconcile_cursor_position("Hello", "Say Hello", 1);
1967        assert_eq!(result, 4); // End of inserted content
1968    }
1969    
1970    #[test]
1971    fn test_cursor_reconcile_suffix_preserved() {
1972        // Text: "Hello World" -> "Hi World", cursor at "World" (position 6)
1973        // "World" is in suffix, so cursor should adjust
1974        let result = reconcile_cursor_position("Hello World", "Hi World", 6);
1975        // In old text: "Hello World" (11 chars), cursor at 6
1976        // Suffix " World" (6 chars) is preserved
1977        // Old suffix starts at 5 (11-6), new suffix starts at 2 (8-6)
1978        // Cursor is at 6, which is in suffix (>= 5)
1979        // Offset from end: 11-6 = 5, new position: 8-5 = 3
1980        assert_eq!(result, 3);
1981    }
1982    
1983    #[test]
1984    fn test_cursor_reconcile_empty_to_text() {
1985        let result = reconcile_cursor_position("", "Hello", 0);
1986        assert_eq!(result, 5); // Cursor at end of new text
1987    }
1988    
1989    #[test]
1990    fn test_cursor_reconcile_text_to_empty() {
1991        let result = reconcile_cursor_position("Hello", "", 3);
1992        assert_eq!(result, 0); // Cursor at 0
1993    }
1994    
1995    #[test]
1996    fn test_cursor_reconcile_insert_at_cursor() {
1997        // Typing 'X' at cursor position 3 in "Hello" -> "HelXlo"
1998        let result = reconcile_cursor_position("Hello", "HelXlo", 3);
1999        // Common prefix: "Hel" (3 bytes), cursor is at 3 (at end of prefix)
2000        // Should stay at 3
2001        assert_eq!(result, 3);
2002    }
2003    
2004    #[test]
2005    fn test_structural_hash_text_nodes_match() {
2006        use azul_css::AzString;
2007        
2008        // Two text nodes with different content should have same structural hash
2009        let text_a = NodeData::create_text(AzString::from("Hello"));
2010        let text_b = NodeData::create_text(AzString::from("Hello World"));
2011        
2012        // Content hash should be different
2013        assert_ne!(text_a.calculate_node_data_hash(), text_b.calculate_node_data_hash());
2014        
2015        // Structural hash should be the same (both are Text nodes)
2016        assert_eq!(text_a.calculate_structural_hash(), text_b.calculate_structural_hash());
2017    }
2018    
2019    #[test]
2020    fn test_structural_hash_different_types() {
2021        use azul_css::AzString;
2022        
2023        // Div and Text should have different structural hashes
2024        let div = NodeData::create_div();
2025        let text = NodeData::create_text(AzString::from("Hello"));
2026        
2027        assert_ne!(div.calculate_structural_hash(), text.calculate_structural_hash());
2028    }
2029    
2030    #[test]
2031    fn test_text_nodes_match_by_structural_hash() {
2032        use azul_css::AzString;
2033        
2034        // Old DOM has Text("Hello"), new DOM has Text("Hello World")
2035        // They should match by structural hash
2036        let old_data = vec![NodeData::create_text(AzString::from("Hello"))];
2037        let new_data = vec![NodeData::create_text(AzString::from("Hello World"))];
2038        
2039        let mut old_layout = FastHashMap::default();
2040        old_layout.insert(NodeId::new(0), LogicalRect::zero());
2041        let mut new_layout = FastHashMap::default();
2042        new_layout.insert(NodeId::new(0), LogicalRect::zero());
2043        
2044        let result = reconcile_dom(
2045            &old_data,
2046            &new_data,
2047            &old_layout,
2048            &new_layout,
2049            DomId { inner: 0 },
2050            Instant::now(),
2051        );
2052        
2053        // Should match by structural hash (same node, just different text content)
2054        assert_eq!(result.node_moves.len(), 1);
2055        assert_eq!(result.node_moves[0].old_node_id, NodeId::new(0));
2056        assert_eq!(result.node_moves[0].new_node_id, NodeId::new(0));
2057    }
2058}