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

            
15
use alloc::{collections::BTreeMap, collections::VecDeque, string::{String, ToString}, vec::Vec};
16
use core::hash::Hash;
17

            
18
use azul_css::props::property::{CssPropertyType, RelayoutScope};
19

            
20
use 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)]
41
pub struct NodeChangeSet {
42
    pub bits: u32,
43
}
44

            
45
impl 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
49985
    #[must_use] pub const fn empty() -> Self {
97
49985
        Self { bits: 0 }
98
49985
    }
99

            
100
414
    #[must_use] pub const fn is_empty(&self) -> bool {
101
414
        self.bits == 0
102
414
    }
103

            
104
1937
    #[must_use] pub const fn contains(&self, flag: u32) -> bool {
105
1937
        (self.bits & flag) == flag
106
1937
    }
107

            
108
97913
    #[must_use] pub const fn intersects(&self, mask: u32) -> bool {
109
97913
        (self.bits & mask) != 0
110
97913
    }
111

            
112
2678
    pub const fn insert(&mut self, flag: u32) {
113
2678
        self.bits |= flag;
114
2678
    }
115

            
116
    /// Returns true if no visual change occurred (only callbacks/dataset/a11y).
117
137
    #[must_use] pub const fn is_visually_unchanged(&self) -> bool {
118
137
        !self.intersects(Self::AFFECTS_LAYOUT) && !self.intersects(Self::AFFECTS_PAINT)
119
137
    }
120

            
121
    /// Returns true if layout is needed.
122
49203
    #[must_use] pub const fn needs_layout(&self) -> bool {
123
49203
        self.intersects(Self::AFFECTS_LAYOUT)
124
49203
    }
125

            
126
    /// Returns true if paint is needed (but not necessarily layout).
127
48435
    #[must_use] pub const fn needs_paint(&self) -> bool {
128
48435
        self.intersects(Self::AFFECTS_PAINT)
129
48435
    }
130
}
131

            
132
impl core::ops::BitOrAssign for NodeChangeSet {
133
161
    fn bitor_assign(&mut self, rhs: Self) {
134
161
        self.bits |= rhs.bits;
135
161
    }
136
}
137

            
138
impl core::ops::BitOr for NodeChangeSet {
139
    type Output = Self;
140
17
    fn bitor(self, rhs: Self) -> Self {
141
17
        Self { bits: self.bits | rhs.bits }
142
17
    }
143
}
144

            
145
/// Extended diff result that includes per-node change information.
146
#[derive(Debug, Clone)]
147
#[derive(Default)]
148
pub 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
1038
#[must_use] pub fn compute_node_changes(
162
1038
    old_node: &NodeData,
163
1038
    new_node: &NodeData,
164
1038
    old_styled_state: Option<&StyledNodeState>,
165
1038
    new_styled_state: Option<&StyledNodeState>,
166
1038
) -> NodeChangeSet {
167
1038
    let mut changes = NodeChangeSet::empty();
168

            
169
    // 1. Node type discriminant
170
1038
    if core::mem::discriminant(old_node.get_node_type())
171
1038
        != core::mem::discriminant(new_node.get_node_type())
172
    {
173
77
        changes.insert(NodeChangeSet::NODE_TYPE_CHANGED);
174
77
        return changes; // everything else is irrelevant
175
961
    }
176

            
177
    // 2. Content-specific comparison (same discriminant)
178
961
    match (old_node.get_node_type(), new_node.get_node_type()) {
179
499
        (NodeType::Text(old_text), NodeType::Text(new_text)) => {
180
499
            if old_text.as_str() != new_text.as_str() {
181
335
                changes.insert(NodeChangeSet::TEXT_CONTENT);
182
335
            }
183
        }
184
2
        (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
4
            let hash_img = |img: &crate::resources::ImageRef| -> u64 {
189
4
                let mut h = crate::hash::DefaultHasher::new();
190
4
                img.hash(&mut h);
191
4
                h.finish()
192
4
            };
193
2
            if hash_img(old_img) != hash_img(new_img) {
194
1
                changes.insert(NodeChangeSet::IMAGE_CHANGED);
195
1
            }
196
        }
197
460
        _ => {} // 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
961
        let old_ids_classes: Vec<_> = old_node.attributes().as_ref().iter()
204
961
            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
205
961
            .collect();
206
961
        let new_ids_classes: Vec<_> = new_node.attributes().as_ref().iter()
207
961
            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
208
961
            .collect();
209
961
        if old_ids_classes != new_ids_classes {
210
96
            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
211
865
        }
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
961
    if old_node.style != new_node.style {
219
118
        let mut has_layout = false;
220
118
        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
118
        fn mark(prop_type: CssPropertyType, has_layout: &mut bool, has_paint: &mut bool) {
225
118
            if prop_type.relayout_scope(true) == RelayoutScope::None {
226
40
                *has_paint = true;
227
78
            } else {
228
78
                *has_layout = true;
229
78
            }
230
118
        }
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
118
        let old_props: Vec<(CssPropertyType, _, _)> = old_node
240
118
            .style
241
118
            .iter_inline_properties()
242
118
            .map(|(prop, conds)| (prop.get_type(), prop, conds))
243
118
            .collect();
244
118
        let mut old_matched = vec![false; old_props.len()];
245

            
246
118
        for (prop, conds) in new_node.style.iter_inline_properties() {
247
117
            let prop_type = prop.get_type();
248
            // Find an as-yet-unmatched old entry with the same (type, conditions).
249
117
            let mut found_unchanged = false;
250
117
            for (i, (old_type, old_prop, old_conds)) in old_props.iter().enumerate() {
251
59
                if old_matched[i]
252
59
                    || *old_type != prop_type
253
59
                    || old_conds.as_slice() != conds.as_slice()
254
                {
255
                    continue;
256
59
                }
257
59
                old_matched[i] = true;
258
59
                if *old_prop == prop {
259
                    found_unchanged = true;
260
59
                }
261
59
                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
117
            if !found_unchanged {
266
117
                mark(prop_type, &mut has_layout, &mut has_paint);
267
117
            }
268
        }
269

            
270
        // Check for removed properties (old (type, conditions) slots never matched)
271
118
        for (i, (old_type, _, _)) in old_props.iter().enumerate() {
272
60
            if !old_matched[i] {
273
1
                mark(*old_type, &mut has_layout, &mut has_paint);
274
59
            }
275
        }
276

            
277
118
        if has_layout {
278
78
            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
279
78
        }
280
118
        if has_paint {
281
40
            changes.insert(NodeChangeSet::INLINE_STYLE_PAINT);
282
78
        }
283
843
    }
284

            
285
    // 5. Callbacks
286
    {
287
961
        let old_cbs = old_node.callbacks.as_ref();
288
961
        let new_cbs = new_node.callbacks.as_ref();
289
961
        if old_cbs.len() == new_cbs.len() {
290
960
            for (o, n) in old_cbs.iter().zip(new_cbs.iter()) {
291
3
                if o.event != n.event || o.callback != n.callback {
292
1
                    changes.insert(NodeChangeSet::CALLBACKS);
293
1
                    break;
294
2
                }
295
            }
296
1
        } else {
297
1
            changes.insert(NodeChangeSet::CALLBACKS);
298
1
        }
299
    }
300

            
301
    // 6. Dataset
302
961
    if old_node.get_dataset() != new_node.get_dataset() {
303
        changes.insert(NodeChangeSet::DATASET);
304
961
    }
305

            
306
    // 7. Contenteditable
307
961
    if old_node.is_contenteditable() != new_node.is_contenteditable() {
308
39
        changes.insert(NodeChangeSet::CONTENTEDITABLE);
309
922
    }
310

            
311
    // 8. Tab index
312
961
    if old_node.get_tab_index() != new_node.get_tab_index() {
313
20
        changes.insert(NodeChangeSet::TAB_INDEX);
314
941
    }
315

            
316
    // 9. Styled node state (hover, active, focused, etc.)
317
961
    if old_styled_state != new_styled_state {
318
40
        changes.insert(NodeChangeSet::STYLED_STATE);
319
921
    }
320

            
321
961
    changes
322
1038
}
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
53558
#[must_use] pub fn calculate_reconciliation_key(
341
53558
    node_data: &[NodeData],
342
53558
    hierarchy: &[NodeHierarchyItem],
343
53558
    node_id: NodeId,
344
53558
) -> u64 {
345
    use core::hash::Hasher;
346

            
347
53558
    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
2154261
    let terminal_key = |nid: NodeId| -> Option<u64> {
352
2154261
        let node = &node_data[nid.index()];
353
        // Priority 1: Explicit key
354
2154261
        if let Some(key) = node.get_key() {
355
342
            return Some(key);
356
2153919
        }
357
        // Priority 2: CSS ID
358
2153919
        for attr in node.attributes().as_ref() {
359
44118
            if let Some(id) = attr.as_id() {
360
39380
                let mut hasher = crate::hash::DefaultHasher::new();
361
39380
                id.hash(&mut hasher);
362
39380
                return Some(hasher.finish());
363
4738
            }
364
        }
365
2114539
        None
366
2154261
    };
367

            
368
    // Fast path: the node itself has an explicit key or CSS ID.
369
53558
    if let Some(key) = terminal_key(node_id) {
370
39136
        return key;
371
14422
    }
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
14422
    let mut chain: Vec<NodeId> = Vec::new();
387
14422
    let mut seed_parent_key: Option<u64> = None;
388
14422
    let mut cur = node_id;
389
14422
    for _ in 0..n {
390
2114538
        if cur.index() >= n {
391
            break;
392
2114538
        }
393
2114538
        chain.push(cur);
394
2114538
        match hierarchy.get(cur.index()).and_then(NodeHierarchyItem::parent_id) {
395
13835
            None => break,
396
2100703
            Some(parent) => {
397
2100703
                if let Some(k) = terminal_key(parent) {
398
586
                    seed_parent_key = Some(k);
399
586
                    break;
400
2100117
                }
401
2100117
                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
14422
    let mut parent_key: Option<u64> = seed_parent_key;
410
2114538
    for &nid in chain.iter().rev() {
411
2114538
        let node = &node_data[nid.index()];
412
2114538
        let mut hasher = crate::hash::DefaultHasher::new();
413

            
414
2114538
        core::mem::discriminant(node.get_node_type()).hash(&mut hasher);
415
2114538
        for attr in node.attributes().as_ref() {
416
4610
            if let Some(class) = attr.as_class() {
417
4610
                class.hash(&mut hasher);
418
4610
            }
419
        }
420

            
421
2100703
        if let Some(parent_id) =
422
2114538
            hierarchy.get(nid.index()).and_then(NodeHierarchyItem::parent_id)
423
        {
424
            // nth-of-type: count same-discriminant siblings before `nid`.
425
2100703
            let mut sibling_index: usize = 0;
426
2100703
            let mut current = hierarchy
427
2100703
                .get(parent_id.index())
428
2100703
                .and_then(|h| h.first_child_id(parent_id));
429
2102722
            while let Some(sibling_id) = current {
430
2102717
                if sibling_id == nid {
431
2100698
                    break;
432
2019
                }
433
2019
                let sibling = &node_data[sibling_id.index()];
434
2019
                if core::mem::discriminant(sibling.get_node_type())
435
2019
                    == core::mem::discriminant(node.get_node_type())
436
1064
                {
437
1064
                    sibling_index += 1;
438
1070
                }
439
2019
                current = hierarchy
440
2019
                    .get(sibling_id.index())
441
2019
                    .and_then(NodeHierarchyItem::next_sibling_id);
442
            }
443

            
444
2100703
            sibling_index.hash(&mut hasher);
445
2100703
            parent_key.unwrap_or(0).hash(&mut hasher);
446
13835
        }
447

            
448
2114538
        parent_key = Some(hasher.finish());
449
    }
450

            
451
14422
    parent_key.unwrap_or(0)
452
53558
}
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
2654
#[must_use] pub fn precompute_reconciliation_keys(
460
2654
    node_data: &[NodeData],
461
2654
    hierarchy: &[NodeHierarchyItem],
462
2654
) -> Vec<u64> {
463
2654
    (0..node_data.len())
464
53197
        .map(|idx| calculate_reconciliation_key(node_data, hierarchy, NodeId::new(idx)))
465
2654
        .collect()
466
2654
}
467

            
468
/// Represents a mapping between a node in the old DOM and the new DOM.
469
#[derive(Debug, Clone, Copy)]
470
pub 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)]
480
pub 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
1324
#[must_use] pub fn reconcile_dom(
516
1324
    old_node_data: &[NodeData],
517
1324
    new_node_data: &[NodeData],
518
1324
    old_hierarchy: &[NodeHierarchyItem],
519
1324
    new_hierarchy: &[NodeHierarchyItem],
520
1324
    old_layout: &OrderedMap<NodeId, LogicalRect>,
521
1324
    new_layout: &OrderedMap<NodeId, LogicalRect>,
522
1324
    dom_id: DomId,
523
1324
    timestamp: Instant,
524
1324
) -> DiffResult {
525
    // Helper: pop the first non-consumed NodeId from a queue.
526
22856
    fn pop_first_unconsumed(
527
22856
        queue: &mut VecDeque<NodeId>,
528
22856
        consumed: &[bool],
529
22856
    ) -> Option<NodeId> {
530
22856
        while let Some(&old_id) = queue.front() {
531
22827
            queue.pop_front();
532
22827
            if !consumed[old_id.index()] {
533
22827
                return Some(old_id);
534
            }
535
        }
536
29
        None
537
22856
    }
538

            
539
1324
    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
1324
    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
1324
    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
1324
    let old_parent_key = |old_id: NodeId| -> Option<u64> {
562
81
        old_hierarchy
563
81
            .get(old_id.index())
564
81
            .and_then(NodeHierarchyItem::parent_id)
565
81
            .map(|p| old_rec_keys[p.index()])
566
81
    };
567

            
568
1324
    let mut old_by_rec_key: OrderedMap<u64, VecDeque<NodeId>> = OrderedMap::default();
569
1324
    let mut old_hashed: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
570
1324
    let mut old_structural: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
571
1324
    let mut old_nodes_consumed = vec![false; old_node_data.len()];
572

            
573
25203
    for (idx, node) in old_node_data.iter().enumerate() {
574
25203
        let id = NodeId::new(idx);
575
25203
        old_by_rec_key.entry(old_rec_keys[idx]).or_default().push_back(id);
576
25203

            
577
25203
        let hash = node.calculate_node_data_hash();
578
25203
        old_hashed.entry(hash).or_default().push_back(id);
579
25203

            
580
25203
        let structural_hash = node.calculate_structural_hash();
581
25203
        old_structural.entry(structural_hash).or_default().push_back(id);
582
25203
    }
583

            
584
    // --- STEP 2: ITERATE NEW DOM AND CLAIM MATCHES ---
585

            
586
25983
    for (new_idx, new_node) in new_node_data.iter().enumerate() {
587
25983
        let new_id = NodeId::new(new_idx);
588
25983
        let mut matched_old_id = None;
589
25983
        let mut matched_by_rec_key = false;
590
25983
        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
25983
        let new_rec_key = new_rec_keys[new_idx];
594
25983
        if let Some(queue) = old_by_rec_key.get_mut(&new_rec_key) {
595
22856
            if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
596
22827
                matched_old_id = Some(old_id);
597
22827
                matched_by_rec_key = true;
598
22827
            }
599
3127
        }
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
25983
        let new_parent_key: Option<u64> = new_hierarchy
608
25983
            .get(new_idx)
609
25983
            .and_then(NodeHierarchyItem::parent_id)
610
25983
            .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
25983
        if !has_explicit_key && matched_old_id.is_none() {
617
            // Tier 2: Content hash (exact match — catches pure reorders)
618
3136
            let hash = new_node.calculate_node_data_hash();
619
3136
            if let Some(queue) = old_hashed.get_mut(&hash) {
620
48
                if let Some(pos) = queue.iter().position(|&old_id| {
621
48
                    !old_nodes_consumed[old_id.index()]
622
17
                        && old_parent_key(old_id) == new_parent_key
623
48
                }) {
624
                    matched_old_id = queue.remove(pos);
625
32
                }
626
3104
            }
627

            
628
            // Tier 3: Structural hash (text-node fallback — ignores text content)
629
3136
            if matched_old_id.is_none() {
630
3136
                let structural_hash = new_node.calculate_structural_hash();
631
3136
                if let Some(queue) = old_structural.get_mut(&structural_hash) {
632
141
                    if let Some(pos) = queue.iter().position(|&old_id| {
633
141
                        !old_nodes_consumed[old_id.index()]
634
64
                            && old_parent_key(old_id) == new_parent_key
635
141
                    }) {
636
                        matched_old_id = queue.remove(pos);
637
44
                    }
638
3092
                }
639
            }
640
22847
        }
641

            
642
        // --- STEP 3: PROCESS MATCH OR MOUNT ---
643

            
644
25983
        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
22827
            old_nodes_consumed[old_id.index()] = true;
648
22827
            result.node_moves.push(NodeMove {
649
22827
                old_node_id: old_id,
650
22827
                new_node_id: new_id,
651
22827
            });
652

            
653
            // Check for Resize
654
22827
            let old_rect = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
655
22827
            let new_rect = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
656

            
657
22827
            if old_rect.size != new_rect.size {
658
                // Fire Resize Event
659
22
                if has_resize_callback(new_node) {
660
22
                    result.events.push(create_lifecycle_event(
661
22
                        EventType::Resize,
662
22
                        new_id,
663
22
                        dom_id,
664
22
                        &timestamp,
665
22
                        LifecycleEventData {
666
22
                            reason: LifecycleReason::Resize,
667
22
                            previous_bounds: Some(old_rect),
668
22
                            current_bounds: new_rect,
669
22
                        },
670
22
                    ));
671
22
                }
672
22805
            }
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
22827
            if matched_by_rec_key {
680
22827
                let old_hash = old_node_data[old_id.index()].calculate_node_data_hash();
681
22827
                let new_hash = new_node.calculate_node_data_hash();
682

            
683
22827
                if old_hash != new_hash && has_update_callback(new_node) {
684
20
                    result.events.push(create_lifecycle_event(
685
20
                        EventType::Update,
686
20
                        new_id,
687
20
                        dom_id,
688
20
                        &timestamp,
689
20
                        LifecycleEventData {
690
20
                            reason: LifecycleReason::Update,
691
20
                            previous_bounds: Some(old_rect),
692
20
                            current_bounds: new_rect,
693
20
                        },
694
20
                    ));
695
22807
                }
696
            }
697
        } else {
698
            // NO MATCH FOUND -> MOUNT (New Node)
699
3156
            if has_mount_callback(new_node) {
700
50
                let bounds = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
701
50
                result.events.push(create_lifecycle_event(
702
50
                    EventType::Mount,
703
50
                    new_id,
704
50
                    dom_id,
705
50
                    &timestamp,
706
50
                    LifecycleEventData {
707
50
                        reason: LifecycleReason::InitialMount,
708
50
                        previous_bounds: None,
709
50
                        current_bounds: bounds,
710
50
                    },
711
50
                ));
712
3115
            }
713
        }
714
    }
715

            
716
    // --- STEP 4: CLEANUP (UNMOUNTS) ---
717
    // Any old node that wasn't claimed is effectively destroyed.
