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