Skip to main content

ferro_json_ui/
spec.rs

1//! v2 Spec types: flat element map with ID-keyed references.
2//!
3//! A [`Spec`] is a top-level JSON-UI document consisting of a `$schema` version tag,
4//! a root element ID, and an `elements` map of type-erased [`Element`] values.
5//! Each `Element` carries a `type_name: String`, a `props: serde_json::Value` payload,
6//! a `children: Vec<String>` list of child IDs (not nested structures), and optional
7//! `action` / `visible` fields.
8//!
9//! [`Spec::from_json`] and [`SpecBuilder::build`] both run the same parse-time structural
10//! validation: duplicate IDs, ID format, root existence, dangling refs, cycles, depth
11//! ≤ [`MAX_NESTING_DEPTH`]. Malformed specs surface as typed [`SpecError`] variants;
12//! `from_json` never panics on arbitrary input.
13
14use 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
26// ---------------------------------------------------------------------------
27// Section A — Constants
28// ---------------------------------------------------------------------------
29
30/// Schema version string embedded in every v2 [`Spec`] under the `$schema` JSON key.
31pub const SCHEMA_VERSION: &str = "ferro-json-ui/v2";
32
33/// Maximum allowed nesting depth from the root element.
34///
35/// Set to 16 to accommodate deeply nested layouts: dashboard shells with
36/// nested tabs, cards, forms, grids, and atomic controls. Real consumer
37/// evidence (gestiscilo-it staff-detail surface, Phase 175) reaches depth
38/// 8; the limit provides headroom for tighter nesting without re-tripping
39/// the field test on the next surface. Paths exceeding this depth surface
40/// as [`SpecError::DepthExceeded`].
41pub const MAX_NESTING_DEPTH: usize = 16;
42
43// ---------------------------------------------------------------------------
44// Section B — Types (Spec, Element, SpecError)
45// ---------------------------------------------------------------------------
46
47/// Bindable string value — either a literal or a runtime `$data` reference.
48///
49/// Used by `Spec.title` to permit either a static document title or a
50/// runtime-resolved value from handler data. The renderer resolves
51/// `Binding(DataRef)` against `spec.data` at response-build time.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
53#[serde(untagged)]
54pub enum TitleBinding {
55    Literal(String),
56    Binding(DataRef),
57}
58
59/// `{"$data": "/path"}` shape — references a JSON pointer in `spec.data`.
60/// Mirrors the `$data` key recognised by expression resolution
61/// (see `expression.rs:EXPR_DATA_KEY`).
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
63pub struct DataRef {
64    #[serde(rename = "$data")]
65    pub data: String,
66}
67
68/// Optional design metadata attached to a [`Spec`] for lint and pattern enforcement.
69///
70/// `intent` declares the page archetype (one of the seven projection intents:
71/// `browse`, `focus`, `collect`, `process`, `summarize`, `analyze`, `track`). An
72/// unknown string produces a warning finding during lint — it never fails spec parse.
73/// `allow` lists rule ids to suppress page-wide. Neither field affects rendering or
74/// spec validation.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
76pub struct DesignMeta {
77    /// Page archetype, one of the seven projection intents.
78    /// Unknown strings produce a warning finding; they never fail spec parse.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub intent: Option<String>,
81    /// Rule ids to suppress for this page. Unknown ids produce a warning finding.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub allow: Vec<String>,
84}
85
86/// Top-level v2 JSON-UI document.
87///
88/// A `Spec` is a flat element map keyed by ID with a single `root` pointer.
89/// Children are referenced by string ID, not by nesting, which keeps the
90/// structure human-auditable and preserves a stable anchor for every element.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct Spec {
93    /// Schema version tag (`"ferro-json-ui/v2"`).
94    #[serde(rename = "$schema")]
95    pub schema: String,
96    /// ID of the root element (must exist as a key in `elements`).
97    pub root: String,
98    /// Flat map of element ID to element body. Element IDs are
99    /// `^[A-Za-z_][A-Za-z0-9_-]{0,127}$`.
100    pub elements: HashMap<String, Element>,
101    /// Optional document title (used by layouts to populate `<title>`).
102    /// Accepts a literal string or `{"$data": "/path"}` binding.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub title: Option<TitleBinding>,
105    /// Optional layout name (e.g. `"dashboard"`, `"app"`).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub layout: Option<String>,
108    /// Viewport-fill workspace flag. When true, dashboard-family layouts pin
109    /// the page to the viewport height (via the `ferro-fill` body class) so a
110    /// fill-mode Grid root scrolls its panes internally instead of the
111    /// document scrolling. For full-height screens like a POS register.
112    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
113    pub fill_viewport: bool,
114    /// Arbitrary data payload consumed by data-path references inside elements.
115    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
116    pub data: Value,
117    /// Optional design metadata (intent + allow list). Never affects rendering.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub design: Option<DesignMeta>,
120}
121
122/// A single type-erased UI element.
123///
124/// The `type_name` is an unrestricted string; catalog / renderer layers decide
125/// whether the name resolves to a built-in or plugin component. `props` is a
126/// free-form JSON value carrying component-specific fields; validation of
127/// per-component props is deferred to the Phase 117 catalog.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct Element {
130    /// Component type name (renamed from `type` to avoid Rust keyword collision).
131    #[serde(rename = "type")]
132    pub type_name: String,
133    /// Component-specific props payload.
134    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
135    pub props: Value,
136    /// IDs of child elements (must resolve in the parent `Spec.elements` map).
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub children: Vec<String>,
139    /// Optional action attached to this element.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub action: Option<Action>,
142    /// Optional visibility rule governing whether this element renders.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub visible: Option<Visibility>,
145    /// Optional iteration directive. When present, the element is treated as
146    /// a template — resolve-time expansion (Plan 03) produces N clones with
147    /// auto-suffixed IDs, one per row in the resolved data array.
148    #[serde(default, skip_serializing_if = "Option::is_none", rename = "$each")]
149    pub each: Option<EachDirective>,
150    /// Optional conditional-emission directive. When the predicate evaluates
151    /// false against [`Spec::data`] at resolve time (Plan 03), the element is
152    /// REMOVED from the element map (no hidden DOM, no JS). Distinct from
153    /// `visible` which renders the element with `hidden` semantics.
154    ///
155    /// Reuses the [`Visibility`] enum (D-04) — accepts flat conditions AND
156    /// And/Or/Not composition because `Visibility` is `#[serde(untagged)]`.
157    ///
158    /// # Interaction with `$each`
159    ///
160    /// When both `$if` and `$each` are present on the same element, `$if` is
161    /// evaluated FIRST. If false, the element is removed before `$each`
162    /// expansion runs (no clones produced).
163    #[serde(default, skip_serializing_if = "Option::is_none", rename = "$if")]
164    pub if_: Option<Visibility>,
165}
166
167/// Iteration directive on an [`Element`]: instantiate one element per row of
168/// a JSON array resolved from [`Spec::data`].
169///
170/// At resolve time, the templated element is replaced by N clones with
171/// auto-suffixed IDs (`{element_id}-0`, `{element_id}-1`, ...). The loop
172/// variable bound by `as` (default name `"row"`) scopes `$data` paths
173/// starting with `/{as}/...` to the current iteration row.
174///
175/// # Reserved names
176///
177/// The `as` field must NOT be one of `["data", "root", "_root", "_each",
178/// "this", "self"]` — see `SpecError::EachAsReservedName` (validated by
179/// `Spec::validate` in Plan 04).
180///
181/// # Wire format example
182///
183/// ```json
184/// {
185///   "type": "Card",
186///   "$each": { "path": "/orders", "as": "order" },
187///   "props": { "title": { "$data": "/order/order_number" } }
188/// }
189/// ```
190///
191/// # Resource bounds
192///
193/// At Plan 01, the directive is inert — no resolver runs yet. A hard cap on
194/// expansion size is a follow-up concern; Phase 163 does not impose a fixed
195/// limit. Spec authors are responsible for bounding the resolved array.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct EachDirective {
198    /// JSONPath-style slash-separated path to a JSON array in [`Spec::data`].
199    pub path: String,
200    /// Loop-variable name bound during expansion. Paths starting with
201    /// `/{as}/...` in the templated element's props resolve to the current row.
202    #[serde(rename = "as")]
203    pub as_: String,
204}
205
206/// Errors returned by [`Spec::from_json`] and [`SpecBuilder::build`].
207///
208/// Variants carry structured payloads (not formatted strings) so tooling
209/// can pinpoint the offending element by ID.
210#[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
255// ---------------------------------------------------------------------------
256// Section C — Builder
257// ---------------------------------------------------------------------------
258
259impl Spec {
260    /// Entry point for fluent construction of a [`Spec`].
261    ///
262    /// The first `.element(id, _)` call sets the `root` if it has not been
263    /// explicitly set via `.root()`. The terminal `.build()` runs the same
264    /// structural validation as [`Spec::from_json`].
265    pub fn builder() -> SpecBuilder {
266        SpecBuilder::new()
267    }
268
269    /// Merge handler-provided data into `spec.data` via a shallow top-level merge.
270    ///
271    /// If `handler_data` is a JSON Object, its keys are inserted into `self.data`,
272    /// overwriting matching keys (handler wins — locked per 119-CONTEXT D-04).
273    /// If `self.data` is `Value::Null` (the default for specs built without `.data(...)`),
274    /// it is initialized to an empty object before inserting — otherwise `as_object_mut()`
275    /// would return `None` and the handler keys would be silently dropped
276    /// (119-RESEARCH §Pitfall 4).
277    ///
278    /// If `handler_data` is not an Object (Null, Array, String, Number, Bool), it is
279    /// silently ignored — a `debug_assert!` fires in dev builds but production never
280    /// panics (119-CONTEXT D-04).
281    ///
282    /// Consuming builder (`mut self -> Self`) for consistency with `SpecBuilder`.
283    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    /// Parse a v2 spec from its JSON representation.
302    ///
303    /// Returns `Ok(spec)` only if the JSON is well-formed AND the element
304    /// graph passes every structural check. Never panics on arbitrary input.
305    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                // Intercept the custom duplicate-ID sentinel and convert to DuplicateId.
310                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    /// Start building an [`Element`] with the given type name.
339    ///
340    /// Returns an [`ElementBuilder`] rather than `Self` because element
341    /// construction is fluent; the terminal call is consumed by
342    /// [`SpecBuilder::element`] which invokes the crate-private `build`.
343    #[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/// Fluent builder for [`Spec`].
358#[derive(Debug, Default)]
359pub struct SpecBuilder {
360    title: Option<TitleBinding>,
361    layout: Option<String>,
362    fill_viewport_: bool,
363    data: Value,
364    root: Option<String>,
365    elements: HashMap<String, Element>,
366}
367
368impl SpecBuilder {
369    fn new() -> Self {
370        Self {
371            title: None,
372            layout: None,
373            fill_viewport_: false,
374            data: Value::Null,
375            root: None,
376            elements: HashMap::new(),
377        }
378    }
379
380    /// Set the document title (literal string).
381    pub fn title(mut self, t: impl Into<String>) -> Self {
382        self.title = Some(TitleBinding::Literal(t.into()));
383        self
384    }
385
386    /// Set the document title to a `{"$data": "/path"}` binding.
387    pub fn title_binding(mut self, path: impl Into<String>) -> Self {
388        self.title = Some(TitleBinding::Binding(DataRef { data: path.into() }));
389        self
390    }
391
392    /// Set the layout name.
393    pub fn layout(mut self, l: impl Into<String>) -> Self {
394        self.layout = Some(l.into());
395        self
396    }
397
398    /// Enable fill-viewport mode. For the ferro-fill CSS chain to activate, the
399    /// root Grid must also have `fill: true` and `spec.layout` must be an
400    /// app-shell layout (`"dashboard"` or `"app"`). Default is `false`.
401    pub fn fill_viewport(mut self, v: bool) -> Self {
402        self.fill_viewport_ = v;
403        self
404    }
405
406    /// Attach the data payload.
407    pub fn data(mut self, d: Value) -> Self {
408        self.data = d;
409        self
410    }
411
412    /// Explicitly set the root element ID.
413    ///
414    /// If omitted, the root defaults to the ID of the first element added.
415    pub fn root(mut self, id: impl Into<String>) -> Self {
416        self.root = Some(id.into());
417        self
418    }
419
420    /// Add an element to the spec. The first call (absent an explicit
421    /// [`SpecBuilder::root`]) establishes the root.
422    pub fn element(mut self, id: impl Into<String>, el: ElementBuilder) -> Self {
423        let id: String = id.into();
424        if self.root.is_none() {
425            self.root = Some(id.clone());
426        }
427        self.elements.insert(id, el.build());
428        self
429    }
430
431    /// Add an element AND its nested children in a single call.
432    ///
433    /// Children are flattened to the canonical flat `Spec.elements` map with
434    /// IDs auto-generated by structural position: `{parent_id}-0`,
435    /// `{parent_id}-1`, ..., and so on recursively
436    /// (`{parent_id}-0-0`, `{parent_id}-0-1`, ...).
437    ///
438    /// The first `.element_nested` call (absent an explicit
439    /// [`SpecBuilder::root`]) establishes the root, just like
440    /// [`SpecBuilder::element`].
441    ///
442    /// # D-07 contract
443    ///
444    /// The runtime [`Spec`] shape is unchanged. After this method returns,
445    /// the nested form is gone — the spec is the canonical flat element map.
446    pub fn element_nested(mut self, id: impl Into<String>, el: NestedElement) -> Self {
447        let id: String = id.into();
448        if self.root.is_none() {
449            self.root = Some(id.clone());
450        }
451        flatten_nested(&mut self.elements, &id, el);
452        self
453    }
454
455    /// Finalize the spec. Runs the same structural validation as
456    /// [`Spec::from_json`].
457    pub fn build(self) -> Result<Spec, SpecError> {
458        let root = self.root.ok_or_else(|| {
459            // An empty builder with no elements has no meaningful root; surface
460            // it as RootMissing("") so the error surface is uniform with
461            // from_json.
462            SpecError::RootMissing(String::new())
463        })?;
464        let spec = Spec {
465            schema: SCHEMA_VERSION.to_string(),
466            root,
467            elements: self.elements,
468            title: self.title,
469            layout: self.layout,
470            fill_viewport: self.fill_viewport_,
471            data: self.data,
472            design: None,
473        };
474        validate_structure(&spec)?;
475        Ok(spec)
476    }
477}
478
479/// Fluent builder for [`Element`].
480#[derive(Debug)]
481pub struct ElementBuilder {
482    type_name: String,
483    props: Map<String, Value>,
484    children: Vec<String>,
485    action: Option<Action>,
486    visible: Option<Visibility>,
487    each: Option<EachDirective>,
488    if_: Option<Visibility>,
489}
490
491impl ElementBuilder {
492    /// Set a prop on the element.
493    pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
494        self.props.insert(k.into(), v.into());
495        self
496    }
497
498    /// Append a child ID.
499    pub fn child(mut self, id: impl Into<String>) -> Self {
500        self.children.push(id.into());
501        self
502    }
503
504    /// Attach an action.
505    pub fn action(mut self, a: Action) -> Self {
506        self.action = Some(a);
507        self
508    }
509
510    /// Attach a visibility rule.
511    pub fn visible(mut self, v: Visibility) -> Self {
512        self.visible = Some(v);
513        self
514    }
515
516    /// Set the `$each` iteration directive on this element.
517    ///
518    /// `path` is a JSON-pointer to an array in `Spec.data`; `as_` is the
519    /// loop-variable name used inside this element's `$data` prop bindings
520    /// (e.g. `.each("/data/products", "p")` then `.prop("name", json!({"$data":"/p/name"}))`).
521    ///
522    /// Template elements carry data-bound props whose type cannot be checked
523    /// before `$each` expansion — `Catalog::validate` skips per-element Props
524    /// validation for elements whose `each` is `Some` (see catalog.rs).
525    pub fn each(mut self, path: impl Into<String>, as_: impl Into<String>) -> Self {
526        self.each = Some(EachDirective {
527            path: path.into(),
528            as_: as_.into(),
529        });
530        self
531    }
532
533    pub(crate) fn build(self) -> Element {
534        let props = if self.props.is_empty() {
535            Value::Null
536        } else {
537            Value::Object(self.props)
538        };
539        Element {
540            type_name: self.type_name,
541            props,
542            children: self.children,
543            action: self.action,
544            visible: self.visible,
545            each: self.each,
546            if_: self.if_,
547        }
548    }
549}
550
551// ---------------------------------------------------------------------------
552// Section C2 — Ergonomic nested builder (Phase 163 D-06/D-07)
553// ---------------------------------------------------------------------------
554
555/// Nested-tree element form for the ergonomic [`SpecBuilder::element_nested`]
556/// API.
557///
558/// `NestedElement` mirrors [`ElementBuilder`] but with `children: Vec<NestedElement>`
559/// instead of `children: Vec<String>`. The runtime [`Spec`] shape is UNCHANGED:
560/// `element_nested` flattens this nested tree to the canonical flat element map
561/// with auto-generated `{parent_id}-{idx}` IDs at `.build()` time. Once `Spec`
562/// is constructed, the nested form is gone (D-07: the nested DSL is sugar over
563/// construction only).
564///
565/// # When to reach for this
566///
567/// Only when neither a static JSON spec, nor `$each`, nor `$if` can express
568/// the element graph (e.g., truly heterogeneous runtime construction).
569///
570/// # Limitations
571///
572/// `NestedElement` does NOT expose `$each` / `$if` directive setters in
573/// Phase 163. Rust callers expressing iteration write the loop directly
574/// in Rust. If a use case emerges for Rust-side directive injection, add
575/// `.each(...)` / `.if_(...)` methods in a follow-up phase.
576#[derive(Debug)]
577pub struct NestedElement {
578    type_name: String,
579    props: Map<String, Value>,
580    children: Vec<NestedElement>,
581    action: Option<Action>,
582    visible: Option<Visibility>,
583}
584
585impl NestedElement {
586    /// Start a nested element tree node.
587    pub fn new(type_name: impl Into<String>) -> Self {
588        Self {
589            type_name: type_name.into(),
590            props: Map::new(),
591            children: Vec::new(),
592            action: None,
593            visible: None,
594        }
595    }
596
597    /// Set a prop.
598    pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
599        self.props.insert(k.into(), v.into());
600        self
601    }
602
603    /// Append a child node. Children are flattened to IDs `{parent}-{idx}` by
604    /// structural position when the spec is built.
605    pub fn child(mut self, c: NestedElement) -> Self {
606        self.children.push(c);
607        self
608    }
609
610    /// Attach an action.
611    pub fn action(mut self, a: Action) -> Self {
612        self.action = Some(a);
613        self
614    }
615
616    /// Attach a visibility rule.
617    pub fn visible(mut self, v: Visibility) -> Self {
618        self.visible = Some(v);
619        self
620    }
621
622    /// **Test-only:** Convert this node to a flat [`Element`] WITHOUT walking
623    /// children. Children are dropped (used only by inline tests that want to
624    /// inspect leaf-node structure). Real builds go through
625    /// [`SpecBuilder::element_nested`] which preserves child structure via
626    /// flattening.
627    #[cfg(test)]
628    pub(crate) fn build_for_test(self) -> Element {
629        let props = if self.props.is_empty() {
630            Value::Null
631        } else {
632            Value::Object(self.props)
633        };
634        Element {
635            type_name: self.type_name,
636            props,
637            children: Vec::new(),
638            action: self.action,
639            visible: self.visible,
640            each: None,
641            if_: None,
642        }
643    }
644}
645
646/// Recursively flatten a [`NestedElement`] tree into a flat element map.
647///
648/// Child IDs are derived from the parent's ID via `format!("{parent}-{idx}")`.
649fn flatten_nested(elements: &mut HashMap<String, Element>, id: &str, el: NestedElement) {
650    let mut child_ids: Vec<String> = Vec::with_capacity(el.children.len());
651    for (idx, child) in el.children.into_iter().enumerate() {
652        let child_id = format!("{id}-{idx}");
653        flatten_nested(elements, &child_id, child);
654        child_ids.push(child_id);
655    }
656    let props = if el.props.is_empty() {
657        Value::Null
658    } else {
659        Value::Object(el.props)
660    };
661    let element = Element {
662        type_name: el.type_name,
663        props,
664        children: child_ids,
665        action: el.action,
666        visible: el.visible,
667        each: None,
668        if_: None,
669    };
670    elements.insert(id.to_string(), element);
671}
672
673// ---------------------------------------------------------------------------
674// Section D — Validation and parse-wire types
675// ---------------------------------------------------------------------------
676
677/// Sentinel string smuggled through `serde::de::Error::custom` so
678/// [`Spec::from_json`] can distinguish duplicate-ID errors from other parse
679/// failures without relying on a forked serde_json. The string is chosen to
680/// not appear in legitimate JSON.
681const DUP_ID_SENTINEL: &str = "__FERRO_DUPLICATE_ID__";
682
683/// Internal wire struct. Wraps `elements` in [`ElementsMap`] so duplicate keys
684/// are rejected during deserialization rather than silently overwritten.
685#[derive(Deserialize)]
686struct SpecWire {
687    #[serde(rename = "$schema", default = "default_schema")]
688    schema: String,
689    root: String,
690    elements: ElementsMap,
691    #[serde(default)]
692    title: Option<TitleBinding>,
693    #[serde(default)]
694    layout: Option<String>,
695    #[serde(default)]
696    fill_viewport: bool,
697    #[serde(default)]
698    data: Value,
699    #[serde(default)]
700    design: Option<DesignMeta>,
701}
702
703fn default_schema() -> String {
704    SCHEMA_VERSION.to_string()
705}
706
707/// Wrapper around `HashMap<String, Element>` that fails deserialization on
708/// duplicate keys. The underlying `serde_json::Map` default silently overwrites
709/// — we want authors to see the mistake.
710struct ElementsMap(HashMap<String, Element>);
711
712impl<'de> DeserializeTrait<'de> for ElementsMap {
713    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
714        struct V;
715        impl<'de> Visitor<'de> for V {
716            type Value = ElementsMap;
717            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
718                f.write_str("a JSON object with unique element IDs")
719            }
720            fn visit_map<M: MapAccess<'de>>(self, mut m: M) -> Result<ElementsMap, M::Error> {
721                let mut map: HashMap<String, Element> = HashMap::new();
722                while let Some(k) = m.next_key::<String>()? {
723                    if map.contains_key(&k) {
724                        return Err(serde::de::Error::custom(format!("{DUP_ID_SENTINEL}{k}")));
725                    }
726                    let v: Element = m.next_value()?;
727                    map.insert(k, v);
728                }
729                Ok(ElementsMap(map))
730            }
731        }
732        d.deserialize_map(V)
733    }
734}
735
736/// Run every structural check on a freshly-parsed (or built) spec.
737///
738/// Order matters: ID format is checked first because every other variant
739/// assumes well-formed IDs when it builds a path. After that, the cheapest
740/// / most-specific failures come before the more expensive graph traversals.
741fn validate_structure(spec: &Spec) -> Result<(), SpecError> {
742    validate_ids(&spec.elements)?;
743    if !spec.elements.contains_key(&spec.root) {
744        return Err(SpecError::RootMissing(spec.root.clone()));
745    }
746    validate_no_dangling(&spec.elements)?;
747    validate_directives(spec)?;
748    validate_footer_ids(spec)?;
749    detect_cycle(&spec.elements, &spec.root)?;
750    check_depth(&spec.elements, &spec.root)?;
751    Ok(())
752}
753
754/// Check a single ID against the `^[A-Za-z_][A-Za-z0-9_-]{0,127}$` grammar.
755fn is_valid_id(s: &str) -> bool {
756    if s.is_empty() || s.len() > 128 {
757        return false;
758    }
759    let bytes = s.as_bytes();
760    let first = bytes[0];
761    let first_ok = first.is_ascii_alphabetic() || first == b'_';
762    if !first_ok {
763        return false;
764    }
765    bytes[1..]
766        .iter()
767        .all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
768}
769
770fn validate_ids(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
771    for (id, el) in elements {
772        if !is_valid_id(id) {
773            return Err(SpecError::InvalidId(id.clone()));
774        }
775        for child in &el.children {
776            if !is_valid_id(child) {
777                return Err(SpecError::InvalidId(child.clone()));
778            }
779        }
780    }
781    Ok(())
782}
783
784fn validate_no_dangling(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
785    for (id, el) in elements {
786        for child in &el.children {
787            if !elements.contains_key(child) {
788                return Err(SpecError::DanglingChild {
789                    element: id.clone(),
790                    child: child.clone(),
791                });
792            }
793        }
794    }
795    Ok(())
796}
797
798/// D-07: every footer-referenced ID must exist in `spec.elements`.
799/// D-08: when an ID appears in both `props.footer` and `children` of the same
800/// parent, emit an `eprintln!` warning — the element renders once (in footer)
801/// and the duplicate listing is dead config.
802fn validate_footer_ids(spec: &Spec) -> Result<(), SpecError> {
803    for (element_id, el) in &spec.elements {
804        // `props` is a generic Value; handle null/missing gracefully.
805        let footer_ids: Vec<String> = el
806            .props
807            .get("footer")
808            .and_then(|v| v.as_array())
809            .map(|arr| {
810                arr.iter()
811                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
812                    .collect()
813            })
814            .unwrap_or_default();
815
816        for footer_id in &footer_ids {
817            if !spec.elements.contains_key(footer_id) {
818                return Err(SpecError::FooterMissing {
819                    element_id: element_id.clone(),
820                    footer_id: footer_id.clone(),
821                });
822            }
823            // D-08 warning — non-fatal, written to stderr.
824            if el.children.iter().any(|c| c == footer_id) {
825                eprintln!(
826                    "ferro-json-ui: element '{element_id}' has '{footer_id}' in both \
827                     props.footer and children — the element renders once (in footer); \
828                     remove the duplicate from children"
829                );
830            }
831        }
832    }
833    Ok(())
834}
835
836/// Reserved names for the `$each.as` loop variable.
837const RESERVED_EACH_AS: &[&str] = &["data", "root", "_root", "_each", "this", "self"];
838
839/// Validate `$each` and `$if` directives on every element.
840///
841/// Best-effort: path resolvability against `spec.data` is checked only when
842/// `spec.data` is non-null. Per-request data is not visible at this stage.
843fn validate_directives(spec: &Spec) -> Result<(), SpecError> {
844    // First pass: collect each-templated element IDs + their directives.
845    let templated: HashMap<&str, &EachDirective> = spec
846        .elements
847        .iter()
848        .filter_map(|(id, el)| el.each.as_ref().map(|e| (id.as_str(), e)))
849        .collect();
850
851    for (id, el) in &spec.elements {
852        // -- $each validation --
853        if let Some(each) = &el.each {
854            // (a) Reserved-name check.
855            if RESERVED_EACH_AS.contains(&each.as_.as_str()) {
856                return Err(SpecError::EachAsReservedName {
857                    element_id: id.clone(),
858                    name: each.as_.clone(),
859                });
860            }
861            // (b) Path-resolves-to-array check (only when spec.data is non-null).
862            if !spec.data.is_null() {
863                if let Some(value) = crate::data::resolve_path(&spec.data, &each.path) {
864                    if !value.is_array() {
865                        return Err(SpecError::EachPathNotArray {
866                            element_id: id.clone(),
867                            path: each.path.clone(),
868                        });
869                    }
870                }
871            }
872            // (c) Mismatched-each child check: scan direct children.
873            for child in &el.children {
874                if let Some(child_each) = templated.get(child.as_str()) {
875                    if child_each.path != each.path || child_each.as_ != each.as_ {
876                        return Err(SpecError::MismatchedEach {
877                            parent: id.clone(),
878                            parent_path: each.path.clone(),
879                            child: child.clone(),
880                            child_path: child_each.path.clone(),
881                        });
882                    }
883                }
884            }
885            // (d) Nested-each check: walk transitive descendants beyond direct
886            // children. Direct children with matching (path, as) are the
887            // correlated-sibling case (valid). Transitive descendants that are
888            // also $each-templated are nested — always rejected.
889            let direct: HashSet<&str> = el.children.iter().map(|s| s.as_str()).collect();
890            let mut visited: HashSet<&str> = HashSet::new();
891            let mut stack: Vec<&str> = Vec::new();
892            // Seed stack with grandchildren (skip direct children).
893            for child in &el.children {
894                if let Some(child_el) = spec.elements.get(child) {
895                    for gc in &child_el.children {
896                        stack.push(gc.as_str());
897                    }
898                }
899            }
900            while let Some(node) = stack.pop() {
901                if !visited.insert(node) {
902                    continue;
903                }
904                if templated.contains_key(node) && !direct.contains(node) {
905                    return Err(SpecError::NestedEach {
906                        outer: id.clone(),
907                        inner: node.to_string(),
908                    });
909                }
910                if let Some(node_el) = spec.elements.get(node) {
911                    for c in &node_el.children {
912                        stack.push(c.as_str());
913                    }
914                }
915            }
916        }
917        // -- $if validation --
918        // Walk all conditions inside the Visibility tree; for each, check path
919        // against spec.data (best-effort, only when spec.data is non-null).
920        if let Some(vis) = &el.if_ {
921            if !spec.data.is_null() {
922                check_visibility_paths(id, vis, &spec.data)?;
923            }
924        }
925    }
926    Ok(())
927}
928
929/// Recursively walk a Visibility tree, asserting every condition's path
930/// resolves to a present key in `data` (Some(_)). Missing → IfPathMissing.
931fn check_visibility_paths(
932    element_id: &str,
933    vis: &Visibility,
934    data: &Value,
935) -> Result<(), SpecError> {
936    match vis {
937        Visibility::And { and } => {
938            for v in and {
939                check_visibility_paths(element_id, v, data)?;
940            }
941        }
942        Visibility::Or { or } => {
943            for v in or {
944                check_visibility_paths(element_id, v, data)?;
945            }
946        }
947        Visibility::Not { not } => check_visibility_paths(element_id, not, data)?,
948        Visibility::Condition(c) => {
949            if crate::data::resolve_path(data, &c.path).is_none() {
950                return Err(SpecError::IfPathMissing {
951                    element_id: element_id.to_string(),
952                    path: c.path.clone(),
953                });
954            }
955        }
956    }
957    Ok(())
958}
959
960fn detect_cycle(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
961    let mut visited: HashSet<String> = HashSet::new();
962    let mut on_stack: Vec<String> = Vec::new();
963    dfs(root, elements, &mut visited, &mut on_stack)
964}
965
966fn dfs(
967    node: &str,
968    elements: &HashMap<String, Element>,
969    visited: &mut HashSet<String>,
970    on_stack: &mut Vec<String>,
971) -> Result<(), SpecError> {
972    if let Some(start) = on_stack.iter().position(|n| n == node) {
973        let mut path: Vec<String> = on_stack[start..].to_vec();
974        path.push(node.to_string());
975        return Err(SpecError::Cycle { path });
976    }
977    if visited.contains(node) {
978        return Ok(());
979    }
980    on_stack.push(node.to_string());
981    if let Some(el) = elements.get(node) {
982        for child in &el.children {
983            dfs(child, elements, visited, on_stack)?;
984        }
985    }
986    on_stack.pop();
987    visited.insert(node.to_string());
988    Ok(())
989}
990
991fn check_depth(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
992    let mut path: Vec<String> = Vec::new();
993    walk(root, elements, 1, &mut path)
994}
995
996fn walk(
997    node: &str,
998    elements: &HashMap<String, Element>,
999    depth: usize,
1000    path: &mut Vec<String>,
1001) -> Result<(), SpecError> {
1002    path.push(node.to_string());
1003    if depth > MAX_NESTING_DEPTH {
1004        return Err(SpecError::DepthExceeded {
1005            max: MAX_NESTING_DEPTH,
1006            found: depth,
1007            path: path.clone(),
1008        });
1009    }
1010    if let Some(el) = elements.get(node) {
1011        for child in &el.children {
1012            walk(child, elements, depth + 1, path)?;
1013        }
1014    }
1015    path.pop();
1016    Ok(())
1017}
1018
1019// ---------------------------------------------------------------------------
1020// Inline unit tests
1021// ---------------------------------------------------------------------------
1022
1023// rustfmt 1.88.0 hangs (>30 min, pathological complexity) when formatting this
1024// test module alongside the rest of the file. Skip the module — its contents
1025// are stable and don't need ongoing reformatting.
1026#[cfg(test)]
1027#[rustfmt::skip]
1028mod tests {
1029    use super::*;
1030    use serde_json::json;
1031
1032    #[test]
1033    fn default_schema_is_v2() {
1034        assert_eq!(default_schema(), SCHEMA_VERSION);
1035        assert_eq!(SCHEMA_VERSION, "ferro-json-ui/v2");
1036    }
1037
1038    #[test]
1039    fn is_valid_id_edge_cases() {
1040        // Each row is (input, expected_valid).
1041        let cases: &[(&str, bool)] = &[
1042            ("", false),
1043            ("1abc", false),
1044            ("a", true),
1045            ("_", true),
1046            ("a_b-c", true),
1047            ("user form", false),
1048            ("ABC123", true),
1049            ("a.b", false),
1050            ("/path", false),
1051        ];
1052        for (s, ok) in cases {
1053            assert_eq!(is_valid_id(s), *ok, "mismatch on {s:?}");
1054        }
1055        // 128 chars ok, 129 chars rejected.
1056        let ok128: String = "a".repeat(128);
1057        let bad129: String = "a".repeat(129);
1058        assert!(is_valid_id(&ok128));
1059        assert!(!is_valid_id(&bad129));
1060    }
1061
1062    #[test]
1063    fn builder_minimal_round_trips() {
1064        let spec = Spec::builder()
1065            .element("a", Element::new("Text").prop("content", "Hi"))
1066            .build()
1067            .unwrap();
1068        assert_eq!(spec.schema, SCHEMA_VERSION);
1069        assert_eq!(spec.root, "a");
1070        assert_eq!(spec.elements.len(), 1);
1071        let json = serde_json::to_string(&spec).unwrap();
1072        let back = Spec::from_json(&json).unwrap();
1073        assert_eq!(spec, back);
1074    }
1075
1076    #[test]
1077    fn builder_parity_with_json() {
1078        let from_json = Spec::from_json(
1079            r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text","props":{"content":"Hi"}}}}"#,
1080        )
1081        .unwrap();
1082        let from_builder = Spec::builder()
1083            .element("a", Element::new("Text").prop("content", "Hi"))
1084            .build()
1085            .unwrap();
1086        assert_eq!(from_json, from_builder);
1087    }
1088
1089    #[test]
1090    fn from_json_rejects_missing_root() {
1091        let err = Spec::from_json(
1092            r#"{"$schema":"ferro-json-ui/v2","root":"nope","elements":{"a":{"type":"Text"}}}"#,
1093        )
1094        .unwrap_err();
1095        match err {
1096            SpecError::RootMissing(id) => assert_eq!(id, "nope"),
1097            other => panic!("expected RootMissing, got {other:?}"),
1098        }
1099    }
1100
1101    #[test]
1102    fn from_json_rejects_dangling_child() {
1103        let err = Spec::from_json(
1104            r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Card","children":["ghost"]}}}"#,
1105        )
1106        .unwrap_err();
1107        match err {
1108            SpecError::DanglingChild { element, child } => {
1109                assert_eq!(element, "a");
1110                assert_eq!(child, "ghost");
1111            }
1112            other => panic!("expected DanglingChild, got {other:?}"),
1113        }
1114    }
1115
1116    /// Phase 164 D-05 case 4 regression test.
1117    ///
1118    /// A `children` reference to an element that has a `$if` directive must NOT
1119    /// be rejected as a dangling child. The referenced element IS present in the
1120    /// elements map; `$if` only removes it at resolve time — the structural
1121    /// validator cannot observe per-request data, so it must accept such refs.
1122    #[test]
1123    fn validate_allows_children_ref_to_if_gated_element() {
1124        // parent references child; child has $if (Visibility condition).
1125        // validate_no_dangling must pass because "child" exists in elements.
1126        let json = r#"{
1127            "$schema": "ferro-json-ui/v2",
1128            "root": "parent",
1129            "elements": {
1130                "parent": {
1131                    "type": "Card",
1132                    "props": {"title": "parent"},
1133                    "children": ["child"]
1134                },
1135                "child": {
1136                    "type": "Text",
1137                    "props": {"content": "conditional"},
1138                    "$if": {"path": "/data/show", "operator": "eq", "value": true}
1139                }
1140            }
1141        }"#;
1142        // Must parse successfully — $if-gated child must not be rejected as dangling.
1143        let spec = Spec::from_json(json);
1144        assert!(
1145            spec.is_ok(),
1146            "$if-gated child must not be rejected as dangling: {:?}",
1147            spec.err()
1148        );
1149    }
1150
1151    #[test]
1152    fn from_json_rejects_self_cycle() {
1153        let err = Spec::from_json(
1154            r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{"A":{"type":"Card","children":["A"]}}}"#,
1155        )
1156        .unwrap_err();
1157        match err {
1158            SpecError::Cycle { path } => {
1159                assert_eq!(path, vec!["A".to_string(), "A".to_string()]);
1160            }
1161            other => panic!("expected Cycle (self), got {other:?}"),
1162        }
1163    }
1164
1165    #[test]
1166    fn from_json_rejects_two_cycle() {
1167        let err = Spec::from_json(
1168            r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{"root":{"type":"Card","children":["A"]},"A":{"type":"Card","children":["root"]}}}"#,
1169        )
1170        .unwrap_err();
1171        match err {
1172            SpecError::Cycle { path } => {
1173                assert!(path.len() >= 3);
1174                assert_eq!(path.first(), path.last());
1175            }
1176            other => panic!("expected Cycle, got {other:?}"),
1177        }
1178    }
1179
1180    #[test]
1181    fn cycle_detector_only_on_revisit() {
1182        // A → B → A: a real two-node cycle. The cycle detector must fire and
1183        // return SpecError::Cycle whose path contains both "A" and "B".
1184        // After the diagnostic split (Task 2), the depth tripwire must NOT
1185        // fire for a real cycle — the parse-time validator catches it first.
1186        let err = Spec::from_json(
1187            r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
1188                "A":{"type":"Card","children":["B"]},
1189                "B":{"type":"Card","children":["A"]}
1190            }}"#,
1191        )
1192        .unwrap_err();
1193        match err {
1194            SpecError::Cycle { path } => {
1195                assert!(
1196                    path.iter().any(|p| p == "A"),
1197                    "cycle path must contain A; got {path:?}"
1198                );
1199                assert!(
1200                    path.iter().any(|p| p == "B"),
1201                    "cycle path must contain B; got {path:?}"
1202                );
1203            }
1204            other => panic!("expected Cycle, got {other:?}"),
1205        }
1206    }
1207
1208    #[test]
1209    fn from_json_rejects_depth_17() {
1210        // Seventeen levels (root + 16 children chain): one past MAX_NESTING_DEPTH=16.
1211        let err = Spec::from_json(
1212            r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
1213                "root":{"type":"Container","children":["e1"]},
1214                "e1":{"type":"Container","children":["e2"]},
1215                "e2":{"type":"Container","children":["e3"]},
1216                "e3":{"type":"Container","children":["e4"]},
1217                "e4":{"type":"Container","children":["e5"]},
1218                "e5":{"type":"Container","children":["e6"]},
1219                "e6":{"type":"Container","children":["e7"]},
1220                "e7":{"type":"Container","children":["e8"]},
1221                "e8":{"type":"Container","children":["e9"]},
1222                "e9":{"type":"Container","children":["e10"]},
1223                "e10":{"type":"Container","children":["e11"]},
1224                "e11":{"type":"Container","children":["e12"]},
1225                "e12":{"type":"Container","children":["e13"]},
1226                "e13":{"type":"Container","children":["e14"]},
1227                "e14":{"type":"Container","children":["e15"]},
1228                "e15":{"type":"Container","children":["e16"]},
1229                "e16":{"type":"Text"}
1230            }}"#,
1231        )
1232        .unwrap_err();
1233        match err {
1234            SpecError::DepthExceeded { max, found, path } => {
1235                assert_eq!(max, 16, "max must equal MAX_NESTING_DEPTH=16");
1236                assert_eq!(found, 17, "found must be 17 (one past the limit)");
1237                assert!(!path.is_empty());
1238            }
1239            other => panic!("expected DepthExceeded, got {other:?}"),
1240        }
1241    }
1242
1243    #[test]
1244    fn from_json_accepts_depth_8() {
1245        // Consumer evidence fixture: staff-detail tree reaches depth 8.
1246        // dashboard → root → DetailPage → tab → card → form → row → switch
1247        // Verifies that depth-8 specs parse without DepthExceeded.
1248        let spec = Spec::from_json(
1249            r#"{"$schema":"ferro-json-ui/v2","root":"dashboard","elements":{
1250                "dashboard":{"type":"Screen","children":["root"]},
1251                "root":{"type":"Container","children":["detail_page"]},
1252                "detail_page":{"type":"DetailPage","children":["tab"]},
1253                "tab":{"type":"Card","children":["card"]},
1254                "card":{"type":"Card","children":["form"]},
1255                "form":{"type":"Form","children":["row"]},
1256                "row":{"type":"Grid","children":["switch_day"]},
1257                "switch_day":{"type":"Switch"}
1258            }}"#,
1259        )
1260        .expect("depth-8 staff-detail spec must parse without DepthExceeded");
1261        assert_eq!(spec.elements.len(), 8);
1262    }
1263
1264    #[test]
1265    fn from_json_rejects_invalid_id_space() {
1266        let err = Spec::from_json(
1267            r#"{"$schema":"ferro-json-ui/v2","root":"user form","elements":{"user form":{"type":"Text"}}}"#,
1268        )
1269        .unwrap_err();
1270        match err {
1271            SpecError::InvalidId(id) => assert_eq!(id, "user form"),
1272            other => panic!("expected InvalidId, got {other:?}"),
1273        }
1274    }
1275
1276    #[test]
1277    fn from_json_rejects_duplicate_id() {
1278        // Raw JSON with two `"a"` keys; serde's default map would silently overwrite.
1279        let err = Spec::from_json(
1280            r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text"},"a":{"type":"Card"}}}"#,
1281        )
1282        .unwrap_err();
1283        match err {
1284            SpecError::DuplicateId(id) => assert_eq!(id, "a"),
1285            other => panic!("expected DuplicateId, got {other:?}"),
1286        }
1287    }
1288
1289    #[test]
1290    fn from_json_accepts_three_level_nesting() {
1291        let spec = Spec::from_json(
1292            r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
1293                "root":{"type":"Card","children":["section"]},
1294                "section":{"type":"FormSection","children":["leaf"]},
1295                "leaf":{"type":"Text"}
1296            }}"#,
1297        )
1298        .unwrap();
1299        assert_eq!(spec.elements.len(), 3);
1300    }
1301
1302    #[test]
1303    fn from_json_accepts_diamond() {
1304        // A -> [B, C]; B -> D; C -> D. D is visited twice via different parents
1305        // but there's no cycle. Depth is 3 (A=1, B/C=2, D=3).
1306        let spec = Spec::from_json(
1307            r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
1308                "A":{"type":"Card","children":["B","C"]},
1309                "B":{"type":"Card","children":["D"]},
1310                "C":{"type":"Card","children":["D"]},
1311                "D":{"type":"Text"}
1312            }}"#,
1313        )
1314        .unwrap();
1315        assert_eq!(spec.elements.len(), 4);
1316    }
1317
1318    #[test]
1319    fn from_json_wraps_syntax_errors() {
1320        // Not a panic — malformed JSON becomes SpecError::Json.
1321        let err = Spec::from_json("{ this is not json ").unwrap_err();
1322        assert!(matches!(err, SpecError::Json(_)), "got {err:?}");
1323    }
1324
1325    #[test]
1326    fn builder_rejects_forward_ref_without_target() {
1327        // Parent references a child that was never added.
1328        let err = Spec::builder()
1329            .element("root", Element::new("Card").child("ghost"))
1330            .build()
1331            .unwrap_err();
1332        match err {
1333            SpecError::DanglingChild { element, child } => {
1334                assert_eq!(element, "root");
1335                assert_eq!(child, "ghost");
1336            }
1337            other => panic!("expected DanglingChild, got {other:?}"),
1338        }
1339    }
1340
1341    #[test]
1342    fn builder_data_payload_survives_round_trip() {
1343        let spec = Spec::builder()
1344            .element("a", Element::new("Text"))
1345            .data(json!({"user":{"name":"Alice"}}))
1346            .build()
1347            .unwrap();
1348        let json = serde_json::to_string(&spec).unwrap();
1349        let back = Spec::from_json(&json).unwrap();
1350        assert_eq!(back.data, json!({"user":{"name":"Alice"}}));
1351    }
1352
1353    #[test]
1354    fn element_omits_optional_fields_when_absent() {
1355        let spec = Spec::builder()
1356            .element("bare", Element::new("Text"))
1357            .build()
1358            .unwrap();
1359        let json = serde_json::to_string(&spec).unwrap();
1360        // children is empty -> skipped; props is null -> skipped; action/visible absent -> skipped.
1361        assert!(!json.contains("children"));
1362        assert!(!json.contains("props"));
1363        assert!(!json.contains("action"));
1364        assert!(!json.contains("visible"));
1365    }
1366
1367    #[test]
1368    fn merge_data_handler_wins() {
1369        let spec = Spec::builder()
1370            .element("a", Element::new("Text"))
1371            .data(json!({"a": 1, "b": 2}))
1372            .build()
1373            .unwrap();
1374        let merged = spec.merge_data(json!({"b": 99, "c": 3}));
1375        assert_eq!(merged.data, json!({"a": 1, "b": 99, "c": 3}));
1376    }
1377
1378    #[test]
1379    fn merge_data_ignores_non_object() {
1380        // Null is a no-op (allowed by debug_assert).
1381        let spec = Spec::builder()
1382            .element("a", Element::new("Text"))
1383            .data(json!({"a": 1}))
1384            .build()
1385            .unwrap();
1386        let merged = spec.merge_data(Value::Null);
1387        assert_eq!(merged.data, json!({"a": 1}));
1388        // Array / String / Number variants would trip debug_assert in debug mode,
1389        // so we exercise only the Null no-op here. Production behavior for those
1390        // variants is covered by inspection — they fall through to the `if let
1391        // Some(obj) = handler_data.as_object()` guard and are ignored.
1392    }
1393
1394    #[test]
1395    fn merge_data_initializes_null_data() {
1396        let spec = Spec::builder()
1397            .element("a", Element::new("Text"))
1398            .build() // no .data(...) call → spec.data is Value::Null
1399            .unwrap();
1400        assert_eq!(spec.data, Value::Null);
1401        let merged = spec.merge_data(json!({"k": "v"}));
1402        assert_eq!(merged.data, json!({"k": "v"}));
1403    }
1404
1405    #[test]
1406    fn merge_data_empty_handler_no_op() {
1407        let spec = Spec::builder()
1408            .element("a", Element::new("Text"))
1409            .data(json!({"a": 1}))
1410            .build()
1411            .unwrap();
1412        let merged = spec.merge_data(json!({}));
1413        assert_eq!(merged.data, json!({"a": 1}));
1414    }
1415
1416    #[test]
1417    fn from_json_rejects_missing_footer_id() {
1418        let err = Spec::from_json(
1419            r#"{
1420            "$schema": "ferro-json-ui/v2",
1421            "root": "card",
1422            "elements": {
1423                "card": {
1424                    "type": "Card",
1425                    "props": {"title": "T", "footer": ["ghost"]}
1426                }
1427            }
1428        }"#,
1429        )
1430        .unwrap_err();
1431        match err {
1432            SpecError::FooterMissing {
1433                element_id,
1434                footer_id,
1435            } => {
1436                assert_eq!(element_id, "card");
1437                assert_eq!(footer_id, "ghost");
1438            }
1439            other => panic!("expected FooterMissing, got {other:?}"),
1440        }
1441    }
1442
1443    #[test]
1444    fn from_json_rejects_missing_modal_footer_id() {
1445        // validate_footer_ids walks `props.footer` for every element, including Modal.
1446        // This test pins Modal coverage explicitly even though the helper is generic.
1447        let err = Spec::from_json(
1448            r#"{
1449            "$schema": "ferro-json-ui/v2",
1450            "root": "modal",
1451            "elements": {
1452                "modal": {
1453                    "type": "Modal",
1454                    "props": {"id": "m", "title": "T", "footer": ["ghost"]}
1455                }
1456            }
1457        }"#,
1458        )
1459        .unwrap_err();
1460        match err {
1461            SpecError::FooterMissing {
1462                element_id,
1463                footer_id,
1464            } => {
1465                assert_eq!(element_id, "modal");
1466                assert_eq!(footer_id, "ghost");
1467            }
1468            other => panic!("expected FooterMissing on Modal, got {other:?}"),
1469        }
1470    }
1471
1472    #[test]
1473    fn spec_warns_duplicate_footer_child() {
1474        // D-08: duplicate footer+children entry produces a stderr warning,
1475        // but parsing must still succeed. We assert only the success path.
1476        let spec = Spec::from_json(
1477            r#"{
1478            "$schema": "ferro-json-ui/v2",
1479            "root": "card",
1480            "elements": {
1481                "card": {
1482                    "type": "Card",
1483                    "props": {"title": "T", "footer": ["btn"]},
1484                    "children": ["btn"]
1485                },
1486                "btn": {
1487                    "type": "Button",
1488                    "props": {"label": "Save"}
1489                }
1490            }
1491        }"#,
1492        )
1493        .expect("D-08 warning is non-fatal; parse must succeed");
1494        assert_eq!(spec.root, "card");
1495    }
1496
1497    #[test]
1498    fn each_directive_round_trips() {
1499        let json = serde_json::json!({"path": "/orders", "as": "order"});
1500        let parsed: EachDirective = serde_json::from_value(json.clone()).expect("decode");
1501        assert_eq!(parsed.path, "/orders");
1502        assert_eq!(parsed.as_, "order");
1503        let reserialized = serde_json::to_value(&parsed).expect("encode");
1504        assert_eq!(reserialized, json);
1505        // Confirm the wire-format uses "as", not "as_".
1506        assert!(reserialized.get("as").is_some());
1507        assert!(reserialized.get("as_").is_none());
1508    }
1509
1510    #[test]
1511    fn element_with_each_round_trips() {
1512        let json = serde_json::json!({
1513            "type": "Card",
1514            "$each": {"path": "/orders", "as": "order"},
1515            "props": {"title": "x"}
1516        });
1517        let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1518        assert!(parsed.each.is_some());
1519        let each = parsed.each.as_ref().unwrap();
1520        assert_eq!(each.path, "/orders");
1521        assert_eq!(each.as_, "order");
1522        let reserialized = serde_json::to_value(&parsed).expect("encode");
1523        assert!(reserialized.get("$each").is_some());
1524    }
1525
1526    #[test]
1527    fn element_without_each_omits_field() {
1528        // Build via Spec::builder() to mirror the canonical no-directive case.
1529        let spec = Spec::builder()
1530            .element("card", Element::new("Card").prop("title", "hello"))
1531            .build()
1532            .expect("spec is valid");
1533        let card = spec.elements.get("card").expect("card present");
1534        let json = serde_json::to_value(card).expect("encode");
1535        assert!(
1536            json.get("$each").is_none(),
1537            "expected $each to be omitted when None; got: {json}"
1538        );
1539    }
1540
1541    #[test]
1542    fn if_directive_flat_condition_round_trips() {
1543        use crate::visibility::Visibility;
1544        let json = serde_json::json!({"path": "/can_advance", "operator": "eq", "value": true});
1545        let parsed: Visibility = serde_json::from_value(json.clone()).expect("decode");
1546        match &parsed {
1547            Visibility::Condition(c) => {
1548                assert_eq!(c.path, "/can_advance");
1549                assert_eq!(c.value, Some(serde_json::json!(true)));
1550            }
1551            _ => panic!("expected flat Condition variant, got: {parsed:?}"),
1552        }
1553        let reserialized = serde_json::to_value(&parsed).expect("encode");
1554        assert!(reserialized.get("path").is_some());
1555        assert!(reserialized.get("operator").is_some());
1556    }
1557
1558    #[test]
1559    fn element_with_if_flat_round_trips() {
1560        let json = serde_json::json!({
1561            "type": "Button",
1562            "$if": {"path": "/can_advance", "operator": "eq", "value": true},
1563            "props": {"label": "x"}
1564        });
1565        let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1566        assert!(parsed.if_.is_some());
1567        let reserialized = serde_json::to_value(&parsed).expect("encode");
1568        assert!(reserialized.get("$if").is_some());
1569    }
1570
1571    #[test]
1572    fn element_with_if_compound_round_trips() {
1573        use crate::visibility::Visibility;
1574        let json = serde_json::json!({
1575            "type": "Button",
1576            "$if": {"and": [
1577                {"path": "/a", "operator": "exists"},
1578                {"path": "/b", "operator": "eq", "value": true}
1579            ]},
1580            "props": {"label": "x"}
1581        });
1582        let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
1583        match parsed.if_.as_ref() {
1584            Some(Visibility::And { and }) => assert_eq!(and.len(), 2),
1585            other => panic!("expected And variant, got: {other:?}"),
1586        }
1587        let reserialized = serde_json::to_value(&parsed).expect("encode");
1588        assert!(reserialized.get("$if").and_then(|v| v.get("and")).is_some());
1589    }
1590
1591    #[test]
1592    fn element_without_if_omits_field() {
1593        let spec = Spec::builder()
1594            .element("btn", Element::new("Button").prop("label", "ok"))
1595            .build()
1596            .expect("spec is valid");
1597        let btn = spec.elements.get("btn").expect("btn present");
1598        let json = serde_json::to_value(btn).expect("encode");
1599        assert!(
1600            json.get("$if").is_none(),
1601            "expected $if to be omitted when None; got: {json}"
1602        );
1603    }
1604
1605    // -----------------------------------------------------------------------
1606    // Directive validator tests (Plan 04)
1607    // -----------------------------------------------------------------------
1608
1609    #[test]
1610    fn validate_each_path_not_array_fires() {
1611        let json = r#"{
1612            "$schema": "ferro-json-ui/v2",
1613            "root": "list",
1614            "elements": {
1615                "list": {
1616                    "type": "Card",
1617                    "$each": {"path": "/orders", "as": "order"},
1618                    "props": {}
1619                }
1620            },
1621            "data": {"orders": "not-an-array"}
1622        }"#;
1623        let err = Spec::from_json(json).expect_err("validator must reject non-array $each.path");
1624        match err {
1625            SpecError::EachPathNotArray { element_id, path } => {
1626                assert_eq!(element_id, "list");
1627                assert_eq!(path, "/orders");
1628            }
1629            other => panic!("expected EachPathNotArray, got: {other:?}"),
1630        }
1631    }
1632
1633    #[test]
1634    fn validate_each_path_not_array_skipped_when_data_null() {
1635        // Same spec but data is null (absent) — validator is best-effort and skips.
1636        let json = r#"{
1637            "$schema": "ferro-json-ui/v2",
1638            "root": "list",
1639            "elements": {
1640                "list": {
1641                    "type": "Card",
1642                    "$each": {"path": "/orders", "as": "order"},
1643                    "props": {}
1644                }
1645            }
1646        }"#;
1647        // No data key → spec.data is Value::Null → no EachPathNotArray.
1648        Spec::from_json(json).expect("no error when data is null");
1649    }
1650
1651    #[test]
1652    fn validate_each_as_reserved_data_rejected() {
1653        let json = r#"{
1654            "$schema": "ferro-json-ui/v2",
1655            "root": "list",
1656            "elements": {
1657                "list": {
1658                    "type": "Card",
1659                    "$each": {"path": "/items", "as": "data"},
1660                    "props": {}
1661                }
1662            }
1663        }"#;
1664        let err = Spec::from_json(json).expect_err("'data' is a reserved name");
1665        match err {
1666            SpecError::EachAsReservedName { element_id, name } => {
1667                assert_eq!(element_id, "list");
1668                assert_eq!(name, "data");
1669            }
1670            other => panic!("expected EachAsReservedName, got: {other:?}"),
1671        }
1672    }
1673
1674    #[test]
1675    fn validate_each_as_reserved_root_rejected() {
1676        let json = r#"{
1677            "$schema": "ferro-json-ui/v2",
1678            "root": "list",
1679            "elements": {
1680                "list": {
1681                    "type": "Card",
1682                    "$each": {"path": "/items", "as": "root"},
1683                    "props": {}
1684                }
1685            }
1686        }"#;
1687        let err = Spec::from_json(json).expect_err("'root' is a reserved name");
1688        match err {
1689            SpecError::EachAsReservedName { element_id, name } => {
1690                assert_eq!(element_id, "list");
1691                assert_eq!(name, "root");
1692            }
1693            other => panic!("expected EachAsReservedName, got: {other:?}"),
1694        }
1695    }
1696
1697    #[test]
1698    fn validate_each_as_non_reserved_accepted() {
1699        // "order" and "row" are not reserved — spec must parse OK.
1700        let json_order = r#"{
1701            "$schema": "ferro-json-ui/v2",
1702            "root": "list",
1703            "elements": {
1704                "list": {
1705                    "type": "Card",
1706                    "$each": {"path": "/items", "as": "order"},
1707                    "props": {}
1708                }
1709            },
1710            "data": {"items": []}
1711        }"#;
1712        Spec::from_json(json_order).expect("'order' is not reserved");
1713
1714        let json_row = r#"{
1715            "$schema": "ferro-json-ui/v2",
1716            "root": "list",
1717            "elements": {
1718                "list": {
1719                    "type": "Card",
1720                    "$each": {"path": "/items", "as": "row"},
1721                    "props": {}
1722                }
1723            },
1724            "data": {"items": []}
1725        }"#;
1726        Spec::from_json(json_row).expect("'row' is not reserved");
1727    }
1728
1729    #[test]
1730    fn validate_if_path_missing_fires() {
1731        let json = r#"{
1732            "$schema": "ferro-json-ui/v2",
1733            "root": "btn",
1734            "elements": {
1735                "btn": {
1736                    "type": "Button",
1737                    "$if": {"path": "/missing_key", "operator": "eq", "value": true},
1738                    "props": {"label": "Go"}
1739                }
1740            },
1741            "data": {"other": true}
1742        }"#;
1743        let err = Spec::from_json(json).expect_err("missing $if.path must error");
1744        match err {
1745            SpecError::IfPathMissing { element_id, path } => {
1746                assert_eq!(element_id, "btn");
1747                assert_eq!(path, "/missing_key");
1748            }
1749            other => panic!("expected IfPathMissing, got: {other:?}"),
1750        }
1751    }
1752
1753    #[test]
1754    fn validate_if_path_missing_skipped_when_data_null() {
1755        // Same spec but data is null — validator is best-effort and skips.
1756        let json = r#"{
1757            "$schema": "ferro-json-ui/v2",
1758            "root": "btn",
1759            "elements": {
1760                "btn": {
1761                    "type": "Button",
1762                    "$if": {"path": "/missing_key", "operator": "eq", "value": true},
1763                    "props": {"label": "Go"}
1764                }
1765            }
1766        }"#;
1767        Spec::from_json(json).expect("no error when data is null");
1768    }
1769
1770    #[test]
1771    fn validate_nested_each_rejected() {
1772        // Element A has $each; A's child B also has $each — nested, must fail.
1773        // B is a grandchild of A (A -> mid -> B) to exercise the transitive walk.
1774        let json = r#"{
1775            "$schema": "ferro-json-ui/v2",
1776            "root": "A",
1777            "elements": {
1778                "A": {
1779                    "type": "Card",
1780                    "$each": {"path": "/items", "as": "item"},
1781                    "children": ["mid"]
1782                },
1783                "mid": {
1784                    "type": "Section",
1785                    "children": ["B"]
1786                },
1787                "B": {
1788                    "type": "Card",
1789                    "$each": {"path": "/other_items", "as": "other"},
1790                    "props": {}
1791                }
1792            }
1793        }"#;
1794        let err = Spec::from_json(json).expect_err("nested $each must be rejected");
1795        match err {
1796            SpecError::NestedEach { outer, inner } => {
1797                assert_eq!(outer, "A");
1798                assert_eq!(inner, "B");
1799            }
1800            other => panic!("expected NestedEach, got: {other:?}"),
1801        }
1802    }
1803
1804    #[test]
1805    fn validate_mismatched_each_child_rejected() {
1806        // A has $each over /items; direct child B has $each over /different — mismatch.
1807        let json = r#"{
1808            "$schema": "ferro-json-ui/v2",
1809            "root": "A",
1810            "elements": {
1811                "A": {
1812                    "type": "Card",
1813                    "$each": {"path": "/items", "as": "item"},
1814                    "children": ["B"]
1815                },
1816                "B": {
1817                    "type": "Text",
1818                    "$each": {"path": "/different_items", "as": "item"}
1819                }
1820            }
1821        }"#;
1822        let err = Spec::from_json(json).expect_err("mismatched $each child must be rejected");
1823        match err {
1824            SpecError::MismatchedEach {
1825                parent,
1826                parent_path,
1827                child,
1828                child_path,
1829            } => {
1830                assert_eq!(parent, "A");
1831                assert_eq!(parent_path, "/items");
1832                assert_eq!(child, "B");
1833                assert_eq!(child_path, "/different_items");
1834            }
1835            other => panic!("expected MismatchedEach, got: {other:?}"),
1836        }
1837    }
1838
1839    #[test]
1840    fn validate_correlated_each_child_accepted() {
1841        // A and its direct child B both have $each with SAME (path, as) — correlated siblings, valid.
1842        let json = r#"{
1843            "$schema": "ferro-json-ui/v2",
1844            "root": "A",
1845            "elements": {
1846                "A": {
1847                    "type": "Card",
1848                    "$each": {"path": "/items", "as": "item"},
1849                    "children": ["B"]
1850                },
1851                "B": {
1852                    "type": "Text",
1853                    "$each": {"path": "/items", "as": "item"}
1854                }
1855            },
1856            "data": {"items": []}
1857        }"#;
1858        Spec::from_json(json).expect("correlated $each children with same (path, as) are valid");
1859    }
1860
1861    // -----------------------------------------------------------------------
1862    // NestedElement / element_nested / flatten_nested tests (Plan 05 D-06/D-07)
1863    // -----------------------------------------------------------------------
1864
1865    #[test]
1866    fn nested_element_builder_basics() {
1867        let el = NestedElement::new("Card")
1868            .prop("title", "x")
1869            .build_for_test();
1870        assert_eq!(el.type_name, "Card");
1871        assert_eq!(el.props.get("title").and_then(|v| v.as_str()), Some("x"));
1872        assert!(el.children.is_empty());
1873        assert!(el.action.is_none());
1874        assert!(el.visible.is_none());
1875    }
1876
1877    #[test]
1878    fn nested_builder_flattens_one_level() {
1879        let spec = Spec::builder()
1880            .element_nested(
1881                "root",
1882                NestedElement::new("Card").child(NestedElement::new("Text").prop("content", "hi")),
1883            )
1884            .build()
1885            .expect("spec is valid");
1886        assert_eq!(spec.root, "root");
1887        assert_eq!(spec.elements.len(), 2);
1888        let root_el = spec.elements.get("root").expect("root present");
1889        assert_eq!(root_el.children, vec!["root-0".to_string()]);
1890        let child = spec.elements.get("root-0").expect("auto-id child present");
1891        assert_eq!(child.type_name, "Text");
1892        assert_eq!(
1893            child.props.get("content").and_then(|v| v.as_str()),
1894            Some("hi")
1895        );
1896    }
1897
1898    #[test]
1899    fn nested_builder_accepts_depth_three() {
1900        // root > section > text — three-deep, well within MAX_NESTING_DEPTH=5.
1901        let spec = Spec::builder()
1902            .element_nested(
1903                "root",
1904                NestedElement::new("Screen").child(
1905                    NestedElement::new("Section")
1906                        .child(NestedElement::new("Text").prop("content", "leaf")),
1907                ),
1908            )
1909            .build()
1910            .expect("three levels at depth limit must be valid");
1911        assert_eq!(spec.elements.len(), 3);
1912        let root_el = spec.elements.get("root").expect("root");
1913        assert_eq!(root_el.children, vec!["root-0".to_string()]);
1914        let section = spec.elements.get("root-0").expect("section");
1915        assert_eq!(section.type_name, "Section");
1916        assert_eq!(section.children, vec!["root-0-0".to_string()]);
1917        let leaf = spec.elements.get("root-0-0").expect("leaf");
1918        assert_eq!(leaf.type_name, "Text");
1919        assert!(leaf.children.is_empty());
1920    }
1921
1922    #[test]
1923    fn nested_builder_accepts_depth_sixteen() {
1924        // root > 15 nested containers > leaf — sixteen levels, exactly at MAX_NESTING_DEPTH=16.
1925        let spec = Spec::builder()
1926            .element_nested(
1927                "root",
1928                NestedElement::new("Screen").child(
1929                    NestedElement::new("Grid").child(
1930                        NestedElement::new("Card").child(
1931                            NestedElement::new("Row").child(
1932                                NestedElement::new("Column").child(
1933                                    NestedElement::new("Section").child(
1934                                        NestedElement::new("Container").child(
1935                                            NestedElement::new("Container").child(
1936                                                NestedElement::new("Container").child(
1937                                                    NestedElement::new("Container").child(
1938                                                        NestedElement::new("Container").child(
1939                                                            NestedElement::new("Container").child(
1940                                                                NestedElement::new("Container").child(
1941                                                                    NestedElement::new("Container").child(
1942                                                                        NestedElement::new("Container").child(
1943                                                                            NestedElement::new("Text")
1944                                                                                .prop("content", "leaf"),
1945                                                                        ),
1946                                                                    ),
1947                                                                ),
1948                                                            ),
1949                                                        ),
1950                                                    ),
1951                                                ),
1952                                            ),
1953                                        ),
1954                                    ),
1955                                ),
1956                            ),
1957                        ),
1958                    ),
1959                ),
1960            )
1961            .build()
1962            .expect("sixteen levels at depth limit must be valid");
1963        assert!(spec.elements.contains_key("root"));
1964    }
1965
1966    #[test]
1967    fn nested_builder_rejects_depth_seventeen() {
1968        // root > 16 nested containers > leaf — seventeen levels, one past MAX_NESTING_DEPTH=16.
1969        let err = Spec::builder()
1970            .element_nested(
1971                "root",
1972                NestedElement::new("Screen").child(
1973                    NestedElement::new("Grid").child(
1974                        NestedElement::new("Card").child(
1975                            NestedElement::new("Row").child(
1976                                NestedElement::new("Column").child(
1977                                    NestedElement::new("Section").child(
1978                                        NestedElement::new("Container").child(
1979                                            NestedElement::new("Container").child(
1980                                                NestedElement::new("Container").child(
1981                                                    NestedElement::new("Container").child(
1982                                                        NestedElement::new("Container").child(
1983                                                            NestedElement::new("Container").child(
1984                                                                NestedElement::new("Container").child(
1985                                                                    NestedElement::new("Container").child(
1986                                                                        NestedElement::new("Container").child(
1987                                                                            NestedElement::new("Column").child(
1988                                                                                NestedElement::new("Text")
1989                                                                                    .prop("content", "too deep"),
1990                                                                            ),
1991                                                                        ),
1992                                                                    ),
1993                                                                ),
1994                                                            ),
1995                                                        ),
1996                                                    ),
1997                                                ),
1998                                            ),
1999                                        ),
2000                                    ),
2001                                ),
2002                            ),
2003                        ),
2004                    ),
2005                ),
2006            )
2007            .build()
2008            .expect_err("seventeen levels must exceed the depth limit");
2009        assert!(
2010            matches!(err, SpecError::DepthExceeded { .. }),
2011            "expected DepthExceeded, got {err:?}"
2012        );
2013    }
2014
2015    #[test]
2016    fn nested_builder_auto_ids_match_position() {
2017        let spec = Spec::builder()
2018            .element_nested(
2019                "parent",
2020                NestedElement::new("Row")
2021                    .child(NestedElement::new("ColA"))
2022                    .child(NestedElement::new("ColB"))
2023                    .child(NestedElement::new("ColC")),
2024            )
2025            .build()
2026            .expect("spec with 3 siblings is valid");
2027        assert_eq!(spec.elements.len(), 4);
2028        let parent = spec.elements.get("parent").expect("parent");
2029        assert_eq!(
2030            parent.children,
2031            vec![
2032                "parent-0".to_string(),
2033                "parent-1".to_string(),
2034                "parent-2".to_string(),
2035            ]
2036        );
2037        assert_eq!(
2038            spec.elements.get("parent-0").expect("child-0").type_name,
2039            "ColA"
2040        );
2041        assert_eq!(
2042            spec.elements.get("parent-1").expect("child-1").type_name,
2043            "ColB"
2044        );
2045        assert_eq!(
2046            spec.elements.get("parent-2").expect("child-2").type_name,
2047            "ColC"
2048        );
2049    }
2050
2051    #[test]
2052    fn nested_builder_root_set_from_first_call() {
2053        let spec = Spec::builder()
2054            .element_nested("first", NestedElement::new("Screen"))
2055            .element_nested("second", NestedElement::new("Screen"))
2056            .build()
2057            .expect("multi-root-call spec");
2058        // root is set from the FIRST element_nested call.
2059        assert_eq!(spec.root, "first");
2060    }
2061
2062    #[test]
2063    fn nested_builder_preserves_action_and_visible() {
2064        use crate::action::Action;
2065        use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
2066        let action = Action::new("home.index");
2067        let vis = Visibility::Condition(VisibilityCondition {
2068            path: "/enabled".to_string(),
2069            operator: VisibilityOperator::Exists,
2070            value: None,
2071        });
2072        let spec = Spec::builder()
2073            .element_nested(
2074                "btn",
2075                NestedElement::new("Button")
2076                    .action(action.clone())
2077                    .visible(vis.clone()),
2078            )
2079            .build()
2080            .expect("spec with action+visible");
2081        let el = spec.elements.get("btn").expect("btn present");
2082        assert!(el.action.is_some(), "action must be preserved");
2083        assert!(el.visible.is_some(), "visible must be preserved");
2084    }
2085
2086    #[test]
2087    fn nested_builder_and_flat_builder_produce_equivalent_specs() {
2088        let nested = Spec::builder()
2089            .element_nested(
2090                "root",
2091                NestedElement::new("Card")
2092                    .prop("title", "T")
2093                    .child(NestedElement::new("Text").prop("content", "hi")),
2094            )
2095            .build()
2096            .expect("nested spec valid");
2097
2098        let flat = Spec::builder()
2099            .element(
2100                "root",
2101                Element::new("Card").prop("title", "T").child("root-0"),
2102            )
2103            .element("root-0", Element::new("Text").prop("content", "hi"))
2104            .build()
2105            .expect("flat spec valid");
2106
2107        let nested_json = serde_json::to_value(&nested).unwrap();
2108        let flat_json = serde_json::to_value(&flat).unwrap();
2109        assert_eq!(nested_json, flat_json);
2110    }
2111
2112    #[test]
2113    fn validate_directives_called_between_no_dangling_and_cycle() {
2114        // Assert by code structure: validate_structure contains the literal call sequence
2115        // validate_no_dangling → validate_directives → detect_cycle.
2116        let src = include_str!("spec.rs");
2117        let validate_section = src
2118            .split("fn validate_structure")
2119            .nth(1)
2120            .expect("validate_structure body present");
2121        let body_end = validate_section
2122            .find("\nfn ")
2123            .unwrap_or(validate_section.len());
2124        let body = &validate_section[..body_end];
2125        let pos_no_dangling = body.find("validate_no_dangling").expect("no_dangling call");
2126        let pos_directives = body.find("validate_directives").expect("directives call");
2127        let pos_cycle = body.find("detect_cycle").expect("cycle call");
2128        assert!(
2129            pos_no_dangling < pos_directives,
2130            "validate_directives must be called AFTER validate_no_dangling"
2131        );
2132        assert!(
2133            pos_directives < pos_cycle,
2134            "validate_directives must be called BEFORE detect_cycle"
2135        );
2136    }
2137
2138    // -----------------------------------------------------------------------
2139    // TitleBinding round-trip tests (D-12)
2140    // -----------------------------------------------------------------------
2141
2142    #[test]
2143    fn spec_title_literal_roundtrip() {
2144        let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":"Hello"}"#;
2145        let spec: Spec = serde_json::from_str(json).expect("parses");
2146        match spec.title.as_ref().unwrap() {
2147            TitleBinding::Literal(s) => assert_eq!(s, "Hello"),
2148            other => panic!("expected Literal, got {other:?}"),
2149        }
2150        let back = serde_json::to_string(&spec).unwrap();
2151        assert!(back.contains(r#""title":"Hello""#), "got: {back}");
2152    }
2153
2154    #[test]
2155    fn spec_title_binding_roundtrip() {
2156        let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"$data":"/page_title"}}"#;
2157        let spec: Spec = serde_json::from_str(json).expect("parses");
2158        match spec.title.as_ref().unwrap() {
2159            TitleBinding::Binding(DataRef { data }) => assert_eq!(data, "/page_title"),
2160            other => panic!("expected Binding, got {other:?}"),
2161        }
2162        let back = serde_json::to_string(&spec).unwrap();
2163        assert!(back.contains(r#""$data":"/page_title""#), "got: {back}");
2164    }
2165
2166    #[test]
2167    fn spec_title_absent() {
2168        let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}}}"#;
2169        let spec: Spec = serde_json::from_str(json).expect("parses");
2170        assert!(spec.title.is_none());
2171    }
2172
2173    #[test]
2174    fn spec_title_invalid_shape_rejected() {
2175        // Neither a string literal nor a {$data:...} object — must fail to parse.
2176        let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"foo":"bar"}}"#;
2177        let result: Result<Spec, _> = serde_json::from_str(json);
2178        assert!(
2179            result.is_err(),
2180            "expected parse failure for {{foo:bar}} title shape"
2181        );
2182    }
2183
2184    #[test]
2185    fn design_meta_valid_round_trip() {
2186        let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}},"design":{"intent":"browse","allow":["prefer-data-table"]}}"#;
2187        let spec = Spec::from_json(json).expect("parses");
2188        let design = spec.design.as_ref().expect("design present");
2189        assert_eq!(design.intent.as_deref(), Some("browse"));
2190        assert_eq!(design.allow, vec!["prefer-data-table"]);
2191        let serialized = serde_json::to_string(&spec).unwrap();
2192        let back = Spec::from_json(&serialized).expect("re-parses");
2193        assert_eq!(spec, back);
2194    }
2195
2196    #[test]
2197    fn design_meta_unknown_intent_parses_without_error() {
2198        // D-02: invalid intent is a String, never a parse failure.
2199        let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}},"design":{"intent":"totally-made-up"}}"#;
2200        let spec = Spec::from_json(json).expect("unknown intent must not fail parse");
2201        let design = spec.design.as_ref().expect("design present");
2202        assert_eq!(design.intent.as_deref(), Some("totally-made-up"));
2203    }
2204
2205    #[test]
2206    fn design_meta_absent_omitted_from_serialized_output() {
2207        let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text"}}}"#;
2208        let spec = Spec::from_json(json).expect("parses");
2209        assert!(spec.design.is_none(), "design should be None");
2210        let serialized = serde_json::to_string(&spec).unwrap();
2211        assert!(!serialized.contains("design"), "design key must be absent from output, got: {serialized}");
2212    }
2213
2214    #[test]
2215    fn each_builder_round_trip() {
2216        let el = Element::new("Tile")
2217            .each("/data/items", "p")
2218            .build();
2219        // The directive is set.
2220        let directive = el.each.as_ref().expect("each must be Some");
2221        assert_eq!(directive.path, "/data/items");
2222        assert_eq!(directive.as_, "p");
2223        // Serializes to {"$each": {"path": "...", "as": "..."}}.
2224        let v = serde_json::to_value(&el).expect("serialize");
2225        assert_eq!(v["$each"]["path"], "/data/items", "path mismatch in JSON");
2226        assert_eq!(v["$each"]["as"], "p", "as mismatch in JSON (as_ must serialize as `as`)");
2227        // Round-trip: deserialize back and assert the directive is preserved.
2228        let back: Element = serde_json::from_value(v).expect("deserialize");
2229        assert_eq!(back.each, el.each, "each directive must survive round-trip");
2230    }
2231
2232    #[test]
2233    fn fill_viewport_builder() {
2234        // (a) explicit true threads through build().
2235        let spec_true = Spec::builder()
2236            .element("root", Element::new("Container"))
2237            .fill_viewport(true)
2238            .build()
2239            .expect("build succeeds");
2240        assert!(spec_true.fill_viewport, "fill_viewport must be true when set");
2241        // (b) default (no call) remains false.
2242        let spec_default = Spec::builder()
2243            .element("root", Element::new("Container"))
2244            .build()
2245            .expect("build succeeds");
2246        assert!(!spec_default.fill_viewport, "fill_viewport must default to false");
2247    }
2248}