718

            
719
25203
    for (old_idx, consumed) in old_nodes_consumed.iter().enumerate() {
720
25203
        if !consumed {
721
2376
            let old_id = NodeId::new(old_idx);
722
2376
            let old_node = &old_node_data[old_idx];
723

            
724
2376
            if has_unmount_callback(old_node) {
725
50
                let bounds = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
726
50
                result.events.push(create_lifecycle_event(
727
50
                    EventType::Unmount,
728
50
                    old_id,
729
50
                    dom_id,
730
50
                    &timestamp,
731
50
                    LifecycleEventData {
732
50
                        reason: LifecycleReason::Unmount,
733
50
                        previous_bounds: Some(bounds),
734
50
                        current_bounds: LogicalRect::zero(),
735
50
                    },
736
50
                ));
737
2336
            }
738
22827
        }
739
    }
740

            
741
1324
    result
742
1324
}
743

            
744
/// Creates a lifecycle event with all necessary fields.
745
143
fn create_lifecycle_event(
746
143
    event_type: EventType,
747
143
    node_id: NodeId,
748
143
    dom_id: DomId,
749
143
    timestamp: &Instant,
750
143
    data: LifecycleEventData,
751
143
) -> SyntheticEvent {
752
143
    let dom_node_id = DomNodeId {
753
143
        dom: dom_id,
754
143
        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
755
143
    };
756
143
    SyntheticEvent {
757
143
        event_type,
758
143
        source: EventSource::Lifecycle,
759
143
        phase: EventPhase::Target,
760
143
        target: dom_node_id,
761
143
        current_target: dom_node_id,
762
143
        timestamp: timestamp.clone(),
763
143
        data: EventData::Lifecycle(data),
764
143
        stopped: false,
765
143
        stopped_immediate: false,
766
143
        prevented_default: false,
767
143
    }
768
143
}
769

            
770
/// Check if the node has an `AfterMount` callback registered.
771
3164
fn has_mount_callback(node: &NodeData) -> bool {
772
3164
    node.get_callbacks().iter().any(|cb| {
773
8
        matches!(
774
59
            cb.event,
775
            EventFilter::Component(ComponentEventFilter::AfterMount)
776
        )
777
59
    })
778
3164
}
779

            
780
/// Check if the node has a `BeforeUnmount` callback registered.
781
2383
fn has_unmount_callback(node: &NodeData) -> bool {
782
2383
    node.get_callbacks().iter().any(|cb| {
783
5
        matches!(
784
56
            cb.event,
785
            EventFilter::Component(ComponentEventFilter::BeforeUnmount)
786
        )
787
56
    })
788
2383
}
789

            
790
/// Check if the node has a `NodeResized` callback registered.
791
30
fn has_resize_callback(node: &NodeData) -> bool {
792
31
    node.get_callbacks().iter().any(|cb| {
793
7
        matches!(
794
31
            cb.event,
795
            EventFilter::Component(ComponentEventFilter::NodeResized)
796
        )
797
31
    })
798
30
}
799

            
800
/// Check if the node has any lifecycle callback that would respond to updates.
801
445
fn has_update_callback(node: &NodeData) -> bool {
802
445
    node.get_callbacks().iter().any(|cb| {
803
64
        matches!(
804
85
            cb.event,
805
            EventFilter::Component(ComponentEventFilter::Updated)
806
        )
807
85
    })
808
445
}
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
138
#[must_use] pub fn create_migration_map(node_moves: &[NodeMove]) -> OrderedMap<NodeId, NodeId> {
831
138
    let mut map = OrderedMap::default();
832
1357
    for m in node_moves {
833
1219
        map.insert(m.old_node_id, m.new_node_id);
834
1219
    }
835
138
    map
836
138
}
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
/// ```
860
275
pub fn transfer_states(
861
275
    old_node_data: &mut [NodeData],
862
275
    new_node_data: &mut [NodeData],
863
275
    node_moves: &[NodeMove],
864
275
) {
865
    use crate::refany::OptionRefAny;
866

            
867
751
    for movement in node_moves {
868
476
        let old_idx = movement.old_node_id.index();
869
476
        let new_idx = movement.new_node_id.index();
870

            
871
        // Bounds check
872
476
        if old_idx >= old_node_data.len() || new_idx >= new_node_data.len() {
873
22
            continue;
874
454
        }
875

            
876
        // 1. Check if the NEW node has requested a merge callback
877
454
        let Some(merge_callback) = new_node_data[new_idx].get_merge_callback() else {
878
281
            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
173
        let old_dataset = old_node_data[old_idx].take_dataset();
884
173
        let new_dataset = new_node_data[new_idx].take_dataset();
885

            
886
173
        match (new_dataset, old_dataset) {
887
134
            (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
134
                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
134
                let merged = (merge_callback.cb)(new_data, old_data);
905

            
906
                // 4. Store the merged result back in the new node
907
134
                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
211
                for nd in new_node_data.iter_mut() {
918
211
                    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
211
                    }
923
211
                    for cb in nd.callbacks.as_mut().iter_mut() {
924
2
                        if cb.refany.sharing_info.ptr as usize == orphan_alloc {
925
2
                            cb.refany = merged.clone();
926
2
                        }
927
                    }
928
211
                    let ds_is_orphan = nd
929
211
                        .get_dataset()
930
211
                        .is_some_and(|ds| ds.sharing_info.ptr as usize == orphan_alloc);
931
211
                    if ds_is_orphan {
932
114
                        nd.set_dataset(OptionRefAny::Some(merged.clone()));
933
116
                    }
934
                }
935
            }
936
39
            (new_ds, old_ds) => {
937
                // One or both datasets missing - restore what we had
938
39
                if let Some(ds) = new_ds {
939
20
                    new_node_data[new_idx].set_dataset(OptionRefAny::Some(ds));
940
20
                }
941
39
                if let Some(ds) = old_ds {
942
19
                    old_node_data[old_idx].set_dataset(OptionRefAny::Some(ds));
943
20
                }
944
            }
945
        }
946
    }
947
275
}
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
474
#[must_use] pub fn calculate_contenteditable_key(
968
474
    node_data: &[NodeData],
969
474
    hierarchy: &[NodeHierarchyItem],
970
474
    node_id: NodeId,
971
474
) -> u64 {
972
    use core::hash::Hasher;
973

            
974
474
    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
100907
    let terminal_key = |nid: NodeId| -> Option<u64> {
979
100907
        let node = &node_data[nid.index()];
980
        // Priority 1: Explicit key (from .with_key())
981
100907
        if let Some(explicit_key) = node.get_key() {
982
2
            return Some(explicit_key);
983
100905
        }
984
        // Priority 2: CSS ID
985
100905
        for attr in node.attributes().as_ref() {
986
268
            if let Some(id) = attr.as_id() {
987
2
                let mut hasher = crate::hash::DefaultHasher::new(); // Different seed for ID keys
988
2
                hasher.write(id.as_bytes());
989
2
                return Some(hasher.finish());
990
266
            }
991
        }
992
100903
        None
993
100907
    };
994

            
995
    // Fast path: the node itself has an explicit key or CSS ID.
996
474
    if let Some(key) = terminal_key(node_id) {
997
4
        return key;
998
470
    }
999

            
    // Priority 3: structural key, computed ITERATIVELY up the parent chain.
    //
    // AUDIT: replaces unbounded parent-chain recursion (stack overflow on deep
    // DOMs, infinite recursion on a cyclic hierarchy). Same fold as the old
    // recursion, unrolled bottom-up and bounded by the node count.
470
    let mut chain: Vec<NodeId> = Vec::new();
470
    let mut seed_parent_key: Option<u64> = None;
470
    let mut cur = node_id;
470
    for _ in 0..n {
100902
        if cur.index() >= n {
            break;
100902
        }
100902
        chain.push(cur);
100902
        match hierarchy.get(cur.index()).and_then(NodeHierarchyItem::parent_id) {
469
            None => break,
100433
            Some(parent) => {
100433
                if let Some(k) = terminal_key(parent) {
                    seed_parent_key = Some(k);
                    break;
100433
                }
100433
                cur = parent;
            }
        }
    }
    // Fold from the topmost ancestor down to `node_id`. Unlike the
    // reconciliation key, the contenteditable structural key ALWAYS writes a
    // `parent_key` (0 at the root) and an `nth_of_type` (0 at the root), so the
    // per-level hashing is unconditional — preserve that exactly.
470
    let mut parent_key: u64 = seed_parent_key.unwrap_or(0);
100902
    for &nid in chain.iter().rev() {
100902
        let node = &node_data[nid.index()];
100902
        let mut hasher = crate::hash::DefaultHasher::new(); // Different seed for structural keys
100902
        let node_parent = hierarchy.get(nid.index()).and_then(NodeHierarchyItem::parent_id);
        // parent_key: 0 at the root, else the accumulated key of the level above.
100902
        let level_parent_key = if node_parent.is_some() { parent_key } else { 0 };
100902
        hasher.write(&level_parent_key.to_le_bytes());
        // nth-of-type: count same-discriminant siblings before `nid`.
100902
        let node_discriminant = core::mem::discriminant(node.get_node_type());
100902
        let nth_of_type = node_parent.map_or(0u32, |parent_id| {
100433
            let mut count = 0u32;
100433
            let mut sibling_id = hierarchy
100433
                .get(parent_id.index())
100433
                .and_then(|h| h.first_child_id(parent_id));
100808
            while let Some(sib_id) = sibling_id {
100805
                if sib_id == nid {
100430
                    break;
375
                }
375
                let sibling_discriminant =
375
                    core::mem::discriminant(node_data[sib_id.index()].get_node_type());
375
                if sibling_discriminant == node_discriminant {
375
                    count += 1;
375
                }
375
                sibling_id = hierarchy
375
                    .get(sib_id.index())
375
                    .and_then(NodeHierarchyItem::next_sibling_id);
            }
100433
            count
100433
        });
100902
        hasher.write(&nth_of_type.to_le_bytes());
        // Hash the node type discriminant (Discriminant<T> implements Hash)
100902
        node_discriminant.hash(&mut hasher);
        // Also hash the classes for additional stability
100902
        for attr in node.attributes().as_ref() {
266
            if let Some(class) = attr.as_class() {
266
                hasher.write(class.as_bytes());
266
            }
        }
100902
        parent_key = hasher.finish();
    }
470
    parent_key
474
}
/// Reconcile cursor byte position when text content changes.
///
/// This function maps a cursor position from old text to new text, preserving
/// the cursor's logical position as much as possible:
///
/// 1. If cursor is in unchanged prefix → stays at same byte offset
/// 2. If cursor is in unchanged suffix → adjusts by length difference
/// 3. If cursor is in changed region → places at end of new content
///
/// # Arguments
/// * `old_text` - The previous text content
/// * `new_text` - The new text content
/// * `old_cursor_byte` - Cursor byte offset in old text
///
/// # Returns
/// The reconciled cursor byte offset in new text
///
/// # Example
/// ```rust,ignore
/// let old_text = "Hello";
/// let new_text = "Hello World";
/// let old_cursor = 5; // cursor at end of "Hello"
/// let new_cursor = reconcile_cursor_position(old_text, new_text, old_cursor);
/// assert_eq!(new_cursor, 5); // cursor stays at same position (prefix unchanged)
/// ```
2446
#[must_use] pub fn reconcile_cursor_position(
2446
    old_text: &str,
2446
    new_text: &str,
2446
    old_cursor_byte: usize,
2446
) -> usize {
    // AUDIT: every returned offset is snapped DOWN to the nearest UTF-8 char
    // boundary in `new_text` (and clamped to its length). The prefix/suffix
    // scans below compare byte-by-byte and can land mid-codepoint, so a raw
    // return value could later panic when used to slice `new_text` as a `str`.
2446
    let snap = |offset: usize| -> usize {
2114
        let mut o = offset.min(new_text.len());
2327
        while o > 0 && !new_text.is_char_boundary(o) {
213
            o -= 1;
213
        }
2114
        o
2114
    };
    // If texts are equal, cursor is unchanged
2446
    if old_text == new_text {
276
        return snap(old_cursor_byte);
2170
    }
    // Empty old text - place cursor at end of new text
2170
    if old_text.is_empty() {
84
        return new_text.len();
2086
    }
    // Empty new text - place cursor at 0
2086
    if new_text.is_empty() {
248
        return 0;
1838
    }
    // Find common prefix (how many bytes from the start are identical)
1838
    let common_prefix_bytes = old_text
1838
        .bytes()
1838
        .zip(new_text.bytes())
492627
        .take_while(|(a, b)| a == b)
1838
        .count();
    // If cursor was in the unchanged prefix, it stays at the same byte offset
1838
    if old_cursor_byte <= common_prefix_bytes {
262
        return snap(old_cursor_byte);
1576
    }
    // Find common suffix (how many bytes from the end are identical)
1576
    let common_suffix_bytes = old_text
1576
        .bytes()
1576
        .rev()
1576
        .zip(new_text.bytes().rev())
102093
        .take_while(|(a, b)| a == b)
1576
        .count();
    // Calculate where the suffix starts in old and new text
1576
    let old_suffix_start = old_text.len().saturating_sub(common_suffix_bytes);
1576
    let new_suffix_start = new_text.len().saturating_sub(common_suffix_bytes);
    // If cursor was in the unchanged suffix, adjust by length difference
1576
    if old_cursor_byte >= old_suffix_start {
        // saturating: an out-of-range cursor (> old_text.len()) must clamp to the
        // end of the new text like every other path here, not underflow-panic.
195
        let offset_from_end = old_text.len().saturating_sub(old_cursor_byte);
195
        return snap(new_text.len().saturating_sub(offset_from_end));
1381
    }
    // Cursor was in the changed region - place at end of inserted content
    // This handles insertions (cursor moves with new text) and deletions (cursor at edit point)
1381
    snap(new_suffix_start)
2446
}
/// Get the text content from a `NodeData` if it's a Text node.
///
/// Returns the text string if the node is `NodeType::Text`, otherwise `None`.
303
#[must_use] pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
303
    if let NodeType::Text(ref text) = node.get_node_type() {
280
        Some(text.as_str())
    } else {
23
        None
    }
303
}
// ============================================================================
// ChangeAccumulator — unifies all change input paths
// ============================================================================
/// Text change info for cursor/selection reconciliation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextChange {
    /// The text content before the change.
    pub old_text: String,
    /// The text content after the change.
    pub new_text: String,
}
/// Per-node change report combining multiple information sources.
#[derive(Debug, Clone, Default)]
pub struct NodeChangeReport {
    /// Bitflags from DOM-level field comparison.
    pub change_set: NodeChangeSet,
    /// Highest `RelayoutScope` from any CSS property that changed on this node.
    /// This is more granular than `NodeChangeSet`'s binary LAYOUT/PAINT split.
    ///
    /// - `None` → repaint only (color, opacity, transform)
    /// - `IfcOnly` → reshape text in the containing IFC
    /// - `SizingOnly` → recompute this node's intrinsic size
    /// - `Full` → full subtree relayout (display, position, float, etc.)
    pub relayout_scope: RelayoutScope,
    /// Individual CSS properties that changed (for fine-grained cache invalidation).
    /// Empty if the change was structural (text content, node type, etc.)
    pub changed_css_properties: Vec<CssPropertyType>,
    /// If text content changed, the old and new text for cursor reconciliation.
    pub text_change: Option<TextChange>,
}
impl NodeChangeReport {
    /// Returns the `DirtyFlag` level needed for this change report.
    /// Maps `RelayoutScope` + `NodeChangeSet` → a simple tri-state.
176
    #[must_use] pub fn needs_layout(&self) -> bool {
176
        self.change_set.needs_layout() || self.relayout_scope > RelayoutScope::None
176
    }
80
    #[must_use] pub const fn needs_paint(&self) -> bool {
80
        self.change_set.needs_paint()
80
    }
61
    #[must_use] pub fn is_visually_unchanged(&self) -> bool {
61
        self.change_set.is_visually_unchanged() && self.relayout_scope == RelayoutScope::None
61
    }
}
/// Unified change report that merges information from all three change paths:
///
/// 1. **DOM reconciliation** (`compute_node_changes` after `reconcile_dom`)
/// 2. **CSS restyle** (`restyle_on_state_change` for hover/focus/active)
/// 3. **Runtime edits** (`words_changed`, `css_properties_changed`, `images_changed`)
///
/// This is the single source of truth for "what work needs to happen this frame".
#[derive(Debug, Clone, Default)]
pub struct ChangeAccumulator {
    /// Per-node change info. Key is the new-DOM `NodeId`.
    pub per_node: BTreeMap<NodeId, NodeChangeReport>,
    /// Maximum `RelayoutScope` across all changed nodes.
    /// Quick check: if this is `None`, we can skip layout entirely.
    pub max_scope: RelayoutScope,
    /// Nodes that are newly mounted (no old counterpart).
    /// These always need full layout.
    pub mounted_nodes: Vec<NodeId>,
    /// Nodes that were unmounted (no new counterpart).
    /// Used for cleanup (remove from scroll/focus/cursor managers).
    pub unmounted_nodes: Vec<NodeId>,
}
impl ChangeAccumulator {
69
    #[must_use] pub fn new() -> Self {
69
        Self::default()
69
    }
    /// Returns true if no changes were detected at all.
16
    #[must_use] pub fn is_empty(&self) -> bool {
16
        self.per_node.is_empty() && self.mounted_nodes.is_empty() && self.unmounted_nodes.is_empty()
16
    }
    /// Returns true if layout work is needed (any node has scope > None).
365
    #[must_use] pub fn needs_layout(&self) -> bool {
365
        self.max_scope > RelayoutScope::None
236
            || !self.mounted_nodes.is_empty()
119
            || self.per_node.values().any(NodeChangeReport::needs_layout)
365
    }
    /// Returns true if only paint work is needed (no layout).
80
    #[must_use] pub fn needs_paint_only(&self) -> bool {
80
        !self.needs_layout() && self.per_node.values().any(NodeChangeReport::needs_paint)
80
    }
    /// Returns true if only non-visual changes occurred (callbacks, dataset, a11y).
140
    #[must_use] pub fn is_visually_unchanged(&self) -> bool {
140
        self.mounted_nodes.is_empty()
119
            && self.unmounted_nodes.is_empty()
99
            && self.max_scope == RelayoutScope::None
79
            && self.per_node.values().all(NodeChangeReport::is_visually_unchanged)
140
    }
    /// Add a node change from DOM reconciliation (Path A).
156
    pub fn add_dom_change(
156
        &mut self,
156
        new_node_id: NodeId,
156
        change_set: NodeChangeSet,
156
        relayout_scope: RelayoutScope,
156
        text_change: Option<TextChange>,
156
        changed_css_properties: Vec<CssPropertyType>,
156
    ) {
156
        if relayout_scope > self.max_scope {
117
            self.max_scope = relayout_scope;
117
        }
156
        let report = self.per_node.entry(new_node_id).or_default();
156
        report.change_set |= change_set;
156
        if relayout_scope > report.relayout_scope {
136
            report.relayout_scope = relayout_scope;
136
        }
156
        if text_change.is_some() {
136
            report.text_change = text_change;
136
        }
156
        report.changed_css_properties.extend(changed_css_properties);
156
    }
    /// Add a text change (from runtime edit or DOM reconciliation).
200
    pub fn add_text_change(
200
        &mut self,
200
        node_id: NodeId,
200
        old_text: String,
200
        new_text: String,
200
    ) {
200
        let scope = RelayoutScope::IfcOnly;
200
        if scope > self.max_scope {
200
            self.max_scope = scope;
200
        }
200
        let report = self.per_node.entry(node_id).or_default();
200
        report.change_set.insert(NodeChangeSet::TEXT_CONTENT);
200
        if scope > report.relayout_scope {
200
            report.relayout_scope = scope;
200
        }
200
        report.text_change = Some(TextChange { old_text, new_text });
200
    }
    /// Add a CSS property change (from runtime edit or restyle).
