Skip to main content

toddy_core/
tree.rs

1//! Retained UI tree.
2//!
3//! [`Tree`] holds the current root [`TreeNode`] and supports full
4//! replacement via [`snapshot`](Tree::snapshot) and incremental updates
5//! via [`apply_patch`](Tree::apply_patch). The renderer reads the tree
6//! during `view()` to produce iced widgets; the host mutates it by
7//! sending Snapshot and Patch messages.
8
9use crate::protocol::{PatchOp, TreeNode};
10
11/// Retained tree store. Holds the current root node (if any) and supports
12/// full replacement (snapshot) and incremental patch application.
13#[derive(Debug, Default)]
14pub struct Tree {
15    root: Option<TreeNode>,
16}
17
18impl Tree {
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    /// Replace the entire tree with a new root (snapshot).
24    pub fn snapshot(&mut self, root: TreeNode) {
25        self.root = Some(root);
26    }
27
28    /// Return a reference to the current root, if any.
29    pub fn root(&self) -> Option<&TreeNode> {
30        self.root.as_ref()
31    }
32
33    /// Find a window node by its toddy ID, searching the entire tree recursively.
34    pub fn find_window(&self, toddy_id: &str) -> Option<&TreeNode> {
35        let root = self.root.as_ref()?;
36        find_window_recursive(root, toddy_id)
37    }
38
39    /// Collect the IDs of all window nodes in the tree (recursive search).
40    pub fn window_ids(&self) -> Vec<String> {
41        let Some(root) = self.root.as_ref() else {
42            return Vec::new();
43        };
44        let mut ids = Vec::new();
45        collect_window_ids_recursive(root, &mut ids);
46        ids
47    }
48
49    /// Apply a sequence of patch operations to the tree.
50    ///
51    /// Operations are applied sequentially. If one operation fails, it is
52    /// skipped with a warning and subsequent operations are still applied.
53    /// This means a partial failure can leave the tree in an intermediate
54    /// state. The host should treat patch sequences as best-effort and
55    /// use Snapshot for full-state recovery when needed.
56    pub fn apply_patch(&mut self, ops: Vec<PatchOp>) {
57        for op in ops {
58            if let Err(e) = self.apply_op(&op) {
59                log::error!("failed to apply patch op {:?}: {}", op.op, e);
60            }
61        }
62    }
63
64    fn apply_op(&mut self, op: &PatchOp) -> Result<(), String> {
65        let root = self.root.as_mut().ok_or("no tree to patch")?;
66
67        match op.op.as_str() {
68            "replace_node" => {
69                let node = op
70                    .rest
71                    .get("node")
72                    .ok_or("replace_node: missing 'node' field")?;
73                let new_node: TreeNode = serde_json::from_value(node.clone())
74                    .map_err(|e| format!("replace_node: invalid node: {e}"))?;
75
76                if op.path.is_empty() {
77                    // Replace root
78                    *root = new_node;
79                } else {
80                    let parent = navigate_mut(root, &op.path[..op.path.len() - 1])?;
81                    let idx = *op.path.last().unwrap();
82                    if idx < parent.children.len() {
83                        parent.children[idx] = new_node;
84                    } else {
85                        return Err(format!("replace_node: index {idx} out of bounds"));
86                    }
87                }
88                Ok(())
89            }
90            "update_props" => {
91                let target = navigate_mut(root, &op.path)?;
92                let props = op
93                    .rest
94                    .get("props")
95                    .ok_or("update_props: missing 'props' field")?;
96
97                if !target.props.is_object() {
98                    log::error!(
99                        "update_props: target node '{}' props is not an object: {}",
100                        target.id,
101                        target.props
102                    );
103                    return Ok(());
104                }
105                if !props.is_object() {
106                    log::error!("update_props: patch props is not an object: {}", props);
107                    return Ok(());
108                }
109                let target_map = target.props.as_object_mut().unwrap();
110                let patch_map = props.as_object().unwrap();
111                for (k, v) in patch_map {
112                    if v.is_null() {
113                        target_map.remove(k);
114                    } else {
115                        target_map.insert(k.clone(), v.clone());
116                    }
117                }
118                Ok(())
119            }
120            "insert_child" => {
121                let parent = navigate_mut(root, &op.path)?;
122                let index = op
123                    .rest
124                    .get("index")
125                    .and_then(|v| v.as_u64())
126                    .ok_or("insert_child: missing or invalid 'index'")?
127                    as usize;
128                let node = op
129                    .rest
130                    .get("node")
131                    .ok_or("insert_child: missing 'node' field")?;
132                let new_node: TreeNode = serde_json::from_value(node.clone())
133                    .map_err(|e| format!("insert_child: invalid node: {e}"))?;
134
135                if index <= parent.children.len() {
136                    parent.children.insert(index, new_node);
137                } else {
138                    log::error!(
139                        "insert_child: index {index} is beyond children length {}, appending instead",
140                        parent.children.len()
141                    );
142                    parent.children.push(new_node);
143                }
144                Ok(())
145            }
146            "remove_child" => {
147                let parent = navigate_mut(root, &op.path)?;
148                let index = op
149                    .rest
150                    .get("index")
151                    .and_then(|v| v.as_u64())
152                    .ok_or("remove_child: missing or invalid 'index'")?
153                    as usize;
154
155                if index < parent.children.len() {
156                    parent.children.remove(index);
157                    Ok(())
158                } else {
159                    Err(format!(
160                        "remove_child: index {index} out of bounds (len={})",
161                        parent.children.len()
162                    ))
163                }
164            }
165            other => {
166                log::warn!("unknown patch op: {other}");
167                Ok(())
168            }
169        }
170    }
171}
172
173fn find_window_recursive<'a>(node: &'a TreeNode, toddy_id: &str) -> Option<&'a TreeNode> {
174    if node.type_name == "window" && node.id == toddy_id {
175        return Some(node);
176    }
177    for child in &node.children {
178        if let Some(found) = find_window_recursive(child, toddy_id) {
179            return Some(found);
180        }
181    }
182    None
183}
184
185fn collect_window_ids_recursive(node: &TreeNode, ids: &mut Vec<String>) {
186    if node.type_name == "window" {
187        ids.push(node.id.clone());
188    }
189    for child in &node.children {
190        collect_window_ids_recursive(child, ids);
191    }
192}
193
194/// Navigate to a node at the given path of child indices.
195fn navigate_mut<'a>(root: &'a mut TreeNode, path: &[usize]) -> Result<&'a mut TreeNode, String> {
196    let mut current = root;
197    for &idx in path {
198        if idx < current.children.len() {
199            current = &mut current.children[idx];
200        } else {
201            return Err(format!(
202                "path navigation: index {idx} out of bounds (len={})",
203                current.children.len()
204            ));
205        }
206    }
207    Ok(current)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::protocol::PatchOp;
214    use crate::testing::{node, node_with_children, node_with_props};
215    use serde_json::json;
216
217    fn make_patch_op(op: &str, path: Vec<usize>, rest: serde_json::Value) -> PatchOp {
218        // Deserialize from JSON to get proper PatchOp with flattened rest
219        let mut obj = serde_json::Map::new();
220        obj.insert("op".to_string(), json!(op));
221        obj.insert("path".to_string(), json!(path));
222        if let Some(map) = rest.as_object() {
223            for (k, v) in map {
224                obj.insert(k.clone(), v.clone());
225            }
226        }
227        serde_json::from_value(serde_json::Value::Object(obj)).unwrap()
228    }
229
230    // -----------------------------------------------------------------------
231    // Tree basics
232    // -----------------------------------------------------------------------
233
234    #[test]
235    fn new_tree_is_empty() {
236        let tree = Tree::new();
237        assert!(tree.root().is_none());
238    }
239
240    #[test]
241    fn default_tree_is_empty() {
242        let tree = Tree::default();
243        assert!(tree.root().is_none());
244    }
245
246    #[test]
247    fn snapshot_sets_root() {
248        let mut tree = Tree::new();
249        tree.snapshot(node("root", "column"));
250        assert!(tree.root().is_some());
251        assert_eq!(tree.root().unwrap().id, "root");
252        assert_eq!(tree.root().unwrap().type_name, "column");
253    }
254
255    #[test]
256    fn snapshot_replaces_previous_root() {
257        let mut tree = Tree::new();
258        tree.snapshot(node("first", "column"));
259        tree.snapshot(node("second", "row"));
260        assert_eq!(tree.root().unwrap().id, "second");
261        assert_eq!(tree.root().unwrap().type_name, "row");
262    }
263
264    #[test]
265    fn snapshot_preserves_children() {
266        let mut tree = Tree::new();
267        let root = node_with_children(
268            "root",
269            "column",
270            vec![node("a", "text"), node("b", "button")],
271        );
272        tree.snapshot(root);
273        assert_eq!(tree.root().unwrap().children.len(), 2);
274        assert_eq!(tree.root().unwrap().children[0].id, "a");
275        assert_eq!(tree.root().unwrap().children[1].id, "b");
276    }
277
278    // -----------------------------------------------------------------------
279    // find_window
280    // -----------------------------------------------------------------------
281
282    #[test]
283    fn find_window_at_root() {
284        let mut tree = Tree::new();
285        tree.snapshot(node("main", "window"));
286        let found = tree.find_window("main");
287        assert!(found.is_some());
288        assert_eq!(found.unwrap().id, "main");
289        assert_eq!(found.unwrap().type_name, "window");
290    }
291
292    #[test]
293    fn find_window_root_wrong_id() {
294        let mut tree = Tree::new();
295        tree.snapshot(node("main", "window"));
296        assert!(tree.find_window("other").is_none());
297    }
298
299    #[test]
300    fn find_window_in_children() {
301        let mut tree = Tree::new();
302        let root = node_with_children(
303            "root",
304            "column",
305            vec![node("win1", "window"), node("win2", "window")],
306        );
307        tree.snapshot(root);
308        assert!(tree.find_window("win1").is_some());
309        assert!(tree.find_window("win2").is_some());
310        assert_eq!(tree.find_window("win1").unwrap().id, "win1");
311    }
312
313    #[test]
314    fn find_window_not_found() {
315        let mut tree = Tree::new();
316        tree.snapshot(node("root", "column"));
317        assert!(tree.find_window("nope").is_none());
318    }
319
320    #[test]
321    fn find_window_on_empty_tree() {
322        let tree = Tree::new();
323        assert!(tree.find_window("anything").is_none());
324    }
325
326    #[test]
327    fn find_window_ignores_non_window_children() {
328        let mut tree = Tree::new();
329        let root = node_with_children(
330            "root",
331            "column",
332            vec![
333                node("btn", "button"),
334                node("win", "window"),
335                node("txt", "text"),
336            ],
337        );
338        tree.snapshot(root);
339        assert!(tree.find_window("btn").is_none());
340        assert!(tree.find_window("txt").is_none());
341        assert!(tree.find_window("win").is_some());
342    }
343
344    #[test]
345    fn find_window_searches_grandchildren() {
346        let mut tree = Tree::new();
347        let root = node_with_children(
348            "root",
349            "column",
350            vec![node_with_children(
351                "inner",
352                "row",
353                vec![node("deep_win", "window")],
354            )],
355        );
356        tree.snapshot(root);
357        let found = tree.find_window("deep_win");
358        assert!(found.is_some());
359        assert_eq!(found.unwrap().id, "deep_win");
360    }
361
362    #[test]
363    fn find_window_deeply_nested() {
364        let mut tree = Tree::new();
365        let root = node_with_children(
366            "root",
367            "column",
368            vec![node_with_children(
369                "l1",
370                "row",
371                vec![node_with_children(
372                    "l2",
373                    "column",
374                    vec![node_with_children(
375                        "l3",
376                        "row",
377                        vec![node("buried_win", "window")],
378                    )],
379                )],
380            )],
381        );
382        tree.snapshot(root);
383        let found = tree.find_window("buried_win");
384        assert!(found.is_some());
385        assert_eq!(found.unwrap().id, "buried_win");
386    }
387
388    #[test]
389    fn window_ids_finds_nested_windows() {
390        let mut tree = Tree::new();
391        let root = node_with_children(
392            "root",
393            "column",
394            vec![
395                node("w1", "window"),
396                node_with_children("inner", "row", vec![node("w2", "window")]),
397            ],
398        );
399        tree.snapshot(root);
400        let ids = tree.window_ids();
401        assert_eq!(ids.len(), 2);
402        assert!(ids.contains(&"w1".to_string()));
403        assert!(ids.contains(&"w2".to_string()));
404    }
405
406    // -----------------------------------------------------------------------
407    // window_ids
408    // -----------------------------------------------------------------------
409
410    #[test]
411    fn window_ids_when_root_is_window() {
412        let mut tree = Tree::new();
413        tree.snapshot(node("main", "window"));
414        let ids = tree.window_ids();
415        assert_eq!(ids, vec!["main".to_string()]);
416    }
417
418    #[test]
419    fn window_ids_collects_child_windows() {
420        let mut tree = Tree::new();
421        let root = node_with_children(
422            "root",
423            "column",
424            vec![
425                node("w1", "window"),
426                node("w2", "window"),
427                node("w3", "window"),
428            ],
429        );
430        tree.snapshot(root);
431        let ids = tree.window_ids();
432        assert_eq!(ids.len(), 3);
433        assert!(ids.contains(&"w1".to_string()));
434        assert!(ids.contains(&"w2".to_string()));
435        assert!(ids.contains(&"w3".to_string()));
436    }
437
438    #[test]
439    fn window_ids_skips_non_windows() {
440        let mut tree = Tree::new();
441        let root = node_with_children(
442            "root",
443            "column",
444            vec![
445                node("w1", "window"),
446                node("btn", "button"),
447                node("w2", "window"),
448            ],
449        );
450        tree.snapshot(root);
451        let ids = tree.window_ids();
452        assert_eq!(ids.len(), 2);
453        assert!(!ids.contains(&"btn".to_string()));
454    }
455
456    #[test]
457    fn window_ids_empty_when_no_windows() {
458        let mut tree = Tree::new();
459        tree.snapshot(node("root", "column"));
460        assert!(tree.window_ids().is_empty());
461    }
462
463    #[test]
464    fn window_ids_empty_on_empty_tree() {
465        let tree = Tree::new();
466        assert!(tree.window_ids().is_empty());
467    }
468
469    // -----------------------------------------------------------------------
470    // apply_patch -- replace_node
471    // -----------------------------------------------------------------------
472
473    #[test]
474    fn patch_replace_root() {
475        let mut tree = Tree::new();
476        tree.snapshot(node("old", "column"));
477        let op = make_patch_op(
478            "replace_node",
479            vec![],
480            json!({
481                "node": {"id": "new", "type": "row", "props": {}, "children": []}
482            }),
483        );
484        tree.apply_patch(vec![op]);
485        assert_eq!(tree.root().unwrap().id, "new");
486        assert_eq!(tree.root().unwrap().type_name, "row");
487    }
488
489    #[test]
490    fn patch_replace_child() {
491        let mut tree = Tree::new();
492        let root = node_with_children(
493            "root",
494            "column",
495            vec![node("a", "text"), node("b", "button")],
496        );
497        tree.snapshot(root);
498        let op = make_patch_op(
499            "replace_node",
500            vec![1],
501            json!({
502                "node": {"id": "c", "type": "text", "props": {"content": "replaced"}, "children": []}
503            }),
504        );
505        tree.apply_patch(vec![op]);
506        assert_eq!(tree.root().unwrap().children[1].id, "c");
507        assert_eq!(
508            tree.root().unwrap().children[1].props["content"],
509            "replaced"
510        );
511    }
512
513    #[test]
514    fn patch_replace_nested_child() {
515        let mut tree = Tree::new();
516        let root = node_with_children(
517            "root",
518            "column",
519            vec![node_with_children(
520                "row",
521                "row",
522                vec![node("inner", "text")],
523            )],
524        );
525        tree.snapshot(root);
526        let op = make_patch_op(
527            "replace_node",
528            vec![0, 0],
529            json!({
530                "node": {"id": "replaced", "type": "button", "props": {}, "children": []}
531            }),
532        );
533        tree.apply_patch(vec![op]);
534        assert_eq!(tree.root().unwrap().children[0].children[0].id, "replaced");
535        assert_eq!(
536            tree.root().unwrap().children[0].children[0].type_name,
537            "button"
538        );
539    }
540
541    #[test]
542    fn patch_replace_out_of_bounds_does_not_panic() {
543        let mut tree = Tree::new();
544        tree.snapshot(node("root", "column"));
545        let op = make_patch_op(
546            "replace_node",
547            vec![5],
548            json!({
549                "node": {"id": "x", "type": "text", "props": {}, "children": []}
550            }),
551        );
552        // Should print an error to stderr but not panic
553        tree.apply_patch(vec![op]);
554        // Root is unchanged
555        assert_eq!(tree.root().unwrap().id, "root");
556    }
557
558    // -----------------------------------------------------------------------
559    // apply_patch -- update_props
560    // -----------------------------------------------------------------------
561
562    #[test]
563    fn patch_update_props_on_root() {
564        let mut tree = Tree::new();
565        tree.snapshot(node_with_props("root", "column", json!({"spacing": 5})));
566        let op = make_patch_op(
567            "update_props",
568            vec![],
569            json!({
570                "props": {"spacing": 10, "padding": 20}
571            }),
572        );
573        tree.apply_patch(vec![op]);
574        assert_eq!(tree.root().unwrap().props["spacing"], 10);
575        assert_eq!(tree.root().unwrap().props["padding"], 20);
576    }
577
578    #[test]
579    fn patch_update_props_removes_null_keys() {
580        let mut tree = Tree::new();
581        tree.snapshot(node_with_props(
582            "root",
583            "text",
584            json!({"content": "hi", "size": 14}),
585        ));
586        let op = make_patch_op(
587            "update_props",
588            vec![],
589            json!({
590                "props": {"size": null}
591            }),
592        );
593        tree.apply_patch(vec![op]);
594        assert_eq!(tree.root().unwrap().props["content"], "hi");
595        assert!(tree.root().unwrap().props.get("size").is_none());
596    }
597
598    #[test]
599    fn patch_update_props_on_child() {
600        let mut tree = Tree::new();
601        let root = node_with_children(
602            "root",
603            "column",
604            vec![node_with_props("txt", "text", json!({"content": "old"}))],
605        );
606        tree.snapshot(root);
607        let op = make_patch_op(
608            "update_props",
609            vec![0],
610            json!({
611                "props": {"content": "new"}
612            }),
613        );
614        tree.apply_patch(vec![op]);
615        assert_eq!(tree.root().unwrap().children[0].props["content"], "new");
616    }
617
618    #[test]
619    fn patch_update_props_non_object_target_props_does_not_panic() {
620        let mut tree = Tree::new();
621        // Target has a non-object props value (a string)
622        tree.snapshot(node_with_props("root", "text", json!("not an object")));
623        let op = make_patch_op(
624            "update_props",
625            vec![],
626            json!({
627                "props": {"content": "new"}
628            }),
629        );
630        tree.apply_patch(vec![op]);
631        // Props unchanged -- the merge was skipped
632        assert_eq!(tree.root().unwrap().props, json!("not an object"));
633    }
634
635    #[test]
636    fn patch_update_props_non_object_patch_props_does_not_panic() {
637        let mut tree = Tree::new();
638        tree.snapshot(node_with_props("root", "text", json!({"content": "hi"})));
639        // Patch props is a string, not an object
640        let op = make_patch_op(
641            "update_props",
642            vec![],
643            json!({
644                "props": "not an object"
645            }),
646        );
647        tree.apply_patch(vec![op]);
648        // Props unchanged -- the merge was skipped
649        assert_eq!(tree.root().unwrap().props["content"], "hi");
650    }
651
652    // -----------------------------------------------------------------------
653    // apply_patch -- insert_child
654    // -----------------------------------------------------------------------
655
656    #[test]
657    fn patch_insert_child_at_beginning() {
658        let mut tree = Tree::new();
659        let root = node_with_children("root", "column", vec![node("a", "text")]);
660        tree.snapshot(root);
661        let op = make_patch_op(
662            "insert_child",
663            vec![],
664            json!({
665                "index": 0,
666                "node": {"id": "b", "type": "button", "props": {}, "children": []}
667            }),
668        );
669        tree.apply_patch(vec![op]);
670        assert_eq!(tree.root().unwrap().children.len(), 2);
671        assert_eq!(tree.root().unwrap().children[0].id, "b");
672        assert_eq!(tree.root().unwrap().children[1].id, "a");
673    }
674
675    #[test]
676    fn patch_insert_child_at_end() {
677        let mut tree = Tree::new();
678        let root = node_with_children("root", "column", vec![node("a", "text")]);
679        tree.snapshot(root);
680        let op = make_patch_op(
681            "insert_child",
682            vec![],
683            json!({
684                "index": 1,
685                "node": {"id": "b", "type": "button", "props": {}, "children": []}
686            }),
687        );
688        tree.apply_patch(vec![op]);
689        assert_eq!(tree.root().unwrap().children.len(), 2);
690        assert_eq!(tree.root().unwrap().children[1].id, "b");
691    }
692
693    #[test]
694    fn patch_insert_child_beyond_length_appends() {
695        let mut tree = Tree::new();
696        tree.snapshot(node("root", "column"));
697        let op = make_patch_op(
698            "insert_child",
699            vec![],
700            json!({
701                "index": 99,
702                "node": {"id": "x", "type": "text", "props": {}, "children": []}
703            }),
704        );
705        tree.apply_patch(vec![op]);
706        assert_eq!(tree.root().unwrap().children.len(), 1);
707        assert_eq!(tree.root().unwrap().children[0].id, "x");
708    }
709
710    #[test]
711    fn patch_insert_child_into_nested_parent() {
712        let mut tree = Tree::new();
713        let root = node_with_children(
714            "root",
715            "column",
716            vec![node_with_children(
717                "row",
718                "row",
719                vec![node("existing", "text")],
720            )],
721        );
722        tree.snapshot(root);
723        let op = make_patch_op(
724            "insert_child",
725            vec![0],
726            json!({
727                "index": 0,
728                "node": {"id": "new", "type": "button", "props": {}, "children": []}
729            }),
730        );
731        tree.apply_patch(vec![op]);
732        let row = &tree.root().unwrap().children[0];
733        assert_eq!(row.children.len(), 2);
734        assert_eq!(row.children[0].id, "new");
735        assert_eq!(row.children[1].id, "existing");
736    }
737
738    // -----------------------------------------------------------------------
739    // apply_patch -- remove_child
740    // -----------------------------------------------------------------------
741
742    #[test]
743    fn patch_remove_child() {
744        let mut tree = Tree::new();
745        let root = node_with_children(
746            "root",
747            "column",
748            vec![node("a", "text"), node("b", "button"), node("c", "text")],
749        );
750        tree.snapshot(root);
751        let op = make_patch_op("remove_child", vec![], json!({"index": 1}));
752        tree.apply_patch(vec![op]);
753        assert_eq!(tree.root().unwrap().children.len(), 2);
754        assert_eq!(tree.root().unwrap().children[0].id, "a");
755        assert_eq!(tree.root().unwrap().children[1].id, "c");
756    }
757
758    #[test]
759    fn patch_remove_child_first() {
760        let mut tree = Tree::new();
761        let root = node_with_children(
762            "root",
763            "column",
764            vec![node("a", "text"), node("b", "button")],
765        );
766        tree.snapshot(root);
767        let op = make_patch_op("remove_child", vec![], json!({"index": 0}));
768        tree.apply_patch(vec![op]);
769        assert_eq!(tree.root().unwrap().children.len(), 1);
770        assert_eq!(tree.root().unwrap().children[0].id, "b");
771    }
772
773    #[test]
774    fn patch_remove_child_last() {
775        let mut tree = Tree::new();
776        let root = node_with_children(
777            "root",
778            "column",
779            vec![node("a", "text"), node("b", "button")],
780        );
781        tree.snapshot(root);
782        let op = make_patch_op("remove_child", vec![], json!({"index": 1}));
783        tree.apply_patch(vec![op]);
784        assert_eq!(tree.root().unwrap().children.len(), 1);
785        assert_eq!(tree.root().unwrap().children[0].id, "a");
786    }
787
788    #[test]
789    fn patch_remove_child_out_of_bounds_does_not_panic() {
790        let mut tree = Tree::new();
791        tree.snapshot(node("root", "column"));
792        let op = make_patch_op("remove_child", vec![], json!({"index": 0}));
793        // Should log error, not panic
794        tree.apply_patch(vec![op]);
795        assert!(tree.root().unwrap().children.is_empty());
796    }
797
798    // -----------------------------------------------------------------------
799    // apply_patch -- unknown op
800    // -----------------------------------------------------------------------
801
802    #[test]
803    fn patch_unknown_op_does_not_panic() {
804        let mut tree = Tree::new();
805        tree.snapshot(node("root", "column"));
806        let op = make_patch_op("frobnicate", vec![], json!({}));
807        tree.apply_patch(vec![op]);
808        // Tree should be unchanged
809        assert_eq!(tree.root().unwrap().id, "root");
810    }
811
812    // -----------------------------------------------------------------------
813    // apply_patch -- multiple ops in sequence
814    // -----------------------------------------------------------------------
815
816    #[test]
817    fn patch_multiple_ops_applied_in_order() {
818        let mut tree = Tree::new();
819        tree.snapshot(node("root", "column"));
820
821        let ops = vec![
822            make_patch_op(
823                "insert_child",
824                vec![],
825                json!({
826                    "index": 0,
827                    "node": {"id": "a", "type": "text", "props": {}, "children": []}
828                }),
829            ),
830            make_patch_op(
831                "insert_child",
832                vec![],
833                json!({
834                    "index": 1,
835                    "node": {"id": "b", "type": "text", "props": {}, "children": []}
836                }),
837            ),
838            make_patch_op(
839                "insert_child",
840                vec![],
841                json!({
842                    "index": 1,
843                    "node": {"id": "c", "type": "text", "props": {}, "children": []}
844                }),
845            ),
846        ];
847        tree.apply_patch(ops);
848        let children = &tree.root().unwrap().children;
849        assert_eq!(children.len(), 3);
850        assert_eq!(children[0].id, "a");
851        assert_eq!(children[1].id, "c");
852        assert_eq!(children[2].id, "b");
853    }
854
855    // -----------------------------------------------------------------------
856    // apply_patch on empty tree
857    // -----------------------------------------------------------------------
858
859    #[test]
860    fn patch_on_empty_tree_does_not_panic() {
861        let mut tree = Tree::new();
862        let op = make_patch_op(
863            "replace_node",
864            vec![],
865            json!({
866                "node": {"id": "x", "type": "text", "props": {}, "children": []}
867            }),
868        );
869        tree.apply_patch(vec![op]);
870        // Still empty -- the op should fail gracefully
871        assert!(tree.root().is_none());
872    }
873
874    // -----------------------------------------------------------------------
875    // navigate_mut edge cases (tested indirectly through patch ops)
876    // -----------------------------------------------------------------------
877
878    #[test]
879    fn patch_deep_path_navigation() {
880        let mut tree = Tree::new();
881        let root = node_with_children(
882            "root",
883            "column",
884            vec![node_with_children(
885                "r0",
886                "row",
887                vec![node_with_children(
888                    "r0c0",
889                    "column",
890                    vec![node("deep", "text")],
891                )],
892            )],
893        );
894        tree.snapshot(root);
895        let op = make_patch_op(
896            "update_props",
897            vec![0, 0, 0],
898            json!({
899                "props": {"content": "updated deep"}
900            }),
901        );
902        tree.apply_patch(vec![op]);
903        let deep = &tree.root().unwrap().children[0].children[0].children[0];
904        assert_eq!(deep.props["content"], "updated deep");
905    }
906
907    #[test]
908    fn patch_invalid_path_does_not_panic() {
909        let mut tree = Tree::new();
910        tree.snapshot(node("root", "column"));
911        let op = make_patch_op(
912            "update_props",
913            vec![0, 1, 2],
914            json!({
915                "props": {"x": 1}
916            }),
917        );
918        tree.apply_patch(vec![op]);
919        // Root unchanged
920        assert_eq!(tree.root().unwrap().id, "root");
921    }
922
923    // -----------------------------------------------------------------------
924    // Malformed patch operations (error paths)
925    // -----------------------------------------------------------------------
926
927    #[test]
928    fn patch_replace_node_missing_node_field_does_not_panic() {
929        let mut tree = Tree::new();
930        tree.snapshot(node("root", "column"));
931        // replace_node without the required "node" field
932        let op = make_patch_op("replace_node", vec![], json!({}));
933        tree.apply_patch(vec![op]);
934        // Tree should be unchanged
935        assert_eq!(tree.root().unwrap().id, "root");
936    }
937
938    #[test]
939    fn patch_replace_node_invalid_node_json_does_not_panic() {
940        let mut tree = Tree::new();
941        tree.snapshot(node("root", "column"));
942        // "node" is present but not a valid TreeNode (missing required fields)
943        let op = make_patch_op("replace_node", vec![], json!({"node": {"garbage": true}}));
944        tree.apply_patch(vec![op]);
945        assert_eq!(tree.root().unwrap().id, "root");
946    }
947
948    #[test]
949    fn patch_update_props_missing_props_field_does_not_panic() {
950        let mut tree = Tree::new();
951        tree.snapshot(node_with_props("root", "text", json!({"content": "hi"})));
952        let op = make_patch_op("update_props", vec![], json!({}));
953        tree.apply_patch(vec![op]);
954        // Props unchanged -- the missing "props" field is handled gracefully
955        assert_eq!(tree.root().unwrap().props["content"], "hi");
956    }
957
958    #[test]
959    fn patch_insert_child_missing_index_does_not_panic() {
960        let mut tree = Tree::new();
961        tree.snapshot(node("root", "column"));
962        let op = make_patch_op(
963            "insert_child",
964            vec![],
965            json!({
966                "node": {"id": "x", "type": "text", "props": {}, "children": []}
967            }),
968        );
969        tree.apply_patch(vec![op]);
970        // No child inserted because index is missing
971        assert!(tree.root().unwrap().children.is_empty());
972    }
973
974    #[test]
975    fn patch_insert_child_missing_node_does_not_panic() {
976        let mut tree = Tree::new();
977        tree.snapshot(node("root", "column"));
978        let op = make_patch_op("insert_child", vec![], json!({"index": 0}));
979        tree.apply_patch(vec![op]);
980        assert!(tree.root().unwrap().children.is_empty());
981    }
982
983    #[test]
984    fn patch_remove_child_missing_index_does_not_panic() {
985        let mut tree = Tree::new();
986        let root = node_with_children("root", "column", vec![node("a", "text")]);
987        tree.snapshot(root);
988        let op = make_patch_op("remove_child", vec![], json!({}));
989        tree.apply_patch(vec![op]);
990        // Child should still be present -- the op failed gracefully
991        assert_eq!(tree.root().unwrap().children.len(), 1);
992    }
993}