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