424
    pub fn add_css_change(
424
        &mut self,
424
        node_id: NodeId,
424
        prop_type: CssPropertyType,
424
        scope: RelayoutScope,
424
    ) {
424
        if scope > self.max_scope {
250
            self.max_scope = scope;
250
        }
424
        let report = self.per_node.entry(node_id).or_default();
424
        if scope > RelayoutScope::None {
270
            report.change_set.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
270
        } else {
154
            report.change_set.insert(NodeChangeSet::INLINE_STYLE_PAINT);
154
        }
424
        if scope > report.relayout_scope {
251
            report.relayout_scope = scope;
251
        }
424
        report.changed_css_properties.push(prop_type);
424
    }
    /// Add an image change (from runtime edit or DOM reconciliation).
58
    pub fn add_image_change(
58
        &mut self,
58
        node_id: NodeId,
58
        scope: RelayoutScope,
58
    ) {
58
        if scope > self.max_scope {
39
            self.max_scope = scope;
39
        }
58
        let report = self.per_node.entry(node_id).or_default();
58
        report.change_set.insert(NodeChangeSet::IMAGE_CHANGED);
58
        if scope > report.relayout_scope {
58
            report.relayout_scope = scope;
58
        }
58
    }
    /// Add a mounted (new) node.
232
    pub fn add_mount(&mut self, node_id: NodeId) {
232
        self.mounted_nodes.push(node_id);
232
    }
    /// Add an unmounted (removed) node.
193
    pub fn add_unmount(&mut self, node_id: NodeId) {
193
        self.unmounted_nodes.push(node_id);
193
    }
    /// Merge a `RestyleResult` (from `restyle_on_state_change()`) into this accumulator.
    ///
    /// This is the bridge between Path B (restyle) and the unified change pipeline.
    /// Each `ChangedCssProperty` is classified via `relayout_scope()` to determine
    /// whether it affects layout or only paint.
2
    pub fn merge_restyle_result(&mut self, restyle: &crate::styled_dom::RestyleResult) {
3
        for (node_id, changed_props) in &restyle.changed_nodes {
2
            for changed in changed_props {
1
                let prop_type = changed.current_prop.get_type();
1
                let scope = prop_type.relayout_scope(true); // conservative
1
                self.add_css_change(*node_id, prop_type, scope);
1
            }
        }
2
    }
    /// Populate this accumulator from an `ExtendedDiffResult` + the old/new DOM data.
    ///
    /// This converts per-node `NodeChangeSet` flags into full `NodeChangeReport`s
    /// with `RelayoutScope` classification.
233
    pub fn merge_extended_diff(
233
        &mut self,
233
        extended: &ExtendedDiffResult,
233
        old_node_data: &[NodeData],
233
        new_node_data: &[NodeData],
233
    ) {
502
        for &(old_id, new_id, ref change_set) in &extended.node_changes {
269
            if change_set.is_empty() {
153
                continue;
116
            }
            // Determine RelayoutScope from the change flags
116
            let scope = Self::classify_change_scope(*change_set, new_node_data, new_id);
            // Extract text change info if TEXT_CONTENT flag is set
116
            let text_change = if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
116
                let old_text = get_node_text_content(&old_node_data[old_id.index()])
116
                    .unwrap_or("")
116
                    .to_string();
116
                let new_text = get_node_text_content(&new_node_data[new_id.index()])
116
                    .unwrap_or("")
116
                    .to_string();
116
                Some(TextChange { old_text, new_text })
            } else {
                None
            };
116
            self.add_dom_change(new_id, *change_set, scope, text_change, Vec::new());
        }
        // Track mounts: new nodes that didn't match anything in old
233
        let matched_new: alloc::collections::BTreeSet<usize> = extended
233
            .diff
233
            .node_moves
233
            .iter()
271
            .map(|m| m.new_node_id.index())
233
            .collect();
405
        for idx in 0..new_node_data.len() {
405
            if !matched_new.contains(&idx) {
136
                self.add_mount(NodeId::new(idx));
269
            }
        }
        // Track unmounts: old nodes that didn't match anything in new
233
        let matched_old: alloc::collections::BTreeSet<usize> = extended
233
            .diff
233
            .node_moves
233
            .iter()
271
            .map(|m| m.old_node_id.index())
233
            .collect();
404
        for idx in 0..old_node_data.len() {
404
            if !matched_old.contains(&idx) {
135
                self.add_unmount(NodeId::new(idx));
269
            }
        }
233
    }
    /// Classify a `NodeChangeSet` into the appropriate `RelayoutScope`.
133
    fn classify_change_scope(
133
        change_set: NodeChangeSet,
133
        new_node_data: &[NodeData],
133
        new_node_id: NodeId,
133
    ) -> RelayoutScope {
        // NODE_TYPE_CHANGED or CHILDREN_CHANGED → Full
133
        if change_set.contains(NodeChangeSet::NODE_TYPE_CHANGED)
131
            || change_set.contains(NodeChangeSet::CHILDREN_CHANGED)
        {
3
            return RelayoutScope::Full;
130
        }
        // IDS_AND_CLASSES → Full (conservative: class change may add layout-affecting CSS)
130
        if change_set.contains(NodeChangeSet::IDS_AND_CLASSES) {
1
            return RelayoutScope::Full;
129
        }
        // INLINE_STYLE_LAYOUT → could be IfcOnly, SizingOnly, or Full
        // We need to check individual properties for the exact scope.
        // For now, we use SizingOnly as a conservative default since
        // the individual property scopes were already checked in compute_node_changes.
129
        if change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT) {
            // Walk the inline CSS properties to find the max scope
3
            let new_node = &new_node_data[new_node_id.index()];
3
            let mut max_scope = RelayoutScope::None;
3
            for (prop, _conds) in new_node.style.iter_inline_properties() {
2
                let scope = prop.get_type().relayout_scope(true);
2
                if scope > max_scope {
2
                    max_scope = scope;
2
                }
            }
3
            return if max_scope == RelayoutScope::None {
1
                RelayoutScope::SizingOnly // conservative fallback
            } else {
2
                max_scope
            };
126
        }
        // TEXT_CONTENT → IfcOnly (reshape text, may cascade)
126
        if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
118
            return RelayoutScope::IfcOnly;
8
        }
        // IMAGE_CHANGED → SizingOnly (intrinsic size may change)
8
        if change_set.contains(NodeChangeSet::IMAGE_CHANGED) {
1
            return RelayoutScope::SizingOnly;
7
        }
        // CONTENTEDITABLE → SizingOnly
7
        if change_set.contains(NodeChangeSet::CONTENTEDITABLE) {
1
            return RelayoutScope::SizingOnly;
6
        }
        // Paint-only or no-visual changes
6
        if change_set.intersects(NodeChangeSet::AFFECTS_PAINT) {
2
            return RelayoutScope::None;
4
        }
4
        RelayoutScope::None
133
    }
}
/// Perform a full reconciliation with change detection.
///
/// This combines `reconcile_dom()` + `compute_node_changes()` into a single
/// pass that produces an `ExtendedDiffResult` with per-node change flags.
///
/// The `ChangeAccumulator` can then be populated from the result via
/// `accumulator.merge_extended_diff()`.
384
#[must_use] pub fn reconcile_dom_with_changes(
384
    old_node_data: &[NodeData],
384
    new_node_data: &[NodeData],
384
    old_hierarchy: &[NodeHierarchyItem],
384
    new_hierarchy: &[NodeHierarchyItem],
384
    old_styled_nodes: Option<&[StyledNodeState]>,
384
    new_styled_nodes: Option<&[StyledNodeState]>,
384
    old_layout: &OrderedMap<NodeId, LogicalRect>,
384
    new_layout: &OrderedMap<NodeId, LogicalRect>,
384
    dom_id: DomId,
384
    timestamp: Instant,
384
) -> ExtendedDiffResult {
    // Step 1: Run standard reconciliation
384
    let diff = reconcile_dom(
384
        old_node_data,
384
        new_node_data,
384
        old_hierarchy,
384
        new_hierarchy,
384
        old_layout,
384
        new_layout,
384
        dom_id,
384
        timestamp,
    );
    // Step 2: For each matched pair, compute what changed
384
    let mut node_changes = Vec::new();
825
    for node_move in &diff.node_moves {
441
        let old_nd = &old_node_data[node_move.old_node_id.index()];
441
        let new_nd = &new_node_data[node_move.new_node_id.index()];
441
        let old_state = old_styled_nodes.and_then(|s| s.get(node_move.old_node_id.index()));
441
        let new_state = new_styled_nodes.and_then(|s| s.get(node_move.new_node_id.index()));
441
        let changes = compute_node_changes(old_nd, new_nd, old_state, new_state);
441
        node_changes.push((node_move.old_node_id, node_move.new_node_id, changes));
    }
384
    ExtendedDiffResult { diff, node_changes }
384
}
// ============================================================================
// NodeDataFingerprint — multi-field hash for fast change detection
// ============================================================================
/// Per-node hash broken into independent fields for fast change detection.
///
/// Instead of a single u64 hash (which loses all granularity), this stores
/// separate hashes per field category. Comparing two fingerprints is O(1)
/// (6 integer comparisons) and immediately tells us WHICH category changed,
/// avoiding the more expensive `compute_node_changes()` for unchanged nodes.
///
/// Two-tier strategy:
/// - **Tier 1** (this struct): O(1) per node, identifies which categories changed.
/// - **Tier 2** (`compute_node_changes`): O(n) per changed field, does field-by-field
///   comparison only for nodes that Tier 1 identified as changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Default)]
pub struct NodeDataFingerprint {
    /// Hash of `node_type` (Text content, Image ref, Div, etc.)
    pub content_hash: u64,
    /// Hash of `styled_node_state` (hover, focus, active bits)
    pub state_hash: u64,
    /// Hash of inline CSS properties
    pub inline_css_hash: u64,
    /// Hash of `ids_and_classes`
    pub ids_classes_hash: u64,
    /// Hash of callbacks (event types + function pointers)
    pub callbacks_hash: u64,
    /// Hash of other attributes (contenteditable, `tab_index`, dataset)
    pub attrs_hash: u64,
}
impl NodeDataFingerprint {
    /// Compute a fingerprint from a node's data and styled state.
479884
    #[must_use] pub fn compute(node: &NodeData, styled_state: Option<&StyledNodeState>) -> Self {
        use core::hash::Hasher;
        use core::hash::Hash;
        // Content hash
479884
        let content_hash = {
479884
            let mut h = crate::hash::DefaultHasher::new();
479884
            node.get_node_type().hash(&mut h);
479884
            h.finish()
        };
        // State hash
479884
        let state_hash = {
479884
            let mut h = crate::hash::DefaultHasher::new();
479884
            if let Some(state) = styled_state {
478942
                state.hash(&mut h);
479702
            }
479884
            h.finish()
        };
        // Inline CSS hash — full CssProperty value (matches the legacy
        // CssPropertyWithConditions::hash that hashed both property and the
        // condition vec length).
479884
        let inline_css_hash = {
479884
            let mut h = crate::hash::DefaultHasher::new();
2737007
            for (prop, conds) in node.style.iter_inline_properties() {
2734467
                prop.hash(&mut h);
2734467
                conds.as_slice().len().hash(&mut h);
2734467
            }
479884
            h.finish()
        };
        // IDs and classes hash (now stored in attributes)
479884
        let ids_classes_hash = {
479884
            let mut h = crate::hash::DefaultHasher::new();
479884
            for attr in node.attributes().as_ref() {
212908
                match attr {
1294
                    crate::dom::AttributeType::Id(s) => {
1294
                        crate::dom::IdOrClass::Id(s.clone()).hash(&mut h);
1294
                    }
211614
                    crate::dom::AttributeType::Class(s) => {
211614
                        crate::dom::IdOrClass::Class(s.clone()).hash(&mut h);
211614
                    }
                    _ => {}
                }
            }
479884
            h.finish()
        };
        // Callbacks hash
479884
        let callbacks_hash = {
479884
            let mut h = crate::hash::DefaultHasher::new();
479884
            for cb in node.callbacks.as_ref() {
80677
                cb.event.hash(&mut h);
80677
                cb.callback.hash(&mut h);
80677
            }
479884
            h.finish()
        };
        // Attributes hash
479884
        let attrs_hash = {
479884
            let mut h = crate::hash::DefaultHasher::new();
479884
            node.is_contenteditable().hash(&mut h);
479884
            node.flags.hash(&mut h);
479884
            node.get_dataset().hash(&mut h);
479884
            h.finish()
        };
479884
        Self {
479884
            content_hash,
479884
            state_hash,
479884
            inline_css_hash,
479884
            ids_classes_hash,
479884
            callbacks_hash,
479884
            attrs_hash,
479884
        }
479884
    }
    /// Returns a quick `NodeChangeSet` by comparing two fingerprints.
    /// This is O(1) — just comparing 6 u64s.
    ///
    /// The result is *conservative*: if a field hash differs, we set the
    /// broadest applicable flag. For precise classification (e.g., which
    /// CSS properties changed and their `relayout_scope()`), the caller
    /// should fall back to `compute_node_changes()` for changed nodes.
44337
    #[must_use] pub const fn diff(&self, other: &Self) -> NodeChangeSet {
44337
        let mut changes = NodeChangeSet::empty();
44337
        if self.content_hash != other.content_hash {
414
            // Could be TEXT_CONTENT, IMAGE_CHANGED, or NODE_TYPE_CHANGED
414
            // We set both TEXT_CONTENT and IMAGE_CHANGED conservatively;
414
            // compute_node_changes() will refine this.
414
            changes.insert(NodeChangeSet::TEXT_CONTENT);
414
            changes.insert(NodeChangeSet::IMAGE_CHANGED);
43923
        }
44337
        if self.state_hash != other.state_hash {
9
            changes.insert(NodeChangeSet::STYLED_STATE);
44328
        }
44337
        if self.inline_css_hash != other.inline_css_hash {
6
            // Conservative: inline CSS could affect layout or paint.
6
            // compute_node_changes() checks relayout_scope() per property.
6
            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
44331
        }
44337
        if self.ids_classes_hash != other.ids_classes_hash {
20
            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
44317
        }
44337
        if self.callbacks_hash != other.callbacks_hash {
1
            changes.insert(NodeChangeSet::CALLBACKS);
44336
        }
44337
        if self.attrs_hash != other.attrs_hash {
22
            changes.insert(NodeChangeSet::TAB_INDEX);
22
            changes.insert(NodeChangeSet::CONTENTEDITABLE);
44315
        }
44337
        changes
44337
    }
    /// Returns true if the fingerprint is identical (no changes at all).
135
    #[must_use] pub fn is_identical(&self, other: &Self) -> bool {
135
        self == other
135
    }
    /// Quick check: could this change affect layout?
13
    #[must_use] pub const fn might_affect_layout(&self, other: &Self) -> bool {
13
        self.content_hash != other.content_hash
9
            || self.inline_css_hash != other.inline_css_hash
8
            || self.ids_classes_hash != other.ids_classes_hash
6
            || self.attrs_hash != other.attrs_hash
13
    }
    /// Quick check: could this change affect visuals at all?
11
    #[must_use] pub const fn might_affect_visuals(&self, other: &Self) -> bool {
11
        self.content_hash != other.content_hash
8
            || self.state_hash != other.state_hash
6
            || self.inline_css_hash != other.inline_css_hash
5
            || self.ids_classes_hash != other.ids_classes_hash
11
    }
}
#[cfg(test)]
mod audit_tests {
    use super::*;
    use crate::dom::NodeData;
    use crate::styled_dom::NodeHierarchyItem;
    // Build a NodeHierarchyItem from optional 0-based indices (encoded 1-based).
102007
    fn hitem(
102007
        parent: Option<usize>,
102007
        prev: Option<usize>,
102007
        next: Option<usize>,
102007
        last_child: Option<usize>,
102007
    ) -> NodeHierarchyItem {
        NodeHierarchyItem {
102007
            parent: parent.map_or(0, |p| p + 1),
102007
            previous_sibling: prev.map_or(0, |p| p + 1),
102007
            next_sibling: next.map_or(0, |p| p + 1),
102007
            last_child: last_child.map_or(0, |p| p + 1),
        }
102007
    }
    // A deep parent chain that would overflow the stack with the old recursion.
    #[test]
1
    fn reconciliation_key_deep_chain_no_overflow() {
2
        let build = |n: usize| -> (Vec<NodeData>, Vec<NodeHierarchyItem>) {
102000
            let node_data = (0..n).map(|_| NodeData::create_div()).collect();
2
            let hierarchy = (0..n)
102000
                .map(|i| {
102000
                    hitem(
102000
                        if i == 0 { None } else { Some(i - 1) },
102000
                        None,
102000
                        None,
102000
                        if i + 1 < n { Some(i + 1) } else { None },
                    )
102000
                })
2
                .collect();
2
            (node_data, hierarchy)
2
        };
        // A very deep linear chain: the OLD recursion overflowed the stack here.
        // A single-node key walk is O(depth) and must complete without recursing.
1
        let n = 100_000usize;
1
        let (node_data, hierarchy) = build(n);
1
        let _ = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(n - 1));
1
        let _ = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(n - 1));
        // Whole-DOM precompute calls the per-node walk once per node, so over a
        // *linear* chain it is O(n²) — that only bites a pathological 100k-deep
        // DOM (never a real tree). Exercise the whole-DOM path over a modest
        // chain; correctness is covered by `reconciliation_key_single_node` and
        // `reconciliation_key_distinguishes_siblings`.
1
        let m = 2_000usize;
1
        let (nd, hi) = build(m);
1
        let keys = precompute_reconciliation_keys(&nd, &hi);
1
        assert_eq!(keys.len(), m);
1
    }
    // A cyclic (corrupt) hierarchy must terminate, not hang.
    #[test]
1
    fn reconciliation_key_cycle_terminates() {
1
        let node_data = vec![
1
            NodeData::create_div(),
1
            NodeData::create_div(),
1
            NodeData::create_div(),
        ];
        // node1.parent = 2, node2.parent = 1 — a cycle not involving root 0.
1
        let hierarchy = vec![
1
            hitem(None, None, None, None),
1
            hitem(Some(2), None, None, None),
1
            hitem(Some(1), None, None, None),
        ];
1
        let _ = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(1));
1
        let _ = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(1));
1
    }
    #[test]
1
    fn reconciliation_key_single_node() {
1
        let node_data = vec![NodeData::create_div()];
1
        let hierarchy = vec![hitem(None, None, None, None)];
1
        let direct = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(0));
1
        let pre = precompute_reconciliation_keys(&node_data, &hierarchy)[0];
1
        assert_eq!(direct, pre);
1
    }
    #[test]
1
    fn reconciliation_key_distinguishes_siblings() {
        // root 0 with two div children 1 and 2 — nth-of-type must differ.
1
        let node_data = vec![NodeData::create_div(); 3];
1
        let hierarchy = vec![
1
            hitem(None, None, None, Some(2)),    // root: first_child=1, last_child=2
1
            hitem(Some(0), None, Some(2), None), // child 1
1
            hitem(Some(0), Some(1), None, None), // child 2
        ];
1
        let k1 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(1));
1
        let k2 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(2));
1
        assert_ne!(k1, k2);
1
    }
    #[test]
