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    // Form controls (form.rs)
90    "Form",
91    "Input",
92    "Select",
93    "Checkbox",
94    "Switch",
95    "CheckboxList",
96    "CheckboxGroup",
97    // Data displays (data.rs)
98    "Table",
99    "DataTable",
100    "MediaCardGrid",
101];
102
103/// Renders an entire `Spec` to a complete HTML response body. Walks from
104/// `spec.root` outward, escaping text content and substituting data bindings
105/// via JSON Pointer. Top-level output is wrapped in a `flex-wrap` container;
106/// the renderer does not emit `<html>` / `<head>` / `<body>` tags — the
107/// layout system supplies those. Always returns a `String`; never panics and
108/// never returns `Result`.
109pub fn render_spec_to_html(spec: &Spec, data: &Value) -> String {
110    let body = render_element(&spec.root, spec, data, 1);
111    let body_or_root_hidden = if body.is_empty() && spec_root_was_hidden(spec, data) {
112        String::from("<!-- ferro-json-ui: root hidden -->")
113    } else {
114        body
115    };
116    format!(
117        "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">{body_or_root_hidden}</div>"
118    )
119}
120
121/// Plugin-aware variant. Walks `spec.elements` to collect plugin type names,
122/// then asks the registry for their CSS/JS asset URLs. Also collects built-in
123/// init scripts (e.g. the `StreamText` EventSource wiring) and merges them
124/// into the scripts output even when no plugins are present.
125pub fn render_spec_to_html_with_plugins(spec: &Spec, data: &Value) -> RenderResult {
126    let html = render_spec_to_html(spec, data);
127    let builtin_scripts = collect_builtin_init_scripts(spec);
128    let plugin_types = collect_plugin_types(spec);
129    if plugin_types.is_empty() && builtin_scripts.is_empty() {
130        return RenderResult {
131            html,
132            css_head: String::new(),
133            scripts: String::new(),
134        };
135    }
136    let type_names: Vec<String> = plugin_types.into_iter().collect();
137    let assets = collect_plugin_assets(&type_names);
138    let all_init_scripts: Vec<String> = assets
139        .init_scripts
140        .iter()
141        .chain(builtin_scripts.iter())
142        .cloned()
143        .collect();
144    RenderResult {
145        html,
146        css_head: render_css_tags(&assets.css),
147        scripts: render_js_tags(&assets.js, &all_init_scripts),
148    }
149}
150
151/// The one recursive function. All dispatch, visibility, depth-guard, and
152/// diagnostic logic lives here. The per-element pipeline is:
153/// (1) depth guard, (2) ID lookup, (3) visibility check, (4) dispatch.
154pub(crate) fn render_element(id: &str, spec: &Spec, data: &Value, depth: usize) -> String {
155    // (1) Depth tripwire. Parse-time depth is capped at `MAX_NESTING_DEPTH = 16`;
156    // this fires only for hand-mutated Specs that bypassed `Spec::from_json`.
157    // Diagnostic names the limit so future failures are legible; this is a
158    // distinct condition from cycle detection (which lives in the parse-time
159    // validator and emits `SpecError::Cycle`).
160    if depth > MAX_NESTING_DEPTH + 1 {
161        return format!(
162            "<!-- ferro-json-ui: depth limit exceeded at depth {depth} (max={MAX_NESTING_DEPTH}) — spec should have been rejected at parse time -->"
163        );
164    }
165
166    // (2) ID lookup: missing IDs surface as an HTML comment.
167    let Some(el) = spec.elements.get(id) else {
168        return format!(
169            "<!-- ferro-json-ui: element references missing id '{}' -->",
170            html_escape(id)
171        );
172    };
173
174    // (3) Visibility check. Invisible → no output, no children walked.
175    if let Some(vis) = &el.visible {
176        if !vis.evaluate(data) {
177            return String::new();
178        }
179    }
180
181    // (4) Dispatch by type_name. Default arm consults plugin registry.
182    match el.type_name.as_str() {
183        // Atoms
184        "Text" => atoms::render_text(el, spec, data, depth),
185        "Button" => atoms::render_button(el, spec, data, depth),
186        "Badge" => atoms::render_badge(el, spec, data, depth),
187        "Alert" => atoms::render_alert(el, spec, data, depth),
188        "Separator" => atoms::render_separator(el, spec, data, depth),
189        "Progress" => atoms::render_progress(el, spec, data, depth),
190        "Avatar" => atoms::render_avatar(el, spec, data, depth),
191        "Image" => atoms::render_image(el, spec, data, depth),
192        "Skeleton" => atoms::render_skeleton(el, spec, data, depth),
193        "Breadcrumb" => atoms::render_breadcrumb(el, spec, data, depth),
194        "Pagination" => atoms::render_pagination(el, spec, data, depth),
195        "DescriptionList" => atoms::render_description_list(el, spec, data, depth),
196        "EmptyState" => atoms::render_empty_state(el, spec, data, depth),
197        "StatCard" => atoms::render_stat_card(el, spec, data, depth),
198        "Checklist" => atoms::render_checklist(el, spec, data, depth),
199        "Toast" => atoms::render_toast(el, spec, data, depth),
200        "NotificationDropdown" => atoms::render_notification_dropdown(el, spec, data, depth),
201        "Sidebar" => atoms::render_sidebar(el, spec, data, depth),
202        "Header" => atoms::render_header(el, spec, data, depth),
203        "CalendarCell" => atoms::render_calendar_cell(el, spec, data, depth),
204        "ActionCard" => atoms::render_action_card(el, spec, data, depth),
205        "Tile" => atoms::render_tile(el, spec, data, depth),
206        "FilterTabs" => atoms::render_filter_tabs(el, spec, data, depth),
207        "RawHtml" => atoms::render_raw_html(el, spec, data, depth),
208        "StreamText" => atoms::render_streamtext(el, spec, data, depth),
209        "QuantityStepper" => atoms::render_quantity_stepper(el, spec, data, depth),
210        "Numpad" => atoms::render_numpad(el, spec, data, depth),
211        // Containers
212        "Card" => containers::render_card(el, spec, data, depth),
213        "Modal" => containers::render_modal(el, spec, data, depth),
214        "Tabs" => containers::render_tabs(el, spec, data, depth),
215        "KanbanBoard" => containers::render_kanban_board(el, spec, data, depth),
216        "PageHeader" => containers::render_page_header(el, spec, data, depth),
217        "DetailPage" => containers::render_detail_page(el, spec, data, depth),
218        "Grid" => containers::render_grid(el, spec, data, depth),
219        "TileGrid" => containers::render_tile_grid(el, spec, data, depth),
220        "Collapsible" => containers::render_collapsible(el, spec, data, depth),
221        "FormSection" => containers::render_form_section(el, spec, data, depth),
222        "ButtonGroup" => containers::render_button_group(el, spec, data, depth),
223        "SegmentedControl" => containers::render_segmented_control(el, spec, data, depth),
224        "SidebarLayout" => containers::render_sidebar_layout(el, spec, data, depth),
225        "ActionGroup" => containers::render_action_group(el, spec, data, depth),
226        "SelectionPanel" => containers::render_selection_panel(el, spec, data, depth),
227        // Form controls
228        "Form" => form::render_form(el, spec, data, depth),
229        "Input" => form::render_input(el, spec, data, depth),
230        "Select" => form::render_select(el, spec, data, depth),
231        "Checkbox" => form::render_checkbox(el, spec, data, depth),
232        "Switch" => form::render_switch(el, spec, data, depth),
233        "CheckboxList" => form::render_checkbox_list(el, spec, data, depth),
234        "CheckboxGroup" => form::render_checkbox_list(el, spec, data, depth),
235        // Data displays
236        "Table" => data::render_table(el, spec, data, depth),
237        "DataTable" => data::render_data_table(el, spec, data, depth),
238        "MediaCardGrid" => data::render_media_card_grid(el, spec, data, depth),
239        // Plugin or unknown type name.
240        other => render_plugin_or_unknown(other, el, data),
241    }
242}
243
244fn render_plugin_or_unknown(type_name: &str, el: &crate::spec::Element, data: &Value) -> String {
245    match with_plugin(type_name, |p| p.render(&el.props, data)) {
246        Some(html) => html,
247        None => format!(
248            "<!-- ferro-json-ui: unknown component type '{}' -->",
249            html_escape(type_name)
250        ),
251    }
252}
253
254/// Helper: detect whether `spec.root` exists and has a visibility rule that
255/// evaluates false. Used to choose between empty body and the root-hidden
256/// diagnostic comment.
257fn spec_root_was_hidden(spec: &Spec, data: &Value) -> bool {
258    spec.elements
259        .get(&spec.root)
260        .and_then(|el| el.visible.as_ref())
261        .map(|vis| !vis.evaluate(data))
262        .unwrap_or(false)
263}
264
265/// Walks `spec.elements` and collects every plugin type name encountered
266/// (every `Element.type_name` not present in [`BUILTIN_TYPES`]). Used by the
267/// asset-collection pipeline to determine which plugin CSS/JS to inject.
268pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
269    let mut types = HashSet::new();
270    for el in spec.elements.values() {
271        if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
272            types.insert(el.type_name.clone());
273        }
274    }
275    types
276}
277
278/// Dependency-free inline EventSource wiring for `StreamText` components.
279/// Skips elements with an empty URL, appends streamed tokens as text nodes
280/// (never `innerHTML`), removes the placeholder on the first token (or on
281/// `done` for an empty stream), and closes the source on `event: done` to
282/// prevent `EventSource` auto-reconnect. Emitted at most once per page.
283const FERRO_STREAM_TEXT_INIT: &str = r#"(function(){
284  document.querySelectorAll('[data-ferro-stream-url]').forEach(function(el){
285    var url = el.dataset.ferroStreamUrl;
286    if(!url) return;
287    var src = new EventSource(url);
288    var placeholder = el.querySelector('[data-ferro-stream-placeholder]');
289    var loading = el.querySelector('[data-ferro-stream-loading]');
290    var firstToken = true;
291    src.onmessage = function(e){
292      if(firstToken){ firstToken=false; if(placeholder) placeholder.remove(); }
293      el.appendChild(document.createTextNode(e.data));
294    };
295    src.addEventListener('done', function(){
296      src.close();
297      if(placeholder) placeholder.remove();
298      if(loading) loading.remove();
299    });
300    src.onerror = function(){
301      src.close();
302      if(loading) loading.remove();
303    };
304  });
305})();"#;
306
307/// Returns the StreamText EventSource init script if the spec contains at least
308/// one `StreamText` element; otherwise an empty `Vec`. Walks `spec.elements`
309/// the same way `collect_plugin_types` does. Returns at most one entry so the
310/// script is emitted exactly once regardless of how many StreamText elements
311/// the spec contains.
312fn collect_builtin_init_scripts(spec: &Spec) -> Vec<String> {
313    let has_stream_text = spec
314        .elements
315        .values()
316        .any(|el| el.type_name == "StreamText");
317    if has_stream_text {
318        vec![FERRO_STREAM_TEXT_INIT.to_string()]
319    } else {
320        vec![]
321    }
322}
323
324/// HTML-escapes interpolated identifiers in diagnostic comments and any prop
325/// content interpolated into emitted markup. Every string that crosses from
326/// a `Spec` or `data` into the HTML output is required to pass through this
327/// function.
328pub(crate) fn html_escape(s: &str) -> String {
329    s.replace('&', "&amp;")
330        .replace('<', "&lt;")
331        .replace('>', "&gt;")
332        .replace('"', "&quot;")
333        .replace('\'', "&#x27;")
334}
335
336/// Emits one `<link rel="stylesheet" href="..." [integrity] [crossorigin]>`
337/// tag per CSS [`Asset`]. URLs and attribute values pass through
338/// [`html_escape`]; `Asset.crossorigin` is an `Option<String>` (e.g.
339/// `Some("anonymous")`) — emitted when present.
340pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
341    let mut out = String::new();
342    for asset in assets {
343        out.push_str("<link rel=\"stylesheet\" href=\"");
344        out.push_str(&html_escape(&asset.url));
345        out.push('"');
346        if let Some(integrity) = &asset.integrity {
347            out.push_str(" integrity=\"");
348            out.push_str(&html_escape(integrity));
349            out.push('"');
350        }
351        if let Some(co) = &asset.crossorigin {
352            out.push_str(" crossorigin=\"");
353            out.push_str(&html_escape(co));
354            out.push('"');
355        }
356        out.push_str(">\n");
357    }
358    out
359}
360
361/// Emits one `<script src="..." [integrity] [crossorigin]></script>` tag per
362/// JS [`Asset`], followed by one `<script>{init}</script>` tag per registered
363/// plugin init script (in registration order). URLs and attribute values
364/// pass through [`html_escape`].
365pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
366    let mut out = String::new();
367    for asset in assets {
368        out.push_str("<script src=\"");
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("></script>\n");
382    }
383    for init in init_scripts {
384        out.push_str("<script>");
385        out.push_str(init);
386        out.push_str("</script>\n");
387    }
388    out
389}
390
391#[cfg(test)]
392mod tests {
393    // Walker-level tests live in this module. Per-component HTML emission tests
394    // live in atoms/containers/form/data submodules.
395    use super::*;
396    use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
397    use crate::spec::{Element, Spec};
398    use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
399    use serde_json::json;
400
401    /// Construct an `Element` directly from its public fields. Used when tests
402    /// need to bypass the parse-time structural validator (e.g. for
403    /// dangling-child or cycle scenarios).
404    fn mk_element(type_name: &str) -> Element {
405        Element {
406            type_name: type_name.to_string(),
407            props: Value::Null,
408            children: Vec::new(),
409            action: None,
410            visible: None,
411            each: None,
412            if_: None,
413        }
414    }
415
416    /// Build a `Spec` whose elements map is overwritten post-build to bypass
417    /// the parse-time structural validator. This is ONLY for testing the
418    /// walker's defense-in-depth guards on hand-mutated specs.
419    fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
420        // Build a minimal valid spec through the normal builder so we get a
421        // correctly-initialized Spec shell; then overwrite root + elements.
422        let mut spec = Spec::builder()
423            .element("__tmp__", Element::new("Text"))
424            .build()
425            .expect("builder accepts trivial well-formed spec");
426        spec.root = root.to_string();
427        spec.elements.clear();
428        for (id, el) in elements {
429            spec.elements.insert(id.to_string(), el);
430        }
431        spec
432    }
433
434    #[test]
435    fn walker_unknown_type_emits_diagnostic() {
436        let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
437        let html = render_spec_to_html(&spec, &json!({}));
438        assert!(
439            html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
440            "got: {html}"
441        );
442    }
443
444    #[test]
445    fn walker_missing_child_emits_diagnostic() {
446        // The simplest way to force the missing-child diagnostic without needing
447        // a real container renderer is to point the spec's root at an ID that
448        // isn't in the elements map.
449        let mut spec = Spec::builder()
450            .element("real", Element::new("Text"))
451            .build()
452            .expect("ok");
453        spec.root = "ghost".to_string();
454        let html = render_spec_to_html(&spec, &json!({}));
455        assert!(
456            html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
457            "got: {html}"
458        );
459    }
460
461    #[test]
462    fn walker_root_hidden_emits_root_hidden_comment() {
463        let mut spec = Spec::builder()
464            .element("root", Element::new("Text"))
465            .build()
466            .expect("ok");
467        let el = spec.elements.get_mut("root").unwrap();
468        el.visible = Some(Visibility::Condition(VisibilityCondition {
469            path: "/show".into(),
470            operator: VisibilityOperator::Eq,
471            value: Some(json!(true)),
472        }));
473        let html = render_spec_to_html(&spec, &json!({"show": false}));
474        assert!(
475            html.contains("<!-- ferro-json-ui: root hidden -->"),
476            "got: {html}"
477        );
478    }
479
480    #[test]
481    fn walker_depth_tripwire_relative() {
482        // A self-cycle is rejected at parse time, so call the walker directly
483        // with a depth exceeding MAX_NESTING_DEPTH + 1 to exercise the
484        // defense-in-depth tripwire. After the diagnostic split (Task 2), the
485        // output must say "depth limit exceeded", not "cycle guard tripped".
486        let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
487        let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
488        assert!(html.contains("depth limit exceeded"), "got: {html}");
489    }
490
491    #[test]
492    fn walker_depth_tripwire() {
493        // Direct invocation of render_element at depth MAX_NESTING_DEPTH + 2
494        // fires the walker tripwire. The output must:
495        //   - contain "depth limit exceeded"
496        //   - contain "max=16"
497        //   - NOT contain "cycle"
498        let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
499        let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
500        assert!(
501            html.contains("depth limit exceeded"),
502            "expected 'depth limit exceeded' in: {html}"
503        );
504        assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
505        assert!(
506            !html.contains("cycle"),
507            "depth tripwire must not mention 'cycle'; got: {html}"
508        );
509    }
510
511    #[test]
512    fn walker_plugin_dispatch_invokes_with_plugin() {
513        struct TestPlugin;
514        impl JsonUiPlugin for TestPlugin {
515            fn component_type(&self) -> &str {
516                "FerroPhase116PluginDispatchTest"
517            }
518            fn props_schema(&self) -> serde_json::Value {
519                serde_json::json!({})
520            }
521            fn render(&self, _props: &Value, _data: &Value) -> String {
522                "<div data-test-plugin>X</div>".to_string()
523            }
524            fn css_assets(&self) -> Vec<Asset> {
525                Vec::new()
526            }
527            fn js_assets(&self) -> Vec<Asset> {
528                Vec::new()
529            }
530            fn init_script(&self) -> Option<String> {
531                None
532            }
533        }
534        register_plugin(TestPlugin);
535
536        let spec = build_spec_unchecked(
537            "root",
538            vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
539        );
540        let html = render_spec_to_html(&spec, &json!({}));
541        assert!(
542            html.contains("<div data-test-plugin>X</div>"),
543            "got: {html}"
544        );
545    }
546
547    #[test]
548    fn walker_plugin_asset_collection_returns_plugin_types() {
549        struct TestPluginB;
550        impl JsonUiPlugin for TestPluginB {
551            fn component_type(&self) -> &str {
552                "FerroPhase116AssetCollectTestPlugin"
553            }
554            fn props_schema(&self) -> serde_json::Value {
555                serde_json::json!({})
556            }
557            fn render(&self, _props: &Value, _data: &Value) -> String {
558                String::new()
559            }
560            fn css_assets(&self) -> Vec<Asset> {
561                Vec::new()
562            }
563            fn js_assets(&self) -> Vec<Asset> {
564                Vec::new()
565            }
566            fn init_script(&self) -> Option<String> {
567                None
568            }
569        }
570        register_plugin(TestPluginB);
571
572        let spec = build_spec_unchecked(
573            "root",
574            vec![
575                ("root", mk_element("Text")),
576                ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
577            ],
578        );
579        let types = collect_plugin_types(&spec);
580        assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
581        assert!(!types.contains("Text"));
582    }
583
584    #[test]
585    fn walker_plugins_cannot_shadow_builtins() {
586        // Register a plugin that claims the built-in type name "Card".
587        // The dispatch match must still route to the built-in renderer, not
588        // to the plugin.
589        struct CardShadow;
590        impl JsonUiPlugin for CardShadow {
591            fn component_type(&self) -> &str {
592                "Card"
593            }
594            fn props_schema(&self) -> serde_json::Value {
595                serde_json::json!({})
596            }
597            fn render(&self, _props: &Value, _data: &Value) -> String {
598                "<div data-from-plugin>SHADOW</div>".to_string()
599            }
600            fn css_assets(&self) -> Vec<Asset> {
601                Vec::new()
602            }
603            fn js_assets(&self) -> Vec<Asset> {
604                Vec::new()
605            }
606            fn init_script(&self) -> Option<String> {
607                None
608            }
609        }
610        register_plugin(CardShadow);
611
612        let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
613        let html = render_spec_to_html(&spec, &json!({}));
614        assert!(
615            !html.contains("data-from-plugin"),
616            "plugin must not shadow built-in Card; got: {html}"
617        );
618    }
619
620    #[test]
621    fn top_level_wrapper_present() {
622        let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
623        let html = render_spec_to_html(&spec, &json!({}));
624        assert!(
625            html.starts_with("<div class=\"flex flex-wrap gap-4"),
626            "got: {html}"
627        );
628        assert!(html.ends_with("</div>"), "got: {html}");
629    }
630
631    #[test]
632    fn html_escape_basic() {
633        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
634        assert_eq!(html_escape("a&b"), "a&amp;b");
635        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
636    }
637
638    #[test]
639    fn builtin_types_have_no_duplicates() {
640        // The dispatch match in `render_element` has one arm per BUILTIN_TYPES
641        // entry (arm coverage is compile-enforced by rustc). The remaining
642        // runtime risk is a DUPLICATE entry — a shadowed dispatch arm or a
643        // double catalog spec — which this guards relationally (no magic count;
644        // the absolute count is pinned once in
645        // catalog::tests::builtin_types_count_drift_guard).
646        let mut seen = std::collections::HashSet::new();
647        for ty in BUILTIN_TYPES {
648            assert!(seen.insert(ty), "duplicate BUILTIN_TYPES entry: {ty}");
649        }
650    }
651
652    #[test]
653    fn render_spec_with_stream_text_emits_init_script() {
654        let spec = Spec::builder()
655            .element(
656                "root",
657                Element::new("StreamText").prop("sse_url", "/stream"),
658            )
659            .build()
660            .expect("spec builds");
661        let result = render_spec_to_html_with_plugins(&spec, &json!({}));
662        assert!(
663            result.scripts.contains("EventSource"),
664            "init script must be present; got: {}",
665            result.scripts
666        );
667        // T-169-02 / T-169-03: tokens appended as text nodes, never parsed as HTML.
668        assert!(
669            result.scripts.contains("createTextNode"),
670            "tokens must append via createTextNode; got: {}",
671            result.scripts
672        );
673        assert!(
674            !result.scripts.contains("innerHTML"),
675            "init script must never use innerHTML; got: {}",
676            result.scripts
677        );
678        // D-03: source closes on `done` to prevent reconnect loop.
679        assert!(
680            result.scripts.contains("'done'") && result.scripts.contains("close()"),
681            "init script must close on done event; got: {}",
682            result.scripts
683        );
684    }
685
686    #[test]
687    fn render_spec_without_stream_text_emits_no_init_script() {
688        let spec = Spec::builder()
689            .element("root", Element::new("Text").prop("content", "Hello"))
690            .build()
691            .expect("spec builds");
692        let result = render_spec_to_html_with_plugins(&spec, &json!({}));
693        assert!(
694            result.scripts.is_empty(),
695            "no init script when no StreamText; got: {}",
696            result.scripts
697        );
698    }
699}