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}