1
    fn cursor_offsets_are_always_char_boundaries() {
        // "héllo": h=0, é=1..3 (2 bytes), l=3, l=4, o=5 (len 6).
1
        let old = "héllo";
1
        let new = "héllo wörld"; // ö is multibyte too
7
        for c in 0..=old.len() {
7
            let r = reconcile_cursor_position(old, new, c);
7
            assert!(
7
                new.is_char_boundary(r),
                "cursor {c} mapped to non-char-boundary offset {r} in {new:?}",
            );
7
            assert!(r <= new.len());
        }
        // Deletion inside a multibyte suffix must not split a codepoint.
1
        let r = reconcile_cursor_position("aömega", "bömega", 3);
1
        assert!("bömega".is_char_boundary(r));
1
    }
    #[test]
1
    fn cursor_prefix_unchanged_stays_put() {
1
        assert_eq!(reconcile_cursor_position("Hello", "Hello World", 5), 5);
1
    }
    #[test]
1
    fn cursor_empty_cases() {
1
        assert_eq!(reconcile_cursor_position("", "abc", 0), 3);
1
        assert_eq!(reconcile_cursor_position("abc", "", 2), 0);
1
        assert_eq!(reconcile_cursor_position("abc", "abc", 2), 2);
1
    }
}
// ============================================================================
// Autotest: adversarial unit tests
// ============================================================================
//
// Generated against the autotest task spec for `core/src/diff.rs`. Strategy per
// category:
//
//   * numeric      -> 0 / MIN / MAX / overflow / NaN / saturation
//   * "parser"-ish -> malformed, huge, boundary and unicode text input
//                     (`reconcile_cursor_position` is the byte-offset parser here)
//   * round-trip   -> precompute == per-node compute, fingerprint == recompute,
//                     BitOr == BitOrAssign
//   * getters /    -> invariants hold on default, empty and extreme instances
//     predicates
//
// The module is inline (not `core/tests/`) because `has_*_callback`,
// `create_lifecycle_event` and `ChangeAccumulator::classify_change_scope` are
// private to this module.
#[cfg(test)]
mod autotest_generated {
    use super::*;
    use azul_css::{
        css::CssPropertyValue,
        props::{layout::LayoutWidth, property::CssProperty},
    };
    use crate::{
        callbacks::CoreCallback,
        dom::{DatasetMergeCallbackType, TabIndex},
        geom::{LogicalPosition, LogicalSize},
        refany::{OptionRefAny, RefAny},
        resources::{ImageRef, RawImageFormat},
    };
    // ---------------------------------------------------------------- helpers
    // `CoreCallback::cb` is a raw `usize` fn-pointer slot. `reconcile_dom` only
    // ever inspects `CoreCallbackData::event`, never calls through the pointer,
    // so `0` is a safe sentinel (same convention as
    // `core/tests/reconciliation/deep_reconciliation.rs`).
    fn noop_callback() -> CoreCallback {
        CoreCallback {
            cb: 0usize,
            ctx: OptionRefAny::None,
        }
    }
    fn with_cb(mut nd: NodeData, filter: ComponentEventFilter) -> NodeData {
        nd.add_callback(
            EventFilter::Component(filter),
            RefAny::new(0u32),
            noop_callback(),
        );
        nd
    }
    // Build a NodeHierarchyItem from optional 0-based indices (encoded 1-based).
    fn hitem(
        parent: Option<usize>,
        prev: Option<usize>,
        next: Option<usize>,
        last_child: Option<usize>,
    ) -> NodeHierarchyItem {
        NodeHierarchyItem {
            parent: parent.map_or(0, |p| p + 1),
            previous_sibling: prev.map_or(0, |p| p + 1),
            next_sibling: next.map_or(0, |p| p + 1),
            last_child: last_child.map_or(0, |p| p + 1),
        }
    }
    fn rect(w: f32, h: f32) -> LogicalRect {
        LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(w, h))
    }
    fn layout_of(entries: &[(usize, LogicalRect)]) -> OrderedMap<NodeId, LogicalRect> {
        let mut m = OrderedMap::default();
        for (idx, r) in entries {
            m.insert(NodeId::new(*idx), *r);
        }
        m
    }
    fn no_layout() -> OrderedMap<NodeId, LogicalRect> {
        OrderedMap::default()
    }
    // Flat diff: empty hierarchies exercise the documented "degrade gracefully"
    // path of the structural reconciliation key.
    fn diff_flat(old: &[NodeData], new: &[NodeData]) -> DiffResult {
        reconcile_dom(
            old,
            new,
            &[],
            &[],
            &no_layout(),
            &no_layout(),
            DomId::ROOT_ID,
            Instant::now(),
        )
    }
    fn count_events(r: &DiffResult, t: EventType) -> usize {
        r.events.iter().filter(|e| e.event_type == t).count()
    }
    fn id_node(id: &str) -> NodeData {
        NodeData::create_div().with_ids_and_classes(vec![IdOrClass::Id(id.into())].into())
    }
    fn class_node(class: &str) -> NodeData {
        NodeData::create_div().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
    }
    // A representative unicode torture corpus: multi-byte, combining marks, RTL,
    // ZWJ emoji sequences, CJK, and a lone BOM.
    const UNICODE_SAMPLES: &[&str] = &[
        "",
        "a",
        "héllo",
        "e\u{0301}galite\u{0301}",   // combining acute accents
        "مرحبا بالعالم",             // RTL Arabic
        "👨‍👩‍👧‍👦 family",           // ZWJ emoji sequence
        "日本語のテキスト",
        "\u{feff}bom-prefixed",
        "🇩🇪🇫🇷🇯🇵",                 // regional indicator pairs
        "mixed 漢字 and ascii ✅",
    ];
    // ========================================================================
    // NodeChangeSet — constructor / predicates / numeric bit ops
    // ========================================================================
    #[test]
    fn autotest_changeset_empty_is_a_neutral_element() {
        let e = NodeChangeSet::empty();
        assert_eq!(e.bits, 0);
        assert!(e.is_empty());
        assert!(!e.needs_layout());
        assert!(!e.needs_paint());
        assert!(e.is_visually_unchanged());
        // `Default` must agree with `empty()`.
        assert_eq!(NodeChangeSet::default(), e);
        // Neutral under BitOr in both directions.
        let mut some = NodeChangeSet::empty();
        some.insert(NodeChangeSet::TEXT_CONTENT);
        assert_eq!((some | e).bits, some.bits);
        assert_eq!((e | some).bits, some.bits);
    }
    #[test]
    fn autotest_changeset_contains_zero_is_vacuously_true() {
        // `contains` is an ALL-bits test: `(bits & 0) == 0` holds for every
        // value, including the empty set. Pin the semantics so a future rewrite
        // to `(bits & flag) != 0` (an ANY-bits test) is caught.
        assert!(NodeChangeSet::empty().contains(0));
        let mut s = NodeChangeSet::empty();
        s.insert(NodeChangeSet::CALLBACKS);
        assert!(s.contains(0));
        assert!(NodeChangeSet { bits: u32::MAX }.contains(0));
    }
    #[test]
    fn autotest_changeset_intersects_zero_is_always_false() {
        // `intersects` is an ANY-bits test: masking with 0 can never be non-zero.
        assert!(!NodeChangeSet::empty().intersects(0));
        assert!(!NodeChangeSet { bits: u32::MAX }.intersects(0));
    }
    #[test]
    fn autotest_changeset_contains_is_all_bits_intersects_is_any_bits() {
        let mut s = NodeChangeSet::empty();
        s.insert(NodeChangeSet::TEXT_CONTENT);
        let both = NodeChangeSet::TEXT_CONTENT | NodeChangeSet::IMAGE_CHANGED;
        assert!(!s.contains(both), "contains() must require ALL bits");
        assert!(s.intersects(both), "intersects() must require ANY bit");
        assert!(s.contains(NodeChangeSet::TEXT_CONTENT));
    }
    #[test]
    fn autotest_changeset_insert_min_max_and_idempotent() {
        // MIN (0) is a no-op.
        let mut s = NodeChangeSet::empty();
        s.insert(0);
        assert!(s.is_empty());
        // MAX must not panic and must saturate to "all bits set".
        let mut s = NodeChangeSet::empty();
        s.insert(u32::MAX);
        assert_eq!(s.bits, u32::MAX);
        // Inserting again is idempotent (OR, not ADD -> cannot overflow).
        s.insert(u32::MAX);
        assert_eq!(s.bits, u32::MAX);
        s.insert(NodeChangeSet::TEXT_CONTENT);
        assert_eq!(s.bits, u32::MAX);
        // With every bit set, all defined flags are present.
        assert!(s.contains(NodeChangeSet::NODE_TYPE_CHANGED));
        assert!(s.contains(NodeChangeSet::AFFECTS_LAYOUT));
        assert!(s.contains(NodeChangeSet::AFFECTS_PAINT));
        assert!(s.needs_layout());
        assert!(s.needs_paint());
        assert!(!s.is_visually_unchanged());
        assert!(!s.is_empty());
    }
    #[test]
    fn autotest_changeset_undefined_high_bits_trigger_no_work() {
        // Bits outside every defined flag must not be interpreted as layout or
        // paint work — but the set is still non-empty.
        let s = NodeChangeSet {
            bits: 0b1000_0000_0000_0000_0000_0000_0000_0000,
        };
        assert!(!s.is_empty());
        assert!(!s.needs_layout());
        assert!(!s.needs_paint());
        assert!(s.is_visually_unchanged());
    }
    #[test]
    fn autotest_changeset_layout_and_paint_masks_are_disjoint() {
        // A single flag must never mean "relayout AND repaint" — the two
        // composite masks partition the visual flags.
        assert_eq!(
            NodeChangeSet::AFFECTS_LAYOUT & NodeChangeSet::AFFECTS_PAINT,
            0,
            "AFFECTS_LAYOUT and AFFECTS_PAINT must not overlap",
        );
        for flag in [
            NodeChangeSet::NODE_TYPE_CHANGED,
            NodeChangeSet::TEXT_CONTENT,
            NodeChangeSet::IDS_AND_CLASSES,
            NodeChangeSet::INLINE_STYLE_LAYOUT,
            NodeChangeSet::CHILDREN_CHANGED,
            NodeChangeSet::IMAGE_CHANGED,
            NodeChangeSet::CONTENTEDITABLE,
            NodeChangeSet::INLINE_STYLE_PAINT,
            NodeChangeSet::STYLED_STATE,
            NodeChangeSet::CALLBACKS,
            NodeChangeSet::DATASET,
            NodeChangeSet::ACCESSIBILITY,
        ] {
            let mut s = NodeChangeSet::empty();
            s.insert(flag);
            assert!(!(s.needs_layout() && s.needs_paint()), "flag {flag:#b} is both");
            // `is_visually_unchanged` is exactly "neither layout nor paint".
            assert_eq!(
                s.is_visually_unchanged(),
                !s.needs_layout() && !s.needs_paint(),
                "is_visually_unchanged() disagrees with needs_layout/needs_paint for {flag:#b}",
            );
        }
    }
    #[test]
    fn autotest_changeset_nonvisual_flags_are_visually_unchanged() {
        let mut s = NodeChangeSet::empty();
        s.insert(NodeChangeSet::CALLBACKS);
        s.insert(NodeChangeSet::DATASET);
        s.insert(NodeChangeSet::ACCESSIBILITY);
        s.insert(NodeChangeSet::TAB_INDEX); // TAB_INDEX is in neither mask
        assert!(!s.is_empty());
        assert!(s.is_visually_unchanged());
        assert!(!s.needs_layout());
        assert!(!s.needs_paint());
    }
    #[test]
    fn autotest_changeset_bitor_matches_bitorassign() {
        // Round-trip: the two operators must agree, and BitOr must be
        // commutative + idempotent for arbitrary (including undefined) bits.
        for (a, b) in [
            (0u32, 0u32),
            (0, u32::MAX),
            (u32::MAX, u32::MAX),
            (NodeChangeSet::TEXT_CONTENT, NodeChangeSet::STYLED_STATE),
            (0xDEAD_BEEF, 0x0BAD_F00D),
        ] {
            let (sa, sb) = (NodeChangeSet { bits: a }, NodeChangeSet { bits: b });
            let by_operator = sa | sb;
            assert_eq!(by_operator.bits, a | b);
            let mut by_assign = sa;
            by_assign |= sb;
            assert_eq!(by_assign, by_operator);
            assert_eq!((sb | sa).bits, by_operator.bits, "BitOr must commute");
            assert_eq!((by_operator | by_operator).bits, by_operator.bits);
        }
    }
    // ========================================================================
    // NodeChangeReport — getters / predicates
    // ========================================================================
    #[test]
    fn autotest_change_report_default_is_inert() {
        let r = NodeChangeReport::default();
        assert!(!r.needs_layout());
        assert!(!r.needs_paint());
        assert!(r.is_visually_unchanged());
        assert_eq!(r.relayout_scope, RelayoutScope::None);
        assert!(r.changed_css_properties.is_empty());
        assert!(r.text_change.is_none());
    }
    #[test]
    fn autotest_change_report_scope_alone_forces_layout() {
        // An empty change_set with a non-None scope must still request layout:
        // `needs_layout()` ORs the two sources.
        let r = NodeChangeReport { relayout_scope: RelayoutScope::IfcOnly, ..Default::default() };
        assert!(r.needs_layout());
        assert!(!r.needs_paint());
        assert!(!r.is_visually_unchanged());
    }
    #[test]
    fn autotest_change_report_paint_flag_does_not_force_layout() {
        let mut r = NodeChangeReport::default();
        r.change_set.insert(NodeChangeSet::STYLED_STATE);
        assert!(!r.needs_layout());
        assert!(r.needs_paint());
        assert!(!r.is_visually_unchanged());
    }
    // ========================================================================
    // reconcile_cursor_position — the byte-offset "parser": unicode + boundary
    // ========================================================================
    #[test]
    fn autotest_cursor_result_is_always_a_valid_slice_index() {
        // The core safety invariant: whatever comes back must be <= new.len()
        // AND land on a char boundary, or a later `&new_text[..cursor]` panics.
        // Sweep every in-range cursor over every pair of the unicode corpus.
        for old in UNICODE_SAMPLES {
            for new in UNICODE_SAMPLES {
                for cursor in 0..=old.len() {
                    let r = reconcile_cursor_position(old, new, cursor);
                    assert!(
                        r <= new.len(),
                        "cursor {cursor} in {old:?} -> {r} exceeds len of {new:?}",
                    );
                    assert!(
                        new.is_char_boundary(r),
                        "cursor {cursor} in {old:?} -> {r} splits a codepoint in {new:?}",
                    );
                    // Must be usable as a real slice index.
                    let _ = &new[..r];
                }
            }
        }
    }
    #[test]
    fn autotest_cursor_is_deterministic() {
        // Same inputs must always give the same answer (no hashing / iteration
        // order leaking into the result).
        for old in UNICODE_SAMPLES {
            for new in UNICODE_SAMPLES {
                let a = reconcile_cursor_position(old, new, old.len() / 2);
                let b = reconcile_cursor_position(old, new, old.len() / 2);
                assert_eq!(a, b);
            }
        }
    }
    #[test]
    fn autotest_cursor_zero_stays_zero_when_texts_differ_at_byte_zero() {
        // cursor 0 <= common_prefix (0) -> snap(0) == 0.
        assert_eq!(reconcile_cursor_position("abc", "xyz", 0), 0);
        assert_eq!(reconcile_cursor_position("日本", "中国", 0), 0);
    }
    #[test]
    fn autotest_cursor_identical_text_clamps_to_len() {
        // Equal texts short-circuit to `snap(cursor)`, which clamps to len and
        // snaps down to a char boundary — so even an absurd cursor is safe here.
        assert_eq!(reconcile_cursor_position("abc", "abc", usize::MAX), 3);
        assert_eq!(reconcile_cursor_position("héllo", "héllo", usize::MAX), 6);
        // Snapping down: byte 2 is mid-'é' (bytes 1..3) -> snaps to 1.
        assert_eq!(reconcile_cursor_position("héllo", "héllo", 2), 1);
    }
    #[test]
    fn autotest_cursor_empty_sides_are_documented_constants() {
        // Empty old  -> end of new. Empty new -> 0. Both empty -> 0 (equal-text path).
        for new in UNICODE_SAMPLES {
            assert_eq!(reconcile_cursor_position("", new, 0), new.len());
            assert_eq!(reconcile_cursor_position("", new, usize::MAX), new.len());
        }
        for old in UNICODE_SAMPLES {
            if old.is_empty() {
                continue; // equal-text path, covered above
            }
            assert_eq!(reconcile_cursor_position(old, "", 0), 0);
            assert_eq!(reconcile_cursor_position(old, "", old.len()), 0);
        }
    }
    #[test]
    fn autotest_cursor_appended_text_keeps_prefix_cursor() {
        // Pure append: any cursor inside the common prefix is untouched.
        let old = "Hello";
        let new = "Hello, World";
        for cursor in 0..=old.len() {
            assert_eq!(reconcile_cursor_position(old, new, cursor), cursor);
        }
    }
    #[test]
    fn autotest_cursor_deleted_tail_clamps_into_new_text() {
        // Pure truncation: a cursor past the end of the new text must land at
        // the new end, never beyond it.
        let old = "Hello, World";
        let new = "Hello";
        assert_eq!(reconcile_cursor_position(old, new, old.len()), new.len());
        assert_eq!(reconcile_cursor_position(old, new, 5), 5);
    }
    #[test]
    fn autotest_cursor_multibyte_insert_before_cursor_shifts_by_suffix_rule() {
        // Insert a 2-byte 'ö' at the front; a cursor sitting in the (unchanged)
        // suffix must keep its distance from the END of the string.
        let old = "mega";
        let new = "ömega";
        let r = reconcile_cursor_position(old, new, 4); // end of old
        assert_eq!(r, new.len());
        assert!(new.is_char_boundary(r));
    }
    #[test]
    fn autotest_cursor_huge_inputs_do_not_hang_or_panic() {
        // 200k-byte strings: the prefix/suffix scans are linear, so this must
        // complete quickly and stay in-bounds.
        let old: String = std::iter::repeat_n('a', 200_000).collect();
        let mut new = old.clone();
        new.push_str("tail");
        let r = reconcile_cursor_position(&old, &new, old.len());
        assert!(r <= new.len());
        assert!(new.is_char_boundary(r));
        // Huge multibyte string: every returned offset must still be a boundary.
        let old_u: String = std::iter::repeat_n('é', 50_000).collect();
        let new_u: String = std::iter::repeat_n('é', 49_999).collect();
        let r = reconcile_cursor_position(&old_u, &new_u, old_u.len());
        assert!(r <= new_u.len());
        assert!(new_u.is_char_boundary(r));
    }
    // Regression test for a former underflow: `reconcile_cursor_position` used
    // to compute `old_text.len() - old_cursor_byte` unchecked, which panicked
    // (debug) / wrapped (release) when the caller passed a cursor byte offset
    // PAST the end of `old_text`. Reaching it needs: old != new, both non-empty,
    // cursor > common_prefix and cursor >= old_suffix_start — e.g.
    // ("abc", "abd", usize::MAX). The fix uses `saturating_sub` so an
    // out-of-range cursor clamps to the end of the new text like every other
    // path here.
    #[test]
    fn autotest_cursor_out_of_range_cursor_must_saturate_not_underflow() {
        // Expected: clamp to the end of the new text, exactly like every other path.
        assert_eq!(reconcile_cursor_position("abc", "abd", usize::MAX), 3);
        assert_eq!(reconcile_cursor_position("abc", "abd", 99), 3);
        assert_eq!(reconcile_cursor_position("héllo", "héllx", usize::MAX), 6);
    }
    // ========================================================================
    // get_node_text_content — round-trip
    // ========================================================================
    #[test]
    fn autotest_text_content_round_trips_unicode() {
        for s in UNICODE_SAMPLES {
            let node = NodeData::create_text_do_not_use_without_block_level_wrapper(*s);
            assert_eq!(
                get_node_text_content(&node),
                Some(*s),
                "create_text -> get_node_text_content must round-trip {s:?}",
            );
        }
    }
    #[test]
    fn autotest_text_content_is_none_for_non_text_nodes() {
        assert_eq!(get_node_text_content(&NodeData::create_div()), None);
        assert_eq!(get_node_text_content(&NodeData::create_body()), None);
        assert_eq!(get_node_text_content(&NodeData::create_br()), None);
        let img = NodeData::create_image(ImageRef::null_image(
            1,
            1,
            RawImageFormat::RGBA8,
            Vec::new(),
        ));
        assert_eq!(get_node_text_content(&img), None);
    }
    // ========================================================================
    // has_*_callback predicates
    // ========================================================================
    #[test]
    fn autotest_callback_predicates_all_false_without_callbacks() {
        let n = NodeData::create_div();
        assert!(!has_mount_callback(&n));
        assert!(!has_unmount_callback(&n));
        assert!(!has_resize_callback(&n));
        assert!(!has_update_callback(&n));
    }
    #[test]
    fn autotest_callback_predicates_are_mutually_exclusive_per_filter() {
        // Each predicate must recognise exactly its own ComponentEventFilter.
        let cases = [
            (ComponentEventFilter::AfterMount, [true, false, false, false]),
            (ComponentEventFilter::BeforeUnmount, [false, true, false, false]),
            (ComponentEventFilter::NodeResized, [false, false, true, false]),
            (ComponentEventFilter::Updated, [false, false, false, true]),
            // A Component filter that none of the four predicates handle.
            (ComponentEventFilter::Selected, [false, false, false, false]),
            (ComponentEventFilter::DefaultAction, [false, false, false, false]),
        ];
        for (filter, expected) in cases {
            let n = with_cb(NodeData::create_div(), filter);
            let got = [
                has_mount_callback(&n),
                has_unmount_callback(&n),
                has_resize_callback(&n),
                has_update_callback(&n),
            ];
            assert_eq!(got, expected, "predicate mismatch for {filter:?}");
        }
    }
    #[test]
    fn autotest_callback_predicates_find_target_among_many() {
        // The target callback is last of several — `any()` must still find it.
        let mut n = NodeData::create_div();
        for f in [
            ComponentEventFilter::Selected,
            ComponentEventFilter::DefaultAction,
            ComponentEventFilter::NodeResized,
        ] {
            n = with_cb(n, f);
        }
        assert!(has_resize_callback(&n));
        assert!(!has_mount_callback(&n));
    }
    // ========================================================================
    // create_lifecycle_event (private)
    // ========================================================================
    #[test]
    fn autotest_lifecycle_event_fields_are_wired_consistently() {
        let ts = Instant::now();
        let ev = create_lifecycle_event(
            EventType::Mount,
            NodeId::new(1_000_000),
            DomId::ROOT_ID,
            &ts,
            LifecycleEventData {
                reason: LifecycleReason::InitialMount,
                previous_bounds: None,
                current_bounds: rect(1.0, 2.0),
            },
        );
        assert_eq!(ev.event_type, EventType::Mount);
        assert_eq!(ev.source, EventSource::Lifecycle);
        assert_eq!(ev.phase, EventPhase::Target);
        // A lifecycle event is delivered at its target, so the two must agree.
        assert_eq!(ev.target, ev.current_target);
        assert_eq!(
            ev.target.node.into_crate_internal(),
            Some(NodeId::new(1_000_000)),
            "NodeId must survive the 1-based NodeHierarchyItemId encoding",
        );
        assert!(!ev.stopped);
        assert!(!ev.stopped_immediate);
        assert!(!ev.prevented_default);
        let EventData::Lifecycle(data) = &ev.data else {
            panic!("expected EventData::Lifecycle, got {:?}", ev.data);
        };
        assert!(data.previous_bounds.is_none());
        assert_eq!(data.current_bounds, rect(1.0, 2.0));
    }
    // ========================================================================
    // compute_node_changes
    // ========================================================================
    #[test]
    fn autotest_compute_changes_identical_nodes_report_nothing() {
        let a = NodeData::create_text_do_not_use_without_block_level_wrapper("same");
        let b = NodeData::create_text_do_not_use_without_block_level_wrapper("same");
        let changes = compute_node_changes(&a, &b, None, None);
        assert!(
            changes.is_empty(),
            "identical nodes must produce no change flags, got {:#b}",
            changes.bits,
        );
        assert!(changes.is_visually_unchanged());
    }
    #[test]
    fn autotest_compute_changes_node_type_change_short_circuits_everything() {
        // The documented early-return: when the discriminant changes, NOTHING
        // else is inspected — even though these two nodes ALSO differ in
        // classes, callbacks, inline CSS, tab index and contenteditable, and
        // sit in different styled states.
        let old = NodeData::create_div();
        let new = with_cb(
            NodeData::create_text_do_not_use_without_block_level_wrapper("now a text node")
                .with_ids_and_classes(vec![IdOrClass::Class("brand-new".into())].into())
                .with_css("width: 10px")
                .with_tab_index(TabIndex::NoKeyboardFocus)
                .with_contenteditable(true),
            ComponentEventFilter::AfterMount,
        );
        let hovered = StyledNodeState {
            hover: true,
            ..StyledNodeState::default()
        };
        let changes = compute_node_changes(
            &old,
            &new,
            Some(&StyledNodeState::default()),
            Some(&hovered),
        );
        assert_eq!(
            changes.bits,
            NodeChangeSet::NODE_TYPE_CHANGED,
            "a node-type change must be reported alone (early return)",
        );
    }
    #[test]
    fn autotest_compute_changes_text_content_unicode() {
        for (i, s) in UNICODE_SAMPLES.iter().enumerate() {
            let old = NodeData::create_text_do_not_use_without_block_level_wrapper(*s);
            // Same text -> no TEXT_CONTENT flag.
            let same = NodeData::create_text_do_not_use_without_block_level_wrapper(*s);
            assert!(
                !compute_node_changes(&old, &same, None, None)
                    .contains(NodeChangeSet::TEXT_CONTENT),
                "identical text {s:?} must not report TEXT_CONTENT",
            );
            // Different text -> TEXT_CONTENT flag.
            let other = UNICODE_SAMPLES[(i + 1) % UNICODE_SAMPLES.len()];
            if other == *s {
                continue;
            }
            let changed = NodeData::create_text_do_not_use_without_block_level_wrapper(other);
            assert!(
                compute_node_changes(&old, &changed, None, None)
                    .contains(NodeChangeSet::TEXT_CONTENT),
                "{s:?} -> {other:?} must report TEXT_CONTENT",
            );
        }
    }
    #[test]
    fn autotest_compute_changes_paint_only_css_never_sets_layout() {
        // `color` is RelayoutScope::None -> paint bucket only.
        let old = NodeData::create_div().with_css("color: red");
        let new = NodeData::create_div().with_css("color: blue");
        let changes = compute_node_changes(&old, &new, None, None);
        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_PAINT));
        assert!(
            !changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT),
            "a paint-only property must not request relayout",
        );
        assert!(changes.needs_paint());
        assert!(!changes.needs_layout());
    }
    #[test]
    fn autotest_compute_changes_sizing_css_sets_layout_not_paint() {
        // `width` is RelayoutScope::SizingOnly -> layout bucket.
        let old = NodeData::create_div().with_css("width: 10px");
        let new = NodeData::create_div().with_css("width: 20px");
        let changes = compute_node_changes(&old, &new, None, None);
        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
        assert!(!changes.contains(NodeChangeSet::INLINE_STYLE_PAINT));
        assert!(changes.needs_layout());
    }
    #[test]
    fn autotest_compute_changes_detects_removed_property() {
        // Regression guard for the AUDIT note at diff.rs:270 — a property that
        // exists only on the OLD node (i.e. was removed) must still be marked.
        let old = NodeData::create_div().with_css("color: red");
        let new = NodeData::create_div();
        let changes = compute_node_changes(&old, &new, None, None);
        assert!(
            changes.contains(NodeChangeSet::INLINE_STYLE_PAINT),
            "removing an inline property must be reported, got {:#b}",
            changes.bits,
        );
    }
    #[test]
    fn autotest_compute_changes_detects_added_property() {
        let old = NodeData::create_div();
        let new = NodeData::create_div().with_css("width: 5px");
        let changes = compute_node_changes(&old, &new, None, None);
        assert!(changes.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
    }
    #[test]
    fn autotest_compute_changes_ids_and_classes() {
        let changes = compute_node_changes(&class_node("a"), &class_node("b"), None, None);
        assert!(changes.contains(NodeChangeSet::IDS_AND_CLASSES));
        // Same classes -> no flag.
        let changes = compute_node_changes(&class_node("a"), &class_node("a"), None, None);
        assert!(!changes.contains(NodeChangeSet::IDS_AND_CLASSES));
    }
    #[test]
    fn autotest_compute_changes_styled_state() {
        let n = NodeData::create_div();
        let calm = StyledNodeState::default();
        let hovered = StyledNodeState {
            hover: true,
            ..StyledNodeState::default()
        };
        let changes = compute_node_changes(&n, &n, Some(&calm), Some(&hovered));
        assert!(changes.contains(NodeChangeSet::STYLED_STATE));
        assert!(changes.needs_paint());
        assert!(!changes.needs_layout());
        // Same state -> no flag; and None/None -> no flag.
        assert!(!compute_node_changes(&n, &n, Some(&calm), Some(&calm))
            .contains(NodeChangeSet::STYLED_STATE));
        assert!(!compute_node_changes(&n, &n, None, None).contains(NodeChangeSet::STYLED_STATE));
        // None vs Some(default) are *different* inputs and must be reported.
        assert!(compute_node_changes(&n, &n, None, Some(&calm))
            .contains(NodeChangeSet::STYLED_STATE));
    }
    #[test]
    fn autotest_compute_changes_tab_index_and_contenteditable() {
        let plain = NodeData::create_div();
        let editable = NodeData::create_div().with_contenteditable(true);
        let changes = compute_node_changes(&plain, &editable, None, None);
        assert!(changes.contains(NodeChangeSet::CONTENTEDITABLE));
        assert!(changes.needs_layout(), "CONTENTEDITABLE is in AFFECTS_LAYOUT");
        let tabbed = NodeData::create_div().with_tab_index(TabIndex::OverrideInParent(3));
        let changes = compute_node_changes(&plain, &tabbed, None, None);
        assert!(changes.contains(NodeChangeSet::TAB_INDEX));
        // TAB_INDEX is in neither composite mask -> no visual work.
        assert!(changes.is_visually_unchanged());
    }
    #[test]
    fn autotest_compute_changes_callbacks_count_and_identity() {
        let plain = NodeData::create_div();
        let one = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
        // Different callback counts.
        let changes = compute_node_changes(&plain, &one, None, None);
        assert!(changes.contains(NodeChangeSet::CALLBACKS));
        assert!(changes.is_visually_unchanged(), "callbacks are not a visual change");
        // Same count, different event filter.
        let other = with_cb(NodeData::create_div(), ComponentEventFilter::BeforeUnmount);
        let changes = compute_node_changes(&one, &other, None, None);
        assert!(changes.contains(NodeChangeSet::CALLBACKS));
        // Same count, same filter -> no flag (cb pointer 0 == 0).
        let same = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
        let changes = compute_node_changes(&one, &same, None, None);
        assert!(!changes.contains(NodeChangeSet::CALLBACKS));
    }
    #[test]
    fn autotest_compute_changes_image_identity_is_by_image_id() {
        // `ImageRef` hashes its process-unique `id`: shallow clones share it,
        // every fresh `null_image()` gets a new one.
        let img = ImageRef::null_image(4, 4, RawImageFormat::RGBA8, Vec::new());
        let same = NodeData::create_image(img.clone());
        let also_same = NodeData::create_image(img.clone());
        assert!(
            !compute_node_changes(&same, &also_same, None, None)
                .contains(NodeChangeSet::IMAGE_CHANGED),
            "two nodes holding clones of the SAME ImageRef must not report a change",
        );
        // A distinct allocation, even with identical pixels/dimensions, is a
        // different image as far as reconciliation is concerned.
        let other = NodeData::create_image(ImageRef::null_image(
            4,
            4,
            RawImageFormat::RGBA8,
            Vec::new(),
        ));
        let changes = compute_node_changes(&same, &other, None, None);
        assert!(changes.contains(NodeChangeSet::IMAGE_CHANGED));
        assert!(changes.needs_layout(), "IMAGE_CHANGED is in AFFECTS_LAYOUT");
    }
    // ========================================================================
    // calculate_reconciliation_key / precompute_reconciliation_keys
    // ========================================================================
    #[test]
    fn autotest_rec_key_empty_node_data_is_safe() {
        assert!(precompute_reconciliation_keys(&[], &[]).is_empty());
    }
    #[test]
    fn autotest_rec_key_precompute_matches_per_node_calculation() {
        // Round-trip: the O(1)-lookup table must agree with the direct call for
        // every node — the whole point of precomputing.
        let node_data = vec![
            NodeData::create_div(),
            class_node("row"),
            NodeData::create_text_do_not_use_without_block_level_wrapper("leaf"),
            id_node("footer"),
        ];
        let hierarchy = vec![
            hitem(None, None, None, Some(3)),
            hitem(Some(0), None, Some(2), None),
            hitem(Some(0), Some(1), Some(3), None),
            hitem(Some(0), Some(2), None, None),
        ];
        let keys = precompute_reconciliation_keys(&node_data, &hierarchy);
        assert_eq!(keys.len(), node_data.len());
        for (i, k) in keys.iter().enumerate() {
            assert_eq!(
                *k,
                calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(i)),
                "precomputed key for node {i} disagrees with the direct call",
            );
        }
    }
    #[test]
    fn autotest_rec_key_explicit_key_beats_css_id_and_node_type() {
        // Priority 1 is absolute: it ignores the CSS ID, the classes, the node
        // type and the position in the tree.
        let bare = NodeData::create_div().with_key(7u32);
        let decorated = NodeData::create_text_do_not_use_without_block_level_wrapper("totally different")
            .with_key(7u32)
            .with_ids_and_classes(
                vec![IdOrClass::Id("hero".into()), IdOrClass::Class("x".into())].into(),
            );
        let a = calculate_reconciliation_key(&[bare], &[], NodeId::new(0));
        let b = calculate_reconciliation_key(&[decorated], &[], NodeId::new(0));
        assert_eq!(a, b, "an explicit .with_key() must dominate every other input");
    }
    #[test]
    fn autotest_rec_key_css_id_used_when_no_explicit_key() {
        let same_a = calculate_reconciliation_key(&[id_node("hero")], &[], NodeId::new(0));
        let same_b = calculate_reconciliation_key(&[id_node("hero")], &[], NodeId::new(0));
        let other = calculate_reconciliation_key(&[id_node("footer")], &[], NodeId::new(0));
        assert_eq!(same_a, same_b, "the CSS-ID key must be stable");
        assert_ne!(same_a, other, "different CSS IDs must produce different keys");
    }
    #[test]
    fn autotest_rec_key_classes_participate_in_the_structural_key() {
        let a = calculate_reconciliation_key(&[class_node("alpha")], &[], NodeId::new(0));
        let b = calculate_reconciliation_key(&[class_node("beta")], &[], NodeId::new(0));
        assert_ne!(a, b, "classes must feed the structural key");
    }
    #[test]
    fn autotest_rec_key_node_type_participates_in_the_structural_key() {
        let div = calculate_reconciliation_key(&[NodeData::create_div()], &[], NodeId::new(0));
        let txt =
            calculate_reconciliation_key(&[NodeData::create_text_do_not_use_without_block_level_wrapper("x")], &[], NodeId::new(0));
        assert_ne!(div, txt, "the node-type discriminant must feed the structural key");
    }
    #[test]
    fn autotest_rec_key_hierarchy_shorter_than_node_data_is_safe() {
        // A truncated / absent hierarchy must degrade to the documented
        // "discriminant + classes" key instead of panicking.
        let node_data = vec![NodeData::create_div(), class_node("a"), id_node("b")];
        let with_none = precompute_reconciliation_keys(&node_data, &[]);
        let with_short = precompute_reconciliation_keys(&node_data, &[hitem(None, None, None, None)]);
        assert_eq!(with_none.len(), 3);
        assert_eq!(with_short.len(), 3);
        // Node 0 is a root either way, so both spellings must agree on it.
        assert_eq!(with_none[0], with_short[0]);
    }
    #[test]
    fn autotest_rec_key_identical_leaves_under_different_parents_differ() {
        // The parent chain must be folded in, otherwise keyless nodes under
        // unrelated parents would collide and migrate state across subtrees.
        //
        //   0 root
        //   ├── 1 (#left)   ── 3 div
        //   └── 2 (#right)  ── 4 div
        let node_data = vec![
            NodeData::create_div(),
            id_node("left"),
            id_node("right"),
            NodeData::create_div(),
            NodeData::create_div(),
        ];
        let hierarchy = vec![
            hitem(None, None, None, Some(2)),       // 0: children 1,2
            hitem(Some(0), None, Some(2), Some(3)), // 1: child 3
            hitem(Some(0), Some(1), None, Some(4)), // 2: child 4
            hitem(Some(1), None, None, None),       // 3
            hitem(Some(2), None, None, None),       // 4
        ];
        let k3 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(3));
        let k4 = calculate_reconciliation_key(&node_data, &hierarchy, NodeId::new(4));
        assert_ne!(k3, k4, "identical leaves under different parents must not share a key");
    }
    // ========================================================================
    // calculate_contenteditable_key
    // ========================================================================
    #[test]
    fn autotest_contenteditable_key_is_deterministic_and_honours_explicit_keys() {
        let node_data = vec![NodeData::create_div().with_key(99u64)];
        let a = calculate_contenteditable_key(&node_data, &[], NodeId::new(0));
        let b = calculate_contenteditable_key(&node_data, &[], NodeId::new(0));
        assert_eq!(a, b, "must be deterministic");
        // Priority 1 is shared with the reconciliation key: for an explicitly
        // keyed node both functions return the SAME value.
        assert_eq!(
            a,
            calculate_reconciliation_key(&node_data, &[], NodeId::new(0)),
            "explicit keys must be identical across both key functions",
        );
    }
    #[test]
    fn autotest_contenteditable_key_distinguishes_nth_of_type() {
        // <div><p>A</p><p contenteditable>B</p></div> — the two same-type
        // siblings must not collide (nth-of-type is folded in).
        let node_data = vec![
            NodeData::create_div(),
            NodeData::create_text_do_not_use_without_block_level_wrapper("A"),
            NodeData::create_text_do_not_use_without_block_level_wrapper("B"),
        ];
        let hierarchy = vec![
            hitem(None, None, None, Some(2)),
            hitem(Some(0), None, Some(2), None),
            hitem(Some(0), Some(1), None, None),
        ];
        let k1 = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(1));
        let k2 = calculate_contenteditable_key(&node_data, &hierarchy, NodeId::new(2));
        assert_ne!(k1, k2, "same-type siblings must differ by nth-of-type");
    }
    #[test]
    fn autotest_contenteditable_key_empty_hierarchy_is_safe() {
        let node_data = vec![NodeData::create_div(), class_node("editor")];
        for i in 0..node_data.len() {
            let k = calculate_contenteditable_key(&node_data, &[], NodeId::new(i));
            assert_eq!(k, calculate_contenteditable_key(&node_data, &[], NodeId::new(i)));
        }
    }
    // ========================================================================
    // reconcile_dom
    // ========================================================================
    #[test]
    fn autotest_reconcile_empty_to_empty_is_a_no_op() {
        let r = diff_flat(&[], &[]);
        assert!(r.events.is_empty());
        assert!(r.node_moves.is_empty());
    }
    #[test]
    fn autotest_reconcile_mount_and_unmount_need_a_callback_to_fire() {
        // Without an AfterMount callback the node still mounts — it just fires
        // no event. Same for unmount. The events are opt-in.
        let silent_new = vec![NodeData::create_div()];
        let r = diff_flat(&[], &silent_new);
        assert!(r.events.is_empty(), "no callback -> no event");
        assert!(r.node_moves.is_empty());
        let loud_new = vec![with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount)];
        let r = diff_flat(&[], &loud_new);
        assert_eq!(count_events(&r, EventType::Mount), 1);
        let loud_old = vec![with_cb(
            NodeData::create_div(),
            ComponentEventFilter::BeforeUnmount,
        )];
        let r = diff_flat(&loud_old, &[]);
        assert_eq!(count_events(&r, EventType::Unmount), 1);
        assert!(r.node_moves.is_empty());
    }
    #[test]
    fn autotest_reconcile_node_moves_are_a_bijection() {
        // 50 indistinguishable divs on both sides: every old node must be
        // claimed exactly once and every new node must claim at most one old
        // node. A queue bug (double-consume) would break this immediately.
        let old: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
        let new: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
        let r = diff_flat(&old, &new);
        assert_eq!(r.node_moves.len(), 50);
        let mut seen_old = [false; 50];
        let mut seen_new = [false; 50];
        for m in &r.node_moves {
            assert!(!seen_old[m.old_node_id.index()], "old node claimed twice");
            assert!(!seen_new[m.new_node_id.index()], "new node matched twice");
            seen_old[m.old_node_id.index()] = true;
            seen_new[m.new_node_id.index()] = true;
        }
        assert!(seen_old.iter().all(|b| *b), "every old node must be claimed");
        assert!(seen_new.iter().all(|b| *b), "every new node must be matched");
        assert!(r.events.is_empty(), "no lifecycle callbacks -> no events");
    }
    #[test]
    fn autotest_reconcile_surplus_new_nodes_mount_and_surplus_old_unmount() {
        // 50 old, 60 new -> 50 matches + 10 mounts, no unmounts.
        let old: Vec<NodeData> = (0..50).map(|_| NodeData::create_div()).collect();
        let new: Vec<NodeData> = (0..60)
            .map(|_| with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount))
            .collect();
        let r = diff_flat(&old, &new);
        assert_eq!(r.node_moves.len(), 50);
        assert_eq!(count_events(&r, EventType::Mount), 10);
        assert_eq!(count_events(&r, EventType::Unmount), 0);
        // 50 old, 40 new -> 40 matches + 10 unmounts.
        let old: Vec<NodeData> = (0..50)
            .map(|_| with_cb(NodeData::create_div(), ComponentEventFilter::BeforeUnmount))
            .collect();
        let new: Vec<NodeData> = (0..40).map(|_| NodeData::create_div()).collect();
        let r = diff_flat(&old, &new);
        assert_eq!(r.node_moves.len(), 40);
        assert_eq!(count_events(&r, EventType::Unmount), 10);
        assert_eq!(count_events(&r, EventType::Mount), 0);
    }
    #[test]
    fn autotest_reconcile_explicit_key_mismatch_mounts_instead_of_guessing() {
        // The documented rule: an explicit `.with_key()` that finds no partner
        // must NOT fall through to the content/structural tiers, even though
        // the two nodes are otherwise byte-identical.
        let old = vec![with_cb(
            NodeData::create_text_do_not_use_without_block_level_wrapper("same content").with_key(1u32),
            ComponentEventFilter::BeforeUnmount,
        )];
        let new = vec![with_cb(
            NodeData::create_text_do_not_use_without_block_level_wrapper("same content").with_key(2u32),
            ComponentEventFilter::AfterMount,
        )];
        let r = diff_flat(&old, &new);
        assert!(
            r.node_moves.is_empty(),
            "keys 1 and 2 must not match, got {:?}",
            r.node_moves,
        );
        assert_eq!(count_events(&r, EventType::Mount), 1);
        assert_eq!(count_events(&r, EventType::Unmount), 1);
    }
    #[test]
    fn autotest_reconcile_update_fires_only_on_rec_key_match_with_changed_content() {
        // Same key, changed text, Updated callback present -> Update event.
        let old = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("v1").with_key(1u32)];
        let new = vec![with_cb(
            NodeData::create_text_do_not_use_without_block_level_wrapper("v2").with_key(1u32),
            ComponentEventFilter::Updated,
        )];
        let r = diff_flat(&old, &new);
        assert_eq!(r.node_moves.len(), 1, "the key must match across frames");
        assert_eq!(count_events(&r, EventType::Update), 1);
        // Same key, SAME content -> no Update. Both frames must be byte-identical
        // for this: `NodeData::hash` folds in the callback events too (dom.rs:1579),
        // so the Updated handler has to be present on BOTH sides — otherwise the
        // hashes differ for the callback alone and we'd be testing nothing.
        let stable = with_cb(
            NodeData::create_text_do_not_use_without_block_level_wrapper("v1").with_key(1u32),
            ComponentEventFilter::Updated,
        );
        let old = vec![stable.clone()];
        let new = vec![stable];
        let r = diff_flat(&old, &new);
        assert_eq!(r.node_moves.len(), 1);
        assert_eq!(
            count_events(&r, EventType::Update),
            0,
            "unchanged content must not fire Update",
        );
    }
    #[test]
    fn autotest_reconcile_update_requires_the_callback() {
        // Content changed under a stable key, but no Updated callback -> silent.
        let old = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("v1").with_key(1u32)];
        let new = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("v2").with_key(1u32)];
        let r = diff_flat(&old, &new);
        assert_eq!(r.node_moves.len(), 1);
        assert!(r.events.is_empty());
    }
    #[test]
    fn autotest_reconcile_missing_layout_entries_default_to_zero_rect() {
        // Neither side has layout data: `unwrap_or(LogicalRect::zero())` means
        // the sizes compare equal, so no Resize fires and nothing panics.
        let old = vec![NodeData::create_div()];
        let new = vec![with_cb(
            NodeData::create_div(),
            ComponentEventFilter::NodeResized,
        )];
        let r = diff_flat(&old, &new);
        assert_eq!(r.node_moves.len(), 1);
        assert_eq!(
            count_events(&r, EventType::Resize),
            0,
            "zero-vs-zero bounds must not be treated as a resize",
        );
    }
    #[test]
    fn autotest_reconcile_resize_fires_with_previous_and_current_bounds() {
        let old = vec![NodeData::create_div()];
        let new = vec![with_cb(
            NodeData::create_div(),
            ComponentEventFilter::NodeResized,
        )];
        let r = reconcile_dom(
            &old,
            &new,
            &[],
            &[],
            &layout_of(&[(0, rect(100.0, 50.0))]),
            &layout_of(&[(0, rect(100.0, 80.0))]),
            DomId::ROOT_ID,
            Instant::now(),
        );
        assert_eq!(count_events(&r, EventType::Resize), 1);
        let EventData::Lifecycle(data) = &r.events[0].data else {
            panic!("resize event must carry EventData::Lifecycle");
        };
        assert_eq!(data.reason, LifecycleReason::Resize);
        assert_eq!(data.previous_bounds, Some(rect(100.0, 50.0)));
        assert_eq!(data.current_bounds, rect(100.0, 80.0));
    }
    #[test]
    fn autotest_reconcile_resize_ignores_pure_translation() {
        // Only `size` is compared — moving a node must not fire Resize.
        let old = vec![NodeData::create_div()];
        let new = vec![with_cb(
            NodeData::create_div(),
            ComponentEventFilter::NodeResized,
        )];
        let moved = LogicalRect::new(LogicalPosition::new(999.0, 999.0), LogicalSize::new(10.0, 10.0));
        let r = reconcile_dom(
            &old,
            &new,
            &[],
            &[],
            &layout_of(&[(0, rect(10.0, 10.0))]),
            &layout_of(&[(0, moved)]),
            DomId::ROOT_ID,
            Instant::now(),
        );
        assert_eq!(count_events(&r, EventType::Resize), 0);
    }
    #[test]
    fn autotest_reconcile_nan_bounds_do_not_fire_a_resize_every_frame() {
        // NUMERIC EDGE — the sharpest one in this file.
        //
        // The Resize check is `old_rect.size != new_rect.size`. With a DERIVED
        // f32 `PartialEq` this would be catastrophic: `NaN != NaN` is true, so a
        // node whose layout solved to NaN would be reported as "resized" on
        // EVERY frame forever, firing an endless Resize-callback storm on a
        // completely static layout.
        //
        // `LogicalSize` dodges that with a hand-written `PartialEq` that runs
        // both operands through `geom::quantize()`, which maps every NaN to the
        // single sentinel `i64::MIN` (geom.rs:218) — so all NaNs compare EQUAL.
        // This test pins that: revert `LogicalSize` to `#[derive(PartialEq)]`
        // and it goes red.
        let old = vec![NodeData::create_div()];
        let new = vec![with_cb(
            NodeData::create_div(),
            ComponentEventFilter::NodeResized,
        )];
        let nan = rect(f32::NAN, f32::NAN);
        let r = reconcile_dom(
            &old,
            &new,
            &[],
            &[],
            &layout_of(&[(0, nan)]),
            &layout_of(&[(0, nan)]),
            DomId::ROOT_ID,
            Instant::now(),
        );
        assert_eq!(r.node_moves.len(), 1);
        assert_eq!(
            count_events(&r, EventType::Resize),
            0,
            "an unchanged NaN size must not be reported as a resize",
        );
        // Infinities are likewise stable against themselves (they saturate to
        // i64::MAX / i64::MIN under quantize()).
        let inf = rect(f32::INFINITY, f32::NEG_INFINITY);
        let r = reconcile_dom(
            &old,
            &new,
            &[],
            &[],
            &layout_of(&[(0, inf)]),
            &layout_of(&[(0, inf)]),
            DomId::ROOT_ID,
            Instant::now(),
        );
        assert_eq!(
            count_events(&r, EventType::Resize),
            0,
            "infinite-but-equal bounds must not be treated as a resize",
        );
        // But a NaN -> real transition IS a genuine resize, and must still fire.
        let r = reconcile_dom(
            &old,
            &new,
            &[],
            &[],
            &layout_of(&[(0, nan)]),
            &layout_of(&[(0, rect(10.0, 20.0))]),
            DomId::ROOT_ID,
            Instant::now(),
        );
        assert_eq!(
            count_events(&r, EventType::Resize),
            1,
            "NaN -> a real size is a real resize",
        );
    }
    #[test]
    fn autotest_reconcile_extreme_bounds_do_not_panic() {
        // f32 MIN/MAX/subnormal bounds must flow through the Resize comparison
        // without arithmetic surprises (the code only compares, never subtracts).
        let old = vec![NodeData::create_div()];
        let new = vec![with_cb(
            NodeData::create_div(),
            ComponentEventFilter::NodeResized,
        )];
        for (a, b) in [
            (rect(f32::MIN, f32::MAX), rect(f32::MAX, f32::MIN)),
            (rect(f32::MIN_POSITIVE, 0.0), rect(0.0, f32::MIN_POSITIVE)),
            (rect(-0.0, 0.0), rect(0.0, -0.0)), // IEEE: -0.0 == 0.0
        ] {
            let r = reconcile_dom(
                &old,
                &new,
                &[],
                &[],
                &layout_of(&[(0, a)]),
                &layout_of(&[(0, b)]),
                DomId::ROOT_ID,
                Instant::now(),
            );
            assert_eq!(r.node_moves.len(), 1);
        }
    }
    #[test]
    fn autotest_reconcile_keyless_tiers_respect_the_parent_key_gate() {
        // Regression guard for the AUDIT note at diff.rs:601. Two structurally
        // identical leaves live under DIFFERENT parents. The content-hash and
        // structural-hash tiers must not match them across parents, or focus /
        // scroll / dataset state migrates into an unrelated subtree.
        //
        // old:  0 root ── 1 (#left)  ── 2 "leaf"
        // new:  0 root ── 1 (#right) ── 2 "leaf"
        let old_nd = vec![
            NodeData::create_div(),
            id_node("left"),
            NodeData::create_text_do_not_use_without_block_level_wrapper("leaf"),
        ];
        let old_hier = vec![
            hitem(None, None, None, Some(1)),
            hitem(Some(0), None, None, Some(2)),
            hitem(Some(1), None, None, None),
        ];
        let new_nd = vec![
            NodeData::create_div(),
            id_node("right"),
            NodeData::create_text_do_not_use_without_block_level_wrapper("leaf"),
        ];
        let new_hier = old_hier.clone();
        let r = reconcile_dom(
            &old_nd,
            &new_nd,
            &old_hier,
            &new_hier,
            &no_layout(),
            &no_layout(),
            DomId::ROOT_ID,
            Instant::now(),
        );
        // The leaf (index 2) must NOT be matched: its parent's reconciliation
        // key differs (#left vs #right), so both keyless tiers are gated off.
        let leaf_matched = r
            .node_moves
            .iter()
            .any(|m| m.new_node_id.index() == 2 && m.old_node_id.index() == 2);
        assert!(
            !leaf_matched,
            "a leaf must not migrate across parents; moves = {:?}",
            r.node_moves,
        );
    }
    // ========================================================================
    // create_migration_map
    // ========================================================================
    #[test]
    fn autotest_migration_map_empty_and_large() {
        assert!(create_migration_map(&[]).is_empty());
        let moves: Vec<NodeMove> = (0..1000)
            .map(|i| NodeMove {
                old_node_id: NodeId::new(i),
                new_node_id: NodeId::new(i * 2),
            })
            .collect();
        let map = create_migration_map(&moves);
        assert_eq!(map.len(), 1000);
        assert_eq!(map.get(&NodeId::new(999)), Some(&NodeId::new(1998)));
    }
    #[test]
    fn autotest_migration_map_duplicate_old_id_keeps_the_last_write() {
        // The map is a BTreeMap, so a repeated old id overwrites. Pin it: a
        // silent "first wins" flip would strand focus on a stale node.
        let moves = vec![
            NodeMove {
                old_node_id: NodeId::new(0),
                new_node_id: NodeId::new(5),
            },
            NodeMove {
                old_node_id: NodeId::new(0),
                new_node_id: NodeId::new(9),
            },
        ];
        let map = create_migration_map(&moves);
        assert_eq!(map.len(), 1);
        assert_eq!(map.get(&NodeId::new(0)), Some(&NodeId::new(9)));
    }
    #[test]
    fn autotest_migration_map_round_trips_a_real_diff() {
        let old: Vec<NodeData> = (0..8).map(|_| NodeData::create_div()).collect();
        let new: Vec<NodeData> = (0..8).map(|_| NodeData::create_div()).collect();
        let r = diff_flat(&old, &new);
        let map = create_migration_map(&r.node_moves);
        assert_eq!(map.len(), r.node_moves.len());
        for m in &r.node_moves {
            assert_eq!(map.get(&m.old_node_id), Some(&m.new_node_id));
        }
    }
    // ========================================================================
    // transfer_states
    // ========================================================================
    #[allow(dead_code)]
    struct TestState(u32);
    // Keeps the PERSISTENT (old) allocation, discarding the fresh one — the
    // real-world case (MapWidget's tile cache is written by background threads).
    extern "C" fn merge_keep_old(_new_data: RefAny, old_data: RefAny) -> RefAny {
        old_data
    }
    #[test]
    fn autotest_transfer_states_out_of_range_moves_are_skipped() {
        // The bounds guard must swallow a corrupt NodeMove instead of indexing
        // out of bounds.
        let mut old = vec![NodeData::create_div()];
        let mut new = vec![NodeData::create_div()];
        let moves = vec![
            NodeMove {
                old_node_id: NodeId::new(5), // out of range
                new_node_id: NodeId::new(0),
            },
            NodeMove {
                old_node_id: NodeId::new(0),
                new_node_id: NodeId::new(7), // out of range
            },
            NodeMove {
                old_node_id: NodeId::new(usize::MAX),
                new_node_id: NodeId::new(usize::MAX),
            },
        ];
        transfer_states(&mut old, &mut new, &moves); // must not panic
        assert!(new[0].get_dataset().is_none());
    }
    #[test]
    fn autotest_transfer_states_without_merge_callback_leaves_datasets_intact() {
        let mut old = vec![NodeData::create_div()];
        old[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(1))));
        let mut new = vec![NodeData::create_div()];
        new[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(2))));
        let old_ptr = old[0].get_dataset().unwrap().sharing_info.ptr as usize;
        let new_ptr = new[0].get_dataset().unwrap().sharing_info.ptr as usize;
        transfer_states(
            &mut old,
            &mut new,
            &[NodeMove {
                old_node_id: NodeId::new(0),
                new_node_id: NodeId::new(0),
            }],
        );
        // No merge callback -> early `continue`, both datasets stay where they were.
        assert_eq!(
            old[0].get_dataset().unwrap().sharing_info.ptr as usize,
            old_ptr,
        );
        assert_eq!(
            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
            new_ptr,
        );
    }
    #[test]
    fn autotest_transfer_states_with_one_missing_dataset_restores_both_sides() {
        // Merge callback present, but the OLD node has no dataset -> the
        // `(new_ds, old_ds)` arm must put the taken dataset back.
        let mut old = vec![NodeData::create_div()];
        let mut new = vec![NodeData::create_div()];
        new[0].set_merge_callback(merge_keep_old as DatasetMergeCallbackType);
        new[0].set_dataset(OptionRefAny::Some(RefAny::new(TestState(2))));
        let new_ptr = new[0].get_dataset().unwrap().sharing_info.ptr as usize;
        transfer_states(
            &mut old,
            &mut new,
            &[NodeMove {
                old_node_id: NodeId::new(0),
                new_node_id: NodeId::new(0),
            }],
        );
        assert!(old[0].get_dataset().is_none());
        assert_eq!(
            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
            new_ptr,
            "the fresh dataset must be restored, not dropped",
        );
    }
    #[test]
    fn autotest_transfer_states_repoints_orphaned_callback_refanys() {
        // The unification rule (diff.rs:909): a widget builds its dataset AND
        // its callback refanys from clones of ONE RefAny. When the merge keeps
        // the OLD allocation, every clone of the FRESH one is orphaned and must
        // be re-pointed at the merged result — otherwise the widget fragments
        // across two caches (the MapWidget grey-tile bug).
        let fresh = RefAny::new(TestState(1));
        let fresh_ptr = fresh.sharing_info.ptr as usize;
        let mut new0 = NodeData::create_div();
        new0.set_merge_callback(merge_keep_old as DatasetMergeCallbackType);
        new0.set_dataset(OptionRefAny::Some(fresh.clone()));
        // A callback on the SAME node, holding a clone of the fresh allocation.
        new0.add_callback(
            EventFilter::Component(ComponentEventFilter::Selected),
            fresh.clone(),
            noop_callback(),
        );
        // A *sibling* node whose callback also clones the fresh allocation —
        // the generalised sweep must reach it too, not just the merge node.
        let mut new1 = NodeData::create_div();
        new1.add_callback(
            EventFilter::Component(ComponentEventFilter::Selected),
            fresh.clone(),
            noop_callback(),
        );
        let persistent = RefAny::new(TestState(2));
        let persistent_ptr = persistent.sharing_info.ptr as usize;
        assert_ne!(fresh_ptr, persistent_ptr, "test setup: allocations must differ");
        let mut old = vec![NodeData::create_div()];
        old[0].set_dataset(OptionRefAny::Some(persistent));
        let mut new = vec![new0, new1];
        transfer_states(
            &mut old,
            &mut new,
            &[NodeMove {
                old_node_id: NodeId::new(0),
                new_node_id: NodeId::new(0),
            }],
        );
        // The merged dataset is the PERSISTENT allocation.
        assert_eq!(
            new[0].get_dataset().unwrap().sharing_info.ptr as usize,
            persistent_ptr,
            "the merge must keep the persistent allocation",
        );
        // The old node's dataset was moved into the merge result.
        assert!(old[0].get_dataset().is_none());
        // Both orphaned callback refanys — on the merge node AND on the sibling
        // — must now point at the merged allocation.
        for (i, nd) in new.iter().enumerate() {
            for cb in nd.callbacks.as_ref() {
                assert_eq!(
                    cb.refany.sharing_info.ptr as usize,
                    persistent_ptr,
                    "node {i}: an orphaned callback refany was not re-pointed",
                );
            }
        }
    }
    // ========================================================================
    // ChangeAccumulator
    // ========================================================================
    #[test]
    fn autotest_accumulator_new_is_empty_and_inert() {
        let a = ChangeAccumulator::new();
        assert!(a.is_empty());
        assert!(!a.needs_layout());
        assert!(!a.needs_paint_only());
        assert!(a.is_visually_unchanged());
        assert_eq!(a.max_scope, RelayoutScope::None);
        // `new()` and `default()` must agree.
        let d = ChangeAccumulator::default();
        assert_eq!(a.is_empty(), d.is_empty());
        assert_eq!(a.max_scope, d.max_scope);
    }
    #[test]
    fn autotest_accumulator_mount_forces_layout_unmount_does_not() {
        let mut a = ChangeAccumulator::new();
        a.add_mount(NodeId::new(0));
        assert!(!a.is_empty());
        assert!(a.needs_layout(), "a mounted node always needs layout");
        assert!(!a.needs_paint_only());
        assert!(!a.is_visually_unchanged());
        // An unmount alone is NOT layout work here (the node is gone); it only
        // breaks `is_visually_unchanged`. Pin the asymmetry.
        let mut a = ChangeAccumulator::new();
        a.add_unmount(NodeId::new(0));
        assert!(!a.is_empty());
        assert!(!a.needs_layout());
        assert!(!a.is_visually_unchanged());
    }
    #[test]
    fn autotest_accumulator_css_change_routes_paint_vs_layout_by_scope() {
        // scope == None -> paint bucket.
        let mut a = ChangeAccumulator::new();
        a.add_css_change(NodeId::new(0), CssPropertyType::TextColor, RelayoutScope::None);
        assert!(!a.needs_layout());
        assert!(a.needs_paint_only());
        assert!(!a.is_visually_unchanged());
        assert_eq!(a.max_scope, RelayoutScope::None);
        assert!(a.per_node[&NodeId::new(0)]
            .change_set
            .contains(NodeChangeSet::INLINE_STYLE_PAINT));
        // scope > None -> layout bucket.
        let mut a = ChangeAccumulator::new();
        a.add_css_change(NodeId::new(0), CssPropertyType::Width, RelayoutScope::SizingOnly);
        assert!(a.needs_layout());
        assert!(!a.needs_paint_only(), "layout work subsumes paint-only");
        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
        assert!(a.per_node[&NodeId::new(0)]
            .change_set
            .contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
    }
    #[test]
    fn autotest_accumulator_max_scope_is_monotone() {
        // Once escalated, the scope must never be lowered by a later, weaker
        // change — otherwise a Full relayout gets silently downgraded.
        let mut a = ChangeAccumulator::new();
        a.add_css_change(NodeId::new(0), CssPropertyType::Display, RelayoutScope::Full);
        assert_eq!(a.max_scope, RelayoutScope::Full);
        a.add_css_change(NodeId::new(0), CssPropertyType::TextColor, RelayoutScope::None);
        assert_eq!(a.max_scope, RelayoutScope::Full, "max_scope must not regress");
        assert_eq!(
            a.per_node[&NodeId::new(0)].relayout_scope,
            RelayoutScope::Full,
            "per-node scope must not regress either",
        );
        a.add_css_change(NodeId::new(1), CssPropertyType::Width, RelayoutScope::SizingOnly);
        assert_eq!(a.max_scope, RelayoutScope::Full);
        assert_eq!(
            a.per_node[&NodeId::new(1)].relayout_scope,
            RelayoutScope::SizingOnly,
            "a different node keeps its own, lower scope",
        );
    }
    #[test]
    fn autotest_accumulator_text_change_is_ifc_scoped_and_unicode_safe() {
        for s in UNICODE_SAMPLES {
            let mut a = ChangeAccumulator::new();
            a.add_text_change(NodeId::new(0), String::new(), (*s).to_string());
            let report = &a.per_node[&NodeId::new(0)];
            assert!(report.change_set.contains(NodeChangeSet::TEXT_CONTENT));
            assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
            assert_eq!(
                report.text_change,
                Some(TextChange {
                    old_text: String::new(),
                    new_text: (*s).to_string(),
                }),
            );
            assert!(a.needs_layout());
            assert_eq!(a.max_scope, RelayoutScope::IfcOnly);
        }
    }
    #[test]
    fn autotest_accumulator_add_dom_change_accumulates_and_never_clears_text() {
        let node = NodeId::new(0);
        let mut a = ChangeAccumulator::new();
        a.add_dom_change(
            node,
            NodeChangeSet {
                bits: NodeChangeSet::TEXT_CONTENT,
            },
            RelayoutScope::IfcOnly,
            Some(TextChange {
                old_text: "a".to_string(),
                new_text: "b".to_string(),
            }),
            vec![CssPropertyType::Width],
        );
        // A second call with text_change == None must NOT wipe the first one.
        a.add_dom_change(
            node,
            NodeChangeSet {
                bits: NodeChangeSet::STYLED_STATE,
            },
            RelayoutScope::None,
            None,
            vec![CssPropertyType::TextColor],
        );
        let report = &a.per_node[&node];
        assert!(report.change_set.contains(NodeChangeSet::TEXT_CONTENT));
        assert!(
            report.change_set.contains(NodeChangeSet::STYLED_STATE),
            "flags must be OR-accumulated across calls",
        );
        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
        assert!(
            report.text_change.is_some(),
            "a None text_change must not erase a previously recorded one",
        );
        assert_eq!(
            report.changed_css_properties,
            vec![CssPropertyType::Width, CssPropertyType::TextColor],
            "changed properties must be appended, not replaced",
        );
    }
    #[test]
    fn autotest_accumulator_image_change() {
        let mut a = ChangeAccumulator::new();
        a.add_image_change(NodeId::new(0), RelayoutScope::SizingOnly);
        assert!(a.per_node[&NodeId::new(0)]
            .change_set
            .contains(NodeChangeSet::IMAGE_CHANGED));
        assert!(a.needs_layout());
        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
    }
    #[test]
    fn autotest_accumulator_merge_empty_restyle_result_is_a_no_op() {
        let mut a = ChangeAccumulator::new();
        a.merge_restyle_result(&RestyleResult::default());
        assert!(a.is_empty());
        assert!(a.is_visually_unchanged());
    }
    #[test]
    fn autotest_accumulator_merge_restyle_result_classifies_by_property() {
        let prop = CssProperty::Width(CssPropertyValue::Exact(LayoutWidth::const_px(100)));
        let changed = ChangedCssProperty {
            previous_state: StyledNodeState::default(),
            previous_prop: prop.clone(),
            current_state: StyledNodeState::default(),
            current_prop: prop,
        };
        let mut restyle = RestyleResult::default();
        restyle
            .changed_nodes
            .insert(NodeId::new(3), vec![changed]);
        let mut a = ChangeAccumulator::new();
        a.merge_restyle_result(&restyle);
        // `width` -> SizingOnly -> layout bucket.
        assert!(!a.is_empty());
        assert!(a.needs_layout());
        assert_eq!(a.max_scope, RelayoutScope::SizingOnly);
        let report = &a.per_node[&NodeId::new(3)];
        assert!(report.change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
        assert_eq!(report.changed_css_properties, vec![CssPropertyType::Width]);
    }
    #[test]
    fn autotest_accumulator_merge_extended_diff_counts_mounts_and_unmounts() {
        // No node_moves at all -> every new node mounted, every old node unmounted.
        let old_nd = vec![NodeData::create_div(), NodeData::create_div()];
        let new_nd = vec![
            NodeData::create_div(),
            NodeData::create_div(),
            NodeData::create_div(),
        ];
        let mut a = ChangeAccumulator::new();
        a.merge_extended_diff(&ExtendedDiffResult::default(), &old_nd, &new_nd);
        assert_eq!(a.mounted_nodes.len(), 3);
        assert_eq!(a.unmounted_nodes.len(), 2);
        assert!(!a.is_empty());
        assert!(a.needs_layout(), "mounted nodes always need layout");
        assert!(!a.is_visually_unchanged());
    }
    #[test]
    fn autotest_accumulator_merge_extended_diff_on_empty_doms_is_empty() {
        let mut a = ChangeAccumulator::new();
        a.merge_extended_diff(&ExtendedDiffResult::default(), &[], &[]);
        assert!(a.is_empty());
    }
    #[test]
    fn autotest_accumulator_merge_extended_diff_skips_empty_change_sets() {
        // A matched node with NO changes must not create a per_node entry.
        let old_nd = vec![NodeData::create_div()];
        let new_nd = vec![NodeData::create_div()];
        let extended = ExtendedDiffResult {
            diff: DiffResult {
                events: Vec::new(),
                node_moves: vec![NodeMove {
                    old_node_id: NodeId::new(0),
                    new_node_id: NodeId::new(0),
                }],
            },
            node_changes: vec![(NodeId::new(0), NodeId::new(0), NodeChangeSet::empty())],
        };
        let mut a = ChangeAccumulator::new();
        a.merge_extended_diff(&extended, &old_nd, &new_nd);
        assert!(a.per_node.is_empty(), "an empty change set must be skipped");
        assert!(a.mounted_nodes.is_empty());
        assert!(a.unmounted_nodes.is_empty());
        assert!(a.is_empty());
    }
    #[test]
    fn autotest_accumulator_merge_extended_diff_extracts_text_change() {
        let old_nd = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("héllo")];
        let new_nd = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("héllo wörld")];
        let extended = ExtendedDiffResult {
            diff: DiffResult {
                events: Vec::new(),
                node_moves: vec![NodeMove {
                    old_node_id: NodeId::new(0),
                    new_node_id: NodeId::new(0),
                }],
            },
            node_changes: vec![(
                NodeId::new(0),
                NodeId::new(0),
                NodeChangeSet {
                    bits: NodeChangeSet::TEXT_CONTENT,
                },
            )],
        };
        let mut a = ChangeAccumulator::new();
        a.merge_extended_diff(&extended, &old_nd, &new_nd);
        let report = &a.per_node[&NodeId::new(0)];
        assert_eq!(
            report.text_change,
            Some(TextChange {
                old_text: "héllo".to_string(),
                new_text: "héllo wörld".to_string(),
            }),
            "TEXT_CONTENT must carry the old/new text for cursor reconciliation",
        );
        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
    }
    // ========================================================================
    // ChangeAccumulator::classify_change_scope (private)
    // ========================================================================
    #[test]
    fn autotest_classify_scope_maps_each_flag_to_its_documented_scope() {
        let nodes = vec![NodeData::create_div()];
        let id = NodeId::new(0);
        let classify = |bits: u32| {
            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id)
        };
        assert_eq!(classify(0), RelayoutScope::None, "empty -> no work");
        assert_eq!(classify(NodeChangeSet::NODE_TYPE_CHANGED), RelayoutScope::Full);
        assert_eq!(classify(NodeChangeSet::CHILDREN_CHANGED), RelayoutScope::Full);
        assert_eq!(classify(NodeChangeSet::IDS_AND_CLASSES), RelayoutScope::Full);
        assert_eq!(classify(NodeChangeSet::TEXT_CONTENT), RelayoutScope::IfcOnly);
        assert_eq!(classify(NodeChangeSet::IMAGE_CHANGED), RelayoutScope::SizingOnly);
        assert_eq!(classify(NodeChangeSet::CONTENTEDITABLE), RelayoutScope::SizingOnly);
        assert_eq!(classify(NodeChangeSet::STYLED_STATE), RelayoutScope::None);
        assert_eq!(classify(NodeChangeSet::INLINE_STYLE_PAINT), RelayoutScope::None);
        // Non-visual flags -> no work.
        assert_eq!(classify(NodeChangeSet::CALLBACKS), RelayoutScope::None);
        assert_eq!(classify(NodeChangeSet::DATASET), RelayoutScope::None);
        assert_eq!(classify(NodeChangeSet::TAB_INDEX), RelayoutScope::None);
    }
    #[test]
    fn autotest_classify_scope_precedence_is_widest_first() {
        let nodes = vec![NodeData::create_div()];
        let id = NodeId::new(0);
        // NODE_TYPE_CHANGED wins over everything below it.
        let bits = NodeChangeSet::NODE_TYPE_CHANGED
            | NodeChangeSet::TEXT_CONTENT
            | NodeChangeSet::IMAGE_CHANGED
            | NodeChangeSet::STYLED_STATE;
        assert_eq!(
            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id),
            RelayoutScope::Full,
        );
        // TEXT_CONTENT (IfcOnly) wins over IMAGE_CHANGED (SizingOnly) — pinning
        // the documented order, even though IfcOnly < SizingOnly.
        let bits = NodeChangeSet::TEXT_CONTENT | NodeChangeSet::IMAGE_CHANGED;
        assert_eq!(
            ChangeAccumulator::classify_change_scope(NodeChangeSet { bits }, &nodes, id),
            RelayoutScope::IfcOnly,
        );
    }
    #[test]
    fn autotest_classify_scope_inline_layout_walks_the_nodes_own_css() {
        // With a sizing property on the node, the scope comes from the property.
        let nodes = vec![NodeData::create_div().with_css("width: 100px")];
        assert_eq!(
            ChangeAccumulator::classify_change_scope(
                NodeChangeSet {
                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
                },
                &nodes,
                NodeId::new(0),
            ),
            RelayoutScope::SizingOnly,
        );
        // A `display` change is a full relayout.
        let nodes = vec![NodeData::create_div().with_css("display: flex")];
        assert_eq!(
            ChangeAccumulator::classify_change_scope(
                NodeChangeSet {
                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
                },
                &nodes,
                NodeId::new(0),
            ),
            RelayoutScope::Full,
        );
        // No inline CSS at all (the property was REMOVED, so the new node has
        // nothing to walk): the conservative SizingOnly fallback must kick in
        // rather than silently reporting "no layout work".
        let nodes = vec![NodeData::create_div()];
        assert_eq!(
            ChangeAccumulator::classify_change_scope(
                NodeChangeSet {
                    bits: NodeChangeSet::INLINE_STYLE_LAYOUT,
                },
                &nodes,
                NodeId::new(0),
            ),
            RelayoutScope::SizingOnly,
            "an INLINE_STYLE_LAYOUT change must never classify as 'no layout'",
        );
    }
    // ========================================================================
    // reconcile_dom_with_changes
    // ========================================================================
    #[test]
    fn autotest_reconcile_with_changes_on_empty_doms() {
        let r = reconcile_dom_with_changes(
            &[],
            &[],
            &[],
            &[],
            None,
            None,
            &no_layout(),
            &no_layout(),
            DomId::ROOT_ID,
            Instant::now(),
        );
        assert!(r.diff.events.is_empty());
        assert!(r.diff.node_moves.is_empty());
        assert!(r.node_changes.is_empty());
    }
    #[test]
    fn autotest_reconcile_with_changes_reports_one_entry_per_move() {
        let old = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("v1").with_key(1u32)];
        let new = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("v2").with_key(1u32)];
        let r = reconcile_dom_with_changes(
            &old,
            &new,
            &[],
            &[],
            None,
            None,
            &no_layout(),
            &no_layout(),
            DomId::ROOT_ID,
            Instant::now(),
        );
        assert_eq!(r.diff.node_moves.len(), 1);
        assert_eq!(
            r.node_changes.len(),
            r.diff.node_moves.len(),
            "there must be exactly one change entry per matched pair",
        );
        let (old_id, new_id, changes) = &r.node_changes[0];
        assert_eq!(*old_id, NodeId::new(0));
        assert_eq!(*new_id, NodeId::new(0));
        assert!(changes.contains(NodeChangeSet::TEXT_CONTENT));
    }
    #[test]
    fn autotest_reconcile_with_changes_tolerates_short_styled_state_slices() {
        // `old_styled_nodes` / `new_styled_nodes` are indexed with `.get()`, so a
        // slice shorter than the DOM must degrade to `None`, not panic.
        let old = vec![NodeData::create_div(), NodeData::create_div()];
        let new = vec![NodeData::create_div(), NodeData::create_div()];
        let short = [StyledNodeState::default()]; // 1 entry for 2 nodes
        let r = reconcile_dom_with_changes(
            &old,
            &new,
            &[],
            &[],
            Some(&short[..]),
            Some(&short[..]),
            &no_layout(),
            &no_layout(),
            DomId::ROOT_ID,
            Instant::now(),
        );
        assert_eq!(r.node_changes.len(), 2);
        // Both sides see the same (present-or-absent) state, so no STYLED_STATE.
        for (_, _, changes) in &r.node_changes {
            assert!(!changes.contains(NodeChangeSet::STYLED_STATE));
        }
    }
    #[test]
    fn autotest_reconcile_with_changes_feeds_the_accumulator() {
        // End-to-end: reconcile -> ExtendedDiffResult -> ChangeAccumulator.
        let old = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("before").with_key(1u32)];
        let new = vec![NodeData::create_text_do_not_use_without_block_level_wrapper("after").with_key(1u32)];
        let extended = reconcile_dom_with_changes(
            &old,
            &new,
            &[],
            &[],
            None,
            None,
            &no_layout(),
            &no_layout(),
            DomId::ROOT_ID,
            Instant::now(),
        );
        let mut acc = ChangeAccumulator::new();
        acc.merge_extended_diff(&extended, &old, &new);
        assert!(!acc.is_empty());
        assert!(acc.needs_layout(), "a text edit needs (IFC) layout");
        assert!(!acc.is_visually_unchanged());
        assert!(acc.mounted_nodes.is_empty());
        assert!(acc.unmounted_nodes.is_empty());
        let report = &acc.per_node[&NodeId::new(0)];
        assert_eq!(report.relayout_scope, RelayoutScope::IfcOnly);
        assert_eq!(
            report.text_change,
            Some(TextChange {
                old_text: "before".to_string(),
                new_text: "after".to_string(),
            }),
        );
    }
    // ========================================================================
    // NodeDataFingerprint
    // ========================================================================
    #[test]
    fn autotest_fingerprint_default_and_self_comparison_are_inert() {
        let d = NodeDataFingerprint::default();
        assert!(d.is_identical(&d));
        assert!(d.diff(&d).is_empty());
        assert!(!d.might_affect_layout(&d));
        assert!(!d.might_affect_visuals(&d));
        assert_eq!(d, NodeDataFingerprint::default());
    }
    #[test]
    fn autotest_fingerprint_is_a_pure_function_of_its_inputs() {
        // Round-trip / determinism: recomputing from equal inputs must give an
        // identical fingerprint (no address or allocation identity leaking in).
        let state = StyledNodeState::default();
        for s in UNICODE_SAMPLES {
            let a = NodeDataFingerprint::compute(&NodeData::create_text_do_not_use_without_block_level_wrapper(*s), Some(&state));
            let b = NodeDataFingerprint::compute(&NodeData::create_text_do_not_use_without_block_level_wrapper(*s), Some(&state));
            assert_eq!(a, b, "fingerprint of {s:?} is not deterministic");
            assert!(a.is_identical(&b));
            assert!(a.diff(&b).is_empty());
        }
    }
    #[test]
    fn autotest_fingerprint_diff_is_symmetric() {
        let a = NodeDataFingerprint::compute(&NodeData::create_text_do_not_use_without_block_level_wrapper("a"), None);
        let b = NodeDataFingerprint::compute(&class_node("x").with_css("width: 1px"), None);
        assert_eq!(a.diff(&b), b.diff(&a), "diff must be symmetric");
        assert_eq!(
            a.might_affect_layout(&b),
            b.might_affect_layout(&a),
            "might_affect_layout must be symmetric",
        );
        assert_eq!(a.might_affect_visuals(&b), b.might_affect_visuals(&a));
    }
    #[test]
    fn autotest_fingerprint_text_change_is_layout_and_visual() {
        let a = NodeDataFingerprint::compute(&NodeData::create_text_do_not_use_without_block_level_wrapper("one"), None);
        let b = NodeDataFingerprint::compute(&NodeData::create_text_do_not_use_without_block_level_wrapper("two"), None);
        assert!(!a.is_identical(&b));
        let changes = a.diff(&b);
        // Conservative by design: content_hash cannot tell text from image.
        assert!(changes.contains(NodeChangeSet::TEXT_CONTENT));
        assert!(changes.contains(NodeChangeSet::IMAGE_CHANGED));
        assert!(a.might_affect_layout(&b));
        assert!(a.might_affect_visuals(&b));
    }
    #[test]
    fn autotest_fingerprint_styled_state_is_visual_but_not_layout() {
        // The sharpest invariant of the fast path: a :hover flip must never be
        // able to trigger relayout.
        let node = NodeData::create_div();
        let calm = StyledNodeState::default();
        let hovered = StyledNodeState {
            hover: true,
            ..StyledNodeState::default()
        };
        let a = NodeDataFingerprint::compute(&node, Some(&calm));
        let b = NodeDataFingerprint::compute(&node, Some(&hovered));
        assert!(!a.is_identical(&b));
        assert!(a.diff(&b).contains(NodeChangeSet::STYLED_STATE));
        assert!(
            !a.might_affect_layout(&b),
            "a styled-state change must not be able to request layout",
        );
        assert!(a.might_affect_visuals(&b));
    }
    #[test]
    fn autotest_fingerprint_callback_change_is_neither_layout_nor_visual() {
        let plain = NodeData::create_div();
        let with_handler = with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount);
        let a = NodeDataFingerprint::compute(&plain, None);
        let b = NodeDataFingerprint::compute(&with_handler, None);
        assert!(!a.is_identical(&b), "the callback list must be fingerprinted");
        assert!(a.diff(&b).contains(NodeChangeSet::CALLBACKS));
        assert!(
            !a.might_affect_layout(&b),
            "swapping an event handler must not trigger relayout",
        );
        assert!(
            !a.might_affect_visuals(&b),
            "swapping an event handler must not trigger a repaint",
        );
    }
    #[test]
    fn autotest_fingerprint_ids_classes_and_inline_css_are_layout_relevant() {
        let base = NodeDataFingerprint::compute(&NodeData::create_div(), None);
        let classes = NodeDataFingerprint::compute(&class_node("banner"), None);
        assert!(base.diff(&classes).contains(NodeChangeSet::IDS_AND_CLASSES));
        assert!(base.might_affect_layout(&classes));
        assert!(base.might_affect_visuals(&classes));
        let styled =
            NodeDataFingerprint::compute(&NodeData::create_div().with_css("width: 3px"), None);
        assert!(base.diff(&styled).contains(NodeChangeSet::INLINE_STYLE_LAYOUT));
        assert!(base.might_affect_layout(&styled));
        assert!(base.might_affect_visuals(&styled));
    }
    #[test]
    fn autotest_fingerprint_attrs_change_flags_tab_index_and_contenteditable() {
        let base = NodeDataFingerprint::compute(&NodeData::create_div(), None);
        let editable =
            NodeDataFingerprint::compute(&NodeData::create_div().with_contenteditable(true), None);
        let changes = base.diff(&editable);
        assert!(changes.contains(NodeChangeSet::TAB_INDEX));
        assert!(changes.contains(NodeChangeSet::CONTENTEDITABLE));
        assert!(
            base.might_affect_layout(&editable),
            "attrs_hash feeds might_affect_layout",
        );
        assert!(
            !base.might_affect_visuals(&editable),
            "attrs_hash is deliberately NOT part of might_affect_visuals",
        );
    }
    #[test]
    fn autotest_fingerprint_agrees_with_compute_node_changes_on_unchanged_nodes() {
        // Tier 1 (fingerprint) must never claim "changed" where Tier 2
        // (compute_node_changes) says "unchanged" — that would defeat the whole
        // two-tier fast path.
        let state = StyledNodeState::default();
        let samples = vec![
            NodeData::create_div(),
            NodeData::create_text_do_not_use_without_block_level_wrapper("hello 🌍"),
            class_node("row"),
            id_node("main"),
            NodeData::create_div().with_css("color: red"),
            NodeData::create_div().with_contenteditable(true),
            with_cb(NodeData::create_div(), ComponentEventFilter::AfterMount),
        ];
        for node in &samples {
            let clone = node.clone();
            let fp_a = NodeDataFingerprint::compute(node, Some(&state));
            let fp_b = NodeDataFingerprint::compute(&clone, Some(&state));
            assert!(
                fp_a.is_identical(&fp_b),
                "a cloned node must fingerprint identically",
            );
            assert!(fp_a.diff(&fp_b).is_empty());
            let tier2 = compute_node_changes(node, &clone, Some(&state), Some(&state));
            assert!(
                tier2.is_empty(),
                "compute_node_changes must agree that a clone is unchanged, got {:#b}",
                tier2.bits,
            );
        }
    }
}
// ============================================================================
// Pre-cascade DOM fingerprints (two tiers: STRUCTURE vs STYLE)
// ============================================================================
/// Two-tier fingerprints of a recursive [`crate::dom::Dom`].
///
/// Computed BEFORE the cascade, in the same pre-order the flattener
/// (`convert_dom_into_compact_dom`) assigns `NodeId`s — index `i` in each Vec
/// is flattened `NodeId(i)`.
///
/// WHY TWO TIERS (user directive 2026-08-08): "the start should just scan
/// over the `NodeHierarchy` to discover anything that changed, which is
/// iterating over a minimal array" — and css must be EXCLUDED from that
/// first equivalence, because a stylesheet can only affect the subtree it
/// is attached to:
///
/// - **structure**: hierarchy shape + node content (`node_type`, ids/classes,
///   attributes, callback EVENT types). NO css of any kind. If this tier is
///   equal, the old tree, its shaped text and its intrinsic caches are all
///   reusable — and if the style tier is ALSO equal, the previous CASCADE
///   is reusable wholesale (skip `create_from_dom` entirely).
/// - **style**: per-node inline css + (at subtree roots that carry
///   `.with_css()` sheets) the sheet content. A difference here with an
///   equal structure tier means: keep the tree, re-cascade the affected
///   subtree(s) only.
///
/// The per-node arrays exist so a mismatch NAMES the changed nodes (the
/// eventual dirty-set for scoped re-cascade / word-granular text relayout);
/// the root folds make the equal case one u64 compare per tier.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DomFingerprints {
    /// Per-node structural hash, pre-order. Folds: `node_type` content
    /// (image-callback nodes hash (fn ptr, `RefAny` `type_id`) — the `RefAny`
    /// INSTANCE is rebuilt every frame by design and is transferred, not
    /// compared; mirrors `is_layout_equivalent`), ids+classes, callback
    /// event types, contenteditable/flags/dataset, and child COUNT (pre-order
    /// alone cannot distinguish `[a [b] c]` from `[a [b c]]`).
    pub structure: Vec<u64>,
    /// Per-node style hash, pre-order: inline css properties + conditions,
    /// plus the node's attached `.with_css()` sheets (path, declarations,
    /// @-conditions, priority per rule).
    pub style: Vec<u64>,
    /// Order-sensitive fold of `structure`.
    pub structure_root: u64,
    /// Order-sensitive fold of `style`.
    pub style_root: u64,
}
/// `RefAny` payloads collected during the fingerprint walk.
///
/// Transferred onto the retained DOM when the produce side is skipped. The skip path
/// keeps last frame's `StyledDom`, but callbacks/image callbacks must use the
/// freshly-created `RefAnys` (they may reference new app state) — same
/// transfer `regenerate_layout`'s equivalence branch has always done, minus
/// the cascade it used to pay to get here. Indices are flattened `NodeIds`.
#[derive(Debug, Default, Clone)]
pub struct PreCascadeTransfers {
    /// `(flattened NodeId index, fresh image callback)` for every
    /// `NodeType::Image(DecodedImage::Callback)` node.
    pub image_callbacks: Vec<(usize, crate::callbacks::CoreImageCallback)>,
    /// `(flattened NodeId index, fresh event callbacks)` for every node with
    /// a non-empty callback list.
    pub callbacks: Vec<(usize, crate::callbacks::CoreCallbackDataVec)>,
}
/// Walk a recursive [`crate::dom::Dom`] once, pre-order.
///
/// Produces both fingerprint tiers and the `RefAny` transfer list. Cost: one hash pass over
/// node data — no cascade, no allocation proportional to anything but node
/// count.
#[allow(clippy::too_many_lines)] // cohesive single-pass walker; splitting adds state-threading
13
#[must_use] pub fn fingerprint_dom(dom: &crate::dom::Dom) -> (DomFingerprints, PreCascadeTransfers) {
    use core::hash::{Hash, Hasher};
