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::BTreeMap, collections::VecDeque, string::{String, ToString}, vec::Vec};
16use core::hash::Hash;
17
18use azul_css::props::property::{CssPropertyType, RelayoutScope};
19
20use crate::{
21    dom::{DomId, DomNodeHash, DomNodeId, NodeData, NodeType, IdOrClass},
22    events::{
23        ComponentEventFilter, EventData, EventFilter, EventPhase, EventSource, EventType,
24        LifecycleEventData, LifecycleReason, SyntheticEvent,
25    },
26    geom::LogicalRect,
27    id::NodeId,
28    styled_dom::{ChangedCssProperty, NodeHierarchyItemId, NodeHierarchyItem, RestyleResult, StyledNodeState},
29    task::Instant,
30    OrderedMap,
31};
32
33// ============================================================================
34// NodeChangeSet — granular per-node change flags
35// ============================================================================
36
37/// Bit flags describing what changed about a node between old and new DOM.
38/// Multiple flags can be set simultaneously. Uses manual bit manipulation
39/// instead of bitflags crate to avoid adding a dependency.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub struct NodeChangeSet {
42    pub bits: u32,
43}
44
45impl NodeChangeSet {
46    // --- Changes that affect LAYOUT (need relayout + repaint) ---
47
48    /// Node type changed entirely (e.g., Text → Image).
49    pub const NODE_TYPE_CHANGED: u32    = 0b0000_0000_0000_0001;
50    /// Text content changed (for Text nodes).
51    pub const TEXT_CONTENT: u32         = 0b0000_0000_0000_0010;
52    /// CSS IDs or classes changed (may cause restyle → relayout).
53    pub const IDS_AND_CLASSES: u32      = 0b0000_0000_0000_0100;
54    /// Inline CSS properties changed that affect layout.
55    pub const INLINE_STYLE_LAYOUT: u32  = 0b0000_0000_0000_1000;
56    /// Children added, removed, or reordered.
57    pub const CHILDREN_CHANGED: u32     = 0b0000_0000_0001_0000;
58    /// Image source changed (may affect intrinsic size).
59    pub const IMAGE_CHANGED: u32        = 0b0000_0000_0010_0000;
60    /// Contenteditable flag changed.
61    pub const CONTENTEDITABLE: u32      = 0b0000_0000_0100_0000;
62    /// Tab index changed.
63    pub const TAB_INDEX: u32            = 0b0000_0000_1000_0000;
64
65    // --- Changes that affect PAINT only (no relayout needed) ---
66
67    /// Inline CSS properties changed that affect paint only.
68    pub const INLINE_STYLE_PAINT: u32   = 0b0000_0001_0000_0000;
69    /// Styled node state changed (hover, active, focus, etc.).
70    pub const STYLED_STATE: u32         = 0b0000_0010_0000_0000;
71
72    // --- Changes that affect NEITHER layout nor paint ---
73
74    /// Callbacks changed (new `RefAny`, different event handlers).
75    pub const CALLBACKS: u32            = 0b0000_0100_0000_0000;
76    /// Dataset changed.
77    pub const DATASET: u32              = 0b0000_1000_0000_0000;
78    /// Accessibility info changed.
79    pub const ACCESSIBILITY: u32        = 0b0001_0000_0000_0000;
80
81    // --- Composite masks ---
82
83    /// Any change that requires a layout pass.
84    pub const AFFECTS_LAYOUT: u32 = Self::NODE_TYPE_CHANGED
85        | Self::TEXT_CONTENT
86        | Self::IDS_AND_CLASSES
87        | Self::INLINE_STYLE_LAYOUT
88        | Self::CHILDREN_CHANGED
89        | Self::IMAGE_CHANGED
90        | Self::CONTENTEDITABLE;
91
92    /// Any change that requires a paint/display-list update (but not layout).
93    pub const AFFECTS_PAINT: u32 = Self::INLINE_STYLE_PAINT
94        | Self::STYLED_STATE;
95
96    #[must_use] pub const fn empty() -> Self {
97        Self { bits: 0 }
98    }
99
100    #[must_use] pub const fn is_empty(&self) -> bool {
101        self.bits == 0
102    }
103
104    #[must_use] pub const fn contains(&self, flag: u32) -> bool {
105        (self.bits & flag) == flag
106    }
107
108    #[must_use] pub const fn intersects(&self, mask: u32) -> bool {
109        (self.bits & mask) != 0
110    }
111
112    pub const fn insert(&mut self, flag: u32) {
113        self.bits |= flag;
114    }
115
116    /// Returns true if no visual change occurred (only callbacks/dataset/a11y).
117    #[must_use] pub const fn is_visually_unchanged(&self) -> bool {
118        !self.intersects(Self::AFFECTS_LAYOUT) && !self.intersects(Self::AFFECTS_PAINT)
119    }
120
121    /// Returns true if layout is needed.
122    #[must_use] pub const fn needs_layout(&self) -> bool {
123        self.intersects(Self::AFFECTS_LAYOUT)
124    }
125
126    /// Returns true if paint is needed (but not necessarily layout).
127    #[must_use] pub const fn needs_paint(&self) -> bool {
128        self.intersects(Self::AFFECTS_PAINT)
129    }
130}
131
132impl core::ops::BitOrAssign for NodeChangeSet {
133    fn bitor_assign(&mut self, rhs: Self) {
134        self.bits |= rhs.bits;
135    }
136}
137
138impl core::ops::BitOr for NodeChangeSet {
139    type Output = Self;
140    fn bitor(self, rhs: Self) -> Self {
141        Self { bits: self.bits | rhs.bits }
142    }
143}
144
145/// Extended diff result that includes per-node change information.
146#[derive(Debug, Clone)]
147#[derive(Default)]
148pub struct ExtendedDiffResult {
149    /// Original diff result (lifecycle events + node moves).
150    pub diff: DiffResult,
151    /// Per-node change report for matched (moved) nodes.
152    /// Each entry: (`old_node_id`, `new_node_id`, `what_changed`).
153    /// Only contains entries for nodes that were matched.
154    pub node_changes: Vec<(NodeId, NodeId, NodeChangeSet)>,
155}
156
157
158/// Compare two matched `NodeData` instances field-by-field and return
159/// a `NodeChangeSet` describing what changed.
160#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
161#[must_use] pub fn compute_node_changes(
162    old_node: &NodeData,
163    new_node: &NodeData,
164    old_styled_state: Option<&StyledNodeState>,
165    new_styled_state: Option<&StyledNodeState>,
166) -> NodeChangeSet {
167    let mut changes = NodeChangeSet::empty();
168
169    // 1. Node type discriminant
170    if core::mem::discriminant(old_node.get_node_type())
171        != core::mem::discriminant(new_node.get_node_type())
172    {
173        changes.insert(NodeChangeSet::NODE_TYPE_CHANGED);
174        return changes; // everything else is irrelevant
175    }
176
177    // 2. Content-specific comparison (same discriminant)
178    match (old_node.get_node_type(), new_node.get_node_type()) {
179        (NodeType::Text(old_text), NodeType::Text(new_text)) => {
180            if old_text.as_str() != new_text.as_str() {
181                changes.insert(NodeChangeSet::TEXT_CONTENT);
182            }
183        }
184        (NodeType::Image(old_img), NodeType::Image(new_img)) => {
185            // Use Hash-based comparison (pointer identity for decoded images,
186            // callback identity for callback images)
187            use core::hash::Hasher;
188            let hash_img = |img: &crate::resources::ImageRef| -> u64 {
189                let mut h = crate::hash::DefaultHasher::new();
190                img.hash(&mut h);
191                h.finish()
192            };
193            if hash_img(old_img) != hash_img(new_img) {
194                changes.insert(NodeChangeSet::IMAGE_CHANGED);
195            }
196        }
197        _ => {} // Same non-content type → no content change
198    }
199
200    // 3. IDs and classes (now stored in attributes as AttributeType::Id/Class)
201    {
202        use crate::dom::AttributeType;
203        let old_ids_classes: Vec<_> = old_node.attributes().as_ref().iter()
204            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
205            .collect();
206        let new_ids_classes: Vec<_> = new_node.attributes().as_ref().iter()
207            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
208            .collect();
209        if old_ids_classes != new_ids_classes {
210            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
211        }
212    }
213
214    // 4. Inline CSS properties — classify into layout-affecting vs paint-only.
215    // After the inline-vs-component unification, inline CSS is stored as a `Css`
216    // with rule blocks; iterate it via the `(property, conditions)` flat view to
217    // keep the per-property compare semantics this code was written for.
218    if old_node.style != new_node.style {
219        let mut has_layout = false;
220        let mut has_paint = false;
221
222        // Classify a changed/added/removed property into the layout vs paint bucket.
223        #[allow(clippy::items_after_statements)]
224        fn mark(prop_type: CssPropertyType, has_layout: &mut bool, has_paint: &mut bool) {
225            if prop_type.relayout_scope(true) == RelayoutScope::None {
226                *has_paint = true;
227            } else {
228                *has_layout = true;
229            }
230        }
231
232        // AUDIT: key the diff by (prop_type, conditions), NOT prop_type alone.
233        // A node can carry the same property under different conditions (e.g.
234        // `color: red` and `color: blue` scoped to `:hover`); keying by
235        // prop_type collapsed them into one map slot, so a change to one
236        // conditional variant could be silently dropped. Match each new
237        // property against an old entry with the SAME prop_type AND the same
238        // conditions, and mark any old entry left unmatched as removed.
239        let old_props: Vec<(CssPropertyType, _, _)> = old_node
240            .style
241            .iter_inline_properties()
242            .map(|(prop, conds)| (prop.get_type(), prop, conds))
243            .collect();
244        let mut old_matched = vec![false; old_props.len()];
245
246        for (prop, conds) in new_node.style.iter_inline_properties() {
247            let prop_type = prop.get_type();
248            // Find an as-yet-unmatched old entry with the same (type, conditions).
249            let mut found_unchanged = false;
250            for (i, (old_type, old_prop, old_conds)) in old_props.iter().enumerate() {
251                if old_matched[i]
252                    || *old_type != prop_type
253                    || old_conds.as_slice() != conds.as_slice()
254                {
255                    continue;
256                }
257                old_matched[i] = true;
258                if *old_prop == prop {
259                    found_unchanged = true;
260                }
261                break;
262            }
263            // Unchanged only when we matched an old (type, conditions) slot whose
264            // value is identical; otherwise the property was added or changed.
265            if !found_unchanged {
266                mark(prop_type, &mut has_layout, &mut has_paint);
267            }
268        }
269
270        // Check for removed properties (old (type, conditions) slots never matched)
271        for (i, (old_type, _, _)) in old_props.iter().enumerate() {
272            if !old_matched[i] {
273                mark(*old_type, &mut has_layout, &mut has_paint);
274            }
275        }
276
277        if has_layout {
278            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
279        }
280        if has_paint {
281            changes.insert(NodeChangeSet::INLINE_STYLE_PAINT);
282        }
283    }
284
285    // 5. Callbacks
286    {
287        let old_cbs = old_node.callbacks.as_ref();
288        let new_cbs = new_node.callbacks.as_ref();
289        if old_cbs.len() == new_cbs.len() {
290            for (o, n) in old_cbs.iter().zip(new_cbs.iter()) {
291                if o.event != n.event || o.callback != n.callback {
292                    changes.insert(NodeChangeSet::CALLBACKS);
293                    break;
294                }
295            }
296        } else {
297            changes.insert(NodeChangeSet::CALLBACKS);
298        }
299    }
300
301    // 6. Dataset
302    if old_node.get_dataset() != new_node.get_dataset() {
303        changes.insert(NodeChangeSet::DATASET);
304    }
305
306    // 7. Contenteditable
307    if old_node.is_contenteditable() != new_node.is_contenteditable() {
308        changes.insert(NodeChangeSet::CONTENTEDITABLE);
309    }
310
311    // 8. Tab index
312    if old_node.get_tab_index() != new_node.get_tab_index() {
313        changes.insert(NodeChangeSet::TAB_INDEX);
314    }
315
316    // 9. Styled node state (hover, active, focused, etc.)
317    if old_styled_state != new_styled_state {
318        changes.insert(NodeChangeSet::STYLED_STATE);
319    }
320
321    changes
322}
323
324/// Calculate the reconciliation key for a node using the priority hierarchy:
325/// 1. Explicit key (set via `.with_key()`)
326/// 2. CSS ID (set via `.with_id("my-id")`)
327/// 3. Structural key: nth-of-type-within-parent + parent's reconciliation key
328///
329/// The structural key prevents incorrect matching when nodes are inserted
330/// before existing nodes (e.g., prepending items to a list) and allows
331/// keyless nodes to be matched across frames when their logical position
332/// and type are stable (even if content changed — which then fires an
333/// `Update` lifecycle event, see `reconcile_dom`).
334///
335/// When `hierarchy` is empty (or this node has no entry), the structural
336/// key degrades to `discriminant(node_type) + classes` — parent/nth-of-type
337/// context simply drops out. This lets callers that don't track hierarchy
338/// (tests, flat-DOM scenarios) still benefit from explicit-key and CSS-ID
339/// matching without divergent behavior.
340#[must_use] pub fn calculate_reconciliation_key(
341    node_data: &[NodeData],
342    hierarchy: &[NodeHierarchyItem],
343    node_id: NodeId,
344) -> u64 {
345    use core::hash::Hasher;
346
347    let n = node_data.len();
348
349    // Terminal (parent-independent) key for a node: Priority 1 explicit key,
350    // else Priority 2 CSS ID, else `None` (structural — needs the parent chain).
351    let terminal_key = |nid: NodeId| -> Option<u64> {
352        let node = &node_data[nid.index()];
353        // Priority 1: Explicit key
354        if let Some(key) = node.get_key() {
355            return Some(key);
356        }
357        // Priority 2: CSS ID
358        for attr in node.attributes().as_ref() {
359            if let Some(id) = attr.as_id() {
360                let mut hasher = crate::hash::DefaultHasher::new();
361                id.hash(&mut hasher);
362                return Some(hasher.finish());
363            }
364        }
365        None
366    };
367
368    // Fast path: the node itself has an explicit key or CSS ID.
369    if let Some(key) = terminal_key(node_id) {
370        return key;
371    }
372
373    // Priority 3: structural key, computed ITERATIVELY up the parent chain.
374    //
375    // AUDIT: the previous implementation recursed once per ancestor with no
376    // depth cap and no cycle guard, so a deep DOM overflowed the stack and a
377    // corrupt (cyclic) hierarchy recursed forever — and `precompute_*` calls
378    // this once per node. Walk upward instead, bounded by the node count.
379    //
380    // Collect the structural chain from `node_id` upward. The walk stops at:
381    //   - the root (a node with no parent) — structural base is just
382    //     `discriminant + classes`,
383    //   - a terminal (explicit-key / CSS-ID) ancestor, whose key seeds the fold, or
384    //   - `n` iterations (a valid parent chain is at most `n` long, so exceeding
385    //     that means the hierarchy is cyclic/corrupt — stop).
386    let mut chain: Vec<NodeId> = Vec::new();
387    let mut seed_parent_key: Option<u64> = None;
388    let mut cur = node_id;
389    for _ in 0..n {
390        if cur.index() >= n {
391            break;
392        }
393        chain.push(cur);
394        match hierarchy.get(cur.index()).and_then(NodeHierarchyItem::parent_id) {
395            None => break,
396            Some(parent) => {
397                if let Some(k) = terminal_key(parent) {
398                    seed_parent_key = Some(k);
399                    break;
400                }
401                cur = parent;
402            }
403        }
404    }
405
406    // Fold from the topmost ancestor down to `node_id`. `parent_key` threads the
407    // accumulated key of the level above (identical to the old recursion, just
408    // unrolled bottom-up).
409    let mut parent_key: Option<u64> = seed_parent_key;
410    for &nid in chain.iter().rev() {
411        let node = &node_data[nid.index()];
412        let mut hasher = crate::hash::DefaultHasher::new();
413
414        core::mem::discriminant(node.get_node_type()).hash(&mut hasher);
415        for attr in node.attributes().as_ref() {
416            if let Some(class) = attr.as_class() {
417                class.hash(&mut hasher);
418            }
419        }
420
421        if let Some(parent_id) =
422            hierarchy.get(nid.index()).and_then(NodeHierarchyItem::parent_id)
423        {
424            // nth-of-type: count same-discriminant siblings before `nid`.
425            let mut sibling_index: usize = 0;
426            let mut current = hierarchy
427                .get(parent_id.index())
428                .and_then(|h| h.first_child_id(parent_id));
429            while let Some(sibling_id) = current {
430                if sibling_id == nid {
431                    break;
432                }
433                let sibling = &node_data[sibling_id.index()];
434                if core::mem::discriminant(sibling.get_node_type())
435                    == core::mem::discriminant(node.get_node_type())
436                {
437                    sibling_index += 1;
438                }
439                current = hierarchy
440                    .get(sibling_id.index())
441                    .and_then(NodeHierarchyItem::next_sibling_id);
442            }
443
444            sibling_index.hash(&mut hasher);
445            parent_key.unwrap_or(0).hash(&mut hasher);
446        }
447
448        parent_key = Some(hasher.finish());
449    }
450
451    parent_key.unwrap_or(0)
452}
453
454/// Precompute reconciliation keys for every node in a DOM tree.
455///
456/// Called once per side (old/new) at the start of `reconcile_dom`. Returns a
457/// vector indexed by node index (`keys[node_id.index()]`) so lookup during
458/// reconciliation is O(1).
459#[must_use] pub fn precompute_reconciliation_keys(
460    node_data: &[NodeData],
461    hierarchy: &[NodeHierarchyItem],
462) -> Vec<u64> {
463    (0..node_data.len())
464        .map(|idx| calculate_reconciliation_key(node_data, hierarchy, NodeId::new(idx)))
465        .collect()
466}
467
468/// Represents a mapping between a node in the old DOM and the new DOM.
469#[derive(Debug, Clone, Copy)]
470pub struct NodeMove {
471    /// The `NodeId` in the old DOM array
472    pub old_node_id: NodeId,
473    /// The `NodeId` in the new DOM array
474    pub new_node_id: NodeId,
475}
476
477/// The result of a DOM diff, containing lifecycle events and node mappings.
478#[derive(Debug, Clone)]
479#[derive(Default)]
480pub struct DiffResult {
481    /// Lifecycle events generated by the diff (Mount, Unmount, Resize, Update)
482    pub events: Vec<SyntheticEvent>,
483    /// Maps Old `NodeId` -> New `NodeId` for state migration (focus, scroll, etc.)
484    pub node_moves: Vec<NodeMove>,
485}
486
487
488/// Calculates the difference between two DOM frames and generates lifecycle events.
489///
490/// This is the main entry point for DOM reconciliation. It compares the old and new
491/// DOM trees and produces:
492/// - Mount events for new nodes
493/// - Unmount events for removed nodes
494/// - Resize events for nodes whose bounds changed
495/// - Update events for nodes whose logical position is stable but content changed
496///
497/// # Matching priority
498/// For every node, the reconciliation key (`calculate_reconciliation_key`) encodes
499/// Priority 1 (`.with_key()`), Priority 2 (CSS ID), and Priority 3 (structural key:
500/// nth-of-type + parent key). The tiers are then tried in order:
501///
502/// 1. **Reconciliation key** — matches logical identity, may fire Update on content change.
503/// 2. **Content hash** — exact match including content; catches pure reorders of anonymous nodes.
504/// 3. **Structural hash** — matches node type + attrs ignoring text content; for text-edit cases.
505///
506/// # Arguments
507/// * `old_node_data` / `new_node_data` - Per-node data for each frame
508/// * `old_hierarchy` / `new_hierarchy` - Parent/sibling pointers. Pass `&[]` if unavailable;
509///   the structural-key branch of the reconciliation key degrades gracefully.
510/// * `old_layout` / `new_layout` - Layout bounds used to detect Resize events
511/// * `dom_id` - The DOM identifier
512/// * `timestamp` - Current timestamp for events
513#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
514#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
515#[must_use] pub fn reconcile_dom(
516    old_node_data: &[NodeData],
517    new_node_data: &[NodeData],
518    old_hierarchy: &[NodeHierarchyItem],
519    new_hierarchy: &[NodeHierarchyItem],
520    old_layout: &OrderedMap<NodeId, LogicalRect>,
521    new_layout: &OrderedMap<NodeId, LogicalRect>,
522    dom_id: DomId,
523    timestamp: Instant,
524) -> DiffResult {
525    // Helper: pop the first non-consumed NodeId from a queue.
526    fn pop_first_unconsumed(
527        queue: &mut VecDeque<NodeId>,
528        consumed: &[bool],
529    ) -> Option<NodeId> {
530        while let Some(&old_id) = queue.front() {
531            queue.pop_front();
532            if !consumed[old_id.index()] {
533                return Some(old_id);
534            }
535        }
536        None
537    }
538
539    let mut result = DiffResult::default();
540
541    // --- STEP 1: INDEX THE OLD DOM ---
542    //
543    // Three tiers, in priority order:
544    //   Tier 1: reconciliation key (.with_key() / CSS ID / structural key)
545    //   Tier 2: content hash (exact node_data hash — matches pure reorders)
546    //   Tier 3: structural hash (discriminant + attrs, ignores text — matches text edits)
547    //
548    // Each tier is keyed with a `VecDeque<NodeId>` because all three can legitimately
549    // collide (two sibling divs produce the same structural key, two identical nodes
550    // produce the same content hash, etc.); we consume in document order on match.
551
552    let old_rec_keys = precompute_reconciliation_keys(old_node_data, old_hierarchy);
553    // AUDIT: precompute NEW keys too so the Tier-2/Tier-3 keyless tiers can be
554    // gated on parent-key agreement (see STEP 2). Also lets Tier 1 look the key
555    // up instead of recomputing it per node.
556    let new_rec_keys = precompute_reconciliation_keys(new_node_data, new_hierarchy);
557
558    // Reconciliation key of a node's PARENT (`None` for a root or when the
559    // hierarchy is unavailable). Used to keep keyless matches from migrating
560    // focus/scroll/dataset state across different parents.
561    let old_parent_key = |old_id: NodeId| -> Option<u64> {
562        old_hierarchy
563            .get(old_id.index())
564            .and_then(NodeHierarchyItem::parent_id)
565            .map(|p| old_rec_keys[p.index()])
566    };
567
568    let mut old_by_rec_key: OrderedMap<u64, VecDeque<NodeId>> = OrderedMap::default();
569    let mut old_hashed: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
570    let mut old_structural: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
571    let mut old_nodes_consumed = vec![false; old_node_data.len()];
572
573    for (idx, node) in old_node_data.iter().enumerate() {
574        let id = NodeId::new(idx);
575        old_by_rec_key.entry(old_rec_keys[idx]).or_default().push_back(id);
576
577        let hash = node.calculate_node_data_hash();
578        old_hashed.entry(hash).or_default().push_back(id);
579
580        let structural_hash = node.calculate_structural_hash();
581        old_structural.entry(structural_hash).or_default().push_back(id);
582    }
583
584    // --- STEP 2: ITERATE NEW DOM AND CLAIM MATCHES ---
585
586    for (new_idx, new_node) in new_node_data.iter().enumerate() {
587        let new_id = NodeId::new(new_idx);
588        let mut matched_old_id = None;
589        let mut matched_by_rec_key = false;
590        let has_explicit_key = new_node.get_key().is_some();
591
592        // Tier 1: Reconciliation key (explicit `.with_key()`, CSS ID, or structural key)
593        let new_rec_key = new_rec_keys[new_idx];
594        if let Some(queue) = old_by_rec_key.get_mut(&new_rec_key) {
595            if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
596                matched_old_id = Some(old_id);
597                matched_by_rec_key = true;
598            }
599        }
600
601        // AUDIT: parent-key of the new node. The keyless Tier-2/Tier-3 tiers are
602        // only allowed to claim an old node whose parent's reconciliation key
603        // agrees — otherwise two structurally-identical nodes under DIFFERENT
604        // parents would match and migrate focus/scroll/dataset state to an
605        // unrelated subtree. When either hierarchy is unavailable this is `None`
606        // on both sides, so the gate is a no-op (flat-DOM behavior preserved).
607        let new_parent_key: Option<u64> = new_hierarchy
608            .get(new_idx)
609            .and_then(NodeHierarchyItem::parent_id)
610            .map(|p| new_rec_keys[p.index()]);
611
612        // An explicit `.with_key()` is a strong, intentional identity marker: if it
613        // doesn't match anything in the old DOM we treat the new node as genuinely
614        // new (Mount), rather than falling through to coarser content/structural
615        // tiers and silently matching an unrelated node.
616        if !has_explicit_key && matched_old_id.is_none() {
617            // Tier 2: Content hash (exact match — catches pure reorders)
618            let hash = new_node.calculate_node_data_hash();
619            if let Some(queue) = old_hashed.get_mut(&hash) {
620                if let Some(pos) = queue.iter().position(|&old_id| {
621                    !old_nodes_consumed[old_id.index()]
622                        && old_parent_key(old_id) == new_parent_key
623                }) {
624                    matched_old_id = queue.remove(pos);
625                }
626            }
627
628            // Tier 3: Structural hash (text-node fallback — ignores text content)
629            if matched_old_id.is_none() {
630                let structural_hash = new_node.calculate_structural_hash();
631                if let Some(queue) = old_structural.get_mut(&structural_hash) {
632                    if let Some(pos) = queue.iter().position(|&old_id| {
633                        !old_nodes_consumed[old_id.index()]
634                            && old_parent_key(old_id) == new_parent_key
635                    }) {
636                        matched_old_id = queue.remove(pos);
637                    }
638                }
639            }
640        }
641
642        // --- STEP 3: PROCESS MATCH OR MOUNT ---
643
644        if let Some(old_id) = matched_old_id {
645            // FOUND A MATCH (It might be at a different index, but it's the "same" node)
646
647            old_nodes_consumed[old_id.index()] = true;
648            result.node_moves.push(NodeMove {
649                old_node_id: old_id,
650                new_node_id: new_id,
651            });
652
653            // Check for Resize
654            let old_rect = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
655            let new_rect = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
656
657            if old_rect.size != new_rect.size {
658                // Fire Resize Event
659                if has_resize_callback(new_node) {
660                    result.events.push(create_lifecycle_event(
661                        EventType::Resize,
662                        new_id,
663                        dom_id,
664                        &timestamp,
665                        LifecycleEventData {
666                            reason: LifecycleReason::Resize,
667                            previous_bounds: Some(old_rect),
668                            current_bounds: new_rect,
669                        },
670                    ));
671                }
672            }
673
674            // Fire Update when the node was matched by logical identity (reconciliation
675            // key: explicit .with_key(), CSS ID, or structural key) but its content hash
676            // differs. Tier-2/Tier-3 matches by definition don't carry an Update — a
677            // content-hash match is content-identical, and a structural-hash match is
678            // a text edit handled by cursor/text reconciliation elsewhere.
679            if matched_by_rec_key {
680                let old_hash = old_node_data[old_id.index()].calculate_node_data_hash();
681                let new_hash = new_node.calculate_node_data_hash();
682
683                if old_hash != new_hash && has_update_callback(new_node) {
684                    result.events.push(create_lifecycle_event(
685                        EventType::Update,
686                        new_id,
687                        dom_id,
688                        &timestamp,
689                        LifecycleEventData {
690                            reason: LifecycleReason::Update,
691                            previous_bounds: Some(old_rect),
692                            current_bounds: new_rect,
693                        },
694                    ));
695                }
696            }
697        } else {
698            // NO MATCH FOUND -> MOUNT (New Node)
699            if has_mount_callback(new_node) {
700                let bounds = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
701                result.events.push(create_lifecycle_event(
702                    EventType::Mount,
703                    new_id,
704                    dom_id,
705                    &timestamp,
706                    LifecycleEventData {
707                        reason: LifecycleReason::InitialMount,
708                        previous_bounds: None,
709                        current_bounds: bounds,
710                    },
711                ));
712            }
713        }
714    }
715
716    // --- STEP 4: CLEANUP (UNMOUNTS) ---
717    // Any old node that wasn't claimed is effectively destroyed.
718
719    for (old_idx, consumed) in old_nodes_consumed.iter().enumerate() {
720        if !consumed {
721            let old_id = NodeId::new(old_idx);
722            let old_node = &old_node_data[old_idx];
723
724            if has_unmount_callback(old_node) {
725                let bounds = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
726                result.events.push(create_lifecycle_event(
727                    EventType::Unmount,
728                    old_id,
729                    dom_id,
730                    &timestamp,
731                    LifecycleEventData {
732                        reason: LifecycleReason::Unmount,
733                        previous_bounds: Some(bounds),
734                        current_bounds: LogicalRect::zero(),
735                    },
736                ));
737            }
738        }
739    }
740
741    result
742}
743
744/// Creates a lifecycle event with all necessary fields.
745fn create_lifecycle_event(
746    event_type: EventType,
747    node_id: NodeId,
748    dom_id: DomId,
749    timestamp: &Instant,
750    data: LifecycleEventData,
751) -> SyntheticEvent {
752    let dom_node_id = DomNodeId {
753        dom: dom_id,
754        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
755    };
756    SyntheticEvent {
757        event_type,
758        source: EventSource::Lifecycle,
759        phase: EventPhase::Target,
760        target: dom_node_id,
761        current_target: dom_node_id,
762        timestamp: timestamp.clone(),
763        data: EventData::Lifecycle(data),
764        stopped: false,
765        stopped_immediate: false,
766        prevented_default: false,
767    }
768}
769
770/// Check if the node has an `AfterMount` callback registered.
771fn has_mount_callback(node: &NodeData) -> bool {
772    node.get_callbacks().iter().any(|cb| {
773        matches!(
774            cb.event,
775            EventFilter::Component(ComponentEventFilter::AfterMount)
776        )
777    })
778}
779
780/// Check if the node has a `BeforeUnmount` callback registered.
781fn has_unmount_callback(node: &NodeData) -> bool {
782    node.get_callbacks().iter().any(|cb| {
783        matches!(
784            cb.event,
785            EventFilter::Component(ComponentEventFilter::BeforeUnmount)
786        )
787    })
788}
789
790/// Check if the node has a `NodeResized` callback registered.
791fn has_resize_callback(node: &NodeData) -> bool {
792    node.get_callbacks().iter().any(|cb| {
793        matches!(
794            cb.event,
795            EventFilter::Component(ComponentEventFilter::NodeResized)
796        )
797    })
798}
799
800/// Check if the node has any lifecycle callback that would respond to updates.
801fn has_update_callback(node: &NodeData) -> bool {
802    node.get_callbacks().iter().any(|cb| {
803        matches!(
804            cb.event,
805            EventFilter::Component(ComponentEventFilter::Updated)
806        )
807    })
808}
809
810/// Migrate state (focus, scroll, etc.) from old node IDs to new node IDs.
811///
812/// This function should be called after reconciliation to update any state
813/// that references old `NodeIds` to use the new `NodeIds`.
814///
815/// # Example
816/// ```rust,ignore
817/// let diff = reconcile_dom(...);
818/// let migration_map = create_migration_map(&diff.node_moves);
819/// 
820/// // Migrate focus
821/// if let Some(current_focus) = focus_manager.focused_node {
822///     if let Some(&new_id) = migration_map.get(&current_focus) {
823///         focus_manager.focused_node = Some(new_id);
824///     } else {
825///         // Focused node was unmounted, clear focus
826///         focus_manager.focused_node = None;
827///     }
828/// }
829/// ```
830#[must_use] pub fn create_migration_map(node_moves: &[NodeMove]) -> OrderedMap<NodeId, NodeId> {
831    let mut map = OrderedMap::default();
832    for m in node_moves {
833        map.insert(m.old_node_id, m.new_node_id);
834    }
835    map
836}
837
838/// Executes state migration between the old DOM and the new DOM based on diff results.
839///
840/// This iterates through matched nodes. If a match has BOTH a merge callback AND a dataset,
841/// it executes the callback to transfer state from the old node to the new node.
842///
843/// This must be called **before** the old DOM is dropped, because we need to access its data.
844///
845/// # Arguments
846/// * `old_node_data` - Mutable reference to the old DOM's node data (source of heavy state)
847/// * `new_node_data` - Mutable reference to the new DOM's node data (target for heavy state)
848/// * `node_moves` - The matched nodes from the reconciliation diff
849///
850/// # Example
851/// ```rust,ignore
852/// let diff_result = reconcile_dom(&old_data, &new_data, ...);
853/// 
854/// // Execute state migration BEFORE old_dom is dropped
855/// transfer_states(&mut old_data, &mut new_data, &diff_result.node_moves);
856/// 
857/// // Now safe to drop old_dom - heavy resources have been transferred
858/// drop(old_dom);
859/// ```
860pub fn transfer_states(
861    old_node_data: &mut [NodeData],
862    new_node_data: &mut [NodeData],
863    node_moves: &[NodeMove],
864) {
865    use crate::refany::OptionRefAny;
866
867    for movement in node_moves {
868        let old_idx = movement.old_node_id.index();
869        let new_idx = movement.new_node_id.index();
870
871        // Bounds check
872        if old_idx >= old_node_data.len() || new_idx >= new_node_data.len() {
873            continue;
874        }
875
876        // 1. Check if the NEW node has requested a merge callback
877        let Some(merge_callback) = new_node_data[new_idx].get_merge_callback() else {
878            continue; // No merge callback, skip
879        };
880
881        // 2. Check if BOTH nodes have datasets
882        // We need to temporarily take the datasets to satisfy borrow checker
883        let old_dataset = old_node_data[old_idx].take_dataset();
884        let new_dataset = new_node_data[new_idx].take_dataset();
885
886        match (new_dataset, old_dataset) {
887            (Some(new_data), Some(old_data)) => {
888                // The fresh DOM's dataset allocation. A widget builds its dataset,
889                // its VirtualView content `refany`, AND its event-callback
890                // `refany`s from clones of ONE `RefAny` — so every one shares THIS
891                // allocation (`RefAny::clone` shares `sharing_info`; only the
892                // per-clone `instance_id` differs). The merge below keeps the
893                // PERSISTENT (old) allocation (e.g. MapWidget shares its tile cache
894                // so background fetch threads keep writing into it), so every clone
895                // of the fresh one is now orphaned and must be re-pointed — or the
896                // widget fragments across two caches: the VirtualView rendered an
897                // empty clone (blank/grey tiles) while the live data sat in the
898                // dataset, and pan/zoom mutated yet a third copy. Identity = the
899                // shared `RefCountInner` pointer (`sharing_info.ptr`).
900                let orphan_alloc = new_data.sharing_info.ptr as usize;
901
902                // 3. EXECUTE THE MERGE CALLBACK
903                // The callback receives both datasets and returns the merged result
904                let merged = (merge_callback.cb)(new_data, old_data);
905
906                // 4. Store the merged result back in the new node
907                new_node_data[new_idx].set_dataset(OptionRefAny::Some(merged.clone()));
908
909                // 5. UNIFY: re-point every refany across the NEW DOM that was a
910                // clone of the now-discarded fresh dataset onto the merged result,
911                // so the whole widget reads ONE cache. Covers VirtualView content
912                // refanys + event-callback refanys + any node's dataset cloned
913                // from the same source. (Generalises the old special-case that
914                // only re-pointed a VirtualView ON the merge node itself — the
915                // MapWidget puts its VirtualView in a CHILD and its pan/zoom
916                // callbacks on the parent, which that case missed.)
917                for nd in new_node_data.iter_mut() {
918                    if let Some(vv) = nd.get_virtual_view_node() {
919                        if vv.refany.sharing_info.ptr as usize == orphan_alloc {
920                            vv.refany = merged.clone();
921                        }
922                    }
923                    for cb in nd.callbacks.as_mut().iter_mut() {
924                        if cb.refany.sharing_info.ptr as usize == orphan_alloc {
925                            cb.refany = merged.clone();
926                        }
927                    }
928                    let ds_is_orphan = nd
929                        .get_dataset()
930                        .is_some_and(|ds| ds.sharing_info.ptr as usize == orphan_alloc);
931                    if ds_is_orphan {
932                        nd.set_dataset(OptionRefAny::Some(merged.clone()));
933                    }
934                }
935            }
936            (new_ds, old_ds) => {
937                // One or both datasets missing - restore what we had
938                if let Some(ds) = new_ds {
939                    new_node_data[new_idx].set_dataset(OptionRefAny::Some(ds));
940                }
941                if let Some(ds) = old_ds {
942                    old_node_data[old_idx].set_dataset(OptionRefAny::Some(ds));
943                }
944            }
945        }
946    }
947}
948
949/// Calculate a stable key for a contenteditable node using the hierarchy:
950///
951/// 1. **Explicit Key** - If `.with_key()` was called, use that
952/// 2. **CSS ID** - If the node has a CSS ID (e.g., `#my-editor`), hash that
953/// 3. **Structural Key** - Hash of `(nth-of-type, parent_key)` recursively
954///
955/// The structural key prevents shifting when elements are inserted before siblings.
956/// For example, in `<div><p>A</p><p contenteditable>B</p></div>`, if we insert
957/// a new `<p>` at the start, the contenteditable `<p>` becomes nth-child(3) but
958/// its nth-of-type stays stable (it's still the 2nd `<p>`).
959///
960/// # Arguments
961/// * `node_data` - All nodes in the DOM
962/// * `hierarchy` - Parent-child relationships
963/// * `node_id` - The node to calculate the key for
964///
965/// # Returns
966/// A stable u64 key for the node
967#[must_use] pub fn calculate_contenteditable_key(
968    node_data: &[NodeData],
969    hierarchy: &[NodeHierarchyItem],
970    node_id: NodeId,
971) -> u64 {
972    use core::hash::Hasher;
973
974    let n = node_data.len();
975
976    // Terminal (parent-independent) key: Priority 1 explicit key, else
977    // Priority 2 CSS ID, else `None` (structural — needs the parent chain).
978    let terminal_key = |nid: NodeId| -> Option<u64> {
979        let node = &node_data[nid.index()];
980        // Priority 1: Explicit key (from .with_key())
981        if let Some(explicit_key) = node.get_key() {
982            return Some(explicit_key);
983        }
984        // Priority 2: CSS ID
985        for attr in node.attributes().as_ref() {
986            if let Some(id) = attr.as_id() {
987                let mut hasher = crate::hash::DefaultHasher::new(); // Different seed for ID keys
988                hasher.write(id.as_bytes());
989                return Some(hasher.finish());
990            }
991        }
992        None
993    };
994
995    // Fast path: the node itself has an explicit key or CSS ID.
996    if let Some(key) = terminal_key(node_id) {
997        return key;
998    }
999
1000    // Priority 3: structural key, computed ITERATIVELY up the parent chain.
1001    //
1002    // AUDIT: replaces unbounded parent-chain recursion (stack overflow on deep
1003    // DOMs, infinite recursion on a cyclic hierarchy). Same fold as the old
1004    // recursion, unrolled bottom-up and bounded by the node count.
1005    let mut chain: Vec<NodeId> = Vec::new();
1006    let mut seed_parent_key: Option<u64> = None;
1007    let mut cur = node_id;
1008    for _ in 0..n {
1009        if cur.index() >= n {
1010            break;
1011        }
1012        chain.push(cur);
1013        match hierarchy.get(cur.index()).and_then(NodeHierarchyItem::parent_id) {
1014            None => break,
1015            Some(parent) => {
1016                if let Some(k) = terminal_key(parent) {
1017                    seed_parent_key = Some(k);
1018                    break;
1019                }
1020                cur = parent;
1021            }
1022        }
1023    }
1024
1025    // Fold from the topmost ancestor down to `node_id`. Unlike the
1026    // reconciliation key, the contenteditable structural key ALWAYS writes a
1027    // `parent_key` (0 at the root) and an `nth_of_type` (0 at the root), so the
1028    // per-level hashing is unconditional — preserve that exactly.
1029    let mut parent_key: u64 = seed_parent_key.unwrap_or(0);
1030    for &nid in chain.iter().rev() {
1031        let node = &node_data[nid.index()];
1032        let mut hasher = crate::hash::DefaultHasher::new(); // Different seed for structural keys
1033
1034        let node_parent = hierarchy.get(nid.index()).and_then(NodeHierarchyItem::parent_id);
1035
1036        // parent_key: 0 at the root, else the accumulated key of the level above.
1037        let level_parent_key = if node_parent.is_some() { parent_key } else { 0 };
1038        hasher.write(&level_parent_key.to_le_bytes());
1039
1040        // nth-of-type: count same-discriminant siblings before `nid`.
1041        let node_discriminant = core::mem::discriminant(node.get_node_type());
1042        let nth_of_type = node_parent.map_or(0u32, |parent_id| {
1043            let mut count = 0u32;
1044            let mut sibling_id = hierarchy
1045                .get(parent_id.index())
1046                .and_then(|h| h.first_child_id(parent_id));
1047            while let Some(sib_id) = sibling_id {
1048                if sib_id == nid {
1049                    break;
1050                }
1051                let sibling_discriminant =
1052                    core::mem::discriminant(node_data[sib_id.index()].get_node_type());
1053                if sibling_discriminant == node_discriminant {
1054                    count += 1;
1055                }
1056                sibling_id = hierarchy
1057                    .get(sib_id.index())
1058                    .and_then(NodeHierarchyItem::next_sibling_id);
1059            }
1060            count
1061        });
1062        hasher.write(&nth_of_type.to_le_bytes());
1063
1064        // Hash the node type discriminant (Discriminant<T> implements Hash)
1065        node_discriminant.hash(&mut hasher);
1066
1067        // Also hash the classes for additional stability
1068        for attr in node.attributes().as_ref() {
1069            if let Some(class) = attr.as_class() {
1070                hasher.write(class.as_bytes());
1071            }
1072        }
1073
1074        parent_key = hasher.finish();
1075    }
1076
1077    parent_key
1078}
1079
1080/// Reconcile cursor byte position when text content changes.
1081///
1082/// This function maps a cursor position from old text to new text, preserving
1083/// the cursor's logical position as much as possible:
1084///
1085/// 1. If cursor is in unchanged prefix → stays at same byte offset
1086/// 2. If cursor is in unchanged suffix → adjusts by length difference
1087/// 3. If cursor is in changed region → places at end of new content
1088///
1089/// # Arguments
1090/// * `old_text` - The previous text content
1091/// * `new_text` - The new text content
1092/// * `old_cursor_byte` - Cursor byte offset in old text
1093///
1094/// # Returns
1095/// The reconciled cursor byte offset in new text
1096///
1097/// # Example
1098/// ```rust,ignore
1099/// let old_text = "Hello";
1100/// let new_text = "Hello World";
1101/// let old_cursor = 5; // cursor at end of "Hello"
1102/// let new_cursor = reconcile_cursor_position(old_text, new_text, old_cursor);
1103/// assert_eq!(new_cursor, 5); // cursor stays at same position (prefix unchanged)
1104/// ```
1105#[must_use] pub fn reconcile_cursor_position(
1106    old_text: &str,
1107    new_text: &str,
1108    old_cursor_byte: usize,
1109) -> usize {
1110    // AUDIT: every returned offset is snapped DOWN to the nearest UTF-8 char
1111    // boundary in `new_text` (and clamped to its length). The prefix/suffix
1112    // scans below compare byte-by-byte and can land mid-codepoint, so a raw
1113    // return value could later panic when used to slice `new_text` as a `str`.
1114    let snap = |offset: usize| -> usize {
1115        let mut o = offset.min(new_text.len());
1116        while o > 0 && !new_text.is_char_boundary(o) {
1117            o -= 1;
1118        }
1119        o
1120    };
1121
1122    // If texts are equal, cursor is unchanged
1123    if old_text == new_text {
1124        return snap(old_cursor_byte);
1125    }
1126
1127    // Empty old text - place cursor at end of new text
1128    if old_text.is_empty() {
1129        return new_text.len();
1130    }
1131
1132    // Empty new text - place cursor at 0
1133    if new_text.is_empty() {
1134        return 0;
1135    }
1136
1137    // Find common prefix (how many bytes from the start are identical)
1138    let common_prefix_bytes = old_text
1139        .bytes()
1140        .zip(new_text.bytes())
1141        .take_while(|(a, b)| a == b)
1142        .count();
1143    
1144    // If cursor was in the unchanged prefix, it stays at the same byte offset
1145    if old_cursor_byte <= common_prefix_bytes {
1146        return snap(old_cursor_byte);
1147    }
1148    
1149    // Find common suffix (how many bytes from the end are identical)
1150    let common_suffix_bytes = old_text
1151        .bytes()
1152        .rev()
1153        .zip(new_text.bytes().rev())
1154        .take_while(|(a, b)| a == b)
1155        .count();
1156    
1157    // Calculate where the suffix starts in old and new text
1158    let old_suffix_start = old_text.len().saturating_sub(common_suffix_bytes);
1159    let new_suffix_start = new_text.len().saturating_sub(common_suffix_bytes);
1160    
1161    // If cursor was in the unchanged suffix, adjust by length difference
1162    if old_cursor_byte >= old_suffix_start {
1163        // saturating: an out-of-range cursor (> old_text.len()) must clamp to the
1164        // end of the new text like every other path here, not underflow-panic.
1165        let offset_from_end = old_text.len().saturating_sub(old_cursor_byte);
1166        return snap(new_text.len().saturating_sub(offset_from_end));
1167    }
1168
1169    // Cursor was in the changed region - place at end of inserted content
1170    // This handles insertions (cursor moves with new text) and deletions (cursor at edit point)
1171    snap(new_suffix_start)
1172}
1173
1174/// Get the text content from a `NodeData` if it's a Text node.
1175///
1176/// Returns the text string if the node is `NodeType::Text`, otherwise `None`.
1177#[must_use] pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
1178    if let NodeType::Text(ref text) = node.get_node_type() {
1179        Some(text.as_str())
1180    } else {
1181        None
1182    }
1183}
1184
1185// ============================================================================
1186// ChangeAccumulator — unifies all change input paths
1187// ============================================================================
1188
1189/// Text change info for cursor/selection reconciliation.
1190#[derive(Debug, Clone, PartialEq, Eq)]
1191pub struct TextChange {
1192    /// The text content before the change.
1193    pub old_text: String,
1194    /// The text content after the change.
1195    pub new_text: String,
1196}
1197
1198/// Per-node change report combining multiple information sources.
1199#[derive(Debug, Clone, Default)]
1200pub struct NodeChangeReport {
1201    /// Bitflags from DOM-level field comparison.
1202    pub change_set: NodeChangeSet,
1203
1204    /// Highest `RelayoutScope` from any CSS property that changed on this node.
1205    /// This is more granular than `NodeChangeSet`'s binary LAYOUT/PAINT split.
1206    ///
1207    /// - `None` → repaint only (color, opacity, transform)
1208    /// - `IfcOnly` → reshape text in the containing IFC
1209    /// - `SizingOnly` → recompute this node's intrinsic size
1210    /// - `Full` → full subtree relayout (display, position, float, etc.)
1211    pub relayout_scope: RelayoutScope,
1212
1213    /// Individual CSS properties that changed (for fine-grained cache invalidation).
1214    /// Empty if the change was structural (text content, node type, etc.)
1215    pub changed_css_properties: Vec<CssPropertyType>,
1216
1217    /// If text content changed, the old and new text for cursor reconciliation.
1218    pub text_change: Option<TextChange>,
1219}
1220
1221impl NodeChangeReport {
1222    /// Returns the `DirtyFlag` level needed for this change report.
1223    /// Maps `RelayoutScope` + `NodeChangeSet` → a simple tri-state.
1224    #[must_use] pub fn needs_layout(&self) -> bool {
1225        self.change_set.needs_layout() || self.relayout_scope > RelayoutScope::None
1226    }
1227
1228    #[must_use] pub const fn needs_paint(&self) -> bool {
1229        self.change_set.needs_paint()
1230    }
1231
1232    #[must_use] pub fn is_visually_unchanged(&self) -> bool {
1233        self.change_set.is_visually_unchanged() && self.relayout_scope == RelayoutScope::None
1234    }
1235}
1236
1237/// Unified change report that merges information from all three change paths:
1238///
1239/// 1. **DOM reconciliation** (`compute_node_changes` after `reconcile_dom`)
1240/// 2. **CSS restyle** (`restyle_on_state_change` for hover/focus/active)
1241/// 3. **Runtime edits** (`words_changed`, `css_properties_changed`, `images_changed`)
1242///
1243/// This is the single source of truth for "what work needs to happen this frame".
1244#[derive(Debug, Clone, Default)]
1245pub struct ChangeAccumulator {
1246    /// Per-node change info. Key is the new-DOM `NodeId`.
1247    pub per_node: BTreeMap<NodeId, NodeChangeReport>,
1248
1249    /// Maximum `RelayoutScope` across all changed nodes.
1250    /// Quick check: if this is `None`, we can skip layout entirely.
1251    pub max_scope: RelayoutScope,
1252
1253    /// Nodes that are newly mounted (no old counterpart).
1254    /// These always need full layout.
1255    pub mounted_nodes: Vec<NodeId>,
1256
1257    /// Nodes that were unmounted (no new counterpart).
1258    /// Used for cleanup (remove from scroll/focus/cursor managers).
1259    pub unmounted_nodes: Vec<NodeId>,
1260}
1261
1262impl ChangeAccumulator {
1263    #[must_use] pub fn new() -> Self {
1264        Self::default()
1265    }
1266
1267    /// Returns true if no changes were detected at all.
1268    #[must_use] pub fn is_empty(&self) -> bool {
1269        self.per_node.is_empty() && self.mounted_nodes.is_empty() && self.unmounted_nodes.is_empty()
1270    }
1271
1272    /// Returns true if layout work is needed (any node has scope > None).
1273    #[must_use] pub fn needs_layout(&self) -> bool {
1274        self.max_scope > RelayoutScope::None
1275            || !self.mounted_nodes.is_empty()
1276            || self.per_node.values().any(NodeChangeReport::needs_layout)
1277    }
1278
1279    /// Returns true if only paint work is needed (no layout).
1280    #[must_use] pub fn needs_paint_only(&self) -> bool {
1281        !self.needs_layout() && self.per_node.values().any(NodeChangeReport::needs_paint)
1282    }
1283
1284    /// Returns true if only non-visual changes occurred (callbacks, dataset, a11y).
1285    #[must_use] pub fn is_visually_unchanged(&self) -> bool {
1286        self.mounted_nodes.is_empty()
1287            && self.unmounted_nodes.is_empty()
1288            && self.max_scope == RelayoutScope::None
1289            && self.per_node.values().all(NodeChangeReport::is_visually_unchanged)
1290    }
1291
1292    /// Add a node change from DOM reconciliation (Path A).
1293    pub fn add_dom_change(
1294        &mut self,
1295        new_node_id: NodeId,
1296        change_set: NodeChangeSet,
1297        relayout_scope: RelayoutScope,
1298        text_change: Option<TextChange>,
1299        changed_css_properties: Vec<CssPropertyType>,
1300    ) {
1301        if relayout_scope > self.max_scope {
1302            self.max_scope = relayout_scope;
1303        }
1304
1305        let report = self.per_node.entry(new_node_id).or_default();
1306        report.change_set |= change_set;
1307        if relayout_scope > report.relayout_scope {
1308            report.relayout_scope = relayout_scope;
1309        }
1310        if text_change.is_some() {
1311            report.text_change = text_change;
1312        }
1313        report.changed_css_properties.extend(changed_css_properties);
1314    }
1315
1316    /// Add a text change (from runtime edit or DOM reconciliation).
1317    pub fn add_text_change(
1318        &mut self,
1319        node_id: NodeId,
1320        old_text: String,
1321        new_text: String,
1322    ) {
1323        let scope = RelayoutScope::IfcOnly;
1324        if scope > self.max_scope {
1325            self.max_scope = scope;
1326        }
1327
1328        let report = self.per_node.entry(node_id).or_default();
1329        report.change_set.insert(NodeChangeSet::TEXT_CONTENT);
1330        if scope > report.relayout_scope {
1331            report.relayout_scope = scope;
1332        }
1333        report.text_change = Some(TextChange { old_text, new_text });
1334    }
1335
1336    /// Add a CSS property change (from runtime edit or restyle).
1337    pub fn add_css_change(
1338        &mut self,
1339        node_id: NodeId,
1340        prop_type: CssPropertyType,
1341        scope: RelayoutScope,
1342    ) {
1343        if scope > self.max_scope {
1344            self.max_scope = scope;
1345        }
1346
1347        let report = self.per_node.entry(node_id).or_default();
1348        if scope > RelayoutScope::None {
1349            report.change_set.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
1350        } else {
1351            report.change_set.insert(NodeChangeSet::INLINE_STYLE_PAINT);
1352        }
1353        if scope > report.relayout_scope {
1354            report.relayout_scope = scope;
1355        }
1356        report.changed_css_properties.push(prop_type);
1357    }
1358
1359    /// Add an image change (from runtime edit or DOM reconciliation).
1360    pub fn add_image_change(
1361        &mut self,
1362        node_id: NodeId,
1363        scope: RelayoutScope,
1364    ) {
1365        if scope > self.max_scope {
1366            self.max_scope = scope;
1367        }
1368
1369        let report = self.per_node.entry(node_id).or_default();
1370        report.change_set.insert(NodeChangeSet::IMAGE_CHANGED);
1371        if scope > report.relayout_scope {
1372            report.relayout_scope = scope;
1373        }
1374    }
1375
1376    /// Add a mounted (new) node.
1377    pub fn add_mount(&mut self, node_id: NodeId) {
1378        self.mounted_nodes.push(node_id);
1379    }
1380
1381    /// Add an unmounted (removed) node.
1382    pub fn add_unmount(&mut self, node_id: NodeId) {
1383        self.unmounted_nodes.push(node_id);
1384    }
1385
1386    /// Merge a `RestyleResult` (from `restyle_on_state_change()`) into this accumulator.
1387    ///
1388    /// This is the bridge between Path B (restyle) and the unified change pipeline.
1389    /// Each `ChangedCssProperty` is classified via `relayout_scope()` to determine
1390    /// whether it affects layout or only paint.
1391    pub fn merge_restyle_result(&mut self, restyle: &crate::styled_dom::RestyleResult) {
1392        for (node_id, changed_props) in &restyle.changed_nodes {
1393            for changed in changed_props {
1394                let prop_type = changed.current_prop.get_type();
1395                let scope = prop_type.relayout_scope(true); // conservative
1396                self.add_css_change(*node_id, prop_type, scope);
1397            }
1398        }
1399    }
1400
1401    /// Populate this accumulator from an `ExtendedDiffResult` + the old/new DOM data.
1402    ///
1403    /// This converts per-node `NodeChangeSet` flags into full `NodeChangeReport`s
1404    /// with `RelayoutScope` classification.
1405    pub fn merge_extended_diff(
1406        &mut self,
1407        extended: &ExtendedDiffResult,
1408        old_node_data: &[NodeData],
1409        new_node_data: &[NodeData],
1410    ) {
1411        for &(old_id, new_id, ref change_set) in &extended.node_changes {
1412            if change_set.is_empty() {
1413                continue;
1414            }
1415
1416            // Determine RelayoutScope from the change flags
1417            let scope = Self::classify_change_scope(*change_set, new_node_data, new_id);
1418
1419            // Extract text change info if TEXT_CONTENT flag is set
1420            let text_change = if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
1421                let old_text = get_node_text_content(&old_node_data[old_id.index()])
1422                    .unwrap_or("")
1423                    .to_string();
1424                let new_text = get_node_text_content(&new_node_data[new_id.index()])
1425                    .unwrap_or("")
1426                    .to_string();
1427                Some(TextChange { old_text, new_text })
1428            } else {
1429                None
1430            };
1431
1432            self.add_dom_change(new_id, *change_set, scope, text_change, Vec::new());
1433        }
1434
1435        // Track mounts: new nodes that didn't match anything in old
1436        let matched_new: alloc::collections::BTreeSet<usize> = extended
1437            .diff
1438            .node_moves
1439            .iter()
1440            .map(|m| m.new_node_id.index())
1441            .collect();
1442
1443        for idx in 0..new_node_data.len() {
1444            if !matched_new.contains(&idx) {
1445                self.add_mount(NodeId::new(idx));
1446            }
1447        }
1448
1449        // Track unmounts: old nodes that didn't match anything in new
1450        let matched_old: alloc::collections::BTreeSet<usize> = extended
1451            .diff
1452            .node_moves
1453            .iter()
1454            .map(|m| m.old_node_id.index())
1455            .collect();
1456
1457        for idx in 0..old_node_data.len() {
1458            if !matched_old.contains(&idx) {
1459                self.add_unmount(NodeId::new(idx));
1460            }
1461        }
1462    }
1463
1464    /// Classify a `NodeChangeSet` into the appropriate `RelayoutScope`.
1465    fn classify_change_scope(
1466        change_set: NodeChangeSet,
1467        new_node_data: &[NodeData],
1468        new_node_id: NodeId,
1469    ) -> RelayoutScope {
1470        // NODE_TYPE_CHANGED or CHILDREN_CHANGED → Full
1471        if change_set.contains(NodeChangeSet::NODE_TYPE_CHANGED)
1472            || change_set.contains(NodeChangeSet::CHILDREN_CHANGED)
1473        {
1474            return RelayoutScope::Full;
1475        }
1476
1477        // IDS_AND_CLASSES → Full (conservative: class change may add layout-affecting CSS)
1478        if change_set.contains(NodeChangeSet::IDS_AND_CLASSES) {
1479            return RelayoutScope::Full;
1480        }
1481
1482        // INLINE_STYLE_LAYOUT → could be IfcOnly, SizingOnly, or Full
1483        // We need to check individual properties for the exact scope.
1484        // For now, we use SizingOnly as a conservative default since
1485        // the individual property scopes were already checked in compute_node_changes.
1486        if change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT) {
1487            // Walk the inline CSS properties to find the max scope
1488            let new_node = &new_node_data[new_node_id.index()];
1489            let mut max_scope = RelayoutScope::None;
1490            for (prop, _conds) in new_node.style.iter_inline_properties() {
1491                let scope = prop.get_type().relayout_scope(true);
1492                if scope > max_scope {
1493                    max_scope = scope;
1494                }
1495            }
1496            return if max_scope == RelayoutScope::None {
1497                RelayoutScope::SizingOnly // conservative fallback
1498            } else {
1499                max_scope
1500            };
1501        }
1502
1503        // TEXT_CONTENT → IfcOnly (reshape text, may cascade)
1504        if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
1505            return RelayoutScope::IfcOnly;
1506        }
1507
1508        // IMAGE_CHANGED → SizingOnly (intrinsic size may change)
1509        if change_set.contains(NodeChangeSet::IMAGE_CHANGED) {
1510            return RelayoutScope::SizingOnly;
1511        }
1512
1513        // CONTENTEDITABLE → SizingOnly
1514        if change_set.contains(NodeChangeSet::CONTENTEDITABLE) {
1515            return RelayoutScope::SizingOnly;
1516        }
1517
1518        // Paint-only or no-visual changes
1519        if change_set.intersects(NodeChangeSet::AFFECTS_PAINT) {
1520            return RelayoutScope::None;
1521        }
1522
1523        RelayoutScope::None
1524    }
1525}
1526
1527/// Perform a full reconciliation with change detection.
1528///
1529/// This combines `reconcile_dom()` + `compute_node_changes()` into a single
1530/// pass that produces an `ExtendedDiffResult` with per-node change flags.
1531///
1532/// The `ChangeAccumulator` can then be populated from the result via
1533/// `accumulator.merge_extended_diff()`.
1534#[must_use] pub fn reconcile_dom_with_changes(
1535    old_node_data: &[NodeData],
1536    new_node_data: &[NodeData],
1537    old_hierarchy: &[NodeHierarchyItem],
1538    new_hierarchy: &[NodeHierarchyItem],
1539    old_styled_nodes: Option<&[StyledNodeState]>,
1540    new_styled_nodes: Option<&[StyledNodeState]>,
1541    old_layout: &OrderedMap<NodeId, LogicalRect>,
1542    new_layout: &OrderedMap<NodeId, LogicalRect>,
1543    dom_id: DomId,
1544    timestamp: Instant,
1545) -> ExtendedDiffResult {
1546    // Step 1: Run standard reconciliation
1547    let diff = reconcile_dom(
1548        old_node_data,
1549        new_node_data,
1550        old_hierarchy,
1551        new_hierarchy,
1552        old_layout,
1553        new_layout,
1554        dom_id,
1555        timestamp,
1556    );
1557
1558    // Step 2: For each matched pair, compute what changed
1559    let mut node_changes = Vec::new();
1560    for node_move in &diff.node_moves {
1561        let old_nd = &old_node_data[node_move.old_node_id.index()];
1562        let new_nd = &new_node_data[node_move.new_node_id.index()];
1563
1564        let old_state = old_styled_nodes.and_then(|s| s.get(node_move.old_node_id.index()));
1565        let new_state = new_styled_nodes.and_then(|s| s.get(node_move.new_node_id.index()));
1566
1567        let changes = compute_node_changes(old_nd, new_nd, old_state, new_state);
1568        node_changes.push((node_move.old_node_id, node_move.new_node_id, changes));
1569    }
1570
1571    ExtendedDiffResult { diff, node_changes }
1572}
1573
1574// ============================================================================
1575// NodeDataFingerprint — multi-field hash for fast change detection
1576// ============================================================================
1577
1578/// Per-node hash broken into independent fields for fast change detection.
1579///
1580/// Instead of a single u64 hash (which loses all granularity), this stores
1581/// separate hashes per field category. Comparing two fingerprints is O(1)
1582/// (6 integer comparisons) and immediately tells us WHICH category changed,
1583/// avoiding the more expensive `compute_node_changes()` for unchanged nodes.
1584///
1585/// Two-tier strategy:
1586/// - **Tier 1** (this struct): O(1) per node, identifies which categories changed.
1587/// - **Tier 2** (`compute_node_changes`): O(n) per changed field, does field-by-field
1588///   comparison only for nodes that Tier 1 identified as changed.
1589#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1590#[derive(Default)]
1591pub struct NodeDataFingerprint {
1592    /// Hash of `node_type` (Text content, Image ref, Div, etc.)
1593    pub content_hash: u64,
1594    /// Hash of `styled_node_state` (hover, focus, active bits)
1595    pub state_hash: u64,
1596    /// Hash of inline CSS properties
1597    pub inline_css_hash: u64,
1598    /// Hash of `ids_and_classes`
1599    pub ids_classes_hash: u64,
1600    /// Hash of callbacks (event types + function pointers)
1601    pub callbacks_hash: u64,
1602    /// Hash of other attributes (contenteditable, `tab_index`, dataset)
1603    pub attrs_hash: u64,
1604}
1605
1606
1607impl NodeDataFingerprint {
1608    /// Compute a fingerprint from a node's data and styled state.
1609    #[must_use] pub fn compute(node: &NodeData, styled_state: Option<&StyledNodeState>) -> Self {
1610        use core::hash::Hasher;
1611        use core::hash::Hash;
1612
1613        // Content hash
1614        let content_hash = {
1615            let mut h = crate::hash::DefaultHasher::new();
1616            node.get_node_type().hash(&mut h);
1617            h.finish()
1618        };
1619
1620        // State hash
1621        let state_hash = {
1622            let mut h = crate::hash::DefaultHasher::new();
1623            if let Some(state) = styled_state {
1624                state.hash(&mut h);
1625            }
1626            h.finish()
1627        };
1628
1629        // Inline CSS hash — full CssProperty value (matches the legacy
1630        // CssPropertyWithConditions::hash that hashed both property and the
1631        // condition vec length).
1632        let inline_css_hash = {
1633            let mut h = crate::hash::DefaultHasher::new();
1634            for (prop, conds) in node.style.iter_inline_properties() {
1635                prop.hash(&mut h);
1636                conds.as_slice().len().hash(&mut h);
1637            }
1638            h.finish()
1639        };
1640
1641        // IDs and classes hash (now stored in attributes)
1642        let ids_classes_hash = {
1643            let mut h = crate::hash::DefaultHasher::new();
1644            for attr in node.attributes().as_ref() {
1645                match attr {
1646                    crate::dom::AttributeType::Id(s) => {
1647                        crate::dom::IdOrClass::Id(s.clone()).hash(&mut h);
1648                    }
1649                    crate::dom::AttributeType::Class(s) => {
1650                        crate::dom::IdOrClass::Class(s.clone()).hash(&mut h);
1651                    }
1652                    _ => {}
1653                }
1654            }
1655            h.finish()
1656        };
1657
1658        // Callbacks hash
1659        let callbacks_hash = {
1660            let mut h = crate::hash::DefaultHasher::new();
1661            for cb in node.callbacks.as_ref() {
1662                cb.event.hash(&mut h);
1663                cb.callback.hash(&mut h);
1664            }
1665            h.finish()
1666        };
1667
1668        // Attributes hash
1669        let attrs_hash = {
1670            let mut h = crate::hash::DefaultHasher::new();
1671            node.is_contenteditable().hash(&mut h);
1672            node.flags.hash(&mut h);
1673            node.get_dataset().hash(&mut h);
1674            h.finish()
1675        };
1676
1677        Self {
1678            content_hash,
1679            state_hash,
1680            inline_css_hash,
1681            ids_classes_hash,
1682            callbacks_hash,
1683            attrs_hash,
1684        }
1685    }
1686
1687    /// Returns a quick `NodeChangeSet` by comparing two fingerprints.
1688    /// This is O(1) — just comparing 6 u64s.
1689    ///
1690    /// The result is *conservative*: if a field hash differs, we set the
1691    /// broadest applicable flag. For precise classification (e.g., which
1692    /// CSS properties changed and their `relayout_scope()`), the caller
1693    /// should fall back to `compute_node_changes()` for changed nodes.
1694    #[must_use] pub const fn diff(&self, other: &Self) -> NodeChangeSet {
1695        let mut changes = NodeChangeSet::empty();
1696
1697        if self.content_hash != other.content_hash {
1698            // Could be TEXT_CONTENT, IMAGE_CHANGED, or NODE_TYPE_CHANGED
1699            // We set both TEXT_CONTENT and IMAGE_CHANGED conservatively;
1700            // compute_node_changes() will refine this.
1701            changes.insert(NodeChangeSet::TEXT_CONTENT);
1702            changes.insert(NodeChangeSet::IMAGE_CHANGED);
1703        }
1704
1705        if self.state_hash != other.state_hash {
1706            changes.insert(NodeChangeSet::STYLED_STATE);
1707        }
1708
1709        if self.inline_css_hash != other.inline_css_hash {
1710            // Conservative: inline CSS could affect layout or paint.
1711            // compute_node_changes() checks relayout_scope() per property.
1712            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
1713        }
1714
1715        if self.ids_classes_hash != other.ids_classes_hash {
1716            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
1717        }
1718
1719        if self.callbacks_hash != other.callbacks_hash {
1720            changes.insert(NodeChangeSet::CALLBACKS);
1721        }
1722
1723        if self.attrs_hash != other.attrs_hash {
1724            changes.insert(NodeChangeSet::TAB_INDEX);
1725            changes.insert(NodeChangeSet::CONTENTEDITABLE);
1726        }
1727
1728        changes
1729    }
1730
1731    /// Returns true if the fingerprint is identical (no changes at all).
1732    #[must_use] pub fn is_identical(&self, other: &Self) -> bool {
1733        self == other
1734    }
1735
1736    /// Quick check: could this change affect layout?
1737    #[must_use] pub const fn might_affect_layout(&self, other: &Self) -> bool {
1738        self.content_hash != other.content_hash
1739            || self.inline_css_hash != other.inline_css_hash
1740            || self.ids_classes_hash != other.ids_classes_hash
1741            || self.attrs_hash != other.attrs_hash
1742    }
1743
1744    /// Quick check: could this change affect visuals at all?
1745    #[must_use] pub const fn might_affect_visuals(&self, other: &Self) -> bool {
1746        self.content_hash != other.content_hash
1747            || self.state_hash != other.state_hash
1748            || self.inline_css_hash != other.inline_css_hash
1749            || self.ids_classes_hash != other.ids_classes_hash
1750    }
1751}
1752
1753#[cfg(test)]
1754mod audit_tests {
1755    use super::*;
1756    use crate::dom::NodeData;
1757    use crate::styled_dom::NodeHierarchyItem;
1758
1759    // Build a NodeHierarchyItem from optional 0-based indices (encoded 1-based).
1760    fn hitem(
1761        parent: Option<usize>,
1762        prev: Option<usize>,
1763        next: Option<usize>,
1764        last_child: Option<usize>,
1765    ) -> NodeHierarchyItem {
1766        NodeHierarchyItem {
1767            parent: parent.map_or(0, |p| p + 1),
1768            previous_sibling: prev.map_or(0, |p| p + 1),
1769            next_sibling: next.map_or(0, |p| p + 1),
1770            last_child: last_child.map_or(0, |p| p + 1),
1771        }
1772    }
1773
1774    // A deep parent chain that would overflow the stack with the old recursion.
1775    #[test]
1776    fn reconciliation_key_deep_chain_no_overflow() {
1777        let build = |n: usize| -> (Vec<NodeData>, Vec<NodeHierarchyItem>) {
1778            let node_data = (0..n).map(|_| NodeData::create_div()).collect();
1779            let hierarchy = (0..n)
1780                .map(|i| {
1781                    hitem(
1782                        if i == 0 { None } else { Some(i - 1) },
1783                        None,
1784                        None,
1785                        if i + 1 < n { Some(i + 1) } else { None },
1786                    )
1787                })
1788                .collect();
1789            (node_data, hierarchy)
1790        };
1791
1792        // A very deep linear chain: the OLD recursion overflowed the stack here.
1793        // A single-node key walk is O(depth) and must complete without recursing.
1794        let n = 100_000usize;
1795        let (node_data, hierarchy) = build(n);
1796        let _ = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(n - 1));
1797        let _ = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(n - 1));
1798
1799        // Whole-DOM precompute calls the per-node walk once per node, so over a
1800        // *linear* chain it is O(n²) — that only bites a pathological 100k-deep
1801        // DOM (never a real tree). Exercise the whole-DOM path over a modest
1802        // chain; correctness is covered by `reconciliation_key_single_node` and
1803        // `reconciliation_key_distinguishes_siblings`.
1804        let m = 2_000usize;
1805        let (nd, hi) = build(m);
1806        let keys = precompute_reconciliation_keys(&nd, &hi);
1807        assert_eq!(keys.len(), m);
1808    }
1809
1810    // A cyclic (corrupt) hierarchy must terminate, not hang.
1811    #[test]
1812    fn reconciliation_key_cycle_terminates() {
1813        let node_data = vec![
1814            NodeData::create_div(),
1815            NodeData::create_div(),
1816            NodeData::create_div(),
1817        ];
1818        // node1.parent = 2, node2.parent = 1 — a cycle not involving root 0.
1819        let hierarchy = vec![
1820            hitem(None, None, None, None),
1821            hitem(Some(2), None, None, None),
1822            hitem(Some(1), None, None, None),
1823        ];
1824        let _ = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(1));
1825        let _ = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(1));
1826    }
1827
1828    #[test]
1829    fn reconciliation_key_single_node() {
1830        let node_data = vec![NodeData::create_div()];
1831        let hierarchy = vec![hitem(None, None, None, None)];
1832        let direct = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(0));
1833        let pre = precompute_reconciliation_keys(&node_data, &hierarchy)[0];
1834        assert_eq!(direct, pre);
1835    }
1836
1837    #[test]
1838    fn reconciliation_key_distinguishes_siblings() {
1839        // root 0 with two div children 1 and 2 — nth-of-type must differ.
1840        let node_data = vec![NodeData::create_div(); 3];
1841        let hierarchy = vec![
1842            hitem(None, None, None, Some(2)),    // root: first_child=1, last_child=2
1843            hitem(Some(0), None, Some(2), None), // child 1
1844            hitem(Some(0), Some(1), None, None), // child 2
1845        ];
1846        let k1 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(1));
1847        let k2 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(2));
1848        assert_ne!(k1, k2);
1849    }
1850
1851    #[test]
1852    fn cursor_offsets_are_always_char_boundaries() {
1853        // "héllo": h=0, é=1..3 (2 bytes), l=3, l=4, o=5 (len 6).
1854        let old = "héllo";
1855        let new = "héllo wörld"; // ö is multibyte too
1856        for c in 0..=old.len() {
1857            let r = reconcile_cursor_position(old, new, c);
1858            assert!(
1859                new.is_char_boundary(r),
1860                "cursor {c} mapped to non-char-boundary offset {r} in {new:?}",
1861            );
1862            assert!(r <= new.len());
1863        }
1864        // Deletion inside a multibyte suffix must not split a codepoint.
1865        let r = reconcile_cursor_position("aömega", "bömega", 3);
1866        assert!("bömega".is_char_boundary(r));
1867    }
1868
1869    #[test]
1870    fn cursor_prefix_unchanged_stays_put() {
1871        assert_eq!(reconcile_cursor_position("Hello", "Hello World", 5), 5);
1872    }
1873
1874    #[test]
1875    fn cursor_empty_cases() {
1876        assert_eq!(reconcile_cursor_position("", "abc", 0), 3);
1877        assert_eq!(reconcile_cursor_position("abc", "", 2), 0);
1878        assert_eq!(reconcile_cursor_position("abc", "abc", 2), 2);
1879    }
1880}
1881
1882// ============================================================================
1883// Autotest: adversarial unit tests
1884// ============================================================================
1885//
1886// Generated against the autotest task spec for `core/src/diff.rs`. Strategy per
1887// category:
1888//
1889//   * numeric      -> 0 / MIN / MAX / overflow / NaN / saturation
1890//   * "parser"-ish -> malformed, huge, boundary and unicode text input
1891//                     (`reconcile_cursor_position` is the byte-offset parser here)
1892//   * round-trip   -> precompute == per-node compute, fingerprint == recompute,
1893//                     BitOr == BitOrAssign
1894//   * getters /    -> invariants hold on default, empty and extreme instances
1895//     predicates
1896//
1897// The module is inline (not `core/tests/`) because `has_*_callback`,
1898// `create_lifecycle_event` and `ChangeAccumulator::classify_change_scope` are
1899// private to this module.
1900#[cfg(test)]
1901mod autotest_generated {
1902    use super::*;
1903
1904    use azul_css::{
1905        css::CssPropertyValue,
1906        props::{layout::LayoutWidth, property::CssProperty},
1907    };
1908
1909    use crate::{
1910        callbacks::CoreCallback,
1911        dom::{DatasetMergeCallbackType, TabIndex},
1912        geom::{LogicalPosition, LogicalSize},
1913        refany::{OptionRefAny, RefAny},
1914        resources::{ImageRef, RawImageFormat},
1915    };
1916
1917    // ---------------------------------------------------------------- helpers
1918
1919    // `CoreCallback::cb` is a raw `usize` fn-pointer slot. `reconcile_dom` only
1920    // ever inspects `CoreCallbackData::event`, never calls through the pointer,
1921    // so `0` is a safe sentinel (same convention as
1922    // `core/tests/reconciliation/deep_reconciliation.rs`).
1923    fn noop_callback() -> CoreCallback {
1924        CoreCallback {
1925            cb: 0usize,
1926            ctx: OptionRefAny::None,
1927        }
1928    }
1929
1930    fn with_cb(mut nd: NodeData, filter: ComponentEventFilter) -> NodeData {
1931        nd.add_callback(
1932            EventFilter::Component(filter),
1933            RefAny::new(0u32),
1934            noop_callback(),
1935        );
1936        nd
1937    }
1938
1939    // Build a NodeHierarchyItem from optional 0-based indices (encoded 1-based).
1940    fn hitem(
1941        parent: Option<usize>,
1942        prev: Option<usize>,
1943        next: Option<usize>,
1944        last_child: Option<usize>,
1945    ) -> NodeHierarchyItem {
1946        NodeHierarchyItem {
1947            parent: parent.map_or(0, |p| p + 1),
1948            previous_sibling: prev.map_or(0, |p| p + 1),
1949            next_sibling: next.map_or(0, |p| p + 1),
1950            last_child: last_child.map_or(0, |p| p + 1),
1951        }
1952    }
1953
1954    fn rect(w: f32, h: f32) -> LogicalRect {
1955        LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(w, h))
1956    }
1957
1958    fn layout_of(entries: &[(usize, LogicalRect)]) -> OrderedMap<NodeId, LogicalRect> {
1959        let mut m = OrderedMap::default();
1960        for (idx, r) in entries {
1961            m.insert(NodeId::new(*idx), *r);
1962        }
1963        m
1964    }
1965
1966    fn no_layout() -> OrderedMap<NodeId, LogicalRect> {
1967        OrderedMap::default()
1968    }
1969
1970    // Flat diff: empty hierarchies exercise the documented "degrade gracefully"
1971    // path of the structural reconciliation key.
1972    fn diff_flat(old: &[NodeData], new: &[NodeData]) -> DiffResult {
1973        reconcile_dom(
1974            old,
1975            new,
1976            &[],
1977            &[],
1978            &no_layout(),
1979            &no_layout(),
1980            DomId::ROOT_ID,
1981            Instant::now(),
1982        )
1983    }
1984
1985    fn count_events(r: &DiffResult, t: EventType) -> usize {
1986        r.events.iter().filter(|e| e.event_type == t).count()
1987    }
1988
1989    fn id_node(id: &str) -> NodeData {
1990        NodeData::create_div().with_ids_and_classes(vec![IdOrClass::Id(id.into())].into())
1991    }
1992
1993    fn class_node(class: &str) -> NodeData {
1994        NodeData::create_div().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
1995    }
1996
1997    // A representative unicode torture corpus: multi-byte, combining marks, RTL,
1998    // ZWJ emoji sequences, CJK, and a lone BOM.
1999    const UNICODE_SAMPLES: &[&str] = &[
2000        "",
2001        "a",
2002        "héllo",
2003        "e\u{0301}galite\u{0301}",   // combining acute accents
2004        "مرحبا بالعالم",             // RTL Arabic
2005        "👨‍👩‍👧‍👦 family",           // ZWJ emoji sequence
2006        "日本語のテキスト",
2007        "\u{feff}bom-prefixed",
2008        "🇩🇪🇫🇷🇯🇵",                 // regional indicator pairs
2009        "mixed 漢字 and ascii ✅",
2010    ];
2011
2012    // ========================================================================
2013    // NodeChangeSet — constructor / predicates / numeric bit ops
2014    // ========================================================================
2015
2016    #[test]
2017    fn autotest_changeset_empty_is_a_neutral_element() {
2018        let e = NodeChangeSet::empty();
2019        assert_eq!(e.bits, 0);
2020        assert!(e.is_empty());
2021        assert!(!e.needs_layout());
2022        assert!(!e.needs_paint());
2023        assert!(e.is_visually_unchanged());
2024        // `Default` must agree with `empty()`.
2025        assert_eq!(NodeChangeSet::default(), e);
2026        // Neutral under BitOr in both directions.
2027        let mut some = NodeChangeSet::empty();
2028        some.insert(NodeChangeSet::TEXT_CONTENT);
2029        assert_eq!((some | e).bits, some.bits);
2030        assert_eq!((e | some).bits, some.bits);
2031    }
2032
2033    #[test]
2034    fn autotest_changeset_contains_zero_is_vacuously_true() {
2035        // `contains` is an ALL-bits test: `(bits & 0) == 0` holds for every
2036        // value, including the empty set. Pin the semantics so a future rewrite
2037        // to `(bits & flag) != 0` (an ANY-bits test) is caught.
2038        assert!(NodeChangeSet::empty().contains(0));
2039        let mut s = NodeChangeSet::empty();
2040        s.insert(NodeChangeSet::CALLBACKS);
2041        assert!(s.contains(0));
2042        assert!(NodeChangeSet { bits: u32::MAX }.contains(0));
2043    }
2044
2045    #[test]
2046    fn autotest_changeset_intersects_zero_is_always_false() {
2047        // `intersects` is an ANY-bits test: masking with 0 can never be non-zero.
2048        assert!(!NodeChangeSet::empty().intersects(0));
2049        assert!(!NodeChangeSet { bits: u32::MAX }.intersects(0));
2050    }
2051
2052    #[test]
2053    fn autotest_changeset_contains_is_all_bits_intersects_is_any_bits() {
2054        let mut s = NodeChangeSet::empty();
2055        s.insert(NodeChangeSet::TEXT_CONTENT);
2056
2057        let both = NodeChangeSet::TEXT_CONTENT | NodeChangeSet::IMAGE_CHANGED;
2058        assert!(!s.contains(both), "contains() must require ALL bits");
2059        assert!(s.intersects(both), "intersects() must require ANY bit");
2060        assert!(s.contains(NodeChangeSet::TEXT_CONTENT));
2061    }
2062
2063    #[test]
2064    fn autotest_changeset_insert_min_max_and_idempotent() {
2065        // MIN (0) is a no-op.
2066        let mut s = NodeChangeSet::empty();
2067        s.insert(0);
2068        assert!(s.is_empty());
2069
2070        // MAX must not panic and must saturate to "all bits set".
2071        let mut s = NodeChangeSet::empty();
2072        s.insert(u32::MAX);
2073        assert_eq!(s.bits, u32::MAX);
2074        // Inserting again is idempotent (OR, not ADD -> cannot overflow).
2075        s.insert(u32::MAX);
2076        assert_eq!(s.bits, u32::MAX);
2077        s.insert(NodeChangeSet::TEXT_CONTENT);
2078        assert_eq!(s.bits, u32::MAX);
2079
2080        // With every bit set, all defined flags are present.
2081        assert!(s.contains(NodeChangeSet::NODE_TYPE_CHANGED));
2082        assert!(s.contains(NodeChangeSet::AFFECTS_LAYOUT));
2083        assert!(s.contains(NodeChangeSet::AFFECTS_PAINT));
2084        assert!(s.needs_layout());
2085        assert!(s.needs_paint());
2086        assert!(!s.is_visually_unchanged());
2087        assert!(!s.is_empty());
2088    }
2089
2090    #[test]
2091    fn autotest_changeset_undefined_high_bits_trigger_no_work() {
2092        // Bits outside every defined flag must not be interpreted as layout or
2093        // paint work — but the set is still non-empty.
2094        let s = NodeChangeSet {
2095            bits: 0b1000_0000_0000_0000_0000_0000_0000_0000,
2096        };
2097        assert!(!s.is_empty());
2098        assert!(!s.needs_layout());
2099        assert!(!s.needs_paint());
2100        assert!(s.is_visually_unchanged());
2101    }
2102
2103    #[test]
2104    fn autotest_changeset_layout_and_paint_masks_are_disjoint() {
2105        // A single flag must never mean "relayout AND repaint" — the two
2106        // composite masks partition the visual flags.
2107        assert_eq!(
2108            NodeChangeSet::AFFECTS_LAYOUT & NodeChangeSet::AFFECTS_PAINT,
2109            0,
2110            "AFFECTS_LAYOUT and AFFECTS_PAINT must not overlap",
2111        );
2112
2113        for flag in [
2114            NodeChangeSet::NODE_TYPE_CHANGED,
2115            NodeChangeSet::TEXT_CONTENT,
2116            NodeChangeSet::IDS_AND_CLASSES,
2117            NodeChangeSet::INLINE_STYLE_LAYOUT,
2118            NodeChangeSet::CHILDREN_CHANGED,
2119            NodeChangeSet::IMAGE_CHANGED,
2120            NodeChangeSet::CONTENTEDITABLE,
2121            NodeChangeSet::INLINE_STYLE_PAINT,
2122            NodeChangeSet::STYLED_STATE,
2123            NodeChangeSet::CALLBACKS,
2124            NodeChangeSet::DATASET,
2125            NodeChangeSet::ACCESSIBILITY,
2126        ] {
2127            let mut s = NodeChangeSet::empty();
2128            s.insert(flag);
2129            assert!(!(s.needs_layout() && s.needs_paint()), "flag {flag:#b} is both");
2130            // `is_visually_unchanged` is exactly "neither layout nor paint".
2131            assert_eq!(
2132                s.is_visually_unchanged(),
2133                !s.needs_layout() && !s.needs_paint(),
2134                "is_visually_unchanged() disagrees with needs_layout/needs_paint for {flag:#b}",
2135            );
2136        }
2137    }
2138
2139    #[test]
2140    fn autotest_changeset_nonvisual_flags_are_visually_unchanged() {
2141        let mut s = NodeChangeSet::empty();
2142        s.insert(NodeChangeSet::CALLBACKS);
2143        s.insert(NodeChangeSet::DATASET);
2144        s.insert(NodeChangeSet::ACCESSIBILITY);
2145        s.insert(NodeChangeSet::TAB_INDEX); // TAB_INDEX is in neither mask
2146        assert!(!s.is_empty());
2147        assert!(s.is_visually_unchanged());
2148        assert!(!s.needs_layout());
2149        assert!(!s.needs_paint());
2150    }
2151
2152    #[test]
2153    fn autotest_changeset_bitor_matches_bitorassign() {
2154        // Round-trip: the two operators must agree, and BitOr must be
2155        // commutative + idempotent for arbitrary (including undefined) bits.
2156        for (a, b) in [
2157            (0u32, 0u32),
2158            (0, u32::MAX),
2159            (u32::MAX, u32::MAX),
2160            (NodeChangeSet::TEXT_CONTENT, NodeChangeSet::STYLED_STATE),
2161            (0xDEAD_BEEF, 0x0BAD_F00D),
2162        ] {
2163            let (sa, sb) = (NodeChangeSet { bits: a }, NodeChangeSet { bits: b });
2164
2165            let by_operator = sa | sb;
2166            assert_eq!(by_operator.bits, a | b);
2167
2168            let mut by_assign = sa;
2169            by_assign |= sb;
2170            assert_eq!(by_assign, by_operator);
2171
2172            assert_eq!((sb | sa).bits, by_operator.bits, "BitOr must commute");
2173            assert_eq!((by_operator | by_operator).bits, by_operator.bits);
2174        }
2175    }
2176
2177    // ========================================================================
2178    // NodeChangeReport — getters / predicates
2179    // ========================================================================
2180
2181    #[test]
2182    fn autotest_change_report_default_is_inert() {
2183        let r = NodeChangeReport::default();
2184        assert!(!r.needs_layout());
2185        assert!(!r.needs_paint());
2186        assert!(r.is_visually_unchanged());
2187        assert_eq!(r.relayout_scope, RelayoutScope::None);
2188        assert!(r.changed_css_properties.is_empty());
2189        assert!(r.text_change.is_none());
2190    }
2191
2192    #[test]
2193    fn autotest_change_report_scope_alone_forces_layout() {
2194        // An empty change_set with a non-None scope must still request layout:
2195        // `needs_layout()` ORs the two sources.
2196        let r = NodeChangeReport { relayout_scope: RelayoutScope::IfcOnly, ..Default::default() };
2197        assert!(r.needs_layout());
2198        assert!(!r.needs_paint());
2199        assert!(!r.is_visually_unchanged());
2200    }
2201
2202    #[test]
2203    fn autotest_change_report_paint_flag_does_not_force_layout() {
2204        let mut r = NodeChangeReport::default();
2205        r.change_set.insert(NodeChangeSet::STYLED_STATE);
2206        assert!(!r.needs_layout());
2207        assert!(r.needs_paint());
2208        assert!(!r.is_visually_unchanged());
2209    }
2210
2211    // ========================================================================
2212    // reconcile_cursor_position — the byte-offset "parser": unicode + boundary
2213    // ========================================================================
2214
2215    #[test]
2216    fn autotest_cursor_result_is_always_a_valid_slice_index() {
2217        // The core safety invariant: whatever comes back must be <= new.len()
2218        // AND land on a char boundary, or a later `&new_text[..cursor]` panics.
2219        // Sweep every in-range cursor over every pair of the unicode corpus.
2220        for old in UNICODE_SAMPLES {
2221            for new in UNICODE_SAMPLES {
2222                for cursor in 0..=old.len() {
2223                    let r = reconcile_cursor_position(old, new, cursor);
2224                    assert!(
2225                        r <= new.len(),
2226                        "cursor {cursor} in {old:?} -> {r} exceeds len of {new:?}",
2227                    );
2228                    assert!(
2229                        new.is_char_boundary(r),
2230                        "cursor {cursor} in {old:?} -> {r} splits a codepoint in {new:?}",
2231                    );
2232                    // Must be usable as a real slice index.
2233                    let _ = &new[..r];
2234                }
2235            }
2236        }
2237    }
2238
2239    #[test]
2240    fn autotest_cursor_is_deterministic() {
2241        // Same inputs must always give the same answer (no hashing / iteration
2242        // order leaking into the result).
2243        for old in UNICODE_SAMPLES {
2244            for new in UNICODE_SAMPLES {
2245                let a = reconcile_cursor_position(old, new, old.len() / 2);
2246                let b = reconcile_cursor_position(old, new, old.len() / 2);
2247                assert_eq!(a, b);
2248            }
2249        }
2250    }
2251
2252    #[test]
2253    fn autotest_cursor_zero_stays_zero_when_texts_differ_at_byte_zero() {
2254        // cursor 0 <= common_prefix (0) -> snap(0) == 0.
2255        assert_eq!(reconcile_cursor_position("abc", "xyz", 0), 0);
2256        assert_eq!(reconcile_cursor_position("日本", "中国", 0), 0);
2257    }
2258
2259    #[test]
2260    fn autotest_cursor_identical_text_clamps_to_len() {
2261        // Equal texts short-circuit to `snap(cursor)`, which clamps to len and
2262        // snaps down to a char boundary — so even an absurd cursor is safe here.
2263        assert_eq!(reconcile_cursor_position("abc", "abc", usize::MAX), 3);
2264        assert_eq!(reconcile_cursor_position("héllo", "héllo", usize::MAX), 6);
2265        // Snapping down: byte 2 is mid-'é' (bytes 1..3) -> snaps to 1.
2266        assert_eq!(reconcile_cursor_position("héllo", "héllo", 2), 1);
2267    }
2268
2269    #[test]
2270    fn autotest_cursor_empty_sides_are_documented_constants() {
2271        // Empty old  -> end of new. Empty new -> 0. Both empty -> 0 (equal-text path).
2272        for new in UNICODE_SAMPLES {
2273            assert_eq!(reconcile_cursor_position("", new, 0), new.len());
2274            assert_eq!(reconcile_cursor_position("", new, usize::MAX), new.len());
2275        }
2276        for old in UNICODE_SAMPLES {
2277            if old.is_empty() {
2278                continue; // equal-text path, covered above
2279            }
2280            assert_eq!(reconcile_cursor_position(old, "", 0), 0);
2281            assert_eq!(reconcile_cursor_position(old, "", old.len()), 0);
2282        }
2283    }
2284
2285    #[test]
2286    fn autotest_cursor_appended_text_keeps_prefix_cursor() {
2287        // Pure append: any cursor inside the common prefix is untouched.
2288        let old = "Hello";
2289        let new = "Hello, World";
2290        for cursor in 0..=old.len() {
2291            assert_eq!(reconcile_cursor_position(old, new, cursor), cursor);
2292        }
2293    }
2294
2295    #[test]
2296    fn autotest_cursor_deleted_tail_clamps_into_new_text() {
2297        // Pure truncation: a cursor past the end of the new text must land at
2298        // the new end, never beyond it.
2299        let old = "Hello, World";
2300        let new = "Hello";
2301        assert_eq!(reconcile_cursor_position(old, new, old.len()), new.len());
2302        assert_eq!(reconcile_cursor_position(old, new, 5), 5);
2303    }
2304
2305    #[test]
2306    fn autotest_cursor_multibyte_insert_before_cursor_shifts_by_suffix_rule() {
2307        // Insert a 2-byte 'ö' at the front; a cursor sitting in the (unchanged)
2308        // suffix must keep its distance from the END of the string.
2309        let old = "mega";
2310        let new = "ömega";
2311        let r = reconcile_cursor_position(old, new, 4); // end of old
2312        assert_eq!(r, new.len());
2313        assert!(new.is_char_boundary(r));
2314    }
2315
2316    #[test]
2317    fn autotest_cursor_huge_inputs_do_not_hang_or_panic() {
2318        // 200k-byte strings: the prefix/suffix scans are linear, so this must
2319        // complete quickly and stay in-bounds.
2320        let old: String = std::iter::repeat_n('a', 200_000).collect();
2321        let mut new = old.clone();
2322        new.push_str("tail");
2323
2324        let r = reconcile_cursor_position(&old, &new, old.len());
2325        assert!(r <= new.len());
2326        assert!(new.is_char_boundary(r));
2327
2328        // Huge multibyte string: every returned offset must still be a boundary.
2329        let old_u: String = std::iter::repeat_n('é', 50_000).collect();
2330        let new_u: String = std::iter::repeat_n('é', 49_999).collect();
2331        let r = reconcile_cursor_position(&old_u, &new_u, old_u.len());
2332        assert!(r <= new_u.len());
2333        assert!(new_u.is_char_boundary(r));
2334    }
2335
2336    // Regression test for a former underflow: `reconcile_cursor_position` used
2337    // to compute `old_text.len() - old_cursor_byte` unchecked, which panicked
2338    // (debug) / wrapped (release) when the caller passed a cursor byte offset
2339    // PAST the end of `old_text`. Reaching it needs: old != new, both non-empty,
2340    // cursor > common_prefix and cursor >= old_suffix_start — e.g.
2341    // ("abc", "abd", usize::MAX). The fix uses `saturating_sub` so an
2342    // out-of-range cursor clamps to the end of the new text like every other
2343    // path here.
2344    #[test]
2345    fn autotest_cursor_out_of_range_cursor_must_saturate_not_underflow() {
2346        // Expected: clamp to the end of the new text, exactly like every other path.
2347        assert_eq!(reconcile_cursor_position("abc", "abd", usize::MAX), 3);
2348        assert_eq!(reconcile_cursor_position("abc", "abd", 99), 3);
2349        assert_eq!(reconcile_cursor_position("héllo", "héllx", usize::MAX), 6);
2350    }
2351
2352    // ========================================================================
2353    // get_node_text_content — round-trip
2354    // ========================================================================
2355
2356    #[test]
2357    fn autotest_text_content_round_trips_unicode() {
2358        for s in UNICODE_SAMPLES {
2359            let node = NodeData::create_text(*s);
2360            assert_eq!(
2361                get_node_text_content(&node),
2362                Some(*s),
2363                "create_text -> get_node_text_content must round-trip {s:?}",
2364            );
2365        }
2366    }
2367
2368    #[test]
2369    fn autotest_text_content_is_none_for_non_text_nodes() {
2370        assert_eq!(get_node_text_content(&NodeData::create_div()), None);
2371        assert_eq!(get_node_text_content(&NodeData::create_body()), None);
2372        assert_eq!(get_node_text_content(&NodeData::create_br()), None);
2373        let img = NodeData::create_image(ImageRef::null_image(
2374            1,
2375            1,
2376            RawImageFormat::RGBA8,
2377            Vec::new(),
2378        ));
2379        assert_eq!(get_node_text_content(&img), None);
2380    }
2381
2382    // ========================================================================
2383    // has_*_callback predicates
2384    // ========================================================================
2385
2386    #[test]
2387    fn autotest_callback_predicates_all_false_without_callbacks() {
2388        let n = NodeData::create_div();
2389        assert!(!has_mount_callback(&n));
2390        assert!(!has_unmount_callback(&n));
2391        assert!(!has_resize_callback(&n));
2392        assert!(!has_update_callback(&n));
2393    }
2394
2395    #[test]
2396    fn autotest_callback_predicates_are_mutually_exclusive_per_filter() {
2397        // Each predicate must recognise exactly its own ComponentEventFilter.
2398        let cases = [
2399            (ComponentEventFilter::AfterMount, [true, false, false, false]),
2400            (ComponentEventFilter::BeforeUnmount, [false, true, false, false]),
2401            (ComponentEventFilter::NodeResized, [false, false, true, false]),
2402            (ComponentEventFilter::Updated, [false, false, false, true]),
2403            // A Component filter that none of the four predicates handle.
2404            (ComponentEventFilter::Selected, [false, false, false, false]),
2405            (ComponentEventFilter::DefaultAction, [false, false, false, false]),
2406        ];
2407
2408        for (filter, expected) in cases {
2409            let n = with_cb(NodeData::create_div(), filter);
2410            let got = [
2411                has_mount_callback(&n),
2412                has_unmount_callback(&n),
2413                has_resize_callback(&n),
2414                has_update_callback(&n),
2415            ];
2416            assert_eq!(got, expected, "predicate mismatch for {filter:?}");
2417        }
2418    }
2419
2420    #[test]
2421    fn autotest_callback_predicates_find_target_among_many() {
2422        // The target callback is last of several — `any()` must still find it.
2423        let mut n = NodeData::create_div();
2424        for f in [
2425            ComponentEventFilter::Selected,
2426            ComponentEventFilter::DefaultAction,
2427            ComponentEventFilter::NodeResized,
2428        ] {
2429            n = with_cb(n, f);
2430        }
2431        assert!(has_resize_callback(&n));
2432        assert!(!has_mount_callback(&n));
2433    }
2434
2435    // ========================================================================
2436    // create_lifecycle_event (private)
2437    // ========================================================================
2438
2439    #[test]
2440    fn autotest_lifecycle_event_fields_are_wired_consistently() {
2441        let ts = Instant::now();
2442        let ev = create_lifecycle_event(
2443            EventType::Mount,
2444            NodeId::new(1_000_000),
2445            DomId::ROOT_ID,
2446            &ts,
2447            LifecycleEventData {
2448                reason: LifecycleReason::InitialMount,
2449                previous_bounds: None,
2450                current_bounds: rect(1.0, 2.0),
2451            },
2452        );
2453
2454        assert_eq!(ev.event_type, EventType::Mount);
2455        assert_eq!(ev.source, EventSource::Lifecycle);
2456        assert_eq!(ev.phase, EventPhase::Target);
2457        // A lifecycle event is delivered at its target, so the two must agree.
2458        assert_eq!(ev.target, ev.current_target);
2459        assert_eq!(
2460            ev.target.node.into_crate_internal(),
2461            Some(NodeId::new(1_000_000)),
2462            "NodeId must survive the 1-based NodeHierarchyItemId encoding",
2463        );
2464        assert!(!ev.stopped);
2465        assert!(!ev.stopped_immediate);
2466        assert!(!ev.prevented_default);
2467
2468        let EventData::Lifecycle(data) = &ev.data else {
2469            panic!("expected EventData::Lifecycle, got {:?}", ev.data);
2470        };
2471        assert!(data.previous_bounds.is_none());
2472        assert_eq!(data.current_bounds, rect(1.0, 2.0));
2473    }
2474
2475    // ========================================================================
2476    // compute_node_changes
2477    // ========================================================================
2478
2479    #[test]
2480    fn autotest_compute_changes_identical_nodes_report_nothing() {
2481        let a = NodeData::create_text("same");
2482        let b = NodeData::create_text("same");
2483        let changes = compute_node_changes(&a, &b, None, None);
2484        assert!(
2485            changes.is_empty(),
2486            "identical nodes must produce no change flags, got {:#b}",
2487            changes.bits,
2488        );
2489        assert!(changes.is_visually_unchanged());
2490    }
2491
2492    #[test]
2493    fn autotest_compute_changes_node_type_change_short_circuits_everything() {
2494        // The documented early-return: when the discriminant changes, NOTHING
2495        // else is inspected — even though these two nodes ALSO differ in
2496        // classes, callbacks, inline CSS, tab index and contenteditable, and
2497        // sit in different styled states.
2498        let old = NodeData::create_div();
2499        let new = with_cb(
2500            NodeData::create_text("now a text node")
2501                .with_ids_and_classes(vec![IdOrClass::Class("brand-new".into())].into())
2502                .with_css("width: 10px")
2503                .with_tab_index(TabIndex::NoKeyboardFocus)
2504                .with_contenteditable(true),
2505            ComponentEventFilter::AfterMount,
2506        );
2507
2508        let hovered = StyledNodeState {
2509            hover: true,
2510            ..StyledNodeState::default()
2511        };
2512        let changes = compute_node_changes(
2513            &old,
2514            &new,
2515            Some(&StyledNodeState::default()),
2516            Some(&hovered),
2517        );
2518        assert_eq!(
2519            changes.bits,
2520            NodeChangeSet::NODE_TYPE_CHANGED,
2521            "a node-type change must be reported alone (early return)",
2522        );
2523    }
2524
2525    #[test]
2526    fn autotest_compute_changes_text_content_unicode() {
2527        for (i, s) in UNICODE_SAMPLES.iter().enumerate() {
2528            let old = NodeData::create_text(*s);
2529
2530            // Same text -> no TEXT_CONTENT flag.
2531            let same = NodeData::create_text(*s);
2532            assert!(
2533                !compute_node_changes(&old, &same, None, None)
2534                    .contains(NodeChangeSet::TEXT_CONTENT),
2535                "identical text {s:?} must not report TEXT_CONTENT",
2536            );
2537
2538            // Different text -> TEXT_CONTENT flag.
2539            let other = UNICODE_SAMPLES[(i + 1) % UNICODE_SAMPLES.len()];
2540            if other == *s {
2541                continue;
2542            }
2543            let changed = NodeData::create_text(other);
2544            assert!(
2545                compute_node_changes(&old, &changed, None, None)
2546                    .contains(NodeChangeSet::TEXT_CONTENT),
2547                "{s:?} -> {other:?} must report TEXT_CONTENT",
2548            );
2549        }
2550    }
2551
2552    #[test]
2553    fn autotest_compute_changes_paint_only_css_never_sets_layout() {
2554        // `color` is RelayoutScope::None -> paint bucket only.
2555        let old = NodeData::create_div().with_css("color: red");
2556        let new = NodeData::create_div().with_css("color: blue");
2557        let changes = compute_node_changes(&old, &new, None, None);
2558
2559        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_PAINT));
2560        assert!(
2561            !changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT),
2562            "a paint-only property must not request relayout",
2563        );
2564        assert!(changes.needs_paint());
2565        assert!(!changes.needs_layout());
2566    }
2567
2568    #[test]
2569    fn autotest_compute_changes_sizing_css_sets_layout_not_paint() {
2570        // `width` is RelayoutScope::SizingOnly -> layout bucket.
2571        let old = NodeData::create_div().with_css("width: 10px");
2572        let new = NodeData::create_div().with_css("width: 20px");
2573        let changes = compute_node_changes(&old, &new, None, None);
2574
2575        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
2576        assert!(!changes.contains(NodeChangeSet::INLINE_STYLE_PAINT));
2577        assert!(changes.needs_layout());
2578    }
2579
2580    #[test]
2581    fn autotest_compute_changes_detects_removed_property() {
2582        // Regression guard for the AUDIT note at diff.rs:270 — a property that
2583        // exists only on the OLD node (i.e. was removed) must still be marked.
2584        let old = NodeData::create_div().with_css("color: red");
2585        let new = NodeData::create_div();
2586        let changes = compute_node_changes(&old, &new, None, None);
2587        assert!(
2588            changes.contains(NodeChangeSet::INLINE_STYLE_PAINT),
2589            "removing an inline property must be reported, got {:#b}",
2590            changes.bits,
2591        );
2592    }
2593
2594    #[test]
2595    fn autotest_compute_changes_detects_added_property() {
2596        let old = NodeData::create_div();
2597        let new = NodeData::create_div().with_css("width: 5px");
2598        let changes = compute_node_changes(&old, &new, None, None);
2599        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
2600    }
2601
2602    #[test]
2603    fn autotest_compute_changes_ids_and_classes() {
2604        let changes = compute_node_changes(&class_node("a"), &class_node("b"), None, None);
2605        assert!(changes.contains(NodeChangeSet::IDS_AND_CLASSES));
2606
2607        // Same classes -> no flag.
2608        let changes = compute_node_changes(&class_node("a"), &class_node("a"), None, None);
2609        assert!(!changes.contains(NodeChangeSet::IDS_AND_CLASSES));
2610    }
2611
2612    #[test]
2613    fn autotest_compute_changes_styled_state() {
2614        let n = NodeData::create_div();
2615        let calm = StyledNodeState::default();
2616        let hovered = StyledNodeState {
2617            hover: true,
2618            ..StyledNodeState::default()
2619        };
2620
2621        let changes = compute_node_changes(&n, &n, Some(&calm), Some(&hovered));
2622        assert!(changes.contains(NodeChangeSet::STYLED_STATE));
2623        assert!(changes.needs_paint());
2624        assert!(!changes.needs_layout());
2625
2626        // Same state -> no flag; and None/None -> no flag.
2627        assert!(!compute_node_changes(&n, &n, Some(&calm), Some(&calm))
2628            .contains(NodeChangeSet::STYLED_STATE));
2629        assert!(!compute_node_changes(&n, &n, None, None).contains(NodeChangeSet::STYLED_STATE));
2630
2631        // None vs Some(default) are *different* inputs and must be reported.
2632        assert!(compute_node_changes(&n, &n, None, Some(&calm))
2633            .contains(NodeChangeSet::STYLED_STATE));
2634    }
2635
2636    #[test]
2637    fn autotest_compute_changes_tab_index_and_contenteditable() {
2638        let plain = NodeData::create_div();
2639
2640        let editable = NodeData::create_div().with_contenteditable(true);
2641        let changes = compute_node_changes(&plain, &editable, None, None);
2642        assert!(changes.contains(NodeChangeSet::CONTENTEDITABLE));
2643        assert!(changes.needs_layout(), "CONTENTEDITABLE is in AFFECTS_LAYOUT");
2644
2645        let tabbed = NodeData::create_div().with_tab_index(TabIndex::OverrideInParent(3));
2646        let changes = compute_node_changes(&plain, &tabbed, None, None);
2647        assert!(changes.contains(NodeChangeSet::TAB_INDEX));
2648        // TAB_INDEX is in neither composite mask -> no visual work.
2649        assert!(changes.is_visually_unchanged());
2650    }
2651
2652    #[test]
2653    fn autotest_compute_changes_callbacks_count_and_identity() {
2654        let plain = NodeData::create_div();
2655        let one = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
2656
2657        // Different callback counts.
2658        let changes = compute_node_changes(&plain, &one, None, None);
2659        assert!(changes.contains(NodeChangeSet::CALLBACKS));
2660        assert!(changes.is_visually_unchanged(), "callbacks are not a visual change");
2661
2662        // Same count, different event filter.
2663        let other = with_cb(NodeData::create_div(), ComponentEventFilter::BeforeUnmount);
2664        let changes = compute_node_changes(&one, &other, None, None);
2665        assert!(changes.contains(NodeChangeSet::CALLBACKS));
2666
2667        // Same count, same filter -> no flag (cb pointer 0 == 0).
2668        let same = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
2669        let changes = compute_node_changes(&one, &same, None, None);
2670        assert!(!changes.contains(NodeChangeSet::CALLBACKS));
2671    }
2672
2673    #[test]
2674    fn autotest_compute_changes_image_identity_is_by_image_id() {
2675        // `ImageRef` hashes its process-unique `id`: shallow clones share it,
2676        // every fresh `null_image()` gets a new one.
2677        let img = ImageRef::null_image(4, 4, RawImageFormat::RGBA8, Vec::new());
2678        let same = NodeData::create_image(img.clone());
2679        let also_same = NodeData::create_image(img.clone());
2680        assert!(
2681            !compute_node_changes(&same, &also_same, None, None)
2682                .contains(NodeChangeSet::IMAGE_CHANGED),
2683            "two nodes holding clones of the SAME ImageRef must not report a change",
2684        );
2685
2686        // A distinct allocation, even with identical pixels/dimensions, is a
2687        // different image as far as reconciliation is concerned.
2688        let other = NodeData::create_image(ImageRef::null_image(
2689            4,
2690            4,
2691            RawImageFormat::RGBA8,
2692            Vec::new(),
2693        ));
2694        let changes = compute_node_changes(&same, &other, None, None);
2695        assert!(changes.contains(NodeChangeSet::IMAGE_CHANGED));
2696        assert!(changes.needs_layout(), "IMAGE_CHANGED is in AFFECTS_LAYOUT");
2697    }
2698
2699    // ========================================================================
2700    // calculate_reconciliation_key / precompute_reconciliation_keys
2701    // ========================================================================
2702
2703    #[test]
2704    fn autotest_rec_key_empty_node_data_is_safe() {
2705        assert!(precompute_reconciliation_keys(&[], &[]).is_empty());
2706    }
2707
2708    #[test]
2709    fn autotest_rec_key_precompute_matches_per_node_calculation() {
2710        // Round-trip: the O(1)-lookup table must agree with the direct call for
2711        // every node — the whole point of precomputing.
2712        let node_data = vec![
2713            NodeData::create_div(),
2714            class_node("row"),
2715            NodeData::create_text("leaf"),
2716            id_node("footer"),
2717        ];
2718        let hierarchy = vec![
2719            hitem(None, None, None, Some(3)),
2720            hitem(Some(0), None, Some(2), None),
2721            hitem(Some(0), Some(1), Some(3), None),
2722            hitem(Some(0), Some(2), None, None),
2723        ];
2724
2725        let keys = precompute_reconciliation_keys(&node_data, &hierarchy);
2726        assert_eq!(keys.len(), node_data.len());
2727        for (i, k) in keys.iter().enumerate() {
2728            assert_eq!(
2729                *k,
2730                calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(i)),
2731                "precomputed key for node {i} disagrees with the direct call",
2732            );
2733        }
2734    }
2735
2736    #[test]
2737    fn autotest_rec_key_explicit_key_beats_css_id_and_node_type() {
2738        // Priority 1 is absolute: it ignores the CSS ID, the classes, the node
2739        // type and the position in the tree.
2740        let bare = NodeData::create_div().with_key(7u32);
2741        let decorated = NodeData::create_text("totally different")
2742            .with_key(7u32)
2743            .with_ids_and_classes(
2744                vec![IdOrClass::Id("hero".into()), IdOrClass::Class("x".into())].into(),
2745            );
2746
2747        let a = calculate_reconciliation_key(&[bare], &[], NodeId::new(0));
2748        let b = calculate_reconciliation_key(&[decorated], &[], NodeId::new(0));
2749        assert_eq!(a, b, "an explicit .with_key() must dominate every other input");
2750    }
2751
2752    #[test]
2753    fn autotest_rec_key_css_id_used_when_no_explicit_key() {
2754        let same_a = calculate_reconciliation_key(&[id_node("hero")], &[], NodeId::new(0));
2755        let same_b = calculate_reconciliation_key(&[id_node("hero")], &[], NodeId::new(0));
2756        let other = calculate_reconciliation_key(&[id_node("footer")], &[], NodeId::new(0));
2757
2758        assert_eq!(same_a, same_b, "the CSS-ID key must be stable");
2759        assert_ne!(same_a, other, "different CSS IDs must produce different keys");
2760    }
2761
2762    #[test]
2763    fn autotest_rec_key_classes_participate_in_the_structural_key() {
2764        let a = calculate_reconciliation_key(&[class_node("alpha")], &[], NodeId::new(0));
2765        let b = calculate_reconciliation_key(&[class_node("beta")], &[], NodeId::new(0));
2766        assert_ne!(a, b, "classes must feed the structural key");
2767    }
2768
2769    #[test]
2770    fn autotest_rec_key_node_type_participates_in_the_structural_key() {
2771        let div = calculate_reconciliation_key(&[NodeData::create_div()], &[], NodeId::new(0));
2772        let txt =
2773            calculate_reconciliation_key(&[NodeData::create_text("x")], &[], NodeId::new(0));
2774        assert_ne!(div, txt, "the node-type discriminant must feed the structural key");
2775    }
2776
2777    #[test]
2778    fn autotest_rec_key_hierarchy_shorter_than_node_data_is_safe() {
2779        // A truncated / absent hierarchy must degrade to the documented
2780        // "discriminant + classes" key instead of panicking.
2781        let node_data = vec![NodeData::create_div(), class_node("a"), id_node("b")];
2782
2783        let with_none = precompute_reconciliation_keys(&node_data, &[]);
2784        let with_short = precompute_reconciliation_keys(&node_data, &[hitem(None, None, None, None)]);
2785
2786        assert_eq!(with_none.len(), 3);
2787        assert_eq!(with_short.len(), 3);
2788        // Node 0 is a root either way, so both spellings must agree on it.
2789        assert_eq!(with_none[0], with_short[0]);
2790    }
2791
2792    #[test]
2793    fn autotest_rec_key_identical_leaves_under_different_parents_differ() {
2794        // The parent chain must be folded in, otherwise keyless nodes under
2795        // unrelated parents would collide and migrate state across subtrees.
2796        //
2797        //   0 root
2798        //   ├── 1 (#left)   ── 3 div
2799        //   └── 2 (#right)  ── 4 div
2800        let node_data = vec![
2801            NodeData::create_div(),
2802            id_node("left"),
2803            id_node("right"),
2804            NodeData::create_div(),
2805            NodeData::create_div(),
2806        ];
2807        let hierarchy = vec![
2808            hitem(None, None, None, Some(2)),       // 0: children 1,2
2809            hitem(Some(0), None, Some(2), Some(3)), // 1: child 3
2810            hitem(Some(0), Some(1), None, Some(4)), // 2: child 4
2811            hitem(Some(1), None, None, None),       // 3
2812            hitem(Some(2), None, None, None),       // 4
2813        ];
2814
2815        let k3 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(3));
2816        let k4 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(4));
2817        assert_ne!(k3, k4, "identical leaves under different parents must not share a key");
2818    }
2819
2820    // ========================================================================
2821    // calculate_contenteditable_key
2822    // ========================================================================
2823
2824    #[test]
2825    fn autotest_contenteditable_key_is_deterministic_and_honours_explicit_keys() {
2826        let node_data = vec![NodeData::create_div().with_key(99u64)];
2827        let a = calculate_contenteditable_key(&node_data, &[], NodeId::new(0));
2828        let b = calculate_contenteditable_key(&node_data, &[], NodeId::new(0));
2829        assert_eq!(a, b, "must be deterministic");
2830
2831        // Priority 1 is shared with the reconciliation key: for an explicitly
2832        // keyed node both functions return the SAME value.
2833        assert_eq!(
2834            a,
2835            calculate_reconciliation_key(&node_data, &[], NodeId::new(0)),
2836            "explicit keys must be identical across both key functions",
2837        );
2838    }
2839
2840    #[test]
2841    fn autotest_contenteditable_key_distinguishes_nth_of_type() {
2842        // <div><p>A</p><p contenteditable>B</p></div> — the two same-type
2843        // siblings must not collide (nth-of-type is folded in).
2844        let node_data = vec![
2845            NodeData::create_div(),
2846            NodeData::create_text("A"),
2847            NodeData::create_text("B"),
2848        ];
2849        let hierarchy = vec![
2850            hitem(None, None, None, Some(2)),
2851            hitem(Some(0), None, Some(2), None),
2852            hitem(Some(0), Some(1), None, None),
2853        ];
2854
2855        let k1 = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(1));
2856        let k2 = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(2));
2857        assert_ne!(k1, k2, "same-type siblings must differ by nth-of-type");
2858    }
2859
2860    #[test]
2861    fn autotest_contenteditable_key_empty_hierarchy_is_safe() {
2862        let node_data = vec![NodeData::create_div(), class_node("editor")];
2863        for i in 0..node_data.len() {
2864            let k = calculate_contenteditable_key(&node_data, &[], NodeId::new(i));
2865            assert_eq!(k, calculate_contenteditable_key(&node_data, &[], NodeId::new(i)));
2866        }
2867    }
2868
2869    // ========================================================================
2870    // reconcile_dom
2871    // ========================================================================
2872
2873    #[test]
2874    fn autotest_reconcile_empty_to_empty_is_a_no_op() {
2875        let r = diff_flat(&[], &[]);
2876        assert!(r.events.is_empty());
2877        assert!(r.node_moves.is_empty());
2878    }
2879
2880    #[test]
2881    fn autotest_reconcile_mount_and_unmount_need_a_callback_to_fire() {
2882        // Without an AfterMount callback the node still mounts — it just fires
2883        // no event. Same for unmount. The events are opt-in.
2884        let silent_new = vec![NodeData::create_div()];
2885        let r = diff_flat(&[], &silent_new);
2886        assert!(r.events.is_empty(), "no callback -> no event");
2887        assert!(r.node_moves.is_empty());
2888
2889        let loud_new = vec![with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount)];
2890        let r = diff_flat(&[], &loud_new);
2891        assert_eq!(count_events(&r, EventType::Mount), 1);
2892
2893        let loud_old = vec![with_cb(
2894            NodeData::create_div(),
2895            ComponentEventFilter::BeforeUnmount,
2896        )];
2897        let r = diff_flat(&loud_old, &[]);
2898        assert_eq!(count_events(&r, EventType::Unmount), 1);
2899        assert!(r.node_moves.is_empty());
2900    }
2901
2902    #[test]
2903    fn autotest_reconcile_node_moves_are_a_bijection() {
2904        // 50 indistinguishable divs on both sides: every old node must be
2905        // claimed exactly once and every new node must claim at most one old
2906        // node. A queue bug (double-consume) would break this immediately.
2907        let old: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
2908        let new: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
2909
2910        let r = diff_flat(&old, &new);
2911        assert_eq!(r.node_moves.len(), 50);
2912
2913        let mut seen_old = [false; 50];
2914        let mut seen_new = [false; 50];
2915        for m in &r.node_moves {
2916            assert!(!seen_old[m.old_node_id.index()], "old node claimed twice");
2917            assert!(!seen_new[m.new_node_id.index()], "new node matched twice");
2918            seen_old[m.old_node_id.index()] = true;
2919            seen_new[m.new_node_id.index()] = true;
2920        }
2921        assert!(seen_old.iter().all(|b| *b), "every old node must be claimed");
2922        assert!(seen_new.iter().all(|b| *b), "every new node must be matched");
2923        assert!(r.events.is_empty(), "no lifecycle callbacks -> no events");
2924    }
2925
2926    #[test]
2927    fn autotest_reconcile_surplus_new_nodes_mount_and_surplus_old_unmount() {
2928        // 50 old, 60 new -> 50 matches + 10 mounts, no unmounts.
2929        let old: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
2930        let new: Vec<NodeData> = (0..60)
2931            .map(|_| with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount))
2932            .collect();
2933
2934        let r = diff_flat(&old, &new);
2935        assert_eq!(r.node_moves.len(), 50);
2936        assert_eq!(count_events(&r, EventType::Mount), 10);
2937        assert_eq!(count_events(&r, EventType::Unmount), 0);
2938
2939        // 50 old, 40 new -> 40 matches + 10 unmounts.
2940        let old: Vec<NodeData> = (0..50)
2941            .map(|_| with_cb(NodeData::create_div(), ComponentEventFilter::BeforeUnmount))
2942            .collect();
2943        let new: Vec<NodeData> = (0..40).map(|_| NodeData::create_div()).collect();
2944
2945        let r = diff_flat(&old, &new);
2946        assert_eq!(r.node_moves.len(), 40);
2947        assert_eq!(count_events(&r, EventType::Unmount), 10);
2948        assert_eq!(count_events(&r, EventType::Mount), 0);
2949    }
2950
2951    #[test]
2952    fn autotest_reconcile_explicit_key_mismatch_mounts_instead_of_guessing() {
2953        // The documented rule: an explicit `.with_key()` that finds no partner
2954        // must NOT fall through to the content/structural tiers, even though
2955        // the two nodes are otherwise byte-identical.
2956        let old = vec![with_cb(
2957            NodeData::create_text("same content").with_key(1u32),
2958            ComponentEventFilter::BeforeUnmount,
2959        )];
2960        let new = vec![with_cb(
2961            NodeData::create_text("same content").with_key(2u32),
2962            ComponentEventFilter::AfterMount,
2963        )];
2964
2965        let r = diff_flat(&old, &new);
2966        assert!(
2967            r.node_moves.is_empty(),
2968            "keys 1 and 2 must not match, got {:?}",
2969            r.node_moves,
2970        );
2971        assert_eq!(count_events(&r, EventType::Mount), 1);
2972        assert_eq!(count_events(&r, EventType::Unmount), 1);
2973    }
2974
2975    #[test]
2976    fn autotest_reconcile_update_fires_only_on_rec_key_match_with_changed_content() {
2977        // Same key, changed text, Updated callback present -> Update event.
2978        let old = vec![NodeData::create_text("v1").with_key(1u32)];
2979        let new = vec![with_cb(
2980            NodeData::create_text("v2").with_key(1u32),
2981            ComponentEventFilter::Updated,
2982        )];
2983
2984        let r = diff_flat(&old, &new);
2985        assert_eq!(r.node_moves.len(), 1, "the key must match across frames");
2986        assert_eq!(count_events(&r, EventType::Update), 1);
2987
2988        // Same key, SAME content -> no Update. Both frames must be byte-identical
2989        // for this: `NodeData::hash` folds in the callback events too (dom.rs:1579),
2990        // so the Updated handler has to be present on BOTH sides — otherwise the
2991        // hashes differ for the callback alone and we'd be testing nothing.
2992        let stable = with_cb(
2993            NodeData::create_text("v1").with_key(1u32),
2994            ComponentEventFilter::Updated,
2995        );
2996        let old = vec![stable.clone()];
2997        let new = vec![stable];
2998
2999        let r = diff_flat(&old, &new);
3000        assert_eq!(r.node_moves.len(), 1);
3001        assert_eq!(
3002            count_events(&r, EventType::Update),
3003            0,
3004            "unchanged content must not fire Update",
3005        );
3006    }
3007
3008    #[test]
3009    fn autotest_reconcile_update_requires_the_callback() {
3010        // Content changed under a stable key, but no Updated callback -> silent.
3011        let old = vec![NodeData::create_text("v1").with_key(1u32)];
3012        let new = vec![NodeData::create_text("v2").with_key(1u32)];
3013        let r = diff_flat(&old, &new);
3014        assert_eq!(r.node_moves.len(), 1);
3015        assert!(r.events.is_empty());
3016    }
3017
3018    #[test]
3019    fn autotest_reconcile_missing_layout_entries_default_to_zero_rect() {
3020        // Neither side has layout data: `unwrap_or(LogicalRect::zero())` means
3021        // the sizes compare equal, so no Resize fires and nothing panics.
3022        let old = vec![NodeData::create_div()];
3023        let new = vec![with_cb(
3024            NodeData::create_div(),
3025            ComponentEventFilter::NodeResized,
3026        )];
3027
3028        let r = diff_flat(&old, &new);
3029        assert_eq!(r.node_moves.len(), 1);
3030        assert_eq!(
3031            count_events(&r, EventType::Resize),
3032            0,
3033            "zero-vs-zero bounds must not be treated as a resize",
3034        );
3035    }
3036
3037    #[test]
3038    fn autotest_reconcile_resize_fires_with_previous_and_current_bounds() {
3039        let old = vec![NodeData::create_div()];
3040        let new = vec![with_cb(
3041            NodeData::create_div(),
3042            ComponentEventFilter::NodeResized,
3043        )];
3044
3045        let r = reconcile_dom(
3046            &old,
3047            &new,
3048            &[],
3049            &[],
3050            &layout_of(&[(0, rect(100.0, 50.0))]),
3051            &layout_of(&[(0, rect(100.0, 80.0))]),
3052            DomId::ROOT_ID,
3053            Instant::now(),
3054        );
3055
3056        assert_eq!(count_events(&r, EventType::Resize), 1);
3057        let EventData::Lifecycle(data) = &r.events[0].data else {
3058            panic!("resize event must carry EventData::Lifecycle");
3059        };
3060        assert_eq!(data.reason, LifecycleReason::Resize);
3061        assert_eq!(data.previous_bounds, Some(rect(100.0, 50.0)));
3062        assert_eq!(data.current_bounds, rect(100.0, 80.0));
3063    }
3064
3065    #[test]
3066    fn autotest_reconcile_resize_ignores_pure_translation() {
3067        // Only `size` is compared — moving a node must not fire Resize.
3068        let old = vec![NodeData::create_div()];
3069        let new = vec![with_cb(
3070            NodeData::create_div(),
3071            ComponentEventFilter::NodeResized,
3072        )];
3073
3074        let moved = LogicalRect::new(LogicalPosition::new(999.0, 999.0), LogicalSize::new(10.0, 10.0));
3075        let r = reconcile_dom(
3076            &old,
3077            &new,
3078            &[],
3079            &[],
3080            &layout_of(&[(0, rect(10.0, 10.0))]),
3081            &layout_of(&[(0, moved)]),
3082            DomId::ROOT_ID,
3083            Instant::now(),
3084        );
3085        assert_eq!(count_events(&r, EventType::Resize), 0);
3086    }
3087
3088    #[test]
3089    fn autotest_reconcile_nan_bounds_do_not_fire_a_resize_every_frame() {
3090        // NUMERIC EDGE — the sharpest one in this file.
3091        //
3092        // The Resize check is `old_rect.size != new_rect.size`. With a DERIVED
3093        // f32 `PartialEq` this would be catastrophic: `NaN != NaN` is true, so a
3094        // node whose layout solved to NaN would be reported as "resized" on
3095        // EVERY frame forever, firing an endless Resize-callback storm on a
3096        // completely static layout.
3097        //
3098        // `LogicalSize` dodges that with a hand-written `PartialEq` that runs
3099        // both operands through `geom::quantize()`, which maps every NaN to the
3100        // single sentinel `i64::MIN` (geom.rs:218) — so all NaNs compare EQUAL.
3101        // This test pins that: revert `LogicalSize` to `#[derive(PartialEq)]`
3102        // and it goes red.
3103        let old = vec![NodeData::create_div()];
3104        let new = vec![with_cb(
3105            NodeData::create_div(),
3106            ComponentEventFilter::NodeResized,
3107        )];
3108
3109        let nan = rect(f32::NAN, f32::NAN);
3110        let r = reconcile_dom(
3111            &old,
3112            &new,
3113            &[],
3114            &[],
3115            &layout_of(&[(0, nan)]),
3116            &layout_of(&[(0, nan)]),
3117            DomId::ROOT_ID,
3118            Instant::now(),
3119        );
3120        assert_eq!(r.node_moves.len(), 1);
3121        assert_eq!(
3122            count_events(&r, EventType::Resize),
3123            0,
3124            "an unchanged NaN size must not be reported as a resize",
3125        );
3126
3127        // Infinities are likewise stable against themselves (they saturate to
3128        // i64::MAX / i64::MIN under quantize()).
3129        let inf = rect(f32::INFINITY, f32::NEG_INFINITY);
3130        let r = reconcile_dom(
3131            &old,
3132            &new,
3133            &[],
3134            &[],
3135            &layout_of(&[(0, inf)]),
3136            &layout_of(&[(0, inf)]),
3137            DomId::ROOT_ID,
3138            Instant::now(),
3139        );
3140        assert_eq!(
3141            count_events(&r, EventType::Resize),
3142            0,
3143            "infinite-but-equal bounds must not be treated as a resize",
3144        );
3145
3146        // But a NaN -> real transition IS a genuine resize, and must still fire.
3147        let r = reconcile_dom(
3148            &old,
3149            &new,
3150            &[],
3151            &[],
3152            &layout_of(&[(0, nan)]),
3153            &layout_of(&[(0, rect(10.0, 20.0))]),
3154            DomId::ROOT_ID,
3155            Instant::now(),
3156        );
3157        assert_eq!(
3158            count_events(&r, EventType::Resize),
3159            1,
3160            "NaN -> a real size is a real resize",
3161        );
3162    }
3163
3164    #[test]
3165    fn autotest_reconcile_extreme_bounds_do_not_panic() {
3166        // f32 MIN/MAX/subnormal bounds must flow through the Resize comparison
3167        // without arithmetic surprises (the code only compares, never subtracts).
3168        let old = vec![NodeData::create_div()];
3169        let new = vec![with_cb(
3170            NodeData::create_div(),
3171            ComponentEventFilter::NodeResized,
3172        )];
3173
3174        for (a, b) in [
3175            (rect(f32::MIN, f32::MAX), rect(f32::MAX, f32::MIN)),
3176            (rect(f32::MIN_POSITIVE, 0.0), rect(0.0, f32::MIN_POSITIVE)),
3177            (rect(-0.0, 0.0), rect(0.0, -0.0)), // IEEE: -0.0 == 0.0
3178        ] {
3179            let r = reconcile_dom(
3180                &old,
3181                &new,
3182                &[],
3183                &[],
3184                &layout_of(&[(0, a)]),
3185                &layout_of(&[(0, b)]),
3186                DomId::ROOT_ID,
3187                Instant::now(),
3188            );
3189            assert_eq!(r.node_moves.len(), 1);
3190        }
3191    }
3192
3193    #[test]
3194    fn autotest_reconcile_keyless_tiers_respect_the_parent_key_gate() {
3195        // Regression guard for the AUDIT note at diff.rs:601. Two structurally
3196        // identical leaves live under DIFFERENT parents. The content-hash and
3197        // structural-hash tiers must not match them across parents, or focus /
3198        // scroll / dataset state migrates into an unrelated subtree.
3199        //
3200        // old:  0 root ── 1 (#left)  ── 2 "leaf"
3201        // new:  0 root ── 1 (#right) ── 2 "leaf"
3202        let old_nd = vec![
3203            NodeData::create_div(),
3204            id_node("left"),
3205            NodeData::create_text("leaf"),
3206        ];
3207        let old_hier = vec![
3208            hitem(None, None, None, Some(1)),
3209            hitem(Some(0), None, None, Some(2)),
3210            hitem(Some(1), None, None, None),
3211        ];
3212
3213        let new_nd = vec![
3214            NodeData::create_div(),
3215            id_node("right"),
3216            NodeData::create_text("leaf"),
3217        ];
3218        let new_hier = old_hier.clone();
3219
3220        let r = reconcile_dom(
3221            &old_nd,
3222            &new_nd,
3223            &old_hier,
3224            &new_hier,
3225            &no_layout(),
3226            &no_layout(),
3227            DomId::ROOT_ID,
3228            Instant::now(),
3229        );
3230
3231        // The leaf (index 2) must NOT be matched: its parent's reconciliation
3232        // key differs (#left vs #right), so both keyless tiers are gated off.
3233        let leaf_matched = r
3234            .node_moves
3235            .iter()
3236            .any(|m| m.new_node_id.index() == 2 && m.old_node_id.index() == 2);
3237        assert!(
3238            !leaf_matched,
3239            "a leaf must not migrate across parents; moves = {:?}",
3240            r.node_moves,
3241        );
3242    }
3243
3244    // ========================================================================
3245    // create_migration_map
3246    // ========================================================================
3247
3248    #[test]
3249    fn autotest_migration_map_empty_and_large() {
3250        assert!(create_migration_map(&[]).is_empty());
3251
3252        let moves: Vec<NodeMove> = (0..1000)
3253            .map(|i| NodeMove {
3254                old_node_id: NodeId::new(i),
3255                new_node_id: NodeId::new(i * 2),
3256            })
3257            .collect();
3258        let map = create_migration_map(&moves);
3259        assert_eq!(map.len(), 1000);
3260        assert_eq!(map.get(&NodeId::new(999)), Some(&NodeId::new(1998)));
3261    }
3262
3263    #[test]
3264    fn autotest_migration_map_duplicate_old_id_keeps_the_last_write() {
3265        // The map is a BTreeMap, so a repeated old id overwrites. Pin it: a
3266        // silent "first wins" flip would strand focus on a stale node.
3267        let moves = vec![
3268            NodeMove {
3269                old_node_id: NodeId::new(0),
3270                new_node_id: NodeId::new(5),
3271            },
3272            NodeMove {
3273                old_node_id: NodeId::new(0),
3274                new_node_id: NodeId::new(9),
3275            },
3276        ];
3277        let map = create_migration_map(&moves);
3278        assert_eq!(map.len(), 1);
3279        assert_eq!(map.get(&NodeId::new(0)), Some(&NodeId::new(9)));
3280    }
3281
3282    #[test]
3283    fn autotest_migration_map_round_trips_a_real_diff() {
3284        let old: Vec<NodeData> = (0..8).map(|_| NodeData::create_div()).collect();
3285        let new: Vec<NodeData> = (0..8).map(|_| NodeData::create_div()).collect();
3286        let r = diff_flat(&old, &new);
3287
3288        let map = create_migration_map(&r.node_moves);
3289        assert_eq!(map.len(), r.node_moves.len());
3290        for m in &r.node_moves {
3291            assert_eq!(map.get(&m.old_node_id), Some(&m.new_node_id));
3292        }
3293    }
3294
3295    // ========================================================================
3296    // transfer_states
3297    // ========================================================================
3298
3299    #[allow(dead_code)]
3300    struct TestState(u32);
3301
3302    // Keeps the PERSISTENT (old) allocation, discarding the fresh one — the
3303    // real-world case (MapWidget's tile cache is written by background threads).
3304    extern "C" fn merge_keep_old(_new_data: RefAny, old_data: RefAny) -> RefAny {
3305        old_data
3306    }
3307
3308    #[test]
3309    fn autotest_transfer_states_out_of_range_moves_are_skipped() {
3310        // The bounds guard must swallow a corrupt NodeMove instead of indexing
3311        // out of bounds.
3312        let mut old = vec![NodeData::create_div()];
3313        let mut new = vec![NodeData::create_div()];
3314
3315        let moves = vec![
3316            NodeMove {
3317                old_node_id: NodeId::new(5), // out of range
3318                new_node_id: NodeId::new(0),
3319            },
3320            NodeMove {
3321                old_node_id: NodeId::new(0),
3322                new_node_id: NodeId::new(7), // out of range
3323            },
3324            NodeMove {
3325                old_node_id: NodeId::new(usize::MAX),
3326                new_node_id: NodeId::new(usize::MAX),
3327            },
3328        ];
3329
3330        transfer_states(&mut old, &mut new, &moves); // must not panic
3331        assert!(new[0].get_dataset().is_none());
3332    }
3333
3334    #[test]
3335    fn autotest_transfer_states_without_merge_callback_leaves_datasets_intact() {
3336        let mut old = vec![NodeData::create_div()];
3337        old[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(1))));
3338
3339        let mut new = vec![NodeData::create_div()];
3340        new[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(2))));
3341
3342        let old_ptr = old[0].get_dataset().unwrap().sharing_info.ptr as usize;
3343        let new_ptr = new[0].get_dataset().unwrap().sharing_info.ptr as usize;
3344
3345        transfer_states(
3346            &mut old,
3347            &mut new,
3348            &[NodeMove {
3349                old_node_id: NodeId::new(0),
3350                new_node_id: NodeId::new(0),
3351            }],
3352        );
3353
3354        // No merge callback -> early `continue`, both datasets stay where they were.
3355        assert_eq!(
3356            old[0].get_dataset().unwrap().sharing_info.ptr as usize,
3357            old_ptr,
3358        );
3359        assert_eq!(
3360            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
3361            new_ptr,
3362        );
3363    }
3364
3365    #[test]
3366    fn autotest_transfer_states_with_one_missing_dataset_restores_both_sides() {
3367        // Merge callback present, but the OLD node has no dataset -> the
3368        // `(new_ds, old_ds)` arm must put the taken dataset back.
3369        let mut old = vec![NodeData::create_div()];
3370        let mut new = vec![NodeData::create_div()];
3371        new[0].set_merge_callback(merge_keep_old as DatasetMergeCallbackType);
3372        new[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(2))));
3373
3374        let new_ptr = new[0].get_dataset().unwrap().sharing_info.ptr as usize;
3375
3376        transfer_states(
3377            &mut old,
3378            &mut new,
3379            &[NodeMove {
3380                old_node_id: NodeId::new(0),
3381                new_node_id: NodeId::new(0),
3382            }],
3383        );
3384
3385        assert!(old[0].get_dataset().is_none());
3386        assert_eq!(
3387            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
3388            new_ptr,
3389            "the fresh dataset must be restored, not dropped",
3390        );
3391    }
3392
3393    #[test]
3394    fn autotest_transfer_states_repoints_orphaned_callback_refanys() {
3395        // The unification rule (diff.rs:909): a widget builds its dataset AND
3396        // its callback refanys from clones of ONE RefAny. When the merge keeps
3397        // the OLD allocation, every clone of the FRESH one is orphaned and must
3398        // be re-pointed at the merged result — otherwise the widget fragments
3399        // across two caches (the MapWidget grey-tile bug).
3400        let fresh = RefAny::new(TestState(1));
3401        let fresh_ptr = fresh.sharing_info.ptr as usize;
3402
3403        let mut new0 = NodeData::create_div();
3404        new0.set_merge_callback(merge_keep_old as DatasetMergeCallbackType);
3405        new0.set_dataset(OptionRefAny::Some(fresh.clone()));
3406        // A callback on the SAME node, holding a clone of the fresh allocation.
3407        new0.add_callback(
3408            EventFilter::Component(ComponentEventFilter::Selected),
3409            fresh.clone(),
3410            noop_callback(),
3411        );
3412
3413        // A *sibling* node whose callback also clones the fresh allocation —
3414        // the generalised sweep must reach it too, not just the merge node.
3415        let mut new1 = NodeData::create_div();
3416        new1.add_callback(
3417            EventFilter::Component(ComponentEventFilter::Selected),
3418            fresh.clone(),
3419            noop_callback(),
3420        );
3421
3422        let persistent = RefAny::new(TestState(2));
3423        let persistent_ptr = persistent.sharing_info.ptr as usize;
3424        assert_ne!(fresh_ptr, persistent_ptr, "test setup: allocations must differ");
3425
3426        let mut old = vec![NodeData::create_div()];
3427        old[0].set_dataset(OptionRefAny::Some(persistent));
3428
3429        let mut new = vec![new0, new1];
3430
3431        transfer_states(
3432            &mut old,
3433            &mut new,
3434            &[NodeMove {
3435                old_node_id: NodeId::new(0),
3436                new_node_id: NodeId::new(0),
3437            }],
3438        );
3439
3440        // The merged dataset is the PERSISTENT allocation.
3441        assert_eq!(
3442            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
3443            persistent_ptr,
3444            "the merge must keep the persistent allocation",
3445        );
3446        // The old node's dataset was moved into the merge result.
3447        assert!(old[0].get_dataset().is_none());
3448
3449        // Both orphaned callback refanys — on the merge node AND on the sibling
3450        // — must now point at the merged allocation.
3451        for (i, nd) in new.iter().enumerate() {
3452            for cb in nd.callbacks.as_ref() {
3453                assert_eq!(
3454                    cb.refany.sharing_info.ptr as usize,
3455                    persistent_ptr,
3456                    "node {i}: an orphaned callback refany was not re-pointed",
3457                );
3458            }
3459        }
3460    }
3461
3462    // ========================================================================
3463    // ChangeAccumulator
3464    // ========================================================================
3465
3466    #[test]
3467    fn autotest_accumulator_new_is_empty_and_inert() {
3468        let a = ChangeAccumulator::new();
3469        assert!(a.is_empty());
3470        assert!(!a.needs_layout());
3471        assert!(!a.needs_paint_only());
3472        assert!(a.is_visually_unchanged());
3473        assert_eq!(a.max_scope, RelayoutScope::None);
3474        // `new()` and `default()` must agree.
3475        let d = ChangeAccumulator::default();
3476        assert_eq!(a.is_empty(), d.is_empty());
3477        assert_eq!(a.max_scope, d.max_scope);
3478    }
3479
3480    #[test]
3481    fn autotest_accumulator_mount_forces_layout_unmount_does_not() {
3482        let mut a = ChangeAccumulator::new();
3483        a.add_mount(NodeId::new(0));
3484        assert!(!a.is_empty());
3485        assert!(a.needs_layout(), "a mounted node always needs layout");
3486        assert!(!a.needs_paint_only());
3487        assert!(!a.is_visually_unchanged());
3488
3489        // An unmount alone is NOT layout work here (the node is gone); it only
3490        // breaks `is_visually_unchanged`. Pin the asymmetry.
3491        let mut a = ChangeAccumulator::new();
3492        a.add_unmount(NodeId::new(0));
3493        assert!(!a.is_empty());
3494        assert!(!a.needs_layout());
3495        assert!(!a.is_visually_unchanged());
3496    }
3497
3498    #[test]
3499    fn autotest_accumulator_css_change_routes_paint_vs_layout_by_scope() {
3500        // scope == None -> paint bucket.
3501        let mut a = ChangeAccumulator::new();
3502        a.add_css_change(NodeId::new(0), CssPropertyType::TextColor, RelayoutScope::None);
3503        assert!(!a.needs_layout());
3504        assert!(a.needs_paint_only());
3505        assert!(!a.is_visually_unchanged());
3506        assert_eq!(a.max_scope, RelayoutScope::None);
3507        assert!(a.per_node[&NodeId::new(0)]
3508            .change_set
3509            .contains(NodeChangeSet::INLINE_STYLE_PAINT));
3510
3511        // scope > None -> layout bucket.
3512        let mut a = ChangeAccumulator::new();
3513        a.add_css_change(NodeId::new(0), CssPropertyType::Width, RelayoutScope::SizingOnly);
3514        assert!(a.needs_layout());
3515        assert!(!a.needs_paint_only(), "layout work subsumes paint-only");
3516        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
3517        assert!(a.per_node[&NodeId::new(0)]
3518            .change_set
3519            .contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
3520    }
3521
3522    #[test]
3523    fn autotest_accumulator_max_scope_is_monotone() {
3524        // Once escalated, the scope must never be lowered by a later, weaker
3525        // change — otherwise a Full relayout gets silently downgraded.
3526        let mut a = ChangeAccumulator::new();
3527        a.add_css_change(NodeId::new(0), CssPropertyType::Display, RelayoutScope::Full);
3528        assert_eq!(a.max_scope, RelayoutScope::Full);
3529
3530        a.add_css_change(NodeId::new(0), CssPropertyType::TextColor, RelayoutScope::None);
3531        assert_eq!(a.max_scope, RelayoutScope::Full, "max_scope must not regress");
3532        assert_eq!(
3533            a.per_node[&NodeId::new(0)].relayout_scope,
3534            RelayoutScope::Full,
3535            "per-node scope must not regress either",
3536        );
3537
3538        a.add_css_change(NodeId::new(1), CssPropertyType::Width, RelayoutScope::SizingOnly);
3539        assert_eq!(a.max_scope, RelayoutScope::Full);
3540        assert_eq!(
3541            a.per_node[&NodeId::new(1)].relayout_scope,
3542            RelayoutScope::SizingOnly,
3543            "a different node keeps its own, lower scope",
3544        );
3545    }
3546
3547    #[test]
3548    fn autotest_accumulator_text_change_is_ifc_scoped_and_unicode_safe() {
3549        for s in UNICODE_SAMPLES {
3550            let mut a = ChangeAccumulator::new();
3551            a.add_text_change(NodeId::new(0), String::new(), (*s).to_string());
3552
3553            let report = &a.per_node[&NodeId::new(0)];
3554            assert!(report.change_set.contains(NodeChangeSet::TEXT_CONTENT));
3555            assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
3556            assert_eq!(
3557                report.text_change,
3558                Some(TextChange {
3559                    old_text: String::new(),
3560                    new_text: (*s).to_string(),
3561                }),
3562            );
3563            assert!(a.needs_layout());
3564            assert_eq!(a.max_scope, RelayoutScope::IfcOnly);
3565        }
3566    }
3567
3568    #[test]
3569    fn autotest_accumulator_add_dom_change_accumulates_and_never_clears_text() {
3570        let node = NodeId::new(0);
3571        let mut a = ChangeAccumulator::new();
3572
3573        a.add_dom_change(
3574            node,
3575            NodeChangeSet {
3576                bits: NodeChangeSet::TEXT_CONTENT,
3577            },
3578            RelayoutScope::IfcOnly,
3579            Some(TextChange {
3580                old_text: "a".to_string(),
3581                new_text: "b".to_string(),
3582            }),
3583            vec![CssPropertyType::Width],
3584        );
3585
3586        // A second call with text_change == None must NOT wipe the first one.
3587        a.add_dom_change(
3588            node,
3589            NodeChangeSet {
3590                bits: NodeChangeSet::STYLED_STATE,
3591            },
3592            RelayoutScope::None,
3593            None,
3594            vec![CssPropertyType::TextColor],
3595        );
3596
3597        let report = &a.per_node[&node];
3598        assert!(report.change_set.contains(NodeChangeSet::TEXT_CONTENT));
3599        assert!(
3600            report.change_set.contains(NodeChangeSet::STYLED_STATE),
3601            "flags must be OR-accumulated across calls",
3602        );
3603        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
3604        assert!(
3605            report.text_change.is_some(),
3606            "a None text_change must not erase a previously recorded one",
3607        );
3608        assert_eq!(
3609            report.changed_css_properties,
3610            vec![CssPropertyType::Width, CssPropertyType::TextColor],
3611            "changed properties must be appended, not replaced",
3612        );
3613    }
3614
3615    #[test]
3616    fn autotest_accumulator_image_change() {
3617        let mut a = ChangeAccumulator::new();
3618        a.add_image_change(NodeId::new(0), RelayoutScope::SizingOnly);
3619        assert!(a.per_node[&NodeId::new(0)]
3620            .change_set
3621            .contains(NodeChangeSet::IMAGE_CHANGED));
3622        assert!(a.needs_layout());
3623        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
3624    }
3625
3626    #[test]
3627    fn autotest_accumulator_merge_empty_restyle_result_is_a_no_op() {
3628        let mut a = ChangeAccumulator::new();
3629        a.merge_restyle_result(&RestyleResult::default());
3630        assert!(a.is_empty());
3631        assert!(a.is_visually_unchanged());
3632    }
3633
3634    #[test]
3635    fn autotest_accumulator_merge_restyle_result_classifies_by_property() {
3636        let prop = CssProperty::Width(CssPropertyValue::Exact(LayoutWidth::const_px(100)));
3637        let changed = ChangedCssProperty {
3638            previous_state: StyledNodeState::default(),
3639            previous_prop: prop.clone(),
3640            current_state: StyledNodeState::default(),
3641            current_prop: prop,
3642        };
3643
3644        let mut restyle = RestyleResult::default();
3645        restyle
3646            .changed_nodes
3647            .insert(NodeId::new(3), vec![changed]);
3648
3649        let mut a = ChangeAccumulator::new();
3650        a.merge_restyle_result(&restyle);
3651
3652        // `width` -> SizingOnly -> layout bucket.
3653        assert!(!a.is_empty());
3654        assert!(a.needs_layout());
3655        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
3656        let report = &a.per_node[&NodeId::new(3)];
3657        assert!(report.change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
3658        assert_eq!(report.changed_css_properties, vec![CssPropertyType::Width]);
3659    }
3660
3661    #[test]
3662    fn autotest_accumulator_merge_extended_diff_counts_mounts_and_unmounts() {
3663        // No node_moves at all -> every new node mounted, every old node unmounted.
3664        let old_nd = vec![NodeData::create_div(), NodeData::create_div()];
3665        let new_nd = vec![
3666            NodeData::create_div(),
3667            NodeData::create_div(),
3668            NodeData::create_div(),
3669        ];
3670
3671        let mut a = ChangeAccumulator::new();
3672        a.merge_extended_diff(&ExtendedDiffResult::default(), &old_nd, &new_nd);
3673
3674        assert_eq!(a.mounted_nodes.len(), 3);
3675        assert_eq!(a.unmounted_nodes.len(), 2);
3676        assert!(!a.is_empty());
3677        assert!(a.needs_layout(), "mounted nodes always need layout");
3678        assert!(!a.is_visually_unchanged());
3679    }
3680
3681    #[test]
3682    fn autotest_accumulator_merge_extended_diff_on_empty_doms_is_empty() {
3683        let mut a = ChangeAccumulator::new();
3684        a.merge_extended_diff(&ExtendedDiffResult::default(), &[], &[]);
3685        assert!(a.is_empty());
3686    }
3687
3688    #[test]
3689    fn autotest_accumulator_merge_extended_diff_skips_empty_change_sets() {
3690        // A matched node with NO changes must not create a per_node entry.
3691        let old_nd = vec![NodeData::create_div()];
3692        let new_nd = vec![NodeData::create_div()];
3693
3694        let extended = ExtendedDiffResult {
3695            diff: DiffResult {
3696                events: Vec::new(),
3697                node_moves: vec![NodeMove {
3698                    old_node_id: NodeId::new(0),
3699                    new_node_id: NodeId::new(0),
3700                }],
3701            },
3702            node_changes: vec![(NodeId::new(0), NodeId::new(0), NodeChangeSet::empty())],
3703        };
3704
3705        let mut a = ChangeAccumulator::new();
3706        a.merge_extended_diff(&extended, &old_nd, &new_nd);
3707
3708        assert!(a.per_node.is_empty(), "an empty change set must be skipped");
3709        assert!(a.mounted_nodes.is_empty());
3710        assert!(a.unmounted_nodes.is_empty());
3711        assert!(a.is_empty());
3712    }
3713
3714    #[test]
3715    fn autotest_accumulator_merge_extended_diff_extracts_text_change() {
3716        let old_nd = vec![NodeData::create_text("héllo")];
3717        let new_nd = vec![NodeData::create_text("héllo wörld")];
3718
3719        let extended = ExtendedDiffResult {
3720            diff: DiffResult {
3721                events: Vec::new(),
3722                node_moves: vec![NodeMove {
3723                    old_node_id: NodeId::new(0),
3724                    new_node_id: NodeId::new(0),
3725                }],
3726            },
3727            node_changes: vec![(
3728                NodeId::new(0),
3729                NodeId::new(0),
3730                NodeChangeSet {
3731                    bits: NodeChangeSet::TEXT_CONTENT,
3732                },
3733            )],
3734        };
3735
3736        let mut a = ChangeAccumulator::new();
3737        a.merge_extended_diff(&extended, &old_nd, &new_nd);
3738
3739        let report = &a.per_node[&NodeId::new(0)];
3740        assert_eq!(
3741            report.text_change,
3742            Some(TextChange {
3743                old_text: "héllo".to_string(),
3744                new_text: "héllo wörld".to_string(),
3745            }),
3746            "TEXT_CONTENT must carry the old/new text for cursor reconciliation",
3747        );
3748        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
3749    }
3750
3751    // ========================================================================
3752    // ChangeAccumulator::classify_change_scope (private)
3753    // ========================================================================
3754
3755    #[test]
3756    fn autotest_classify_scope_maps_each_flag_to_its_documented_scope() {
3757        let nodes = vec![NodeData::create_div()];
3758        let id = NodeId::new(0);
3759
3760        let classify = |bits: u32| {
3761            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id)
3762        };
3763
3764        assert_eq!(classify(0), RelayoutScope::None, "empty -> no work");
3765        assert_eq!(classify(NodeChangeSet::NODE_TYPE_CHANGED), RelayoutScope::Full);
3766        assert_eq!(classify(NodeChangeSet::CHILDREN_CHANGED), RelayoutScope::Full);
3767        assert_eq!(classify(NodeChangeSet::IDS_AND_CLASSES), RelayoutScope::Full);
3768        assert_eq!(classify(NodeChangeSet::TEXT_CONTENT), RelayoutScope::IfcOnly);
3769        assert_eq!(classify(NodeChangeSet::IMAGE_CHANGED), RelayoutScope::SizingOnly);
3770        assert_eq!(classify(NodeChangeSet::CONTENTEDITABLE), RelayoutScope::SizingOnly);
3771        assert_eq!(classify(NodeChangeSet::STYLED_STATE), RelayoutScope::None);
3772        assert_eq!(classify(NodeChangeSet::INLINE_STYLE_PAINT), RelayoutScope::None);
3773        // Non-visual flags -> no work.
3774        assert_eq!(classify(NodeChangeSet::CALLBACKS), RelayoutScope::None);
3775        assert_eq!(classify(NodeChangeSet::DATASET), RelayoutScope::None);
3776        assert_eq!(classify(NodeChangeSet::TAB_INDEX), RelayoutScope::None);
3777    }
3778
3779    #[test]
3780    fn autotest_classify_scope_precedence_is_widest_first() {
3781        let nodes = vec![NodeData::create_div()];
3782        let id = NodeId::new(0);
3783
3784        // NODE_TYPE_CHANGED wins over everything below it.
3785        let bits = NodeChangeSet::NODE_TYPE_CHANGED
3786            | NodeChangeSet::TEXT_CONTENT
3787            | NodeChangeSet::IMAGE_CHANGED
3788            | NodeChangeSet::STYLED_STATE;
3789        assert_eq!(
3790            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id),
3791            RelayoutScope::Full,
3792        );
3793
3794        // TEXT_CONTENT (IfcOnly) wins over IMAGE_CHANGED (SizingOnly) — pinning
3795        // the documented order, even though IfcOnly < SizingOnly.
3796        let bits = NodeChangeSet::TEXT_CONTENT | NodeChangeSet::IMAGE_CHANGED;
3797        assert_eq!(
3798            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id),
3799            RelayoutScope::IfcOnly,
3800        );
3801    }
3802
3803    #[test]
3804    fn autotest_classify_scope_inline_layout_walks_the_nodes_own_css() {
3805        // With a sizing property on the node, the scope comes from the property.
3806        let nodes = vec![NodeData::create_div().with_css("width: 100px")];
3807        assert_eq!(
3808            ChangeAccumulator::classify_change_scope(
3809                NodeChangeSet {
3810                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
3811                },
3812                &nodes,
3813                NodeId::new(0),
3814            ),
3815            RelayoutScope::SizingOnly,
3816        );
3817
3818        // A `display` change is a full relayout.
3819        let nodes = vec![NodeData::create_div().with_css("display: flex")];
3820        assert_eq!(
3821            ChangeAccumulator::classify_change_scope(
3822                NodeChangeSet {
3823                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
3824                },
3825                &nodes,
3826                NodeId::new(0),
3827            ),
3828            RelayoutScope::Full,
3829        );
3830
3831        // No inline CSS at all (the property was REMOVED, so the new node has
3832        // nothing to walk): the conservative SizingOnly fallback must kick in
3833        // rather than silently reporting "no layout work".
3834        let nodes = vec![NodeData::create_div()];
3835        assert_eq!(
3836            ChangeAccumulator::classify_change_scope(
3837                NodeChangeSet {
3838                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
3839                },
3840                &nodes,
3841                NodeId::new(0),
3842            ),
3843            RelayoutScope::SizingOnly,
3844            "an INLINE_STYLE_LAYOUT change must never classify as 'no layout'",
3845        );
3846    }
3847
3848    // ========================================================================
3849    // reconcile_dom_with_changes
3850    // ========================================================================
3851
3852    #[test]
3853    fn autotest_reconcile_with_changes_on_empty_doms() {
3854        let r = reconcile_dom_with_changes(
3855            &[],
3856            &[],
3857            &[],
3858            &[],
3859            None,
3860            None,
3861            &no_layout(),
3862            &no_layout(),
3863            DomId::ROOT_ID,
3864            Instant::now(),
3865        );
3866        assert!(r.diff.events.is_empty());
3867        assert!(r.diff.node_moves.is_empty());
3868        assert!(r.node_changes.is_empty());
3869    }
3870
3871    #[test]
3872    fn autotest_reconcile_with_changes_reports_one_entry_per_move() {
3873        let old = vec![NodeData::create_text("v1").with_key(1u32)];
3874        let new = vec![NodeData::create_text("v2").with_key(1u32)];
3875
3876        let r = reconcile_dom_with_changes(
3877            &old,
3878            &new,
3879            &[],
3880            &[],
3881            None,
3882            None,
3883            &no_layout(),
3884            &no_layout(),
3885            DomId::ROOT_ID,
3886            Instant::now(),
3887        );
3888
3889        assert_eq!(r.diff.node_moves.len(), 1);
3890        assert_eq!(
3891            r.node_changes.len(),
3892            r.diff.node_moves.len(),
3893            "there must be exactly one change entry per matched pair",
3894        );
3895
3896        let (old_id, new_id, changes) = &r.node_changes[0];
3897        assert_eq!(*old_id, NodeId::new(0));
3898        assert_eq!(*new_id, NodeId::new(0));
3899        assert!(changes.contains(NodeChangeSet::TEXT_CONTENT));
3900    }
3901
3902    #[test]
3903    fn autotest_reconcile_with_changes_tolerates_short_styled_state_slices() {
3904        // `old_styled_nodes` / `new_styled_nodes` are indexed with `.get()`, so a
3905        // slice shorter than the DOM must degrade to `None`, not panic.
3906        let old = vec![NodeData::create_div(), NodeData::create_div()];
3907        let new = vec![NodeData::create_div(), NodeData::create_div()];
3908        let short = [StyledNodeState::default()]; // 1 entry for 2 nodes
3909
3910        let r = reconcile_dom_with_changes(
3911            &old,
3912            &new,
3913            &[],
3914            &[],
3915            Some(&short[..]),
3916            Some(&short[..]),
3917            &no_layout(),
3918            &no_layout(),
3919            DomId::ROOT_ID,
3920            Instant::now(),
3921        );
3922        assert_eq!(r.node_changes.len(), 2);
3923        // Both sides see the same (present-or-absent) state, so no STYLED_STATE.
3924        for (_, _, changes) in &r.node_changes {
3925            assert!(!changes.contains(NodeChangeSet::STYLED_STATE));
3926        }
3927    }
3928
3929    #[test]
3930    fn autotest_reconcile_with_changes_feeds_the_accumulator() {
3931        // End-to-end: reconcile -> ExtendedDiffResult -> ChangeAccumulator.
3932        let old = vec![NodeData::create_text("before").with_key(1u32)];
3933        let new = vec![NodeData::create_text("after").with_key(1u32)];
3934
3935        let extended = reconcile_dom_with_changes(
3936            &old,
3937            &new,
3938            &[],
3939            &[],
3940            None,
3941            None,
3942            &no_layout(),
3943            &no_layout(),
3944            DomId::ROOT_ID,
3945            Instant::now(),
3946        );
3947
3948        let mut acc = ChangeAccumulator::new();
3949        acc.merge_extended_diff(&extended, &old, &new);
3950
3951        assert!(!acc.is_empty());
3952        assert!(acc.needs_layout(), "a text edit needs (IFC) layout");
3953        assert!(!acc.is_visually_unchanged());
3954        assert!(acc.mounted_nodes.is_empty());
3955        assert!(acc.unmounted_nodes.is_empty());
3956
3957        let report = &acc.per_node[&NodeId::new(0)];
3958        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
3959        assert_eq!(
3960            report.text_change,
3961            Some(TextChange {
3962                old_text: "before".to_string(),
3963                new_text: "after".to_string(),
3964            }),
3965        );
3966    }
3967
3968    // ========================================================================
3969    // NodeDataFingerprint
3970    // ========================================================================
3971
3972    #[test]
3973    fn autotest_fingerprint_default_and_self_comparison_are_inert() {
3974        let d = NodeDataFingerprint::default();
3975        assert!(d.is_identical(&d));
3976        assert!(d.diff(&d).is_empty());
3977        assert!(!d.might_affect_layout(&d));
3978        assert!(!d.might_affect_visuals(&d));
3979        assert_eq!(d, NodeDataFingerprint::default());
3980    }
3981
3982    #[test]
3983    fn autotest_fingerprint_is_a_pure_function_of_its_inputs() {
3984        // Round-trip / determinism: recomputing from equal inputs must give an
3985        // identical fingerprint (no address or allocation identity leaking in).
3986        let state = StyledNodeState::default();
3987        for s in UNICODE_SAMPLES {
3988            let a = NodeDataFingerprint::compute(&NodeData::create_text(*s), Some(&state));
3989            let b = NodeDataFingerprint::compute(&NodeData::create_text(*s), Some(&state));
3990            assert_eq!(a, b, "fingerprint of {s:?} is not deterministic");
3991            assert!(a.is_identical(&b));
3992            assert!(a.diff(&b).is_empty());
3993        }
3994    }
3995
3996    #[test]
3997    fn autotest_fingerprint_diff_is_symmetric() {
3998        let a = NodeDataFingerprint::compute(&NodeData::create_text("a"), None);
3999        let b = NodeDataFingerprint::compute(&class_node("x").with_css("width: 1px"), None);
4000
4001        assert_eq!(a.diff(&b), b.diff(&a), "diff must be symmetric");
4002        assert_eq!(
4003            a.might_affect_layout(&b),
4004            b.might_affect_layout(&a),
4005            "might_affect_layout must be symmetric",
4006        );
4007        assert_eq!(a.might_affect_visuals(&b), b.might_affect_visuals(&a));
4008    }
4009
4010    #[test]
4011    fn autotest_fingerprint_text_change_is_layout_and_visual() {
4012        let a = NodeDataFingerprint::compute(&NodeData::create_text("one"), None);
4013        let b = NodeDataFingerprint::compute(&NodeData::create_text("two"), None);
4014
4015        assert!(!a.is_identical(&b));
4016        let changes = a.diff(&b);
4017        // Conservative by design: content_hash cannot tell text from image.
4018        assert!(changes.contains(NodeChangeSet::TEXT_CONTENT));
4019        assert!(changes.contains(NodeChangeSet::IMAGE_CHANGED));
4020        assert!(a.might_affect_layout(&b));
4021        assert!(a.might_affect_visuals(&b));
4022    }
4023
4024    #[test]
4025    fn autotest_fingerprint_styled_state_is_visual_but_not_layout() {
4026        // The sharpest invariant of the fast path: a :hover flip must never be
4027        // able to trigger relayout.
4028        let node = NodeData::create_div();
4029        let calm = StyledNodeState::default();
4030        let hovered = StyledNodeState {
4031            hover: true,
4032            ..StyledNodeState::default()
4033        };
4034
4035        let a = NodeDataFingerprint::compute(&node, Some(&calm));
4036        let b = NodeDataFingerprint::compute(&node, Some(&hovered));
4037
4038        assert!(!a.is_identical(&b));
4039        assert!(a.diff(&b).contains(NodeChangeSet::STYLED_STATE));
4040        assert!(
4041            !a.might_affect_layout(&b),
4042            "a styled-state change must not be able to request layout",
4043        );
4044        assert!(a.might_affect_visuals(&b));
4045    }
4046
4047    #[test]
4048    fn autotest_fingerprint_callback_change_is_neither_layout_nor_visual() {
4049        let plain = NodeData::create_div();
4050        let with_handler = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
4051
4052        let a = NodeDataFingerprint::compute(&plain, None);
4053        let b = NodeDataFingerprint::compute(&with_handler, None);
4054
4055        assert!(!a.is_identical(&b), "the callback list must be fingerprinted");
4056        assert!(a.diff(&b).contains(NodeChangeSet::CALLBACKS));
4057        assert!(
4058            !a.might_affect_layout(&b),
4059            "swapping an event handler must not trigger relayout",
4060        );
4061        assert!(
4062            !a.might_affect_visuals(&b),
4063            "swapping an event handler must not trigger a repaint",
4064        );
4065    }
4066
4067    #[test]
4068    fn autotest_fingerprint_ids_classes_and_inline_css_are_layout_relevant() {
4069        let base = NodeDataFingerprint::compute(&NodeData::create_div(), None);
4070
4071        let classes = NodeDataFingerprint::compute(&class_node("banner"), None);
4072        assert!(base.diff(&classes).contains(NodeChangeSet::IDS_AND_CLASSES));
4073        assert!(base.might_affect_layout(&classes));
4074        assert!(base.might_affect_visuals(&classes));
4075
4076        let styled =
4077            NodeDataFingerprint::compute(&NodeData::create_div().with_css("width: 3px"), None);
4078        assert!(base.diff(&styled).contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
4079        assert!(base.might_affect_layout(&styled));
4080        assert!(base.might_affect_visuals(&styled));
4081    }
4082
4083    #[test]
4084    fn autotest_fingerprint_attrs_change_flags_tab_index_and_contenteditable() {
4085        let base = NodeDataFingerprint::compute(&NodeData::create_div(), None);
4086        let editable =
4087            NodeDataFingerprint::compute(&NodeData::create_div().with_contenteditable(true), None);
4088
4089        let changes = base.diff(&editable);
4090        assert!(changes.contains(NodeChangeSet::TAB_INDEX));
4091        assert!(changes.contains(NodeChangeSet::CONTENTEDITABLE));
4092        assert!(
4093            base.might_affect_layout(&editable),
4094            "attrs_hash feeds might_affect_layout",
4095        );
4096        assert!(
4097            !base.might_affect_visuals(&editable),
4098            "attrs_hash is deliberately NOT part of might_affect_visuals",
4099        );
4100    }
4101
4102    #[test]
4103    fn autotest_fingerprint_agrees_with_compute_node_changes_on_unchanged_nodes() {
4104        // Tier 1 (fingerprint) must never claim "changed" where Tier 2
4105        // (compute_node_changes) says "unchanged" — that would defeat the whole
4106        // two-tier fast path.
4107        let state = StyledNodeState::default();
4108        let samples = vec![
4109            NodeData::create_div(),
4110            NodeData::create_text("hello 🌍"),
4111            class_node("row"),
4112            id_node("main"),
4113            NodeData::create_div().with_css("color: red"),
4114            NodeData::create_div().with_contenteditable(true),
4115            with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount),
4116        ];
4117
4118        for node in &samples {
4119            let clone = node.clone();
4120
4121            let fp_a = NodeDataFingerprint::compute(node, Some(&state));
4122            let fp_b = NodeDataFingerprint::compute(&clone, Some(&state));
4123            assert!(
4124                fp_a.is_identical(&fp_b),
4125                "a cloned node must fingerprint identically",
4126            );
4127            assert!(fp_a.diff(&fp_b).is_empty());
4128
4129            let tier2 = compute_node_changes(node, &clone, Some(&state), Some(&state));
4130            assert!(
4131                tier2.is_empty(),
4132                "compute_node_changes must agree that a clone is unchanged, got {:#b}",
4133                tier2.bits,
4134            );
4135        }
4136    }
4137}