Skip to main content

hypen_engine/wasm/
ffi.rs

1//! Shared FFI utilities for WASM bindings
2//!
3//! This module contains helpers and types used by both JS and WASI bindings.
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::EngineError;
8
9/// Extract changed paths from a state patch JSON value
10pub fn extract_changed_paths(patch: &serde_json::Value) -> Vec<String> {
11    let mut paths = Vec::new();
12    extract_paths_recursive(patch, String::new(), &mut paths);
13    paths
14}
15
16fn extract_paths_recursive(value: &serde_json::Value, prefix: String, paths: &mut Vec<String>) {
17    if let serde_json::Value::Object(map) = value {
18        for (key, val) in map {
19            let path = if prefix.is_empty() {
20                key.clone()
21            } else {
22                format!("{}.{}", prefix, key)
23            };
24            paths.push(path.clone());
25            extract_paths_recursive(val, path, paths);
26        }
27    }
28}
29
30/// Result type for FFI operations that can be serialized
31#[derive(Debug, Serialize, Deserialize)]
32#[serde(tag = "status")]
33pub enum FfiResult<T> {
34    #[serde(rename = "ok")]
35    Ok { value: T },
36    #[serde(rename = "error")]
37    Error { message: String },
38}
39
40impl<T> From<Result<T, String>> for FfiResult<T> {
41    fn from(result: Result<T, String>) -> Self {
42        match result {
43            Ok(value) => FfiResult::Ok { value },
44            Err(message) => FfiResult::Error { message },
45        }
46    }
47}
48
49impl<T> From<Result<T, EngineError>> for FfiResult<T> {
50    fn from(result: Result<T, EngineError>) -> Self {
51        match result {
52            Ok(value) => FfiResult::Ok { value },
53            Err(err) => FfiResult::Error { message: err.to_string() },
54        }
55    }
56}
57
58/// Module configuration for initialization
59#[derive(Debug, Serialize, Deserialize)]
60pub struct ModuleConfig {
61    pub name: String,
62    pub actions: Vec<String>,
63    pub state_keys: Vec<String>,
64    pub initial_state: serde_json::Value,
65}
66
67/// Component resolution result
68#[derive(Debug, Serialize, Deserialize)]
69pub struct ResolvedComponent {
70    pub source: String,
71    pub path: String,
72    #[serde(default)]
73    pub passthrough: bool,
74    #[serde(default)]
75    pub lazy: bool,
76}
77
78/// Action payload for dispatching
79#[derive(Debug, Serialize, Deserialize)]
80pub struct ActionPayload {
81    pub name: String,
82    #[serde(default)]
83    pub payload: serde_json::Value,
84}
85
86/// Sparse state update request
87#[derive(Debug, Serialize, Deserialize)]
88pub struct SparseStateUpdate {
89    pub paths: Vec<String>,
90    pub values: serde_json::Value,
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use serde_json::json;
97
98    // =========================================================================
99    // Path Extraction Tests
100    // =========================================================================
101
102    #[test]
103    fn test_extract_changed_paths_nested() {
104        let patch = json!({
105            "user": {
106                "name": "Alice",
107                "age": 30
108            }
109        });
110
111        let paths = extract_changed_paths(&patch);
112        assert!(paths.contains(&"user".to_string()));
113        assert!(paths.contains(&"user.name".to_string()));
114        assert!(paths.contains(&"user.age".to_string()));
115    }
116
117    #[test]
118    fn test_extract_changed_paths_flat() {
119        let patch = json!({
120            "count": 42,
121            "name": "test"
122        });
123
124        let paths = extract_changed_paths(&patch);
125        assert!(paths.contains(&"count".to_string()));
126        assert!(paths.contains(&"name".to_string()));
127        assert_eq!(paths.len(), 2);
128    }
129
130    #[test]
131    fn test_extract_changed_paths_deeply_nested() {
132        let patch = json!({
133            "a": {
134                "b": {
135                    "c": {
136                        "d": "value"
137                    }
138                }
139            }
140        });
141
142        let paths = extract_changed_paths(&patch);
143        assert!(paths.contains(&"a".to_string()));
144        assert!(paths.contains(&"a.b".to_string()));
145        assert!(paths.contains(&"a.b.c".to_string()));
146        assert!(paths.contains(&"a.b.c.d".to_string()));
147    }
148
149    #[test]
150    fn test_extract_changed_paths_empty() {
151        let patch = json!({});
152        let paths = extract_changed_paths(&patch);
153        assert!(paths.is_empty());
154    }
155
156    #[test]
157    fn test_extract_changed_paths_primitive() {
158        let patch = json!(42);
159        let paths = extract_changed_paths(&patch);
160        assert!(paths.is_empty()); // Root primitive has no path
161    }
162
163    #[test]
164    fn test_extract_changed_paths_array() {
165        let patch = json!({
166            "items": [1, 2, 3]
167        });
168
169        let paths = extract_changed_paths(&patch);
170        assert!(paths.contains(&"items".to_string()));
171        // Arrays are treated as leaf values, not traversed
172        assert_eq!(paths.len(), 1);
173    }
174
175    #[test]
176    fn test_extract_changed_paths_mixed_types() {
177        let patch = json!({
178            "string_val": "hello",
179            "number_val": 42,
180            "bool_val": true,
181            "null_val": null,
182            "array_val": [1, 2],
183            "nested": {"key": "value"}
184        });
185
186        let paths = extract_changed_paths(&patch);
187        assert!(paths.contains(&"string_val".to_string()));
188        assert!(paths.contains(&"number_val".to_string()));
189        assert!(paths.contains(&"bool_val".to_string()));
190        assert!(paths.contains(&"null_val".to_string()));
191        assert!(paths.contains(&"array_val".to_string()));
192        assert!(paths.contains(&"nested".to_string()));
193        assert!(paths.contains(&"nested.key".to_string()));
194        assert_eq!(paths.len(), 7);
195    }
196
197    #[test]
198    fn test_extract_changed_paths_special_characters_in_keys() {
199        let patch = json!({
200            "key-with-dash": 1,
201            "key_with_underscore": 2,
202            "key.with.dots": 3
203        });
204
205        let paths = extract_changed_paths(&patch);
206        assert!(paths.contains(&"key-with-dash".to_string()));
207        assert!(paths.contains(&"key_with_underscore".to_string()));
208        assert!(paths.contains(&"key.with.dots".to_string()));
209    }
210
211    #[test]
212    fn test_extract_changed_paths_unicode_keys() {
213        let patch = json!({
214            "日本語": "value",
215            "emoji🎉": "party"
216        });
217
218        let paths = extract_changed_paths(&patch);
219        assert!(paths.contains(&"日本語".to_string()));
220        assert!(paths.contains(&"emoji🎉".to_string()));
221    }
222
223    #[test]
224    fn test_extract_changed_paths_numeric_string_keys() {
225        let patch = json!({
226            "0": "first",
227            "1": "second",
228            "100": "hundredth"
229        });
230
231        let paths = extract_changed_paths(&patch);
232        assert!(paths.contains(&"0".to_string()));
233        assert!(paths.contains(&"1".to_string()));
234        assert!(paths.contains(&"100".to_string()));
235    }
236
237    // =========================================================================
238    // FfiResult Tests
239    // =========================================================================
240
241    #[test]
242    fn test_ffi_result_ok_serialization() {
243        let ok_result: FfiResult<i32> = FfiResult::Ok { value: 42 };
244        let json = serde_json::to_string(&ok_result).unwrap();
245        assert!(json.contains("\"status\":\"ok\""));
246        assert!(json.contains("\"value\":42"));
247    }
248
249    #[test]
250    fn test_ffi_result_error_serialization() {
251        let err_result: FfiResult<i32> = FfiResult::Error {
252            message: "Something went wrong".to_string(),
253        };
254        let json = serde_json::to_string(&err_result).unwrap();
255        assert!(json.contains("\"status\":\"error\""));
256        assert!(json.contains("Something went wrong"));
257    }
258
259    #[test]
260    fn test_ffi_result_from_result_ok() {
261        let result: Result<String, String> = Ok("success".to_string());
262        let ffi_result: FfiResult<String> = result.into();
263        match ffi_result {
264            FfiResult::Ok { value } => assert_eq!(value, "success"),
265            FfiResult::Error { .. } => panic!("Expected Ok"),
266        }
267    }
268
269    #[test]
270    fn test_ffi_result_from_result_err() {
271        let result: Result<String, String> = Err("failure".to_string());
272        let ffi_result: FfiResult<String> = result.into();
273        match ffi_result {
274            FfiResult::Ok { .. } => panic!("Expected Error"),
275            FfiResult::Error { message } => assert_eq!(message, "failure"),
276        }
277    }
278
279    #[test]
280    fn test_ffi_result_roundtrip() {
281        let original: FfiResult<Vec<i32>> = FfiResult::Ok { value: vec![1, 2, 3] };
282        let json = serde_json::to_string(&original).unwrap();
283        let parsed: FfiResult<Vec<i32>> = serde_json::from_str(&json).unwrap();
284        match parsed {
285            FfiResult::Ok { value } => assert_eq!(value, vec![1, 2, 3]),
286            FfiResult::Error { .. } => panic!("Expected Ok"),
287        }
288    }
289
290    #[test]
291    fn test_ffi_result_with_complex_value() {
292        #[derive(Debug, Serialize, Deserialize, PartialEq)]
293        struct ComplexData {
294            id: u32,
295            name: String,
296            tags: Vec<String>,
297        }
298
299        let data = ComplexData {
300            id: 123,
301            name: "test".to_string(),
302            tags: vec!["a".to_string(), "b".to_string()],
303        };
304
305        let result: FfiResult<ComplexData> = FfiResult::Ok { value: data };
306        let json = serde_json::to_string(&result).unwrap();
307        let parsed: FfiResult<ComplexData> = serde_json::from_str(&json).unwrap();
308
309        match parsed {
310            FfiResult::Ok { value } => {
311                assert_eq!(value.id, 123);
312                assert_eq!(value.name, "test");
313                assert_eq!(value.tags, vec!["a", "b"]);
314            }
315            FfiResult::Error { .. } => panic!("Expected Ok"),
316        }
317    }
318
319    #[test]
320    fn test_ffi_result_error_with_special_chars() {
321        let err: FfiResult<()> = FfiResult::Error {
322            message: "Error: \"quotes\" and 'apostrophes' and\nnewlines".to_string(),
323        };
324        let json = serde_json::to_string(&err).unwrap();
325        let parsed: FfiResult<()> = serde_json::from_str(&json).unwrap();
326
327        match parsed {
328            FfiResult::Error { message } => {
329                assert!(message.contains("quotes"));
330                assert!(message.contains("newlines"));
331            }
332            FfiResult::Ok { .. } => panic!("Expected Error"),
333        }
334    }
335
336    // =========================================================================
337    // ModuleConfig Tests
338    // =========================================================================
339
340    #[test]
341    fn test_module_config_serialization() {
342        let config = ModuleConfig {
343            name: "TestModule".to_string(),
344            actions: vec!["action1".to_string(), "action2".to_string()],
345            state_keys: vec!["count".to_string(), "name".to_string()],
346            initial_state: json!({"count": 0, "name": ""}),
347        };
348
349        let json = serde_json::to_string(&config).unwrap();
350        assert!(json.contains("\"name\":\"TestModule\""));
351        assert!(json.contains("\"actions\""));
352        assert!(json.contains("\"action1\""));
353    }
354
355    #[test]
356    fn test_module_config_deserialization() {
357        let json = r#"{
358            "name": "MyModule",
359            "actions": ["submit", "cancel"],
360            "state_keys": ["value"],
361            "initial_state": {"value": 100}
362        }"#;
363
364        let config: ModuleConfig = serde_json::from_str(json).unwrap();
365        assert_eq!(config.name, "MyModule");
366        assert_eq!(config.actions, vec!["submit", "cancel"]);
367        assert_eq!(config.state_keys, vec!["value"]);
368        assert_eq!(config.initial_state["value"], 100);
369    }
370
371    #[test]
372    fn test_module_config_empty_collections() {
373        let config = ModuleConfig {
374            name: "EmptyModule".to_string(),
375            actions: vec![],
376            state_keys: vec![],
377            initial_state: json!({}),
378        };
379
380        let json = serde_json::to_string(&config).unwrap();
381        let parsed: ModuleConfig = serde_json::from_str(&json).unwrap();
382
383        assert_eq!(parsed.name, "EmptyModule");
384        assert!(parsed.actions.is_empty());
385        assert!(parsed.state_keys.is_empty());
386        assert!(parsed.initial_state.is_object());
387    }
388
389    #[test]
390    fn test_module_config_complex_initial_state() {
391        let config = ModuleConfig {
392            name: "ComplexModule".to_string(),
393            actions: vec!["update".to_string()],
394            state_keys: vec!["user".to_string(), "items".to_string()],
395            initial_state: json!({
396                "user": {
397                    "id": null,
398                    "name": "",
399                    "settings": {
400                        "theme": "dark",
401                        "notifications": true
402                    }
403                },
404                "items": [],
405                "count": 0
406            }),
407        };
408
409        let json = serde_json::to_string(&config).unwrap();
410        let parsed: ModuleConfig = serde_json::from_str(&json).unwrap();
411
412        assert_eq!(parsed.initial_state["user"]["settings"]["theme"], "dark");
413        assert!(parsed.initial_state["user"]["id"].is_null());
414        assert!(parsed.initial_state["items"].is_array());
415    }
416
417    // =========================================================================
418    // ResolvedComponent Tests
419    // =========================================================================
420
421    #[test]
422    fn test_resolved_component_defaults() {
423        let json = r#"{
424            "source": "Text(\"Hello\")",
425            "path": "/components/Hello.hypen"
426        }"#;
427
428        let component: ResolvedComponent = serde_json::from_str(json).unwrap();
429        assert_eq!(component.source, "Text(\"Hello\")");
430        assert_eq!(component.path, "/components/Hello.hypen");
431        assert!(!component.passthrough); // Default false
432        assert!(!component.lazy); // Default false
433    }
434
435    #[test]
436    fn test_resolved_component_with_flags() {
437        let component = ResolvedComponent {
438            source: "".to_string(),
439            path: "/lazy/Component.hypen".to_string(),
440            passthrough: false,
441            lazy: true,
442        };
443
444        let json = serde_json::to_string(&component).unwrap();
445        assert!(json.contains("\"lazy\":true"));
446    }
447
448    #[test]
449    fn test_resolved_component_passthrough() {
450        let component = ResolvedComponent {
451            source: String::new(),
452            path: "/components/Router.hypen".to_string(),
453            passthrough: true,
454            lazy: false,
455        };
456
457        let json = serde_json::to_string(&component).unwrap();
458        let parsed: ResolvedComponent = serde_json::from_str(&json).unwrap();
459
460        assert!(parsed.passthrough);
461        assert!(!parsed.lazy);
462        assert!(parsed.source.is_empty());
463    }
464
465    #[test]
466    fn test_resolved_component_complex_source() {
467        let source = r#"Column {
468    Text("Header")
469    Row {
470        Button("Submit") { Text("OK") }
471        Button("Cancel") { Text("No") }
472    }
473}"#;
474        let component = ResolvedComponent {
475            source: source.to_string(),
476            path: "/components/Form.hypen".to_string(),
477            passthrough: false,
478            lazy: false,
479        };
480
481        let json = serde_json::to_string(&component).unwrap();
482        let parsed: ResolvedComponent = serde_json::from_str(&json).unwrap();
483
484        assert!(parsed.source.contains("Column"));
485        assert!(parsed.source.contains("Button"));
486    }
487
488    // =========================================================================
489    // ActionPayload Tests
490    // =========================================================================
491
492    #[test]
493    fn test_action_payload_minimal() {
494        let json = r#"{"name": "click"}"#;
495        let action: ActionPayload = serde_json::from_str(json).unwrap();
496        assert_eq!(action.name, "click");
497        assert!(action.payload.is_null()); // Default
498    }
499
500    #[test]
501    fn test_action_payload_with_data() {
502        let action = ActionPayload {
503            name: "submit".to_string(),
504            payload: json!({"form": {"email": "test@example.com"}}),
505        };
506
507        let json = serde_json::to_string(&action).unwrap();
508        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
509        assert_eq!(parsed.name, "submit");
510        assert_eq!(parsed.payload["form"]["email"], "test@example.com");
511    }
512
513    #[test]
514    fn test_action_payload_with_array() {
515        let action = ActionPayload {
516            name: "selectItems".to_string(),
517            payload: json!({
518                "ids": [1, 2, 3, 4, 5],
519                "selectAll": true
520            }),
521        };
522
523        let json = serde_json::to_string(&action).unwrap();
524        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
525
526        assert_eq!(parsed.payload["ids"].as_array().unwrap().len(), 5);
527        assert_eq!(parsed.payload["selectAll"], true);
528    }
529
530    #[test]
531    fn test_action_payload_with_primitive_payload() {
532        let action = ActionPayload {
533            name: "setCount".to_string(),
534            payload: json!(42),
535        };
536
537        let json = serde_json::to_string(&action).unwrap();
538        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
539
540        assert_eq!(parsed.payload, 42);
541    }
542
543    #[test]
544    fn test_action_payload_roundtrip() {
545        let original = ActionPayload {
546            name: "complexAction".to_string(),
547            payload: json!({
548                "nested": {
549                    "deeply": {
550                        "value": "found"
551                    }
552                }
553            }),
554        };
555
556        let json = serde_json::to_string(&original).unwrap();
557        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
558
559        assert_eq!(parsed.name, original.name);
560        assert_eq!(
561            parsed.payload["nested"]["deeply"]["value"],
562            original.payload["nested"]["deeply"]["value"]
563        );
564    }
565
566    // =========================================================================
567    // SparseStateUpdate Tests
568    // =========================================================================
569
570    #[test]
571    fn test_sparse_state_update() {
572        let update = SparseStateUpdate {
573            paths: vec!["user.name".to_string(), "count".to_string()],
574            values: json!({
575                "user.name": "Bob",
576                "count": 42
577            }),
578        };
579
580        let json = serde_json::to_string(&update).unwrap();
581        let parsed: SparseStateUpdate = serde_json::from_str(&json).unwrap();
582        assert_eq!(parsed.paths.len(), 2);
583        assert!(parsed.paths.contains(&"user.name".to_string()));
584        assert_eq!(parsed.values["count"], 42);
585    }
586
587    #[test]
588    fn test_sparse_state_update_empty() {
589        let update = SparseStateUpdate {
590            paths: vec![],
591            values: json!({}),
592        };
593
594        let json = serde_json::to_string(&update).unwrap();
595        assert!(json.contains("\"paths\":[]"));
596    }
597
598    #[test]
599    fn test_sparse_state_update_deeply_nested_paths() {
600        let update = SparseStateUpdate {
601            paths: vec![
602                "a.b.c.d".to_string(),
603                "x.y.z".to_string(),
604            ],
605            values: json!({
606                "a.b.c.d": "deep value",
607                "x.y.z": 123
608            }),
609        };
610
611        let json = serde_json::to_string(&update).unwrap();
612        let parsed: SparseStateUpdate = serde_json::from_str(&json).unwrap();
613
614        assert_eq!(parsed.paths.len(), 2);
615        assert_eq!(parsed.values["a.b.c.d"], "deep value");
616        assert_eq!(parsed.values["x.y.z"], 123);
617    }
618
619    #[test]
620    fn test_sparse_state_update_complex_values() {
621        let update = SparseStateUpdate {
622            paths: vec!["user".to_string(), "items".to_string()],
623            values: json!({
624                "user": {"id": 1, "name": "Alice"},
625                "items": [{"id": 1}, {"id": 2}]
626            }),
627        };
628
629        let json = serde_json::to_string(&update).unwrap();
630        let parsed: SparseStateUpdate = serde_json::from_str(&json).unwrap();
631
632        assert_eq!(parsed.values["user"]["id"], 1);
633        assert_eq!(parsed.values["items"].as_array().unwrap().len(), 2);
634    }
635
636    // =========================================================================
637    // WASI FFI Pattern Tests
638    // These tests verify that our types work correctly in FFI scenarios
639    // =========================================================================
640
641    #[test]
642    fn test_json_bytes_roundtrip() {
643        // Simulates WASI FFI pattern: struct -> JSON bytes -> struct
644        let config = ModuleConfig {
645            name: "TestModule".to_string(),
646            actions: vec!["act1".to_string()],
647            state_keys: vec!["key1".to_string()],
648            initial_state: json!({"key1": "value"}),
649        };
650
651        // Convert to bytes (what we'd pass across FFI)
652        let json_bytes = serde_json::to_vec(&config).unwrap();
653
654        // Parse from bytes (what we'd receive on the other side)
655        let parsed: ModuleConfig = serde_json::from_slice(&json_bytes).unwrap();
656
657        assert_eq!(parsed.name, config.name);
658        assert_eq!(parsed.actions, config.actions);
659    }
660
661    #[test]
662    fn test_action_dispatch_pattern() {
663        // Simulates receiving an action from the UI and serializing for host
664        let action = ActionPayload {
665            name: "submitForm".to_string(),
666            payload: json!({
667                "formData": {
668                    "username": "alice",
669                    "remember": true
670                }
671            }),
672        };
673
674        // Serialize to JSON (what we'd return to host)
675        let json = serde_json::to_string(&action).unwrap();
676
677        // Host would parse this JSON
678        let parsed: ActionPayload = serde_json::from_str(&json).unwrap();
679
680        assert_eq!(parsed.name, "submitForm");
681        assert_eq!(parsed.payload["formData"]["username"], "alice");
682    }
683
684    #[test]
685    fn test_state_update_pattern() {
686        // Simulates sparse state update from host
687        let update_json = r#"{
688            "paths": ["counter", "user.lastActive"],
689            "values": {
690                "counter": 10,
691                "user.lastActive": "2024-01-01T00:00:00Z"
692            }
693        }"#;
694
695        let update: SparseStateUpdate = serde_json::from_str(update_json).unwrap();
696
697        // Extract paths that changed
698        assert!(update.paths.contains(&"counter".to_string()));
699        assert!(update.paths.contains(&"user.lastActive".to_string()));
700
701        // Get values
702        assert_eq!(update.values["counter"], 10);
703    }
704
705    #[test]
706    fn test_error_result_pattern() {
707        // Simulates returning an error from engine to host
708        let result: FfiResult<String> = FfiResult::Error {
709            message: "Parse error at line 5: unexpected token".to_string(),
710        };
711
712        let json = serde_json::to_string(&result).unwrap();
713
714        // Host checks status
715        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
716        assert_eq!(parsed["status"], "error");
717        assert!(parsed["message"].as_str().unwrap().contains("line 5"));
718    }
719
720    #[test]
721    fn test_patches_array_pattern() {
722        // Simulates returning patches as JSON array
723        use crate::reconcile::Patch;
724
725        let patches = vec![
726            Patch::Create {
727                id: "node1".to_string(),
728                element_type: "Text".to_string(),
729                props: indexmap::IndexMap::new(),
730            },
731            Patch::SetText {
732                id: "node1".to_string(),
733                text: "Hello World".to_string(),
734            },
735        ];
736
737        let json = serde_json::to_string(&patches).unwrap();
738        let parsed: Vec<Patch> = serde_json::from_str(&json).unwrap();
739
740        assert_eq!(parsed.len(), 2);
741        match &parsed[0] {
742            Patch::Create { element_type, .. } => assert_eq!(element_type, "Text"),
743            _ => panic!("Expected Create patch"),
744        }
745    }
746
747    #[test]
748    fn test_patch_serializes_camelcase_field_names() {
749        // Ensure patches serialize with camelCase field names for JavaScript consumption.
750        // The #[serde(rename_all = "camelCase")] must be on EACH variant, not just the enum.
751        use crate::reconcile::Patch;
752
753        let create = Patch::Create {
754            id: "n1".to_string(),
755            element_type: "Column".to_string(),
756            props: indexmap::IndexMap::new(),
757        };
758        let json = serde_json::to_string(&create).unwrap();
759        assert!(json.contains("\"elementType\""), "Create patch must use camelCase 'elementType', got: {}", json);
760        assert!(!json.contains("\"element_type\""), "Create patch must NOT use snake_case 'element_type', got: {}", json);
761
762        let insert = Patch::Insert {
763            parent_id: "root".to_string(),
764            id: "n1".to_string(),
765            before_id: None,
766        };
767        let json = serde_json::to_string(&insert).unwrap();
768        assert!(json.contains("\"parentId\""), "Insert patch must use camelCase 'parentId', got: {}", json);
769        assert!(json.contains("\"beforeId\""), "Insert patch must use camelCase 'beforeId', got: {}", json);
770        assert!(!json.contains("\"parent_id\""), "Insert patch must NOT use snake_case, got: {}", json);
771
772        let mv = Patch::Move {
773            parent_id: "root".to_string(),
774            id: "n1".to_string(),
775            before_id: Some("n2".to_string()),
776        };
777        let json = serde_json::to_string(&mv).unwrap();
778        assert!(json.contains("\"parentId\""), "Move patch must use camelCase 'parentId', got: {}", json);
779        assert!(json.contains("\"beforeId\""), "Move patch must use camelCase 'beforeId', got: {}", json);
780    }
781}