53
    fn node_structure_hash(node: &NodeData, child_count: usize) -> u64 {
        use crate::dom::NodeType;
        use crate::resources::DecodedImage;
        use core::hash::{Hash, Hasher};
53
        let mut h = crate::hash::DefaultHasher::new();
        // node_type content — image-callback special case (see struct doc)
53
        match node.get_node_type() {
            NodeType::Image(img) => {
                match img.get_data() {
                    DecodedImage::Callback(cb) => {
                        0xB0DE_CA11u32.hash(&mut h);
                        cb.callback.cb.hash(&mut h);
                        cb.refany.get_type_id().hash(&mut h);
                    }
                    _ => {
                        // Raw / GPU images: ImageRef hashes by id — instance
                        // identity, the same strictness is_layout_equivalent's
                        // `old_img != new_img` applies.
                        node.get_node_type().hash(&mut h);
                    }
                }
            }
53
            other => other.hash(&mut h),
        }
        // ids + classes (order-sensitive, as worn)
53
        for attr in node.attributes().as_ref() {
13
            match attr {
                crate::dom::AttributeType::Id(s) => {
                    1u8.hash(&mut h);
                    s.hash(&mut h);
                }
13
                crate::dom::AttributeType::Class(s) => {
13
                    2u8.hash(&mut h);
13
                    s.hash(&mut h);
13
                }
                other => {
                    3u8.hash(&mut h);
                    other.hash(&mut h);
                }
            }
        }
        // callback EVENT types only — the fn ptr + RefAny are transferred,
        // not compared (is_layout_equivalent: "compare only event types")
53
        node.callbacks.as_ref().len().hash(&mut h);
53
        for cb in node.callbacks.as_ref() {
            cb.event.hash(&mut h);
        }
        // layout-relevant attributes
53
        node.is_contenteditable().hash(&mut h);
53
        node.flags.hash(&mut h);
        // hierarchy shape
53
        child_count.hash(&mut h);
53
        h.finish()
53
    }
