Skip to main content

ferro_json_ui/render/
mod.rs

1//! Renders a `Spec` to HTML.
2//!
3//! Walks `spec.elements` by ID starting at `spec.root`, dispatches per-element
4//! by `type_name` against `BUILTIN_TYPES` (or the plugin registry for any
5//! type name not in that list), and lets each container recurse via
6//! `render_element` for its child IDs. The renderer is infallible — every
7//! failure path (missing ID, decode error, depth overflow) emits an HTML
8//! comment and returns an empty string for the offending element rather than
9//! panicking.
10//!
11//! Per-component bodies live in:
12//! - `render/atoms.rs` — leaf renderers
13//! - `render/containers.rs` — multi-child layout components
14//! - `render/form.rs` — `Form`, `Input`, `Select`, `Checkbox`, `Switch`,
15//!   `CheckboxList`
16//! - `render/data.rs` — `Table`, `DataTable`
17
18use serde_json::Value;
19use std::collections::HashSet;
20
21use crate::plugin::{collect_plugin_assets, with_plugin, Asset};
22use crate::spec::{Spec, MAX_NESTING_DEPTH};
23
24pub(crate) mod atoms;
25pub mod classes;
26pub(crate) mod containers;
27pub(crate) mod data;
28pub(crate) mod form;
29
30/// Plugin-asset bundle returned by `render_spec_to_html_with_plugins`.
31pub struct RenderResult {
32    pub html: String,
33    pub css_head: String,
34    pub scripts: String,
35}
36
37/// Single source of truth for the built-in element type names recognized
38/// by the renderer. Plugins cannot register a type name that shadows an entry
39/// here — if `type_name` matches an entry, the dispatch match arm wins
40/// regardless of plugin registry contents.
41///
42/// Order matches the dispatch match below for reviewability. Adding a new
43/// built-in requires updating BOTH this list AND the dispatch arm.
44pub(crate) const BUILTIN_TYPES: &[&str] = &[
45    // Leaves (atoms.rs)
46    "Text",
47    "Button",
48    "Badge",
49    "Alert",
50    "Separator",
51    "Progress",
52    "Avatar",
53    "Image",
54    "Skeleton",
55    "Breadcrumb",
56    "Pagination",
57    "DescriptionList",
58    "EmptyState",
59    "StatCard",
60    "Checklist",
61    "Toast",
62    "NotificationDropdown",
63    "Sidebar",
64    "Header",
65    "CalendarCell",
66    "ActionCard",
67    "Tile",
68    "FilterTabs",
69    "RawHtml",
70    "StreamText",
71    "QuantityStepper",
72    "Numpad",
73    // Containers (containers.rs)
74    "Card",
75    "Modal",
76    "Tabs",
77    "KanbanBoard",
78    "PageHeader",
79    "DetailPage",
80    "Grid",
81    "TileGrid",
82    "Collapsible",
83    "FormSection",
84    "ButtonGroup",
85    "SegmentedControl",
86    "SidebarLayout",
87    "ActionGroup",
88    "SelectionPanel",
89    // Live reactive primitive — ferro-projection per-key snapshot binding (Phase 260)
90    "LiveFragment",
91    // Form controls (form.rs)
92    "Form",
93    "Input",
94    "Select",
95    "Checkbox",
96    "Switch",
97    "CheckboxList",
98    "CheckboxGroup",
99    // Data displays (data.rs)
100    "Table",
101    "DataTable",
102    "MediaCardGrid",
103];
104
105/// Renders an entire `Spec` to a complete HTML response body. Walks from
106/// `spec.root` outward, escaping text content and substituting data bindings
107/// via JSON Pointer. Top-level output is wrapped in a `flex-wrap` container;
108/// the renderer does not emit `<html>` / `<head>` / `<body>` tags — the
109/// layout system supplies those. Always returns a `String`; never panics and
110/// never returns `Result`.
111pub fn render_spec_to_html(spec: &Spec, data: &Value) -> String {
112    let body = render_element(&spec.root, spec, data, 1);
113    let body_or_root_hidden = if body.is_empty() && spec_root_was_hidden(spec, data) {
114        String::from("<!-- ferro-json-ui: root hidden -->")
115    } else {
116        body
117    };
118    format!(
119        "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">{body_or_root_hidden}</div>"
120    )
121}
122
123/// Plugin-aware variant. Walks `spec.elements` to collect plugin type names,
124/// then asks the registry for their CSS/JS asset URLs. Also collects built-in
125/// init scripts (e.g. the `StreamText` EventSource wiring) and merges them
126/// into the scripts output even when no plugins are present.
127pub fn render_spec_to_html_with_plugins(spec: &Spec, data: &Value) -> RenderResult {
128    let html = render_spec_to_html(spec, data);
129    let builtin_scripts = collect_builtin_init_scripts(spec);
130    let plugin_types = collect_plugin_types(spec);
131    if plugin_types.is_empty() && builtin_scripts.is_empty() {
132        return RenderResult {
133            html,
134            css_head: String::new(),
135            scripts: String::new(),
136        };
137    }
138    let type_names: Vec<String> = plugin_types.into_iter().collect();
139    let assets = collect_plugin_assets(&type_names);
140    let all_init_scripts: Vec<String> = assets
141        .init_scripts
142        .iter()
143        .chain(builtin_scripts.iter())
144        .cloned()
145        .collect();
146    RenderResult {
147        html,
148        css_head: render_css_tags(&assets.css),
149        scripts: render_js_tags(&assets.js, &all_init_scripts),
150    }
151}
152
153/// The one recursive function. All dispatch, visibility, depth-guard, and
154/// diagnostic logic lives here. The per-element pipeline is:
155/// (1) depth guard, (2) ID lookup, (3) visibility check, (4) dispatch.
156pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
157    // (1) Depth tripwire. Parse-time depth is capped at `MAX_NESTING_DEPTH = 16`;
158    // this fires only for hand-mutated Specs that bypassed `Spec::from_json`.
159    // Diagnostic names the limit so future failures are legible; this is a
160    // distinct condition from cycle detection (which lives in the parse-time
161    // validator and emits `SpecError::Cycle`).
162    if depth > MAX_NESTING_DEPTH + 1 {
163        return format!(
164            "<!-- ferro-json-ui: depth limit exceeded at depth {depth} (max={MAX_NESTING_DEPTH}) — spec should have been rejected at parse time -->"
165        );
166    }
167
168    // (2) ID lookup: missing IDs surface as an HTML comment.
169    let Some(el) = spec.elements.get(id) else {
170        return format!(
171            "<!-- ferro-json-ui: element references missing id '{}' -->",
172            html_escape(id)
173        );
174    };
175
176    // (3) Visibility check. Invisible → no output, no children walked.
177    if let Some(vis) = &el.visible {
178        if !vis.evaluate(data) {
179            return String::new();
180        }
181    }
182
183    // (4) Dispatch by type_name. Default arm consults plugin registry.
184    match el.type_name.as_str() {
185        // Atoms
186        "Text" => atoms::render_text(el, spec, data, depth),
187        "Button" => atoms::render_button(el, spec, data, depth),
188        "Badge" => atoms::render_badge(el, spec, data, depth),
189        "Alert" => atoms::render_alert(el, spec, data, depth),
190        "Separator" => atoms::render_separator(el, spec, data, depth),
191        "Progress" => atoms::render_progress(el, spec, data, depth),
192        "Avatar" => atoms::render_avatar(el, spec, data, depth),
193        "Image" => atoms::render_image(el, spec, data, depth),
194        "Skeleton" => atoms::render_skeleton(el, spec, data, depth),
195        "Breadcrumb" => atoms::render_breadcrumb(el, spec, data, depth),
196        "Pagination" => atoms::render_pagination(el, spec, data, depth),
197        "DescriptionList" => atoms::render_description_list(el, spec, data, depth),
198        "EmptyState" => atoms::render_empty_state(el, spec, data, depth),
199        "StatCard" => atoms::render_stat_card(el, spec, data, depth),
200        "Checklist" => atoms::render_checklist(el, spec, data, depth),
201        "Toast" => atoms::render_toast(el, spec, data, depth),
202        "NotificationDropdown" => atoms::render_notification_dropdown(el, spec, data, depth),
203        "Sidebar" => atoms::render_sidebar(el, spec, data, depth),
204        "Header" => atoms::render_header(el, spec, data, depth),
205        "CalendarCell" => atoms::render_calendar_cell(el, spec, data, depth),
206        "ActionCard" => atoms::render_action_card(el, spec, data, depth),
207        "Tile" => atoms::render_tile(el, spec, data, depth),
208        "FilterTabs" => atoms::render_filter_tabs(el, spec, data, depth),
209        "RawHtml" => atoms::render_raw_html(el, spec, data, depth),
210        "StreamText" => atoms::render_streamtext(el, spec, data, depth),
211        "QuantityStepper" => atoms::render_quantity_stepper(el, spec, data, depth),
212        "Numpad" => atoms::render_numpad(el, spec, data, depth),
213        // Containers
214        "Card" => containers::render_card(el, spec, data, depth),
215        "Modal" => containers::render_modal(el, spec, data, depth),
216        "Tabs" => containers::render_tabs(el, spec, data, depth),
217        "KanbanBoard" => containers::render_kanban_board(el, spec, data, depth),
218        "PageHeader" => containers::render_page_header(el, spec, data, depth),
219        "DetailPage" => containers::render_detail_page(el, spec, data, depth),
220        "Grid" => containers::render_grid(el, spec, data, depth),
221        "TileGrid" => containers::render_tile_grid(el, spec, data, depth),
222        "Collapsible" => containers::render_collapsible(el, spec, data, depth),
223        "FormSection" => containers::render_form_section(el, spec, data, depth),
224        "ButtonGroup" => containers::render_button_group(el, spec, data, depth),
225        "SegmentedControl" => containers::render_segmented_control(el, spec, data, depth),
226        "SidebarLayout" => containers::render_sidebar_layout(el, spec, data, depth),
227        "ActionGroup" => containers::render_action_group(el, spec, data, depth),
228        "SelectionPanel" => containers::render_selection_panel(el, spec, data, depth),
229        "LiveFragment" => containers::render_live_fragment(el, spec, data, depth),
230        // Form controls
231        "Form" => form::render_form(el, spec, data, depth),
232        "Input" => form::render_input(el, spec, data, depth),
233        "Select" => form::render_select(el, spec, data, depth),
234        "Checkbox" => form::render_checkbox(el, spec, data, depth),
235        "Switch" => form::render_switch(el, spec, data, depth),
236        "CheckboxList" => form::render_checkbox_list(el, spec, data, depth),
237        "CheckboxGroup" => form::render_checkbox_list(el, spec, data, depth),
238        // Data displays
239        "Table" => data::render_table(el, spec, data, depth),
240        "DataTable" => data::render_data_table(el, spec, data, depth),
241        "MediaCardGrid" => data::render_media_card_grid(el, spec, data, depth),
242        // Plugin or unknown type name.
243        other => render_plugin_or_unknown(other, el, data),
244    }
245}
246
247fn render_plugin_or_unknown(type_name: &str, el: &crate::spec::Element, data: &Value) -> String {
248    match with_plugin(type_name, |p| p.render(&el.props, data)) {
249        Some(html) => html,
250        None => format!(
251            "<!-- ferro-json-ui: unknown component type '{}' -->",
252            html_escape(type_name)
253        ),
254    }
255}
256
257/// Helper: detect whether `spec.root` exists and has a visibility rule that
258/// evaluates false. Used to choose between empty body and the root-hidden
259/// diagnostic comment.
260fn spec_root_was_hidden(spec: &Spec, data: &Value) -> bool {
261    spec.elements
262        .get(&spec.root)
263        .and_then(|el| el.visible.as_ref())
264        .map(|vis| !vis.evaluate(data))
265        .unwrap_or(false)
266}
267
268/// Render only the subtree rooted at `target_id`.
269///
270/// - If `target_id == "ferro-json-ui"` (the well-known nav target): renders
271///   `spec.root` and wraps it in the standard `<div id="ferro-json-ui" …>` shell.
272/// - Otherwise: looks up `target_id` in `spec.elements` and renders that element.
273///   If the element is not found, returns an HTML comment.
274///
275/// Used by the framework fragment response path when `X-FJUI-Target` is present.
276pub fn render_subtree(target_id: &str, spec: &Spec, data: &Value) -> String {
277    if target_id == "ferro-json-ui" {
278        let content = render_element(&spec.root, spec, data, 1);
279        format!(r#"<div id="ferro-json-ui">{content}</div>"#)
280    } else if spec.elements.contains_key(target_id) {
281        render_element(target_id, spec, data, 1)
282    } else {
283        format!(
284            "<!-- ferro-json-ui: render_subtree: target '{}' not found -->",
285            html_escape(target_id)
286        )
287    }
288}
289
290/// Walks `spec.elements` and collects every plugin type name encountered
291/// (every `Element.type_name` not present in [`BUILTIN_TYPES`]). Used by the
292/// asset-collection pipeline to determine which plugin CSS/JS to inject.
293pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
294    let mut types = HashSet::new();
295    for el in spec.elements.values() {
296        if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
297            types.insert(el.type_name.clone());
298        }
299    }
300    types
301}
302
303/// Dependency-free inline EventSource wiring for `StreamText` components.
304/// Skips elements with an empty URL, appends streamed tokens as text nodes
305/// (never `innerHTML`), removes the placeholder on the first token (or on
306/// `done` for an empty stream), and closes the source on `event: done` to
307/// prevent `EventSource` auto-reconnect. Emitted at most once per page.
308const FERRO_STREAM_TEXT_INIT: &str = r#"(function(){
309  document.querySelectorAll('[data-ferro-stream-url]').forEach(function(el){
310    var url = el.dataset.ferroStreamUrl;
311    if(!url) return;
312    var src = new EventSource(url);
313    var placeholder = el.querySelector('[data-ferro-stream-placeholder]');
314    var loading = el.querySelector('[data-ferro-stream-loading]');
315    var firstToken = true;
316    src.onmessage = function(e){
317      if(firstToken){ firstToken=false; if(placeholder) placeholder.remove(); }
318      el.appendChild(document.createTextNode(e.data));
319    };
320    src.addEventListener('done', function(){
321      src.close();
322      if(placeholder) placeholder.remove();
323      if(loading) loading.remove();
324    });
325    src.onerror = function(){
326      src.close();
327      if(loading) loading.remove();
328    };
329  });
330})();"#;
331
332/// Returns the StreamText EventSource init script if the spec contains at least
333/// one `StreamText` element; otherwise an empty `Vec`. Walks `spec.elements`
334/// the same way `collect_plugin_types` does. Returns at most one entry so the
335/// script is emitted exactly once regardless of how many StreamText elements
336/// the spec contains.
337fn collect_builtin_init_scripts(spec: &Spec) -> Vec<String> {
338    let has_stream_text = spec
339        .elements
340        .values()
341        .any(|el| el.type_name == "StreamText");
342    if has_stream_text {
343        vec![FERRO_STREAM_TEXT_INIT.to_string()]
344    } else {
345        vec![]
346    }
347}
348
349/// HTML-escapes interpolated identifiers in diagnostic comments and any prop
350/// content interpolated into emitted markup. Every string that crosses from
351/// a `Spec` or `data` into the HTML output is required to pass through this
352/// function.
353pub(crate) fn html_escape(s: &str) -> String {
354    s.replace('&', "&amp;")
355        .replace('<', "&lt;")
356        .replace('>', "&gt;")
357        .replace('"', "&quot;")
358        .replace('\'', "&#x27;")
359}
360
361/// Emits one `<link rel="stylesheet" href="..." [integrity] [crossorigin]>`
362/// tag per CSS [`Asset`]. URLs and attribute values pass through
363/// [`html_escape`]; `Asset.crossorigin` is an `Option<String>` (e.g.
364/// `Some("anonymous")`) — emitted when present.
365pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
366    let mut out = String::new();
367    for asset in assets {
368        out.push_str("<link rel=\"stylesheet\" href=\"");
369        out.push_str(&html_escape(&asset.url));
370        out.push('"');
371        if let Some(integrity) = &asset.integrity {
372            out.push_str(" integrity=\"");
373            out.push_str(&html_escape(integrity));
374            out.push('"');
375        }
376        if let Some(co) = &asset.crossorigin {
377            out.push_str(" crossorigin=\"");
378            out.push_str(&html_escape(co));
379            out.push('"');
380        }
381        out.push_str(">\n");
382    }
383    out
384}
385
386/// Emits one `<script src="..." [integrity] [crossorigin]></script>` tag per
387/// JS [`Asset`], followed by one `<script>{init}</script>` tag per registered
388/// plugin init script (in registration order). URLs and attribute values
389/// pass through [`html_escape`].
390pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
391    let mut out = String::new();
392    for asset in assets {
393        out.push_str("<script src=\"");
394        out.push_str(&html_escape(&asset.url));
395        out.push('"');
396        if let Some(integrity) = &asset.integrity {
397            out.push_str(" integrity=\"");
398            out.push_str(&html_escape(integrity));
399            out.push('"');
400        }
401        if let Some(co) = &asset.crossorigin {
402            out.push_str(" crossorigin=\"");
403            out.push_str(&html_escape(co));
404            out.push('"');
405        }
406        out.push_str("></script>\n");
407    }
408    for init in init_scripts {
409        out.push_str("<script>");
410        out.push_str(init);
411        out.push_str("</script>\n");
412    }
413    out
414}
415
416#[cfg(test)]
417mod tests {
418    // Walker-level tests live in this module. Per-component HTML emission tests
419    // live in atoms/containers/form/data submodules.
420    use super::*;
421    use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
422    use crate::spec::{Element, Spec};
423    use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
424    use serde_json::json;
425
426    /// Construct an `Element` directly from its public fields. Used when tests
427    /// need to bypass the parse-time structural validator (e.g. for
428    /// dangling-child or cycle scenarios).
429    fn mk_element(type_name: &str) -> Element {
430        Element {
431            type_name: type_name.to_string(),
432            props: Value::Null,
433            children: Vec::new(),
434            action: None,
435            visible: None,
436            each: None,
437            if_: None,
438        }
439    }
440
441    /// Build a `Spec` whose elements map is overwritten post-build to bypass
442    /// the parse-time structural validator. This is ONLY for testing the
443    /// walker's defense-in-depth guards on hand-mutated specs.
444    fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
445        // Build a minimal valid spec through the normal builder so we get a
446        // correctly-initialized Spec shell; then overwrite root + elements.
447        let mut spec = Spec::builder()
448            .element("__tmp__", Element::new("Text"))
449            .build()
450            .expect("builder accepts trivial well-formed spec");
451        spec.root = root.to_string();
452        spec.elements.clear();
453        for (id, el) in elements {
454            spec.elements.insert(id.to_string(), el);
455        }
456        spec
457    }
458
459    #[test]
460    fn walker_unknown_type_emits_diagnostic() {
461        let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
462        let html = render_spec_to_html(&spec, &json!({}));
463        assert!(
464            html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
465            "got: {html}"
466        );
467    }
468
469    #[test]
470    fn walker_missing_child_emits_diagnostic() {
471        // The simplest way to force the missing-child diagnostic without needing
472        // a real container renderer is to point the spec's root at an ID that
473        // isn't in the elements map.
474        let mut spec = Spec::builder()
475            .element("real", Element::new("Text"))
476            .build()
477            .expect("ok");
478        spec.root = "ghost".to_string();
479        let html = render_spec_to_html(&spec, &json!({}));
480        assert!(
481            html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
482            "got: {html}"
483        );
484    }
485
486    #[test]
487    fn walker_root_hidden_emits_root_hidden_comment() {
488        let mut spec = Spec::builder()
489            .element("root", Element::new("Text"))
490            .build()
491            .expect("ok");
492        let el = spec.elements.get_mut("root").unwrap();
493        el.visible = Some(Visibility::Condition(VisibilityCondition {
494            path: "/show".into(),
495            operator: VisibilityOperator::Eq,
496            value: Some(json!(true)),
497        }));
498        let html = render_spec_to_html(&spec, &json!({"show": false}));
499        assert!(
500            html.contains("<!-- ferro-json-ui: root hidden -->"),
501            "got: {html}"
502        );
503    }
504
505    #[test]
506    fn walker_depth_tripwire_relative() {
507        // A self-cycle is rejected at parse time, so call the walker directly
508        // with a depth exceeding MAX_NESTING_DEPTH + 1 to exercise the
509        // defense-in-depth tripwire. After the diagnostic split (Task 2), the
510        // output must say "depth limit exceeded", not "cycle guard tripped".
511        let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
512        let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
513        assert!(html.contains("depth limit exceeded"), "got: {html}");
514    }
515
516    #[test]
517    fn walker_depth_tripwire() {
518        // Direct invocation of render_element at depth MAX_NESTING_DEPTH + 2
519        // fires the walker tripwire. The output must:
520        //   - contain "depth limit exceeded"
521        //   - contain "max=16"
522        //   - NOT contain "cycle"
523        let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
524        let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
525        assert!(
526            html.contains("depth limit exceeded"),
527            "expected 'depth limit exceeded' in: {html}"
528        );
529        assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
530        assert!(
531            !html.contains("cycle"),
532            "depth tripwire must not mention 'cycle'; got: {html}"
533        );
534    }
535
536    #[test]
537    fn walker_plugin_dispatch_invokes_with_plugin() {
538        struct TestPlugin;
539        impl JsonUiPlugin for TestPlugin {
540            fn component_type(&self) -> &str {
541                "FerroPhase116PluginDispatchTest"
542            }
543            fn props_schema(&self) -> serde_json::Value {
544                serde_json::json!({})
545            }
546            fn render(&self, _props: &Value, _data: &Value) -> String {
547                "<div data-test-plugin>X</div>".to_string()
548            }
549            fn css_assets(&self) -> Vec<Asset> {
550                Vec::new()
551            }
552            fn js_assets(&self) -> Vec<Asset> {
553                Vec::new()
554            }
555            fn init_script(&self) -> Option<String> {
556                None
557            }
558        }
559        register_plugin(TestPlugin);
560
561        let spec = build_spec_unchecked(
562            "root",
563            vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
564        );
565        let html = render_spec_to_html(&spec, &json!({}));
566        assert!(
567            html.contains("<div data-test-plugin>X</div>"),
568            "got: {html}"
569        );
570    }
571
572    #[test]
573    fn walker_plugin_asset_collection_returns_plugin_types() {
574        struct TestPluginB;
575        impl JsonUiPlugin for TestPluginB {
576            fn component_type(&self) -> &str {
577                "FerroPhase116AssetCollectTestPlugin"
578            }
579            fn props_schema(&self) -> serde_json::Value {
580                serde_json::json!({})
581            }
582            fn render(&self, _props: &Value, _data: &Value) -> String {
583                String::new()
584            }
585            fn css_assets(&self) -> Vec<Asset> {
586                Vec::new()
587            }
588            fn js_assets(&self) -> Vec<Asset> {
589                Vec::new()
590            }
591            fn init_script(&self) -> Option<String> {
592                None
593            }
594        }
595        register_plugin(TestPluginB);
596
597        let spec = build_spec_unchecked(
598            "root",
599            vec![
600                ("root", mk_element("Text")),
601                ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
602            ],
603        );
604        let types = collect_plugin_types(&spec);
605        assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
606        assert!(!types.contains("Text"));
607    }
608
609    #[test]
610    fn walker_plugins_cannot_shadow_builtins() {
611        // Register a plugin that claims the built-in type name "Card".
612        // The dispatch match must still route to the built-in renderer, not
613        // to the plugin.
614        struct CardShadow;
615        impl JsonUiPlugin for CardShadow {
616            fn component_type(&self) -> &str {
617                "Card"
618            }
619            fn props_schema(&self) -> serde_json::Value {
620                serde_json::json!({})
621            }
622            fn render(&self, _props: &Value, _data: &Value) -> String {
623                "<div data-from-plugin>SHADOW</div>".to_string()
624            }
625            fn css_assets(&self) -> Vec<Asset> {
626                Vec::new()
627            }
628            fn js_assets(&self) -> Vec<Asset> {
629                Vec::new()
630            }
631            fn init_script(&self) -> Option<String> {
632                None
633            }
634        }
635        register_plugin(CardShadow);
636
637        let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
638        let html = render_spec_to_html(&spec, &json!({}));
639        assert!(
640            !html.contains("data-from-plugin"),
641            "plugin must not shadow built-in Card; got: {html}"
642        );
643    }
644
645    #[test]
646    fn top_level_wrapper_present() {
647        let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
648        let html = render_spec_to_html(&spec, &json!({}));
649        assert!(
650            html.starts_with("<div class=\"flex flex-wrap gap-4"),
651            "got: {html}"
652        );
653        assert!(html.ends_with("</div>"), "got: {html}");
654    }
655
656    #[test]
657    fn html_escape_basic() {
658        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
659        assert_eq!(html_escape("a&b"), "a&amp;b");
660        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
661    }
662
663    #[test]
664    fn builtin_types_have_no_duplicates() {
665        // The dispatch match in `render_element` has one arm per BUILTIN_TYPES
666        // entry (arm coverage is compile-enforced by rustc). The remaining
667        // runtime risk is a DUPLICATE entry — a shadowed dispatch arm or a
668        // double catalog spec — which this guards relationally (no magic count;
669        // the absolute count is pinned once in
670        // catalog::tests::builtin_types_count_drift_guard).
671        let mut seen = std::collections::HashSet::new();
672        for ty in BUILTIN_TYPES {
673            assert!(seen.insert(ty), "duplicate BUILTIN_TYPES entry: {ty}");
674        }
675    }
676
677    #[test]
678    fn render_spec_with_stream_text_emits_init_script() {
679        let spec = Spec::builder()
680            .element(
681                "root",
682                Element::new("StreamText").prop("sse_url", "/stream"),
683            )
684            .build()
685            .expect("spec builds");
686        let result = render_spec_to_html_with_plugins(&spec, &json!({}));
687        assert!(
688            result.scripts.contains("EventSource"),
689            "init script must be present; got: {}",
690            result.scripts
691        );
692        // T-169-02 / T-169-03: tokens appended as text nodes, never parsed as HTML.
693        assert!(
694            result.scripts.contains("createTextNode"),
695            "tokens must append via createTextNode; got: {}",
696            result.scripts
697        );
698        assert!(
699            !result.scripts.contains("innerHTML"),
700            "init script must never use innerHTML; got: {}",
701            result.scripts
702        );
703        // D-03: source closes on `done` to prevent reconnect loop.
704        assert!(
705            result.scripts.contains("'done'") && result.scripts.contains("close()"),
706            "init script must close on done event; got: {}",
707            result.scripts
708        );
709    }
710
711    #[test]
712    fn render_spec_without_stream_text_emits_no_init_script() {
713        let spec = Spec::builder()
714            .element("root", Element::new("Text").prop("content", "Hello"))
715            .build()
716            .expect("spec builds");
717        let result = render_spec_to_html_with_plugins(&spec, &json!({}));
718        assert!(
719            result.scripts.is_empty(),
720            "no init script when no StreamText; got: {}",
721            result.scripts
722        );
723    }
724
725    // ── LiveFragment end-to-end tests (Phase 260 Plan 04 Task 3) ────────────
726
727    #[test]
728    fn live_fragment_end_to_end_first_paint_and_delta_use_one_render_path() {
729        // Build a child template spec (a Text element).
730        let child = Spec::builder()
731            .element("content", Element::new("Text").prop("content", "count: 7"))
732            .build()
733            .expect("child spec");
734        let template = serde_json::to_value(&child).expect("serialize child");
735
736        // Build the host spec with LiveFragment at root.
737        let host = Spec::builder()
738            .element(
739                "root",
740                Element::new("LiveFragment")
741                    .prop("projection", "inventory.dashboard")
742                    .prop("key", "warehouse-a")
743                    .prop("template", template.clone()),
744            )
745            .build()
746            .expect("host spec");
747
748        // SC1 via the PUBLIC API: dispatch reaches render_live_fragment.
749        let snapshot = json!({"total": 7});
750        let first_paint = render_spec_to_html(&host, &snapshot);
751        assert!(
752            first_paint.contains("data-live-fragment"),
753            "container marker present; got: {first_paint}"
754        );
755        assert!(
756            first_paint.contains(r#"data-channel="projection.inventory.dashboard.warehouse-a""#),
757            "channel attribute present; got: {first_paint}"
758        );
759        assert!(
760            first_paint.contains("count: 7"),
761            "child rendered on first paint; got: {first_paint}"
762        );
763
764        // D-05 / SC4: the delta re-render the app hook broadcasts uses the SAME
765        // render path over the same child spec + a new snapshot — proving ONE
766        // binding pattern (per-key snapshot) and one render function.
767        let child_spec: crate::spec::Spec =
768            serde_json::from_value(template).expect("deserialize child");
769        let delta_snapshot = json!({"total": 8});
770        let delta_html_a = render_spec_to_html(&child_spec, &delta_snapshot);
771        let delta_html_b = render_spec_to_html(&child_spec, &delta_snapshot);
772        assert_eq!(
773            delta_html_a, delta_html_b,
774            "single deterministic render path (D-05)"
775        );
776    }
777
778    #[test]
779    fn live_fragment_ships_one_binding_pattern_no_list_reconciliation() {
780        // SC4: exactly one binding pattern (per-key snapshot). No keyed-list /
781        // collection reconciliation code in the LiveFragment renderer.
782        let src = include_str!("containers.rs");
783        // The renderer must not implement keyed diffing / reconciliation.
784        assert!(
785            !src.contains("reconcile"),
786            "no list reconciliation in v17.0"
787        );
788        assert!(
789            !src.contains("keyed_diff"),
790            "no keyed list diffing in v17.0"
791        );
792    }
793}
794
795#[cfg(test)]
796mod render_subtree_tests {
797    use super::*;
798    use crate::spec::Spec;
799    use serde_json::json;
800
801    fn simple_spec() -> Spec {
802        Spec::builder()
803            .title("Test")
804            .element(
805                "root",
806                crate::spec::Element::new("Text").prop("content", "hello"),
807            )
808            .build()
809            .expect("spec valid")
810    }
811
812    #[test]
813    fn render_subtree_root_target_wraps_in_ferro_json_ui_div() {
814        let spec = simple_spec();
815        let html = render_subtree("ferro-json-ui", &spec, &json!({}));
816        assert!(
817            html.starts_with(r#"<div id="ferro-json-ui">"#),
818            "got: {html}"
819        );
820        assert!(html.contains("hello"), "got: {html}");
821        // Must NOT contain layout chrome (sidebar, header, nav)
822        assert!(
823            !html.contains("<html"),
824            "must not contain html tag; got: {html}"
825        );
826        assert!(!html.contains("<nav"), "must not contain nav; got: {html}");
827    }
828
829    #[test]
830    fn render_subtree_unknown_target_returns_comment() {
831        let spec = simple_spec();
832        let html = render_subtree("nonexistent", &spec, &json!({}));
833        assert!(html.contains("<!-- ferro-json-ui:"), "got: {html}");
834        assert!(html.contains("nonexistent"), "got: {html}");
835    }
836
837    #[test]
838    fn render_spec_to_html_unchanged() {
839        // Regression: full render path is not affected by render_subtree addition.
840        let spec = simple_spec();
841        let html = render_spec_to_html(&spec, &json!({}));
842        assert!(html.contains("hello"), "got: {html}");
843        // Full render wraps in flex container, not the nav-target div
844        assert!(
845            !html.starts_with(r#"<div id="ferro-json-ui">"#),
846            "got: {html}"
847        );
848    }
849}