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