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 data: Value,
363 root: Option<String>,
364 elements: HashMap<String, Element>,
365}
366
367impl SpecBuilder {
368 fn new() -> Self {
369 Self {
370 title: None,
371 layout: None,
372 data: Value::Null,
373 root: None,
374 elements: HashMap::new(),
375 }
376 }
377
378 pub fn title(mut self, t: impl Into<String>) -> Self {
380 self.title = Some(TitleBinding::Literal(t.into()));
381 self
382 }
383
384 pub fn title_binding(mut self, path: impl Into<String>) -> Self {
386 self.title = Some(TitleBinding::Binding(DataRef { data: path.into() }));
387 self
388 }
389
390 pub fn layout(mut self, l: impl Into<String>) -> Self {
392 self.layout = Some(l.into());
393 self
394 }
395
396 pub fn data(mut self, d: Value) -> Self {
398 self.data = d;
399 self
400 }
401
402 pub fn root(mut self, id: impl Into<String>) -> Self {
406 self.root = Some(id.into());
407 self
408 }
409
410 pub fn element(mut self, id: impl Into<String>, el: ElementBuilder) -> Self {
413 let id: String = id.into();
414 if self.root.is_none() {
415 self.root = Some(id.clone());
416 }
417 self.elements.insert(id, el.build());
418 self
419 }
420
421 pub fn element_nested(mut self, id: impl Into<String>, el: NestedElement) -> Self {
437 let id: String = id.into();
438 if self.root.is_none() {
439 self.root = Some(id.clone());
440 }
441 flatten_nested(&mut self.elements, &id, el);
442 self
443 }
444
445 pub fn build(self) -> Result<Spec, SpecError> {
448 let root = self.root.ok_or_else(|| {
449 SpecError::RootMissing(String::new())
453 })?;
454 let spec = Spec {
455 schema: SCHEMA_VERSION.to_string(),
456 root,
457 elements: self.elements,
458 title: self.title,
459 layout: self.layout,
460 fill_viewport: false,
461 data: self.data,
462 design: None,
463 };
464 validate_structure(&spec)?;
465 Ok(spec)
466 }
467}
468
469#[derive(Debug)]
471pub struct ElementBuilder {
472 type_name: String,
473 props: Map<String, Value>,
474 children: Vec<String>,
475 action: Option<Action>,
476 visible: Option<Visibility>,
477 each: Option<EachDirective>,
478 if_: Option<Visibility>,
479}
480
481impl ElementBuilder {
482 pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
484 self.props.insert(k.into(), v.into());
485 self
486 }
487
488 pub fn child(mut self, id: impl Into<String>) -> Self {
490 self.children.push(id.into());
491 self
492 }
493
494 pub fn action(mut self, a: Action) -> Self {
496 self.action = Some(a);
497 self
498 }
499
500 pub fn visible(mut self, v: Visibility) -> Self {
502 self.visible = Some(v);
503 self
504 }
505
506 pub(crate) fn build(self) -> Element {
507 let props = if self.props.is_empty() {
508 Value::Null
509 } else {
510 Value::Object(self.props)
511 };
512 Element {
513 type_name: self.type_name,
514 props,
515 children: self.children,
516 action: self.action,
517 visible: self.visible,
518 each: self.each,
519 if_: self.if_,
520 }
521 }
522}
523
524#[derive(Debug)]
550pub struct NestedElement {
551 type_name: String,
552 props: Map<String, Value>,
553 children: Vec<NestedElement>,
554 action: Option<Action>,
555 visible: Option<Visibility>,
556}
557
558impl NestedElement {
559 pub fn new(type_name: impl Into<String>) -> Self {
561 Self {
562 type_name: type_name.into(),
563 props: Map::new(),
564 children: Vec::new(),
565 action: None,
566 visible: None,
567 }
568 }
569
570 pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
572 self.props.insert(k.into(), v.into());
573 self
574 }
575
576 pub fn child(mut self, c: NestedElement) -> Self {
579 self.children.push(c);
580 self
581 }
582
583 pub fn action(mut self, a: Action) -> Self {
585 self.action = Some(a);
586 self
587 }
588
589 pub fn visible(mut self, v: Visibility) -> Self {
591 self.visible = Some(v);
592 self
593 }
594
595 #[cfg(test)]
601 pub(crate) fn build_for_test(self) -> Element {
602 let props = if self.props.is_empty() {
603 Value::Null
604 } else {
605 Value::Object(self.props)
606 };
607 Element {
608 type_name: self.type_name,
609 props,
610 children: Vec::new(),
611 action: self.action,
612 visible: self.visible,
613 each: None,
614 if_: None,
615 }
616 }
617}
618
619fn flatten_nested(elements: &mut HashMap<String, Element>, id: &str, el: NestedElement) {
623 let mut child_ids: Vec<String> = Vec::with_capacity(el.children.len());
624 for (idx, child) in el.children.into_iter().enumerate() {
625 let child_id = format!("{id}-{idx}");
626 flatten_nested(elements, &child_id, child);
627 child_ids.push(child_id);
628 }
629 let props = if el.props.is_empty() {
630 Value::Null
631 } else {
632 Value::Object(el.props)
633 };
634 let element = Element {
635 type_name: el.type_name,
636 props,
637 children: child_ids,
638 action: el.action,
639 visible: el.visible,
640 each: None,
641 if_: None,
642 };
643 elements.insert(id.to_string(), element);
644}
645
646const DUP_ID_SENTINEL: &str = "__FERRO_DUPLICATE_ID__";
655
656#[derive(Deserialize)]
659struct SpecWire {
660 #[serde(rename = "$schema", default = "default_schema")]
661 schema: String,
662 root: String,
663 elements: ElementsMap,
664 #[serde(default)]
665 title: Option<TitleBinding>,
666 #[serde(default)]
667 layout: Option<String>,
668 #[serde(default)]
669 fill_viewport: bool,
670 #[serde(default)]
671 data: Value,
672 #[serde(default)]
673 design: Option<DesignMeta>,
674}
675
676fn default_schema() -> String {
677 SCHEMA_VERSION.to_string()
678}
679
680struct ElementsMap(HashMap<String, Element>);
684
685impl<'de> DeserializeTrait<'de> for ElementsMap {
686 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
687 struct V;
688 impl<'de> Visitor<'de> for V {
689 type Value = ElementsMap;
690 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
691 f.write_str("a JSON object with unique element IDs")
692 }
693 fn visit_map<M: MapAccess<'de>>(self, mut m: M) -> Result<ElementsMap, M::Error> {
694 let mut map: HashMap<String, Element> = HashMap::new();
695 while let Some(k) = m.next_key::<String>()? {
696 if map.contains_key(&k) {
697 return Err(serde::de::Error::custom(format!("{DUP_ID_SENTINEL}{k}")));
698 }
699 let v: Element = m.next_value()?;
700 map.insert(k, v);
701 }
702 Ok(ElementsMap(map))
703 }
704 }
705 d.deserialize_map(V)
706 }
707}
708
709fn validate_structure(spec: &Spec) -> Result<(), SpecError> {
715 validate_ids(&spec.elements)?;
716 if !spec.elements.contains_key(&spec.root) {
717 return Err(SpecError::RootMissing(spec.root.clone()));
718 }
719 validate_no_dangling(&spec.elements)?;
720 validate_directives(spec)?;
721 validate_footer_ids(spec)?;
722 detect_cycle(&spec.elements, &spec.root)?;
723 check_depth(&spec.elements, &spec.root)?;
724 Ok(())
725}
726
727fn is_valid_id(s: &str) -> bool {
729 if s.is_empty() || s.len() > 128 {
730 return false;
731 }
732 let bytes = s.as_bytes();
733 let first = bytes[0];
734 let first_ok = first.is_ascii_alphabetic() || first == b'_';
735 if !first_ok {
736 return false;
737 }
738 bytes[1..]
739 .iter()
740 .all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
741}
742
743fn validate_ids(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
744 for (id, el) in elements {
745 if !is_valid_id(id) {
746 return Err(SpecError::InvalidId(id.clone()));
747 }
748 for child in &el.children {
749 if !is_valid_id(child) {
750 return Err(SpecError::InvalidId(child.clone()));
751 }
752 }
753 }
754 Ok(())
755}
756
757fn validate_no_dangling(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
758 for (id, el) in elements {
759 for child in &el.children {
760 if !elements.contains_key(child) {
761 return Err(SpecError::DanglingChild {
762 element: id.clone(),
763 child: child.clone(),
764 });
765 }
766 }
767 }
768 Ok(())
769}
770
771fn validate_footer_ids(spec: &Spec) -> Result<(), SpecError> {
776 for (element_id, el) in &spec.elements {
777 let footer_ids: Vec<String> = el
779 .props
780 .get("footer")
781 .and_then(|v| v.as_array())
782 .map(|arr| {
783 arr.iter()
784 .filter_map(|v| v.as_str().map(|s| s.to_string()))
785 .collect()
786 })
787 .unwrap_or_default();
788
789 for footer_id in &footer_ids {
790 if !spec.elements.contains_key(footer_id) {
791 return Err(SpecError::FooterMissing {
792 element_id: element_id.clone(),
793 footer_id: footer_id.clone(),
794 });
795 }
796 if el.children.iter().any(|c| c == footer_id) {
798 eprintln!(
799 "ferro-json-ui: element '{element_id}' has '{footer_id}' in both \
800 props.footer and children — the element renders once (in footer); \
801 remove the duplicate from children"
802 );
803 }
804 }
805 }
806 Ok(())
807}
808
809const RESERVED_EACH_AS: &[&str] = &["data", "root", "_root", "_each", "this", "self"];
811
812fn validate_directives(spec: &Spec) -> Result<(), SpecError> {
817 let templated: HashMap<&str, &EachDirective> = spec
819 .elements
820 .iter()
821 .filter_map(|(id, el)| el.each.as_ref().map(|e| (id.as_str(), e)))
822 .collect();
823
824 for (id, el) in &spec.elements {
825 if let Some(each) = &el.each {
827 if RESERVED_EACH_AS.contains(&each.as_.as_str()) {
829 return Err(SpecError::EachAsReservedName {
830 element_id: id.clone(),
831 name: each.as_.clone(),
832 });
833 }
834 if !spec.data.is_null() {
836 if let Some(value) = crate::data::resolve_path(&spec.data, &each.path) {
837 if !value.is_array() {
838 return Err(SpecError::EachPathNotArray {
839 element_id: id.clone(),
840 path: each.path.clone(),
841 });
842 }
843 }
844 }
845 for child in &el.children {
847 if let Some(child_each) = templated.get(child.as_str()) {
848 if child_each.path != each.path || child_each.as_ != each.as_ {
849 return Err(SpecError::MismatchedEach {
850 parent: id.clone(),
851 parent_path: each.path.clone(),
852 child: child.clone(),
853 child_path: child_each.path.clone(),
854 });
855 }
856 }
857 }
858 let direct: HashSet<&str> = el.children.iter().map(|s| s.as_str()).collect();
863 let mut visited: HashSet<&str> = HashSet::new();
864 let mut stack: Vec<&str> = Vec::new();
865 for child in &el.children {
867 if let Some(child_el) = spec.elements.get(child) {
868 for gc in &child_el.children {
869 stack.push(gc.as_str());
870 }
871 }
872 }
873 while let Some(node) = stack.pop() {
874 if !visited.insert(node) {
875 continue;
876 }
877 if templated.contains_key(node) && !direct.contains(node) {
878 return Err(SpecError::NestedEach {
879 outer: id.clone(),
880 inner: node.to_string(),
881 });
882 }
883 if let Some(node_el) = spec.elements.get(node) {
884 for c in &node_el.children {
885 stack.push(c.as_str());
886 }
887 }
888 }
889 }
890 if let Some(vis) = &el.if_ {
894 if !spec.data.is_null() {
895 check_visibility_paths(id, vis, &spec.data)?;
896 }
897 }
898 }
899 Ok(())
900}
901
902fn check_visibility_paths(
905 element_id: &str,
906 vis: &Visibility,
907 data: &Value,
908) -> Result<(), SpecError> {
909 match vis {
910 Visibility::And { and } => {
911 for v in and {
912 check_visibility_paths(element_id, v, data)?;
913 }
914 }
915 Visibility::Or { or } => {
916 for v in or {
917 check_visibility_paths(element_id, v, data)?;
918 }
919 }
920 Visibility::Not { not } => check_visibility_paths(element_id, not, data)?,
921 Visibility::Condition(c) => {
922 if crate::data::resolve_path(data, &c.path).is_none() {
923 return Err(SpecError::IfPathMissing {
924 element_id: element_id.to_string(),
925 path: c.path.clone(),
926 });
927 }
928 }
929 }
930 Ok(())
931}
932
933fn detect_cycle(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
934 let mut visited: HashSet<String> = HashSet::new();
935 let mut on_stack: Vec<String> = Vec::new();
936 dfs(root, elements, &mut visited, &mut on_stack)
937}
938
939fn dfs(
940 node: &str,
941 elements: &HashMap<String, Element>,
942 visited: &mut HashSet<String>,
943 on_stack: &mut Vec<String>,
944) -> Result<(), SpecError> {
945 if let Some(start) = on_stack.iter().position(|n| n == node) {
946 let mut path: Vec<String> = on_stack[start..].to_vec();
947 path.push(node.to_string());
948 return Err(SpecError::Cycle { path });
949 }
950 if visited.contains(node) {
951 return Ok(());
952 }
953 on_stack.push(node.to_string());
954 if let Some(el) = elements.get(node) {
955 for child in &el.children {
956 dfs(child, elements, visited, on_stack)?;
957 }
958 }
959 on_stack.pop();
960 visited.insert(node.to_string());
961 Ok(())
962}
963
964fn check_depth(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
965 let mut path: Vec<String> = Vec::new();
966 walk(root, elements, 1, &mut path)
967}
968
969fn walk(
970 node: &str,
971 elements: &HashMap<String, Element>,
972 depth: usize,
973 path: &mut Vec<String>,
974) -> Result<(), SpecError> {
975 path.push(node.to_string());
976 if depth > MAX_NESTING_DEPTH {
977 return Err(SpecError::DepthExceeded {
978 max: MAX_NESTING_DEPTH,
979 found: depth,
980 path: path.clone(),
981 });
982 }
983 if let Some(el) = elements.get(node) {
984 for child in &el.children {
985 walk(child, elements, depth + 1, path)?;
986 }
987 }
988 path.pop();
989 Ok(())
990}
991
992#[cfg(test)]
1000#[rustfmt::skip]
1001mod tests {
1002 use super::*;
1003 use serde_json::json;
1004
1005 #[test]
1006 fn default_schema_is_v2() {
1007 assert_eq!(default_schema(), SCHEMA_VERSION);
1008 assert_eq!(SCHEMA_VERSION, "ferro-json-ui/v2");
1009 }
1010
1011 #[test]
1012 fn is_valid_id_edge_cases() {
1013 let cases: &[(&str, bool)] = &[
1015 ("", false),
1016 ("1abc", false),
1017 ("a", true),
1018 ("_", true),
1019 ("a_b-c", true),
1020 ("user form", false),
1021 ("ABC123", true),
1022 ("a.b", false),
1023 ("/path", false),
1024 ];
1025 for (s, ok) in cases {
1026 assert_eq!(is_valid_id(s), *ok, "mismatch on {s:?}");
1027 }
1028 let ok128: String = "a".repeat(128);
1030 let bad129: String = "a".repeat(129);
1031 assert!(is_valid_id(&ok128));
1032 assert!(!is_valid_id(&bad129));
1033 }
1034
1035 #[test]
1036 fn builder_minimal_round_trips() {
1037 let spec = Spec::builder()
1038 .element("a", Element::new("Text").prop("content", "Hi"))
1039 .build()
1040 .unwrap();
1041 assert_eq!(spec.schema, SCHEMA_VERSION);
1042 assert_eq!(spec.root, "a");
1043 assert_eq!(spec.elements.len(), 1);
1044 let json = serde_json::to_string(&spec).unwrap();
1045 let back = Spec::from_json(&json).unwrap();
1046 assert_eq!(spec, back);
1047 }
1048
1049 #[test]
1050 fn builder_parity_with_json() {
1051 let from_json = Spec::from_json(
1052 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text","props":{"content":"Hi"}}}}"#,
1053 )
1054 .unwrap();
1055 let from_builder = Spec::builder()
1056 .element("a", Element::new("Text").prop("content", "Hi"))
1057 .build()
1058 .unwrap();
1059 assert_eq!(from_json, from_builder);
1060 }
1061
1062 #[test]
1063 fn from_json_rejects_missing_root() {
1064 let err = Spec::from_json(
1065 r#"{"$schema":"ferro-json-ui/v2","root":"nope","elements":{"a":{"type":"Text"}}}"#,
1066 )
1067 .unwrap_err();
1068 match err {
1069 SpecError::RootMissing(id) => assert_eq!(id, "nope"),
1070 other => panic!("expected RootMissing, got {other:?}"),
1071 }
1072 }
1073
1074 #[test]
1075 fn from_json_rejects_dangling_child() {
1076 let err = Spec::from_json(
1077 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Card","children":["ghost"]}}}"#,
1078 )
1079 .unwrap_err();
1080 match err {
1081 SpecError::DanglingChild { element, child } => {
1082 assert_eq!(element, "a");
1083 assert_eq!(child, "ghost");
1084 }
1085 other => panic!("expected DanglingChild, got {other:?}"),
1086 }
1087 }
1088
1089 #[test]
1096 fn validate_allows_children_ref_to_if_gated_element() {
1097 let json = r#"{
1100 "$schema": "ferro-json-ui/v2",
1101 "root": "parent",
1102 "elements": {
1103 "parent": {
1104 "type": "Card",
1105 "props": {"title": "parent"},
1106 "children": ["child"]
1107 },
1108 "child": {
1109 "type": "Text",
1110 "props": {"content": "conditional"},
1111 "$if": {"path": "/data/show", "operator": "eq", "value": true}
1112 }
1113 }
1114 }"#;
1115 let spec = Spec::from_json(json);
1117 assert!(
1118 spec.is_ok(),
1119 "$if-gated child must not be rejected as dangling: {:?}",
1120 spec.err()
1121 );
1122 }
1123
1124 #[test]
1125 fn from_json_rejects_self_cycle() {
1126 let err = Spec::from_json(
1127 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{"A":{"type":"Card","children":["A"]}}}"#,
1128 )
1129 .unwrap_err();
1130 match err {
1131 SpecError::Cycle { path } => {
1132 assert_eq!(path, vec!["A".to_string(), "A".to_string()]);
1133 }
1134 other => panic!("expected Cycle (self), got {other:?}"),
1135 }
1136 }
1137
1138 #[test]
1139 fn from_json_rejects_two_cycle() {
1140 let err = Spec::from_json(
1141 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{"root":{"type":"Card","children":["A"]},"A":{"type":"Card","children":["root"]}}}"#,
1142 )
1143 .unwrap_err();
1144 match err {
1145 SpecError::Cycle { path } => {
1146 assert!(path.len() >= 3);
1147 assert_eq!(path.first(), path.last());
1148 }
1149 other => panic!("expected Cycle, got {other:?}"),
1150 }
1151 }
1152
1153 #[test]
1154 fn cycle_detector_only_on_revisit() {
1155 let err = Spec::from_json(
1160 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
1161 "A":{"type":"Card","children":["B"]},
1162 "B":{"type":"Card","children":["A"]}
1163 }}"#,
1164 )
1165 .unwrap_err();
1166 match err {
1167 SpecError::Cycle { path } => {
1168 assert!(
1169 path.iter().any(|p| p == "A"),
1170 "cycle path must contain A; got {path:?}"
1171 );
1172 assert!(
1173 path.iter().any(|p| p == "B"),
1174 "cycle path must contain B; got {path:?}"
1175 );
1176 }
1177 other => panic!("expected Cycle, got {other:?}"),
1178 }
1179 }
1180
1181 #[test]
1182 fn from_json_rejects_depth_17() {
1183 let err = Spec::from_json(
1185 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
1186 "root":{"type":"Container","children":["e1"]},
1187 "e1":{"type":"Container","children":["e2"]},
1188 "e2":{"type":"Container","children":["e3"]},
1189 "e3":{"type":"Container","children":["e4"]},
1190 "e4":{"type":"Container","children":["e5"]},
1191 "e5":{"type":"Container","children":["e6"]},
1192 "e6":{"type":"Container","children":["e7"]},
1193 "e7":{"type":"Container","children":["e8"]},
1194 "e8":{"type":"Container","children":["e9"]},
1195 "e9":{"type":"Container","children":["e10"]},
1196 "e10":{"type":"Container","children":["e11"]},
1197 "e11":{"type":"Container","children":["e12"]},
1198 "e12":{"type":"Container","children":["e13"]},
1199 "e13":{"type":"Container","children":["e14"]},
1200 "e14":{"type":"Container","children":["e15"]},
1201 "e15":{"type":"Container","children":["e16"]},
1202 "e16":{"type":"Text"}
1203 }}"#,
1204 )
1205 .unwrap_err();
1206 match err {
1207 SpecError::DepthExceeded { max, found, path } => {
1208 assert_eq!(max, 16, "max must equal MAX_NESTING_DEPTH=16");
1209 assert_eq!(found, 17, "found must be 17 (one past the limit)");
1210 assert!(!path.is_empty());
1211 }
1212 other => panic!("expected DepthExceeded, got {other:?}"),
1213 }
1214 }
1215
1216 #[test]
1217 fn from_json_accepts_depth_8() {
1218 let spec = Spec::from_json(
1222 r#"{"$schema":"ferro-json-ui/v2","root":"dashboard","elements":{
1223 "dashboard":{"type":"Screen","children":["root"]},
1224 "root":{"type":"Container","children":["detail_page"]},
1225 "detail_page":{"type":"DetailPage","children":["tab"]},
1226 "tab":{"type":"Card","children":["card"]},
1227 "card":{"type":"Card","children":["form"]},
1228 "form":{"type":"Form","children":["row"]},
1229 "row":{"type":"Grid","children":["switch_day"]},
1230 "switch_day":{"type":"Switch"}
1231 }}"#,
1232 )
1233 .expect("depth-8 staff-detail spec must parse without DepthExceeded");
1234 assert_eq!(spec.elements.len(), 8);
1235 }
1236
1237 #[test]
1238 fn from_json_rejects_invalid_id_space() {
1239 let err = Spec::from_json(
1240 r#"{"$schema":"ferro-json-ui/v2","root":"user form","elements":{"user form":{"type":"Text"}}}"#,
1241 )
1242 .unwrap_err();
1243 match err {
1244 SpecError::InvalidId(id) => assert_eq!(id, "user form"),
1245 other => panic!("expected InvalidId, got {other:?}"),
1246 }
1247 }
1248
1249 #[test]
1250 fn from_json_rejects_duplicate_id() {
1251 let err = Spec::from_json(
1253 r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text"},"a":{"type":"Card"}}}"#,
1254 )
1255 .unwrap_err();
1256 match err {
1257 SpecError::DuplicateId(id) => assert_eq!(id, "a"),
1258 other => panic!("expected DuplicateId, got {other:?}"),
1259 }
1260 }
1261
1262 #[test]
1263 fn from_json_accepts_three_level_nesting() {
1264 let spec = Spec::from_json(
1265 r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
1266 "root":{"type":"Card","children":["section"]},
1267 "section":{"type":"FormSection","children":["leaf"]},
1268 "leaf":{"type":"Text"}
1269 }}"#,
1270 )
1271 .unwrap();
1272 assert_eq!(spec.elements.len(), 3);
1273 }
1274
1275 #[test]
1276 fn from_json_accepts_diamond() {
1277 let spec = Spec::from_json(
1280 r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
1281 "A":{"type":"Card","children":["B","C"]},
1282 "B":{"type":"Card","children":["D"]},
1283 "C":{"type":"Card","children":["D"]},
1284 "D":{"type":"Text"}
1285 }}"#,
1286 )
1287 .unwrap();
1288 assert_eq!(spec.elements.len(), 4);
1289 }
1290
1291 #[test]
1292 fn from_json_wraps_syntax_errors() {
1293 let err = Spec::from_json("{ this is not json ").unwrap_err();
1295 assert!(matches!(err, SpecError::Json(_)), "got {err:?}");
1296 }
1297
1298 #[test]
1299 fn builder_rejects_forward_ref_without_target() {
1300 let err = Spec::builder()
1302 .element("root", Element::new("Card").child("ghost"))
1303 .build()
1304 .unwrap_err();
1305 match err {
1306 SpecError::DanglingChild { element, child } => {
1307 assert_eq!(element, "root");
1308 assert_eq!(child, "ghost");
1309 }
1310 other => panic!("expected DanglingChild, got {other:?}"),
1311 }
1312 }
1313
1314 #[test]
1315 fn builder_data_payload_survives_round_trip() {
1316 let spec = Spec::builder()
1317 .element("a", Element::new("Text"))
1318 .data(json!({"user":{"name":"Alice"}}))
1319 .build()
1320 .unwrap();
1321 let json = serde_json::to_string(&spec).unwrap();
1322 let back = Spec::from_json(&json).unwrap();
1323 assert_eq!(back.data, json!({"user":{"name":"Alice"}}));
1324 }
1325
1326 #[test]
1327 fn element_omits_optional_fields_when_absent() {
1328 let spec = Spec::builder()
1329 .element("bare", Element::new("Text"))
1330 .build()
1331 .unwrap();
1332 let json = serde_json::to_string(&spec).unwrap();
1333 assert!(!json.contains("children"));
1335 assert!(!json.contains("props"));
1336 assert!(!json.contains("action"));
1337 assert!(!json.contains("visible"));
1338 }
1339
1340 #[test]
1341 fn merge_data_handler_wins() {
1342 let spec = Spec::builder()
1343 .element("a", Element::new("Text"))
1344 .data(json!({"a": 1, "b": 2}))
1345 .build()
1346 .unwrap();
1347 let merged = spec.merge_data(json!({"b": 99, "c": 3}));
1348 assert_eq!(merged.data, json!({"a": 1, "b": 99, "c": 3}));
1349 }
1350
1351 #[test]
1352 fn merge_data_ignores_non_object() {
1353 let spec = Spec::builder()
1355 .element("a", Element::new("Text"))
1356 .data(json!({"a": 1}))
1357 .build()
1358 .unwrap();
1359 let merged = spec.merge_data(Value::Null);
1360 assert_eq!(merged.data, json!({"a": 1}));
1361 }
1366
1367 #[test]
1368 fn merge_data_initializes_null_data() {
1369 let spec = Spec::builder()
1370 .element("a", Element::new("Text"))
1371 .build() .unwrap();
1373 assert_eq!(spec.data, Value::Null);
1374 let merged = spec.merge_data(json!({"k": "v"}));
1375 assert_eq!(merged.data, json!({"k": "v"}));
1376 }
1377
1378 #[test]
1379 fn merge_data_empty_handler_no_op() {
1380 let spec = Spec::builder()
1381 .element("a", Element::new("Text"))
1382 .data(json!({"a": 1}))
1383 .build()
1384 .unwrap();
1385 let merged = spec.merge_data(json!({}));
1386 assert_eq!(merged.data, json!({"a": 1}));
1387 }
1388
1389 #[test]
1390 fn from_json_rejects_missing_footer_id() {
1391 let err = Spec::from_json(
1392 r#"{
1393 "$schema": "ferro-json-ui/v2",
1394 "root": "card",
1395 "elements": {
1396 "card": {
1397 "type": "Card",
1398 "props": {"title": "T", "footer": ["ghost"]}
1399 }
1400 }
1401 }"#,
1402 )
1403 .unwrap_err();
1404 match err {
1405 SpecError::FooterMissing {
1406 element_id,
1407 footer_id,
1408 } => {
1409 assert_eq!(element_id, "card");
1410 assert_eq!(footer_id, "ghost");
1411 }
1412 other => panic!("expected FooterMissing, got {other:?}"),
1413 }
1414 }
1415
1416 #[test]
1417 fn from_json_rejects_missing_modal_footer_id() {
1418 let err = Spec::from_json(
1421 r#"{
1422 "$schema": "ferro-json-ui/v2",
1423 "root": "modal",
1424 "elements": {
1425 "modal": {
1426 "type": "Modal",
1427 "props": {"id": "m", "title": "T", "footer": ["ghost"]}
1428 }
1429 }
1430 }"#,
1431 )
1432 .unwrap_err();
1433 match err {
1434 SpecError::FooterMissing {
1435 element_id,
1436 footer_id,
1437 } => {
1438 assert_eq!(element_id, "modal");
1439 assert_eq!(footer_id, "ghost");
1440 }
1441 other => panic!("expected FooterMissing on Modal, got {other:?}"),
1442 }
1443 }
1444
1445 #[test]
1446 fn spec_warns_duplicate_footer_child() {
1447 let spec = Spec::from_json(
1450 r#"{
1451 "$schema": "ferro-json-ui/v2",
1452 "root": "card",
1453 "elements": {
1454 "card": {
1455 "type": "Card",
1456 "props": {"title": "T", "footer": ["btn"]},
1457 "children": ["btn"]
1458 },
1459 "btn": {
1460 "type": "Button",
1461 "props": {"label": "Save"}
1462 }
1463 }
1464 }"#,
1465 )
1466 .expect("D-08 warning is non-fatal; parse must succeed");
1467 assert_eq!(spec.root, "card");
1468 }
1469
1470 #[test]
1471 fn each_directive_round_trips() {
1472 let json = serde_json::json!({"path": "/orders", "as": "order"});
1473 let parsed: EachDirective = serde_json::from_value(json.clone()).expect("decode");
1474 assert_eq!(parsed.path, "/orders");
1475 assert_eq!(parsed.as_, "order");
1476 let reserialized = serde_json::to_value(&parsed).expect("encode");
1477 assert_eq!(reserialized, json);
1478 assert!(reserialized.get("as").is_some());
1480 assert!(reserialized.get("as_").is_none());
1481 }
1482
1483 #[test]
1484 fn element_with_each_round_trips() {
1485 let json = serde_json::json!({
1486 "type": "Card",
1487 "$each": {"path": "/orders", "as": "order"},
1488 "props": {"title": "x"}
1489 });
1490 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1491 assert!(parsed.each.is_some());
1492 let each = parsed.each.as_ref().unwrap();
1493 assert_eq!(each.path, "/orders");
1494 assert_eq!(each.as_, "order");
1495 let reserialized = serde_json::to_value(&parsed).expect("encode");
1496 assert!(reserialized.get("$each").is_some());
1497 }
1498
1499 #[test]
1500 fn element_without_each_omits_field() {
1501 let spec = Spec::builder()
1503 .element("card", Element::new("Card").prop("title", "hello"))
1504 .build()
1505 .expect("spec is valid");
1506 let card = spec.elements.get("card").expect("card present");
1507 let json = serde_json::to_value(card).expect("encode");
1508 assert!(
1509 json.get("$each").is_none(),
1510 "expected $each to be omitted when None; got: {json}"
1511 );
1512 }
1513
1514 #[test]
1515 fn if_directive_flat_condition_round_trips() {
1516 use crate::visibility::Visibility;
1517 let json = serde_json::json!({"path": "/can_advance", "operator": "eq", "value": true});
1518 let parsed: Visibility = serde_json::from_value(json.clone()).expect("decode");
1519 match &parsed {
1520 Visibility::Condition(c) => {
1521 assert_eq!(c.path, "/can_advance");
1522 assert_eq!(c.value, Some(serde_json::json!(true)));
1523 }
1524 _ => panic!("expected flat Condition variant, got: {parsed:?}"),
1525 }
1526 let reserialized = serde_json::to_value(&parsed).expect("encode");
1527 assert!(reserialized.get("path").is_some());
1528 assert!(reserialized.get("operator").is_some());
1529 }
1530
1531 #[test]
1532 fn element_with_if_flat_round_trips() {
1533 let json = serde_json::json!({
1534 "type": "Button",
1535 "$if": {"path": "/can_advance", "operator": "eq", "value": true},
1536 "props": {"label": "x"}
1537 });
1538 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1539 assert!(parsed.if_.is_some());
1540 let reserialized = serde_json::to_value(&parsed).expect("encode");
1541 assert!(reserialized.get("$if").is_some());
1542 }
1543
1544 #[test]
1545 fn element_with_if_compound_round_trips() {
1546 use crate::visibility::Visibility;
1547 let json = serde_json::json!({
1548 "type": "Button",
1549 "$if": {"and": [
1550 {"path": "/a", "operator": "exists"},
1551 {"path": "/b", "operator": "eq", "value": true}
1552 ]},
1553 "props": {"label": "x"}
1554 });
1555 let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1556 match parsed.if_.as_ref() {
1557 Some(Visibility::And { and }) => assert_eq!(and.len(), 2),
1558 other => panic!("expected And variant, got: {other:?}"),
1559 }
1560 let reserialized = serde_json::to_value(&parsed).expect("encode");
1561 assert!(reserialized.get("$if").and_then(|v| v.get("and")).is_some());
1562 }
1563
1564 #[test]
1565 fn element_without_if_omits_field() {
1566 let spec = Spec::builder()
1567 .element("btn", Element::new("Button").prop("label", "ok"))
1568 .build()
1569 .expect("spec is valid");
1570 let btn = spec.elements.get("btn").expect("btn present");
1571 let json = serde_json::to_value(btn).expect("encode");
1572 assert!(
1573 json.get("$if").is_none(),
1574 "expected $if to be omitted when None; got: {json}"
1575 );
1576 }
1577
1578 #[test]
1583 fn validate_each_path_not_array_fires() {
1584 let json = r#"{
1585 "$schema": "ferro-json-ui/v2",
1586 "root": "list",
1587 "elements": {
1588 "list": {
1589 "type": "Card",
1590 "$each": {"path": "/orders", "as": "order"},
1591 "props": {}
1592 }
1593 },
1594 "data": {"orders": "not-an-array"}
1595 }"#;
1596 let err = Spec::from_json(json).expect_err("validator must reject non-array $each.path");
1597 match err {
1598 SpecError::EachPathNotArray { element_id, path } => {
1599 assert_eq!(element_id, "list");
1600 assert_eq!(path, "/orders");
1601 }
1602 other => panic!("expected EachPathNotArray, got: {other:?}"),
1603 }
1604 }
1605
1606 #[test]
1607 fn validate_each_path_not_array_skipped_when_data_null() {
1608 let json = r#"{
1610 "$schema": "ferro-json-ui/v2",
1611 "root": "list",
1612 "elements": {
1613 "list": {
1614 "type": "Card",
1615 "$each": {"path": "/orders", "as": "order"},
1616 "props": {}
1617 }
1618 }
1619 }"#;
1620 Spec::from_json(json).expect("no error when data is null");
1622 }
1623
1624 #[test]
1625 fn validate_each_as_reserved_data_rejected() {
1626 let json = r#"{
1627 "$schema": "ferro-json-ui/v2",
1628 "root": "list",
1629 "elements": {
1630 "list": {
1631 "type": "Card",
1632 "$each": {"path": "/items", "as": "data"},
1633 "props": {}
1634 }
1635 }
1636 }"#;
1637 let err = Spec::from_json(json).expect_err("'data' is a reserved name");
1638 match err {
1639 SpecError::EachAsReservedName { element_id, name } => {
1640 assert_eq!(element_id, "list");
1641 assert_eq!(name, "data");
1642 }
1643 other => panic!("expected EachAsReservedName, got: {other:?}"),
1644 }
1645 }
1646
1647 #[test]
1648 fn validate_each_as_reserved_root_rejected() {
1649 let json = r#"{
1650 "$schema": "ferro-json-ui/v2",
1651 "root": "list",
1652 "elements": {
1653 "list": {
1654 "type": "Card",
1655 "$each": {"path": "/items", "as": "root"},
1656 "props": {}
1657 }
1658 }
1659 }"#;
1660 let err = Spec::from_json(json).expect_err("'root' is a reserved name");
1661 match err {
1662 SpecError::EachAsReservedName { element_id, name } => {
1663 assert_eq!(element_id, "list");
1664 assert_eq!(name, "root");
1665 }
1666 other => panic!("expected EachAsReservedName, got: {other:?}"),
1667 }
1668 }
1669
1670 #[test]
1671 fn validate_each_as_non_reserved_accepted() {
1672 let json_order = r#"{
1674 "$schema": "ferro-json-ui/v2",
1675 "root": "list",
1676 "elements": {
1677 "list": {
1678 "type": "Card",
1679 "$each": {"path": "/items", "as": "order"},
1680 "props": {}
1681 }
1682 },
1683 "data": {"items": []}
1684 }"#;
1685 Spec::from_json(json_order).expect("'order' is not reserved");
1686
1687 let json_row = r#"{
1688 "$schema": "ferro-json-ui/v2",
1689 "root": "list",
1690 "elements": {
1691 "list": {
1692 "type": "Card",
1693 "$each": {"path": "/items", "as": "row"},
1694 "props": {}
1695 }
1696 },
1697 "data": {"items": []}
1698 }"#;
1699 Spec::from_json(json_row).expect("'row' is not reserved");
1700 }
1701
1702 #[test]
1703 fn validate_if_path_missing_fires() {
1704 let json = r#"{
1705 "$schema": "ferro-json-ui/v2",
1706 "root": "btn",
1707 "elements": {
1708 "btn": {
1709 "type": "Button",
1710 "$if": {"path": "/missing_key", "operator": "eq", "value": true},
1711 "props": {"label": "Go"}
1712 }
1713 },
1714 "data": {"other": true}
1715 }"#;
1716 let err = Spec::from_json(json).expect_err("missing $if.path must error");
1717 match err {
1718 SpecError::IfPathMissing { element_id, path } => {
1719 assert_eq!(element_id, "btn");
1720 assert_eq!(path, "/missing_key");
1721 }
1722 other => panic!("expected IfPathMissing, got: {other:?}"),
1723 }
1724 }
1725
1726 #[test]
1727 fn validate_if_path_missing_skipped_when_data_null() {
1728 let json = r#"{
1730 "$schema": "ferro-json-ui/v2",
1731 "root": "btn",
1732 "elements": {
1733 "btn": {
1734 "type": "Button",
1735 "$if": {"path": "/missing_key", "operator": "eq", "value": true},
1736 "props": {"label": "Go"}
1737 }
1738 }
1739 }"#;
1740 Spec::from_json(json).expect("no error when data is null");
1741 }
1742
1743 #[test]
1744 fn validate_nested_each_rejected() {
1745 let json = r#"{
1748 "$schema": "ferro-json-ui/v2",
1749 "root": "A",
1750 "elements": {
1751 "A": {
1752 "type": "Card",
1753 "$each": {"path": "/items", "as": "item"},
1754 "children": ["mid"]
1755 },
1756 "mid": {
1757 "type": "Section",
1758 "children": ["B"]
1759 },
1760 "B": {
1761 "type": "Card",
1762 "$each": {"path": "/other_items", "as": "other"},
1763 "props": {}
1764 }
1765 }
1766 }"#;
1767 let err = Spec::from_json(json).expect_err("nested $each must be rejected");
1768 match err {
1769 SpecError::NestedEach { outer, inner } => {
1770 assert_eq!(outer, "A");
1771 assert_eq!(inner, "B");
1772 }
1773 other => panic!("expected NestedEach, got: {other:?}"),
1774 }
1775 }
1776
1777 #[test]
1778 fn validate_mismatched_each_child_rejected() {
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": "/different_items", "as": "item"}
1792 }
1793 }
1794 }"#;
1795 let err = Spec::from_json(json).expect_err("mismatched $each child must be rejected");
1796 match err {
1797 SpecError::MismatchedEach {
1798 parent,
1799 parent_path,
1800 child,
1801 child_path,
1802 } => {
1803 assert_eq!(parent, "A");
1804 assert_eq!(parent_path, "/items");
1805 assert_eq!(child, "B");
1806 assert_eq!(child_path, "/different_items");
1807 }
1808 other => panic!("expected MismatchedEach, got: {other:?}"),
1809 }
1810 }
1811
1812 #[test]
1813 fn validate_correlated_each_child_accepted() {
1814 let json = r#"{
1816 "$schema": "ferro-json-ui/v2",
1817 "root": "A",
1818 "elements": {
1819 "A": {
1820 "type": "Card",
1821 "$each": {"path": "/items", "as": "item"},
1822 "children": ["B"]
1823 },
1824 "B": {
1825 "type": "Text",
1826 "$each": {"path": "/items", "as": "item"}
1827 }
1828 },
1829 "data": {"items": []}
1830 }"#;
1831 Spec::from_json(json).expect("correlated $each children with same (path, as) are valid");
1832 }
1833
1834 #[test]
1839 fn nested_element_builder_basics() {
1840 let el = NestedElement::new("Card")
1841 .prop("title", "x")
1842 .build_for_test();
1843 assert_eq!(el.type_name, "Card");
1844 assert_eq!(el.props.get("title").and_then(|v| v.as_str()), Some("x"));
1845 assert!(el.children.is_empty());
1846 assert!(el.action.is_none());
1847 assert!(el.visible.is_none());
1848 }
1849
1850 #[test]
1851 fn nested_builder_flattens_one_level() {
1852 let spec = Spec::builder()
1853 .element_nested(
1854 "root",
1855 NestedElement::new("Card").child(NestedElement::new("Text").prop("content", "hi")),
1856 )
1857 .build()
1858 .expect("spec is valid");
1859 assert_eq!(spec.root, "root");
1860 assert_eq!(spec.elements.len(), 2);
1861 let root_el = spec.elements.get("root").expect("root present");
1862 assert_eq!(root_el.children, vec!["root-0".to_string()]);
1863 let child = spec.elements.get("root-0").expect("auto-id child present");
1864 assert_eq!(child.type_name, "Text");
1865 assert_eq!(
1866 child.props.get("content").and_then(|v| v.as_str()),
1867 Some("hi")
1868 );
1869 }
1870
1871 #[test]
1872 fn nested_builder_accepts_depth_three() {
1873 let spec = Spec::builder()
1875 .element_nested(
1876 "root",
1877 NestedElement::new("Screen").child(
1878 NestedElement::new("Section")
1879 .child(NestedElement::new("Text").prop("content", "leaf")),
1880 ),
1881 )
1882 .build()
1883 .expect("three levels at depth limit must be valid");
1884 assert_eq!(spec.elements.len(), 3);
1885 let root_el = spec.elements.get("root").expect("root");
1886 assert_eq!(root_el.children, vec!["root-0".to_string()]);
1887 let section = spec.elements.get("root-0").expect("section");
1888 assert_eq!(section.type_name, "Section");
1889 assert_eq!(section.children, vec!["root-0-0".to_string()]);
1890 let leaf = spec.elements.get("root-0-0").expect("leaf");
1891 assert_eq!(leaf.type_name, "Text");
1892 assert!(leaf.children.is_empty());
1893 }
1894
1895 #[test]
1896 fn nested_builder_accepts_depth_sixteen() {
1897 let spec = Spec::builder()
1899 .element_nested(
1900 "root",
1901 NestedElement::new("Screen").child(
1902 NestedElement::new("Grid").child(
1903 NestedElement::new("Card").child(
1904 NestedElement::new("Row").child(
1905 NestedElement::new("Column").child(
1906 NestedElement::new("Section").child(
1907 NestedElement::new("Container").child(
1908 NestedElement::new("Container").child(
1909 NestedElement::new("Container").child(
1910 NestedElement::new("Container").child(
1911 NestedElement::new("Container").child(
1912 NestedElement::new("Container").child(
1913 NestedElement::new("Container").child(
1914 NestedElement::new("Container").child(
1915 NestedElement::new("Container").child(
1916 NestedElement::new("Text")
1917 .prop("content", "leaf"),
1918 ),
1919 ),
1920 ),
1921 ),
1922 ),
1923 ),
1924 ),
1925 ),
1926 ),
1927 ),
1928 ),
1929 ),
1930 ),
1931 ),
1932 ),
1933 )
1934 .build()
1935 .expect("sixteen levels at depth limit must be valid");
1936 assert!(spec.elements.contains_key("root"));
1937 }
1938
1939 #[test]
1940 fn nested_builder_rejects_depth_seventeen() {
1941 let err = Spec::builder()
1943 .element_nested(
1944 "root",
1945 NestedElement::new("Screen").child(
1946 NestedElement::new("Grid").child(
1947 NestedElement::new("Card").child(
1948 NestedElement::new("Row").child(
1949 NestedElement::new("Column").child(
1950 NestedElement::new("Section").child(
1951 NestedElement::new("Container").child(
1952 NestedElement::new("Container").child(
1953 NestedElement::new("Container").child(
1954 NestedElement::new("Container").child(
1955 NestedElement::new("Container").child(
1956 NestedElement::new("Container").child(
1957 NestedElement::new("Container").child(
1958 NestedElement::new("Container").child(
1959 NestedElement::new("Container").child(
1960 NestedElement::new("Column").child(
1961 NestedElement::new("Text")
1962 .prop("content", "too deep"),
1963 ),
1964 ),
1965 ),
1966 ),
1967 ),
1968 ),
1969 ),
1970 ),
1971 ),
1972 ),
1973 ),
1974 ),
1975 ),
1976 ),
1977 ),
1978 ),
1979 )
1980 .build()
1981 .expect_err("seventeen levels must exceed the depth limit");
1982 assert!(
1983 matches!(err, SpecError::DepthExceeded { .. }),
1984 "expected DepthExceeded, got {err:?}"
1985 );
1986 }
1987
1988 #[test]
1989 fn nested_builder_auto_ids_match_position() {
1990 let spec = Spec::builder()
1991 .element_nested(
1992 "parent",
1993 NestedElement::new("Row")
1994 .child(NestedElement::new("ColA"))
1995 .child(NestedElement::new("ColB"))
1996 .child(NestedElement::new("ColC")),
1997 )
1998 .build()
1999 .expect("spec with 3 siblings is valid");
2000 assert_eq!(spec.elements.len(), 4);
2001 let parent = spec.elements.get("parent").expect("parent");
2002 assert_eq!(
2003 parent.children,
2004 vec![
2005 "parent-0".to_string(),
2006 "parent-1".to_string(),
2007 "parent-2".to_string(),
2008 ]
2009 );
2010 assert_eq!(
2011 spec.elements.get("parent-0").expect("child-0").type_name,
2012 "ColA"
2013 );
2014 assert_eq!(
2015 spec.elements.get("parent-1").expect("child-1").type_name,
2016 "ColB"
2017 );
2018 assert_eq!(
2019 spec.elements.get("parent-2").expect("child-2").type_name,
2020 "ColC"
2021 );
2022 }
2023
2024 #[test]
2025 fn nested_builder_root_set_from_first_call() {
2026 let spec = Spec::builder()
2027 .element_nested("first", NestedElement::new("Screen"))
2028 .element_nested("second", NestedElement::new("Screen"))
2029 .build()
2030 .expect("multi-root-call spec");
2031 assert_eq!(spec.root, "first");
2033 }
2034
2035 #[test]
2036 fn nested_builder_preserves_action_and_visible() {
2037 use crate::action::Action;
2038 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
2039 let action = Action::new("home.index");
2040 let vis = Visibility::Condition(VisibilityCondition {
2041 path: "/enabled".to_string(),
2042 operator: VisibilityOperator::Exists,
2043 value: None,
2044 });
2045 let spec = Spec::builder()
2046 .element_nested(
2047 "btn",
2048 NestedElement::new("Button")
2049 .action(action.clone())
2050 .visible(vis.clone()),
2051 )
2052 .build()
2053 .expect("spec with action+visible");
2054 let el = spec.elements.get("btn").expect("btn present");
2055 assert!(el.action.is_some(), "action must be preserved");
2056 assert!(el.visible.is_some(), "visible must be preserved");
2057 }
2058
2059 #[test]
2060 fn nested_builder_and_flat_builder_produce_equivalent_specs() {
2061 let nested = Spec::builder()
2062 .element_nested(
2063 "root",
2064 NestedElement::new("Card")
2065 .prop("title", "T")
2066 .child(NestedElement::new("Text").prop("content", "hi")),
2067 )
2068 .build()
2069 .expect("nested spec valid");
2070
2071 let flat = Spec::builder()
2072 .element(
2073 "root",
2074 Element::new("Card").prop("title", "T").child("root-0"),
2075 )
2076 .element("root-0", Element::new("Text").prop("content", "hi"))
2077 .build()
2078 .expect("flat spec valid");
2079
2080 let nested_json = serde_json::to_value(&nested).unwrap();
2081 let flat_json = serde_json::to_value(&flat).unwrap();
2082 assert_eq!(nested_json, flat_json);
2083 }
2084
2085 #[test]
2086 fn validate_directives_called_between_no_dangling_and_cycle() {
2087 let src = include_str!("spec.rs");
2090 let validate_section = src
2091 .split("fn validate_structure")
2092 .nth(1)
2093 .expect("validate_structure body present");
2094 let body_end = validate_section
2095 .find("\nfn ")
2096 .unwrap_or(validate_section.len());
2097 let body = &validate_section[..body_end];
2098 let pos_no_dangling = body.find("validate_no_dangling").expect("no_dangling call");
2099 let pos_directives = body.find("validate_directives").expect("directives call");
2100 let pos_cycle = body.find("detect_cycle").expect("cycle call");
2101 assert!(
2102 pos_no_dangling < pos_directives,
2103 "validate_directives must be called AFTER validate_no_dangling"
2104 );
2105 assert!(
2106 pos_directives < pos_cycle,
2107 "validate_directives must be called BEFORE detect_cycle"
2108 );
2109 }
2110
2111 #[test]
2116 fn spec_title_literal_roundtrip() {
2117 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":"Hello"}"#;
2118 let spec: Spec = serde_json::from_str(json).expect("parses");
2119 match spec.title.as_ref().unwrap() {
2120 TitleBinding::Literal(s) => assert_eq!(s, "Hello"),
2121 other => panic!("expected Literal, got {other:?}"),
2122 }
2123 let back = serde_json::to_string(&spec).unwrap();
2124 assert!(back.contains(r#""title":"Hello""#), "got: {back}");
2125 }
2126
2127 #[test]
2128 fn spec_title_binding_roundtrip() {
2129 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"$data":"/page_title"}}"#;
2130 let spec: Spec = serde_json::from_str(json).expect("parses");
2131 match spec.title.as_ref().unwrap() {
2132 TitleBinding::Binding(DataRef { data }) => assert_eq!(data, "/page_title"),
2133 other => panic!("expected Binding, got {other:?}"),
2134 }
2135 let back = serde_json::to_string(&spec).unwrap();
2136 assert!(back.contains(r#""$data":"/page_title""#), "got: {back}");
2137 }
2138
2139 #[test]
2140 fn spec_title_absent() {
2141 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}}}"#;
2142 let spec: Spec = serde_json::from_str(json).expect("parses");
2143 assert!(spec.title.is_none());
2144 }
2145
2146 #[test]
2147 fn spec_title_invalid_shape_rejected() {
2148 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"foo":"bar"}}"#;
2150 let result: Result<Spec, _> = serde_json::from_str(json);
2151 assert!(
2152 result.is_err(),
2153 "expected parse failure for {{foo:bar}} title shape"
2154 );
2155 }
2156
2157 #[test]
2158 fn design_meta_valid_round_trip() {
2159 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}},"design":{"intent":"browse","allow":["prefer-data-table"]}}"#;
2160 let spec = Spec::from_json(json).expect("parses");
2161 let design = spec.design.as_ref().expect("design present");
2162 assert_eq!(design.intent.as_deref(), Some("browse"));
2163 assert_eq!(design.allow, vec!["prefer-data-table"]);
2164 let serialized = serde_json::to_string(&spec).unwrap();
2165 let back = Spec::from_json(&serialized).expect("re-parses");
2166 assert_eq!(spec, back);
2167 }
2168
2169 #[test]
2170 fn design_meta_unknown_intent_parses_without_error() {
2171 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}},"design":{"intent":"totally-made-up"}}"#;
2173 let spec = Spec::from_json(json).expect("unknown intent must not fail parse");
2174 let design = spec.design.as_ref().expect("design present");
2175 assert_eq!(design.intent.as_deref(), Some("totally-made-up"));
2176 }
2177
2178 #[test]
2179 fn design_meta_absent_omitted_from_serialized_output() {
2180 let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}}}"#;
2181 let spec = Spec::from_json(json).expect("parses");
2182 assert!(spec.design.is_none(), "design should be None");
2183 let serialized = serde_json::to_string(&spec).unwrap();
2184 assert!(!serialized.contains("design"), "design key must be absent from output, got: {serialized}");
2185 }
2186}