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)]
74pub struct Spec {
75 #[serde(rename = "$schema")]
77 pub schema: String,
78 pub root: String,
80 pub elements: HashMap<String, Element>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub title: Option<TitleBinding>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub layout: Option<String>,
90 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
92 pub data: Value,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct Element {
103 #[serde(rename = "type")]
105 pub type_name: String,
106 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
108 pub props: Value,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub children: Vec<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub action: Option<Action>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub visible: Option<Visibility>,
118 #[serde(default, skip_serializing_if = "Option::is_none", rename = "$each")]
122 pub each: Option<EachDirective>,
123 #[serde(default, skip_serializing_if = "Option::is_none", rename = "$if")]
137 pub if_: Option<Visibility>,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct EachDirective {
171 pub path: String,
173 #[serde(rename = "as")]
176 pub as_: String,
177}
178
179#[derive(Debug, Error)]
184pub enum SpecError {
185 #[error("failed to parse JSON: {0}")]
186 Json(#[from] serde_json::Error),
187 #[error("duplicate element ID in spec: {0}")]
188 DuplicateId(String),
189 #[error("root element '{0}' not found in elements map")]
190 RootMissing(String),
191 #[error("element '{element}' references child '{child}' which does not exist")]
192 DanglingChild { element: String, child: String },
193 #[error("cycle detected in element graph: {}", path.join(" -> "))]
194 Cycle { path: Vec<String> },
195 #[error(
196 "nesting depth exceeds maximum of {max}: found depth {found} at {}",
197 path.join(" -> ")
198 )]
199 DepthExceeded {
200 max: usize,
201 found: usize,
202 path: Vec<String>,
203 },
204 #[error("invalid element ID '{0}' — must match ^[A-Za-z_][A-Za-z0-9_-]{{0,127}}$")]
205 InvalidId(String),
206 #[error("element '{element_id}' has footer reference '{footer_id}' not found in elements")]
207 FooterMissing {
208 element_id: String,
209 footer_id: String,
210 },
211 #[error("element '{element_id}' has `$each.path = \"{path}\"` resolving to a non-array value in spec.data")]
212 EachPathNotArray { element_id: String, path: String },
213 #[error("element '{element_id}' has `$if.path = \"{path}\"` referencing a key absent from spec.data")]
214 IfPathMissing { element_id: String, path: String },
215 #[error("element '{element_id}' has `$each.as = \"{name}\"` which is a reserved name (one of: data, root, _root, _each, this, self)")]
216 EachAsReservedName { element_id: String, name: String },
217 #[error("nested `$each` is not supported in Phase 163: element '{outer}' templates element '{inner}' which is also `$each`-templated")]
218 NestedEach { outer: String, inner: String },
219 #[error("element '{parent}' (`$each` over '{parent_path}') references child '{child}' which is `$each` over a different path '{child_path}' — mismatched each siblings")]
220 MismatchedEach {
221 parent: String,
222 parent_path: String,
223 child: String,
224 child_path: String,
225 },
226}
227
228impl Spec {
233 pub fn builder() -> SpecBuilder {
239 SpecBuilder::new()
240 }
241
242 pub fn merge_data(mut self, handler_data: serde_json::Value) -> Self {
257 debug_assert!(
258 handler_data.is_null() || handler_data.is_object(),
259 "merge_data expects an Object or Null; non-Object handler_data ignored"
260 );
261 if let Some(obj) = handler_data.as_object() {
262 if self.data.is_null() {
263 self.data = Value::Object(Map::new());
264 }
265 if let Some(data_map) = self.data.as_object_mut() {
266 for (k, v) in obj {
267 data_map.insert(k.clone(), v.clone());
268 }
269 }
270 }
271 self
272 }
273
274 pub fn from_json(json: &str) -> Result<Spec, SpecError> {
279 let raw: SpecWire = match serde_json::from_str::<SpecWire>(json) {
280 Ok(r) => r,
281 Err(e) => {
282 let msg = e.to_string();
284 if let Some(idx) = msg.find(DUP_ID_SENTINEL) {
285 let after = &msg[idx + DUP_ID_SENTINEL.len()..];
286 let id: String = after
287 .chars()
288 .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'' && *c != ',')
289 .collect();
290 return Err(SpecError::DuplicateId(id));
291 }
292 return Err(SpecError::Json(e));
293 }
294 };
295 let spec = Spec {
296 schema: raw.schema,
297 root: raw.root,
298 elements: raw.elements.0,
299 title: raw.title,
300 layout: raw.layout,
301 data: raw.data,
302 };
303 validate_structure(&spec)?;
304 Ok(spec)
305 }
306}
307
308impl Element {
309 #[allow(clippy::new_ret_no_self)]
315 pub fn new(type_name: impl Into<String>) -> ElementBuilder {
316 ElementBuilder {
317 type_name: type_name.into(),
318 props: Map::new(),
319 children: Vec::new(),
320 action: None,
321 visible: None,
322 each: None,
323 if_: None,
324 }
325 }
326}
327
328#[derive(Debug, Default)]
330pub struct SpecBuilder {
331 title: Option<TitleBinding>,
332 layout: Option<String>,
333 data: Value,
334 root: Option<String>,
335 elements: HashMap<String, Element>,
336}
337
338impl SpecBuilder {
339 fn new() -> Self {
340 Self {
341 title: None,
342 layout: None,
343 data: Value::Null,
344 root: None,
345 elements: HashMap::new(),
346 }
347 }
348
349 pub fn title(mut self, t: impl Into<String>) -> Self {
351 self.title = Some(TitleBinding::Literal(t.into()));
352 self
353 }
354
355 pub fn title_binding(mut self, path: impl Into<String>) -> Self {
357 self.title = Some(TitleBinding::Binding(DataRef { data: path.into() }));
358 self
359 }
360
361 pub fn layout(mut self, l: impl Into<String>) -> Self {
363 self.layout = Some(l.into());
364 self
365 }
366
367 pub fn data(mut self, d: Value) -> Self {
369 self.data = d;
370 self
371 }
372
373 pub fn root(mut self, id: impl Into<String>) -> Self {
377 self.root = Some(id.into());
378 self
379 }
380
381 pub fn element(mut self, id: impl Into<String>, el: ElementBuilder) -> Self {
384 let id: String = id.into();
385 if self.root.is_none() {
386 self.root = Some(id.clone());
387 }
388 self.elements.insert(id, el.build());
389 self
390 }
391
392 pub fn element_nested(mut self, id: impl Into<String>, el: NestedElement) -> Self {
408 let id: String = id.into();
409 if self.root.is_none() {
410 self.root = Some(id.clone());
411 }
412 flatten_nested(&mut self.elements, &id, el);
413 self
414 }
415
416 pub fn build(self) -> Result<Spec, SpecError> {
419 let root = self.root.ok_or_else(|| {
420 SpecError::RootMissing(String::new())
424 })?;
425 let spec = Spec {
426 schema: SCHEMA_VERSION.to_string(),
427 root,
428 elements: self.elements,
429 title: self.title,
430 layout: self.layout,
431 data: self.data,
432 };
433 validate_structure(&spec)?;
434 Ok(spec)
435 }
436}
437
438#[derive(Debug)]
440pub struct ElementBuilder {
441 type_name: String,
442 props: Map<String, Value>,
443 children: Vec<String>,
444 action: Option<Action>,
445 visible: Option<Visibility>,
446 each: Option<EachDirective>,
447 if_: Option<Visibility>,
448}
449
450impl ElementBuilder {
451 pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
453 self.props.insert(k.into(), v.into());
454 self
455 }
456
457 pub fn child(mut self, id: impl Into<String>) -> Self {
459 self.children.push(id.into());
460 self
461 }
462
463 pub fn action(mut self, a: Action) -> Self {
465 self.action = Some(a);
466 self
467 }
468
469 pub fn visible(mut self, v: Visibility) -> Self {
471 self.visible = Some(v);
472 self
473 }
474
475 pub(crate) fn build(self) -> Element {
476 let props = if self.props.is_empty() {
477 Value::Null
478 } else {
479 Value::Object(self.props)
480 };
481 Element {
482 type_name: self.type_name,
483 props,
484 children: self.children,
485 action: self.action,
486 visible: self.visible,
487 each: self.each,
488 if_: self.if_,
489 }
490 }
491}
492
493#[derive(Debug)]
519pub struct NestedElement {
520 type_name: String,
521 props: Map<String, Value>,
522 children: Vec<NestedElement>,
523 action: Option<Action>,
524 visible: Option<Visibility>,
525}
526
527impl NestedElement {
528 pub fn new(type_name: impl Into<String>) -> Self {
530 Self {
531 type_name: type_name.into(),
532 props: Map::new(),
533 children: Vec::new(),
534 action: None,
535 visible: None,
536 }
537 }
538
539 pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
541 self.props.insert(k.into(), v.into());
542 self
543 }
544
545 pub fn child(mut self, c: NestedElement) -> Self {
548 self.children.push(c);
549 self
550 }
551
552 pub fn action(mut self, a: Action) -> Self {
554 self.action = Some(a);
555 self
556 }
557
558 pub fn visible(mut self, v: Visibility) -> Self {
560 self.visible = Some(v);
561 self
562 }
563
564 #[cfg(test)]
570 pub(crate) fn build_for_test(self) -> Element {
571 let props = if self.props.is_empty() {
572 Value::Null
573 } else {
574 Value::Object(self.props)
575 };
576 Element {
577 type_name: self.type_name,
578 props,
579 children: Vec::new(),
580 action: self.action,
581 visible: self.visible,
582 each: None,
583 if_: None,
584 }
585 }
586}
587
588fn flatten_nested(elements: &mut HashMap<String, Element>, id: &str, el: NestedElement) {
592 let mut child_ids: Vec<String> = Vec::with_capacity(el.children.len());
593 for (idx, child) in el.children.into_iter().enumerate() {
594 let child_id = format!("{id}-{idx}");
595 flatten_nested(elements, &child_id, child);
596 child_ids.push(child_id);
597 }
598 let props = if el.props.is_empty() {
599 Value::Null
600 } else {
601 Value::Object(el.props)
602 };
603 let element = Element {
604 type_name: el.type_name,
605 props,
606 children: child_ids,
607 action: el.action,
608 visible: el.visible,
609 each: None,
610 if_: None,
611 };
612 elements.insert(id.to_string(), element);
613}
614
615const DUP_ID_SENTINEL: &str = "__FERRO_DUPLICATE_ID__";
624
625#[derive(Deserialize)]
628struct SpecWire {
629 #[serde(rename = "$schema", default = "default_schema")]
630 schema: String,
631 root: String,
632 elements: ElementsMap,
633 #[serde(default)]
634 title: Option<TitleBinding>,
635 #[serde(default)]
636 layout: Option<String>,
637 #[serde(default)]
638 data: Value,
639}
640
641fn default_schema() -> String {
642 SCHEMA_VERSION.to_string()
643}
644
645struct ElementsMap(HashMap<String, Element>);
649
650impl<'de> DeserializeTrait<'de> for ElementsMap {
651 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
652 struct V;
653 impl<'de> Visitor<'de> for V {
654 type Value = ElementsMap;
655 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
656 f.write_str("a JSON object with unique element IDs")
657 }
658 fn visit_map<M: MapAccess<'de>>(self, mut m: M) -> Result<ElementsMap, M::Error> {
659 let mut map: HashMap<String, Element> = HashMap::new();
660 while let Some(k) = m.next_key::<String>()? {
661 if map.contains_key(&k) {
662 return Err(serde::de::Error::custom(format!("{DUP_ID_SENTINEL}{k}")));
663 }
664 let v: Element = m.next_value()?;
665 map.insert(k, v);
666 }
667 Ok(ElementsMap(map))
668 }
669 }
670 d.deserialize_map(V)
671 }
672}
673
674fn validate_structure(spec: &Spec) -> Result<(), SpecError> {
680 validate_ids(&spec.elements)?;
681 if !spec.elements.contains_key(&spec.root) {
682 return Err(SpecError::RootMissing(spec.root.clone()));
683 }
684 validate_no_dangling(&spec.elements)?;
685 validate_directives(spec)?;
686 validate_footer_ids(spec)?;
687 detect_cycle(&spec.elements, &spec.root)?;
688 check_depth(&spec.elements, &spec.root)?;
689 Ok(())
690}
691
692fn is_valid_id(s: &str) -> bool {
694 if s.is_empty() || s.len() > 128 {
695 return false;
696 }
697 let bytes = s.as_bytes();
698 let first = bytes[0];
699 let first_ok = first.is_ascii_alphabetic() || first == b'_';
700 if !first_ok {
701 return false;
702 }
703 bytes[1..]
704 .iter()
705 .all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
706}
707
708fn validate_ids(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
709 for (id, el) in elements {
710 if !is_valid_id(id) {
711 return Err(SpecError::InvalidId(id.clone()));
712 }
713 for child in &el.children {
714 if !is_valid_id(child) {
715 return Err(SpecError::InvalidId(child.clone()));
716 }
717 }
718 }
719 Ok(())
720}
721
722fn validate_no_dangling(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
723 for (id, el) in elements {
724 for child in &el.children {
725 if !elements.contains_key(child) {
726 return Err(SpecError::DanglingChild {
727 element: id.clone(),
728 child: child.clone(),
729 });
730 }
731 }
732 }
733 Ok(())
734}
735
736fn validate_footer_ids(spec: &Spec) -> Result<(), SpecError> {
741 for (element_id, el) in &spec.elements {
742 let footer_ids: Vec<String> = el
744 .props
745 .get("footer")
746 .and_then(|v| v.as_array())
747 .map(|arr| {
748 arr.iter()
749 .filter_map(|v| v.as_str().map(|s| s.to_string()))
750 .collect()
751 })
752 .unwrap_or_default();
753
754 for footer_id in &footer_ids {
755 if !spec.elements.contains_key(footer_id) {
756 return Err(SpecError::FooterMissing {
757 element_id: element_id.clone(),
758 footer_id: footer_id.clone(),
759 });
760 }
761 if el.children.iter().any(|c| c == footer_id) {
763 eprintln!(
764 "ferro-json-ui: element '{element_id}' has '{footer_id}' in both \
765 props.footer and children — the element renders once (in footer); \
766 remove the duplicate from children"
767 );
768 }
769 }
770 }
771 Ok(())
772}
773
774const RESERVED_EACH_AS: &[&str] = &["data", "root", "_root", "_each", "this", "self"];
776
777fn validate_directives(spec: &Spec) -> Result<(), SpecError> {
782 let templated: HashMap<&str, &EachDirective> = spec
784 .elements
785 .iter()
786 .filter_map(|(id, el)| el.each.as_ref().map(|e| (id.as_str(), e)))
787 .collect();
788
789 for (id, el) in &spec.elements {
790 if let Some(each) = &el.each {
792 if RESERVED_EACH_AS.contains(&each.as_.as_str()) {
794 return Err(SpecError::EachAsReservedName {
795 element_id: id.clone(),
796 name: each.as_.clone(),
797 });
798 }
799 if !spec.data.is_null() {
801 if let Some(value) = crate::data::resolve_path(&spec.data, &each.path) {
802 if !value.is_array() {
803 return Err(SpecError::EachPathNotArray {
804 element_id: id.clone(),
805 path: each.path.clone(),
806 });
807 }
808 }
809 }
810 for child in &el.children {
812 if let Some(child_each) = templated.get(child.as_str()) {
813 if child_each.path != each.path || child_each.as_ != each.as_ {
814 return Err(SpecError::MismatchedEach {
815 parent: id.clone(),
816 parent_path: each.path.clone(),
817 child: child.clone(),
818 child_path: child_each.path.clone(),
819 });
820 }
821 }
822 }
823 let direct: HashSet<&str> = el.children.iter().map(|s| s.as_str()).collect();
828 let mut visited: HashSet<&str> = HashSet::new();
829 let mut stack: Vec<&str> = Vec::new();
830 for child in &el.children {
832 if let Some(child_el) = spec.elements.get(child) {
833 for gc in &child_el.children {
834 stack.push(gc.as_str());
835 }
836 }
837 }
838 while let Some(node) = stack.pop() {
839 if !visited.insert(node) {
840 continue;
841 }
842 if templated.contains_key(node) && !direct.contains(node) {
843 return Err(SpecError::NestedEach {
844 outer: id.clone(),
845 inner: node.to_string(),
846 });
847 }
848 if let Some(node_el) = spec.elements.get(node) {
849 for c in &node_el.children {
850 stack.push(c.as_str());
851 }
852 }
853 }
854 }
855 if let Some(vis) = &el.if_ {
859 if !spec.data.is_null() {
860 check_visibility_paths(id, vis, &spec.data)?;
861 }
862 }
863 }
864 Ok(())
865}
866
867fn check_visibility_paths(
870 element_id: &str,
871 vis: &Visibility,
872 data: &Value,
873) -> Result<(), SpecError> {
874 match vis {
875 Visibility::And { and } => {
876 for v in and {
877 check_visibility_paths(element_id, v, data)?;
878 }
879 }
880 Visibility::Or { or } => {
881 for v in or {
882 check_visibility_paths(element_id, v, data)?;
883 }
884 }
885 Visibility::Not { not } => check_visibility_paths(element_id, not, data)?,
886 Visibility::Condition(c) => {
887 if crate::data::resolve_path(data, &c.path).is_none() {
888 return Err(SpecError::IfPathMissing {
889 element_id: element_id.to_string(),
890 path: c.path.clone(),
891 });
892 }
893 }
894 }
895 Ok(())
896}
897
898fn detect_cycle(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
899 let mut visited: HashSet<String> = HashSet::new();
900 let mut on_stack: Vec<String> = Vec::new();
901 dfs(root, elements, &mut visited, &mut on_stack)
902}
903
904fn dfs(
905 node: &str,
906 elements: &HashMap<String, Element>,
907 visited: &mut HashSet<String>,
908 on_stack: &mut Vec<String>,
909) -> Result<(), SpecError> {
910 if let Some(start) = on_stack.iter().position(|n| n == node) {
911 let mut path: Vec<String> = on_stack[start..].to_vec();
912 path.push(node.to_string());
913 return Err(SpecError::Cycle { path });
914 }
915 if visited.contains(node) {
916 return Ok(());
917 }
918 on_stack.push(node.to_string());
919 if let Some(el) = elements.get(node) {
920 for child in &el.children {
921 dfs(child, elements, visited, on_stack)?;
922 }
923 }
924 on_stack.pop();
925 visited.insert(node.to_string());
926 Ok(())
927}
928
929fn check_depth(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
930 let mut path: Vec<String> = Vec::new();
931 walk(root, elements, 1, &mut path)
932}
933
934fn walk(
935 node: &str,
936 elements: &HashMap<String, Element>,
937 depth: usize,
938 path: &mut Vec<String>,
939) -> Result<(), SpecError> {
940 path.push(node.to_string());
941 if depth > MAX_NESTING_DEPTH {
942 return Err(SpecError::DepthExceeded {
943 max: MAX_NESTING_DEPTH,
944 found: depth,
945 path: path.clone(),
946 });
947 }
948 if let Some(el) = elements.get(node) {
949 for child in &el.children {
950 walk(child, elements, depth + 1, path)?;
951 }
952 }
953 path.pop();
954 Ok(())
955}
956
957#[cfg(test)]
965#[rustfmt::skip]
966mod tests {
967 use super::*;
968 use serde_json::json;
969
970 #[test]
971 fn default_schema_is_v2() {
972 assert_eq!(default_schema(), SCHEMA_VERSION);
973 assert_eq!(SCHEMA_VERSION, "ferro-json-ui/v2");
974 }
975
976 #[test]
977 fn is_valid_id_edge_cases() {
978 let cases: &[(&str, bool)] = &[
980 ("", false),
981 ("1abc", false),
982 ("a", true),
983 ("_", true),
984 ("a_b-c", true),
985 ("user form", false),
986 ("ABC123", true),
987 ("a.b", false),
988 ("/path", false),
989 ];
990 for (s, ok) in cases {
991 assert_eq!(is_valid_id(s), *ok, "mismatch on {s:?}");
992 }
993 let ok128: String = "a".repeat(128);
995 let bad129: String = "a".repeat(129);
996 assert!(is_valid_id(&ok128));
997 assert!(!is_valid_id(&bad129));
998 }
999
1000 #[test]
1001 fn builder_minimal_round_trips() {
1002 let spec = Spec::builder()
1003 .element("a", Element::new("Text").prop("content", "Hi"))
1004 .build()
1005 .unwrap();
1006 assert_eq!(spec.schema, SCHEMA_VERSION);
1007 assert_eq!(spec.root, "a");
1008 assert_eq!(spec.elements.len(), 1);
1009 let json = serde_json::to_string(&spec).unwrap();
1010 let back = Spec::from_json(&json).unwrap();
1011 assert_eq!(spec, back);
1012 }
1013
1014 #[test]
1015 fn builder_parity_with_json() {
1016 let from_json = Spec::from_json(
1017 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text","props":{"content":"Hi"}}}}"#,
1018 )
1019 .unwrap();
1020 let from_builder = Spec::builder()
1021 .element("a", Element::new("Text").prop("content", "Hi"))
1022 .build()
1023 .unwrap();
1024 assert_eq!(from_json, from_builder);
1025 }
1026
1027 #[test]
1028 fn from_json_rejects_missing_root() {
1029 let err = Spec::from_json(
1030 r#"{"$schema":"ferro-json-ui/v2","root":"nope","elements":{"a":{"type":"Text"}}}"#,
1031 )
1032 .unwrap_err();
1033 match err {
1034 SpecError::RootMissing(id) => assert_eq!(id, "nope"),
1035 other => panic!("expected RootMissing, got {other:?}"),
1036 }
1037 }
1038
1039 #[test]
1040 fn from_json_rejects_dangling_child() {
1041 let err = Spec::from_json(
1042 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Card","children":["ghost"]}}}"#,
1043 )
1044 .unwrap_err();
1045 match err {
1046 SpecError::DanglingChild { element, child } => {
1047 assert_eq!(element, "a");
1048 assert_eq!(child, "ghost");
1049 }
1050 other => panic!("expected DanglingChild, got {other:?}"),
1051 }
1052 }
1053
1054 #[test]
1061 fn validate_allows_children_ref_to_if_gated_element() {
1062 let json = r#"{
1065 "$schema": "ferro-json-ui/v2",
1066 "root": "parent",
1067 "elements": {
1068 "parent": {
1069 "type": "Card",
1070 "props": {"title": "parent"},
1071 "children": ["child"]
1072 },
1073 "child": {
1074 "type": "Text",
1075 "props": {"content": "conditional"},
1076 "$if": {"path": "/data/show", "operator": "eq", "value": true}
1077 }
1078 }
1079 }"#;
1080 let spec = Spec::from_json(json);
1082 assert!(
1083 spec.is_ok(),
1084 "$if-gated child must not be rejected as dangling: {:?}",
1085 spec.err()
1086 );
1087 }
1088
1089 #[test]
1090 fn from_json_rejects_self_cycle() {
1091 let err = Spec::from_json(
1092 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{"A":{"type":"Card","children":["A"]}}}"#,
1093 )
1094 .unwrap_err();
1095 match err {
1096 SpecError::Cycle { path } => {
1097 assert_eq!(path, vec!["A".to_string(), "A".to_string()]);
1098 }
1099 other => panic!("expected Cycle (self), got {other:?}"),
1100 }
1101 }
1102
1103 #[test]
1104 fn from_json_rejects_two_cycle() {
1105 let err = Spec::from_json(
1106 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{"root":{"type":"Card","children":["A"]},"A":{"type":"Card","children":["root"]}}}"#,
1107 )
1108 .unwrap_err();
1109 match err {
1110 SpecError::Cycle { path } => {
1111 assert!(path.len() >= 3);
1112 assert_eq!(path.first(), path.last());
1113 }
1114 other => panic!("expected Cycle, got {other:?}"),
1115 }
1116 }
1117
1118 #[test]
1119 fn cycle_detector_only_on_revisit() {
1120 let err = Spec::from_json(
1125 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
1126 "A":{"type":"Card","children":["B"]},
1127 "B":{"type":"Card","children":["A"]}
1128 }}"#,
1129 )
1130 .unwrap_err();
1131 match err {
1132 SpecError::Cycle { path } => {
1133 assert!(
1134 path.iter().any(|p| p == "A"),
1135 "cycle path must contain A; got {path:?}"
1136 );
1137 assert!(
1138 path.iter().any(|p| p == "B"),
1139 "cycle path must contain B; got {path:?}"
1140 );
1141 }
1142 other => panic!("expected Cycle, got {other:?}"),
1143 }
1144 }
1145
1146 #[test]
1147 fn from_json_rejects_depth_17() {
1148 let err = Spec::from_json(
1150 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
1151 "root":{"type":"Container","children":["e1"]},
1152 "e1":{"type":"Container","children":["e2"]},
1153 "e2":{"type":"Container","children":["e3"]},
1154 "e3":{"type":"Container","children":["e4"]},
1155 "e4":{"type":"Container","children":["e5"]},
1156 "e5":{"type":"Container","children":["e6"]},
1157 "e6":{"type":"Container","children":["e7"]},
1158 "e7":{"type":"Container","children":["e8"]},
1159 "e8":{"type":"Container","children":["e9"]},
1160 "e9":{"type":"Container","children":["e10"]},
1161 "e10":{"type":"Container","children":["e11"]},
1162 "e11":{"type":"Container","children":["e12"]},
1163 "e12":{"type":"Container","children":["e13"]},
1164 "e13":{"type":"Container","children":["e14"]},
1165 "e14":{"type":"Container","children":["e15"]},
1166 "e15":{"type":"Container","children":["e16"]},
1167 "e16":{"type":"Text"}
1168 }}"#,
1169 )
1170 .unwrap_err();
1171 match err {
1172 SpecError::DepthExceeded { max, found, path } => {
1173 assert_eq!(max, 16, "max must equal MAX_NESTING_DEPTH=16");
1174 assert_eq!(found, 17, "found must be 17 (one past the limit)");
1175 assert!(!path.is_empty());
1176 }
1177 other => panic!("expected DepthExceeded, got {other:?}"),
1178 }
1179 }
1180
1181 #[test]
1182 fn from_json_accepts_depth_8() {
1183 let spec = Spec::from_json(
1187 r#"{"$schema":"ferro-json-ui/v2","root":"dashboard","elements":{
1188 "dashboard":{"type":"Screen","children":["root"]},
1189 "root":{"type":"Container","children":["detail_page"]},
1190 "detail_page":{"type":"DetailPage","children":["tab"]},
1191 "tab":{"type":"Card","children":["card"]},
1192 "card":{"type":"Card","children":["form"]},
1193 "form":{"type":"Form","children":["row"]},
1194 "row":{"type":"Grid","children":["switch_day"]},
1195 "switch_day":{"type":"Switch"}
1196 }}"#,
1197 )
1198 .expect("depth-8 staff-detail spec must parse without DepthExceeded");
1199 assert_eq!(spec.elements.len(), 8);
1200 }
1201
1202 #[test]
1203 fn from_json_rejects_invalid_id_space() {
1204 let err = Spec::from_json(
1205 r#"{"$schema":"ferro-json-ui/v2","root":"user form","elements":{"user form":{"type":"Text"}}}"#,
1206 )
1207 .unwrap_err();
1208 match err {
1209 SpecError::InvalidId(id) => assert_eq!(id, "user form"),
1210 other => panic!("expected InvalidId, got {other:?}"),
1211 }
1212 }
1213
1214 #[test]
1215 fn from_json_rejects_duplicate_id() {
1216 let err = Spec::from_json(
1218 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text"},"a":{"type":"Card"}}}"#,
1219 )
1220 .unwrap_err();
1221 match err {
1222 SpecError::DuplicateId(id) => assert_eq!(id, "a"),
1223 other => panic!("expected DuplicateId, got {other:?}"),
1224 }
1225 }
1226
1227 #[test]
1228 fn from_json_accepts_three_level_nesting() {
1229 let spec = Spec::from_json(
1230 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
1231 "root":{"type":"Card","children":["section"]},
1232 "section":{"type":"FormSection","children":["leaf"]},
1233 "leaf":{"type":"Text"}
1234 }}"#,
1235 )
1236 .unwrap();
1237 assert_eq!(spec.elements.len(), 3);
1238 }
1239
1240 #[test]
1241 fn from_json_accepts_diamond() {
1242 let spec = Spec::from_json(
1245 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
1246 "A":{"type":"Card","children":["B","C"]},
1247 "B":{"type":"Card","children":["D"]},
1248 "C":{"type":"Card","children":["D"]},
1249 "D":{"type":"Text"}
1250 }}"#,
1251 )
1252 .unwrap();
1253 assert_eq!(spec.elements.len(), 4);
1254 }
1255
1256 #[test]
1257 fn from_json_wraps_syntax_errors() {
1258 let err = Spec::from_json("{ this is not json ").unwrap_err();
1260 assert!(matches!(err, SpecError::Json(_)), "got {err:?}");
1261 }
1262
1263 #[test]
1264 fn builder_rejects_forward_ref_without_target() {
1265 let err = Spec::builder()
1267 .element("root", Element::new("Card").child("ghost"))
1268 .build()
1269 .unwrap_err();
1270 match err {
1271 SpecError::DanglingChild { element, child } => {
1272 assert_eq!(element, "root");
1273 assert_eq!(child, "ghost");
1274 }
1275 other => panic!("expected DanglingChild, got {other:?}"),
1276 }
1277 }
1278
1279 #[test]
1280 fn builder_data_payload_survives_round_trip() {
1281 let spec = Spec::builder()
1282 .element("a", Element::new("Text"))
1283 .data(json!({"user":{"name":"Alice"}}))
1284 .build()
1285 .unwrap();
1286 let json = serde_json::to_string(&spec).unwrap();
1287 let back = Spec::from_json(&json).unwrap();
1288 assert_eq!(back.data, json!({"user":{"name":"Alice"}}));
1289 }
1290
1291 #[test]
1292 fn element_omits_optional_fields_when_absent() {
1293 let spec = Spec::builder()
1294 .element("bare", Element::new("Text"))
1295 .build()
1296 .unwrap();
1297 let json = serde_json::to_string(&spec).unwrap();
1298 assert!(!json.contains("children"));
1300 assert!(!json.contains("props"));
1301 assert!(!json.contains("action"));
1302 assert!(!json.contains("visible"));
1303 }
1304
1305 #[test]
1306 fn merge_data_handler_wins() {
1307 let spec = Spec::builder()
1308 .element("a", Element::new("Text"))
1309 .data(json!({"a": 1, "b": 2}))
1310 .build()
1311 .unwrap();
1312 let merged = spec.merge_data(json!({"b": 99, "c": 3}));
1313 assert_eq!(merged.data, json!({"a": 1, "b": 99, "c": 3}));
1314 }
1315
1316 #[test]
1317 fn merge_data_ignores_non_object() {
1318 let spec = Spec::builder()
1320 .element("a", Element::new("Text"))
1321 .data(json!({"a": 1}))
1322 .build()
1323 .unwrap();
1324 let merged = spec.merge_data(Value::Null);
1325 assert_eq!(merged.data, json!({"a": 1}));
1326 }
1331
1332 #[test]
1333 fn merge_data_initializes_null_data() {
1334 let spec = Spec::builder()
1335 .element("a", Element::new("Text"))
1336 .build() .unwrap();
1338 assert_eq!(spec.data, Value::Null);
1339 let merged = spec.merge_data(json!({"k": "v"}));
1340 assert_eq!(merged.data, json!({"k": "v"}));
1341 }
1342
1343 #[test]
1344 fn merge_data_empty_handler_no_op() {
1345 let spec = Spec::builder()
1346 .element("a", Element::new("Text"))
1347 .data(json!({"a": 1}))
1348 .build()
1349 .unwrap();
1350 let merged = spec.merge_data(json!({}));
1351 assert_eq!(merged.data, json!({"a": 1}));
1352 }
1353
1354 #[test]
1355 fn from_json_rejects_missing_footer_id() {
1356 let err = Spec::from_json(
1357 r#"{
1358 "$schema": "ferro-json-ui/v2",
1359 "root": "card",
1360 "elements": {
1361 "card": {
1362 "type": "Card",
1363 "props": {"title": "T", "footer": ["ghost"]}
1364 }
1365 }
1366 }"#,
1367 )
1368 .unwrap_err();
1369 match err {
1370 SpecError::FooterMissing {
1371 element_id,
1372 footer_id,
1373 } => {
1374 assert_eq!(element_id, "card");
1375 assert_eq!(footer_id, "ghost");
1376 }
1377 other => panic!("expected FooterMissing, got {other:?}"),
1378 }
1379 }
1380
1381 #[test]
1382 fn from_json_rejects_missing_modal_footer_id() {
1383 let err = Spec::from_json(
1386 r#"{
1387 "$schema": "ferro-json-ui/v2",
1388 "root": "modal",
1389 "elements": {
1390 "modal": {
1391 "type": "Modal",
1392 "props": {"id": "m", "title": "T", "footer": ["ghost"]}
1393 }
1394 }
1395 }"#,
1396 )
1397 .unwrap_err();
1398 match err {
1399 SpecError::FooterMissing {
1400 element_id,
1401 footer_id,
1402 } => {
1403 assert_eq!(element_id, "modal");
1404 assert_eq!(footer_id, "ghost");
1405 }
1406 other => panic!("expected FooterMissing on Modal, got {other:?}"),
1407 }
1408 }
1409
1410 #[test]
1411 fn spec_warns_duplicate_footer_child() {
1412 let spec = Spec::from_json(
1415 r#"{
1416 "$schema": "ferro-json-ui/v2",
1417 "root": "card",
1418 "elements": {
1419 "card": {
1420 "type": "Card",
1421 "props": {"title": "T", "footer": ["btn"]},
1422 "children": ["btn"]
1423 },
1424 "btn": {
1425 "type": "Button",
1426 "props": {"label": "Save"}
1427 }
1428 }
1429 }"#,
1430 )
1431 .expect("D-08 warning is non-fatal; parse must succeed");
1432 assert_eq!(spec.root, "card");
1433 }
1434
1435 #[test]
1436 fn each_directive_round_trips() {
1437 let json = serde_json::json!({"path": "/orders", "as": "order"});
1438 let parsed: EachDirective = serde_json::from_value(json.clone()).expect("decode");
1439 assert_eq!(parsed.path, "/orders");
1440 assert_eq!(parsed.as_, "order");
1441 let reserialized = serde_json::to_value(&parsed).expect("encode");
1442 assert_eq!(reserialized, json);
1443 assert!(reserialized.get("as").is_some());
1445 assert!(reserialized.get("as_").is_none());
1446 }
1447
1448 #[test]
1449 fn element_with_each_round_trips() {
1450 let json = serde_json::json!({
1451 "type": "Card",
1452 "$each": {"path": "/orders", "as": "order"},
1453 "props": {"title": "x"}
1454 });
1455 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1456 assert!(parsed.each.is_some());
1457 let each = parsed.each.as_ref().unwrap();
1458 assert_eq!(each.path, "/orders");
1459 assert_eq!(each.as_, "order");
1460 let reserialized = serde_json::to_value(&parsed).expect("encode");
1461 assert!(reserialized.get("$each").is_some());
1462 }
1463
1464 #[test]
1465 fn element_without_each_omits_field() {
1466 let spec = Spec::builder()
1468 .element("card", Element::new("Card").prop("title", "hello"))
1469 .build()
1470 .expect("spec is valid");
1471 let card = spec.elements.get("card").expect("card present");
1472 let json = serde_json::to_value(card).expect("encode");
1473 assert!(
1474 json.get("$each").is_none(),
1475 "expected $each to be omitted when None; got: {json}"
1476 );
1477 }
1478
1479 #[test]
1480 fn if_directive_flat_condition_round_trips() {
1481 use crate::visibility::Visibility;
1482 let json = serde_json::json!({"path": "/can_advance", "operator": "eq", "value": true});
1483 let parsed: Visibility = serde_json::from_value(json.clone()).expect("decode");
1484 match &parsed {
1485 Visibility::Condition(c) => {
1486 assert_eq!(c.path, "/can_advance");
1487 assert_eq!(c.value, Some(serde_json::json!(true)));
1488 }
1489 _ => panic!("expected flat Condition variant, got: {parsed:?}"),
1490 }
1491 let reserialized = serde_json::to_value(&parsed).expect("encode");
1492 assert!(reserialized.get("path").is_some());
1493 assert!(reserialized.get("operator").is_some());
1494 }
1495
1496 #[test]
1497 fn element_with_if_flat_round_trips() {
1498 let json = serde_json::json!({
1499 "type": "Button",
1500 "$if": {"path": "/can_advance", "operator": "eq", "value": true},
1501 "props": {"label": "x"}
1502 });
1503 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1504 assert!(parsed.if_.is_some());
1505 let reserialized = serde_json::to_value(&parsed).expect("encode");
1506 assert!(reserialized.get("$if").is_some());
1507 }
1508
1509 #[test]
1510 fn element_with_if_compound_round_trips() {
1511 use crate::visibility::Visibility;
1512 let json = serde_json::json!({
1513 "type": "Button",
1514 "$if": {"and": [
1515 {"path": "/a", "operator": "exists"},
1516 {"path": "/b", "operator": "eq", "value": true}
1517 ]},
1518 "props": {"label": "x"}
1519 });
1520 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1521 match parsed.if_.as_ref() {
1522 Some(Visibility::And { and }) => assert_eq!(and.len(), 2),
1523 other => panic!("expected And variant, got: {other:?}"),
1524 }
1525 let reserialized = serde_json::to_value(&parsed).expect("encode");
1526 assert!(reserialized.get("$if").and_then(|v| v.get("and")).is_some());
1527 }
1528
1529 #[test]
1530 fn element_without_if_omits_field() {
1531 let spec = Spec::builder()
1532 .element("btn", Element::new("Button").prop("label", "ok"))
1533 .build()
1534 .expect("spec is valid");
1535 let btn = spec.elements.get("btn").expect("btn present");
1536 let json = serde_json::to_value(btn).expect("encode");
1537 assert!(
1538 json.get("$if").is_none(),
1539 "expected $if to be omitted when None; got: {json}"
1540 );
1541 }
1542
1543 #[test]
1548 fn validate_each_path_not_array_fires() {
1549 let json = r#"{
1550 "$schema": "ferro-json-ui/v2",
1551 "root": "list",
1552 "elements": {
1553 "list": {
1554 "type": "Card",
1555 "$each": {"path": "/orders", "as": "order"},
1556 "props": {}
1557 }
1558 },
1559 "data": {"orders": "not-an-array"}
1560 }"#;
1561 let err = Spec::from_json(json).expect_err("validator must reject non-array $each.path");
1562 match err {
1563 SpecError::EachPathNotArray { element_id, path } => {
1564 assert_eq!(element_id, "list");
1565 assert_eq!(path, "/orders");
1566 }
1567 other => panic!("expected EachPathNotArray, got: {other:?}"),
1568 }
1569 }
1570
1571 #[test]
1572 fn validate_each_path_not_array_skipped_when_data_null() {
1573 let json = r#"{
1575 "$schema": "ferro-json-ui/v2",
1576 "root": "list",
1577 "elements": {
1578 "list": {
1579 "type": "Card",
1580 "$each": {"path": "/orders", "as": "order"},
1581 "props": {}
1582 }
1583 }
1584 }"#;
1585 Spec::from_json(json).expect("no error when data is null");
1587 }
1588
1589 #[test]
1590 fn validate_each_as_reserved_data_rejected() {
1591 let json = r#"{
1592 "$schema": "ferro-json-ui/v2",
1593 "root": "list",
1594 "elements": {
1595 "list": {
1596 "type": "Card",
1597 "$each": {"path": "/items", "as": "data"},
1598 "props": {}
1599 }
1600 }
1601 }"#;
1602 let err = Spec::from_json(json).expect_err("'data' is a reserved name");
1603 match err {
1604 SpecError::EachAsReservedName { element_id, name } => {
1605 assert_eq!(element_id, "list");
1606 assert_eq!(name, "data");
1607 }
1608 other => panic!("expected EachAsReservedName, got: {other:?}"),
1609 }
1610 }
1611
1612 #[test]
1613 fn validate_each_as_reserved_root_rejected() {
1614 let json = r#"{
1615 "$schema": "ferro-json-ui/v2",
1616 "root": "list",
1617 "elements": {
1618 "list": {
1619 "type": "Card",
1620 "$each": {"path": "/items", "as": "root"},
1621 "props": {}
1622 }
1623 }
1624 }"#;
1625 let err = Spec::from_json(json).expect_err("'root' is a reserved name");
1626 match err {
1627 SpecError::EachAsReservedName { element_id, name } => {
1628 assert_eq!(element_id, "list");
1629 assert_eq!(name, "root");
1630 }
1631 other => panic!("expected EachAsReservedName, got: {other:?}"),
1632 }
1633 }
1634
1635 #[test]
1636 fn validate_each_as_non_reserved_accepted() {
1637 let json_order = r#"{
1639 "$schema": "ferro-json-ui/v2",
1640 "root": "list",
1641 "elements": {
1642 "list": {
1643 "type": "Card",
1644 "$each": {"path": "/items", "as": "order"},
1645 "props": {}
1646 }
1647 },
1648 "data": {"items": []}
1649 }"#;
1650 Spec::from_json(json_order).expect("'order' is not reserved");
1651
1652 let json_row = r#"{
1653 "$schema": "ferro-json-ui/v2",
1654 "root": "list",
1655 "elements": {
1656 "list": {
1657 "type": "Card",
1658 "$each": {"path": "/items", "as": "row"},
1659 "props": {}
1660 }
1661 },
1662 "data": {"items": []}
1663 }"#;
1664 Spec::from_json(json_row).expect("'row' is not reserved");
1665 }
1666
1667 #[test]
1668 fn validate_if_path_missing_fires() {
1669 let json = r#"{
1670 "$schema": "ferro-json-ui/v2",
1671 "root": "btn",
1672 "elements": {
1673 "btn": {
1674 "type": "Button",
1675 "$if": {"path": "/missing_key", "operator": "eq", "value": true},
1676 "props": {"label": "Go"}
1677 }
1678 },
1679 "data": {"other": true}
1680 }"#;
1681 let err = Spec::from_json(json).expect_err("missing $if.path must error");
1682 match err {
1683 SpecError::IfPathMissing { element_id, path } => {
1684 assert_eq!(element_id, "btn");
1685 assert_eq!(path, "/missing_key");
1686 }
1687 other => panic!("expected IfPathMissing, got: {other:?}"),
1688 }
1689 }
1690
1691 #[test]
1692 fn validate_if_path_missing_skipped_when_data_null() {
1693 let json = r#"{
1695 "$schema": "ferro-json-ui/v2",
1696 "root": "btn",
1697 "elements": {
1698 "btn": {
1699 "type": "Button",
1700 "$if": {"path": "/missing_key", "operator": "eq", "value": true},
1701 "props": {"label": "Go"}
1702 }
1703 }
1704 }"#;
1705 Spec::from_json(json).expect("no error when data is null");
1706 }
1707
1708 #[test]
1709 fn validate_nested_each_rejected() {
1710 let json = r#"{
1713 "$schema": "ferro-json-ui/v2",
1714 "root": "A",
1715 "elements": {
1716 "A": {
1717 "type": "Card",
1718 "$each": {"path": "/items", "as": "item"},
1719 "children": ["mid"]
1720 },
1721 "mid": {
1722 "type": "Section",
1723 "children": ["B"]
1724 },
1725 "B": {
1726 "type": "Card",
1727 "$each": {"path": "/other_items", "as": "other"},
1728 "props": {}
1729 }
1730 }
1731 }"#;
1732 let err = Spec::from_json(json).expect_err("nested $each must be rejected");
1733 match err {
1734 SpecError::NestedEach { outer, inner } => {
1735 assert_eq!(outer, "A");
1736 assert_eq!(inner, "B");
1737 }
1738 other => panic!("expected NestedEach, got: {other:?}"),
1739 }
1740 }
1741
1742 #[test]
1743 fn validate_mismatched_each_child_rejected() {
1744 let json = r#"{
1746 "$schema": "ferro-json-ui/v2",
1747 "root": "A",
1748 "elements": {
1749 "A": {
1750 "type": "Card",
1751 "$each": {"path": "/items", "as": "item"},
1752 "children": ["B"]
1753 },
1754 "B": {
1755 "type": "Text",
1756 "$each": {"path": "/different_items", "as": "item"}
1757 }
1758 }
1759 }"#;
1760 let err = Spec::from_json(json).expect_err("mismatched $each child must be rejected");
1761 match err {
1762 SpecError::MismatchedEach {
1763 parent,
1764 parent_path,
1765 child,
1766 child_path,
1767 } => {
1768 assert_eq!(parent, "A");
1769 assert_eq!(parent_path, "/items");
1770 assert_eq!(child, "B");
1771 assert_eq!(child_path, "/different_items");
1772 }
1773 other => panic!("expected MismatchedEach, got: {other:?}"),
1774 }
1775 }
1776
1777 #[test]
1778 fn validate_correlated_each_child_accepted() {
1779 let json = r#"{
1781 "$schema": "ferro-json-ui/v2",
1782 "root": "A",
1783 "elements": {
1784 "A": {
1785 "type": "Card",
1786 "$each": {"path": "/items", "as": "item"},
1787 "children": ["B"]
1788 },
1789 "B": {
1790 "type": "Text",
1791 "$each": {"path": "/items", "as": "item"}
1792 }
1793 },
1794 "data": {"items": []}
1795 }"#;
1796 Spec::from_json(json).expect("correlated $each children with same (path, as) are valid");
1797 }
1798
1799 #[test]
1804 fn nested_element_builder_basics() {
1805 let el = NestedElement::new("Card")
1806 .prop("title", "x")
1807 .build_for_test();
1808 assert_eq!(el.type_name, "Card");
1809 assert_eq!(el.props.get("title").and_then(|v| v.as_str()), Some("x"));
1810 assert!(el.children.is_empty());
1811 assert!(el.action.is_none());
1812 assert!(el.visible.is_none());
1813 }
1814
1815 #[test]
1816 fn nested_builder_flattens_one_level() {
1817 let spec = Spec::builder()
1818 .element_nested(
1819 "root",
1820 NestedElement::new("Card").child(NestedElement::new("Text").prop("content", "hi")),
1821 )
1822 .build()
1823 .expect("spec is valid");
1824 assert_eq!(spec.root, "root");
1825 assert_eq!(spec.elements.len(), 2);
1826 let root_el = spec.elements.get("root").expect("root present");
1827 assert_eq!(root_el.children, vec!["root-0".to_string()]);
1828 let child = spec.elements.get("root-0").expect("auto-id child present");
1829 assert_eq!(child.type_name, "Text");
1830 assert_eq!(
1831 child.props.get("content").and_then(|v| v.as_str()),
1832 Some("hi")
1833 );
1834 }
1835
1836 #[test]
1837 fn nested_builder_accepts_depth_three() {
1838 let spec = Spec::builder()
1840 .element_nested(
1841 "root",
1842 NestedElement::new("Screen").child(
1843 NestedElement::new("Section")
1844 .child(NestedElement::new("Text").prop("content", "leaf")),
1845 ),
1846 )
1847 .build()
1848 .expect("three levels at depth limit must be valid");
1849 assert_eq!(spec.elements.len(), 3);
1850 let root_el = spec.elements.get("root").expect("root");
1851 assert_eq!(root_el.children, vec!["root-0".to_string()]);
1852 let section = spec.elements.get("root-0").expect("section");
1853 assert_eq!(section.type_name, "Section");
1854 assert_eq!(section.children, vec!["root-0-0".to_string()]);
1855 let leaf = spec.elements.get("root-0-0").expect("leaf");
1856 assert_eq!(leaf.type_name, "Text");
1857 assert!(leaf.children.is_empty());
1858 }
1859
1860 #[test]
1861 fn nested_builder_accepts_depth_sixteen() {
1862 let spec = Spec::builder()
1864 .element_nested(
1865 "root",
1866 NestedElement::new("Screen").child(
1867 NestedElement::new("Grid").child(
1868 NestedElement::new("Card").child(
1869 NestedElement::new("Row").child(
1870 NestedElement::new("Column").child(
1871 NestedElement::new("Section").child(
1872 NestedElement::new("Container").child(
1873 NestedElement::new("Container").child(
1874 NestedElement::new("Container").child(
1875 NestedElement::new("Container").child(
1876 NestedElement::new("Container").child(
1877 NestedElement::new("Container").child(
1878 NestedElement::new("Container").child(
1879 NestedElement::new("Container").child(
1880 NestedElement::new("Container").child(
1881 NestedElement::new("Text")
1882 .prop("content", "leaf"),
1883 ),
1884 ),
1885 ),
1886 ),
1887 ),
1888 ),
1889 ),
1890 ),
1891 ),
1892 ),
1893 ),
1894 ),
1895 ),
1896 ),
1897 ),
1898 )
1899 .build()
1900 .expect("sixteen levels at depth limit must be valid");
1901 assert!(spec.elements.contains_key("root"));
1902 }
1903
1904 #[test]
1905 fn nested_builder_rejects_depth_seventeen() {
1906 let err = Spec::builder()
1908 .element_nested(
1909 "root",
1910 NestedElement::new("Screen").child(
1911 NestedElement::new("Grid").child(
1912 NestedElement::new("Card").child(
1913 NestedElement::new("Row").child(
1914 NestedElement::new("Column").child(
1915 NestedElement::new("Section").child(
1916 NestedElement::new("Container").child(
1917 NestedElement::new("Container").child(
1918 NestedElement::new("Container").child(
1919 NestedElement::new("Container").child(
1920 NestedElement::new("Container").child(
1921 NestedElement::new("Container").child(
1922 NestedElement::new("Container").child(
1923 NestedElement::new("Container").child(
1924 NestedElement::new("Container").child(
1925 NestedElement::new("Column").child(
1926 NestedElement::new("Text")
1927 .prop("content", "too deep"),
1928 ),
1929 ),
1930 ),
1931 ),
1932 ),
1933 ),
1934 ),
1935 ),
1936 ),
1937 ),
1938 ),
1939 ),
1940 ),
1941 ),
1942 ),
1943 ),
1944 )
1945 .build()
1946 .expect_err("seventeen levels must exceed the depth limit");
1947 assert!(
1948 matches!(err, SpecError::DepthExceeded { .. }),
1949 "expected DepthExceeded, got {err:?}"
1950 );
1951 }
1952
1953 #[test]
1954 fn nested_builder_auto_ids_match_position() {
1955 let spec = Spec::builder()
1956 .element_nested(
1957 "parent",
1958 NestedElement::new("Row")
1959 .child(NestedElement::new("ColA"))
1960 .child(NestedElement::new("ColB"))
1961 .child(NestedElement::new("ColC")),
1962 )
1963 .build()
1964 .expect("spec with 3 siblings is valid");
1965 assert_eq!(spec.elements.len(), 4);
1966 let parent = spec.elements.get("parent").expect("parent");
1967 assert_eq!(
1968 parent.children,
1969 vec![
1970 "parent-0".to_string(),
1971 "parent-1".to_string(),
1972 "parent-2".to_string(),
1973 ]
1974 );
1975 assert_eq!(
1976 spec.elements.get("parent-0").expect("child-0").type_name,
1977 "ColA"
1978 );
1979 assert_eq!(
1980 spec.elements.get("parent-1").expect("child-1").type_name,
1981 "ColB"
1982 );
1983 assert_eq!(
1984 spec.elements.get("parent-2").expect("child-2").type_name,
1985 "ColC"
1986 );
1987 }
1988
1989 #[test]
1990 fn nested_builder_root_set_from_first_call() {
1991 let spec = Spec::builder()
1992 .element_nested("first", NestedElement::new("Screen"))
1993 .element_nested("second", NestedElement::new("Screen"))
1994 .build()
1995 .expect("multi-root-call spec");
1996 assert_eq!(spec.root, "first");
1998 }
1999
2000 #[test]
2001 fn nested_builder_preserves_action_and_visible() {
2002 use crate::action::Action;
2003 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
2004 let action = Action::new("home.index");
2005 let vis = Visibility::Condition(VisibilityCondition {
2006 path: "/enabled".to_string(),
2007 operator: VisibilityOperator::Exists,
2008 value: None,
2009 });
2010 let spec = Spec::builder()
2011 .element_nested(
2012 "btn",
2013 NestedElement::new("Button")
2014 .action(action.clone())
2015 .visible(vis.clone()),
2016 )
2017 .build()
2018 .expect("spec with action+visible");
2019 let el = spec.elements.get("btn").expect("btn present");
2020 assert!(el.action.is_some(), "action must be preserved");
2021 assert!(el.visible.is_some(), "visible must be preserved");
2022 }
2023
2024 #[test]
2025 fn nested_builder_and_flat_builder_produce_equivalent_specs() {
2026 let nested = Spec::builder()
2027 .element_nested(
2028 "root",
2029 NestedElement::new("Card")
2030 .prop("title", "T")
2031 .child(NestedElement::new("Text").prop("content", "hi")),
2032 )
2033 .build()
2034 .expect("nested spec valid");
2035
2036 let flat = Spec::builder()
2037 .element(
2038 "root",
2039 Element::new("Card").prop("title", "T").child("root-0"),
2040 )
2041 .element("root-0", Element::new("Text").prop("content", "hi"))
2042 .build()
2043 .expect("flat spec valid");
2044
2045 let nested_json = serde_json::to_value(&nested).unwrap();
2046 let flat_json = serde_json::to_value(&flat).unwrap();
2047 assert_eq!(nested_json, flat_json);
2048 }
2049
2050 #[test]
2051 fn validate_directives_called_between_no_dangling_and_cycle() {
2052 let src = include_str!("spec.rs");
2055 let validate_section = src
2056 .split("fn validate_structure")
2057 .nth(1)
2058 .expect("validate_structure body present");
2059 let body_end = validate_section
2060 .find("\nfn ")
2061 .unwrap_or(validate_section.len());
2062 let body = &validate_section[..body_end];
2063 let pos_no_dangling = body.find("validate_no_dangling").expect("no_dangling call");
2064 let pos_directives = body.find("validate_directives").expect("directives call");
2065 let pos_cycle = body.find("detect_cycle").expect("cycle call");
2066 assert!(
2067 pos_no_dangling < pos_directives,
2068 "validate_directives must be called AFTER validate_no_dangling"
2069 );
2070 assert!(
2071 pos_directives < pos_cycle,
2072 "validate_directives must be called BEFORE detect_cycle"
2073 );
2074 }
2075
2076 #[test]
2081 fn spec_title_literal_roundtrip() {
2082 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":"Hello"}"#;
2083 let spec: Spec = serde_json::from_str(json).expect("parses");
2084 match spec.title.as_ref().unwrap() {
2085 TitleBinding::Literal(s) => assert_eq!(s, "Hello"),
2086 other => panic!("expected Literal, got {other:?}"),
2087 }
2088 let back = serde_json::to_string(&spec).unwrap();
2089 assert!(back.contains(r#""title":"Hello""#), "got: {back}");
2090 }
2091
2092 #[test]
2093 fn spec_title_binding_roundtrip() {
2094 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"$data":"/page_title"}}"#;
2095 let spec: Spec = serde_json::from_str(json).expect("parses");
2096 match spec.title.as_ref().unwrap() {
2097 TitleBinding::Binding(DataRef { data }) => assert_eq!(data, "/page_title"),
2098 other => panic!("expected Binding, got {other:?}"),
2099 }
2100 let back = serde_json::to_string(&spec).unwrap();
2101 assert!(back.contains(r#""$data":"/page_title""#), "got: {back}");
2102 }
2103
2104 #[test]
2105 fn spec_title_absent() {
2106 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}}}"#;
2107 let spec: Spec = serde_json::from_str(json).expect("parses");
2108 assert!(spec.title.is_none());
2109 }
2110
2111 #[test]
2112 fn spec_title_invalid_shape_rejected() {
2113 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"foo":"bar"}}"#;
2115 let result: Result<Spec, _> = serde_json::from_str(json);
2116 assert!(
2117 result.is_err(),
2118 "expected parse failure for {{foo:bar}} title shape"
2119 );
2120 }
2121}