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