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        let offset_from_end = old_text.len() - old_cursor_byte;
1164        return snap(new_text.len().saturating_sub(offset_from_end));
1165    }
1166
1167    // Cursor was in the changed region - place at end of inserted content
1168    // This handles insertions (cursor moves with new text) and deletions (cursor at edit point)
1169    snap(new_suffix_start)
1170}
1171
1172/// Get the text content from a `NodeData` if it's a Text node.
1173///
1174/// Returns the text string if the node is `NodeType::Text`, otherwise `None`.
1175#[must_use] pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
1176    if let NodeType::Text(ref text) = node.get_node_type() {
1177        Some(text.as_str())
1178    } else {
1179        None
1180    }
1181}
1182
1183// ============================================================================
1184// ChangeAccumulator — unifies all change input paths
1185// ============================================================================
1186
1187/// Text change info for cursor/selection reconciliation.
1188#[derive(Debug, Clone, PartialEq, Eq)]
1189pub struct TextChange {
1190    /// The text content before the change.
1191    pub old_text: String,
1192    /// The text content after the change.
1193    pub new_text: String,
1194}
1195
1196/// Per-node change report combining multiple information sources.
1197#[derive(Debug, Clone, Default)]
1198pub struct NodeChangeReport {
1199    /// Bitflags from DOM-level field comparison.
1200    pub change_set: NodeChangeSet,
1201
1202    /// Highest `RelayoutScope` from any CSS property that changed on this node.
1203    /// This is more granular than `NodeChangeSet`'s binary LAYOUT/PAINT split.
1204    ///
1205    /// - `None` → repaint only (color, opacity, transform)
1206    /// - `IfcOnly` → reshape text in the containing IFC
1207    /// - `SizingOnly` → recompute this node's intrinsic size
1208    /// - `Full` → full subtree relayout (display, position, float, etc.)
1209    pub relayout_scope: RelayoutScope,
1210
1211    /// Individual CSS properties that changed (for fine-grained cache invalidation).
1212    /// Empty if the change was structural (text content, node type, etc.)
1213    pub changed_css_properties: Vec<CssPropertyType>,
1214
1215    /// If text content changed, the old and new text for cursor reconciliation.
1216    pub text_change: Option<TextChange>,
1217}
1218
1219impl NodeChangeReport {
1220    /// Returns the `DirtyFlag` level needed for this change report.
1221    /// Maps `RelayoutScope` + `NodeChangeSet` → a simple tri-state.
1222    #[must_use] pub fn needs_layout(&self) -> bool {
1223        self.change_set.needs_layout() || self.relayout_scope > RelayoutScope::None
1224    }
1225
1226    #[must_use] pub const fn needs_paint(&self) -> bool {
1227        self.change_set.needs_paint()
1228    }
1229
1230    #[must_use] pub fn is_visually_unchanged(&self) -> bool {
1231        self.change_set.is_visually_unchanged() && self.relayout_scope == RelayoutScope::None
1232    }
1233}
1234
1235/// Unified change report that merges information from all three change paths:
1236///
1237/// 1. **DOM reconciliation** (`compute_node_changes` after `reconcile_dom`)
1238/// 2. **CSS restyle** (`restyle_on_state_change` for hover/focus/active)
1239/// 3. **Runtime edits** (`words_changed`, `css_properties_changed`, `images_changed`)
1240///
1241/// This is the single source of truth for "what work needs to happen this frame".
1242#[derive(Debug, Clone, Default)]
1243pub struct ChangeAccumulator {
1244    /// Per-node change info. Key is the new-DOM `NodeId`.
1245    pub per_node: BTreeMap<NodeId, NodeChangeReport>,
1246
1247    /// Maximum `RelayoutScope` across all changed nodes.
1248    /// Quick check: if this is `None`, we can skip layout entirely.
1249    pub max_scope: RelayoutScope,
1250
1251    /// Nodes that are newly mounted (no old counterpart).
1252    /// These always need full layout.
1253    pub mounted_nodes: Vec<NodeId>,
1254
1255    /// Nodes that were unmounted (no new counterpart).
1256    /// Used for cleanup (remove from scroll/focus/cursor managers).
1257    pub unmounted_nodes: Vec<NodeId>,
1258}
1259
1260impl ChangeAccumulator {
1261    #[must_use] pub fn new() -> Self {
1262        Self::default()
1263    }
1264
1265    /// Returns true if no changes were detected at all.
1266    #[must_use] pub fn is_empty(&self) -> bool {
1267        self.per_node.is_empty() && self.mounted_nodes.is_empty() && self.unmounted_nodes.is_empty()
1268    }
1269
1270    /// Returns true if layout work is needed (any node has scope > None).
1271    #[must_use] pub fn needs_layout(&self) -> bool {
1272        self.max_scope > RelayoutScope::None
1273            || !self.mounted_nodes.is_empty()
1274            || self.per_node.values().any(NodeChangeReport::needs_layout)
1275    }
1276
1277    /// Returns true if only paint work is needed (no layout).
1278    #[must_use] pub fn needs_paint_only(&self) -> bool {
1279        !self.needs_layout() && self.per_node.values().any(NodeChangeReport::needs_paint)
1280    }
1281
1282    /// Returns true if only non-visual changes occurred (callbacks, dataset, a11y).
1283    #[must_use] pub fn is_visually_unchanged(&self) -> bool {
1284        self.mounted_nodes.is_empty()
1285            && self.unmounted_nodes.is_empty()
1286            && self.max_scope == RelayoutScope::None
1287            && self.per_node.values().all(NodeChangeReport::is_visually_unchanged)
1288    }
1289
1290    /// Add a node change from DOM reconciliation (Path A).
1291    pub fn add_dom_change(
1292        &mut self,
1293        new_node_id: NodeId,
1294        change_set: NodeChangeSet,
1295        relayout_scope: RelayoutScope,
1296        text_change: Option<TextChange>,
1297        changed_css_properties: Vec<CssPropertyType>,
1298    ) {
1299        if relayout_scope > self.max_scope {
1300            self.max_scope = relayout_scope;
1301        }
1302
1303        let report = self.per_node.entry(new_node_id).or_default();
1304        report.change_set |= change_set;
1305        if relayout_scope > report.relayout_scope {
1306            report.relayout_scope = relayout_scope;
1307        }
1308        if text_change.is_some() {
1309            report.text_change = text_change;
1310        }
1311        report.changed_css_properties.extend(changed_css_properties);
1312    }
1313
1314    /// Add a text change (from runtime edit or DOM reconciliation).
1315    pub fn add_text_change(
1316        &mut self,
1317        node_id: NodeId,
1318        old_text: String,
1319        new_text: String,
1320    ) {
1321        let scope = RelayoutScope::IfcOnly;
1322        if scope > self.max_scope {
1323            self.max_scope = scope;
1324        }
1325
1326        let report = self.per_node.entry(node_id).or_default();
1327        report.change_set.insert(NodeChangeSet::TEXT_CONTENT);
1328        if scope > report.relayout_scope {
1329            report.relayout_scope = scope;
1330        }
1331        report.text_change = Some(TextChange { old_text, new_text });
1332    }
1333
1334    /// Add a CSS property change (from runtime edit or restyle).
1335    pub fn add_css_change(
1336        &mut self,
1337        node_id: NodeId,
1338        prop_type: CssPropertyType,
1339        scope: RelayoutScope,
1340    ) {
1341        if scope > self.max_scope {
1342            self.max_scope = scope;
1343        }
1344
1345        let report = self.per_node.entry(node_id).or_default();
1346        if scope > RelayoutScope::None {
1347            report.change_set.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
1348        } else {
1349            report.change_set.insert(NodeChangeSet::INLINE_STYLE_PAINT);
1350        }
1351        if scope > report.relayout_scope {
1352            report.relayout_scope = scope;
1353        }
1354        report.changed_css_properties.push(prop_type);
1355    }
1356
1357    /// Add an image change (from runtime edit or DOM reconciliation).
1358    pub fn add_image_change(
1359        &mut self,
1360        node_id: NodeId,
1361        scope: RelayoutScope,
1362    ) {
1363        if scope > self.max_scope {
1364            self.max_scope = scope;
1365        }
1366
1367        let report = self.per_node.entry(node_id).or_default();
1368        report.change_set.insert(NodeChangeSet::IMAGE_CHANGED);
1369        if scope > report.relayout_scope {
1370            report.relayout_scope = scope;
1371        }
1372    }
1373
1374    /// Add a mounted (new) node.
1375    pub fn add_mount(&mut self, node_id: NodeId) {
1376        self.mounted_nodes.push(node_id);
1377    }
1378
1379    /// Add an unmounted (removed) node.
1380    pub fn add_unmount(&mut self, node_id: NodeId) {
1381        self.unmounted_nodes.push(node_id);
1382    }
1383
1384    /// Merge a `RestyleResult` (from `restyle_on_state_change()`) into this accumulator.
1385    ///
1386    /// This is the bridge between Path B (restyle) and the unified change pipeline.
1387    /// Each `ChangedCssProperty` is classified via `relayout_scope()` to determine
1388    /// whether it affects layout or only paint.
1389    pub fn merge_restyle_result(&mut self, restyle: &crate::styled_dom::RestyleResult) {
1390        for (node_id, changed_props) in &restyle.changed_nodes {
1391            for changed in changed_props {
1392                let prop_type = changed.current_prop.get_type();
1393                let scope = prop_type.relayout_scope(true); // conservative
1394                self.add_css_change(*node_id, prop_type, scope);
1395            }
1396        }
1397    }
1398
1399    /// Populate this accumulator from an `ExtendedDiffResult` + the old/new DOM data.
1400    ///
1401    /// This converts per-node `NodeChangeSet` flags into full `NodeChangeReport`s
1402    /// with `RelayoutScope` classification.
1403    pub fn merge_extended_diff(
1404        &mut self,
1405        extended: &ExtendedDiffResult,
1406        old_node_data: &[NodeData],
1407        new_node_data: &[NodeData],
1408    ) {
1409        for &(old_id, new_id, ref change_set) in &extended.node_changes {
1410            if change_set.is_empty() {
1411                continue;
1412            }
1413
1414            // Determine RelayoutScope from the change flags
1415            let scope = Self::classify_change_scope(*change_set, new_node_data, new_id);
1416
1417            // Extract text change info if TEXT_CONTENT flag is set
1418            let text_change = if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
1419                let old_text = get_node_text_content(&old_node_data[old_id.index()])
1420                    .unwrap_or("")
1421                    .to_string();
1422                let new_text = get_node_text_content(&new_node_data[new_id.index()])
1423                    .unwrap_or("")
1424                    .to_string();
1425                Some(TextChange { old_text, new_text })
1426            } else {
1427                None
1428            };
1429
1430            self.add_dom_change(new_id, *change_set, scope, text_change, Vec::new());
1431        }
1432
1433        // Track mounts: new nodes that didn't match anything in old
1434        let matched_new: alloc::collections::BTreeSet<usize> = extended
1435            .diff
1436            .node_moves
1437            .iter()
1438            .map(|m| m.new_node_id.index())
1439            .collect();
1440
1441        for idx in 0..new_node_data.len() {
1442            if !matched_new.contains(&idx) {
1443                self.add_mount(NodeId::new(idx));
1444            }
1445        }
1446
1447        // Track unmounts: old nodes that didn't match anything in new
1448        let matched_old: alloc::collections::BTreeSet<usize> = extended
1449            .diff
1450            .node_moves
1451            .iter()
1452            .map(|m| m.old_node_id.index())
1453            .collect();
1454
1455        for idx in 0..old_node_data.len() {
1456            if !matched_old.contains(&idx) {
1457                self.add_unmount(NodeId::new(idx));
1458            }
1459        }
1460    }
1461
1462    /// Classify a `NodeChangeSet` into the appropriate `RelayoutScope`.
1463    fn classify_change_scope(
1464        change_set: NodeChangeSet,
1465        new_node_data: &[NodeData],
1466        new_node_id: NodeId,
1467    ) -> RelayoutScope {
1468        // NODE_TYPE_CHANGED or CHILDREN_CHANGED → Full
1469        if change_set.contains(NodeChangeSet::NODE_TYPE_CHANGED)
1470            || change_set.contains(NodeChangeSet::CHILDREN_CHANGED)
1471        {
1472            return RelayoutScope::Full;
1473        }
1474
1475        // IDS_AND_CLASSES → Full (conservative: class change may add layout-affecting CSS)
1476        if change_set.contains(NodeChangeSet::IDS_AND_CLASSES) {
1477            return RelayoutScope::Full;
1478        }
1479
1480        // INLINE_STYLE_LAYOUT → could be IfcOnly, SizingOnly, or Full
1481        // We need to check individual properties for the exact scope.
1482        // For now, we use SizingOnly as a conservative default since
1483        // the individual property scopes were already checked in compute_node_changes.
1484        if change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT) {
1485            // Walk the inline CSS properties to find the max scope
1486            let new_node = &new_node_data[new_node_id.index()];
1487            let mut max_scope = RelayoutScope::None;
1488            for (prop, _conds) in new_node.style.iter_inline_properties() {
1489                let scope = prop.get_type().relayout_scope(true);
1490                if scope > max_scope {
1491                    max_scope = scope;
1492                }
1493            }
1494            return if max_scope == RelayoutScope::None {
1495                RelayoutScope::SizingOnly // conservative fallback
1496            } else {
1497                max_scope
1498            };
1499        }
1500
1501        // TEXT_CONTENT → IfcOnly (reshape text, may cascade)
1502        if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
1503            return RelayoutScope::IfcOnly;
1504        }
1505
1506        // IMAGE_CHANGED → SizingOnly (intrinsic size may change)
1507        if change_set.contains(NodeChangeSet::IMAGE_CHANGED) {
1508            return RelayoutScope::SizingOnly;
1509        }
1510
1511        // CONTENTEDITABLE → SizingOnly
1512        if change_set.contains(NodeChangeSet::CONTENTEDITABLE) {
1513            return RelayoutScope::SizingOnly;
1514        }
1515
1516        // Paint-only or no-visual changes
1517        if change_set.intersects(NodeChangeSet::AFFECTS_PAINT) {
1518            return RelayoutScope::None;
1519        }
1520
1521        RelayoutScope::None
1522    }
1523}
1524
1525/// Perform a full reconciliation with change detection.
1526///
1527/// This combines `reconcile_dom()` + `compute_node_changes()` into a single
1528/// pass that produces an `ExtendedDiffResult` with per-node change flags.
1529///
1530/// The `ChangeAccumulator` can then be populated from the result via
1531/// `accumulator.merge_extended_diff()`.
1532#[must_use] pub fn reconcile_dom_with_changes(
1533    old_node_data: &[NodeData],
1534    new_node_data: &[NodeData],
1535    old_hierarchy: &[NodeHierarchyItem],
1536    new_hierarchy: &[NodeHierarchyItem],
1537    old_styled_nodes: Option<&[StyledNodeState]>,
1538    new_styled_nodes: Option<&[StyledNodeState]>,
1539    old_layout: &OrderedMap<NodeId, LogicalRect>,
1540    new_layout: &OrderedMap<NodeId, LogicalRect>,
1541    dom_id: DomId,
1542    timestamp: Instant,
1543) -> ExtendedDiffResult {
1544    // Step 1: Run standard reconciliation
1545    let diff = reconcile_dom(
1546        old_node_data,
1547        new_node_data,
1548        old_hierarchy,
1549        new_hierarchy,
1550        old_layout,
1551        new_layout,
1552        dom_id,
1553        timestamp,
1554    );
1555
1556    // Step 2: For each matched pair, compute what changed
1557    let mut node_changes = Vec::new();
1558    for node_move in &diff.node_moves {
1559        let old_nd = &old_node_data[node_move.old_node_id.index()];
1560        let new_nd = &new_node_data[node_move.new_node_id.index()];
1561
1562        let old_state = old_styled_nodes.and_then(|s| s.get(node_move.old_node_id.index()));
1563        let new_state = new_styled_nodes.and_then(|s| s.get(node_move.new_node_id.index()));
1564
1565        let changes = compute_node_changes(old_nd, new_nd, old_state, new_state);
1566        node_changes.push((node_move.old_node_id, node_move.new_node_id, changes));
1567    }
1568
1569    ExtendedDiffResult { diff, node_changes }
1570}
1571
1572// ============================================================================
1573// NodeDataFingerprint — multi-field hash for fast change detection
1574// ============================================================================
1575
1576/// Per-node hash broken into independent fields for fast change detection.
1577///
1578/// Instead of a single u64 hash (which loses all granularity), this stores
1579/// separate hashes per field category. Comparing two fingerprints is O(1)
1580/// (6 integer comparisons) and immediately tells us WHICH category changed,
1581/// avoiding the more expensive `compute_node_changes()` for unchanged nodes.
1582///
1583/// Two-tier strategy:
1584/// - **Tier 1** (this struct): O(1) per node, identifies which categories changed.
1585/// - **Tier 2** (`compute_node_changes`): O(n) per changed field, does field-by-field
1586///   comparison only for nodes that Tier 1 identified as changed.
1587#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1588#[derive(Default)]
1589pub struct NodeDataFingerprint {
1590    /// Hash of `node_type` (Text content, Image ref, Div, etc.)
1591    pub content_hash: u64,
1592    /// Hash of `styled_node_state` (hover, focus, active bits)
1593    pub state_hash: u64,
1594    /// Hash of inline CSS properties
1595    pub inline_css_hash: u64,
1596    /// Hash of `ids_and_classes`
1597    pub ids_classes_hash: u64,
1598    /// Hash of callbacks (event types + function pointers)
1599    pub callbacks_hash: u64,
1600    /// Hash of other attributes (contenteditable, `tab_index`, dataset)
1601    pub attrs_hash: u64,
1602}
1603
1604
1605impl NodeDataFingerprint {
1606    /// Compute a fingerprint from a node's data and styled state.
1607    #[must_use] pub fn compute(node: &NodeData, styled_state: Option<&StyledNodeState>) -> Self {
1608        use core::hash::Hasher;
1609        use core::hash::Hash;
1610
1611        // Content hash
1612        let content_hash = {
1613            let mut h = crate::hash::DefaultHasher::new();
1614            node.get_node_type().hash(&mut h);
1615            h.finish()
1616        };
1617
1618        // State hash
1619        let state_hash = {
1620            let mut h = crate::hash::DefaultHasher::new();
1621            if let Some(state) = styled_state {
1622                state.hash(&mut h);
1623            }
1624            h.finish()
1625        };
1626
1627        // Inline CSS hash — full CssProperty value (matches the legacy
1628        // CssPropertyWithConditions::hash that hashed both property and the
1629        // condition vec length).
1630        let inline_css_hash = {
1631            let mut h = crate::hash::DefaultHasher::new();
1632            for (prop, conds) in node.style.iter_inline_properties() {
1633                prop.hash(&mut h);
1634                conds.as_slice().len().hash(&mut h);
1635            }
1636            h.finish()
1637        };
1638
1639        // IDs and classes hash (now stored in attributes)
1640        let ids_classes_hash = {
1641            let mut h = crate::hash::DefaultHasher::new();
1642            for attr in node.attributes().as_ref() {
1643                match attr {
1644                    crate::dom::AttributeType::Id(s) => {
1645                        crate::dom::IdOrClass::Id(s.clone()).hash(&mut h);
1646                    }
1647                    crate::dom::AttributeType::Class(s) => {
1648                        crate::dom::IdOrClass::Class(s.clone()).hash(&mut h);
1649                    }
1650                    _ => {}
1651                }
1652            }
1653            h.finish()
1654        };
1655
1656        // Callbacks hash
1657        let callbacks_hash = {
1658            let mut h = crate::hash::DefaultHasher::new();
1659            for cb in node.callbacks.as_ref() {
1660                cb.event.hash(&mut h);
1661                cb.callback.hash(&mut h);
1662            }
1663            h.finish()
1664        };
1665
1666        // Attributes hash
1667        let attrs_hash = {
1668            let mut h = crate::hash::DefaultHasher::new();
1669            node.is_contenteditable().hash(&mut h);
1670            node.flags.hash(&mut h);
1671            node.get_dataset().hash(&mut h);
1672            h.finish()
1673        };
1674
1675        Self {
1676            content_hash,
1677            state_hash,
1678            inline_css_hash,
1679            ids_classes_hash,
1680            callbacks_hash,
1681            attrs_hash,
1682        }
1683    }
1684
1685    /// Returns a quick `NodeChangeSet` by comparing two fingerprints.
1686    /// This is O(1) — just comparing 6 u64s.
1687    ///
1688    /// The result is *conservative*: if a field hash differs, we set the
1689    /// broadest applicable flag. For precise classification (e.g., which
1690    /// CSS properties changed and their `relayout_scope()`), the caller
1691    /// should fall back to `compute_node_changes()` for changed nodes.
1692    #[must_use] pub const fn diff(&self, other: &Self) -> NodeChangeSet {
1693        let mut changes = NodeChangeSet::empty();
1694
1695        if self.content_hash != other.content_hash {
1696            // Could be TEXT_CONTENT, IMAGE_CHANGED, or NODE_TYPE_CHANGED
1697            // We set both TEXT_CONTENT and IMAGE_CHANGED conservatively;
1698            // compute_node_changes() will refine this.
1699            changes.insert(NodeChangeSet::TEXT_CONTENT);
1700            changes.insert(NodeChangeSet::IMAGE_CHANGED);
1701        }
1702
1703        if self.state_hash != other.state_hash {
1704            changes.insert(NodeChangeSet::STYLED_STATE);
1705        }
1706
1707        if self.inline_css_hash != other.inline_css_hash {
1708            // Conservative: inline CSS could affect layout or paint.
1709            // compute_node_changes() checks relayout_scope() per property.
1710            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
1711        }
1712
1713        if self.ids_classes_hash != other.ids_classes_hash {
1714            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
1715        }
1716
1717        if self.callbacks_hash != other.callbacks_hash {
1718            changes.insert(NodeChangeSet::CALLBACKS);
1719        }
1720
1721        if self.attrs_hash != other.attrs_hash {
1722            changes.insert(NodeChangeSet::TAB_INDEX);
1723            changes.insert(NodeChangeSet::CONTENTEDITABLE);
1724        }
1725
1726        changes
1727    }
1728
1729    /// Returns true if the fingerprint is identical (no changes at all).
1730    #[must_use] pub fn is_identical(&self, other: &Self) -> bool {
1731        self == other
1732    }
1733
1734    /// Quick check: could this change affect layout?
1735    #[must_use] pub const fn might_affect_layout(&self, other: &Self) -> bool {
1736        self.content_hash != other.content_hash
1737            || self.inline_css_hash != other.inline_css_hash
1738            || self.ids_classes_hash != other.ids_classes_hash
1739            || self.attrs_hash != other.attrs_hash
1740    }
1741
1742    /// Quick check: could this change affect visuals at all?
1743    #[must_use] pub const fn might_affect_visuals(&self, other: &Self) -> bool {
1744        self.content_hash != other.content_hash
1745            || self.state_hash != other.state_hash
1746            || self.inline_css_hash != other.inline_css_hash
1747            || self.ids_classes_hash != other.ids_classes_hash
1748    }
1749}
1750
1751#[cfg(test)]
1752mod audit_tests {
1753    use super::*;
1754    use crate::dom::NodeData;
1755    use crate::styled_dom::NodeHierarchyItem;
1756
1757    // Build a NodeHierarchyItem from optional 0-based indices (encoded 1-based).
1758    fn hitem(
1759        parent: Option<usize>,
1760        prev: Option<usize>,
1761        next: Option<usize>,
1762        last_child: Option<usize>,
1763    ) -> NodeHierarchyItem {
1764        NodeHierarchyItem {
1765            parent: parent.map_or(0, |p| p + 1),
1766            previous_sibling: prev.map_or(0, |p| p + 1),
1767            next_sibling: next.map_or(0, |p| p + 1),
1768            last_child: last_child.map_or(0, |p| p + 1),
1769        }
1770    }
1771
1772    // A deep parent chain that would overflow the stack with the old recursion.
1773    #[test]
1774    fn reconciliation_key_deep_chain_no_overflow() {
1775        let build = |n: usize| -> (Vec<NodeData>, Vec<NodeHierarchyItem>) {
1776            let node_data = (0..n).map(|_| NodeData::create_div()).collect();
1777            let hierarchy = (0..n)
1778                .map(|i| {
1779                    hitem(
1780                        if i == 0 { None } else { Some(i - 1) },
1781                        None,
1782                        None,
1783                        if i + 1 < n { Some(i + 1) } else { None },
1784                    )
1785                })
1786                .collect();
1787            (node_data, hierarchy)
1788        };
1789
1790        // A very deep linear chain: the OLD recursion overflowed the stack here.
1791        // A single-node key walk is O(depth) and must complete without recursing.
1792        let n = 100_000usize;
1793        let (node_data, hierarchy) = build(n);
1794        let _ = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(n - 1));
1795        let _ = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(n - 1));
1796
1797        // Whole-DOM precompute calls the per-node walk once per node, so over a
1798        // *linear* chain it is O(n²) — that only bites a pathological 100k-deep
1799        // DOM (never a real tree). Exercise the whole-DOM path over a modest
1800        // chain; correctness is covered by `reconciliation_key_single_node` and
1801        // `reconciliation_key_distinguishes_siblings`.
1802        let m = 2_000usize;
1803        let (nd, hi) = build(m);
1804        let keys = precompute_reconciliation_keys(&nd, &hi);
1805        assert_eq!(keys.len(), m);
1806    }
1807
1808    // A cyclic (corrupt) hierarchy must terminate, not hang.
1809    #[test]
1810    fn reconciliation_key_cycle_terminates() {
1811        let node_data = vec![
1812            NodeData::create_div(),
1813            NodeData::create_div(),
1814            NodeData::create_div(),
1815        ];
1816        // node1.parent = 2, node2.parent = 1 — a cycle not involving root 0.
1817        let hierarchy = vec![
1818            hitem(None, None, None, None),
1819            hitem(Some(2), None, None, None),
1820            hitem(Some(1), None, None, None),
1821        ];
1822        let _ = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(1));
1823        let _ = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(1));
1824    }
1825
1826    #[test]
1827    fn reconciliation_key_single_node() {
1828        let node_data = vec![NodeData::create_div()];
1829        let hierarchy = vec![hitem(None, None, None, None)];
1830        let direct = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(0));
1831        let pre = precompute_reconciliation_keys(&node_data, &hierarchy)[0];
1832        assert_eq!(direct, pre);
1833    }
1834
1835    #[test]
1836    fn reconciliation_key_distinguishes_siblings() {
1837        // root 0 with two div children 1 and 2 — nth-of-type must differ.
1838        let node_data = vec![NodeData::create_div(); 3];
1839        let hierarchy = vec![
1840            hitem(None, None, None, Some(2)),    // root: first_child=1, last_child=2
1841            hitem(Some(0), None, Some(2), None), // child 1
1842            hitem(Some(0), Some(1), None, None), // child 2
1843        ];
1844        let k1 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(1));
1845        let k2 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(2));
1846        assert_ne!(k1, k2);
1847    }
1848
1849    #[test]
1850    fn cursor_offsets_are_always_char_boundaries() {
1851        // "héllo": h=0, é=1..3 (2 bytes), l=3, l=4, o=5 (len 6).
1852        let old = "héllo";
1853        let new = "héllo wörld"; // ö is multibyte too
1854        for c in 0..=old.len() {
1855            let r = reconcile_cursor_position(old, new, c);
1856            assert!(
1857                new.is_char_boundary(r),
1858                "cursor {c} mapped to non-char-boundary offset {r} in {new:?}",
1859            );
1860            assert!(r <= new.len());
1861        }
1862        // Deletion inside a multibyte suffix must not split a codepoint.
1863        let r = reconcile_cursor_position("aömega", "bömega", 3);
1864        assert!("bömega".is_char_boundary(r));
1865    }
1866
1867    #[test]
1868    fn cursor_prefix_unchanged_stays_put() {
1869        assert_eq!(reconcile_cursor_position("Hello", "Hello World", 5), 5);
1870    }
1871
1872    #[test]
1873    fn cursor_empty_cases() {
1874        assert_eq!(reconcile_cursor_position("", "abc", 0), 3);
1875        assert_eq!(reconcile_cursor_position("abc", "", 2), 0);
1876        assert_eq!(reconcile_cursor_position("abc", "abc", 2), 2);
1877    }
1878}
1879
1880// ============================================================================
1881// Autotest: adversarial unit tests
1882// ============================================================================
1883//
1884// Generated against the autotest task spec for `core/src/diff.rs`. Strategy per
1885// category:
1886//
1887//   * numeric      -> 0 / MIN / MAX / overflow / NaN / saturation
1888//   * "parser"-ish -> malformed, huge, boundary and unicode text input
1889//                     (`reconcile_cursor_position` is the byte-offset parser here)
1890//   * round-trip   -> precompute == per-node compute, fingerprint == recompute,
1891//                     BitOr == BitOrAssign
1892//   * getters /    -> invariants hold on default, empty and extreme instances
1893//     predicates
1894//
1895// The module is inline (not `core/tests/`) because `has_*_callback`,
1896// `create_lifecycle_event` and `ChangeAccumulator::classify_change_scope` are
1897// private to this module.
1898#[cfg(test)]
1899mod autotest_generated {
1900    use super::*;
1901
1902    use azul_css::{
1903        css::CssPropertyValue,
1904        props::{layout::LayoutWidth, property::CssProperty},
1905    };
1906
1907    use crate::{
1908        callbacks::CoreCallback,
1909        dom::{DatasetMergeCallbackType, TabIndex},
1910        geom::{LogicalPosition, LogicalSize},
1911        refany::{OptionRefAny, RefAny},
1912        resources::{ImageRef, RawImageFormat},
1913    };
1914
1915    // ---------------------------------------------------------------- helpers
1916
1917    // `CoreCallback::cb` is a raw `usize` fn-pointer slot. `reconcile_dom` only
1918    // ever inspects `CoreCallbackData::event`, never calls through the pointer,
1919    // so `0` is a safe sentinel (same convention as
1920    // `core/tests/reconciliation/deep_reconciliation.rs`).
1921    fn noop_callback() -> CoreCallback {
1922        CoreCallback {
1923            cb: 0usize,
1924            ctx: OptionRefAny::None,
1925        }
1926    }
1927
1928    fn with_cb(mut nd: NodeData, filter: ComponentEventFilter) -> NodeData {
1929        nd.add_callback(
1930            EventFilter::Component(filter),
1931            RefAny::new(0u32),
1932            noop_callback(),
1933        );
1934        nd
1935    }
1936
1937    // Build a NodeHierarchyItem from optional 0-based indices (encoded 1-based).
1938    fn hitem(
1939        parent: Option<usize>,
1940        prev: Option<usize>,
1941        next: Option<usize>,
1942        last_child: Option<usize>,
1943    ) -> NodeHierarchyItem {
1944        NodeHierarchyItem {
1945            parent: parent.map_or(0, |p| p + 1),
1946            previous_sibling: prev.map_or(0, |p| p + 1),
1947            next_sibling: next.map_or(0, |p| p + 1),
1948            last_child: last_child.map_or(0, |p| p + 1),
1949        }
1950    }
1951
1952    fn rect(w: f32, h: f32) -> LogicalRect {
1953        LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(w, h))
1954    }
1955
1956    fn layout_of(entries: &[(usize, LogicalRect)]) -> OrderedMap<NodeId, LogicalRect> {
1957        let mut m = OrderedMap::default();
1958        for (idx, r) in entries {
1959            m.insert(NodeId::new(*idx), *r);
1960        }
1961        m
1962    }
1963
1964    fn no_layout() -> OrderedMap<NodeId, LogicalRect> {
1965        OrderedMap::default()
1966    }
1967
1968    // Flat diff: empty hierarchies exercise the documented "degrade gracefully"
1969    // path of the structural reconciliation key.
1970    fn diff_flat(old: &[NodeData], new: &[NodeData]) -> DiffResult {
1971        reconcile_dom(
1972            old,
1973            new,
1974            &[],
1975            &[],
1976            &no_layout(),
1977            &no_layout(),
1978            DomId::ROOT_ID,
1979            Instant::now(),
1980        )
1981    }
1982
1983    fn count_events(r: &DiffResult, t: EventType) -> usize {
1984        r.events.iter().filter(|e| e.event_type == t).count()
1985    }
1986
1987    fn id_node(id: &str) -> NodeData {
1988        NodeData::create_div().with_ids_and_classes(vec![IdOrClass::Id(id.into())].into())
1989    }
1990
1991    fn class_node(class: &str) -> NodeData {
1992        NodeData::create_div().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
1993    }
1994
1995    // A representative unicode torture corpus: multi-byte, combining marks, RTL,
1996    // ZWJ emoji sequences, CJK, and a lone BOM.
1997    const UNICODE_SAMPLES: &[&str] = &[
1998        "",
1999        "a",
2000        "héllo",
2001        "e\u{0301}galite\u{0301}",   // combining acute accents
2002        "مرحبا بالعالم",             // RTL Arabic
2003        "👨‍👩‍👧‍👦 family",           // ZWJ emoji sequence
2004        "日本語のテキスト",
2005        "\u{feff}bom-prefixed",
2006        "🇩🇪🇫🇷🇯🇵",                 // regional indicator pairs
2007        "mixed 漢字 and ascii ✅",
2008    ];
2009
2010    // ========================================================================
2011    // NodeChangeSet — constructor / predicates / numeric bit ops
2012    // ========================================================================
2013
2014    #[test]
2015    fn autotest_changeset_empty_is_a_neutral_element() {
2016        let e = NodeChangeSet::empty();
2017        assert_eq!(e.bits, 0);
2018        assert!(e.is_empty());
2019        assert!(!e.needs_layout());
2020        assert!(!e.needs_paint());
2021        assert!(e.is_visually_unchanged());
2022        // `Default` must agree with `empty()`.
2023        assert_eq!(NodeChangeSet::default(), e);
2024        // Neutral under BitOr in both directions.
2025        let mut some = NodeChangeSet::empty();
2026        some.insert(NodeChangeSet::TEXT_CONTENT);
2027        assert_eq!((some | e).bits, some.bits);
2028        assert_eq!((e | some).bits, some.bits);
2029    }
2030
2031    #[test]
2032    fn autotest_changeset_contains_zero_is_vacuously_true() {
2033        // `contains` is an ALL-bits test: `(bits & 0) == 0` holds for every
2034        // value, including the empty set. Pin the semantics so a future rewrite
2035        // to `(bits & flag) != 0` (an ANY-bits test) is caught.
2036        assert!(NodeChangeSet::empty().contains(0));
2037        let mut s = NodeChangeSet::empty();
2038        s.insert(NodeChangeSet::CALLBACKS);
2039        assert!(s.contains(0));
2040        assert!(NodeChangeSet { bits: u32::MAX }.contains(0));
2041    }
2042
2043    #[test]
2044    fn autotest_changeset_intersects_zero_is_always_false() {
2045        // `intersects` is an ANY-bits test: masking with 0 can never be non-zero.
2046        assert!(!NodeChangeSet::empty().intersects(0));
2047        assert!(!NodeChangeSet { bits: u32::MAX }.intersects(0));
2048    }
2049
2050    #[test]
2051    fn autotest_changeset_contains_is_all_bits_intersects_is_any_bits() {
2052        let mut s = NodeChangeSet::empty();
2053        s.insert(NodeChangeSet::TEXT_CONTENT);
2054
2055        let both = NodeChangeSet::TEXT_CONTENT | NodeChangeSet::IMAGE_CHANGED;
2056        assert!(!s.contains(both), "contains() must require ALL bits");
2057        assert!(s.intersects(both), "intersects() must require ANY bit");
2058        assert!(s.contains(NodeChangeSet::TEXT_CONTENT));
2059    }
2060
2061    #[test]
2062    fn autotest_changeset_insert_min_max_and_idempotent() {
2063        // MIN (0) is a no-op.
2064        let mut s = NodeChangeSet::empty();
2065        s.insert(0);
2066        assert!(s.is_empty());
2067
2068        // MAX must not panic and must saturate to "all bits set".
2069        let mut s = NodeChangeSet::empty();
2070        s.insert(u32::MAX);
2071        assert_eq!(s.bits, u32::MAX);
2072        // Inserting again is idempotent (OR, not ADD -> cannot overflow).
2073        s.insert(u32::MAX);
2074        assert_eq!(s.bits, u32::MAX);
2075        s.insert(NodeChangeSet::TEXT_CONTENT);
2076        assert_eq!(s.bits, u32::MAX);
2077
2078        // With every bit set, all defined flags are present.
2079        assert!(s.contains(NodeChangeSet::NODE_TYPE_CHANGED));
2080        assert!(s.contains(NodeChangeSet::AFFECTS_LAYOUT));
2081        assert!(s.contains(NodeChangeSet::AFFECTS_PAINT));
2082        assert!(s.needs_layout());
2083        assert!(s.needs_paint());
2084        assert!(!s.is_visually_unchanged());
2085        assert!(!s.is_empty());
2086    }
2087
2088    #[test]
2089    fn autotest_changeset_undefined_high_bits_trigger_no_work() {
2090        // Bits outside every defined flag must not be interpreted as layout or
2091        // paint work — but the set is still non-empty.
2092        let s = NodeChangeSet {
2093            bits: 0b1000_0000_0000_0000_0000_0000_0000_0000,
2094        };
2095        assert!(!s.is_empty());
2096        assert!(!s.needs_layout());
2097        assert!(!s.needs_paint());
2098        assert!(s.is_visually_unchanged());
2099    }
2100
2101    #[test]
2102    fn autotest_changeset_layout_and_paint_masks_are_disjoint() {
2103        // A single flag must never mean "relayout AND repaint" — the two
2104        // composite masks partition the visual flags.
2105        assert_eq!(
2106            NodeChangeSet::AFFECTS_LAYOUT & NodeChangeSet::AFFECTS_PAINT,
2107            0,
2108            "AFFECTS_LAYOUT and AFFECTS_PAINT must not overlap",
2109        );
2110
2111        for flag in [
2112            NodeChangeSet::NODE_TYPE_CHANGED,
2113            NodeChangeSet::TEXT_CONTENT,
2114            NodeChangeSet::IDS_AND_CLASSES,
2115            NodeChangeSet::INLINE_STYLE_LAYOUT,
2116            NodeChangeSet::CHILDREN_CHANGED,
2117            NodeChangeSet::IMAGE_CHANGED,
2118            NodeChangeSet::CONTENTEDITABLE,
2119            NodeChangeSet::INLINE_STYLE_PAINT,
2120            NodeChangeSet::STYLED_STATE,
2121            NodeChangeSet::CALLBACKS,
2122            NodeChangeSet::DATASET,
2123            NodeChangeSet::ACCESSIBILITY,
2124        ] {
2125            let mut s = NodeChangeSet::empty();
2126            s.insert(flag);
2127            assert!(!(s.needs_layout() && s.needs_paint()), "flag {flag:#b} is both");
2128            // `is_visually_unchanged` is exactly "neither layout nor paint".
2129            assert_eq!(
2130                s.is_visually_unchanged(),
2131                !s.needs_layout() && !s.needs_paint(),
2132                "is_visually_unchanged() disagrees with needs_layout/needs_paint for {flag:#b}",
2133            );
2134        }
2135    }
2136
2137    #[test]
2138    fn autotest_changeset_nonvisual_flags_are_visually_unchanged() {
2139        let mut s = NodeChangeSet::empty();
2140        s.insert(NodeChangeSet::CALLBACKS);
2141        s.insert(NodeChangeSet::DATASET);
2142        s.insert(NodeChangeSet::ACCESSIBILITY);
2143        s.insert(NodeChangeSet::TAB_INDEX); // TAB_INDEX is in neither mask
2144        assert!(!s.is_empty());
2145        assert!(s.is_visually_unchanged());
2146        assert!(!s.needs_layout());
2147        assert!(!s.needs_paint());
2148    }
2149
2150    #[test]
2151    fn autotest_changeset_bitor_matches_bitorassign() {
2152        // Round-trip: the two operators must agree, and BitOr must be
2153        // commutative + idempotent for arbitrary (including undefined) bits.
2154        for (a, b) in [
2155            (0u32, 0u32),
2156            (0, u32::MAX),
2157            (u32::MAX, u32::MAX),
2158            (NodeChangeSet::TEXT_CONTENT, NodeChangeSet::STYLED_STATE),
2159            (0xDEAD_BEEF, 0x0BAD_F00D),
2160        ] {
2161            let (sa, sb) = (NodeChangeSet { bits: a }, NodeChangeSet { bits: b });
2162
2163            let by_operator = sa | sb;
2164            assert_eq!(by_operator.bits, a | b);
2165
2166            let mut by_assign = sa;
2167            by_assign |= sb;
2168            assert_eq!(by_assign, by_operator);
2169
2170            assert_eq!((sb | sa).bits, by_operator.bits, "BitOr must commute");
2171            assert_eq!((by_operator | by_operator).bits, by_operator.bits);
2172        }
2173    }
2174
2175    // ========================================================================
2176    // NodeChangeReport — getters / predicates
2177    // ========================================================================
2178
2179    #[test]
2180    fn autotest_change_report_default_is_inert() {
2181        let r = NodeChangeReport::default();
2182        assert!(!r.needs_layout());
2183        assert!(!r.needs_paint());
2184        assert!(r.is_visually_unchanged());
2185        assert_eq!(r.relayout_scope, RelayoutScope::None);
2186        assert!(r.changed_css_properties.is_empty());
2187        assert!(r.text_change.is_none());
2188    }
2189
2190    #[test]
2191    fn autotest_change_report_scope_alone_forces_layout() {
2192        // An empty change_set with a non-None scope must still request layout:
2193        // `needs_layout()` ORs the two sources.
2194        let mut r = NodeChangeReport::default();
2195        r.relayout_scope = RelayoutScope::IfcOnly;
2196        assert!(r.needs_layout());
2197        assert!(!r.needs_paint());
2198        assert!(!r.is_visually_unchanged());
2199    }
2200
2201    #[test]
2202    fn autotest_change_report_paint_flag_does_not_force_layout() {
2203        let mut r = NodeChangeReport::default();
2204        r.change_set.insert(NodeChangeSet::STYLED_STATE);
2205        assert!(!r.needs_layout());
2206        assert!(r.needs_paint());
2207        assert!(!r.is_visually_unchanged());
2208    }
2209
2210    // ========================================================================
2211    // reconcile_cursor_position — the byte-offset "parser": unicode + boundary
2212    // ========================================================================
2213
2214    #[test]
2215    fn autotest_cursor_result_is_always_a_valid_slice_index() {
2216        // The core safety invariant: whatever comes back must be <= new.len()
2217        // AND land on a char boundary, or a later `&new_text[..cursor]` panics.
2218        // Sweep every in-range cursor over every pair of the unicode corpus.
2219        for old in UNICODE_SAMPLES {
2220            for new in UNICODE_SAMPLES {
2221                for cursor in 0..=old.len() {
2222                    let r = reconcile_cursor_position(old, new, cursor);
2223                    assert!(
2224                        r <= new.len(),
2225                        "cursor {cursor} in {old:?} -> {r} exceeds len of {new:?}",
2226                    );
2227                    assert!(
2228                        new.is_char_boundary(r),
2229                        "cursor {cursor} in {old:?} -> {r} splits a codepoint in {new:?}",
2230                    );
2231                    // Must be usable as a real slice index.
2232                    let _ = &new[..r];
2233                }
2234            }
2235        }
2236    }
2237
2238    #[test]
2239    fn autotest_cursor_is_deterministic() {
2240        // Same inputs must always give the same answer (no hashing / iteration
2241        // order leaking into the result).
2242        for old in UNICODE_SAMPLES {
2243            for new in UNICODE_SAMPLES {
2244                let a = reconcile_cursor_position(old, new, old.len() / 2);
2245                let b = reconcile_cursor_position(old, new, old.len() / 2);
2246                assert_eq!(a, b);
2247            }
2248        }
2249    }
2250
2251    #[test]
2252    fn autotest_cursor_zero_stays_zero_when_texts_differ_at_byte_zero() {
2253        // cursor 0 <= common_prefix (0) -> snap(0) == 0.
2254        assert_eq!(reconcile_cursor_position("abc", "xyz", 0), 0);
2255        assert_eq!(reconcile_cursor_position("日本", "中国", 0), 0);
2256    }
2257
2258    #[test]
2259    fn autotest_cursor_identical_text_clamps_to_len() {
2260        // Equal texts short-circuit to `snap(cursor)`, which clamps to len and
2261        // snaps down to a char boundary — so even an absurd cursor is safe here.
2262        assert_eq!(reconcile_cursor_position("abc", "abc", usize::MAX), 3);
2263        assert_eq!(reconcile_cursor_position("héllo", "héllo", usize::MAX), 6);
2264        // Snapping down: byte 2 is mid-'é' (bytes 1..3) -> snaps to 1.
2265        assert_eq!(reconcile_cursor_position("héllo", "héllo", 2), 1);
2266    }
2267
2268    #[test]
2269    fn autotest_cursor_empty_sides_are_documented_constants() {
2270        // Empty old  -> end of new. Empty new -> 0. Both empty -> 0 (equal-text path).
2271        for new in UNICODE_SAMPLES {
2272            assert_eq!(reconcile_cursor_position("", new, 0), new.len());
2273            assert_eq!(reconcile_cursor_position("", new, usize::MAX), new.len());
2274        }
2275        for old in UNICODE_SAMPLES {
2276            if old.is_empty() {
2277                continue; // equal-text path, covered above
2278            }
2279            assert_eq!(reconcile_cursor_position(old, "", 0), 0);
2280            assert_eq!(reconcile_cursor_position(old, "", old.len()), 0);
2281        }
2282    }
2283
2284    #[test]
2285    fn autotest_cursor_appended_text_keeps_prefix_cursor() {
2286        // Pure append: any cursor inside the common prefix is untouched.
2287        let old = "Hello";
2288        let new = "Hello, World";
2289        for cursor in 0..=old.len() {
2290            assert_eq!(reconcile_cursor_position(old, new, cursor), cursor);
2291        }
2292    }
2293
2294    #[test]
2295    fn autotest_cursor_deleted_tail_clamps_into_new_text() {
2296        // Pure truncation: a cursor past the end of the new text must land at
2297        // the new end, never beyond it.
2298        let old = "Hello, World";
2299        let new = "Hello";
2300        assert_eq!(reconcile_cursor_position(old, new, old.len()), new.len());
2301        assert_eq!(reconcile_cursor_position(old, new, 5), 5);
2302    }
2303
2304    #[test]
2305    fn autotest_cursor_multibyte_insert_before_cursor_shifts_by_suffix_rule() {
2306        // Insert a 2-byte 'ö' at the front; a cursor sitting in the (unchanged)
2307        // suffix must keep its distance from the END of the string.
2308        let old = "mega";
2309        let new = "ömega";
2310        let r = reconcile_cursor_position(old, new, 4); // end of old
2311        assert_eq!(r, new.len());
2312        assert!(new.is_char_boundary(r));
2313    }
2314
2315    #[test]
2316    fn autotest_cursor_huge_inputs_do_not_hang_or_panic() {
2317        // 200k-byte strings: the prefix/suffix scans are linear, so this must
2318        // complete quickly and stay in-bounds.
2319        let old: String = core::iter::repeat('a').take(200_000).collect();
2320        let mut new = old.clone();
2321        new.push_str("tail");
2322
2323        let r = reconcile_cursor_position(&old, &new, old.len());
2324        assert!(r <= new.len());
2325        assert!(new.is_char_boundary(r));
2326
2327        // Huge multibyte string: every returned offset must still be a boundary.
2328        let old_u: String = core::iter::repeat('é').take(50_000).collect();
2329        let new_u: String = core::iter::repeat('é').take(49_999).collect();
2330        let r = reconcile_cursor_position(&old_u, &new_u, old_u.len());
2331        assert!(r <= new_u.len());
2332        assert!(new_u.is_char_boundary(r));
2333    }
2334
2335    // KNOWN BUG (reported, not fixed here — this module may only touch tests).
2336    //
2337    // `reconcile_cursor_position` underflows when the caller passes a cursor
2338    // byte offset PAST the end of `old_text`:
2339    //
2340    //     diff.rs:1163  let offset_from_end = old_text.len() - old_cursor_byte;
2341    //
2342    // Reaching it needs: old != new, both non-empty, cursor > common_prefix and
2343    // cursor >= old_suffix_start. With ("abc", "abd", usize::MAX) the guard at
2344    // :1145 does not fire (MAX > prefix 2), old_suffix_start is 3, and
2345    // `3 - usize::MAX` panics with "attempt to subtract with overflow" in debug
2346    // (silently wraps in release, then `snap()` clamps the garbage — so release
2347    // hides it). Every other exit from this function is clamped via `snap()`;
2348    // this one is not. Fix: `old_text.len().saturating_sub(old_cursor_byte)`.
2349    //
2350    // Kept as an #[ignore]d executable repro asserting the CORRECT behavior, so
2351    // it does not fail the suite but flips to green once the fix lands.
2352    #[test]
2353    #[ignore = "known bug: subtract-with-overflow on out-of-range cursor; see comment above"]
2354    fn autotest_cursor_out_of_range_cursor_must_saturate_not_underflow() {
2355        // Expected: clamp to the end of the new text, exactly like every other path.
2356        assert_eq!(reconcile_cursor_position("abc", "abd", usize::MAX), 3);
2357        assert_eq!(reconcile_cursor_position("abc", "abd", 99), 3);
2358        assert_eq!(reconcile_cursor_position("héllo", "héllx", usize::MAX), 6);
2359    }
2360
2361    // ========================================================================
2362    // get_node_text_content — round-trip
2363    // ========================================================================
2364
2365    #[test]
2366    fn autotest_text_content_round_trips_unicode() {
2367        for s in UNICODE_SAMPLES {
2368            let node = NodeData::create_text(*s);
2369            assert_eq!(
2370                get_node_text_content(&node),
2371                Some(*s),
2372                "create_text -> get_node_text_content must round-trip {s:?}",
2373            );
2374        }
2375    }
2376
2377    #[test]
2378    fn autotest_text_content_is_none_for_non_text_nodes() {
2379        assert_eq!(get_node_text_content(&NodeData::create_div()), None);
2380        assert_eq!(get_node_text_content(&NodeData::create_body()), None);
2381        assert_eq!(get_node_text_content(&NodeData::create_br()), None);
2382        let img = NodeData::create_image(ImageRef::null_image(
2383            1,
2384            1,
2385            RawImageFormat::RGBA8,
2386            Vec::new(),
2387        ));
2388        assert_eq!(get_node_text_content(&img), None);
2389    }
2390
2391    // ========================================================================
2392    // has_*_callback predicates
2393    // ========================================================================
2394
2395    #[test]
2396    fn autotest_callback_predicates_all_false_without_callbacks() {
2397        let n = NodeData::create_div();
2398        assert!(!has_mount_callback(&n));
2399        assert!(!has_unmount_callback(&n));
2400        assert!(!has_resize_callback(&n));
2401        assert!(!has_update_callback(&n));
2402    }
2403
2404    #[test]
2405    fn autotest_callback_predicates_are_mutually_exclusive_per_filter() {
2406        // Each predicate must recognise exactly its own ComponentEventFilter.
2407        let cases = [
2408            (ComponentEventFilter::AfterMount, [true, false, false, false]),
2409            (ComponentEventFilter::BeforeUnmount, [false, true, false, false]),
2410            (ComponentEventFilter::NodeResized, [false, false, true, false]),
2411            (ComponentEventFilter::Updated, [false, false, false, true]),
2412            // A Component filter that none of the four predicates handle.
2413            (ComponentEventFilter::Selected, [false, false, false, false]),
2414            (ComponentEventFilter::DefaultAction, [false, false, false, false]),
2415        ];
2416
2417        for (filter, expected) in cases {
2418            let n = with_cb(NodeData::create_div(), filter);
2419            let got = [
2420                has_mount_callback(&n),
2421                has_unmount_callback(&n),
2422                has_resize_callback(&n),
2423                has_update_callback(&n),
2424            ];
2425            assert_eq!(got, expected, "predicate mismatch for {filter:?}");
2426        }
2427    }
2428
2429    #[test]
2430    fn autotest_callback_predicates_find_target_among_many() {
2431        // The target callback is last of several — `any()` must still find it.
2432        let mut n = NodeData::create_div();
2433        for f in [
2434            ComponentEventFilter::Selected,
2435            ComponentEventFilter::DefaultAction,
2436            ComponentEventFilter::NodeResized,
2437        ] {
2438            n = with_cb(n, f);
2439        }
2440        assert!(has_resize_callback(&n));
2441        assert!(!has_mount_callback(&n));
2442    }
2443
2444    // ========================================================================
2445    // create_lifecycle_event (private)
2446    // ========================================================================
2447
2448    #[test]
2449    fn autotest_lifecycle_event_fields_are_wired_consistently() {
2450        let ts = Instant::now();
2451        let ev = create_lifecycle_event(
2452            EventType::Mount,
2453            NodeId::new(1_000_000),
2454            DomId::ROOT_ID,
2455            &ts,
2456            LifecycleEventData {
2457                reason: LifecycleReason::InitialMount,
2458                previous_bounds: None,
2459                current_bounds: rect(1.0, 2.0),
2460            },
2461        );
2462
2463        assert_eq!(ev.event_type, EventType::Mount);
2464        assert_eq!(ev.source, EventSource::Lifecycle);
2465        assert_eq!(ev.phase, EventPhase::Target);
2466        // A lifecycle event is delivered at its target, so the two must agree.
2467        assert_eq!(ev.target, ev.current_target);
2468        assert_eq!(
2469            ev.target.node.into_crate_internal(),
2470            Some(NodeId::new(1_000_000)),
2471            "NodeId must survive the 1-based NodeHierarchyItemId encoding",
2472        );
2473        assert!(!ev.stopped);
2474        assert!(!ev.stopped_immediate);
2475        assert!(!ev.prevented_default);
2476
2477        let EventData::Lifecycle(data) = &ev.data else {
2478            panic!("expected EventData::Lifecycle, got {:?}", ev.data);
2479        };
2480        assert!(data.previous_bounds.is_none());
2481        assert_eq!(data.current_bounds, rect(1.0, 2.0));
2482    }
2483
2484    // ========================================================================
2485    // compute_node_changes
2486    // ========================================================================
2487
2488    #[test]
2489    fn autotest_compute_changes_identical_nodes_report_nothing() {
2490        let a = NodeData::create_text("same");
2491        let b = NodeData::create_text("same");
2492        let changes = compute_node_changes(&a, &b, None, None);
2493        assert!(
2494            changes.is_empty(),
2495            "identical nodes must produce no change flags, got {:#b}",
2496            changes.bits,
2497        );
2498        assert!(changes.is_visually_unchanged());
2499    }
2500
2501    #[test]
2502    fn autotest_compute_changes_node_type_change_short_circuits_everything() {
2503        // The documented early-return: when the discriminant changes, NOTHING
2504        // else is inspected — even though these two nodes ALSO differ in
2505        // classes, callbacks, inline CSS, tab index and contenteditable, and
2506        // sit in different styled states.
2507        let old = NodeData::create_div();
2508        let new = with_cb(
2509            NodeData::create_text("now a text node")
2510                .with_ids_and_classes(vec![IdOrClass::Class("brand-new".into())].into())
2511                .with_css("width: 10px")
2512                .with_tab_index(TabIndex::NoKeyboardFocus)
2513                .with_contenteditable(true),
2514            ComponentEventFilter::AfterMount,
2515        );
2516
2517        let hovered = StyledNodeState {
2518            hover: true,
2519            ..StyledNodeState::default()
2520        };
2521        let changes = compute_node_changes(
2522            &old,
2523            &new,
2524            Some(&StyledNodeState::default()),
2525            Some(&hovered),
2526        );
2527        assert_eq!(
2528            changes.bits,
2529            NodeChangeSet::NODE_TYPE_CHANGED,
2530            "a node-type change must be reported alone (early return)",
2531        );
2532    }
2533
2534    #[test]
2535    fn autotest_compute_changes_text_content_unicode() {
2536        for (i, s) in UNICODE_SAMPLES.iter().enumerate() {
2537            let old = NodeData::create_text(*s);
2538
2539            // Same text -> no TEXT_CONTENT flag.
2540            let same = NodeData::create_text(*s);
2541            assert!(
2542                !compute_node_changes(&old, &same, None, None)
2543                    .contains(NodeChangeSet::TEXT_CONTENT),
2544                "identical text {s:?} must not report TEXT_CONTENT",
2545            );
2546
2547            // Different text -> TEXT_CONTENT flag.
2548            let other = UNICODE_SAMPLES[(i + 1) % UNICODE_SAMPLES.len()];
2549            if other == *s {
2550                continue;
2551            }
2552            let changed = NodeData::create_text(other);
2553            assert!(
2554                compute_node_changes(&old, &changed, None, None)
2555                    .contains(NodeChangeSet::TEXT_CONTENT),
2556                "{s:?} -> {other:?} must report TEXT_CONTENT",
2557            );
2558        }
2559    }
2560
2561    #[test]
2562    fn autotest_compute_changes_paint_only_css_never_sets_layout() {
2563        // `color` is RelayoutScope::None -> paint bucket only.
2564        let old = NodeData::create_div().with_css("color: red");
2565        let new = NodeData::create_div().with_css("color: blue");
2566        let changes = compute_node_changes(&old, &new, None, None);
2567
2568        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_PAINT));
2569        assert!(
2570            !changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT),
2571            "a paint-only property must not request relayout",
2572        );
2573        assert!(changes.needs_paint());
2574        assert!(!changes.needs_layout());
2575    }
2576
2577    #[test]
2578    fn autotest_compute_changes_sizing_css_sets_layout_not_paint() {
2579        // `width` is RelayoutScope::SizingOnly -> layout bucket.
2580        let old = NodeData::create_div().with_css("width: 10px");
2581        let new = NodeData::create_div().with_css("width: 20px");
2582        let changes = compute_node_changes(&old, &new, None, None);
2583
2584        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
2585        assert!(!changes.contains(NodeChangeSet::INLINE_STYLE_PAINT));
2586        assert!(changes.needs_layout());
2587    }
2588
2589    #[test]
2590    fn autotest_compute_changes_detects_removed_property() {
2591        // Regression guard for the AUDIT note at diff.rs:270 — a property that
2592        // exists only on the OLD node (i.e. was removed) must still be marked.
2593        let old = NodeData::create_div().with_css("color: red");
2594        let new = NodeData::create_div();
2595        let changes = compute_node_changes(&old, &new, None, None);
2596        assert!(
2597            changes.contains(NodeChangeSet::INLINE_STYLE_PAINT),
2598            "removing an inline property must be reported, got {:#b}",
2599            changes.bits,
2600        );
2601    }
2602
2603    #[test]
2604    fn autotest_compute_changes_detects_added_property() {
2605        let old = NodeData::create_div();
2606        let new = NodeData::create_div().with_css("width: 5px");
2607        let changes = compute_node_changes(&old, &new, None, None);
2608        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
2609    }
2610
2611    #[test]
2612    fn autotest_compute_changes_ids_and_classes() {
2613        let changes = compute_node_changes(&class_node("a"), &class_node("b"), None, None);
2614        assert!(changes.contains(NodeChangeSet::IDS_AND_CLASSES));
2615
2616        // Same classes -> no flag.
2617        let changes = compute_node_changes(&class_node("a"), &class_node("a"), None, None);
2618        assert!(!changes.contains(NodeChangeSet::IDS_AND_CLASSES));
2619    }
2620
2621    #[test]
2622    fn autotest_compute_changes_styled_state() {
2623        let n = NodeData::create_div();
2624        let calm = StyledNodeState::default();
2625        let hovered = StyledNodeState {
2626            hover: true,
2627            ..StyledNodeState::default()
2628        };
2629
2630        let changes = compute_node_changes(&n, &n, Some(&calm), Some(&hovered));
2631        assert!(changes.contains(NodeChangeSet::STYLED_STATE));
2632        assert!(changes.needs_paint());
2633        assert!(!changes.needs_layout());
2634
2635        // Same state -> no flag; and None/None -> no flag.
2636        assert!(!compute_node_changes(&n, &n, Some(&calm), Some(&calm))
2637            .contains(NodeChangeSet::STYLED_STATE));
2638        assert!(!compute_node_changes(&n, &n, None, None).contains(NodeChangeSet::STYLED_STATE));
2639
2640        // None vs Some(default) are *different* inputs and must be reported.
2641        assert!(compute_node_changes(&n, &n, None, Some(&calm))
2642            .contains(NodeChangeSet::STYLED_STATE));
2643    }
2644
2645    #[test]
2646    fn autotest_compute_changes_tab_index_and_contenteditable() {
2647        let plain = NodeData::create_div();
2648
2649        let editable = NodeData::create_div().with_contenteditable(true);
2650        let changes = compute_node_changes(&plain, &editable, None, None);
2651        assert!(changes.contains(NodeChangeSet::CONTENTEDITABLE));
2652        assert!(changes.needs_layout(), "CONTENTEDITABLE is in AFFECTS_LAYOUT");
2653
2654        let tabbed = NodeData::create_div().with_tab_index(TabIndex::OverrideInParent(3));
2655        let changes = compute_node_changes(&plain, &tabbed, None, None);
2656        assert!(changes.contains(NodeChangeSet::TAB_INDEX));
2657        // TAB_INDEX is in neither composite mask -> no visual work.
2658        assert!(changes.is_visually_unchanged());
2659    }
2660
2661    #[test]
2662    fn autotest_compute_changes_callbacks_count_and_identity() {
2663        let plain = NodeData::create_div();
2664        let one = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
2665
2666        // Different callback counts.
2667        let changes = compute_node_changes(&plain, &one, None, None);
2668        assert!(changes.contains(NodeChangeSet::CALLBACKS));
2669        assert!(changes.is_visually_unchanged(), "callbacks are not a visual change");
2670
2671        // Same count, different event filter.
2672        let other = with_cb(NodeData::create_div(), ComponentEventFilter::BeforeUnmount);
2673        let changes = compute_node_changes(&one, &other, None, None);
2674        assert!(changes.contains(NodeChangeSet::CALLBACKS));
2675
2676        // Same count, same filter -> no flag (cb pointer 0 == 0).
2677        let same = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
2678        let changes = compute_node_changes(&one, &same, None, None);
2679        assert!(!changes.contains(NodeChangeSet::CALLBACKS));
2680    }
2681
2682    #[test]
2683    fn autotest_compute_changes_image_identity_is_by_image_id() {
2684        // `ImageRef` hashes its process-unique `id`: shallow clones share it,
2685        // every fresh `null_image()` gets a new one.
2686        let img = ImageRef::null_image(4, 4, RawImageFormat::RGBA8, Vec::new());
2687        let same = NodeData::create_image(img.clone());
2688        let also_same = NodeData::create_image(img.clone());
2689        assert!(
2690            !compute_node_changes(&same, &also_same, None, None)
2691                .contains(NodeChangeSet::IMAGE_CHANGED),
2692            "two nodes holding clones of the SAME ImageRef must not report a change",
2693        );
2694
2695        // A distinct allocation, even with identical pixels/dimensions, is a
2696        // different image as far as reconciliation is concerned.
2697        let other = NodeData::create_image(ImageRef::null_image(
2698            4,
2699            4,
2700            RawImageFormat::RGBA8,
2701            Vec::new(),
2702        ));
2703        let changes = compute_node_changes(&same, &other, None, None);
2704        assert!(changes.contains(NodeChangeSet::IMAGE_CHANGED));
2705        assert!(changes.needs_layout(), "IMAGE_CHANGED is in AFFECTS_LAYOUT");
2706    }
2707
2708    // ========================================================================
2709    // calculate_reconciliation_key / precompute_reconciliation_keys
2710    // ========================================================================
2711
2712    #[test]
2713    fn autotest_rec_key_empty_node_data_is_safe() {
2714        assert!(precompute_reconciliation_keys(&[], &[]).is_empty());
2715    }
2716
2717    #[test]
2718    fn autotest_rec_key_precompute_matches_per_node_calculation() {
2719        // Round-trip: the O(1)-lookup table must agree with the direct call for
2720        // every node — the whole point of precomputing.
2721        let node_data = vec![
2722            NodeData::create_div(),
2723            class_node("row"),
2724            NodeData::create_text("leaf"),
2725            id_node("footer"),
2726        ];
2727        let hierarchy = vec![
2728            hitem(None, None, None, Some(3)),
2729            hitem(Some(0), None, Some(2), None),
2730            hitem(Some(0), Some(1), Some(3), None),
2731            hitem(Some(0), Some(2), None, None),
2732        ];
2733
2734        let keys = precompute_reconciliation_keys(&node_data, &hierarchy);
2735        assert_eq!(keys.len(), node_data.len());
2736        for (i, k) in keys.iter().enumerate() {
2737            assert_eq!(
2738                *k,
2739                calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(i)),
2740                "precomputed key for node {i} disagrees with the direct call",
2741            );
2742        }
2743    }
2744
2745    #[test]
2746    fn autotest_rec_key_explicit_key_beats_css_id_and_node_type() {
2747        // Priority 1 is absolute: it ignores the CSS ID, the classes, the node
2748        // type and the position in the tree.
2749        let bare = NodeData::create_div().with_key(7u32);
2750        let decorated = NodeData::create_text("totally different")
2751            .with_key(7u32)
2752            .with_ids_and_classes(
2753                vec![IdOrClass::Id("hero".into()), IdOrClass::Class("x".into())].into(),
2754            );
2755
2756        let a = calculate_reconciliation_key(&[bare], &[], NodeId::new(0));
2757        let b = calculate_reconciliation_key(&[decorated], &[], NodeId::new(0));
2758        assert_eq!(a, b, "an explicit .with_key() must dominate every other input");
2759    }
2760
2761    #[test]
2762    fn autotest_rec_key_css_id_used_when_no_explicit_key() {
2763        let same_a = calculate_reconciliation_key(&[id_node("hero")], &[], NodeId::new(0));
2764        let same_b = calculate_reconciliation_key(&[id_node("hero")], &[], NodeId::new(0));
2765        let other = calculate_reconciliation_key(&[id_node("footer")], &[], NodeId::new(0));
2766
2767        assert_eq!(same_a, same_b, "the CSS-ID key must be stable");
2768        assert_ne!(same_a, other, "different CSS IDs must produce different keys");
2769    }
2770
2771    #[test]
2772    fn autotest_rec_key_classes_participate_in_the_structural_key() {
2773        let a = calculate_reconciliation_key(&[class_node("alpha")], &[], NodeId::new(0));
2774        let b = calculate_reconciliation_key(&[class_node("beta")], &[], NodeId::new(0));
2775        assert_ne!(a, b, "classes must feed the structural key");
2776    }
2777
2778    #[test]
2779    fn autotest_rec_key_node_type_participates_in_the_structural_key() {
2780        let div = calculate_reconciliation_key(&[NodeData::create_div()], &[], NodeId::new(0));
2781        let txt =
2782            calculate_reconciliation_key(&[NodeData::create_text("x")], &[], NodeId::new(0));
2783        assert_ne!(div, txt, "the node-type discriminant must feed the structural key");
2784    }
2785
2786    #[test]
2787    fn autotest_rec_key_hierarchy_shorter_than_node_data_is_safe() {
2788        // A truncated / absent hierarchy must degrade to the documented
2789        // "discriminant + classes" key instead of panicking.
2790        let node_data = vec![NodeData::create_div(), class_node("a"), id_node("b")];
2791
2792        let with_none = precompute_reconciliation_keys(&node_data, &[]);
2793        let with_short = precompute_reconciliation_keys(&node_data, &[hitem(None, None, None, None)]);
2794
2795        assert_eq!(with_none.len(), 3);
2796        assert_eq!(with_short.len(), 3);
2797        // Node 0 is a root either way, so both spellings must agree on it.
2798        assert_eq!(with_none[0], with_short[0]);
2799    }
2800
2801    #[test]
2802    fn autotest_rec_key_identical_leaves_under_different_parents_differ() {
2803        // The parent chain must be folded in, otherwise keyless nodes under
2804        // unrelated parents would collide and migrate state across subtrees.
2805        //
2806        //   0 root
2807        //   ├── 1 (#left)   ── 3 div
2808        //   └── 2 (#right)  ── 4 div
2809        let node_data = vec![
2810            NodeData::create_div(),
2811            id_node("left"),
2812            id_node("right"),
2813            NodeData::create_div(),
2814            NodeData::create_div(),
2815        ];
2816        let hierarchy = vec![
2817            hitem(None, None, None, Some(2)),       // 0: children 1,2
2818            hitem(Some(0), None, Some(2), Some(3)), // 1: child 3
2819            hitem(Some(0), Some(1), None, Some(4)), // 2: child 4
2820            hitem(Some(1), None, None, None),       // 3
2821            hitem(Some(2), None, None, None),       // 4
2822        ];
2823
2824        let k3 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(3));
2825        let k4 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(4));
2826        assert_ne!(k3, k4, "identical leaves under different parents must not share a key");
2827    }
2828
2829    // ========================================================================
2830    // calculate_contenteditable_key
2831    // ========================================================================
2832
2833    #[test]
2834    fn autotest_contenteditable_key_is_deterministic_and_honours_explicit_keys() {
2835        let node_data = vec![NodeData::create_div().with_key(99u64)];
2836        let a = calculate_contenteditable_key(&node_data, &[], NodeId::new(0));
2837        let b = calculate_contenteditable_key(&node_data, &[], NodeId::new(0));
2838        assert_eq!(a, b, "must be deterministic");
2839
2840        // Priority 1 is shared with the reconciliation key: for an explicitly
2841        // keyed node both functions return the SAME value.
2842        assert_eq!(
2843            a,
2844            calculate_reconciliation_key(&node_data, &[], NodeId::new(0)),
2845            "explicit keys must be identical across both key functions",
2846        );
2847    }
2848
2849    #[test]
2850    fn autotest_contenteditable_key_distinguishes_nth_of_type() {
2851        // <div><p>A</p><p contenteditable>B</p></div> — the two same-type
2852        // siblings must not collide (nth-of-type is folded in).
2853        let node_data = vec![
2854            NodeData::create_div(),
2855            NodeData::create_text("A"),
2856            NodeData::create_text("B"),
2857        ];
2858        let hierarchy = vec![
2859            hitem(None, None, None, Some(2)),
2860            hitem(Some(0), None, Some(2), None),
2861            hitem(Some(0), Some(1), None, None),
2862        ];
2863
2864        let k1 = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(1));
2865        let k2 = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(2));
2866        assert_ne!(k1, k2, "same-type siblings must differ by nth-of-type");
2867    }
2868
2869    #[test]
2870    fn autotest_contenteditable_key_empty_hierarchy_is_safe() {
2871        let node_data = vec![NodeData::create_div(), class_node("editor")];
2872        for i in 0..node_data.len() {
2873            let k = calculate_contenteditable_key(&node_data, &[], NodeId::new(i));
2874            assert_eq!(k, calculate_contenteditable_key(&node_data, &[], NodeId::new(i)));
2875        }
2876    }
2877
2878    // ========================================================================
2879    // reconcile_dom
2880    // ========================================================================
2881
2882    #[test]
2883    fn autotest_reconcile_empty_to_empty_is_a_no_op() {
2884        let r = diff_flat(&[], &[]);
2885        assert!(r.events.is_empty());
2886        assert!(r.node_moves.is_empty());
2887    }
2888
2889    #[test]
2890    fn autotest_reconcile_mount_and_unmount_need_a_callback_to_fire() {
2891        // Without an AfterMount callback the node still mounts — it just fires
2892        // no event. Same for unmount. The events are opt-in.
2893        let silent_new = vec![NodeData::create_div()];
2894        let r = diff_flat(&[], &silent_new);
2895        assert!(r.events.is_empty(), "no callback -> no event");
2896        assert!(r.node_moves.is_empty());
2897
2898        let loud_new = vec![with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount)];
2899        let r = diff_flat(&[], &loud_new);
2900        assert_eq!(count_events(&r, EventType::Mount), 1);
2901
2902        let loud_old = vec![with_cb(
2903            NodeData::create_div(),
2904            ComponentEventFilter::BeforeUnmount,
2905        )];
2906        let r = diff_flat(&loud_old, &[]);
2907        assert_eq!(count_events(&r, EventType::Unmount), 1);
2908        assert!(r.node_moves.is_empty());
2909    }
2910
2911    #[test]
2912    fn autotest_reconcile_node_moves_are_a_bijection() {
2913        // 50 indistinguishable divs on both sides: every old node must be
2914        // claimed exactly once and every new node must claim at most one old
2915        // node. A queue bug (double-consume) would break this immediately.
2916        let old: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
2917        let new: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
2918
2919        let r = diff_flat(&old, &new);
2920        assert_eq!(r.node_moves.len(), 50);
2921
2922        let mut seen_old = vec![false; 50];
2923        let mut seen_new = vec![false; 50];
2924        for m in &r.node_moves {
2925            assert!(!seen_old[m.old_node_id.index()], "old node claimed twice");
2926            assert!(!seen_new[m.new_node_id.index()], "new node matched twice");
2927            seen_old[m.old_node_id.index()] = true;
2928            seen_new[m.new_node_id.index()] = true;
2929        }
2930        assert!(seen_old.iter().all(|b| *b), "every old node must be claimed");
2931        assert!(seen_new.iter().all(|b| *b), "every new node must be matched");
2932        assert!(r.events.is_empty(), "no lifecycle callbacks -> no events");
2933    }
2934
2935    #[test]
2936    fn autotest_reconcile_surplus_new_nodes_mount_and_surplus_old_unmount() {
2937        // 50 old, 60 new -> 50 matches + 10 mounts, no unmounts.
2938        let old: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
2939        let new: Vec<NodeData> = (0..60)
2940            .map(|_| with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount))
2941            .collect();
2942
2943        let r = diff_flat(&old, &new);
2944        assert_eq!(r.node_moves.len(), 50);
2945        assert_eq!(count_events(&r, EventType::Mount), 10);
2946        assert_eq!(count_events(&r, EventType::Unmount), 0);
2947
2948        // 50 old, 40 new -> 40 matches + 10 unmounts.
2949        let old: Vec<NodeData> = (0..50)
2950            .map(|_| with_cb(NodeData::create_div(), ComponentEventFilter::BeforeUnmount))
2951            .collect();
2952        let new: Vec<NodeData> = (0..40).map(|_| NodeData::create_div()).collect();
2953
2954        let r = diff_flat(&old, &new);
2955        assert_eq!(r.node_moves.len(), 40);
2956        assert_eq!(count_events(&r, EventType::Unmount), 10);
2957        assert_eq!(count_events(&r, EventType::Mount), 0);
2958    }
2959
2960    #[test]
2961    fn autotest_reconcile_explicit_key_mismatch_mounts_instead_of_guessing() {
2962        // The documented rule: an explicit `.with_key()` that finds no partner
2963        // must NOT fall through to the content/structural tiers, even though
2964        // the two nodes are otherwise byte-identical.
2965        let old = vec![with_cb(
2966            NodeData::create_text("same content").with_key(1u32),
2967            ComponentEventFilter::BeforeUnmount,
2968        )];
2969        let new = vec![with_cb(
2970            NodeData::create_text("same content").with_key(2u32),
2971            ComponentEventFilter::AfterMount,
2972        )];
2973
2974        let r = diff_flat(&old, &new);
2975        assert!(
2976            r.node_moves.is_empty(),
2977            "keys 1 and 2 must not match, got {:?}",
2978            r.node_moves,
2979        );
2980        assert_eq!(count_events(&r, EventType::Mount), 1);
2981        assert_eq!(count_events(&r, EventType::Unmount), 1);
2982    }
2983
2984    #[test]
2985    fn autotest_reconcile_update_fires_only_on_rec_key_match_with_changed_content() {
2986        // Same key, changed text, Updated callback present -> Update event.
2987        let old = vec![NodeData::create_text("v1").with_key(1u32)];
2988        let new = vec![with_cb(
2989            NodeData::create_text("v2").with_key(1u32),
2990            ComponentEventFilter::Updated,
2991        )];
2992
2993        let r = diff_flat(&old, &new);
2994        assert_eq!(r.node_moves.len(), 1, "the key must match across frames");
2995        assert_eq!(count_events(&r, EventType::Update), 1);
2996
2997        // Same key, SAME content -> no Update. Both frames must be byte-identical
2998        // for this: `NodeData::hash` folds in the callback events too (dom.rs:1579),
2999        // so the Updated handler has to be present on BOTH sides — otherwise the
3000        // hashes differ for the callback alone and we'd be testing nothing.
3001        let stable = with_cb(
3002            NodeData::create_text("v1").with_key(1u32),
3003            ComponentEventFilter::Updated,
3004        );
3005        let old = vec![stable.clone()];
3006        let new = vec![stable];
3007
3008        let r = diff_flat(&old, &new);
3009        assert_eq!(r.node_moves.len(), 1);
3010        assert_eq!(
3011            count_events(&r, EventType::Update),
3012            0,
3013            "unchanged content must not fire Update",
3014        );
3015    }
3016
3017    #[test]
3018    fn autotest_reconcile_update_requires_the_callback() {
3019        // Content changed under a stable key, but no Updated callback -> silent.
3020        let old = vec![NodeData::create_text("v1").with_key(1u32)];
3021        let new = vec![NodeData::create_text("v2").with_key(1u32)];
3022        let r = diff_flat(&old, &new);
3023        assert_eq!(r.node_moves.len(), 1);
3024        assert!(r.events.is_empty());
3025    }
3026
3027    #[test]
3028    fn autotest_reconcile_missing_layout_entries_default_to_zero_rect() {
3029        // Neither side has layout data: `unwrap_or(LogicalRect::zero())` means
3030        // the sizes compare equal, so no Resize fires and nothing panics.
3031        let old = vec![NodeData::create_div()];
3032        let new = vec![with_cb(
3033            NodeData::create_div(),
3034            ComponentEventFilter::NodeResized,
3035        )];
3036
3037        let r = diff_flat(&old, &new);
3038        assert_eq!(r.node_moves.len(), 1);
3039        assert_eq!(
3040            count_events(&r, EventType::Resize),
3041            0,
3042            "zero-vs-zero bounds must not be treated as a resize",
3043        );
3044    }
3045
3046    #[test]
3047    fn autotest_reconcile_resize_fires_with_previous_and_current_bounds() {
3048        let old = vec![NodeData::create_div()];
3049        let new = vec![with_cb(
3050            NodeData::create_div(),
3051            ComponentEventFilter::NodeResized,
3052        )];
3053
3054        let r = reconcile_dom(
3055            &old,
3056            &new,
3057            &[],
3058            &[],
3059            &layout_of(&[(0, rect(100.0, 50.0))]),
3060            &layout_of(&[(0, rect(100.0, 80.0))]),
3061            DomId::ROOT_ID,
3062            Instant::now(),
3063        );
3064
3065        assert_eq!(count_events(&r, EventType::Resize), 1);
3066        let EventData::Lifecycle(data) = &r.events[0].data else {
3067            panic!("resize event must carry EventData::Lifecycle");
3068        };
3069        assert_eq!(data.reason, LifecycleReason::Resize);
3070        assert_eq!(data.previous_bounds, Some(rect(100.0, 50.0)));
3071        assert_eq!(data.current_bounds, rect(100.0, 80.0));
3072    }
3073
3074    #[test]
3075    fn autotest_reconcile_resize_ignores_pure_translation() {
3076        // Only `size` is compared — moving a node must not fire Resize.
3077        let old = vec![NodeData::create_div()];
3078        let new = vec![with_cb(
3079            NodeData::create_div(),
3080            ComponentEventFilter::NodeResized,
3081        )];
3082
3083        let moved = LogicalRect::new(LogicalPosition::new(999.0, 999.0), LogicalSize::new(10.0, 10.0));
3084        let r = reconcile_dom(
3085            &old,
3086            &new,
3087            &[],
3088            &[],
3089            &layout_of(&[(0, rect(10.0, 10.0))]),
3090            &layout_of(&[(0, moved)]),
3091            DomId::ROOT_ID,
3092            Instant::now(),
3093        );
3094        assert_eq!(count_events(&r, EventType::Resize), 0);
3095    }
3096
3097    #[test]
3098    fn autotest_reconcile_nan_bounds_do_not_fire_a_resize_every_frame() {
3099        // NUMERIC EDGE — the sharpest one in this file.
3100        //
3101        // The Resize check is `old_rect.size != new_rect.size`. With a DERIVED
3102        // f32 `PartialEq` this would be catastrophic: `NaN != NaN` is true, so a
3103        // node whose layout solved to NaN would be reported as "resized" on
3104        // EVERY frame forever, firing an endless Resize-callback storm on a
3105        // completely static layout.
3106        //
3107        // `LogicalSize` dodges that with a hand-written `PartialEq` that runs
3108        // both operands through `geom::quantize()`, which maps every NaN to the
3109        // single sentinel `i64::MIN` (geom.rs:218) — so all NaNs compare EQUAL.
3110        // This test pins that: revert `LogicalSize` to `#[derive(PartialEq)]`
3111        // and it goes red.
3112        let old = vec![NodeData::create_div()];
3113        let new = vec![with_cb(
3114            NodeData::create_div(),
3115            ComponentEventFilter::NodeResized,
3116        )];
3117
3118        let nan = rect(f32::NAN, f32::NAN);
3119        let r = reconcile_dom(
3120            &old,
3121            &new,
3122            &[],
3123            &[],
3124            &layout_of(&[(0, nan)]),
3125            &layout_of(&[(0, nan)]),
3126            DomId::ROOT_ID,
3127            Instant::now(),
3128        );
3129        assert_eq!(r.node_moves.len(), 1);
3130        assert_eq!(
3131            count_events(&r, EventType::Resize),
3132            0,
3133            "an unchanged NaN size must not be reported as a resize",
3134        );
3135
3136        // Infinities are likewise stable against themselves (they saturate to
3137        // i64::MAX / i64::MIN under quantize()).
3138        let inf = rect(f32::INFINITY, f32::NEG_INFINITY);
3139        let r = reconcile_dom(
3140            &old,
3141            &new,
3142            &[],
3143            &[],
3144            &layout_of(&[(0, inf)]),
3145            &layout_of(&[(0, inf)]),
3146            DomId::ROOT_ID,
3147            Instant::now(),
3148        );
3149        assert_eq!(
3150            count_events(&r, EventType::Resize),
3151            0,
3152            "infinite-but-equal bounds must not be treated as a resize",
3153        );
3154
3155        // But a NaN -> real transition IS a genuine resize, and must still fire.
3156        let r = reconcile_dom(
3157            &old,
3158            &new,
3159            &[],
3160            &[],
3161            &layout_of(&[(0, nan)]),
3162            &layout_of(&[(0, rect(10.0, 20.0))]),
3163            DomId::ROOT_ID,
3164            Instant::now(),
3165        );
3166        assert_eq!(
3167            count_events(&r, EventType::Resize),
3168            1,
3169            "NaN -> a real size is a real resize",
3170        );
3171    }
3172
3173    #[test]
3174    fn autotest_reconcile_extreme_bounds_do_not_panic() {
3175        // f32 MIN/MAX/subnormal bounds must flow through the Resize comparison
3176        // without arithmetic surprises (the code only compares, never subtracts).
3177        let old = vec![NodeData::create_div()];
3178        let new = vec![with_cb(
3179            NodeData::create_div(),
3180            ComponentEventFilter::NodeResized,
3181        )];
3182
3183        for (a, b) in [
3184            (rect(f32::MIN, f32::MAX), rect(f32::MAX, f32::MIN)),
3185            (rect(f32::MIN_POSITIVE, 0.0), rect(0.0, f32::MIN_POSITIVE)),
3186            (rect(-0.0, 0.0), rect(0.0, -0.0)), // IEEE: -0.0 == 0.0
3187        ] {
3188            let r = reconcile_dom(
3189                &old,
3190                &new,
3191                &[],
3192                &[],
3193                &layout_of(&[(0, a)]),
3194                &layout_of(&[(0, b)]),
3195                DomId::ROOT_ID,
3196                Instant::now(),
3197            );
3198            assert_eq!(r.node_moves.len(), 1);
3199        }
3200    }
3201
3202    #[test]
3203    fn autotest_reconcile_keyless_tiers_respect_the_parent_key_gate() {
3204        // Regression guard for the AUDIT note at diff.rs:601. Two structurally
3205        // identical leaves live under DIFFERENT parents. The content-hash and
3206        // structural-hash tiers must not match them across parents, or focus /
3207        // scroll / dataset state migrates into an unrelated subtree.
3208        //
3209        // old:  0 root ── 1 (#left)  ── 2 "leaf"
3210        // new:  0 root ── 1 (#right) ── 2 "leaf"
3211        let old_nd = vec![
3212            NodeData::create_div(),
3213            id_node("left"),
3214            NodeData::create_text("leaf"),
3215        ];
3216        let old_hier = vec![
3217            hitem(None, None, None, Some(1)),
3218            hitem(Some(0), None, None, Some(2)),
3219            hitem(Some(1), None, None, None),
3220        ];
3221
3222        let new_nd = vec![
3223            NodeData::create_div(),
3224            id_node("right"),
3225            NodeData::create_text("leaf"),
3226        ];
3227        let new_hier = old_hier.clone();
3228
3229        let r = reconcile_dom(
3230            &old_nd,
3231            &new_nd,
3232            &old_hier,
3233            &new_hier,
3234            &no_layout(),
3235            &no_layout(),
3236            DomId::ROOT_ID,
3237            Instant::now(),
3238        );
3239
3240        // The leaf (index 2) must NOT be matched: its parent's reconciliation
3241        // key differs (#left vs #right), so both keyless tiers are gated off.
3242        let leaf_matched = r
3243            .node_moves
3244            .iter()
3245            .any(|m| m.new_node_id.index() == 2 && m.old_node_id.index() == 2);
3246        assert!(
3247            !leaf_matched,
3248            "a leaf must not migrate across parents; moves = {:?}",
3249            r.node_moves,
3250        );
3251    }
3252
3253    // ========================================================================
3254    // create_migration_map
3255    // ========================================================================
3256
3257    #[test]
3258    fn autotest_migration_map_empty_and_large() {
3259        assert!(create_migration_map(&[]).is_empty());
3260
3261        let moves: Vec<NodeMove> = (0..1000)
3262            .map(|i| NodeMove {
3263                old_node_id: NodeId::new(i),
3264                new_node_id: NodeId::new(i * 2),
3265            })
3266            .collect();
3267        let map = create_migration_map(&moves);
3268        assert_eq!(map.len(), 1000);
3269        assert_eq!(map.get(&NodeId::new(999)), Some(&NodeId::new(1998)));
3270    }
3271
3272    #[test]
3273    fn autotest_migration_map_duplicate_old_id_keeps_the_last_write() {
3274        // The map is a BTreeMap, so a repeated old id overwrites. Pin it: a
3275        // silent "first wins" flip would strand focus on a stale node.
3276        let moves = vec![
3277            NodeMove {
3278                old_node_id: NodeId::new(0),
3279                new_node_id: NodeId::new(5),
3280            },
3281            NodeMove {
3282                old_node_id: NodeId::new(0),
3283                new_node_id: NodeId::new(9),
3284            },
3285        ];
3286        let map = create_migration_map(&moves);
3287        assert_eq!(map.len(), 1);
3288        assert_eq!(map.get(&NodeId::new(0)), Some(&NodeId::new(9)));
3289    }
3290
3291    #[test]
3292    fn autotest_migration_map_round_trips_a_real_diff() {
3293        let old: Vec<NodeData> = (0..8).map(|_| NodeData::create_div()).collect();
3294        let new: Vec<NodeData> = (0..8).map(|_| NodeData::create_div()).collect();
3295        let r = diff_flat(&old, &new);
3296
3297        let map = create_migration_map(&r.node_moves);
3298        assert_eq!(map.len(), r.node_moves.len());
3299        for m in &r.node_moves {
3300            assert_eq!(map.get(&m.old_node_id), Some(&m.new_node_id));
3301        }
3302    }
3303
3304    // ========================================================================
3305    // transfer_states
3306    // ========================================================================
3307
3308    #[allow(dead_code)]
3309    struct TestState(u32);
3310
3311    // Keeps the PERSISTENT (old) allocation, discarding the fresh one — the
3312    // real-world case (MapWidget's tile cache is written by background threads).
3313    extern "C" fn merge_keep_old(_new_data: RefAny, old_data: RefAny) -> RefAny {
3314        old_data
3315    }
3316
3317    #[test]
3318    fn autotest_transfer_states_out_of_range_moves_are_skipped() {
3319        // The bounds guard must swallow a corrupt NodeMove instead of indexing
3320        // out of bounds.
3321        let mut old = vec![NodeData::create_div()];
3322        let mut new = vec![NodeData::create_div()];
3323
3324        let moves = vec![
3325            NodeMove {
3326                old_node_id: NodeId::new(5), // out of range
3327                new_node_id: NodeId::new(0),
3328            },
3329            NodeMove {
3330                old_node_id: NodeId::new(0),
3331                new_node_id: NodeId::new(7), // out of range
3332            },
3333            NodeMove {
3334                old_node_id: NodeId::new(usize::MAX),
3335                new_node_id: NodeId::new(usize::MAX),
3336            },
3337        ];
3338
3339        transfer_states(&mut old, &mut new, &moves); // must not panic
3340        assert!(new[0].get_dataset().is_none());
3341    }
3342
3343    #[test]
3344    fn autotest_transfer_states_without_merge_callback_leaves_datasets_intact() {
3345        let mut old = vec![NodeData::create_div()];
3346        old[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(1))));
3347
3348        let mut new = vec![NodeData::create_div()];
3349        new[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(2))));
3350
3351        let old_ptr = old[0].get_dataset().unwrap().sharing_info.ptr as usize;
3352        let new_ptr = new[0].get_dataset().unwrap().sharing_info.ptr as usize;
3353
3354        transfer_states(
3355            &mut old,
3356            &mut new,
3357            &[NodeMove {
3358                old_node_id: NodeId::new(0),
3359                new_node_id: NodeId::new(0),
3360            }],
3361        );
3362
3363        // No merge callback -> early `continue`, both datasets stay where they were.
3364        assert_eq!(
3365            old[0].get_dataset().unwrap().sharing_info.ptr as usize,
3366            old_ptr,
3367        );
3368        assert_eq!(
3369            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
3370            new_ptr,
3371        );
3372    }
3373
3374    #[test]
3375    fn autotest_transfer_states_with_one_missing_dataset_restores_both_sides() {
3376        // Merge callback present, but the OLD node has no dataset -> the
3377        // `(new_ds, old_ds)` arm must put the taken dataset back.
3378        let mut old = vec![NodeData::create_div()];
3379        let mut new = vec![NodeData::create_div()];
3380        new[0].set_merge_callback(merge_keep_old as DatasetMergeCallbackType);
3381        new[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(2))));
3382
3383        let new_ptr = new[0].get_dataset().unwrap().sharing_info.ptr as usize;
3384
3385        transfer_states(
3386            &mut old,
3387            &mut new,
3388            &[NodeMove {
3389                old_node_id: NodeId::new(0),
3390                new_node_id: NodeId::new(0),
3391            }],
3392        );
3393
3394        assert!(old[0].get_dataset().is_none());
3395        assert_eq!(
3396            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
3397            new_ptr,
3398            "the fresh dataset must be restored, not dropped",
3399        );
3400    }
3401
3402    #[test]
3403    fn autotest_transfer_states_repoints_orphaned_callback_refanys() {
3404        // The unification rule (diff.rs:909): a widget builds its dataset AND
3405        // its callback refanys from clones of ONE RefAny. When the merge keeps
3406        // the OLD allocation, every clone of the FRESH one is orphaned and must
3407        // be re-pointed at the merged result — otherwise the widget fragments
3408        // across two caches (the MapWidget grey-tile bug).
3409        let fresh = RefAny::new(TestState(1));
3410        let fresh_ptr = fresh.sharing_info.ptr as usize;
3411
3412        let mut new0 = NodeData::create_div();
3413        new0.set_merge_callback(merge_keep_old as DatasetMergeCallbackType);
3414        new0.set_dataset(OptionRefAny::Some(fresh.clone()));
3415        // A callback on the SAME node, holding a clone of the fresh allocation.
3416        new0.add_callback(
3417            EventFilter::Component(ComponentEventFilter::Selected),
3418            fresh.clone(),
3419            noop_callback(),
3420        );
3421
3422        // A *sibling* node whose callback also clones the fresh allocation —
3423        // the generalised sweep must reach it too, not just the merge node.
3424        let mut new1 = NodeData::create_div();
3425        new1.add_callback(
3426            EventFilter::Component(ComponentEventFilter::Selected),
3427            fresh.clone(),
3428            noop_callback(),
3429        );
3430
3431        let persistent = RefAny::new(TestState(2));
3432        let persistent_ptr = persistent.sharing_info.ptr as usize;
3433        assert_ne!(fresh_ptr, persistent_ptr, "test setup: allocations must differ");
3434
3435        let mut old = vec![NodeData::create_div()];
3436        old[0].set_dataset(OptionRefAny::Some(persistent));
3437
3438        let mut new = vec![new0, new1];
3439
3440        transfer_states(
3441            &mut old,
3442            &mut new,
3443            &[NodeMove {
3444                old_node_id: NodeId::new(0),
3445                new_node_id: NodeId::new(0),
3446            }],
3447        );
3448
3449        // The merged dataset is the PERSISTENT allocation.
3450        assert_eq!(
3451            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
3452            persistent_ptr,
3453            "the merge must keep the persistent allocation",
3454        );
3455        // The old node's dataset was moved into the merge result.
3456        assert!(old[0].get_dataset().is_none());
3457
3458        // Both orphaned callback refanys — on the merge node AND on the sibling
3459        // — must now point at the merged allocation.
3460        for (i, nd) in new.iter().enumerate() {
3461            for cb in nd.callbacks.as_ref() {
3462                assert_eq!(
3463                    cb.refany.sharing_info.ptr as usize,
3464                    persistent_ptr,
3465                    "node {i}: an orphaned callback refany was not re-pointed",
3466                );
3467            }
3468        }
3469    }
3470
3471    // ========================================================================
3472    // ChangeAccumulator
3473    // ========================================================================
3474
3475    #[test]
3476    fn autotest_accumulator_new_is_empty_and_inert() {
3477        let a = ChangeAccumulator::new();
3478        assert!(a.is_empty());
3479        assert!(!a.needs_layout());
3480        assert!(!a.needs_paint_only());
3481        assert!(a.is_visually_unchanged());
3482        assert_eq!(a.max_scope, RelayoutScope::None);
3483        // `new()` and `default()` must agree.
3484        let d = ChangeAccumulator::default();
3485        assert_eq!(a.is_empty(), d.is_empty());
3486        assert_eq!(a.max_scope, d.max_scope);
3487    }
3488
3489    #[test]
3490    fn autotest_accumulator_mount_forces_layout_unmount_does_not() {
3491        let mut a = ChangeAccumulator::new();
3492        a.add_mount(NodeId::new(0));
3493        assert!(!a.is_empty());
3494        assert!(a.needs_layout(), "a mounted node always needs layout");
3495        assert!(!a.needs_paint_only());
3496        assert!(!a.is_visually_unchanged());
3497
3498        // An unmount alone is NOT layout work here (the node is gone); it only
3499        // breaks `is_visually_unchanged`. Pin the asymmetry.
3500        let mut a = ChangeAccumulator::new();
3501        a.add_unmount(NodeId::new(0));
3502        assert!(!a.is_empty());
3503        assert!(!a.needs_layout());
3504        assert!(!a.is_visually_unchanged());
3505    }
3506
3507    #[test]
3508    fn autotest_accumulator_css_change_routes_paint_vs_layout_by_scope() {
3509        // scope == None -> paint bucket.
3510        let mut a = ChangeAccumulator::new();
3511        a.add_css_change(NodeId::new(0), CssPropertyType::TextColor, RelayoutScope::None);
3512        assert!(!a.needs_layout());
3513        assert!(a.needs_paint_only());
3514        assert!(!a.is_visually_unchanged());
3515        assert_eq!(a.max_scope, RelayoutScope::None);
3516        assert!(a.per_node[&NodeId::new(0)]
3517            .change_set
3518            .contains(NodeChangeSet::INLINE_STYLE_PAINT));
3519
3520        // scope > None -> layout bucket.
3521        let mut a = ChangeAccumulator::new();
3522        a.add_css_change(NodeId::new(0), CssPropertyType::Width, RelayoutScope::SizingOnly);
3523        assert!(a.needs_layout());
3524        assert!(!a.needs_paint_only(), "layout work subsumes paint-only");
3525        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
3526        assert!(a.per_node[&NodeId::new(0)]
3527            .change_set
3528            .contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
3529    }
3530
3531    #[test]
3532    fn autotest_accumulator_max_scope_is_monotone() {
3533        // Once escalated, the scope must never be lowered by a later, weaker
3534        // change — otherwise a Full relayout gets silently downgraded.
3535        let mut a = ChangeAccumulator::new();
3536        a.add_css_change(NodeId::new(0), CssPropertyType::Display, RelayoutScope::Full);
3537        assert_eq!(a.max_scope, RelayoutScope::Full);
3538
3539        a.add_css_change(NodeId::new(0), CssPropertyType::TextColor, RelayoutScope::None);
3540        assert_eq!(a.max_scope, RelayoutScope::Full, "max_scope must not regress");
3541        assert_eq!(
3542            a.per_node[&NodeId::new(0)].relayout_scope,
3543            RelayoutScope::Full,
3544            "per-node scope must not regress either",
3545        );
3546
3547        a.add_css_change(NodeId::new(1), CssPropertyType::Width, RelayoutScope::SizingOnly);
3548        assert_eq!(a.max_scope, RelayoutScope::Full);
3549        assert_eq!(
3550            a.per_node[&NodeId::new(1)].relayout_scope,
3551            RelayoutScope::SizingOnly,
3552            "a different node keeps its own, lower scope",
3553        );
3554    }
3555
3556    #[test]
3557    fn autotest_accumulator_text_change_is_ifc_scoped_and_unicode_safe() {
3558        for s in UNICODE_SAMPLES {
3559            let mut a = ChangeAccumulator::new();
3560            a.add_text_change(NodeId::new(0), String::new(), (*s).to_string());
3561
3562            let report = &a.per_node[&NodeId::new(0)];
3563            assert!(report.change_set.contains(NodeChangeSet::TEXT_CONTENT));
3564            assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
3565            assert_eq!(
3566                report.text_change,
3567                Some(TextChange {
3568                    old_text: String::new(),
3569                    new_text: (*s).to_string(),
3570                }),
3571            );
3572            assert!(a.needs_layout());
3573            assert_eq!(a.max_scope, RelayoutScope::IfcOnly);
3574        }
3575    }
3576
3577    #[test]
3578    fn autotest_accumulator_add_dom_change_accumulates_and_never_clears_text() {
3579        let node = NodeId::new(0);
3580        let mut a = ChangeAccumulator::new();
3581
3582        a.add_dom_change(
3583            node,
3584            NodeChangeSet {
3585                bits: NodeChangeSet::TEXT_CONTENT,
3586            },
3587            RelayoutScope::IfcOnly,
3588            Some(TextChange {
3589                old_text: "a".to_string(),
3590                new_text: "b".to_string(),
3591            }),
3592            vec![CssPropertyType::Width],
3593        );
3594
3595        // A second call with text_change == None must NOT wipe the first one.
3596        a.add_dom_change(
3597            node,
3598            NodeChangeSet {
3599                bits: NodeChangeSet::STYLED_STATE,
3600            },
3601            RelayoutScope::None,
3602            None,
3603            vec![CssPropertyType::TextColor],
3604        );
3605
3606        let report = &a.per_node[&node];
3607        assert!(report.change_set.contains(NodeChangeSet::TEXT_CONTENT));
3608        assert!(
3609            report.change_set.contains(NodeChangeSet::STYLED_STATE),
3610            "flags must be OR-accumulated across calls",
3611        );
3612        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
3613        assert!(
3614            report.text_change.is_some(),
3615            "a None text_change must not erase a previously recorded one",
3616        );
3617        assert_eq!(
3618            report.changed_css_properties,
3619            vec![CssPropertyType::Width, CssPropertyType::TextColor],
3620            "changed properties must be appended, not replaced",
3621        );
3622    }
3623
3624    #[test]
3625    fn autotest_accumulator_image_change() {
3626        let mut a = ChangeAccumulator::new();
3627        a.add_image_change(NodeId::new(0), RelayoutScope::SizingOnly);
3628        assert!(a.per_node[&NodeId::new(0)]
3629            .change_set
3630            .contains(NodeChangeSet::IMAGE_CHANGED));
3631        assert!(a.needs_layout());
3632        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
3633    }
3634
3635    #[test]
3636    fn autotest_accumulator_merge_empty_restyle_result_is_a_no_op() {
3637        let mut a = ChangeAccumulator::new();
3638        a.merge_restyle_result(&RestyleResult::default());
3639        assert!(a.is_empty());
3640        assert!(a.is_visually_unchanged());
3641    }
3642
3643    #[test]
3644    fn autotest_accumulator_merge_restyle_result_classifies_by_property() {
3645        let prop = CssProperty::Width(CssPropertyValue::Exact(LayoutWidth::const_px(100)));
3646        let changed = ChangedCssProperty {
3647            previous_state: StyledNodeState::default(),
3648            previous_prop: prop.clone(),
3649            current_state: StyledNodeState::default(),
3650            current_prop: prop,
3651        };
3652
3653        let mut restyle = RestyleResult::default();
3654        restyle
3655            .changed_nodes
3656            .insert(NodeId::new(3), vec![changed]);
3657
3658        let mut a = ChangeAccumulator::new();
3659        a.merge_restyle_result(&restyle);
3660
3661        // `width` -> SizingOnly -> layout bucket.
3662        assert!(!a.is_empty());
3663        assert!(a.needs_layout());
3664        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
3665        let report = &a.per_node[&NodeId::new(3)];
3666        assert!(report.change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
3667        assert_eq!(report.changed_css_properties, vec![CssPropertyType::Width]);
3668    }
3669
3670    #[test]
3671    fn autotest_accumulator_merge_extended_diff_counts_mounts_and_unmounts() {
3672        // No node_moves at all -> every new node mounted, every old node unmounted.
3673        let old_nd = vec![NodeData::create_div(), NodeData::create_div()];
3674        let new_nd = vec![
3675            NodeData::create_div(),
3676            NodeData::create_div(),
3677            NodeData::create_div(),
3678        ];
3679
3680        let mut a = ChangeAccumulator::new();
3681        a.merge_extended_diff(&ExtendedDiffResult::default(), &old_nd, &new_nd);
3682
3683        assert_eq!(a.mounted_nodes.len(), 3);
3684        assert_eq!(a.unmounted_nodes.len(), 2);
3685        assert!(!a.is_empty());
3686        assert!(a.needs_layout(), "mounted nodes always need layout");
3687        assert!(!a.is_visually_unchanged());
3688    }
3689
3690    #[test]
3691    fn autotest_accumulator_merge_extended_diff_on_empty_doms_is_empty() {
3692        let mut a = ChangeAccumulator::new();
3693        a.merge_extended_diff(&ExtendedDiffResult::default(), &[], &[]);
3694        assert!(a.is_empty());
3695    }
3696
3697    #[test]
3698    fn autotest_accumulator_merge_extended_diff_skips_empty_change_sets() {
3699        // A matched node with NO changes must not create a per_node entry.
3700        let old_nd = vec![NodeData::create_div()];
3701        let new_nd = vec![NodeData::create_div()];
3702
3703        let extended = ExtendedDiffResult {
3704            diff: DiffResult {
3705                events: Vec::new(),
3706                node_moves: vec![NodeMove {
3707                    old_node_id: NodeId::new(0),
3708                    new_node_id: NodeId::new(0),
3709                }],
3710            },
3711            node_changes: vec![(NodeId::new(0), NodeId::new(0), NodeChangeSet::empty())],
3712        };
3713
3714        let mut a = ChangeAccumulator::new();
3715        a.merge_extended_diff(&extended, &old_nd, &new_nd);
3716
3717        assert!(a.per_node.is_empty(), "an empty change set must be skipped");
3718        assert!(a.mounted_nodes.is_empty());
3719        assert!(a.unmounted_nodes.is_empty());
3720        assert!(a.is_empty());
3721    }
3722
3723    #[test]
3724    fn autotest_accumulator_merge_extended_diff_extracts_text_change() {
3725        let old_nd = vec![NodeData::create_text("héllo")];
3726        let new_nd = vec![NodeData::create_text("héllo wörld")];
3727
3728        let extended = ExtendedDiffResult {
3729            diff: DiffResult {
3730                events: Vec::new(),
3731                node_moves: vec![NodeMove {
3732                    old_node_id: NodeId::new(0),
3733                    new_node_id: NodeId::new(0),
3734                }],
3735            },
3736            node_changes: vec![(
3737                NodeId::new(0),
3738                NodeId::new(0),
3739                NodeChangeSet {
3740                    bits: NodeChangeSet::TEXT_CONTENT,
3741                },
3742            )],
3743        };
3744
3745        let mut a = ChangeAccumulator::new();
3746        a.merge_extended_diff(&extended, &old_nd, &new_nd);
3747
3748        let report = &a.per_node[&NodeId::new(0)];
3749        assert_eq!(
3750            report.text_change,
3751            Some(TextChange {
3752                old_text: "héllo".to_string(),
3753                new_text: "héllo wörld".to_string(),
3754            }),
3755            "TEXT_CONTENT must carry the old/new text for cursor reconciliation",
3756        );
3757        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
3758    }
3759
3760    // ========================================================================
3761    // ChangeAccumulator::classify_change_scope (private)
3762    // ========================================================================
3763
3764    #[test]
3765    fn autotest_classify_scope_maps_each_flag_to_its_documented_scope() {
3766        let nodes = vec![NodeData::create_div()];
3767        let id = NodeId::new(0);
3768
3769        let classify = |bits: u32| {
3770            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id)
3771        };
3772
3773        assert_eq!(classify(0), RelayoutScope::None, "empty -> no work");
3774        assert_eq!(classify(NodeChangeSet::NODE_TYPE_CHANGED), RelayoutScope::Full);
3775        assert_eq!(classify(NodeChangeSet::CHILDREN_CHANGED), RelayoutScope::Full);
3776        assert_eq!(classify(NodeChangeSet::IDS_AND_CLASSES), RelayoutScope::Full);
3777        assert_eq!(classify(NodeChangeSet::TEXT_CONTENT), RelayoutScope::IfcOnly);
3778        assert_eq!(classify(NodeChangeSet::IMAGE_CHANGED), RelayoutScope::SizingOnly);
3779        assert_eq!(classify(NodeChangeSet::CONTENTEDITABLE), RelayoutScope::SizingOnly);
3780        assert_eq!(classify(NodeChangeSet::STYLED_STATE), RelayoutScope::None);
3781        assert_eq!(classify(NodeChangeSet::INLINE_STYLE_PAINT), RelayoutScope::None);
3782        // Non-visual flags -> no work.
3783        assert_eq!(classify(NodeChangeSet::CALLBACKS), RelayoutScope::None);
3784        assert_eq!(classify(NodeChangeSet::DATASET), RelayoutScope::None);
3785        assert_eq!(classify(NodeChangeSet::TAB_INDEX), RelayoutScope::None);
3786    }
3787
3788    #[test]
3789    fn autotest_classify_scope_precedence_is_widest_first() {
3790        let nodes = vec![NodeData::create_div()];
3791        let id = NodeId::new(0);
3792
3793        // NODE_TYPE_CHANGED wins over everything below it.
3794        let bits = NodeChangeSet::NODE_TYPE_CHANGED
3795            | NodeChangeSet::TEXT_CONTENT
3796            | NodeChangeSet::IMAGE_CHANGED
3797            | NodeChangeSet::STYLED_STATE;
3798        assert_eq!(
3799            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id),
3800            RelayoutScope::Full,
3801        );
3802
3803        // TEXT_CONTENT (IfcOnly) wins over IMAGE_CHANGED (SizingOnly) — pinning
3804        // the documented order, even though IfcOnly < SizingOnly.
3805        let bits = NodeChangeSet::TEXT_CONTENT | NodeChangeSet::IMAGE_CHANGED;
3806        assert_eq!(
3807            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id),
3808            RelayoutScope::IfcOnly,
3809        );
3810    }
3811
3812    #[test]
3813    fn autotest_classify_scope_inline_layout_walks_the_nodes_own_css() {
3814        // With a sizing property on the node, the scope comes from the property.
3815        let nodes = vec![NodeData::create_div().with_css("width: 100px")];
3816        assert_eq!(
3817            ChangeAccumulator::classify_change_scope(
3818                NodeChangeSet {
3819                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
3820                },
3821                &nodes,
3822                NodeId::new(0),
3823            ),
3824            RelayoutScope::SizingOnly,
3825        );
3826
3827        // A `display` change is a full relayout.
3828        let nodes = vec![NodeData::create_div().with_css("display: flex")];
3829        assert_eq!(
3830            ChangeAccumulator::classify_change_scope(
3831                NodeChangeSet {
3832                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
3833                },
3834                &nodes,
3835                NodeId::new(0),
3836            ),
3837            RelayoutScope::Full,
3838        );
3839
3840        // No inline CSS at all (the property was REMOVED, so the new node has
3841        // nothing to walk): the conservative SizingOnly fallback must kick in
3842        // rather than silently reporting "no layout work".
3843        let nodes = vec![NodeData::create_div()];
3844        assert_eq!(
3845            ChangeAccumulator::classify_change_scope(
3846                NodeChangeSet {
3847                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
3848                },
3849                &nodes,
3850                NodeId::new(0),
3851            ),
3852            RelayoutScope::SizingOnly,
3853            "an INLINE_STYLE_LAYOUT change must never classify as 'no layout'",
3854        );
3855    }
3856
3857    // ========================================================================
3858    // reconcile_dom_with_changes
3859    // ========================================================================
3860
3861    #[test]
3862    fn autotest_reconcile_with_changes_on_empty_doms() {
3863        let r = reconcile_dom_with_changes(
3864            &[],
3865            &[],
3866            &[],
3867            &[],
3868            None,
3869            None,
3870            &no_layout(),
3871            &no_layout(),
3872            DomId::ROOT_ID,
3873            Instant::now(),
3874        );
3875        assert!(r.diff.events.is_empty());
3876        assert!(r.diff.node_moves.is_empty());
3877        assert!(r.node_changes.is_empty());
3878    }
3879
3880    #[test]
3881    fn autotest_reconcile_with_changes_reports_one_entry_per_move() {
3882        let old = vec![NodeData::create_text("v1").with_key(1u32)];
3883        let new = vec![NodeData::create_text("v2").with_key(1u32)];
3884
3885        let r = reconcile_dom_with_changes(
3886            &old,
3887            &new,
3888            &[],
3889            &[],
3890            None,
3891            None,
3892            &no_layout(),
3893            &no_layout(),
3894            DomId::ROOT_ID,
3895            Instant::now(),
3896        );
3897
3898        assert_eq!(r.diff.node_moves.len(), 1);
3899        assert_eq!(
3900            r.node_changes.len(),
3901            r.diff.node_moves.len(),
3902            "there must be exactly one change entry per matched pair",
3903        );
3904
3905        let (old_id, new_id, changes) = &r.node_changes[0];
3906        assert_eq!(*old_id, NodeId::new(0));
3907        assert_eq!(*new_id, NodeId::new(0));
3908        assert!(changes.contains(NodeChangeSet::TEXT_CONTENT));
3909    }
3910
3911    #[test]
3912    fn autotest_reconcile_with_changes_tolerates_short_styled_state_slices() {
3913        // `old_styled_nodes` / `new_styled_nodes` are indexed with `.get()`, so a
3914        // slice shorter than the DOM must degrade to `None`, not panic.
3915        let old = vec![NodeData::create_div(), NodeData::create_div()];
3916        let new = vec![NodeData::create_div(), NodeData::create_div()];
3917        let short = [StyledNodeState::default()]; // 1 entry for 2 nodes
3918
3919        let r = reconcile_dom_with_changes(
3920            &old,
3921            &new,
3922            &[],
3923            &[],
3924            Some(&short[..]),
3925            Some(&short[..]),
3926            &no_layout(),
3927            &no_layout(),
3928            DomId::ROOT_ID,
3929            Instant::now(),
3930        );
3931        assert_eq!(r.node_changes.len(), 2);
3932        // Both sides see the same (present-or-absent) state, so no STYLED_STATE.
3933        for (_, _, changes) in &r.node_changes {
3934            assert!(!changes.contains(NodeChangeSet::STYLED_STATE));
3935        }
3936    }
3937
3938    #[test]
3939    fn autotest_reconcile_with_changes_feeds_the_accumulator() {
3940        // End-to-end: reconcile -> ExtendedDiffResult -> ChangeAccumulator.
3941        let old = vec![NodeData::create_text("before").with_key(1u32)];
3942        let new = vec![NodeData::create_text("after").with_key(1u32)];
3943
3944        let extended = reconcile_dom_with_changes(
3945            &old,
3946            &new,
3947            &[],
3948            &[],
3949            None,
3950            None,
3951            &no_layout(),
3952            &no_layout(),
3953            DomId::ROOT_ID,
3954            Instant::now(),
3955        );
3956
3957        let mut acc = ChangeAccumulator::new();
3958        acc.merge_extended_diff(&extended, &old, &new);
3959
3960        assert!(!acc.is_empty());
3961        assert!(acc.needs_layout(), "a text edit needs (IFC) layout");
3962        assert!(!acc.is_visually_unchanged());
3963        assert!(acc.mounted_nodes.is_empty());
3964        assert!(acc.unmounted_nodes.is_empty());
3965
3966        let report = &acc.per_node[&NodeId::new(0)];
3967        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
3968        assert_eq!(
3969            report.text_change,
3970            Some(TextChange {
3971                old_text: "before".to_string(),
3972                new_text: "after".to_string(),
3973            }),
3974        );
3975    }
3976
3977    // ========================================================================
3978    // NodeDataFingerprint
3979    // ========================================================================
3980
3981    #[test]
3982    fn autotest_fingerprint_default_and_self_comparison_are_inert() {
3983        let d = NodeDataFingerprint::default();
3984        assert!(d.is_identical(&d));
3985        assert!(d.diff(&d).is_empty());
3986        assert!(!d.might_affect_layout(&d));
3987        assert!(!d.might_affect_visuals(&d));
3988        assert_eq!(d, NodeDataFingerprint::default());
3989    }
3990
3991    #[test]
3992    fn autotest_fingerprint_is_a_pure_function_of_its_inputs() {
3993        // Round-trip / determinism: recomputing from equal inputs must give an
3994        // identical fingerprint (no address or allocation identity leaking in).
3995        let state = StyledNodeState::default();
3996        for s in UNICODE_SAMPLES {
3997            let a = NodeDataFingerprint::compute(&NodeData::create_text(*s), Some(&state));
3998            let b = NodeDataFingerprint::compute(&NodeData::create_text(*s), Some(&state));
3999            assert_eq!(a, b, "fingerprint of {s:?} is not deterministic");
4000            assert!(a.is_identical(&b));
4001            assert!(a.diff(&b).is_empty());
4002        }
4003    }
4004
4005    #[test]
4006    fn autotest_fingerprint_diff_is_symmetric() {
4007        let a = NodeDataFingerprint::compute(&NodeData::create_text("a"), None);
4008        let b = NodeDataFingerprint::compute(&class_node("x").with_css("width: 1px"), None);
4009
4010        assert_eq!(a.diff(&b), b.diff(&a), "diff must be symmetric");
4011        assert_eq!(
4012            a.might_affect_layout(&b),
4013            b.might_affect_layout(&a),
4014            "might_affect_layout must be symmetric",
4015        );
4016        assert_eq!(a.might_affect_visuals(&b), b.might_affect_visuals(&a));
4017    }
4018
4019    #[test]
4020    fn autotest_fingerprint_text_change_is_layout_and_visual() {
4021        let a = NodeDataFingerprint::compute(&NodeData::create_text("one"), None);
4022        let b = NodeDataFingerprint::compute(&NodeData::create_text("two"), None);
4023
4024        assert!(!a.is_identical(&b));
4025        let changes = a.diff(&b);
4026        // Conservative by design: content_hash cannot tell text from image.
4027        assert!(changes.contains(NodeChangeSet::TEXT_CONTENT));
4028        assert!(changes.contains(NodeChangeSet::IMAGE_CHANGED));
4029        assert!(a.might_affect_layout(&b));
4030        assert!(a.might_affect_visuals(&b));
4031    }
4032
4033    #[test]
4034    fn autotest_fingerprint_styled_state_is_visual_but_not_layout() {
4035        // The sharpest invariant of the fast path: a :hover flip must never be
4036        // able to trigger relayout.
4037        let node = NodeData::create_div();
4038        let calm = StyledNodeState::default();
4039        let hovered = StyledNodeState {
4040            hover: true,
4041            ..StyledNodeState::default()
4042        };
4043
4044        let a = NodeDataFingerprint::compute(&node, Some(&calm));
4045        let b = NodeDataFingerprint::compute(&node, Some(&hovered));
4046
4047        assert!(!a.is_identical(&b));
4048        assert!(a.diff(&b).contains(NodeChangeSet::STYLED_STATE));
4049        assert!(
4050            !a.might_affect_layout(&b),
4051            "a styled-state change must not be able to request layout",
4052        );
4053        assert!(a.might_affect_visuals(&b));
4054    }
4055
4056    #[test]
4057    fn autotest_fingerprint_callback_change_is_neither_layout_nor_visual() {
4058        let plain = NodeData::create_div();
4059        let with_handler = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
4060
4061        let a = NodeDataFingerprint::compute(&plain, None);
4062        let b = NodeDataFingerprint::compute(&with_handler, None);
4063
4064        assert!(!a.is_identical(&b), "the callback list must be fingerprinted");
4065        assert!(a.diff(&b).contains(NodeChangeSet::CALLBACKS));
4066        assert!(
4067            !a.might_affect_layout(&b),
4068            "swapping an event handler must not trigger relayout",
4069        );
4070        assert!(
4071            !a.might_affect_visuals(&b),
4072            "swapping an event handler must not trigger a repaint",
4073        );
4074    }
4075
4076    #[test]
4077    fn autotest_fingerprint_ids_classes_and_inline_css_are_layout_relevant() {
4078        let base = NodeDataFingerprint::compute(&NodeData::create_div(), None);
4079
4080        let classes = NodeDataFingerprint::compute(&class_node("banner"), None);
4081        assert!(base.diff(&classes).contains(NodeChangeSet::IDS_AND_CLASSES));
4082        assert!(base.might_affect_layout(&classes));
4083        assert!(base.might_affect_visuals(&classes));
4084
4085        let styled =
4086            NodeDataFingerprint::compute(&NodeData::create_div().with_css("width: 3px"), None);
4087        assert!(base.diff(&styled).contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
4088        assert!(base.might_affect_layout(&styled));
4089        assert!(base.might_affect_visuals(&styled));
4090    }
4091
4092    #[test]
4093    fn autotest_fingerprint_attrs_change_flags_tab_index_and_contenteditable() {
4094        let base = NodeDataFingerprint::compute(&NodeData::create_div(), None);
4095        let editable =
4096            NodeDataFingerprint::compute(&NodeData::create_div().with_contenteditable(true), None);
4097
4098        let changes = base.diff(&editable);
4099        assert!(changes.contains(NodeChangeSet::TAB_INDEX));
4100        assert!(changes.contains(NodeChangeSet::CONTENTEDITABLE));
4101        assert!(
4102            base.might_affect_layout(&editable),
4103            "attrs_hash feeds might_affect_layout",
4104        );
4105        assert!(
4106            !base.might_affect_visuals(&editable),
4107            "attrs_hash is deliberately NOT part of might_affect_visuals",
4108        );
4109    }
4110
4111    #[test]
4112    fn autotest_fingerprint_agrees_with_compute_node_changes_on_unchanged_nodes() {
4113        // Tier 1 (fingerprint) must never claim "changed" where Tier 2
4114        // (compute_node_changes) says "unchanged" — that would defeat the whole
4115        // two-tier fast path.
4116        let state = StyledNodeState::default();
4117        let samples = vec![
4118            NodeData::create_div(),
4119            NodeData::create_text("hello 🌍"),
4120            class_node("row"),
4121            id_node("main"),
4122            NodeData::create_div().with_css("color: red"),
4123            NodeData::create_div().with_contenteditable(true),
4124            with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount),
4125        ];
4126
4127        for node in &samples {
4128            let clone = node.clone();
4129
4130            let fp_a = NodeDataFingerprint::compute(node, Some(&state));
4131            let fp_b = NodeDataFingerprint::compute(&clone, Some(&state));
4132            assert!(
4133                fp_a.is_identical(&fp_b),
4134                "a cloned node must fingerprint identically",
4135            );
4136            assert!(fp_a.diff(&fp_b).is_empty());
4137
4138            let tier2 = compute_node_changes(node, &clone, Some(&state), Some(&state));
4139            assert!(
4140                tier2.is_empty(),
4141                "compute_node_changes must agree that a clone is unchanged, got {:#b}",
4142                tier2.bits,
4143            );
4144        }
4145    }
4146}