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