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/// Walks `spec.elements` and collects every plugin type name encountered
269/// (every `Element.type_name` not present in [`BUILTIN_TYPES`]). Used by the
270/// asset-collection pipeline to determine which plugin CSS/JS to inject.
271pub(crate) fn collect_plugin_types(spec: &Spec) -> HashSet<String> {
272    let mut types = HashSet::new();
273    for el in spec.elements.values() {
274        if !BUILTIN_TYPES.contains(&el.type_name.as_str()) {
275            types.insert(el.type_name.clone());
276        }
277    }
278    types
279}
280
281/// Dependency-free inline EventSource wiring for `StreamText` components.
282/// Skips elements with an empty URL, appends streamed tokens as text nodes
283/// (never `innerHTML`), removes the placeholder on the first token (or on
284/// `done` for an empty stream), and closes the source on `event: done` to
285/// prevent `EventSource` auto-reconnect. Emitted at most once per page.
286const FERRO_STREAM_TEXT_INIT: &str = r#"(function(){
287  document.querySelectorAll('[data-ferro-stream-url]').forEach(function(el){
288    var url = el.dataset.ferroStreamUrl;
289    if(!url) return;
290    var src = new EventSource(url);
291    var placeholder = el.querySelector('[data-ferro-stream-placeholder]');
292    var loading = el.querySelector('[data-ferro-stream-loading]');
293    var firstToken = true;
294    src.onmessage = function(e){
295      if(firstToken){ firstToken=false; if(placeholder) placeholder.remove(); }
296      el.appendChild(document.createTextNode(e.data));
297    };
298    src.addEventListener('done', function(){
299      src.close();
300      if(placeholder) placeholder.remove();
301      if(loading) loading.remove();
302    });
303    src.onerror = function(){
304      src.close();
305      if(loading) loading.remove();
306    };
307  });
308})();"#;
309
310/// Returns the StreamText EventSource init script if the spec contains at least
311/// one `StreamText` element; otherwise an empty `Vec`. Walks `spec.elements`
312/// the same way `collect_plugin_types` does. Returns at most one entry so the
313/// script is emitted exactly once regardless of how many StreamText elements
314/// the spec contains.
315fn collect_builtin_init_scripts(spec: &Spec) -> Vec<String> {
316    let has_stream_text = spec
317        .elements
318        .values()
319        .any(|el| el.type_name == "StreamText");
320    if has_stream_text {
321        vec![FERRO_STREAM_TEXT_INIT.to_string()]
322    } else {
323        vec![]
324    }
325}
326
327/// HTML-escapes interpolated identifiers in diagnostic comments and any prop
328/// content interpolated into emitted markup. Every string that crosses from
329/// a `Spec` or `data` into the HTML output is required to pass through this
330/// function.
331pub(crate) fn html_escape(s: &str) -> String {
332    s.replace('&', "&amp;")
333        .replace('<', "&lt;")
334        .replace('>', "&gt;")
335        .replace('"', "&quot;")
336        .replace('\'', "&#x27;")
337}
338
339/// Emits one `<link rel="stylesheet" href="..." [integrity] [crossorigin]>`
340/// tag per CSS [`Asset`]. URLs and attribute values pass through
341/// [`html_escape`]; `Asset.crossorigin` is an `Option<String>` (e.g.
342/// `Some("anonymous")`) — emitted when present.
343pub(crate) fn render_css_tags(assets: &[Asset]) -> String {
344    let mut out = String::new();
345    for asset in assets {
346        out.push_str("<link rel=\"stylesheet\" href=\"");
347        out.push_str(&html_escape(&asset.url));
348        out.push('"');
349        if let Some(integrity) = &asset.integrity {
350            out.push_str(" integrity=\"");
351            out.push_str(&html_escape(integrity));
352            out.push('"');
353        }
354        if let Some(co) = &asset.crossorigin {
355            out.push_str(" crossorigin=\"");
356            out.push_str(&html_escape(co));
357            out.push('"');
358        }
359        out.push_str(">\n");
360    }
361    out
362}
363
364/// Emits one `<script src="..." [integrity] [crossorigin]></script>` tag per
365/// JS [`Asset`], followed by one `<script>{init}</script>` tag per registered
366/// plugin init script (in registration order). URLs and attribute values
367/// pass through [`html_escape`].
368pub(crate) fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
369    let mut out = String::new();
370    for asset in assets {
371        out.push_str("<script src=\"");
372        out.push_str(&html_escape(&asset.url));
373        out.push('"');
374        if let Some(integrity) = &asset.integrity {
375            out.push_str(" integrity=\"");
376            out.push_str(&html_escape(integrity));
377            out.push('"');
378        }
379        if let Some(co) = &asset.crossorigin {
380            out.push_str(" crossorigin=\"");
381            out.push_str(&html_escape(co));
382            out.push('"');
383        }
384        out.push_str("></script>\n");
385    }
386    for init in init_scripts {
387        out.push_str("<script>");
388        out.push_str(init);
389        out.push_str("</script>\n");
390    }
391    out
392}
393
394#[cfg(test)]
395mod tests {
396    // Walker-level tests live in this module. Per-component HTML emission tests
397    // live in atoms/containers/form/data submodules.
398    use super::*;
399    use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
400    use crate::spec::{Element, Spec};
401    use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
402    use serde_json::json;
403
404    /// Construct an `Element` directly from its public fields. Used when tests
405    /// need to bypass the parse-time structural validator (e.g. for
406    /// dangling-child or cycle scenarios).
407    fn mk_element(type_name: &str) -> Element {
408        Element {
409            type_name: type_name.to_string(),
410            props: Value::Null,
411            children: Vec::new(),
412            action: None,
413            visible: None,
414            each: None,
415            if_: None,
416        }
417    }
418
419    /// Build a `Spec` whose elements map is overwritten post-build to bypass
420    /// the parse-time structural validator. This is ONLY for testing the
421    /// walker's defense-in-depth guards on hand-mutated specs.
422    fn build_spec_unchecked(root: &str, elements: Vec<(&str, Element)>) -> Spec {
423        // Build a minimal valid spec through the normal builder so we get a
424        // correctly-initialized Spec shell; then overwrite root + elements.
425        let mut spec = Spec::builder()
426            .element("__tmp__", Element::new("Text"))
427            .build()
428            .expect("builder accepts trivial well-formed spec");
429        spec.root = root.to_string();
430        spec.elements.clear();
431        for (id, el) in elements {
432            spec.elements.insert(id.to_string(), el);
433        }
434        spec
435    }
436
437    #[test]
438    fn walker_unknown_type_emits_diagnostic() {
439        let spec = build_spec_unchecked("root", vec![("root", mk_element("ImaginaryWidget"))]);
440        let html = render_spec_to_html(&spec, &json!({}));
441        assert!(
442            html.contains("<!-- ferro-json-ui: unknown component type 'ImaginaryWidget' -->"),
443            "got: {html}"
444        );
445    }
446
447    #[test]
448    fn walker_missing_child_emits_diagnostic() {
449        // The simplest way to force the missing-child diagnostic without needing
450        // a real container renderer is to point the spec's root at an ID that
451        // isn't in the elements map.
452        let mut spec = Spec::builder()
453            .element("real", Element::new("Text"))
454            .build()
455            .expect("ok");
456        spec.root = "ghost".to_string();
457        let html = render_spec_to_html(&spec, &json!({}));
458        assert!(
459            html.contains("<!-- ferro-json-ui: element references missing id 'ghost' -->"),
460            "got: {html}"
461        );
462    }
463
464    #[test]
465    fn walker_root_hidden_emits_root_hidden_comment() {
466        let mut spec = Spec::builder()
467            .element("root", Element::new("Text"))
468            .build()
469            .expect("ok");
470        let el = spec.elements.get_mut("root").unwrap();
471        el.visible = Some(Visibility::Condition(VisibilityCondition {
472            path: "/show".into(),
473            operator: VisibilityOperator::Eq,
474            value: Some(json!(true)),
475        }));
476        let html = render_spec_to_html(&spec, &json!({"show": false}));
477        assert!(
478            html.contains("<!-- ferro-json-ui: root hidden -->"),
479            "got: {html}"
480        );
481    }
482
483    #[test]
484    fn walker_depth_tripwire_relative() {
485        // A self-cycle is rejected at parse time, so call the walker directly
486        // with a depth exceeding MAX_NESTING_DEPTH + 1 to exercise the
487        // defense-in-depth tripwire. After the diagnostic split (Task 2), the
488        // output must say "depth limit exceeded", not "cycle guard tripped".
489        let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
490        let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
491        assert!(html.contains("depth limit exceeded"), "got: {html}");
492    }
493
494    #[test]
495    fn walker_depth_tripwire() {
496        // Direct invocation of render_element at depth MAX_NESTING_DEPTH + 2
497        // fires the walker tripwire. The output must:
498        //   - contain "depth limit exceeded"
499        //   - contain "max=16"
500        //   - NOT contain "cycle"
501        let spec = build_spec_unchecked("A", vec![("A", mk_element("Text"))]);
502        let html = render_element("A", &spec, &json!({}), MAX_NESTING_DEPTH + 2);
503        assert!(
504            html.contains("depth limit exceeded"),
505            "expected 'depth limit exceeded' in: {html}"
506        );
507        assert!(html.contains("max=16"), "expected 'max=16' in: {html}");
508        assert!(
509            !html.contains("cycle"),
510            "depth tripwire must not mention 'cycle'; got: {html}"
511        );
512    }
513
514    #[test]
515    fn walker_plugin_dispatch_invokes_with_plugin() {
516        struct TestPlugin;
517        impl JsonUiPlugin for TestPlugin {
518            fn component_type(&self) -> &str {
519                "FerroPhase116PluginDispatchTest"
520            }
521            fn props_schema(&self) -> serde_json::Value {
522                serde_json::json!({})
523            }
524            fn render(&self, _props: &Value, _data: &Value) -> String {
525                "<div data-test-plugin>X</div>".to_string()
526            }
527            fn css_assets(&self) -> Vec<Asset> {
528                Vec::new()
529            }
530            fn js_assets(&self) -> Vec<Asset> {
531                Vec::new()
532            }
533            fn init_script(&self) -> Option<String> {
534                None
535            }
536        }
537        register_plugin(TestPlugin);
538
539        let spec = build_spec_unchecked(
540            "root",
541            vec![("root", mk_element("FerroPhase116PluginDispatchTest"))],
542        );
543        let html = render_spec_to_html(&spec, &json!({}));
544        assert!(
545            html.contains("<div data-test-plugin>X</div>"),
546            "got: {html}"
547        );
548    }
549
550    #[test]
551    fn walker_plugin_asset_collection_returns_plugin_types() {
552        struct TestPluginB;
553        impl JsonUiPlugin for TestPluginB {
554            fn component_type(&self) -> &str {
555                "FerroPhase116AssetCollectTestPlugin"
556            }
557            fn props_schema(&self) -> serde_json::Value {
558                serde_json::json!({})
559            }
560            fn render(&self, _props: &Value, _data: &Value) -> String {
561                String::new()
562            }
563            fn css_assets(&self) -> Vec<Asset> {
564                Vec::new()
565            }
566            fn js_assets(&self) -> Vec<Asset> {
567                Vec::new()
568            }
569            fn init_script(&self) -> Option<String> {
570                None
571            }
572        }
573        register_plugin(TestPluginB);
574
575        let spec = build_spec_unchecked(
576            "root",
577            vec![
578                ("root", mk_element("Text")),
579                ("plug", mk_element("FerroPhase116AssetCollectTestPlugin")),
580            ],
581        );
582        let types = collect_plugin_types(&spec);
583        assert!(types.contains("FerroPhase116AssetCollectTestPlugin"));
584        assert!(!types.contains("Text"));
585    }
586
587    #[test]
588    fn walker_plugins_cannot_shadow_builtins() {
589        // Register a plugin that claims the built-in type name "Card".
590        // The dispatch match must still route to the built-in renderer, not
591        // to the plugin.
592        struct CardShadow;
593        impl JsonUiPlugin for CardShadow {
594            fn component_type(&self) -> &str {
595                "Card"
596            }
597            fn props_schema(&self) -> serde_json::Value {
598                serde_json::json!({})
599            }
600            fn render(&self, _props: &Value, _data: &Value) -> String {
601                "<div data-from-plugin>SHADOW</div>".to_string()
602            }
603            fn css_assets(&self) -> Vec<Asset> {
604                Vec::new()
605            }
606            fn js_assets(&self) -> Vec<Asset> {
607                Vec::new()
608            }
609            fn init_script(&self) -> Option<String> {
610                None
611            }
612        }
613        register_plugin(CardShadow);
614
615        let spec = build_spec_unchecked("root", vec![("root", mk_element("Card"))]);
616        let html = render_spec_to_html(&spec, &json!({}));
617        assert!(
618            !html.contains("data-from-plugin"),
619            "plugin must not shadow built-in Card; got: {html}"
620        );
621    }
622
623    #[test]
624    fn top_level_wrapper_present() {
625        let spec = build_spec_unchecked("root", vec![("root", mk_element("Text"))]);
626        let html = render_spec_to_html(&spec, &json!({}));
627        assert!(
628            html.starts_with("<div class=\"flex flex-wrap gap-4"),
629            "got: {html}"
630        );
631        assert!(html.ends_with("</div>"), "got: {html}");
632    }
633
634    #[test]
635    fn html_escape_basic() {
636        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
637        assert_eq!(html_escape("a&b"), "a&amp;b");
638        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
639    }
640
641    #[test]
642    fn builtin_types_have_no_duplicates() {
643        // The dispatch match in `render_element` has one arm per BUILTIN_TYPES
644        // entry (arm coverage is compile-enforced by rustc). The remaining
645        // runtime risk is a DUPLICATE entry — a shadowed dispatch arm or a
646        // double catalog spec — which this guards relationally (no magic count;
647        // the absolute count is pinned once in
648        // catalog::tests::builtin_types_count_drift_guard).
649        let mut seen = std::collections::HashSet::new();
650        for ty in BUILTIN_TYPES {
651            assert!(seen.insert(ty), "duplicate BUILTIN_TYPES entry: {ty}");
652        }
653    }
654
655    #[test]
656    fn render_spec_with_stream_text_emits_init_script() {
657        let spec = Spec::builder()
658            .element(
659                "root",
660                Element::new("StreamText").prop("sse_url", "/stream"),
661            )
662            .build()
663            .expect("spec builds");
664        let result = render_spec_to_html_with_plugins(&spec, &json!({}));
665        assert!(
666            result.scripts.contains("EventSource"),
667            "init script must be present; got: {}",
668            result.scripts
669        );
670        // T-169-02 / T-169-03: tokens appended as text nodes, never parsed as HTML.
671        assert!(
672            result.scripts.contains("createTextNode"),
673            "tokens must append via createTextNode; got: {}",
674            result.scripts
675        );
676        assert!(
677            !result.scripts.contains("innerHTML"),
678            "init script must never use innerHTML; got: {}",
679            result.scripts
680        );
681        // D-03: source closes on `done` to prevent reconnect loop.
682        assert!(
683            result.scripts.contains("'done'") && result.scripts.contains("close()"),
684            "init script must close on done event; got: {}",
685            result.scripts
686        );
687    }
688
689    #[test]
690    fn render_spec_without_stream_text_emits_no_init_script() {
691        let spec = Spec::builder()
692            .element("root", Element::new("Text").prop("content", "Hello"))
693            .build()
694            .expect("spec builds");
695        let result = render_spec_to_html_with_plugins(&spec, &json!({}));
696        assert!(
697            result.scripts.is_empty(),
698            "no init script when no StreamText; got: {}",
699            result.scripts
700        );
701    }
702
703    // ── LiveFragment end-to-end tests (Phase 260 Plan 04 Task 3) ────────────
704
705    #[test]
706    fn live_fragment_end_to_end_first_paint_and_delta_use_one_render_path() {
707        // Build a child template spec (a Text element).
708        let child = Spec::builder()
709            .element("content", Element::new("Text").prop("content", "count: 7"))
710            .build()
711            .expect("child spec");
712        let template = serde_json::to_value(&child).expect("serialize child");
713
714        // Build the host spec with LiveFragment at root.
715        let host = Spec::builder()
716            .element(
717                "root",
718                Element::new("LiveFragment")
719                    .prop("projection", "inventory.dashboard")
720                    .prop("key", "warehouse-a")
721                    .prop("template", template.clone()),
722            )
723            .build()
724            .expect("host spec");
725
726        // SC1 via the PUBLIC API: dispatch reaches render_live_fragment.
727        let snapshot = json!({"total": 7});
728        let first_paint = render_spec_to_html(&host, &snapshot);
729        assert!(
730            first_paint.contains("data-live-fragment"),
731            "container marker present; got: {first_paint}"
732        );
733        assert!(
734            first_paint.contains(r#"data-channel="projection.inventory.dashboard.warehouse-a""#),
735            "channel attribute present; got: {first_paint}"
736        );
737        assert!(
738            first_paint.contains("count: 7"),
739            "child rendered on first paint; got: {first_paint}"
740        );
741
742        // D-05 / SC4: the delta re-render the app hook broadcasts uses the SAME
743        // render path over the same child spec + a new snapshot — proving ONE
744        // binding pattern (per-key snapshot) and one render function.
745        let child_spec: crate::spec::Spec =
746            serde_json::from_value(template).expect("deserialize child");
747        let delta_snapshot = json!({"total": 8});
748        let delta_html_a = render_spec_to_html(&child_spec, &delta_snapshot);
749        let delta_html_b = render_spec_to_html(&child_spec, &delta_snapshot);
750        assert_eq!(
751            delta_html_a, delta_html_b,
752            "single deterministic render path (D-05)"
753        );
754    }
755
756    #[test]
757    fn live_fragment_ships_one_binding_pattern_no_list_reconciliation() {
758        // SC4: exactly one binding pattern (per-key snapshot). No keyed-list /
759        // collection reconciliation code in the LiveFragment renderer.
760        let src = include_str!("containers.rs");
761        // The renderer must not implement keyed diffing / reconciliation.
762        assert!(
763            !src.contains("reconcile"),
764            "no list reconciliation in v17.0"
765        );
766        assert!(
767            !src.contains("keyed_diff"),
768            "no keyed list diffing in v17.0"
769        );
770    }
771}