53
    fn node_style_hash(dom: &crate::dom::Dom) -> u64 {
        use core::hash::{Hash, Hasher};
53
        let mut h = crate::hash::DefaultHasher::new();
53
        for (prop, conds) in dom.root.style.iter_inline_properties() {
1
            prop.hash(&mut h);
1
            conds.as_slice().len().hash(&mut h);
1
        }
        // Attached .with_css() sheets — subtree-scoped by construction, so
        // they belong to THIS node's style identity.
53
        dom.css.as_ref().len().hash(&mut h);
53
        for css in dom.css.as_ref() {
2
            for rule in css.rules.as_ref() {
2
                rule.path.hash(&mut h);
2
                for decl in rule.declarations.as_ref() {
2
                    decl.hash(&mut h);
2
                }
                // DynamicSelector carries f32 media thresholds and derives no
                // Hash — the Debug repr is the stable identity here (rare
                // path: only @-rule-conditioned blocks have any).
2
                for cond in rule.conditions.as_ref() {
                    alloc::format!("{cond:?}").hash(&mut h);
                }
2
                rule.priority.hash(&mut h);
            }
        }
53
        h.finish()
53
    }
53
    fn walk(
53
        dom: &crate::dom::Dom,
53
        fp: &mut DomFingerprints,
53
        transfers: &mut PreCascadeTransfers,
53
    ) {
        use crate::dom::NodeType;
        use crate::resources::DecodedImage;
53
        let idx = fp.structure.len();
53
        fp.structure
53
            .push(node_structure_hash(&dom.root, dom.children.as_ref().len()));
53
        fp.style.push(node_style_hash(dom));
53
        if let NodeType::Image(img) = dom.root.get_node_type() {
            if let DecodedImage::Callback(cb) = img.get_data() {
                transfers.image_callbacks.push((idx, cb.clone()));
            }
53
        }
53
        if !dom.root.callbacks.as_ref().is_empty() {
            transfers.callbacks.push((idx, dom.root.callbacks.clone()));
53
        }
53
        for child in dom.children.as_ref() {
40
            walk(child, fp, transfers);
40
        }
53
    }
