Skip to main content

inkferro_core/dom/
op.rs

1//! Op enum and `apply` — the in-Rust contract for DOM mutations.
2//!
3//! The flat byte-buffer FFI encoding is M3's surface, not this task.
4//! See `dom/mod.rs` for the error-philosophy and Free-cascade rationale.
5
6use super::arena::Arena;
7use super::node::{AttrValue, Kind, Node, Style, TextStyle};
8
9/// All mutations expressible over the arena.
10#[derive(Debug, Clone, PartialEq)]
11pub enum Op {
12    Create {
13        id: u32,
14        kind: Kind,
15    },
16    AppendChild {
17        parent: u32,
18        child: u32,
19    },
20    InsertBefore {
21        parent: u32,
22        child: u32,
23        before: u32,
24    },
25    RemoveChild {
26        parent: u32,
27        child: u32,
28    },
29    SetText {
30        id: u32,
31        text: String,
32    },
33    SetStyle {
34        id: u32,
35        style: Box<Style>,
36    },
37    SetAttribute {
38        id: u32,
39        key: String,
40        value: AttrValue,
41    },
42    SetTransform {
43        id: u32,
44        has: bool,
45    },
46    /// Inline text styling (P5.1 SET_TEXT_STYLE).  The native render path reads
47    /// this for the guarded-simple `<Text>` styling (P5.1b).
48    SetTextStyle {
49        id: u32,
50        style: TextStyle,
51    },
52    /// Clear inline text styling (P6.2 CLEAR_TEXT_STYLE).  Resets the node's
53    /// `text_styling` to `None` so a styled→plain in-place `<Text>` rerender
54    /// stops rendering the stale native SGR.  The additive sibling of
55    /// `SetTransform { has: false }` for the native text-style field.
56    ClearTextStyle {
57        id: u32,
58    },
59    SetStatic {
60        id: u32,
61        value: bool,
62    },
63    Hide {
64        id: u32,
65    },
66    Unhide {
67        id: u32,
68    },
69    Free {
70        id: u32,
71    },
72}
73
74/// Apply a slice of ops to the arena.
75///
76/// Total function: any op whose ids cannot be resolved is silently skipped.
77/// This mirrors ink's own no-op guard in `removeChildNode` (dom.ts:178-182)
78/// — JS would guard with `indexOf >= 0` rather than throw.
79pub fn apply(arena: &mut Arena, ops: &[Op]) {
80    for op in ops {
81        apply_one(arena, op);
82    }
83}
84
85fn apply_one(arena: &mut Arena, op: &Op) {
86    match op {
87        Op::Create { id, kind } => {
88            // dom.ts:95-112 — createNode allocates a fresh element.
89            arena.insert(*id, Node::new(*kind));
90        }
91
92        Op::AppendChild { parent, child } => {
93            // dom.ts:114-135 — appendChildNode:
94            //   1. validate both ids first — in JS `node` is always a live
95            //      object, so a missing parent is purely a Rust/FFI concern;
96            //      we validate before detaching so an unknown parent never
97            //      orphans the child (atomicity: resolve, then mutate).
98            //   2. detach child from its current parent (move semantics)
99            //   3. set child.parentNode = node
100            //   4. push to node.childNodes
101            if arena.get(*parent).is_none() || arena.get(*child).is_none() {
102                return;
103            }
104            detach(arena, *child);
105            if let Some(c) = arena.get_mut(*child) {
106                c.parent = Some(*parent);
107            }
108            if let Some(p) = arena.get_mut(*parent) {
109                p.children.push(*child);
110            }
111        }
112
113        Op::InsertBefore {
114            parent,
115            child,
116            before,
117        } => {
118            // dom.ts:137-168 — insertBeforeNode:
119            //   1. validate both ids first (same atomicity rationale as AppendChild)
120            //   2. detach child first (detach-before-indexOf avoids stale index
121            //      when child and before share the same parent)
122            //   3. set child.parentNode = node
123            //   4. indexOf(before); splice at index if found, else push to end
124            if arena.get(*parent).is_none() || arena.get(*child).is_none() {
125                return;
126            }
127            detach(arena, *child);
128            if let Some(c) = arena.get_mut(*child) {
129                c.parent = Some(*parent);
130            }
131            let idx = arena
132                .get(*parent)
133                .and_then(|p| p.children.iter().position(|&c| c == *before));
134            if let Some(p) = arena.get_mut(*parent) {
135                match idx {
136                    Some(i) => p.children.insert(i, *child),
137                    // dom.ts:155 — if before not found, append to end
138                    None => p.children.push(*child),
139                }
140            }
141        }
142
143        Op::RemoveChild { parent, child } => {
144            // dom.ts:170-188 — removeChildNode:
145            //   1. clear child.parentNode (unconditional)
146            //   2. indexOf child in parent.childNodes; splice if >= 0
147            if arena.get(*child).is_none() {
148                return;
149            }
150            if let Some(c) = arena.get_mut(*child) {
151                c.parent = None;
152            }
153            // guard: if child not found in parent's list, silently no-op
154            // (dom.ts:178-182 guards with index >= 0)
155            if let Some(p) = arena.get_mut(*parent)
156                && let Some(i) = p.children.iter().position(|&c| c == *child)
157            {
158                p.children.remove(i);
159            }
160        }
161
162        Op::SetText { id, text } => {
163            // dom.ts:262-269 — setTextNodeValue sets nodeValue.
164            // Ink applies `String(text)` coercion; here we receive a String.
165            if let Some(node) = arena.get_mut(*id) {
166                node.text = Some(text.clone());
167            }
168        }
169
170        Op::SetStyle { id, style } => {
171            // dom.ts:203-206 — setStyle: style is always an object.
172            if let Some(node) = arena.get_mut(*id) {
173                node.style = *style.clone();
174            }
175        }
176
177        Op::SetAttribute { id, key, value } => {
178            // dom.ts:200-201 — setAttribute stores into node.attributes.
179            // dom.ts:195-198 (internal_accessibility special-case) is out of
180            // M1-1 scope: it stores an object type incompatible with AttrValue.
181            // `internal_transform` and `internal_static` are NOT routed here;
182            // the reconciler handles them separately (reconciler.ts:231-245).
183            if let Some(node) = arena.get_mut(*id) {
184                if let Some(entry) = node.attributes.iter_mut().find(|(k, _)| k == key) {
185                    entry.1 = value.clone();
186                } else {
187                    node.attributes.push((key.clone(), value.clone()));
188                }
189            }
190        }
191
192        Op::SetTransform { id, has } => {
193            // reconciler.ts:231-235 — internal_transform presence flag. Mutual
194            // exclusion with text_styling (P5.1b): a node taking the JS transform
195            // path (has=true) must NOT retain stale native `text_styling` from a
196            // prior simple-styled state, or `resolve_transform` (which prefers
197            // text_styling) would render the stale style across an in-place
198            // native→JS prop transition (e.g. bold → dimColor+bold). Clearing it
199            // makes the walk fall through to the JS closure. has=false leaves
200            // text_styling for a same-commit SetTextStyle to set.
201            if let Some(node) = arena.get_mut(*id) {
202                node.has_transform = *has;
203                if *has {
204                    node.text_styling = None;
205                }
206            }
207        }
208
209        Op::SetTextStyle { id, style } => {
210            // P5.1(b) native Text-style render: `resolve_transform` reads this for
211            // the guarded-simple path. Clear has_transform so a JS→native in-place
212            // transition (e.g. dimColor+bold → bold) cannot leave a stale JS
213            // dispatch shadowing the native style. Text.tsx emits exactly one of
214            // SetTransform / SetTextStyle per node, so the clears are mutually safe.
215            if let Some(node) = arena.get_mut(*id) {
216                node.text_styling = Some(style.clone());
217                node.has_transform = false;
218            }
219        }
220
221        Op::ClearTextStyle { id } => {
222            // reconciler.ts internal_text_style clear branch — a styled→plain
223            // in-place `<Text>` rerender (e.g. `color={active ? 'red' : undefined}`
224            // when `active` flips false). Without this the node retains its prior
225            // `text_styling = Some(..)`, the diff sees zero changed lines, and the
226            // terminal keeps the stale color where ink renders plain. Resetting to
227            // `None` makes `resolve_transform` fall through to the (now absent) JS
228            // transform → empty chain → plain render, matching ink. This is the
229            // additive mirror of `SetTransform { has: false }` for the native
230            // text-style field; it does NOT touch `has_transform` (a node never
231            // carries both an active native style and a JS transform in one commit).
232            if let Some(node) = arena.get_mut(*id) {
233                node.text_styling = None;
234            }
235        }
236
237        Op::SetStatic { id, value } => {
238            // reconciler.ts:237-244 — internal_static flag.
239            if let Some(node) = arena.get_mut(*id) {
240                node.is_static = *value;
241            }
242        }
243
244        Op::Hide { id } => {
245            // reconciler.ts:269-271 — hideInstance sets yoga DISPLAY_NONE.
246            // Actual yoga effect is JS-side; we record the flag.
247            if let Some(node) = arena.get_mut(*id) {
248                node.is_hidden = true;
249            }
250        }
251
252        Op::Unhide { id } => {
253            // reconciler.ts:272-274 — unhideInstance restores DISPLAY_FLEX.
254            if let Some(node) = arena.get_mut(*id) {
255                node.is_hidden = false;
256            }
257        }
258
259        Op::Free { id } => {
260            // See module docs: Free drops a single slot, no cascade.
261            arena.free(*id);
262        }
263    }
264}
265
266/// Detach `child` from its current parent, updating both the child's
267/// `parent` field and the parent's `children` list.
268///
269/// Mirrors the detach-first step in `appendChildNode` (dom.ts:118-120) and
270/// `insertBeforeNode` (dom.ts:142-144).  No-ops if `child` has no parent or
271/// if any id is unknown.
272fn detach(arena: &mut Arena, child: u32) {
273    let parent_id = match arena.get(child) {
274        Some(n) => n.parent,
275        None => return,
276    };
277    let Some(pid) = parent_id else { return };
278
279    if let Some(c) = arena.get_mut(child) {
280        c.parent = None;
281    }
282    if let Some(p) = arena.get_mut(pid)
283        && let Some(i) = p.children.iter().position(|&c| c == child)
284    {
285        p.children.remove(i);
286    }
287}
288
289// ─── Tests ───────────────────────────────────────────────────────────────────
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn arena_with(ops: &[Op]) -> Arena {
296        let mut a = Arena::new();
297        apply(&mut a, ops);
298        a
299    }
300
301    fn create(id: u32, kind: Kind) -> Op {
302        Op::Create { id, kind }
303    }
304
305    // ── 1. append ordering ───────────────────────────────────────────────────
306    // dom.ts:114-135 — appendChildNode pushes to the end of childNodes.
307    #[test]
308    fn append_ordering() {
309        let a = arena_with(&[
310            create(0, Kind::Root),
311            create(1, Kind::Box),
312            create(2, Kind::Box),
313            create(3, Kind::Box),
314            Op::AppendChild {
315                parent: 0,
316                child: 1,
317            },
318            Op::AppendChild {
319                parent: 0,
320                child: 2,
321            },
322            Op::AppendChild {
323                parent: 0,
324                child: 3,
325            },
326        ]);
327        assert_eq!(a.get(0).unwrap().children, vec![1, 2, 3]);
328        assert_eq!(a.get(1).unwrap().parent, Some(0));
329    }
330
331    // ── 2. insertBefore ordering ─────────────────────────────────────────────
332    // dom.ts:148-153 — splice at indexOf(before) when found.
333    #[test]
334    fn insert_before_ordering() {
335        let a = arena_with(&[
336            create(0, Kind::Root),
337            create(1, Kind::Box),
338            create(2, Kind::Box),
339            create(3, Kind::Box),
340            Op::AppendChild {
341                parent: 0,
342                child: 1,
343            },
344            Op::AppendChild {
345                parent: 0,
346                child: 3,
347            },
348            // insert 2 before 3 → [1, 2, 3]
349            Op::InsertBefore {
350                parent: 0,
351                child: 2,
352                before: 3,
353            },
354        ]);
355        assert_eq!(a.get(0).unwrap().children, vec![1, 2, 3]);
356        assert_eq!(a.get(2).unwrap().parent, Some(0));
357    }
358
359    // ── 3. insertBefore with unknown `before` appends to end ─────────────────
360    // dom.ts:154-162 — if indexOf < 0, push to end (not a panic / throw).
361    #[test]
362    fn insert_before_unknown_appends_end() {
363        let a = arena_with(&[
364            create(0, Kind::Root),
365            create(1, Kind::Box),
366            create(2, Kind::Box),
367            Op::AppendChild {
368                parent: 0,
369                child: 1,
370            },
371            // `before` id 99 does not exist → append
372            Op::InsertBefore {
373                parent: 0,
374                child: 2,
375                before: 99,
376            },
377        ]);
378        assert_eq!(a.get(0).unwrap().children, vec![1, 2]);
379    }
380
381    // ── 4. reparent-move via AppendChild ─────────────────────────────────────
382    // dom.ts:118-120 — if child already has a parent, remove it first.
383    #[test]
384    fn reparent_move_append() {
385        let a = arena_with(&[
386            create(0, Kind::Root),
387            create(1, Kind::Root),
388            create(2, Kind::Box),
389            Op::AppendChild {
390                parent: 0,
391                child: 2,
392            },
393            // move node 2 from parent 0 → parent 1
394            Op::AppendChild {
395                parent: 1,
396                child: 2,
397            },
398        ]);
399        assert_eq!(a.get(0).unwrap().children, vec![] as Vec<u32>);
400        assert_eq!(a.get(1).unwrap().children, vec![2]);
401        assert_eq!(a.get(2).unwrap().parent, Some(1));
402    }
403
404    // ── 5. reparent-move via InsertBefore ────────────────────────────────────
405    // dom.ts:142-144 — detach before computing indexOf to avoid stale index.
406    #[test]
407    fn reparent_move_insert_before_same_parent() {
408        // Move child 1 (currently before 2) to after 2 in same parent.
409        // After detach: children = [2, 3].  InsertBefore(child=1, before=3)
410        // → splice at idx=1 → [2, 1, 3].
411        let a = arena_with(&[
412            create(0, Kind::Root),
413            create(1, Kind::Box),
414            create(2, Kind::Box),
415            create(3, Kind::Box),
416            Op::AppendChild {
417                parent: 0,
418                child: 1,
419            },
420            Op::AppendChild {
421                parent: 0,
422                child: 2,
423            },
424            Op::AppendChild {
425                parent: 0,
426                child: 3,
427            },
428            // move 1 to before 3 (should end up [2, 1, 3])
429            Op::InsertBefore {
430                parent: 0,
431                child: 1,
432                before: 3,
433            },
434        ]);
435        assert_eq!(a.get(0).unwrap().children, vec![2, 1, 3]);
436    }
437
438    // ── 6. removeChild clears parent and removes from list ───────────────────
439    // dom.ts:170-188 — removeChildNode clears parentNode, splices if found.
440    #[test]
441    fn remove_child() {
442        let a = arena_with(&[
443            create(0, Kind::Root),
444            create(1, Kind::Box),
445            create(2, Kind::Box),
446            Op::AppendChild {
447                parent: 0,
448                child: 1,
449            },
450            Op::AppendChild {
451                parent: 0,
452                child: 2,
453            },
454            Op::RemoveChild {
455                parent: 0,
456                child: 1,
457            },
458        ]);
459        assert_eq!(a.get(0).unwrap().children, vec![2]);
460        assert_eq!(a.get(1).unwrap().parent, None);
461    }
462
463    // ── 7. Free drops node; children stay (no cascade) ───────────────────────
464    // detachDeletedInstance is a no-op (reconciler.ts:298).  Free = single slot.
465    #[test]
466    fn free_no_cascade() {
467        let a = arena_with(&[
468            create(0, Kind::Root),
469            create(1, Kind::Box),
470            create(2, Kind::Box),
471            Op::AppendChild {
472                parent: 0,
473                child: 1,
474            },
475            Op::AppendChild {
476                parent: 1,
477                child: 2,
478            },
479            // Remove 1 from root first (detach), then free 1.
480            Op::RemoveChild {
481                parent: 0,
482                child: 1,
483            },
484            Op::Free { id: 1 },
485        ]);
486        // Node 1 dropped; node 2 still lives (children freed separately by reconciler).
487        assert!(a.get(1).is_none());
488        assert!(a.get(2).is_some());
489        // live nodes: root(0) + child(2) = 2
490        assert_eq!(a.live_count(), 2);
491    }
492
493    // ── 8. SetText / text mutation ───────────────────────────────────────────
494    // dom.ts:262-269 — setTextNodeValue sets nodeValue.
495    #[test]
496    fn set_text() {
497        let a = arena_with(&[
498            create(10, Kind::Text),
499            Op::SetText {
500                id: 10,
501                text: "hello".into(),
502            },
503        ]);
504        assert_eq!(a.get(10).unwrap().text.as_deref(), Some("hello"));
505    }
506
507    // dom.ts:262-269 — subsequent SetText overwrites.
508    #[test]
509    fn set_text_overwrite() {
510        let a = arena_with(&[
511            create(10, Kind::VirtualText),
512            Op::SetText {
513                id: 10,
514                text: "hello".into(),
515            },
516            Op::SetText {
517                id: 10,
518                text: "world".into(),
519            },
520        ]);
521        assert_eq!(a.get(10).unwrap().text.as_deref(), Some("world"));
522    }
523
524    // ── 9. attribute mutation ─────────────────────────────────────────────────
525    // dom.ts:190-201 — setAttribute stores key→value.
526    #[test]
527    fn set_attribute_insert_and_update() {
528        let a = arena_with(&[
529            create(5, Kind::Box),
530            Op::SetAttribute {
531                id: 5,
532                key: "onClick".into(),
533                value: AttrValue::Bool(true),
534            },
535            Op::SetAttribute {
536                id: 5,
537                key: "label".into(),
538                value: AttrValue::Str("x".into()),
539            },
540            // update existing key
541            Op::SetAttribute {
542                id: 5,
543                key: "label".into(),
544                value: AttrValue::Str("y".into()),
545            },
546        ]);
547        let attrs = &a.get(5).unwrap().attributes;
548        assert_eq!(attrs.len(), 2);
549        assert!(
550            attrs
551                .iter()
552                .any(|(k, v)| k == "onClick" && *v == AttrValue::Bool(true))
553        );
554        assert!(
555            attrs
556                .iter()
557                .any(|(k, v)| k == "label" && *v == AttrValue::Str("y".into()))
558        );
559    }
560
561    // ── 10. flags: SetStatic, SetTransform, Hide/Unhide ──────────────────────
562    // reconciler.ts:237-244 — internal_static flag.
563    // reconciler.ts:231-235 — internal_transform presence.
564    // reconciler.ts:269-274 — hide/unhide toggle is_hidden.
565    #[test]
566    fn flags() {
567        let a = arena_with(&[
568            create(7, Kind::Box),
569            Op::SetStatic { id: 7, value: true },
570            Op::SetTransform { id: 7, has: true },
571            Op::Hide { id: 7 },
572        ]);
573        let n = a.get(7).unwrap();
574        assert!(n.is_static);
575        assert!(n.has_transform);
576        assert!(n.is_hidden);
577    }
578
579    #[test]
580    fn unhide_clears_flag() {
581        let a = arena_with(&[
582            create(7, Kind::Box),
583            Op::Hide { id: 7 },
584            Op::Unhide { id: 7 },
585        ]);
586        assert!(!a.get(7).unwrap().is_hidden);
587    }
588
589    // ── 10b. SetTransform / SetTextStyle mutually clear (P5.1b transition guard) ──
590    // An in-place native↔JS style transition must not leave stale state that
591    // `resolve_transform` (text_styling-first) would prefer. SetTransform{has:true}
592    // clears text_styling; SetTextStyle clears has_transform. Without this, e.g.
593    // bold→dimColor+bold would emit only SetTransform and keep the stale native
594    // {bold}, rendering bold instead of the JS dim+bold.
595    #[test]
596    fn text_style_transform_mutual_clear() {
597        let bold = || TextStyle {
598            bold: true,
599            ..Default::default()
600        };
601
602        // native (text_styling) → JS (SetTransform has=true): native state cleared.
603        let a = arena_with(&[
604            create(7, Kind::Box),
605            Op::SetTextStyle {
606                id: 7,
607                style: bold(),
608            },
609            Op::SetTransform { id: 7, has: true },
610        ]);
611        let n = a.get(7).unwrap();
612        assert!(n.has_transform);
613        assert!(
614            n.text_styling.is_none(),
615            "SetTransform{{has:true}} must clear stale text_styling"
616        );
617
618        // JS (SetTransform has=true) → native (SetTextStyle): JS flag cleared.
619        let b = arena_with(&[
620            create(8, Kind::Box),
621            Op::SetTransform { id: 8, has: true },
622            Op::SetTextStyle {
623                id: 8,
624                style: bold(),
625            },
626        ]);
627        let m = b.get(8).unwrap();
628        assert!(m.text_styling.is_some());
629        assert!(
630            !m.has_transform,
631            "SetTextStyle must clear stale has_transform"
632        );
633
634        // SetTransform{has:false} must NOT clear text_styling. Apply SetTextStyle
635        // FIRST so the assertion fails if has=false wrongly cleared it (ordering
636        // a later SetTextStyle would otherwise mask the bug by restoring it).
637        let c = arena_with(&[
638            create(9, Kind::Box),
639            Op::SetTextStyle {
640                id: 9,
641                style: bold(),
642            },
643            Op::SetTransform { id: 9, has: false },
644        ]);
645        let p = c.get(9).unwrap();
646        assert!(
647            p.text_styling.is_some(),
648            "SetTransform{{has:false}} must NOT clear text_styling"
649        );
650        assert!(!p.has_transform);
651    }
652
653    // ── 10c. ClearTextStyle resets text_styling to None (P6.2 styled→plain) ──
654    // A styled→plain in-place `<Text>` rerender: the node was native-styled
655    // (SetTextStyle), then the style is removed. ClearTextStyle must reset
656    // text_styling to None so `resolve_transform` renders plain (matching ink).
657    // Without it the node keeps Some(..) and the terminal shows the stale color.
658    #[test]
659    fn clear_text_style_resets_to_none() {
660        let a = arena_with(&[
661            create(7, Kind::Text),
662            Op::SetTextStyle {
663                id: 7,
664                style: TextStyle {
665                    color: Some("red".into()),
666                    ..Default::default()
667                },
668            },
669            Op::ClearTextStyle { id: 7 },
670        ]);
671        assert_eq!(
672            a.get(7).unwrap().text_styling,
673            None,
674            "ClearTextStyle must reset text_styling to None"
675        );
676        // Mutation-discriminating control: WITHOUT the clear the style persists,
677        // so the None assertion above is non-vacuous.
678        let b = arena_with(&[
679            create(8, Kind::Text),
680            Op::SetTextStyle {
681                id: 8,
682                style: TextStyle {
683                    color: Some("red".into()),
684                    ..Default::default()
685                },
686            },
687        ]);
688        assert!(
689            b.get(8).unwrap().text_styling.is_some(),
690            "control: without ClearTextStyle the native style persists"
691        );
692    }
693
694    // ── 10d. styled → plain → styled-AGAIN: the clear is NON-DESTRUCTIVE ──────
695    // A toggle like `color={active ? 'red' : undefined}` flips BOTH ways: after a
696    // ClearTextStyle wipes the field, a later SetTextStyle must be able to re-style
697    // the SAME node. ClearTextStyle only resets the value to None; it must not wedge
698    // the node into a permanently-plain state. This pins the full round trip:
699    // SetTextStyle(red) → Some, ClearTextStyle → None, SetTextStyle(blue) → Some(blue).
700    #[test]
701    fn set_text_style_after_clear_restyles_node() {
702        let a = arena_with(&[
703            create(7, Kind::Text),
704            Op::SetTextStyle {
705                id: 7,
706                style: TextStyle {
707                    color: Some("red".into()),
708                    ..Default::default()
709                },
710            },
711        ]);
712        // Styled: the native style is present (discriminates a never-styled node).
713        assert_eq!(
714            a.get(7).unwrap().text_styling,
715            Some(TextStyle {
716                color: Some("red".into()),
717                ..Default::default()
718            }),
719            "after SetTextStyle the node carries the red native style"
720        );
721
722        // Clear: text_styling resets to None (the styled→plain leg).
723        let b = arena_with(&[
724            create(7, Kind::Text),
725            Op::SetTextStyle {
726                id: 7,
727                style: TextStyle {
728                    color: Some("red".into()),
729                    ..Default::default()
730                },
731            },
732            Op::ClearTextStyle { id: 7 },
733        ]);
734        assert_eq!(
735            b.get(7).unwrap().text_styling,
736            None,
737            "ClearTextStyle resets text_styling to None"
738        );
739
740        // Re-style AGAIN after the clear: a fresh SetTextStyle must take hold with
741        // the NEW color (proves the clear did not destroy the field's capacity to be
742        // set, and that the prior red did not bleed through).
743        let c = arena_with(&[
744            create(7, Kind::Text),
745            Op::SetTextStyle {
746                id: 7,
747                style: TextStyle {
748                    color: Some("red".into()),
749                    ..Default::default()
750                },
751            },
752            Op::ClearTextStyle { id: 7 },
753            Op::SetTextStyle {
754                id: 7,
755                style: TextStyle {
756                    color: Some("blue".into()),
757                    ..Default::default()
758                },
759            },
760        ]);
761        let n = c.get(7).unwrap();
762        assert!(
763            n.text_styling.is_some(),
764            "re-style after clear must take hold (clear is non-destructive)"
765        );
766        assert_eq!(
767            n.text_styling.as_ref().unwrap().color.as_deref(),
768            Some("blue"),
769            "the re-styled node carries the NEW color (blue), not the cleared red"
770        );
771    }
772
773    // ── 11. malformed ops do NOT panic ───────────────────────────────────────
774    // dom.ts guards: indexOf >= 0 before splice (dom.ts:178-182 and 149-163).
775    // ink's JS would silently no-op or guard; we match that.
776    #[test]
777    fn malformed_op_no_panic() {
778        let mut a = Arena::new();
779        // All these reference ids that do not exist — must not panic.
780        apply(
781            &mut a,
782            &[
783                Op::AppendChild {
784                    parent: 99,
785                    child: 42,
786                },
787                Op::InsertBefore {
788                    parent: 99,
789                    child: 42,
790                    before: 1,
791                },
792                Op::RemoveChild {
793                    parent: 99,
794                    child: 42,
795                },
796                Op::SetText {
797                    id: 99,
798                    text: "x".into(),
799                },
800                Op::SetStyle {
801                    id: 99,
802                    style: Box::new(Style::default()),
803                },
804                Op::SetAttribute {
805                    id: 99,
806                    key: "k".into(),
807                    value: AttrValue::Bool(false),
808                },
809                Op::SetTransform { id: 99, has: true },
810                Op::ClearTextStyle { id: 99 },
811                Op::SetStatic {
812                    id: 99,
813                    value: true,
814                },
815                Op::Hide { id: 99 },
816                Op::Unhide { id: 99 },
817                Op::Free { id: 99 },
818            ],
819        );
820        // Nothing was created — arena stays empty.
821        assert_eq!(a.live_count(), 0);
822    }
823
824    // ── 11b. AppendChild with unknown parent does NOT orphan the child ──────────
825    // Codex review finding: detaching before validation would orphan the child
826    // even though the op is supposed to be a no-op.  Fix: validate both ids
827    // before calling detach (atomic resolve-then-mutate).
828    #[test]
829    fn append_child_unknown_parent_does_not_orphan() {
830        let a = arena_with(&[
831            create(0, Kind::Root),
832            create(1, Kind::Box),
833            Op::AppendChild {
834                parent: 0,
835                child: 1,
836            },
837            // parent 99 doesn't exist — child 1 must stay under parent 0
838            Op::AppendChild {
839                parent: 99,
840                child: 1,
841            },
842        ]);
843        assert_eq!(a.get(1).unwrap().parent, Some(0));
844        assert!(a.get(0).unwrap().children.contains(&1));
845    }
846
847    // ── 11c. InsertBefore with unknown parent does NOT orphan the child ──────────
848    #[test]
849    fn insert_before_unknown_parent_does_not_orphan() {
850        let a = arena_with(&[
851            create(0, Kind::Root),
852            create(1, Kind::Box),
853            create(2, Kind::Box),
854            Op::AppendChild {
855                parent: 0,
856                child: 1,
857            },
858            Op::AppendChild {
859                parent: 0,
860                child: 2,
861            },
862            // parent 99 doesn't exist — child 1 must stay under parent 0
863            Op::InsertBefore {
864                parent: 99,
865                child: 1,
866                before: 2,
867            },
868        ]);
869        assert_eq!(a.get(1).unwrap().parent, Some(0));
870        assert!(a.get(0).unwrap().children.contains(&1));
871    }
872
873    // ── 12. AppendChild-move is idempotent for child already at end ───────────
874    // dom.ts:118-123 — detach then append; re-appending the same child is safe.
875    #[test]
876    fn append_existing_child_moves_to_end() {
877        let a = arena_with(&[
878            create(0, Kind::Root),
879            create(1, Kind::Box),
880            create(2, Kind::Box),
881            Op::AppendChild {
882                parent: 0,
883                child: 1,
884            },
885            Op::AppendChild {
886                parent: 0,
887                child: 2,
888            },
889            // Re-append child 1 → detaches then appends → [2, 1]
890            Op::AppendChild {
891                parent: 0,
892                child: 1,
893            },
894        ]);
895        assert_eq!(a.get(0).unwrap().children, vec![2, 1]);
896    }
897
898    // ── 13. SetStyle stores style placeholder ────────────────────────────────
899    #[test]
900    fn set_style() {
901        let a = arena_with(&[
902            create(3, Kind::Box),
903            Op::SetStyle {
904                id: 3,
905                style: Box::new(Style::default()),
906            },
907        ]);
908        assert_eq!(a.get(3).unwrap().style, Style::default());
909    }
910
911    // ── 14. Create on occupied slot is a no-op ───────────────────────────────
912    // Arena doc: React allocates each id once; double-create is a protocol error.
913    #[test]
914    fn create_occupied_slot_noop() {
915        let mut a = Arena::new();
916        apply(&mut a, &[create(0, Kind::Root)]);
917        // Give node 0 a child so we can detect if it was overwritten.
918        apply(
919            &mut a,
920            &[
921                create(1, Kind::Box),
922                Op::AppendChild {
923                    parent: 0,
924                    child: 1,
925                },
926            ],
927        );
928        // Second Create on id=0 with a different kind — should be ignored.
929        apply(&mut a, &[create(0, Kind::Box)]);
930        assert_eq!(a.get(0).unwrap().kind, Kind::Root);
931        assert_eq!(a.get(0).unwrap().children, vec![1]);
932    }
933
934    // ── 15. Realistic tree in one apply() batch ──────────────────────────────
935    // Cross-op integration: the reconciler sends one op buffer per commit, so a
936    // whole mount arrives as a single batch. Pins ordering across op kinds that
937    // the per-op unit tests each miss.
938    #[test]
939    fn build_realistic_tree_in_one_batch() {
940        let a = arena_with(&[
941            create(0, Kind::Root),
942            create(1, Kind::Box),
943            create(2, Kind::Text),
944            Op::AppendChild {
945                parent: 0,
946                child: 1,
947            },
948            Op::AppendChild {
949                parent: 1,
950                child: 2,
951            },
952            Op::SetText {
953                id: 2,
954                text: "hi".into(),
955            },
956            Op::SetAttribute {
957                id: 1,
958                key: "flexDirection".into(),
959                value: AttrValue::Str("row".into()),
960            },
961        ]);
962        assert_eq!(a.get(0).unwrap().children, vec![1]);
963        assert_eq!(a.get(1).unwrap().children, vec![2]);
964        assert_eq!(a.get(2).unwrap().parent, Some(1));
965        assert_eq!(a.get(2).unwrap().text.as_deref(), Some("hi"));
966        assert_eq!(
967            a.get(1).unwrap().attributes,
968            vec![("flexDirection".to_owned(), AttrValue::Str("row".into()))]
969        );
970        assert_eq!(a.live_count(), 3);
971    }
972}