1use std::collections::{HashMap, HashSet};
15use std::fmt;
16
17use schemars::JsonSchema;
18use serde::de::{Deserialize as DeserializeTrait, Deserializer, MapAccess, Visitor};
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21use thiserror::Error;
22
23use crate::action::Action;
24use crate::visibility::Visibility;
25
26pub const SCHEMA_VERSION: &str = "ferro-json-ui/v2";
32
33pub const MAX_NESTING_DEPTH: usize = 16;
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
53#[serde(untagged)]
54pub enum TitleBinding {
55 Literal(String),
56 Binding(DataRef),
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
63pub struct DataRef {
64 #[serde(rename = "$data")]
65 pub data: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
76pub struct DesignMeta {
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub intent: Option<String>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub allow: Vec<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct Spec {
93 #[serde(rename = "$schema")]
95 pub schema: String,
96 pub root: String,
98 pub elements: HashMap<String, Element>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub title: Option<TitleBinding>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub layout: Option<String>,
108 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
113 pub fill_viewport: bool,
114 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
116 pub data: Value,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub design: Option<DesignMeta>,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct Element {
130 #[serde(rename = "type")]
132 pub type_name: String,
133 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
135 pub props: Value,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub children: Vec<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub action: Option<Action>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub visible: Option<Visibility>,
145 #[serde(default, skip_serializing_if = "Option::is_none", rename = "$each")]
149 pub each: Option<EachDirective>,
150 #[serde(default, skip_serializing_if = "Option::is_none", rename = "$if")]
164 pub if_: Option<Visibility>,
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct EachDirective {
198 pub path: String,
200 #[serde(rename = "as")]
203 pub as_: String,
204}
205
206#[derive(Debug, Error)]
211pub enum SpecError {
212 #[error("failed to parse JSON: {0}")]
213 Json(#[from] serde_json::Error),
214 #[error("duplicate element ID in spec: {0}")]
215 DuplicateId(String),
216 #[error("root element '{0}' not found in elements map")]
217 RootMissing(String),
218 #[error("element '{element}' references child '{child}' which does not exist")]
219 DanglingChild { element: String, child: String },
220 #[error("cycle detected in element graph: {}", path.join(" -> "))]
221 Cycle { path: Vec<String> },
222 #[error(
223 "nesting depth exceeds maximum of {max}: found depth {found} at {}",
224 path.join(" -> ")
225 )]
226 DepthExceeded {
227 max: usize,
228 found: usize,
229 path: Vec<String>,
230 },
231 #[error("invalid element ID '{0}' — must match ^[A-Za-z_][A-Za-z0-9_-]{{0,127}}$")]
232 InvalidId(String),
233 #[error("element '{element_id}' has footer reference '{footer_id}' not found in elements")]
234 FooterMissing {
235 element_id: String,
236 footer_id: String,
237 },
238 #[error("element '{element_id}' has `$each.path = \"{path}\"` resolving to a non-array value in spec.data")]
239 EachPathNotArray { element_id: String, path: String },
240 #[error("element '{element_id}' has `$if.path = \"{path}\"` referencing a key absent from spec.data")]
241 IfPathMissing { element_id: String, path: String },
242 #[error("element '{element_id}' has `$each.as = \"{name}\"` which is a reserved name (one of: data, root, _root, _each, this, self)")]
243 EachAsReservedName { element_id: String, name: String },
244 #[error("nested `$each` is not supported in Phase 163: element '{outer}' templates element '{inner}' which is also `$each`-templated")]
245 NestedEach { outer: String, inner: String },
246 #[error("element '{parent}' (`$each` over '{parent_path}') references child '{child}' which is `$each` over a different path '{child_path}' — mismatched each siblings")]
247 MismatchedEach {
248 parent: String,
249 parent_path: String,
250 child: String,
251 child_path: String,
252 },
253}
254
255impl Spec {
260 pub fn builder() -> SpecBuilder {
266 SpecBuilder::new()
267 }
268
269 pub fn merge_data(mut self, handler_data: serde_json::Value) -> Self {
284 debug_assert!(
285 handler_data.is_null() || handler_data.is_object(),
286 "merge_data expects an Object or Null; non-Object handler_data ignored"
287 );
288 if let Some(obj) = handler_data.as_object() {
289 if self.data.is_null() {
290 self.data = Value::Object(Map::new());
291 }
292 if let Some(data_map) = self.data.as_object_mut() {
293 for (k, v) in obj {
294 data_map.insert(k.clone(), v.clone());
295 }
296 }
297 }
298 self
299 }
300
301 pub fn from_json(json: &str) -> Result<Spec, SpecError> {
306 let raw: SpecWire = match serde_json::from_str::<SpecWire>(json) {
307 Ok(r) => r,
308 Err(e) => {
309 let msg = e.to_string();
311 if let Some(idx) = msg.find(DUP_ID_SENTINEL) {
312 let after = &msg[idx + DUP_ID_SENTINEL.len()..];
313 let id: String = after
314 .chars()
315 .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'' && *c != ',')
316 .collect();
317 return Err(SpecError::DuplicateId(id));
318 }
319 return Err(SpecError::Json(e));
320 }
321 };
322 let spec = Spec {
323 schema: raw.schema,
324 root: raw.root,
325 elements: raw.elements.0,
326 title: raw.title,
327 layout: raw.layout,
328 fill_viewport: raw.fill_viewport,
329 data: raw.data,
330 design: raw.design,
331 };
332 validate_structure(&spec)?;
333 Ok(spec)
334 }
335}
336
337impl Element {
338 #[allow(clippy::new_ret_no_self)]
344 pub fn new(type_name: impl Into<String>) -> ElementBuilder {
345 ElementBuilder {
346 type_name: type_name.into(),
347 props: Map::new(),
348 children: Vec::new(),
349 action: None,
350 visible: None,
351 each: None,
352 if_: None,
353 }
354 }
355}
356
357#[derive(Debug, Default)]
359pub struct SpecBuilder {
360 title: Option<TitleBinding>,
361 layout: Option<String>,
362 fill_viewport_: bool,
363 data: Value,
364 root: Option<String>,
365 elements: HashMap<String, Element>,
366}
367
368impl SpecBuilder {
369 fn new() -> Self {
370 Self {
371 title: None,
372 layout: None,
373 fill_viewport_: false,
374 data: Value::Null,
375 root: None,
376 elements: HashMap::new(),
377 }
378 }
379
380 pub fn title(mut self, t: impl Into<String>) -> Self {
382 self.title = Some(TitleBinding::Literal(t.into()));
383 self
384 }
385
386 pub fn title_binding(mut self, path: impl Into<String>) -> Self {
388 self.title = Some(TitleBinding::Binding(DataRef { data: path.into() }));
389 self
390 }
391
392 pub fn layout(mut self, l: impl Into<String>) -> Self {
394 self.layout = Some(l.into());
395 self
396 }
397
398 pub fn fill_viewport(mut self, v: bool) -> Self {
402 self.fill_viewport_ = v;
403 self
404 }
405
406 pub fn data(mut self, d: Value) -> Self {
408 self.data = d;
409 self
410 }
411
412 pub fn root(mut self, id: impl Into<String>) -> Self {
416 self.root = Some(id.into());
417 self
418 }
419
420 pub fn element(mut self, id: impl Into<String>, el: ElementBuilder) -> Self {
423 let id: String = id.into();
424 if self.root.is_none() {
425 self.root = Some(id.clone());
426 }
427 self.elements.insert(id, el.build());
428 self
429 }
430
431 pub fn element_nested(mut self, id: impl Into<String>, el: NestedElement) -> Self {
447 let id: String = id.into();
448 if self.root.is_none() {
449 self.root = Some(id.clone());
450 }
451 flatten_nested(&mut self.elements, &id, el);
452 self
453 }
454
455 pub fn build(self) -> Result<Spec, SpecError> {
458 let root = self.root.ok_or_else(|| {
459 SpecError::RootMissing(String::new())
463 })?;
464 let spec = Spec {
465 schema: SCHEMA_VERSION.to_string(),
466 root,
467 elements: self.elements,
468 title: self.title,
469 layout: self.layout,
470 fill_viewport: self.fill_viewport_,
471 data: self.data,
472 design: None,
473 };
474 validate_structure(&spec)?;
475 Ok(spec)
476 }
477}
478
479#[derive(Debug)]
481pub struct ElementBuilder {
482 type_name: String,
483 props: Map<String, Value>,
484 children: Vec<String>,
485 action: Option<Action>,
486 visible: Option<Visibility>,
487 each: Option<EachDirective>,
488 if_: Option<Visibility>,
489}
490
491impl ElementBuilder {
492 pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
494 self.props.insert(k.into(), v.into());
495 self
496 }
497
498 pub fn child(mut self, id: impl Into<String>) -> Self {
500 self.children.push(id.into());
501 self
502 }
503
504 pub fn action(mut self, a: Action) -> Self {
506 self.action = Some(a);
507 self
508 }
509
510 pub fn visible(mut self, v: Visibility) -> Self {
512 self.visible = Some(v);
513 self
514 }
515
516 pub fn each(mut self, path: impl Into<String>, as_: impl Into<String>) -> Self {
526 self.each = Some(EachDirective {
527 path: path.into(),
528 as_: as_.into(),
529 });
530 self
531 }
532
533 pub(crate) fn build(self) -> Element {
534 let props = if self.props.is_empty() {
535 Value::Null
536 } else {
537 Value::Object(self.props)
538 };
539 Element {
540 type_name: self.type_name,
541 props,
542 children: self.children,
543 action: self.action,
544 visible: self.visible,
545 each: self.each,
546 if_: self.if_,
547 }
548 }
549}
550
551#[derive(Debug)]
577pub struct NestedElement {
578 type_name: String,
579 props: Map<String, Value>,
580 children: Vec<NestedElement>,
581 action: Option<Action>,
582 visible: Option<Visibility>,
583}
584
585impl NestedElement {
586 pub fn new(type_name: impl Into<String>) -> Self {
588 Self {
589 type_name: type_name.into(),
590 props: Map::new(),
591 children: Vec::new(),
592 action: None,
593 visible: None,
594 }
595 }
596
597 pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
599 self.props.insert(k.into(), v.into());
600 self
601 }
602
603 pub fn child(mut self, c: NestedElement) -> Self {
606 self.children.push(c);
607 self
608 }
609
610 pub fn action(mut self, a: Action) -> Self {
612 self.action = Some(a);
613 self
614 }
615
616 pub fn visible(mut self, v: Visibility) -> Self {
618 self.visible = Some(v);
619 self
620 }
621
622 #[cfg(test)]
628 pub(crate) fn build_for_test(self) -> Element {
629 let props = if self.props.is_empty() {
630 Value::Null
631 } else {
632 Value::Object(self.props)
633 };
634 Element {
635 type_name: self.type_name,
636 props,
637 children: Vec::new(),
638 action: self.action,
639 visible: self.visible,
640 each: None,
641 if_: None,
642 }
643 }
644}
645
646fn flatten_nested(elements: &mut HashMap<String, Element>, id: &str, el: NestedElement) {
650 let mut child_ids: Vec<String> = Vec::with_capacity(el.children.len());
651 for (idx, child) in el.children.into_iter().enumerate() {
652 let child_id = format!("{id}-{idx}");
653 flatten_nested(elements, &child_id, child);
654 child_ids.push(child_id);
655 }
656 let props = if el.props.is_empty() {
657 Value::Null
658 } else {
659 Value::Object(el.props)
660 };
661 let element = Element {
662 type_name: el.type_name,
663 props,
664 children: child_ids,
665 action: el.action,
666 visible: el.visible,
667 each: None,
668 if_: None,
669 };
670 elements.insert(id.to_string(), element);
671}
672
673const DUP_ID_SENTINEL: &str = "__FERRO_DUPLICATE_ID__";
682
683#[derive(Deserialize)]
686struct SpecWire {
687 #[serde(rename = "$schema", default = "default_schema")]
688 schema: String,
689 root: String,
690 elements: ElementsMap,
691 #[serde(default)]
692 title: Option<TitleBinding>,
693 #[serde(default)]
694 layout: Option<String>,
695 #[serde(default)]
696 fill_viewport: bool,
697 #[serde(default)]
698 data: Value,
699 #[serde(default)]
700 design: Option<DesignMeta>,
701}
702
703fn default_schema() -> String {
704 SCHEMA_VERSION.to_string()
705}
706
707struct ElementsMap(HashMap<String, Element>);
711
712impl<'de> DeserializeTrait<'de> for ElementsMap {
713 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
714 struct V;
715 impl<'de> Visitor<'de> for V {
716 type Value = ElementsMap;
717 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
718 f.write_str("a JSON object with unique element IDs")
719 }
720 fn visit_map<M: MapAccess<'de>>(self, mut m: M) -> Result<ElementsMap, M::Error> {
721 let mut map: HashMap<String, Element> = HashMap::new();
722 while let Some(k) = m.next_key::<String>()? {
723 if map.contains_key(&k) {
724 return Err(serde::de::Error::custom(format!("{DUP_ID_SENTINEL}{k}")));
725 }
726 let v: Element = m.next_value()?;
727 map.insert(k, v);
728 }
729 Ok(ElementsMap(map))
730 }
731 }
732 d.deserialize_map(V)
733 }
734}
735
736fn validate_structure(spec: &Spec) -> Result<(), SpecError> {
742 validate_ids(&spec.elements)?;
743 if !spec.elements.contains_key(&spec.root) {
744 return Err(SpecError::RootMissing(spec.root.clone()));
745 }
746 validate_no_dangling(&spec.elements)?;
747 validate_directives(spec)?;
748 validate_footer_ids(spec)?;
749 detect_cycle(&spec.elements, &spec.root)?;
750 check_depth(&spec.elements, &spec.root)?;
751 Ok(())
752}
753
754fn is_valid_id(s: &str) -> bool {
756 if s.is_empty() || s.len() > 128 {
757 return false;
758 }
759 let bytes = s.as_bytes();
760 let first = bytes[0];
761 let first_ok = first.is_ascii_alphabetic() || first == b'_';
762 if !first_ok {
763 return false;
764 }
765 bytes[1..]
766 .iter()
767 .all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
768}
769
770fn validate_ids(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
771 for (id, el) in elements {
772 if !is_valid_id(id) {
773 return Err(SpecError::InvalidId(id.clone()));
774 }
775 for child in &el.children {
776 if !is_valid_id(child) {
777 return Err(SpecError::InvalidId(child.clone()));
778 }
779 }
780 }
781 Ok(())
782}
783
784fn validate_no_dangling(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
785 for (id, el) in elements {
786 for child in &el.children {
787 if !elements.contains_key(child) {
788 return Err(SpecError::DanglingChild {
789 element: id.clone(),
790 child: child.clone(),
791 });
792 }
793 }
794 }
795 Ok(())
796}
797
798fn validate_footer_ids(spec: &Spec) -> Result<(), SpecError> {
803 for (element_id, el) in &spec.elements {
804 let footer_ids: Vec<String> = el
806 .props
807 .get("footer")
808 .and_then(|v| v.as_array())
809 .map(|arr| {
810 arr.iter()
811 .filter_map(|v| v.as_str().map(|s| s.to_string()))
812 .collect()
813 })
814 .unwrap_or_default();
815
816 for footer_id in &footer_ids {
817 if !spec.elements.contains_key(footer_id) {
818 return Err(SpecError::FooterMissing {
819 element_id: element_id.clone(),
820 footer_id: footer_id.clone(),
821 });
822 }
823 if el.children.iter().any(|c| c == footer_id) {
825 eprintln!(
826 "ferro-json-ui: element '{element_id}' has '{footer_id}' in both \
827 props.footer and children — the element renders once (in footer); \
828 remove the duplicate from children"
829 );
830 }
831 }
832 }
833 Ok(())
834}
835
836const RESERVED_EACH_AS: &[&str] = &["data", "root", "_root", "_each", "this", "self"];
838
839fn validate_directives(spec: &Spec) -> Result<(), SpecError> {
844 let templated: HashMap<&str, &EachDirective> = spec
846 .elements
847 .iter()
848 .filter_map(|(id, el)| el.each.as_ref().map(|e| (id.as_str(), e)))
849 .collect();
850
851 for (id, el) in &spec.elements {
852 if let Some(each) = &el.each {
854 if RESERVED_EACH_AS.contains(&each.as_.as_str()) {
856 return Err(SpecError::EachAsReservedName {
857 element_id: id.clone(),
858 name: each.as_.clone(),
859 });
860 }
861 if !spec.data.is_null() {
863 if let Some(value) = crate::data::resolve_path(&spec.data, &each.path) {
864 if !value.is_array() {
865 return Err(SpecError::EachPathNotArray {
866 element_id: id.clone(),
867 path: each.path.clone(),
868 });
869 }
870 }
871 }
872 for child in &el.children {
874 if let Some(child_each) = templated.get(child.as_str()) {
875 if child_each.path != each.path || child_each.as_ != each.as_ {
876 return Err(SpecError::MismatchedEach {
877 parent: id.clone(),
878 parent_path: each.path.clone(),
879 child: child.clone(),
880 child_path: child_each.path.clone(),
881 });
882 }
883 }
884 }
885 let direct: HashSet<&str> = el.children.iter().map(|s| s.as_str()).collect();
890 let mut visited: HashSet<&str> = HashSet::new();
891 let mut stack: Vec<&str> = Vec::new();
892 for child in &el.children {
894 if let Some(child_el) = spec.elements.get(child) {
895 for gc in &child_el.children {
896 stack.push(gc.as_str());
897 }
898 }
899 }
900 while let Some(node) = stack.pop() {
901 if !visited.insert(node) {
902 continue;
903 }
904 if templated.contains_key(node) && !direct.contains(node) {
905 return Err(SpecError::NestedEach {
906 outer: id.clone(),
907 inner: node.to_string(),
908 });
909 }
910 if let Some(node_el) = spec.elements.get(node) {
911 for c in &node_el.children {
912 stack.push(c.as_str());
913 }
914 }
915 }
916 }
917 if let Some(vis) = &el.if_ {
921 if !spec.data.is_null() {
922 check_visibility_paths(id, vis, &spec.data)?;
923 }
924 }
925 }
926 Ok(())
927}
928
929fn check_visibility_paths(
932 element_id: &str,
933 vis: &Visibility,
934 data: &Value,
935) -> Result<(), SpecError> {
936 match vis {
937 Visibility::And { and } => {
938 for v in and {
939 check_visibility_paths(element_id, v, data)?;
940 }
941 }
942 Visibility::Or { or } => {
943 for v in or {
944 check_visibility_paths(element_id, v, data)?;
945 }
946 }
947 Visibility::Not { not } => check_visibility_paths(element_id, not, data)?,
948 Visibility::Condition(c) => {
949 if crate::data::resolve_path(data, &c.path).is_none() {
950 return Err(SpecError::IfPathMissing {
951 element_id: element_id.to_string(),
952 path: c.path.clone(),
953 });
954 }
955 }
956 }
957 Ok(())
958}
959
960fn detect_cycle(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
961 let mut visited: HashSet<String> = HashSet::new();
962 let mut on_stack: Vec<String> = Vec::new();
963 dfs(root, elements, &mut visited, &mut on_stack)
964}
965
966fn dfs(
967 node: &str,
968 elements: &HashMap<String, Element>,
969 visited: &mut HashSet<String>,
970 on_stack: &mut Vec<String>,
971) -> Result<(), SpecError> {
972 if let Some(start) = on_stack.iter().position(|n| n == node) {
973 let mut path: Vec<String> = on_stack[start..].to_vec();
974 path.push(node.to_string());
975 return Err(SpecError::Cycle { path });
976 }
977 if visited.contains(node) {
978 return Ok(());
979 }
980 on_stack.push(node.to_string());
981 if let Some(el) = elements.get(node) {
982 for child in &el.children {
983 dfs(child, elements, visited, on_stack)?;
984 }
985 }
986 on_stack.pop();
987 visited.insert(node.to_string());
988 Ok(())
989}
990
991fn check_depth(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
992 let mut path: Vec<String> = Vec::new();
993 walk(root, elements, 1, &mut path)
994}
995
996fn walk(
997 node: &str,
998 elements: &HashMap<String, Element>,
999 depth: usize,
1000 path: &mut Vec<String>,
1001) -> Result<(), SpecError> {
1002 path.push(node.to_string());
1003 if depth > MAX_NESTING_DEPTH {
1004 return Err(SpecError::DepthExceeded {
1005 max: MAX_NESTING_DEPTH,
1006 found: depth,
1007 path: path.clone(),
1008 });
1009 }
1010 if let Some(el) = elements.get(node) {
1011 for child in &el.children {
1012 walk(child, elements, depth + 1, path)?;
1013 }
1014 }
1015 path.pop();
1016 Ok(())
1017}
1018
1019#[cfg(test)]
1027#[rustfmt::skip]
1028mod tests {
1029 use super::*;
1030 use serde_json::json;
1031
1032 #[test]
1033 fn default_schema_is_v2() {
1034 assert_eq!(default_schema(), SCHEMA_VERSION);
1035 assert_eq!(SCHEMA_VERSION, "ferro-json-ui/v2");
1036 }
1037
1038 #[test]
1039 fn is_valid_id_edge_cases() {
1040 let cases: &[(&str, bool)] = &[
1042 ("", false),
1043 ("1abc", false),
1044 ("a", true),
1045 ("_", true),
1046 ("a_b-c", true),
1047 ("user form", false),
1048 ("ABC123", true),
1049 ("a.b", false),
1050 ("/path", false),
1051 ];
1052 for (s, ok) in cases {
1053 assert_eq!(is_valid_id(s), *ok, "mismatch on {s:?}");
1054 }
1055 let ok128: String = "a".repeat(128);
1057 let bad129: String = "a".repeat(129);
1058 assert!(is_valid_id(&ok128));
1059 assert!(!is_valid_id(&bad129));
1060 }
1061
1062 #[test]
1063 fn builder_minimal_round_trips() {
1064 let spec = Spec::builder()
1065 .element("a", Element::new("Text").prop("content", "Hi"))
1066 .build()
1067 .unwrap();
1068 assert_eq!(spec.schema, SCHEMA_VERSION);
1069 assert_eq!(spec.root, "a");
1070 assert_eq!(spec.elements.len(), 1);
1071 let json = serde_json::to_string(&spec).unwrap();
1072 let back = Spec::from_json(&json).unwrap();
1073 assert_eq!(spec, back);
1074 }
1075
1076 #[test]
1077 fn builder_parity_with_json() {
1078 let from_json = Spec::from_json(
1079 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text","props":{"content":"Hi"}}}}"#,
1080 )
1081 .unwrap();
1082 let from_builder = Spec::builder()
1083 .element("a", Element::new("Text").prop("content", "Hi"))
1084 .build()
1085 .unwrap();
1086 assert_eq!(from_json, from_builder);
1087 }
1088
1089 #[test]
1090 fn from_json_rejects_missing_root() {
1091 let err = Spec::from_json(
1092 r#"{"$schema":"ferro-json-ui/v2","root":"nope","elements":{"a":{"type":"Text"}}}"#,
1093 )
1094 .unwrap_err();
1095 match err {
1096 SpecError::RootMissing(id) => assert_eq!(id, "nope"),
1097 other => panic!("expected RootMissing, got {other:?}"),
1098 }
1099 }
1100
1101 #[test]
1102 fn from_json_rejects_dangling_child() {
1103 let err = Spec::from_json(
1104 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Card","children":["ghost"]}}}"#,
1105 )
1106 .unwrap_err();
1107 match err {
1108 SpecError::DanglingChild { element, child } => {
1109 assert_eq!(element, "a");
1110 assert_eq!(child, "ghost");
1111 }
1112 other => panic!("expected DanglingChild, got {other:?}"),
1113 }
1114 }
1115
1116 #[test]
1123 fn validate_allows_children_ref_to_if_gated_element() {
1124 let json = r#"{
1127 "$schema": "ferro-json-ui/v2",
1128 "root": "parent",
1129 "elements": {
1130 "parent": {
1131 "type": "Card",
1132 "props": {"title": "parent"},
1133 "children": ["child"]
1134 },
1135 "child": {
1136 "type": "Text",
1137 "props": {"content": "conditional"},
1138 "$if": {"path": "/data/show", "operator": "eq", "value": true}
1139 }
1140 }
1141 }"#;
1142 let spec = Spec::from_json(json);
1144 assert!(
1145 spec.is_ok(),
1146 "$if-gated child must not be rejected as dangling: {:?}",
1147 spec.err()
1148 );
1149 }
1150
1151 #[test]
1152 fn from_json_rejects_self_cycle() {
1153 let err = Spec::from_json(
1154 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{"A":{"type":"Card","children":["A"]}}}"#,
1155 )
1156 .unwrap_err();
1157 match err {
1158 SpecError::Cycle { path } => {
1159 assert_eq!(path, vec!["A".to_string(), "A".to_string()]);
1160 }
1161 other => panic!("expected Cycle (self), got {other:?}"),
1162 }
1163 }
1164
1165 #[test]
1166 fn from_json_rejects_two_cycle() {
1167 let err = Spec::from_json(
1168 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{"root":{"type":"Card","children":["A"]},"A":{"type":"Card","children":["root"]}}}"#,
1169 )
1170 .unwrap_err();
1171 match err {
1172 SpecError::Cycle { path } => {
1173 assert!(path.len() >= 3);
1174 assert_eq!(path.first(), path.last());
1175 }
1176 other => panic!("expected Cycle, got {other:?}"),
1177 }
1178 }
1179
1180 #[test]
1181 fn cycle_detector_only_on_revisit() {
1182 let err = Spec::from_json(
1187 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
1188 "A":{"type":"Card","children":["B"]},
1189 "B":{"type":"Card","children":["A"]}
1190 }}"#,
1191 )
1192 .unwrap_err();
1193 match err {
1194 SpecError::Cycle { path } => {
1195 assert!(
1196 path.iter().any(|p| p == "A"),
1197 "cycle path must contain A; got {path:?}"
1198 );
1199 assert!(
1200 path.iter().any(|p| p == "B"),
1201 "cycle path must contain B; got {path:?}"
1202 );
1203 }
1204 other => panic!("expected Cycle, got {other:?}"),
1205 }
1206 }
1207
1208 #[test]
1209 fn from_json_rejects_depth_17() {
1210 let err = Spec::from_json(
1212 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
1213 "root":{"type":"Container","children":["e1"]},
1214 "e1":{"type":"Container","children":["e2"]},
1215 "e2":{"type":"Container","children":["e3"]},
1216 "e3":{"type":"Container","children":["e4"]},
1217 "e4":{"type":"Container","children":["e5"]},
1218 "e5":{"type":"Container","children":["e6"]},
1219 "e6":{"type":"Container","children":["e7"]},
1220 "e7":{"type":"Container","children":["e8"]},
1221 "e8":{"type":"Container","children":["e9"]},
1222 "e9":{"type":"Container","children":["e10"]},
1223 "e10":{"type":"Container","children":["e11"]},
1224 "e11":{"type":"Container","children":["e12"]},
1225 "e12":{"type":"Container","children":["e13"]},
1226 "e13":{"type":"Container","children":["e14"]},
1227 "e14":{"type":"Container","children":["e15"]},
1228 "e15":{"type":"Container","children":["e16"]},
1229 "e16":{"type":"Text"}
1230 }}"#,
1231 )
1232 .unwrap_err();
1233 match err {
1234 SpecError::DepthExceeded { max, found, path } => {
1235 assert_eq!(max, 16, "max must equal MAX_NESTING_DEPTH=16");
1236 assert_eq!(found, 17, "found must be 17 (one past the limit)");
1237 assert!(!path.is_empty());
1238 }
1239 other => panic!("expected DepthExceeded, got {other:?}"),
1240 }
1241 }
1242
1243 #[test]
1244 fn from_json_accepts_depth_8() {
1245 let spec = Spec::from_json(
1249 r#"{"$schema":"ferro-json-ui/v2","root":"dashboard","elements":{
1250 "dashboard":{"type":"Screen","children":["root"]},
1251 "root":{"type":"Container","children":["detail_page"]},
1252 "detail_page":{"type":"DetailPage","children":["tab"]},
1253 "tab":{"type":"Card","children":["card"]},
1254 "card":{"type":"Card","children":["form"]},
1255 "form":{"type":"Form","children":["row"]},
1256 "row":{"type":"Grid","children":["switch_day"]},
1257 "switch_day":{"type":"Switch"}
1258 }}"#,
1259 )
1260 .expect("depth-8 staff-detail spec must parse without DepthExceeded");
1261 assert_eq!(spec.elements.len(), 8);
1262 }
1263
1264 #[test]
1265 fn from_json_rejects_invalid_id_space() {
1266 let err = Spec::from_json(
1267 r#"{"$schema":"ferro-json-ui/v2","root":"user form","elements":{"user form":{"type":"Text"}}}"#,
1268 )
1269 .unwrap_err();
1270 match err {
1271 SpecError::InvalidId(id) => assert_eq!(id, "user form"),
1272 other => panic!("expected InvalidId, got {other:?}"),
1273 }
1274 }
1275
1276 #[test]
1277 fn from_json_rejects_duplicate_id() {
1278 let err = Spec::from_json(
1280 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text"},"a":{"type":"Card"}}}"#,
1281 )
1282 .unwrap_err();
1283 match err {
1284 SpecError::DuplicateId(id) => assert_eq!(id, "a"),
1285 other => panic!("expected DuplicateId, got {other:?}"),
1286 }
1287 }
1288
1289 #[test]
1290 fn from_json_accepts_three_level_nesting() {
1291 let spec = Spec::from_json(
1292 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
1293 "root":{"type":"Card","children":["section"]},
1294 "section":{"type":"FormSection","children":["leaf"]},
1295 "leaf":{"type":"Text"}
1296 }}"#,
1297 )
1298 .unwrap();
1299 assert_eq!(spec.elements.len(), 3);
1300 }
1301
1302 #[test]
1303 fn from_json_accepts_diamond() {
1304 let spec = Spec::from_json(
1307 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
1308 "A":{"type":"Card","children":["B","C"]},
1309 "B":{"type":"Card","children":["D"]},
1310 "C":{"type":"Card","children":["D"]},
1311 "D":{"type":"Text"}
1312 }}"#,
1313 )
1314 .unwrap();
1315 assert_eq!(spec.elements.len(), 4);
1316 }
1317
1318 #[test]
1319 fn from_json_wraps_syntax_errors() {
1320 let err = Spec::from_json("{ this is not json ").unwrap_err();
1322 assert!(matches!(err, SpecError::Json(_)), "got {err:?}");
1323 }
1324
1325 #[test]
1326 fn builder_rejects_forward_ref_without_target() {
1327 let err = Spec::builder()
1329 .element("root", Element::new("Card").child("ghost"))
1330 .build()
1331 .unwrap_err();
1332 match err {
1333 SpecError::DanglingChild { element, child } => {
1334 assert_eq!(element, "root");
1335 assert_eq!(child, "ghost");
1336 }
1337 other => panic!("expected DanglingChild, got {other:?}"),
1338 }
1339 }
1340
1341 #[test]
1342 fn builder_data_payload_survives_round_trip() {
1343 let spec = Spec::builder()
1344 .element("a", Element::new("Text"))
1345 .data(json!({"user":{"name":"Alice"}}))
1346 .build()
1347 .unwrap();
1348 let json = serde_json::to_string(&spec).unwrap();
1349 let back = Spec::from_json(&json).unwrap();
1350 assert_eq!(back.data, json!({"user":{"name":"Alice"}}));
1351 }
1352
1353 #[test]
1354 fn element_omits_optional_fields_when_absent() {
1355 let spec = Spec::builder()
1356 .element("bare", Element::new("Text"))
1357 .build()
1358 .unwrap();
1359 let json = serde_json::to_string(&spec).unwrap();
1360 assert!(!json.contains("children"));
1362 assert!(!json.contains("props"));
1363 assert!(!json.contains("action"));
1364 assert!(!json.contains("visible"));
1365 }
1366
1367 #[test]
1368 fn merge_data_handler_wins() {
1369 let spec = Spec::builder()
1370 .element("a", Element::new("Text"))
1371 .data(json!({"a": 1, "b": 2}))
1372 .build()
1373 .unwrap();
1374 let merged = spec.merge_data(json!({"b": 99, "c": 3}));
1375 assert_eq!(merged.data, json!({"a": 1, "b": 99, "c": 3}));
1376 }
1377
1378 #[test]
1379 fn merge_data_ignores_non_object() {
1380 let spec = Spec::builder()
1382 .element("a", Element::new("Text"))
1383 .data(json!({"a": 1}))
1384 .build()
1385 .unwrap();
1386 let merged = spec.merge_data(Value::Null);
1387 assert_eq!(merged.data, json!({"a": 1}));
1388 }
1393
1394 #[test]
1395 fn merge_data_initializes_null_data() {
1396 let spec = Spec::builder()
1397 .element("a", Element::new("Text"))
1398 .build() .unwrap();
1400 assert_eq!(spec.data, Value::Null);
1401 let merged = spec.merge_data(json!({"k": "v"}));
1402 assert_eq!(merged.data, json!({"k": "v"}));
1403 }
1404
1405 #[test]
1406 fn merge_data_empty_handler_no_op() {
1407 let spec = Spec::builder()
1408 .element("a", Element::new("Text"))
1409 .data(json!({"a": 1}))
1410 .build()
1411 .unwrap();
1412 let merged = spec.merge_data(json!({}));
1413 assert_eq!(merged.data, json!({"a": 1}));
1414 }
1415
1416 #[test]
1417 fn from_json_rejects_missing_footer_id() {
1418 let err = Spec::from_json(
1419 r#"{
1420 "$schema": "ferro-json-ui/v2",
1421 "root": "card",
1422 "elements": {
1423 "card": {
1424 "type": "Card",
1425 "props": {"title": "T", "footer": ["ghost"]}
1426 }
1427 }
1428 }"#,
1429 )
1430 .unwrap_err();
1431 match err {
1432 SpecError::FooterMissing {
1433 element_id,
1434 footer_id,
1435 } => {
1436 assert_eq!(element_id, "card");
1437 assert_eq!(footer_id, "ghost");
1438 }
1439 other => panic!("expected FooterMissing, got {other:?}"),
1440 }
1441 }
1442
1443 #[test]
1444 fn from_json_rejects_missing_modal_footer_id() {
1445 let err = Spec::from_json(
1448 r#"{
1449 "$schema": "ferro-json-ui/v2",
1450 "root": "modal",
1451 "elements": {
1452 "modal": {
1453 "type": "Modal",
1454 "props": {"id": "m", "title": "T", "footer": ["ghost"]}
1455 }
1456 }
1457 }"#,
1458 )
1459 .unwrap_err();
1460 match err {
1461 SpecError::FooterMissing {
1462 element_id,
1463 footer_id,
1464 } => {
1465 assert_eq!(element_id, "modal");
1466 assert_eq!(footer_id, "ghost");
1467 }
1468 other => panic!("expected FooterMissing on Modal, got {other:?}"),
1469 }
1470 }
1471
1472 #[test]
1473 fn spec_warns_duplicate_footer_child() {
1474 let spec = Spec::from_json(
1477 r#"{
1478 "$schema": "ferro-json-ui/v2",
1479 "root": "card",
1480 "elements": {
1481 "card": {
1482 "type": "Card",
1483 "props": {"title": "T", "footer": ["btn"]},
1484 "children": ["btn"]
1485 },
1486 "btn": {
1487 "type": "Button",
1488 "props": {"label": "Save"}
1489 }
1490 }
1491 }"#,
1492 )
1493 .expect("D-08 warning is non-fatal; parse must succeed");
1494 assert_eq!(spec.root, "card");
1495 }
1496
1497 #[test]
1498 fn each_directive_round_trips() {
1499 let json = serde_json::json!({"path": "/orders", "as": "order"});
1500 let parsed: EachDirective = serde_json::from_value(json.clone()).expect("decode");
1501 assert_eq!(parsed.path, "/orders");
1502 assert_eq!(parsed.as_, "order");
1503 let reserialized = serde_json::to_value(&parsed).expect("encode");
1504 assert_eq!(reserialized, json);
1505 assert!(reserialized.get("as").is_some());
1507 assert!(reserialized.get("as_").is_none());
1508 }
1509
1510 #[test]
1511 fn element_with_each_round_trips() {
1512 let json = serde_json::json!({
1513 "type": "Card",
1514 "$each": {"path": "/orders", "as": "order"},
1515 "props": {"title": "x"}
1516 });
1517 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1518 assert!(parsed.each.is_some());
1519 let each = parsed.each.as_ref().unwrap();
1520 assert_eq!(each.path, "/orders");
1521 assert_eq!(each.as_, "order");
1522 let reserialized = serde_json::to_value(&parsed).expect("encode");
1523 assert!(reserialized.get("$each").is_some());
1524 }
1525
1526 #[test]
1527 fn element_without_each_omits_field() {
1528 let spec = Spec::builder()
1530 .element("card", Element::new("Card").prop("title", "hello"))
1531 .build()
1532 .expect("spec is valid");
1533 let card = spec.elements.get("card").expect("card present");
1534 let json = serde_json::to_value(card).expect("encode");
1535 assert!(
1536 json.get("$each").is_none(),
1537 "expected $each to be omitted when None; got: {json}"
1538 );
1539 }
1540
1541 #[test]
1542 fn if_directive_flat_condition_round_trips() {
1543 use crate::visibility::Visibility;
1544 let json = serde_json::json!({"path": "/can_advance", "operator": "eq", "value": true});
1545 let parsed: Visibility = serde_json::from_value(json.clone()).expect("decode");
1546 match &parsed {
1547 Visibility::Condition(c) => {
1548 assert_eq!(c.path, "/can_advance");
1549 assert_eq!(c.value, Some(serde_json::json!(true)));
1550 }
1551 _ => panic!("expected flat Condition variant, got: {parsed:?}"),
1552 }
1553 let reserialized = serde_json::to_value(&parsed).expect("encode");
1554 assert!(reserialized.get("path").is_some());
1555 assert!(reserialized.get("operator").is_some());
1556 }
1557
1558 #[test]
1559 fn element_with_if_flat_round_trips() {
1560 let json = serde_json::json!({
1561 "type": "Button",
1562 "$if": {"path": "/can_advance", "operator": "eq", "value": true},
1563 "props": {"label": "x"}
1564 });
1565 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1566 assert!(parsed.if_.is_some());
1567 let reserialized = serde_json::to_value(&parsed).expect("encode");
1568 assert!(reserialized.get("$if").is_some());
1569 }
1570
1571 #[test]
1572 fn element_with_if_compound_round_trips() {
1573 use crate::visibility::Visibility;
1574 let json = serde_json::json!({
1575 "type": "Button",
1576 "$if": {"and": [
1577 {"path": "/a", "operator": "exists"},
1578 {"path": "/b", "operator": "eq", "value": true}
1579 ]},
1580 "props": {"label": "x"}
1581 });
1582 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1583 match parsed.if_.as_ref() {
1584 Some(Visibility::And { and }) => assert_eq!(and.len(), 2),
1585 other => panic!("expected And variant, got: {other:?}"),
1586 }
1587 let reserialized = serde_json::to_value(&parsed).expect("encode");
1588 assert!(reserialized.get("$if").and_then(|v| v.get("and")).is_some());
1589 }
1590
1591 #[test]
1592 fn element_without_if_omits_field() {
1593 let spec = Spec::builder()
1594 .element("btn", Element::new("Button").prop("label", "ok"))
1595 .build()
1596 .expect("spec is valid");
1597 let btn = spec.elements.get("btn").expect("btn present");
1598 let json = serde_json::to_value(btn).expect("encode");
1599 assert!(
1600 json.get("$if").is_none(),
1601 "expected $if to be omitted when None; got: {json}"
1602 );
1603 }
1604
1605 #[test]
1610 fn validate_each_path_not_array_fires() {
1611 let json = r#"{
1612 "$schema": "ferro-json-ui/v2",
1613 "root": "list",
1614 "elements": {
1615 "list": {
1616 "type": "Card",
1617 "$each": {"path": "/orders", "as": "order"},
1618 "props": {}
1619 }
1620 },
1621 "data": {"orders": "not-an-array"}
1622 }"#;
1623 let err = Spec::from_json(json).expect_err("validator must reject non-array $each.path");
1624 match err {
1625 SpecError::EachPathNotArray { element_id, path } => {
1626 assert_eq!(element_id, "list");
1627 assert_eq!(path, "/orders");
1628 }
1629 other => panic!("expected EachPathNotArray, got: {other:?}"),
1630 }
1631 }
1632
1633 #[test]
1634 fn validate_each_path_not_array_skipped_when_data_null() {
1635 let json = r#"{
1637 "$schema": "ferro-json-ui/v2",
1638 "root": "list",
1639 "elements": {
1640 "list": {
1641 "type": "Card",
1642 "$each": {"path": "/orders", "as": "order"},
1643 "props": {}
1644 }
1645 }
1646 }"#;
1647 Spec::from_json(json).expect("no error when data is null");
1649 }
1650
1651 #[test]
1652 fn validate_each_as_reserved_data_rejected() {
1653 let json = r#"{
1654 "$schema": "ferro-json-ui/v2",
1655 "root": "list",
1656 "elements": {
1657 "list": {
1658 "type": "Card",
1659 "$each": {"path": "/items", "as": "data"},
1660 "props": {}
1661 }
1662 }
1663 }"#;
1664 let err = Spec::from_json(json).expect_err("'data' is a reserved name");
1665 match err {
1666 SpecError::EachAsReservedName { element_id, name } => {
1667 assert_eq!(element_id, "list");
1668 assert_eq!(name, "data");
1669 }
1670 other => panic!("expected EachAsReservedName, got: {other:?}"),
1671 }
1672 }
1673
1674 #[test]
1675 fn validate_each_as_reserved_root_rejected() {
1676 let json = r#"{
1677 "$schema": "ferro-json-ui/v2",
1678 "root": "list",
1679 "elements": {
1680 "list": {
1681 "type": "Card",
1682 "$each": {"path": "/items", "as": "root"},
1683 "props": {}
1684 }
1685 }
1686 }"#;
1687 let err = Spec::from_json(json).expect_err("'root' is a reserved name");
1688 match err {
1689 SpecError::EachAsReservedName { element_id, name } => {
1690 assert_eq!(element_id, "list");
1691 assert_eq!(name, "root");
1692 }
1693 other => panic!("expected EachAsReservedName, got: {other:?}"),
1694 }
1695 }
1696
1697 #[test]
1698 fn validate_each_as_non_reserved_accepted() {
1699 let json_order = r#"{
1701 "$schema": "ferro-json-ui/v2",
1702 "root": "list",
1703 "elements": {
1704 "list": {
1705 "type": "Card",
1706 "$each": {"path": "/items", "as": "order"},
1707 "props": {}
1708 }
1709 },
1710 "data": {"items": []}
1711 }"#;
1712 Spec::from_json(json_order).expect("'order' is not reserved");
1713
1714 let json_row = r#"{
1715 "$schema": "ferro-json-ui/v2",
1716 "root": "list",
1717 "elements": {
1718 "list": {
1719 "type": "Card",
1720 "$each": {"path": "/items", "as": "row"},
1721 "props": {}
1722 }
1723 },
1724 "data": {"items": []}
1725 }"#;
1726 Spec::from_json(json_row).expect("'row' is not reserved");
1727 }
1728
1729 #[test]
1730 fn validate_if_path_missing_fires() {
1731 let json = r#"{
1732 "$schema": "ferro-json-ui/v2",
1733 "root": "btn",
1734 "elements": {
1735 "btn": {
1736 "type": "Button",
1737 "$if": {"path": "/missing_key", "operator": "eq", "value": true},
1738 "props": {"label": "Go"}
1739 }
1740 },
1741 "data": {"other": true}
1742 }"#;
1743 let err = Spec::from_json(json).expect_err("missing $if.path must error");
1744 match err {
1745 SpecError::IfPathMissing { element_id, path } => {
1746 assert_eq!(element_id, "btn");
1747 assert_eq!(path, "/missing_key");
1748 }
1749 other => panic!("expected IfPathMissing, got: {other:?}"),
1750 }
1751 }
1752
1753 #[test]
1754 fn validate_if_path_missing_skipped_when_data_null() {
1755 let json = r#"{
1757 "$schema": "ferro-json-ui/v2",
1758 "root": "btn",
1759 "elements": {
1760 "btn": {
1761 "type": "Button",
1762 "$if": {"path": "/missing_key", "operator": "eq", "value": true},
1763 "props": {"label": "Go"}
1764 }
1765 }
1766 }"#;
1767 Spec::from_json(json).expect("no error when data is null");
1768 }
1769
1770 #[test]
1771 fn validate_nested_each_rejected() {
1772 let json = r#"{
1775 "$schema": "ferro-json-ui/v2",
1776 "root": "A",
1777 "elements": {
1778 "A": {
1779 "type": "Card",
1780 "$each": {"path": "/items", "as": "item"},
1781 "children": ["mid"]
1782 },
1783 "mid": {
1784 "type": "Section",
1785 "children": ["B"]
1786 },
1787 "B": {
1788 "type": "Card",
1789 "$each": {"path": "/other_items", "as": "other"},
1790 "props": {}
1791 }
1792 }
1793 }"#;
1794 let err = Spec::from_json(json).expect_err("nested $each must be rejected");
1795 match err {
1796 SpecError::NestedEach { outer, inner } => {
1797 assert_eq!(outer, "A");
1798 assert_eq!(inner, "B");
1799 }
1800 other => panic!("expected NestedEach, got: {other:?}"),
1801 }
1802 }
1803
1804 #[test]
1805 fn validate_mismatched_each_child_rejected() {
1806 let json = r#"{
1808 "$schema": "ferro-json-ui/v2",
1809 "root": "A",
1810 "elements": {
1811 "A": {
1812 "type": "Card",
1813 "$each": {"path": "/items", "as": "item"},
1814 "children": ["B"]
1815 },
1816 "B": {
1817 "type": "Text",
1818 "$each": {"path": "/different_items", "as": "item"}
1819 }
1820 }
1821 }"#;
1822 let err = Spec::from_json(json).expect_err("mismatched $each child must be rejected");
1823 match err {
1824 SpecError::MismatchedEach {
1825 parent,
1826 parent_path,
1827 child,
1828 child_path,
1829 } => {
1830 assert_eq!(parent, "A");
1831 assert_eq!(parent_path, "/items");
1832 assert_eq!(child, "B");
1833 assert_eq!(child_path, "/different_items");
1834 }
1835 other => panic!("expected MismatchedEach, got: {other:?}"),
1836 }
1837 }
1838
1839 #[test]
1840 fn validate_correlated_each_child_accepted() {
1841 let json = r#"{
1843 "$schema": "ferro-json-ui/v2",
1844 "root": "A",
1845 "elements": {
1846 "A": {
1847 "type": "Card",
1848 "$each": {"path": "/items", "as": "item"},
1849 "children": ["B"]
1850 },
1851 "B": {
1852 "type": "Text",
1853 "$each": {"path": "/items", "as": "item"}
1854 }
1855 },
1856 "data": {"items": []}
1857 }"#;
1858 Spec::from_json(json).expect("correlated $each children with same (path, as) are valid");
1859 }
1860
1861 #[test]
1866 fn nested_element_builder_basics() {
1867 let el = NestedElement::new("Card")
1868 .prop("title", "x")
1869 .build_for_test();
1870 assert_eq!(el.type_name, "Card");
1871 assert_eq!(el.props.get("title").and_then(|v| v.as_str()), Some("x"));
1872 assert!(el.children.is_empty());
1873 assert!(el.action.is_none());
1874 assert!(el.visible.is_none());
1875 }
1876
1877 #[test]
1878 fn nested_builder_flattens_one_level() {
1879 let spec = Spec::builder()
1880 .element_nested(
1881 "root",
1882 NestedElement::new("Card").child(NestedElement::new("Text").prop("content", "hi")),
1883 )
1884 .build()
1885 .expect("spec is valid");
1886 assert_eq!(spec.root, "root");
1887 assert_eq!(spec.elements.len(), 2);
1888 let root_el = spec.elements.get("root").expect("root present");
1889 assert_eq!(root_el.children, vec!["root-0".to_string()]);
1890 let child = spec.elements.get("root-0").expect("auto-id child present");
1891 assert_eq!(child.type_name, "Text");
1892 assert_eq!(
1893 child.props.get("content").and_then(|v| v.as_str()),
1894 Some("hi")
1895 );
1896 }
1897
1898 #[test]
1899 fn nested_builder_accepts_depth_three() {
1900 let spec = Spec::builder()
1902 .element_nested(
1903 "root",
1904 NestedElement::new("Screen").child(
1905 NestedElement::new("Section")
1906 .child(NestedElement::new("Text").prop("content", "leaf")),
1907 ),
1908 )
1909 .build()
1910 .expect("three levels at depth limit must be valid");
1911 assert_eq!(spec.elements.len(), 3);
1912 let root_el = spec.elements.get("root").expect("root");
1913 assert_eq!(root_el.children, vec!["root-0".to_string()]);
1914 let section = spec.elements.get("root-0").expect("section");
1915 assert_eq!(section.type_name, "Section");
1916 assert_eq!(section.children, vec!["root-0-0".to_string()]);
1917 let leaf = spec.elements.get("root-0-0").expect("leaf");
1918 assert_eq!(leaf.type_name, "Text");
1919 assert!(leaf.children.is_empty());
1920 }
1921
1922 #[test]
1923 fn nested_builder_accepts_depth_sixteen() {
1924 let spec = Spec::builder()
1926 .element_nested(
1927 "root",
1928 NestedElement::new("Screen").child(
1929 NestedElement::new("Grid").child(
1930 NestedElement::new("Card").child(
1931 NestedElement::new("Row").child(
1932 NestedElement::new("Column").child(
1933 NestedElement::new("Section").child(
1934 NestedElement::new("Container").child(
1935 NestedElement::new("Container").child(
1936 NestedElement::new("Container").child(
1937 NestedElement::new("Container").child(
1938 NestedElement::new("Container").child(
1939 NestedElement::new("Container").child(
1940 NestedElement::new("Container").child(
1941 NestedElement::new("Container").child(
1942 NestedElement::new("Container").child(
1943 NestedElement::new("Text")
1944 .prop("content", "leaf"),
1945 ),
1946 ),
1947 ),
1948 ),
1949 ),
1950 ),
1951 ),
1952 ),
1953 ),
1954 ),
1955 ),
1956 ),
1957 ),
1958 ),
1959 ),
1960 )
1961 .build()
1962 .expect("sixteen levels at depth limit must be valid");
1963 assert!(spec.elements.contains_key("root"));
1964 }
1965
1966 #[test]
1967 fn nested_builder_rejects_depth_seventeen() {
1968 let err = Spec::builder()
1970 .element_nested(
1971 "root",
1972 NestedElement::new("Screen").child(
1973 NestedElement::new("Grid").child(
1974 NestedElement::new("Card").child(
1975 NestedElement::new("Row").child(
1976 NestedElement::new("Column").child(
1977 NestedElement::new("Section").child(
1978 NestedElement::new("Container").child(
1979 NestedElement::new("Container").child(
1980 NestedElement::new("Container").child(
1981 NestedElement::new("Container").child(
1982 NestedElement::new("Container").child(
1983 NestedElement::new("Container").child(
1984 NestedElement::new("Container").child(
1985 NestedElement::new("Container").child(
1986 NestedElement::new("Container").child(
1987 NestedElement::new("Column").child(
1988 NestedElement::new("Text")
1989 .prop("content", "too deep"),
1990 ),
1991 ),
1992 ),
1993 ),
1994 ),
1995 ),
1996 ),
1997 ),
1998 ),
1999 ),
2000 ),
2001 ),
2002 ),
2003 ),
2004 ),
2005 ),
2006 )
2007 .build()
2008 .expect_err("seventeen levels must exceed the depth limit");
2009 assert!(
2010 matches!(err, SpecError::DepthExceeded { .. }),
2011 "expected DepthExceeded, got {err:?}"
2012 );
2013 }
2014
2015 #[test]
2016 fn nested_builder_auto_ids_match_position() {
2017 let spec = Spec::builder()
2018 .element_nested(
2019 "parent",
2020 NestedElement::new("Row")
2021 .child(NestedElement::new("ColA"))
2022 .child(NestedElement::new("ColB"))
2023 .child(NestedElement::new("ColC")),
2024 )
2025 .build()
2026 .expect("spec with 3 siblings is valid");
2027 assert_eq!(spec.elements.len(), 4);
2028 let parent = spec.elements.get("parent").expect("parent");
2029 assert_eq!(
2030 parent.children,
2031 vec![
2032 "parent-0".to_string(),
2033 "parent-1".to_string(),
2034 "parent-2".to_string(),
2035 ]
2036 );
2037 assert_eq!(
2038 spec.elements.get("parent-0").expect("child-0").type_name,
2039 "ColA"
2040 );
2041 assert_eq!(
2042 spec.elements.get("parent-1").expect("child-1").type_name,
2043 "ColB"
2044 );
2045 assert_eq!(
2046 spec.elements.get("parent-2").expect("child-2").type_name,
2047 "ColC"
2048 );
2049 }
2050
2051 #[test]
2052 fn nested_builder_root_set_from_first_call() {
2053 let spec = Spec::builder()
2054 .element_nested("first", NestedElement::new("Screen"))
2055 .element_nested("second", NestedElement::new("Screen"))
2056 .build()
2057 .expect("multi-root-call spec");
2058 assert_eq!(spec.root, "first");
2060 }
2061
2062 #[test]
2063 fn nested_builder_preserves_action_and_visible() {
2064 use crate::action::Action;
2065 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
2066 let action = Action::new("home.index");
2067 let vis = Visibility::Condition(VisibilityCondition {
2068 path: "/enabled".to_string(),
2069 operator: VisibilityOperator::Exists,
2070 value: None,
2071 });
2072 let spec = Spec::builder()
2073 .element_nested(
2074 "btn",
2075 NestedElement::new("Button")
2076 .action(action.clone())
2077 .visible(vis.clone()),
2078 )
2079 .build()
2080 .expect("spec with action+visible");
2081 let el = spec.elements.get("btn").expect("btn present");
2082 assert!(el.action.is_some(), "action must be preserved");
2083 assert!(el.visible.is_some(), "visible must be preserved");
2084 }
2085
2086 #[test]
2087 fn nested_builder_and_flat_builder_produce_equivalent_specs() {
2088 let nested = Spec::builder()
2089 .element_nested(
2090 "root",
2091 NestedElement::new("Card")
2092 .prop("title", "T")
2093 .child(NestedElement::new("Text").prop("content", "hi")),
2094 )
2095 .build()
2096 .expect("nested spec valid");
2097
2098 let flat = Spec::builder()
2099 .element(
2100 "root",
2101 Element::new("Card").prop("title", "T").child("root-0"),
2102 )
2103 .element("root-0", Element::new("Text").prop("content", "hi"))
2104 .build()
2105 .expect("flat spec valid");
2106
2107 let nested_json = serde_json::to_value(&nested).unwrap();
2108 let flat_json = serde_json::to_value(&flat).unwrap();
2109 assert_eq!(nested_json, flat_json);
2110 }
2111
2112 #[test]
2113 fn validate_directives_called_between_no_dangling_and_cycle() {
2114 let src = include_str!("spec.rs");
2117 let validate_section = src
2118 .split("fn validate_structure")
2119 .nth(1)
2120 .expect("validate_structure body present");
2121 let body_end = validate_section
2122 .find("\nfn ")
2123 .unwrap_or(validate_section.len());
2124 let body = &validate_section[..body_end];
2125 let pos_no_dangling = body.find("validate_no_dangling").expect("no_dangling call");
2126 let pos_directives = body.find("validate_directives").expect("directives call");
2127 let pos_cycle = body.find("detect_cycle").expect("cycle call");
2128 assert!(
2129 pos_no_dangling < pos_directives,
2130 "validate_directives must be called AFTER validate_no_dangling"
2131 );
2132 assert!(
2133 pos_directives < pos_cycle,
2134 "validate_directives must be called BEFORE detect_cycle"
2135 );
2136 }
2137
2138 #[test]
2143 fn spec_title_literal_roundtrip() {
2144 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":"Hello"}"#;
2145 let spec: Spec = serde_json::from_str(json).expect("parses");
2146 match spec.title.as_ref().unwrap() {
2147 TitleBinding::Literal(s) => assert_eq!(s, "Hello"),
2148 other => panic!("expected Literal, got {other:?}"),
2149 }
2150 let back = serde_json::to_string(&spec).unwrap();
2151 assert!(back.contains(r#""title":"Hello""#), "got: {back}");
2152 }
2153
2154 #[test]
2155 fn spec_title_binding_roundtrip() {
2156 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"$data":"/page_title"}}"#;
2157 let spec: Spec = serde_json::from_str(json).expect("parses");
2158 match spec.title.as_ref().unwrap() {
2159 TitleBinding::Binding(DataRef { data }) => assert_eq!(data, "/page_title"),
2160 other => panic!("expected Binding, got {other:?}"),
2161 }
2162 let back = serde_json::to_string(&spec).unwrap();
2163 assert!(back.contains(r#""$data":"/page_title""#), "got: {back}");
2164 }
2165
2166 #[test]
2167 fn spec_title_absent() {
2168 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}}}"#;
2169 let spec: Spec = serde_json::from_str(json).expect("parses");
2170 assert!(spec.title.is_none());
2171 }
2172
2173 #[test]
2174 fn spec_title_invalid_shape_rejected() {
2175 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"foo":"bar"}}"#;
2177 let result: Result<Spec, _> = serde_json::from_str(json);
2178 assert!(
2179 result.is_err(),
2180 "expected parse failure for {{foo:bar}} title shape"
2181 );
2182 }
2183
2184 #[test]
2185 fn design_meta_valid_round_trip() {
2186 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}},"design":{"intent":"browse","allow":["prefer-data-table"]}}"#;
2187 let spec = Spec::from_json(json).expect("parses");
2188 let design = spec.design.as_ref().expect("design present");
2189 assert_eq!(design.intent.as_deref(), Some("browse"));
2190 assert_eq!(design.allow, vec!["prefer-data-table"]);
2191 let serialized = serde_json::to_string(&spec).unwrap();
2192 let back = Spec::from_json(&serialized).expect("re-parses");
2193 assert_eq!(spec, back);
2194 }
2195
2196 #[test]
2197 fn design_meta_unknown_intent_parses_without_error() {
2198 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}},"design":{"intent":"totally-made-up"}}"#;
2200 let spec = Spec::from_json(json).expect("unknown intent must not fail parse");
2201 let design = spec.design.as_ref().expect("design present");
2202 assert_eq!(design.intent.as_deref(), Some("totally-made-up"));
2203 }
2204
2205 #[test]
2206 fn design_meta_absent_omitted_from_serialized_output() {
2207 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}}}"#;
2208 let spec = Spec::from_json(json).expect("parses");
2209 assert!(spec.design.is_none(), "design should be None");
2210 let serialized = serde_json::to_string(&spec).unwrap();
2211 assert!(!serialized.contains("design"), "design key must be absent from output, got: {serialized}");
2212 }
2213
2214 #[test]
2215 fn each_builder_round_trip() {
2216 let el = Element::new("Tile")
2217 .each("/data/items", "p")
2218 .build();
2219 let directive = el.each.as_ref().expect("each must be Some");
2221 assert_eq!(directive.path, "/data/items");
2222 assert_eq!(directive.as_, "p");
2223 let v = serde_json::to_value(&el).expect("serialize");
2225 assert_eq!(v["$each"]["path"], "/data/items", "path mismatch in JSON");
2226 assert_eq!(v["$each"]["as"], "p", "as mismatch in JSON (as_ must serialize as `as`)");
2227 let back: Element = serde_json::from_value(v).expect("deserialize");
2229 assert_eq!(back.each, el.each, "each directive must survive round-trip");
2230 }
2231
2232 #[test]
2233 fn fill_viewport_builder() {
2234 let spec_true = Spec::builder()
2236 .element("root", Element::new("Container"))
2237 .fill_viewport(true)
2238 .build()
2239 .expect("build succeeds");
2240 assert!(spec_true.fill_viewport, "fill_viewport must be true when set");
2241 let spec_default = Spec::builder()
2243 .element("root", Element::new("Container"))
2244 .build()
2245 .expect("build succeeds");
2246 assert!(!spec_default.fill_viewport, "fill_viewport must default to false");
2247 }
2248}