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