13
    let mut fp = DomFingerprints {
13
        structure: Vec::new(),
13
        style: Vec::new(),
13
        structure_root: 0,
13
        style_root: 0,
13
    };
13
    let mut transfers = PreCascadeTransfers::default();
13
    walk(dom, &mut fp, &mut transfers);
13
    let mut hs = crate::hash::DefaultHasher::new();
66
    for v in &fp.structure {
53
        v.hash(&mut hs);
53
    }
13
    fp.structure_root = hs.finish();
13
    let mut hy = crate::hash::DefaultHasher::new();
66
    for v in &fp.style {
53
        v.hash(&mut hy);
53
    }
13
    fp.style_root = hy.finish();
13
    (fp, transfers)
13
}
#[cfg(test)]
mod dom_fingerprint_tests {
    use super::*;
    use crate::dom::{Dom, NodeType};
11
    fn sample_dom() -> Dom {
11
        Dom::create_node(NodeType::Div)
11
            .with_class("page".into())
11
            .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("hello"))
11
            .with_child(
11
                Dom::create_node(NodeType::Div)
11
                    .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("world")),
            )
11
    }
    #[test]
1
    fn identical_independently_built_doms_fingerprint_equal_on_both_tiers() {
1
        let (a, _) = fingerprint_dom(&sample_dom());
1
        let (b, _) = fingerprint_dom(&sample_dom());
1
        assert_eq!(a.structure_root, b.structure_root);
1
        assert_eq!(a.style_root, b.style_root);
1
        assert_eq!(a.structure, b.structure);
1
        assert_eq!(a.style, b.style);
        // preorder: root, text, div, text = 4 nodes
1
        assert_eq!(a.structure.len(), 4);
1
    }
    #[test]
