1use std::collections::HashMap;
10
11use serde_json::Value;
12
13use crate::action::{Action, ActionHandler};
14use crate::spec::{Element, Spec};
15
16fn resolve_action(
25 action: &mut Action,
26 data: &serde_json::Value,
27 resolver: &impl Fn(&str) -> Option<String>,
28) {
29 if action.url.is_some() {
30 return;
31 }
32 let literal: Option<String> = match &action.handler {
36 ActionHandler::Literal(s) => Some(s.clone()),
37 ActionHandler::Binding(d) => crate::data::resolve_path(data, &d.data)
38 .and_then(|v| v.as_str())
39 .map(|s| s.to_string()),
40 };
41 let Some(s) = literal else { return };
42
43 if s.starts_with('/') {
44 action.url = Some(s);
45 return;
46 }
47 if let Some(url) = resolver(&s) {
48 action.url = Some(url);
49 }
50}
51
52pub fn resolve_actions(spec: &mut Spec, resolver: impl Fn(&str) -> Option<String>) {
63 let data = spec.data.clone();
64 for el in spec.elements.values_mut() {
65 if let Some(action) = el.action.as_mut() {
66 resolve_action(action, &data, &resolver);
67 }
68 resolve_actions_in_value(&mut el.props, &data, &resolver);
69 }
70}
71
72fn resolve_actions_in_value(
85 value: &mut Value,
86 data: &Value,
87 resolver: &impl Fn(&str) -> Option<String>,
88) {
89 match value {
90 Value::Object(map) => {
91 let has_handler = map.contains_key("handler");
92 let already_resolved = matches!(map.get("url"), Some(Value::String(_)));
93 if has_handler && !already_resolved {
94 if let Some(url) = resolve_props_handler_to_url(map.get("handler"), data, resolver)
95 {
96 map.insert("url".to_string(), Value::String(url));
97 }
98 }
99 for v in map.values_mut() {
100 resolve_actions_in_value(v, data, resolver);
101 }
102 }
103 Value::Array(arr) => {
104 for v in arr.iter_mut() {
105 resolve_actions_in_value(v, data, resolver);
106 }
107 }
108 _ => {}
109 }
110}
111
112fn resolve_props_handler_to_url(
116 handler: Option<&Value>,
117 data: &Value,
118 resolver: &impl Fn(&str) -> Option<String>,
119) -> Option<String> {
120 let literal: String = match handler? {
121 Value::String(s) => s.clone(),
122 Value::Object(map) if map.len() == 1 => {
123 let path = map.get("$data").and_then(|v| v.as_str())?;
124 crate::data::resolve_path(data, path)
125 .and_then(|v| v.as_str())
126 .map(|s| s.to_string())?
127 }
128 _ => return None,
129 };
130 if literal.starts_with('/') {
131 Some(literal)
132 } else {
133 resolver(&literal)
134 }
135}
136
137pub fn resolve_actions_strict(
141 spec: &mut Spec,
142 resolver: impl Fn(&str) -> Option<String>,
143) -> Result<(), Vec<String>> {
144 let data = spec.data.clone();
145 let mut missing: Vec<String> = Vec::new();
146 for el in spec.elements.values_mut() {
147 if let Some(action) = el.action.as_mut() {
148 resolve_action(action, &data, &resolver);
149 if action.url.is_none() {
150 missing.push(action.handler.as_str().to_string());
151 }
152 }
153 resolve_actions_in_value(&mut el.props, &data, &resolver);
154 }
155 if missing.is_empty() {
156 Ok(())
157 } else {
158 Err(missing)
159 }
160}
161
162pub fn resolve_errors(spec: &mut Spec, errors: &HashMap<String, Vec<String>>) {
165 for el in spec.elements.values_mut() {
166 attach_errors(el, errors, false);
167 }
168}
169
170pub fn resolve_errors_all(spec: &mut Spec, errors: &HashMap<String, Vec<String>>) {
173 for el in spec.elements.values_mut() {
174 attach_errors(el, errors, true);
175 }
176}
177
178fn attach_errors(el: &mut Element, errors: &HashMap<String, Vec<String>>, all: bool) {
179 let Some(props_obj) = el.props.as_object_mut() else {
180 return;
181 };
182 let key = props_obj
185 .get("name")
186 .or_else(|| props_obj.get("field"))
187 .and_then(|v| v.as_str())
188 .map(String::from);
189 if let Some(k) = key {
190 if let Some(msgs) = errors.get(&k) {
191 if let Some(first) = msgs.first() {
192 props_obj.insert("error".to_string(), Value::String(first.clone()));
193 }
194 }
195 } else if all {
196 if let Ok(errors_value) = serde_json::to_value(errors) {
197 props_obj.insert("errors".to_string(), errors_value);
198 }
199 }
200}
201
202pub fn expand_directives(spec: &mut Spec) {
233 let data = spec.data.clone();
234 let if_removed = remove_if_falsy(spec, &data);
237 let each_expanded = expand_each(spec, &data);
239 rewrite_parent_children(spec, &if_removed, &each_expanded);
241}
242
243fn remove_if_falsy(spec: &mut Spec, data: &serde_json::Value) -> std::collections::HashSet<String> {
247 let mut to_delete: Vec<String> = Vec::new();
248 for (id, el) in spec.elements.iter() {
249 if let Some(predicate) = &el.if_ {
250 if !predicate.evaluate(data) {
252 to_delete.push(id.clone());
253 }
254 }
255 }
256 let removed: std::collections::HashSet<String> = to_delete.iter().cloned().collect();
257 for id in &to_delete {
258 spec.elements.remove(id);
259 }
260 for el in spec.elements.values_mut() {
262 if el.if_.is_some() {
263 el.if_ = None;
264 }
265 }
266 removed
267}
268
269fn expand_each(
273 spec: &mut Spec,
274 data: &serde_json::Value,
275) -> std::collections::HashMap<String, Vec<String>> {
276 let templates: Vec<(String, Element)> = spec
279 .elements
280 .iter()
281 .filter_map(|(id, el)| el.each.as_ref().map(|_| (id.clone(), el.clone())))
282 .collect();
283
284 let template_directives: std::collections::HashMap<String, crate::spec::EachDirective> =
285 templates
286 .iter()
287 .map(|(id, el)| (id.clone(), el.each.clone().unwrap()))
288 .collect();
289
290 let mut expanded: std::collections::HashMap<String, Vec<String>> =
291 std::collections::HashMap::new();
292
293 for (tmpl_id, tmpl_el) in &templates {
294 let each = tmpl_el.each.as_ref().unwrap();
295 let rows: Vec<serde_json::Value> = crate::data::resolve_path(data, &each.path)
296 .and_then(|v| v.as_array())
297 .cloned()
298 .unwrap_or_default();
299 let mut clone_ids: Vec<String> = Vec::with_capacity(rows.len());
300 for (i, row) in rows.iter().enumerate() {
301 let clone_id = format!("{tmpl_id}-{i}");
302 let mut clone = tmpl_el.clone();
303 clone.each = None; clone.if_ = None;
305 inline_resolve_row_paths(&mut clone.props, &each.as_, row);
307 if let Some(action) = clone.action.as_mut() {
311 inline_resolve_row_action(action, &each.as_, row);
312 }
313 for child in clone.children.iter_mut() {
315 if let Some(child_each) = template_directives.get(child) {
316 if child_each.path == each.path && child_each.as_ == each.as_ {
317 *child = format!("{child}-{i}");
318 }
319 }
324 }
325 spec.elements.insert(clone_id.clone(), clone);
326 clone_ids.push(clone_id);
327 }
328 spec.elements.remove(tmpl_id);
329 expanded.insert(tmpl_id.clone(), clone_ids);
330 }
331
332 expanded
333}
334
335fn inline_resolve_row_paths(value: &mut serde_json::Value, as_name: &str, row: &serde_json::Value) {
340 let prefix = format!("/{as_name}/");
341 inline_walk(value, &prefix, row, as_name);
342}
343
344fn inline_resolve_row_action(
351 action: &mut crate::action::Action,
352 as_name: &str,
353 row: &serde_json::Value,
354) {
355 use crate::action::ActionHandler;
356 let prefix = format!("/{as_name}/");
357 if let ActionHandler::Binding(d) = &action.handler {
358 if let Some(rest) = d.data.strip_prefix(&prefix) {
359 let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
360 .and_then(|v| v.as_str())
361 .map(|s| s.to_string());
362 if let Some(s) = resolved {
363 action.handler = ActionHandler::Literal(s);
364 }
365 } else if d.data == format!("/{as_name}") {
367 if let Some(s) = row.as_str() {
369 action.handler = ActionHandler::Literal(s.to_string());
370 }
371 }
372 }
374}
375
376fn inline_walk(
377 value: &mut serde_json::Value,
378 prefix: &str,
379 row: &serde_json::Value,
380 as_name: &str,
381) {
382 match value {
383 serde_json::Value::Object(map) => {
384 if map.len() == 1 {
385 if let Some(serde_json::Value::String(path)) = map.get("$data") {
387 if let Some(rest) = path.strip_prefix(prefix) {
388 let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
389 .cloned()
390 .unwrap_or(serde_json::Value::Null);
391 *value = resolved;
392 return;
393 } else if path == &format!("/{as_name}") {
394 *value = row.clone();
396 return;
397 }
398 }
399 if let Some(serde_json::Value::String(tpl)) = map.get("$template") {
403 let interpolated = interpolate_row_template(tpl, prefix, row, as_name);
404 if !contains_template_marker(&interpolated) {
405 *value = serde_json::Value::String(interpolated);
406 return;
407 } else {
408 map.insert(
409 "$template".to_string(),
410 serde_json::Value::String(interpolated),
411 );
412 return;
413 }
414 }
415 }
416 for v in map.values_mut() {
417 inline_walk(v, prefix, row, as_name);
418 }
419 }
420 serde_json::Value::Array(arr) => {
421 for v in arr.iter_mut() {
422 inline_walk(v, prefix, row, as_name);
423 }
424 }
425 _ => {}
426 }
427}
428
429fn interpolate_row_template(
430 tpl: &str,
431 prefix: &str,
432 row: &serde_json::Value,
433 as_name: &str,
434) -> String {
435 let mut out = String::with_capacity(tpl.len());
436 let mut chars = tpl.chars().peekable();
437 while let Some(c) = chars.next() {
438 if c == '{' {
439 let mut path = String::new();
440 let mut closed = false;
441 for nc in chars.by_ref() {
442 if nc == '}' {
443 closed = true;
444 break;
445 }
446 path.push(nc);
447 }
448 if closed {
449 if let Some(rest) = path.strip_prefix(prefix) {
450 let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
451 .map(value_to_string)
452 .unwrap_or_default();
453 out.push_str(&resolved);
454 } else if path == format!("/{as_name}") {
455 out.push_str(&value_to_string(row));
456 } else {
457 out.push('{');
460 out.push_str(&path);
461 out.push('}');
462 }
463 } else {
464 out.push('{');
465 out.push_str(&path);
466 }
467 } else {
468 out.push(c);
469 }
470 }
471 out
472}
473
474fn contains_template_marker(s: &str) -> bool {
475 let mut chars = s.chars().peekable();
478 while let Some(c) = chars.next() {
479 if c == '{' && matches!(chars.peek(), Some('/')) {
480 return true;
481 }
482 }
483 false
484}
485
486fn value_to_string(v: &serde_json::Value) -> String {
487 match v {
488 serde_json::Value::String(s) => s.clone(),
489 serde_json::Value::Null => String::new(),
490 other => other.to_string(),
491 }
492}
493
494fn rewrite_parent_children(
504 spec: &mut Spec,
505 if_removed: &std::collections::HashSet<String>,
506 each_expanded: &std::collections::HashMap<String, Vec<String>>,
507) {
508 for el in spec.elements.values_mut() {
509 let mut new_children: Vec<String> = Vec::with_capacity(el.children.len());
510 for child in el.children.drain(..) {
511 if if_removed.contains(&child) {
512 continue; }
514 if let Some(clones) = each_expanded.get(&child) {
515 new_children.extend(clones.iter().cloned());
516 } else {
517 new_children.push(child);
518 }
519 }
520 el.children = new_children;
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::action::{Action, HttpMethod};
528 use crate::spec::{Element, Spec};
529
530 fn action(handler: &str) -> Action {
531 Action {
532 handler: ActionHandler::Literal(handler.to_string()),
533 url: None,
534 method: HttpMethod::Post,
535 confirm: None,
536 on_success: None,
537 on_error: None,
538 target: None,
539 }
540 }
541
542 #[test]
543 fn resolve_actions_populates_url_from_resolver() {
544 let mut spec = Spec::builder()
545 .element("btn", Element::new("Button").action(action("users.create")))
546 .build()
547 .unwrap();
548
549 resolve_actions(&mut spec, |h| {
550 if h == "users.create" {
551 Some("/users".to_string())
552 } else {
553 None
554 }
555 });
556
557 let el = spec.elements.get("btn").unwrap();
558 assert_eq!(el.action.as_ref().unwrap().url.as_deref(), Some("/users"));
559 }
560
561 fn action_with_binding(path: &str) -> Action {
564 Action {
565 handler: ActionHandler::Binding(crate::spec::DataRef {
566 data: path.to_string(),
567 }),
568 url: None,
569 method: HttpMethod::Get,
570 confirm: None,
571 on_success: None,
572 on_error: None,
573 target: None,
574 }
575 }
576
577 #[test]
578 fn resolve_actions_resolves_binding_to_literal_path_via_spec_data() {
579 let mut spec = Spec::builder()
580 .data(serde_json::json!({ "row": { "url": "/dashboard/orders/42" } }))
581 .element(
582 "btn",
583 Element::new("Button").action(action_with_binding("/row/url")),
584 )
585 .build()
586 .unwrap();
587
588 resolve_actions(&mut spec, |_| None);
589
590 let el = spec.elements.get("btn").unwrap();
591 assert_eq!(
592 el.action.as_ref().unwrap().url.as_deref(),
593 Some("/dashboard/orders/42"),
594 "binding pointing to a `/path` string in spec.data resolves to action.url"
595 );
596 }
597
598 #[test]
599 fn resolve_actions_resolves_binding_to_named_handler_via_resolver() {
600 let mut spec = Spec::builder()
601 .data(serde_json::json!({ "row": { "handler": "users.show" } }))
602 .element(
603 "btn",
604 Element::new("Button").action(action_with_binding("/row/handler")),
605 )
606 .build()
607 .unwrap();
608
609 resolve_actions(&mut spec, |name| {
610 (name == "users.show").then(|| "/users/show".to_string())
611 });
612
613 let el = spec.elements.get("btn").unwrap();
614 assert_eq!(
615 el.action.as_ref().unwrap().url.as_deref(),
616 Some("/users/show"),
617 "binding resolved to handler name flows through the resolver"
618 );
619 }
620
621 #[test]
622 fn resolve_actions_binding_missing_data_leaves_url_unset() {
623 let mut spec = Spec::builder()
624 .data(serde_json::json!({}))
625 .element(
626 "btn",
627 Element::new("Button").action(action_with_binding("/missing")),
628 )
629 .build()
630 .unwrap();
631
632 resolve_actions(&mut spec, |_| Some("UNEXPECTED".to_string()));
633
634 let el = spec.elements.get("btn").unwrap();
635 assert!(
636 el.action.as_ref().unwrap().url.is_none(),
637 "missing binding data leaves url unset (renderer emits diagnostic)"
638 );
639 }
640
641 #[test]
642 fn resolve_actions_skips_when_url_already_resolved() {
643 let mut spec = Spec::builder()
644 .data(serde_json::json!({ "row": { "url": "/from-data" } }))
645 .element("btn", {
646 let mut a = action_with_binding("/row/url");
647 a.url = Some("/preset".to_string());
648 Element::new("Button").action(a)
649 })
650 .build()
651 .unwrap();
652
653 resolve_actions(&mut spec, |_| None);
654
655 let el = spec.elements.get("btn").unwrap();
656 assert_eq!(el.action.as_ref().unwrap().url.as_deref(), Some("/preset"));
657 }
658
659 #[test]
660 fn each_inlines_row_action_url_to_literal() {
661 let mut spec = parse_spec(serde_json::json!({
666 "$schema": "ferro-json-ui/v2",
667 "root": "grid",
668 "elements": {
669 "grid": {
670 "type": "Grid",
671 "props": {},
672 "children": ["cell"]
673 },
674 "cell": {
675 "type": "CalendarCell",
676 "$each": {"path": "/cells", "as": "c"},
677 "props": {"day": {"$data": "/c/day"}},
678 "action": {
679 "handler": {"$data": "/c/action_url"},
680 "method": "GET"
681 }
682 }
683 },
684 "data": {
685 "cells": [
686 {"day": 17, "action_url": "/dashboard/calendario?date=2026-05-17"},
687 {"day": 18, "action_url": "/dashboard/calendario?date=2026-05-18"}
688 ]
689 }
690 }));
691
692 expand_directives(&mut spec);
693 resolve_actions(&mut spec, |_| None);
694
695 let cell0 = spec.elements.get("cell-0").expect("expanded cell-0");
696 let cell1 = spec.elements.get("cell-1").expect("expanded cell-1");
697 assert_eq!(
698 cell0.action.as_ref().unwrap().url.as_deref(),
699 Some("/dashboard/calendario?date=2026-05-17"),
700 "$each inlines /c/action_url to a literal; resolve_actions promotes literal to url"
701 );
702 assert_eq!(
703 cell1.action.as_ref().unwrap().url.as_deref(),
704 Some("/dashboard/calendario?date=2026-05-18")
705 );
706 }
707
708 #[test]
709 fn each_leaves_non_row_action_bindings_for_resolve_actions() {
710 let mut spec = parse_spec(serde_json::json!({
713 "$schema": "ferro-json-ui/v2",
714 "root": "grid",
715 "elements": {
716 "grid": {
717 "type": "Grid",
718 "props": {},
719 "children": ["item"]
720 },
721 "item": {
722 "type": "Button",
723 "$each": {"path": "/items", "as": "i"},
724 "props": {"label": {"$data": "/i/label"}},
725 "action": {
726 "handler": {"$data": "/global_link"},
727 "method": "GET"
728 }
729 }
730 },
731 "data": {
732 "items": [{"label": "a"}, {"label": "b"}],
733 "global_link": "/dashboard/shared"
734 }
735 }));
736
737 expand_directives(&mut spec);
738 resolve_actions(&mut spec, |_| None);
739
740 let i0 = spec.elements.get("item-0").unwrap();
741 let i1 = spec.elements.get("item-1").unwrap();
742 assert_eq!(
743 i0.action.as_ref().unwrap().url.as_deref(),
744 Some("/dashboard/shared")
745 );
746 assert_eq!(
747 i1.action.as_ref().unwrap().url.as_deref(),
748 Some("/dashboard/shared")
749 );
750 }
751
752 #[test]
753 fn resolve_actions_passes_through_literal_paths() {
754 let mut spec = Spec::builder()
755 .element("btn", Element::new("Button").action(action("/dashboard")))
756 .build()
757 .unwrap();
758
759 resolve_actions(&mut spec, |_| None);
760
761 let el = spec.elements.get("btn").unwrap();
762 assert_eq!(
763 el.action.as_ref().unwrap().url.as_deref(),
764 Some("/dashboard")
765 );
766 }
767
768 #[test]
769 fn resolve_actions_strict_reports_missing() {
770 let mut spec = Spec::builder()
771 .element(
772 "btn",
773 Element::new("Button").action(action("missing.handler")),
774 )
775 .build()
776 .unwrap();
777
778 let result = resolve_actions_strict(&mut spec, |_| None);
779 assert!(result.is_err());
780 assert_eq!(result.unwrap_err(), vec!["missing.handler".to_string()]);
781 }
782
783 #[test]
784 fn resolve_errors_matches_by_name_prop() {
785 let mut spec = Spec::builder()
786 .element("email", Element::new("Input").prop("name", "email"))
787 .build()
788 .unwrap();
789
790 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
791 errors.insert("email".to_string(), vec!["required".to_string()]);
792
793 resolve_errors(&mut spec, &errors);
794
795 let el = spec.elements.get("email").unwrap();
796 let err_val = el.props.as_object().unwrap().get("error").unwrap();
797 assert_eq!(err_val, &serde_json::json!("required"));
798 }
799
800 #[test]
801 fn resolve_errors_matches_by_field_prop() {
802 let mut spec = Spec::builder()
803 .element("email", Element::new("Input").prop("field", "email"))
804 .build()
805 .unwrap();
806
807 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
808 errors.insert("email".to_string(), vec!["required".to_string()]);
809
810 resolve_errors(&mut spec, &errors);
811
812 let el = spec.elements.get("email").unwrap();
813 let err_val = el.props.as_object().unwrap().get("error").unwrap();
814 assert_eq!(err_val, &serde_json::json!("required"));
815 }
816
817 #[test]
818 fn resolve_errors_all_writes_full_bag_when_no_match() {
819 let mut spec = Spec::builder()
820 .element("card", Element::new("Card").prop("title", "t"))
821 .build()
822 .unwrap();
823
824 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
825 errors.insert("email".to_string(), vec!["required".to_string()]);
826
827 resolve_errors_all(&mut spec, &errors);
828
829 let el = spec.elements.get("card").unwrap();
830 let err_val = el.props.as_object().unwrap().get("errors").unwrap();
831 assert_eq!(err_val["email"], serde_json::json!(["required"]));
832 }
833
834 fn parse_spec(json: serde_json::Value) -> Spec {
839 serde_json::from_value::<Spec>(json).expect("spec parses")
840 }
841
842 #[test]
843 fn expand_if_falsy_deletes_element() {
844 let mut spec = parse_spec(serde_json::json!({
845 "$schema": "ferro-json-ui/v2",
846 "root": "btn",
847 "elements": {
848 "btn": {
849 "type": "Button",
850 "$if": {"path": "/show", "operator": "eq", "value": true},
851 "props": {"label": "Hi"}
852 }
853 },
854 "data": {"show": false}
855 }));
856 expand_directives(&mut spec);
857 assert!(!spec.elements.contains_key("btn"));
858 }
859
860 #[test]
861 fn expand_if_truthy_retains_element() {
862 let mut spec = parse_spec(serde_json::json!({
863 "$schema": "ferro-json-ui/v2",
864 "root": "btn",
865 "elements": {
866 "btn": {
867 "type": "Button",
868 "$if": {"path": "/show", "operator": "eq", "value": true},
869 "props": {"label": "Hi"}
870 }
871 },
872 "data": {"show": true}
873 }));
874 expand_directives(&mut spec);
875 let el = spec.elements.get("btn").expect("btn retained");
876 assert!(
877 el.if_.is_none(),
878 "if_ stripped post-expansion for idempotency"
879 );
880 }
881
882 #[test]
883 fn expand_if_uses_visibility_evaluate() {
884 let mut spec = parse_spec(serde_json::json!({
886 "$schema": "ferro-json-ui/v2",
887 "root": "btn",
888 "elements": {
889 "btn": {
890 "type": "Button",
891 "$if": {"and": [
892 {"path": "/a", "operator": "eq", "value": true},
893 {"path": "/b", "operator": "eq", "value": true}
894 ]},
895 "props": {"label": "Hi"}
896 }
897 },
898 "data": {"a": true, "b": false}
899 }));
900 expand_directives(&mut spec);
901 assert!(!spec.elements.contains_key("btn"));
903 }
904
905 #[test]
906 fn expand_each_produces_n_elements() {
907 let mut spec = parse_spec(serde_json::json!({
908 "$schema": "ferro-json-ui/v2",
909 "root": "order_card",
910 "elements": {
911 "order_card": {
912 "type": "Card",
913 "$each": {"path": "/orders", "as": "order"},
914 "props": {"title": {"$data": "/order/order_number"}}
915 }
916 },
917 "data": {"orders": [
918 {"order_number": "ORD-1"},
919 {"order_number": "ORD-2"},
920 {"order_number": "ORD-3"}
921 ]}
922 }));
923 expand_directives(&mut spec);
924 assert!(spec.elements.contains_key("order_card-0"));
925 assert!(spec.elements.contains_key("order_card-1"));
926 assert!(spec.elements.contains_key("order_card-2"));
927 assert!(!spec.elements.contains_key("order_card"));
928 let c0 = spec.elements.get("order_card-0").unwrap();
929 assert_eq!(c0.props.get("title").unwrap(), &serde_json::json!("ORD-1"));
930 }
931
932 #[test]
933 fn expand_each_auto_suffixes_ids() {
934 let mut spec = parse_spec(serde_json::json!({
935 "$schema": "ferro-json-ui/v2",
936 "root": "order_card",
937 "elements": {
938 "order_card": {
939 "type": "Card",
940 "$each": {"path": "/orders", "as": "order"},
941 "props": {}
942 }
943 },
944 "data": {"orders": [{"x":1},{"x":2}]}
945 }));
946 expand_directives(&mut spec);
947 for id in ["order_card-0", "order_card-1"] {
948 let el = spec.elements.get(id).unwrap();
949 assert!(el.each.is_none(), "{id} should have each stripped");
950 assert!(el.if_.is_none(), "{id} should have if_ stripped");
951 }
952 }
953
954 #[test]
955 fn expand_each_pre_resolves_row_paths() {
956 let mut spec = parse_spec(serde_json::json!({
957 "$schema": "ferro-json-ui/v2",
958 "root": "order_card",
959 "elements": {
960 "order_card": {
961 "type": "Card",
962 "$each": {"path": "/orders", "as": "order"},
963 "props": {"title": {"$data": "/order/order_number"}}
964 }
965 },
966 "data": {"orders": [{"order_number": "ORD-7"}]}
967 }));
968 expand_directives(&mut spec);
969 let c0 = spec.elements.get("order_card-0").unwrap();
970 assert_eq!(
971 c0.props.get("title").unwrap(),
972 &serde_json::json!("ORD-7"),
973 "/order/X must be pre-resolved to a literal value"
974 );
975 }
976
977 #[test]
978 fn expand_each_correlates_child_indexes() {
979 let mut spec = parse_spec(serde_json::json!({
980 "$schema": "ferro-json-ui/v2",
981 "root": "root",
982 "elements": {
983 "root": {
984 "type": "Grid",
985 "props": {},
986 "children": ["card"]
987 },
988 "card": {
989 "type": "Card",
990 "$each": {"path": "/orders", "as": "order"},
991 "props": {},
992 "children": ["badge"]
993 },
994 "badge": {
995 "type": "Badge",
996 "$each": {"path": "/orders", "as": "order"},
997 "props": {"label": {"$data": "/order/status"}}
998 }
999 },
1000 "data": {"orders": [{"status": "A"}, {"status": "B"}]}
1001 }));
1002 expand_directives(&mut spec);
1003 let card0 = spec.elements.get("card-0").unwrap();
1004 assert_eq!(card0.children, vec!["badge-0".to_string()]);
1005 let card1 = spec.elements.get("card-1").unwrap();
1006 assert_eq!(card1.children, vec!["badge-1".to_string()]);
1007 let root = spec.elements.get("root").unwrap();
1008 assert_eq!(
1009 root.children,
1010 vec!["card-0".to_string(), "card-1".to_string()]
1011 );
1012 }
1013
1014 #[test]
1015 fn expand_parent_children_rewritten_for_each() {
1016 let mut spec = parse_spec(serde_json::json!({
1017 "$schema": "ferro-json-ui/v2",
1018 "root": "root",
1019 "elements": {
1020 "root": {
1021 "type": "Grid",
1022 "props": {},
1023 "children": ["card"]
1024 },
1025 "card": {
1026 "type": "Card",
1027 "$each": {"path": "/orders", "as": "order"},
1028 "props": {}
1029 }
1030 },
1031 "data": {"orders": [{"x":1},{"x":2},{"x":3}]}
1032 }));
1033 expand_directives(&mut spec);
1034 let root = spec.elements.get("root").unwrap();
1035 assert_eq!(
1036 root.children,
1037 vec![
1038 "card-0".to_string(),
1039 "card-1".to_string(),
1040 "card-2".to_string()
1041 ]
1042 );
1043 }
1044
1045 #[test]
1046 fn expand_parent_children_pruned_for_if() {
1047 let mut spec = parse_spec(serde_json::json!({
1048 "$schema": "ferro-json-ui/v2",
1049 "root": "root",
1050 "elements": {
1051 "root": {
1052 "type": "Grid",
1053 "props": {},
1054 "children": ["btn"]
1055 },
1056 "btn": {
1057 "type": "Button",
1058 "$if": {"path": "/flag", "operator": "eq", "value": true},
1059 "props": {"label": "Hi"}
1060 }
1061 },
1062 "data": {"flag": false}
1063 }));
1064 expand_directives(&mut spec);
1065 let root = spec.elements.get("root").unwrap();
1066 assert!(root.children.is_empty(), "pruned $if-false child");
1067 assert!(!spec.elements.contains_key("btn"));
1068 }
1069
1070 #[test]
1071 fn expand_if_first_then_each() {
1072 let mut spec = parse_spec(serde_json::json!({
1074 "$schema": "ferro-json-ui/v2",
1075 "root": "card",
1076 "elements": {
1077 "card": {
1078 "type": "Card",
1079 "$if": {"path": "/show", "operator": "eq", "value": true},
1080 "$each": {"path": "/orders", "as": "order"},
1081 "props": {}
1082 }
1083 },
1084 "data": {"show": false, "orders": [{"x":1},{"x":2}]}
1085 }));
1086 expand_directives(&mut spec);
1087 for id in ["card", "card-0", "card-1"] {
1088 assert!(
1089 !spec.elements.contains_key(id),
1090 "{id} must not exist when $if removed the template"
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn expand_each_empty_array_produces_zero_clones() {
1097 let mut spec = parse_spec(serde_json::json!({
1098 "$schema": "ferro-json-ui/v2",
1099 "root": "root",
1100 "elements": {
1101 "root": {
1102 "type": "Grid",
1103 "props": {},
1104 "children": ["card"]
1105 },
1106 "card": {
1107 "type": "Card",
1108 "$each": {"path": "/orders", "as": "order"},
1109 "props": {}
1110 }
1111 },
1112 "data": {"orders": []}
1113 }));
1114 expand_directives(&mut spec);
1115 assert!(!spec.elements.contains_key("card"));
1116 let root = spec.elements.get("root").unwrap();
1117 assert!(root.children.is_empty());
1118 }
1119
1120 #[test]
1121 fn expand_directives_idempotent() {
1122 let mut spec = parse_spec(serde_json::json!({
1123 "$schema": "ferro-json-ui/v2",
1124 "root": "root",
1125 "elements": {
1126 "root": {
1127 "type": "Grid",
1128 "props": {},
1129 "children": ["card"]
1130 },
1131 "card": {
1132 "type": "Card",
1133 "$each": {"path": "/orders", "as": "order"},
1134 "props": {"title": {"$data": "/order/name"}}
1135 }
1136 },
1137 "data": {"orders": [{"name": "A"}, {"name": "B"}]}
1138 }));
1139 expand_directives(&mut spec);
1140 let snapshot_after_first = serde_json::to_value(&spec.elements).unwrap();
1141 expand_directives(&mut spec);
1142 let snapshot_after_second = serde_json::to_value(&spec.elements).unwrap();
1143 assert_eq!(
1144 snapshot_after_first, snapshot_after_second,
1145 "expand_directives must be idempotent"
1146 );
1147 }
1148}