1
    fn text_change_moves_exactly_one_structure_hash_and_no_style_hash() {
1
        let (a, _) = fingerprint_dom(&sample_dom());
1
        let changed = Dom::create_node(NodeType::Div)
1
            .with_class("page".into())
1
            .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("hellX"))
1
            .with_child(
1
                Dom::create_node(NodeType::Div)
1
                    .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("world")),
            );
1
        let (b, _) = fingerprint_dom(&changed);
1
        assert_ne!(a.structure_root, b.structure_root, "text is structure");
1
        assert_eq!(a.style_root, b.style_root, "text change must not touch the style tier");
1
        let diffs: Vec<usize> = (0..a.structure.len())
4
            .filter(|&i| a.structure[i] != b.structure[i])
1
            .collect();
        // "hello" is the root's first child → preorder index 1
1
        assert_eq!(diffs, alloc::vec![1], "exactly the edited text node differs");
1
    }
    #[test]
1
    fn with_css_sheet_change_is_style_tier_only() {
2
        let base = || sample_dom();
1
        let (a, _) = fingerprint_dom(&base().with_css("div { color: red; }"));
1
        let (b, _) = fingerprint_dom(&base().with_css("div { color: blue; }"));
1
        assert_eq!(a.structure_root, b.structure_root, "css is EXCLUDED from structure");
1
        assert_ne!(a.style_root, b.style_root, "sheet content is style identity");
        // The sheet hangs on the root → style diff localizes to preorder 0.
1
        let diffs: Vec<usize> = (0..a.style.len())
4
            .filter(|&i| a.style[i] != b.style[i])
1
            .collect();
1
        assert_eq!(diffs, alloc::vec![0]);
1
    }
    #[test]
1
    fn inline_css_change_is_style_tier_only() {
1
        let (a, _) = fingerprint_dom(&sample_dom());
1
        let mut changed = sample_dom();
1
        changed.root.set_css("background: red;");
1
        let (b, _) = fingerprint_dom(&changed);
1
        assert_eq!(a.structure_root, b.structure_root);
1
        assert_ne!(a.style_root, b.style_root);
1
    }
    #[test]
1
    fn class_change_is_structural() {
1
        let (a, _) = fingerprint_dom(&sample_dom());
1
        let changed = Dom::create_node(NodeType::Div)
1
            .with_class("pages".into())
1
            .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("hello"))
1
            .with_child(
1
                Dom::create_node(NodeType::Div)
1
                    .with_child(Dom::create_text_do_not_use_without_block_level_wrapper("world")),
            );
1
        let (b, _) = fingerprint_dom(&changed);
1
        assert_ne!(
            a.structure_root, b.structure_root,
            "a class changes which sheet rules match — structural identity"
        );
1
    }
    #[test]
1
    fn added_child_changes_the_parent_and_the_shape() {
1
        let (a, _) = fingerprint_dom(&sample_dom());
1
        let (b, _) = fingerprint_dom(&sample_dom().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("extra")));
1
        assert_ne!(a.structure_root, b.structure_root);
1
        assert_ne!(a.structure.len(), b.structure.len());
        // The parent's own hash moved too (child count is folded in), so a
        // same-length reshuffle can never alias.
1
        assert_ne!(a.structure[0], b.structure[0]);
1
    }
    #[test]
1
    fn transfers_collect_callback_nodes_at_their_preorder_indices() {
1
        let (_, transfers) = fingerprint_dom(&sample_dom());
1
        assert!(transfers.image_callbacks.is_empty());
1
        assert!(transfers.callbacks.is_empty());
1
    }
}