Skip to main content

ferro_json_ui/
render.rs

1//! HTML render engine for JSON-UI views.
2//!
3//! Walks a `JsonUiView` component tree and produces an HTML fragment using
4//! Tailwind CSS utility classes. All 20 built-in component types plus plugin
5//! components are supported. Plugin components are dispatched to the plugin
6//! registry; their CSS/JS assets are collected and returned separately.
7
8use std::collections::HashSet;
9
10use serde_json::Value;
11
12use crate::action::HttpMethod;
13use crate::component::{
14    AlertProps, AlertVariant, AvatarProps, BadgeProps, BadgeVariant, BreadcrumbProps, ButtonProps,
15    ButtonVariant, CardProps, CheckboxProps, Component, ComponentNode, DescriptionListProps,
16    FormProps, IconPosition, InputProps, InputType, ModalProps, Orientation, PaginationProps,
17    PluginProps, ProgressProps, SelectProps, SeparatorProps, Size, SkeletonProps, SwitchProps,
18    TableProps, TabsProps, TextElement, TextProps,
19};
20use crate::data::{resolve_path, resolve_path_string};
21use crate::plugin::{collect_plugin_assets, Asset};
22use crate::view::JsonUiView;
23
24/// Render a JSON-UI view to an HTML fragment.
25///
26/// Walks the component tree and produces a `<div>` containing all rendered
27/// components. This is a fragment, not a full page -- the framework wrapper
28/// handles `<html>`, `<head>`, and `<body>`.
29///
30/// The `data` parameter is used to resolve `data_path` references on form
31/// fields and table components.
32pub fn render_to_html(view: &JsonUiView, data: &Value) -> String {
33    let mut html = String::from("<div>");
34    for node in &view.components {
35        html.push_str(&render_node(node, data));
36    }
37    html.push_str("</div>");
38    html
39}
40
41/// Result of rendering a view with plugin support.
42///
43/// Contains the rendered HTML fragment plus CSS and JS tags collected
44/// from plugins used on the page.
45pub struct RenderResult {
46    /// The rendered HTML fragment (same as `render_to_html` output).
47    pub html: String,
48    /// CSS `<link>` tags to inject into `<head>`.
49    pub css_head: String,
50    /// JS `<script>` tags and init scripts to inject before `</body>`.
51    pub scripts: String,
52}
53
54/// Render a JSON-UI view to HTML and collect plugin assets.
55///
56/// Scans the component tree for plugin components, renders everything to
57/// HTML (including plugin components via the registry), then collects and
58/// deduplicates CSS/JS assets from the plugins used on the page.
59pub fn render_to_html_with_plugins(view: &JsonUiView, data: &Value) -> RenderResult {
60    let html = render_to_html(view, data);
61
62    let plugin_types = collect_plugin_types(view);
63    if plugin_types.is_empty() {
64        return RenderResult {
65            html,
66            css_head: String::new(),
67            scripts: String::new(),
68        };
69    }
70
71    let type_names: Vec<String> = plugin_types.into_iter().collect();
72    let assets = collect_plugin_assets(&type_names);
73
74    let css_head = render_css_tags(&assets.css);
75    let scripts = render_js_tags(&assets.js, &assets.init_scripts);
76
77    RenderResult {
78        html,
79        css_head,
80        scripts,
81    }
82}
83
84/// Walk the component tree and collect unique plugin type names.
85pub fn collect_plugin_types(view: &JsonUiView) -> HashSet<String> {
86    let mut types = HashSet::new();
87    for node in &view.components {
88        collect_plugin_types_node(node, &mut types);
89    }
90    types
91}
92
93/// Recursively collect plugin type names from a component node.
94fn collect_plugin_types_node(node: &ComponentNode, types: &mut HashSet<String>) {
95    match &node.component {
96        Component::Plugin(props) => {
97            types.insert(props.plugin_type.clone());
98        }
99        Component::Card(props) => {
100            for child in &props.children {
101                collect_plugin_types_node(child, types);
102            }
103            for child in &props.footer {
104                collect_plugin_types_node(child, types);
105            }
106        }
107        Component::Form(props) => {
108            for field in &props.fields {
109                collect_plugin_types_node(field, types);
110            }
111        }
112        Component::Modal(props) => {
113            for child in &props.children {
114                collect_plugin_types_node(child, types);
115            }
116            for child in &props.footer {
117                collect_plugin_types_node(child, types);
118            }
119        }
120        Component::Tabs(props) => {
121            for tab in &props.tabs {
122                for child in &tab.children {
123                    collect_plugin_types_node(child, types);
124                }
125            }
126        }
127        // Leaf components have no children to recurse into.
128        Component::Table(_)
129        | Component::Button(_)
130        | Component::Input(_)
131        | Component::Select(_)
132        | Component::Alert(_)
133        | Component::Badge(_)
134        | Component::Text(_)
135        | Component::Checkbox(_)
136        | Component::Switch(_)
137        | Component::Separator(_)
138        | Component::DescriptionList(_)
139        | Component::Breadcrumb(_)
140        | Component::Pagination(_)
141        | Component::Progress(_)
142        | Component::Avatar(_)
143        | Component::Skeleton(_) => {}
144    }
145}
146
147/// Render CSS assets as `<link>` tags.
148fn render_css_tags(assets: &[Asset]) -> String {
149    let mut out = String::new();
150    for asset in assets {
151        out.push_str("<link rel=\"stylesheet\" href=\"");
152        out.push_str(&html_escape(&asset.url));
153        out.push('"');
154        if let Some(ref integrity) = asset.integrity {
155            out.push_str(" integrity=\"");
156            out.push_str(&html_escape(integrity));
157            out.push('"');
158        }
159        if let Some(ref co) = asset.crossorigin {
160            out.push_str(" crossorigin=\"");
161            out.push_str(&html_escape(co));
162            out.push('"');
163        }
164        out.push('>');
165    }
166    out
167}
168
169/// Render JS assets as `<script>` tags followed by inline init scripts.
170fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
171    let mut out = String::new();
172    for asset in assets {
173        out.push_str("<script src=\"");
174        out.push_str(&html_escape(&asset.url));
175        out.push('"');
176        if let Some(ref integrity) = asset.integrity {
177            out.push_str(" integrity=\"");
178            out.push_str(&html_escape(integrity));
179            out.push('"');
180        }
181        if let Some(ref co) = asset.crossorigin {
182            out.push_str(" crossorigin=\"");
183            out.push_str(&html_escape(co));
184            out.push('"');
185        }
186        out.push_str("></script>");
187    }
188    if !init_scripts.is_empty() {
189        out.push_str("<script>");
190        for script in init_scripts {
191            out.push_str(script);
192        }
193        out.push_str("</script>");
194    }
195    out
196}
197
198/// Render a single component node, optionally wrapping in `<a>` for GET actions.
199fn render_node(node: &ComponentNode, data: &Value) -> String {
200    let component_html = render_component(&node.component, data);
201
202    // Wrap in <a> if the node has a GET action with a resolved URL.
203    if let Some(ref action) = node.action {
204        if action.method == HttpMethod::Get {
205            if let Some(ref url) = action.url {
206                return format!(
207                    "<a href=\"{}\" class=\"block\">{}</a>",
208                    html_escape(url),
209                    component_html
210                );
211            }
212        }
213    }
214
215    component_html
216}
217
218/// Dispatch to the appropriate per-component renderer.
219fn render_component(component: &Component, data: &Value) -> String {
220    match component {
221        Component::Text(props) => render_text(props),
222        Component::Button(props) => render_button(props),
223        Component::Badge(props) => render_badge(props),
224        Component::Alert(props) => render_alert(props),
225        Component::Separator(props) => render_separator(props),
226        Component::Progress(props) => render_progress(props),
227        Component::Avatar(props) => render_avatar(props),
228        Component::Skeleton(props) => render_skeleton(props),
229        Component::Breadcrumb(props) => render_breadcrumb(props),
230        Component::Pagination(props) => render_pagination(props),
231        Component::DescriptionList(props) => render_description_list(props),
232
233        // Container components.
234        Component::Card(props) => render_card(props, data),
235        Component::Form(props) => render_form(props, data),
236        Component::Modal(props) => render_modal(props, data),
237        Component::Tabs(props) => render_tabs(props, data),
238        Component::Table(props) => render_table(props, data),
239
240        // Form field components.
241        Component::Input(props) => render_input(props, data),
242        Component::Select(props) => render_select(props, data),
243        Component::Checkbox(props) => render_checkbox(props, data),
244        Component::Switch(props) => render_switch(props, data),
245
246        // Plugin components (rendered via plugin registry).
247        Component::Plugin(props) => render_plugin(props, data),
248    }
249}
250
251// ── Plugin component renderer ───────────────────────────────────────────
252
253fn render_plugin(props: &PluginProps, data: &Value) -> String {
254    crate::plugin::with_plugin(&props.plugin_type, |plugin| {
255        plugin.render(&props.props, data)
256    })
257    .unwrap_or_else(|| {
258        format!(
259            "<div class=\"p-4 bg-red-50 text-red-600 rounded\">Unknown plugin component: {}</div>",
260            html_escape(&props.plugin_type)
261        )
262    })
263}
264
265// ── Container component renderers ───────────────────────────────────────
266
267fn render_card(props: &CardProps, data: &Value) -> String {
268    let mut html = String::from(
269        "<div class=\"rounded-lg border border-gray-200 bg-white shadow-sm\"><div class=\"p-6\">",
270    );
271    html.push_str(&format!(
272        "<h3 class=\"text-lg font-semibold text-gray-900\">{}</h3>",
273        html_escape(&props.title)
274    ));
275    if let Some(ref desc) = props.description {
276        html.push_str(&format!(
277            "<p class=\"mt-1 text-sm text-gray-500\">{}</p>",
278            html_escape(desc)
279        ));
280    }
281    if !props.children.is_empty() {
282        html.push_str("<div class=\"mt-4 space-y-4\">");
283        for child in &props.children {
284            html.push_str(&render_node(child, data));
285        }
286        html.push_str("</div>");
287    }
288    html.push_str("</div>"); // close p-6
289    if !props.footer.is_empty() {
290        html.push_str("<div class=\"border-t border-gray-200 px-6 py-4 flex items-center gap-2\">");
291        for child in &props.footer {
292            html.push_str(&render_node(child, data));
293        }
294        html.push_str("</div>");
295    }
296    html.push_str("</div>"); // close outer card
297    html
298}
299
300fn render_modal(props: &ModalProps, data: &Value) -> String {
301    let trigger = props.trigger_label.as_deref().unwrap_or("Open");
302    let mut html = String::from("<details class=\"group\">");
303    html.push_str(&format!(
304        "<summary class=\"inline-flex items-center justify-center rounded-md bg-blue-600 text-white px-4 py-2 text-sm font-medium cursor-pointer\">{}</summary>",
305        html_escape(trigger)
306    ));
307    html.push_str("<div class=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 group-open:block hidden\">");
308    html.push_str(
309        "<div class=\"relative bg-white rounded-lg shadow-lg max-w-lg w-full mx-4 p-6\">",
310    );
311    html.push_str(&format!(
312        "<h3 class=\"text-lg font-semibold text-gray-900\">{}</h3>",
313        html_escape(&props.title)
314    ));
315    if let Some(ref desc) = props.description {
316        html.push_str(&format!(
317            "<p class=\"mt-1 text-sm text-gray-500\">{}</p>",
318            html_escape(desc)
319        ));
320    }
321    html.push_str("<div class=\"mt-4 space-y-4\">");
322    for child in &props.children {
323        html.push_str(&render_node(child, data));
324    }
325    html.push_str("</div>");
326    if !props.footer.is_empty() {
327        html.push_str("<div class=\"mt-6 flex items-center justify-end gap-2\">");
328        for child in &props.footer {
329            html.push_str(&render_node(child, data));
330        }
331        html.push_str("</div>");
332    }
333    html.push_str("</div></div></details>");
334    html
335}
336
337fn render_tabs(props: &TabsProps, data: &Value) -> String {
338    let mut html = String::from("<div>");
339    html.push_str("<div class=\"border-b border-gray-200\">");
340    html.push_str("<nav class=\"flex -mb-px space-x-4\">");
341    for tab in &props.tabs {
342        if tab.value == props.default_tab {
343            html.push_str(&format!(
344                "<span class=\"border-b-2 border-blue-600 text-blue-600 px-3 py-2 text-sm font-medium\">{}</span>",
345                html_escape(&tab.label)
346            ));
347        } else {
348            html.push_str(&format!(
349                "<span class=\"border-b-2 border-transparent text-gray-500 px-3 py-2 text-sm font-medium\">{}</span>",
350                html_escape(&tab.label)
351            ));
352        }
353    }
354    html.push_str("</nav></div>");
355    // Render only the default tab's children.
356    for tab in &props.tabs {
357        if tab.value == props.default_tab {
358            html.push_str("<div class=\"pt-4 space-y-4\">");
359            for child in &tab.children {
360                html.push_str(&render_node(child, data));
361            }
362            html.push_str("</div>");
363            break;
364        }
365    }
366    html.push_str("</div>");
367    html
368}
369
370fn render_form(props: &FormProps, data: &Value) -> String {
371    // Determine the effective HTTP method.
372    let effective_method = props
373        .method
374        .as_ref()
375        .unwrap_or(&props.action.method)
376        .clone();
377
378    // For PUT/PATCH/DELETE, use POST with method spoofing.
379    let (form_method, needs_spoofing) = match effective_method {
380        HttpMethod::Get => ("get", false),
381        HttpMethod::Post => ("post", false),
382        HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
383    };
384
385    let action_url = props.action.url.as_deref().unwrap_or("#");
386    let mut html = format!(
387        "<form action=\"{}\" method=\"{}\" class=\"space-y-4\">",
388        html_escape(action_url),
389        form_method
390    );
391
392    if needs_spoofing {
393        let method_value = match effective_method {
394            HttpMethod::Put => "PUT",
395            HttpMethod::Patch => "PATCH",
396            HttpMethod::Delete => "DELETE",
397            _ => unreachable!(),
398        };
399        html.push_str(&format!(
400            "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
401        ));
402    }
403
404    for field in &props.fields {
405        html.push_str(&render_node(field, data));
406    }
407    html.push_str("</form>");
408    html
409}
410
411fn render_table(props: &TableProps, data: &Value) -> String {
412    let mut html = String::from(
413        "<div class=\"overflow-x-auto\"><table class=\"min-w-full divide-y divide-gray-200\">",
414    );
415
416    // Header.
417    html.push_str("<thead class=\"bg-gray-50\"><tr>");
418    for col in &props.columns {
419        html.push_str(&format!(
420            "<th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500\">{}</th>",
421            html_escape(&col.label)
422        ));
423    }
424    if props.row_actions.is_some() {
425        html.push_str("<th class=\"px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500\">Actions</th>");
426    }
427    html.push_str("</tr></thead>");
428
429    // Body.
430    html.push_str("<tbody class=\"divide-y divide-gray-200 bg-white\">");
431
432    let rows = resolve_path(data, &props.data_path);
433    let row_array = rows.and_then(|v| v.as_array());
434
435    if let Some(items) = row_array {
436        if items.is_empty() {
437            if let Some(ref msg) = props.empty_message {
438                let col_count =
439                    props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
440                html.push_str(&format!(
441                    "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-gray-500\">{}</td></tr>",
442                    col_count,
443                    html_escape(msg)
444                ));
445            }
446        } else {
447            for row in items {
448                html.push_str("<tr>");
449                for col in &props.columns {
450                    let cell_value = row.get(&col.key);
451                    let cell_text = match cell_value {
452                        Some(Value::String(s)) => s.clone(),
453                        Some(Value::Number(n)) => n.to_string(),
454                        Some(Value::Bool(b)) => b.to_string(),
455                        Some(Value::Null) | None => String::new(),
456                        Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
457                            serde_json::to_string(v).unwrap_or_default()
458                        }
459                    };
460                    html.push_str(&format!(
461                        "<td class=\"px-6 py-4 text-sm text-gray-900 whitespace-nowrap\">{}</td>",
462                        html_escape(&cell_text)
463                    ));
464                }
465                if let Some(ref actions) = props.row_actions {
466                    html.push_str("<td class=\"px-6 py-4 text-right text-sm space-x-2\">");
467                    for action in actions {
468                        let url = action.url.as_deref().unwrap_or("#");
469                        let label = action
470                            .handler
471                            .split('.')
472                            .next_back()
473                            .unwrap_or(&action.handler);
474                        html.push_str(&format!(
475                            "<a href=\"{}\" class=\"text-blue-600 hover:text-blue-800\">{}</a>",
476                            html_escape(url),
477                            html_escape(label)
478                        ));
479                    }
480                    html.push_str("</td>");
481                }
482                html.push_str("</tr>");
483            }
484        }
485    } else if let Some(ref msg) = props.empty_message {
486        let col_count = props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
487        html.push_str(&format!(
488            "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-gray-500\">{}</td></tr>",
489            col_count,
490            html_escape(msg)
491        ));
492    }
493
494    html.push_str("</tbody></table></div>");
495    html
496}
497
498// ── Form field component renderers ──────────────────────────────────────
499
500fn render_input(props: &InputProps, data: &Value) -> String {
501    // Resolve the effective value: default_value wins, else data_path, else empty.
502    let resolved_value = if let Some(ref dv) = props.default_value {
503        Some(dv.clone())
504    } else if let Some(ref dp) = props.data_path {
505        resolve_path_string(data, dp)
506    } else {
507        None
508    };
509
510    let has_error = props.error.is_some();
511    let border_class = if has_error {
512        "border-red-500"
513    } else {
514        "border-gray-300"
515    };
516
517    let mut html = String::from("<div class=\"space-y-1\">");
518    html.push_str(&format!(
519        "<label class=\"block text-sm font-medium text-gray-700\" for=\"{}\">{}</label>",
520        html_escape(&props.field),
521        html_escape(&props.label)
522    ));
523
524    if let Some(ref desc) = props.description {
525        html.push_str(&format!(
526            "<p class=\"text-sm text-gray-500\">{}</p>",
527            html_escape(desc)
528        ));
529    }
530
531    match props.input_type {
532        InputType::Hidden => {
533            let val = resolved_value.as_deref().unwrap_or("");
534            html.push_str(&format!(
535                "<input type=\"hidden\" id=\"{}\" name=\"{}\" value=\"{}\">",
536                html_escape(&props.field),
537                html_escape(&props.field),
538                html_escape(val)
539            ));
540        }
541        InputType::Textarea => {
542            let val = resolved_value.as_deref().unwrap_or("");
543            html.push_str(&format!(
544                "<textarea id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"",
545                html_escape(&props.field),
546                html_escape(&props.field),
547                border_class
548            ));
549            if let Some(ref placeholder) = props.placeholder {
550                html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
551            }
552            if props.required == Some(true) {
553                html.push_str(" required");
554            }
555            if props.disabled == Some(true) {
556                html.push_str(" disabled");
557            }
558            html.push_str(&format!(">{}</textarea>", html_escape(val)));
559        }
560        _ => {
561            let input_type = match props.input_type {
562                InputType::Text => "text",
563                InputType::Email => "email",
564                InputType::Password => "password",
565                InputType::Number => "number",
566                InputType::Date => "date",
567                InputType::Time => "time",
568                InputType::Url => "url",
569                InputType::Tel => "tel",
570                InputType::Search => "search",
571                InputType::Textarea | InputType::Hidden => unreachable!(),
572            };
573            html.push_str(&format!(
574                "<input type=\"{}\" id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"",
575                input_type,
576                html_escape(&props.field),
577                html_escape(&props.field),
578                border_class
579            ));
580            if let Some(ref placeholder) = props.placeholder {
581                html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
582            }
583            if let Some(ref val) = resolved_value {
584                html.push_str(&format!(" value=\"{}\"", html_escape(val)));
585            }
586            if let Some(ref step) = props.step {
587                html.push_str(&format!(" step=\"{}\"", html_escape(step)));
588            }
589            if props.required == Some(true) {
590                html.push_str(" required");
591            }
592            if props.disabled == Some(true) {
593                html.push_str(" disabled");
594            }
595            html.push('>');
596        }
597    }
598
599    if let Some(ref error) = props.error {
600        html.push_str(&format!(
601            "<p class=\"text-sm text-red-600\">{}</p>",
602            html_escape(error)
603        ));
604    }
605    html.push_str("</div>");
606    html
607}
608
609fn render_select(props: &SelectProps, data: &Value) -> String {
610    // Resolve the effective selected value.
611    let selected_value = if let Some(ref dv) = props.default_value {
612        Some(dv.clone())
613    } else if let Some(ref dp) = props.data_path {
614        resolve_path_string(data, dp)
615    } else {
616        None
617    };
618
619    let has_error = props.error.is_some();
620    let border_class = if has_error {
621        "border-red-500"
622    } else {
623        "border-gray-300"
624    };
625
626    let mut html = String::from("<div class=\"space-y-1\">");
627    html.push_str(&format!(
628        "<label class=\"block text-sm font-medium text-gray-700\" for=\"{}\">{}</label>",
629        html_escape(&props.field),
630        html_escape(&props.label)
631    ));
632
633    if let Some(ref desc) = props.description {
634        html.push_str(&format!(
635            "<p class=\"text-sm text-gray-500\">{}</p>",
636            html_escape(desc)
637        ));
638    }
639
640    html.push_str(&format!(
641        "<select id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"",
642        html_escape(&props.field),
643        html_escape(&props.field),
644        border_class
645    ));
646    if props.required == Some(true) {
647        html.push_str(" required");
648    }
649    if props.disabled == Some(true) {
650        html.push_str(" disabled");
651    }
652    html.push('>');
653
654    if let Some(ref placeholder) = props.placeholder {
655        html.push_str(&format!(
656            "<option value=\"\">{}</option>",
657            html_escape(placeholder)
658        ));
659    }
660
661    for opt in &props.options {
662        let is_selected = selected_value.as_deref() == Some(&opt.value);
663        let selected_attr = if is_selected { " selected" } else { "" };
664        html.push_str(&format!(
665            "<option value=\"{}\"{}>{}</option>",
666            html_escape(&opt.value),
667            selected_attr,
668            html_escape(&opt.label)
669        ));
670    }
671
672    html.push_str("</select>");
673
674    if let Some(ref error) = props.error {
675        html.push_str(&format!(
676            "<p class=\"text-sm text-red-600\">{}</p>",
677            html_escape(error)
678        ));
679    }
680    html.push_str("</div>");
681    html
682}
683
684fn render_checkbox(props: &CheckboxProps, data: &Value) -> String {
685    // Resolve checked state: explicit `checked` prop wins, else data_path truthy.
686    let is_checked = if let Some(c) = props.checked {
687        c
688    } else if let Some(ref dp) = props.data_path {
689        resolve_path(data, dp)
690            .map(|v| match v {
691                Value::Bool(b) => *b,
692                Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
693                Value::String(s) => !s.is_empty() && s != "false" && s != "0",
694                Value::Null => false,
695                _ => true,
696            })
697            .unwrap_or(false)
698    } else {
699        false
700    };
701
702    let mut html = String::from("<div class=\"space-y-1\">");
703    html.push_str("<div class=\"flex items-center gap-2\">");
704    html.push_str(&format!(
705        "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"1\" class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500\"",
706        html_escape(&props.field),
707        html_escape(&props.field)
708    ));
709    if is_checked {
710        html.push_str(" checked");
711    }
712    if props.required == Some(true) {
713        html.push_str(" required");
714    }
715    if props.disabled == Some(true) {
716        html.push_str(" disabled");
717    }
718    html.push('>');
719    html.push_str(&format!(
720        "<label class=\"text-sm font-medium text-gray-700\" for=\"{}\">{}</label>",
721        html_escape(&props.field),
722        html_escape(&props.label)
723    ));
724    html.push_str("</div>");
725
726    if let Some(ref desc) = props.description {
727        html.push_str(&format!(
728            "<p class=\"ml-6 text-sm text-gray-500\">{}</p>",
729            html_escape(desc)
730        ));
731    }
732
733    if let Some(ref error) = props.error {
734        html.push_str(&format!(
735            "<p class=\"ml-6 text-sm text-red-600\">{}</p>",
736            html_escape(error)
737        ));
738    }
739    html.push_str("</div>");
740    html
741}
742
743fn render_switch(props: &SwitchProps, data: &Value) -> String {
744    // Resolve checked state: explicit `checked` prop wins, else data_path truthy.
745    let is_checked = if let Some(c) = props.checked {
746        c
747    } else if let Some(ref dp) = props.data_path {
748        resolve_path(data, dp)
749            .map(|v| match v {
750                Value::Bool(b) => *b,
751                Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
752                Value::String(s) => !s.is_empty() && s != "false" && s != "0",
753                Value::Null => false,
754                _ => true,
755            })
756            .unwrap_or(false)
757    } else {
758        false
759    };
760
761    let mut html = String::from("<div class=\"space-y-1\">");
762    html.push_str("<div class=\"flex items-center justify-between\">");
763
764    // Left side: label + description.
765    html.push_str("<div>");
766    html.push_str(&format!(
767        "<label class=\"text-sm font-medium text-gray-700\" for=\"{}\">{}</label>",
768        html_escape(&props.field),
769        html_escape(&props.label)
770    ));
771    if let Some(ref desc) = props.description {
772        html.push_str(&format!(
773            "<p class=\"text-sm text-gray-500\">{}</p>",
774            html_escape(desc)
775        ));
776    }
777    html.push_str("</div>");
778
779    // Right side: toggle.
780    html.push_str("<label class=\"relative inline-flex items-center cursor-pointer\">");
781    html.push_str(&format!(
782        "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"1\" class=\"sr-only peer\"",
783        html_escape(&props.field),
784        html_escape(&props.field)
785    ));
786    if is_checked {
787        html.push_str(" checked");
788    }
789    if props.required == Some(true) {
790        html.push_str(" required");
791    }
792    if props.disabled == Some(true) {
793        html.push_str(" disabled");
794    }
795    html.push('>');
796    html.push_str("<div class=\"w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-blue-600 peer-focus:ring-2 peer-focus:ring-blue-300 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full\"></div>");
797    html.push_str("</label>");
798    html.push_str("</div>");
799
800    if let Some(ref error) = props.error {
801        html.push_str(&format!(
802            "<p class=\"text-sm text-red-600\">{}</p>",
803            html_escape(error)
804        ));
805    }
806    html.push_str("</div>");
807    html
808}
809
810// ── Leaf component renderers ────────────────────────────────────────────
811
812fn render_text(props: &TextProps) -> String {
813    let content = html_escape(&props.content);
814    match props.element {
815        TextElement::P => format!("<p class=\"text-base text-gray-700\">{content}</p>"),
816        TextElement::H1 => format!("<h1 class=\"text-3xl font-bold text-gray-900\">{content}</h1>"),
817        TextElement::H2 => {
818            format!("<h2 class=\"text-2xl font-semibold text-gray-900\">{content}</h2>")
819        }
820        TextElement::H3 => {
821            format!("<h3 class=\"text-xl font-semibold text-gray-900\">{content}</h3>")
822        }
823        TextElement::Span => format!("<span class=\"text-base text-gray-700\">{content}</span>"),
824        TextElement::Div => format!("<div class=\"text-base text-gray-700\">{content}</div>"),
825        TextElement::Section => {
826            format!("<section class=\"text-base text-gray-700\">{content}</section>")
827        }
828    }
829}
830
831fn render_button(props: &ButtonProps) -> String {
832    let base = "inline-flex items-center justify-center rounded-md font-medium transition-colors";
833
834    let variant_classes = match props.variant {
835        ButtonVariant::Default => "bg-blue-600 text-white hover:bg-blue-700",
836        ButtonVariant::Secondary => "bg-gray-100 text-gray-900 hover:bg-gray-200",
837        ButtonVariant::Destructive => "bg-red-600 text-white hover:bg-red-700",
838        ButtonVariant::Outline => "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
839        ButtonVariant::Ghost => "text-gray-700 hover:bg-gray-100",
840        ButtonVariant::Link => "text-blue-600 underline hover:text-blue-700",
841    };
842
843    let size_classes = match props.size {
844        Size::Xs => "px-2 py-1 text-xs",
845        Size::Sm => "px-3 py-1.5 text-sm",
846        Size::Default => "px-4 py-2 text-sm",
847        Size::Lg => "px-6 py-3 text-base",
848    };
849
850    let disabled_classes = if props.disabled == Some(true) {
851        " opacity-50 cursor-not-allowed"
852    } else {
853        ""
854    };
855
856    let disabled_attr = if props.disabled == Some(true) {
857        " disabled"
858    } else {
859        ""
860    };
861
862    let label = html_escape(&props.label);
863
864    // Build icon + label content.
865    let content = if let Some(ref icon) = props.icon {
866        let icon_span = format!(
867            "<span class=\"icon\" data-icon=\"{}\">{}</span>",
868            html_escape(icon),
869            html_escape(icon)
870        );
871        let position = props.icon_position.as_ref().cloned().unwrap_or_default();
872        match position {
873            IconPosition::Left => format!("{icon_span} {label}"),
874            IconPosition::Right => format!("{label} {icon_span}"),
875        }
876    } else {
877        label
878    };
879
880    format!(
881        "<button class=\"{base} {variant_classes} {size_classes}{disabled_classes}\"{disabled_attr}>{content}</button>"
882    )
883}
884
885fn render_badge(props: &BadgeProps) -> String {
886    let base = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium";
887    let variant_classes = match props.variant {
888        BadgeVariant::Default => "bg-blue-100 text-blue-800",
889        BadgeVariant::Secondary => "bg-gray-100 text-gray-800",
890        BadgeVariant::Destructive => "bg-red-100 text-red-800",
891        BadgeVariant::Outline => "border border-gray-300 text-gray-700",
892    };
893    format!(
894        "<span class=\"{} {}\">{}</span>",
895        base,
896        variant_classes,
897        html_escape(&props.label)
898    )
899}
900
901fn render_alert(props: &AlertProps) -> String {
902    let variant_classes = match props.variant {
903        AlertVariant::Info => "bg-blue-50 border-blue-200 text-blue-800",
904        AlertVariant::Success => "bg-green-50 border-green-200 text-green-800",
905        AlertVariant::Warning => "bg-yellow-50 border-yellow-200 text-yellow-800",
906        AlertVariant::Error => "bg-red-50 border-red-200 text-red-800",
907    };
908    let mut html =
909        format!("<div role=\"alert\" class=\"rounded-md border p-4 {variant_classes}\">");
910    if let Some(ref title) = props.title {
911        html.push_str(&format!(
912            "<h4 class=\"font-semibold mb-1\">{}</h4>",
913            html_escape(title)
914        ));
915    }
916    html.push_str(&format!("<p>{}</p>", html_escape(&props.message)));
917    html.push_str("</div>");
918    html
919}
920
921fn render_separator(props: &SeparatorProps) -> String {
922    let orientation = props.orientation.as_ref().cloned().unwrap_or_default();
923    match orientation {
924        Orientation::Horizontal => "<hr class=\"my-4 border-gray-200\">".to_string(),
925        Orientation::Vertical => "<div class=\"mx-4 h-full w-px bg-gray-200\"></div>".to_string(),
926    }
927}
928
929fn render_progress(props: &ProgressProps) -> String {
930    let max = props.max.unwrap_or(100) as f64;
931    let pct = if max > 0.0 {
932        ((props.value as f64 * 100.0 / max).round() as u8).min(100)
933    } else {
934        0
935    };
936
937    let mut html = String::from("<div class=\"w-full\">");
938    if let Some(ref label) = props.label {
939        html.push_str(&format!(
940            "<div class=\"mb-1 text-sm text-gray-600\">{}</div>",
941            html_escape(label)
942        ));
943    }
944    html.push_str(&format!(
945        "<div class=\"w-full rounded-full bg-gray-200 h-2.5\"><div class=\"rounded-full bg-blue-600 h-2.5\" style=\"width: {pct}%\"></div></div>"
946    ));
947    html.push_str("</div>");
948    html
949}
950
951fn render_avatar(props: &AvatarProps) -> String {
952    let size = props.size.as_ref().cloned().unwrap_or_default();
953    let size_classes = match size {
954        Size::Xs => "h-6 w-6 text-xs",
955        Size::Sm => "h-8 w-8 text-sm",
956        Size::Default => "h-10 w-10 text-sm",
957        Size::Lg => "h-12 w-12 text-base",
958    };
959
960    if let Some(ref src) = props.src {
961        format!(
962            "<img src=\"{}\" alt=\"{}\" class=\"rounded-full object-cover {}\">",
963            html_escape(src),
964            html_escape(&props.alt),
965            size_classes
966        )
967    } else {
968        let fallback_text = props.fallback.as_deref().unwrap_or_else(|| {
969            // Use first characters of alt as fallback.
970            &props.alt
971        });
972        // Take first two chars for initials.
973        let initials: String = fallback_text.chars().take(2).collect();
974        format!(
975            "<span class=\"inline-flex items-center justify-center rounded-full bg-gray-200 text-gray-600 {}\">{}</span>",
976            size_classes,
977            html_escape(&initials)
978        )
979    }
980}
981
982fn render_skeleton(props: &SkeletonProps) -> String {
983    let width = props.width.as_deref().unwrap_or("100%");
984    let height = props.height.as_deref().unwrap_or("1rem");
985    let rounded = if props.rounded == Some(true) {
986        "rounded-full"
987    } else {
988        "rounded-md"
989    };
990    format!(
991        "<div class=\"animate-pulse bg-gray-200 {rounded}\" style=\"width: {width}; height: {height}\"></div>"
992    )
993}
994
995fn render_breadcrumb(props: &BreadcrumbProps) -> String {
996    let mut html =
997        String::from("<nav class=\"flex items-center space-x-2 text-sm text-gray-500\">");
998    let len = props.items.len();
999    for (i, item) in props.items.iter().enumerate() {
1000        let is_last = i == len - 1;
1001        if is_last {
1002            html.push_str(&format!(
1003                "<span class=\"text-gray-900 font-medium\">{}</span>",
1004                html_escape(&item.label)
1005            ));
1006        } else if let Some(ref url) = item.url {
1007            html.push_str(&format!(
1008                "<a href=\"{}\" class=\"hover:text-gray-700\">{}</a>",
1009                html_escape(url),
1010                html_escape(&item.label)
1011            ));
1012        } else {
1013            html.push_str(&format!("<span>{}</span>", html_escape(&item.label)));
1014        }
1015        if !is_last {
1016            html.push_str("<span>/</span>");
1017        }
1018    }
1019    html.push_str("</nav>");
1020    html
1021}
1022
1023fn render_pagination(props: &PaginationProps) -> String {
1024    if props.total == 0 || props.per_page == 0 {
1025        return String::new();
1026    }
1027
1028    let total_pages = props.total.div_ceil(props.per_page);
1029    if total_pages <= 1 {
1030        return String::new();
1031    }
1032
1033    let base_url = props.base_url.as_deref().unwrap_or("?");
1034    let current = props.current_page;
1035
1036    let mut html = String::from("<nav class=\"flex items-center space-x-1\">");
1037
1038    // Previous button.
1039    if current > 1 {
1040        html.push_str(&format!(
1041            "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-white text-gray-700 hover:bg-gray-50\">&laquo;</a>",
1042            html_escape(base_url),
1043            current - 1
1044        ));
1045    }
1046
1047    // Page numbers — show up to 7 with ellipsis.
1048    let pages = compute_page_range(current, total_pages);
1049    let mut prev_page = 0u32;
1050    for page in pages {
1051        if prev_page > 0 && page > prev_page + 1 {
1052            html.push_str("<span class=\"px-2 text-gray-400\">&hellip;</span>");
1053        }
1054        if page == current {
1055            html.push_str(&format!(
1056                "<span class=\"px-3 py-1 rounded-md bg-blue-600 text-white\">{page}</span>"
1057            ));
1058        } else {
1059            html.push_str(&format!(
1060                "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-white text-gray-700 hover:bg-gray-50\">{}</a>",
1061                html_escape(base_url),
1062                page,
1063                page
1064            ));
1065        }
1066        prev_page = page;
1067    }
1068
1069    // Next button.
1070    if current < total_pages {
1071        html.push_str(&format!(
1072            "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-white text-gray-700 hover:bg-gray-50\">&raquo;</a>",
1073            html_escape(base_url),
1074            current + 1
1075        ));
1076    }
1077
1078    html.push_str("</nav>");
1079    html
1080}
1081
1082/// Compute which page numbers to display (up to 7 entries).
1083fn compute_page_range(current: u32, total: u32) -> Vec<u32> {
1084    if total <= 7 {
1085        return (1..=total).collect();
1086    }
1087    let mut pages = Vec::new();
1088    pages.push(1);
1089    let start = current.saturating_sub(1).max(2);
1090    let end = (current + 1).min(total - 1);
1091    for p in start..=end {
1092        if !pages.contains(&p) {
1093            pages.push(p);
1094        }
1095    }
1096    if !pages.contains(&total) {
1097        pages.push(total);
1098    }
1099    pages.sort();
1100    pages.dedup();
1101    pages
1102}
1103
1104fn render_description_list(props: &DescriptionListProps) -> String {
1105    let columns = props.columns.unwrap_or(1);
1106    let mut html = format!("<dl class=\"grid grid-cols-{columns} gap-4\">");
1107    for item in &props.items {
1108        html.push_str(&format!(
1109            "<div><dt class=\"text-sm font-medium text-gray-500\">{}</dt><dd class=\"mt-1 text-sm text-gray-900\">{}</dd></div>",
1110            html_escape(&item.label),
1111            html_escape(&item.value)
1112        ));
1113    }
1114    html.push_str("</dl>");
1115    html
1116}
1117
1118// ── HTML escaping ───────────────────────────────────────────────────────
1119
1120/// Escape special HTML characters to prevent XSS.
1121pub(crate) fn html_escape(s: &str) -> String {
1122    let mut escaped = String::with_capacity(s.len());
1123    for c in s.chars() {
1124        match c {
1125            '&' => escaped.push_str("&amp;"),
1126            '<' => escaped.push_str("&lt;"),
1127            '>' => escaped.push_str("&gt;"),
1128            '"' => escaped.push_str("&quot;"),
1129            '\'' => escaped.push_str("&#x27;"),
1130            _ => escaped.push(c),
1131        }
1132    }
1133    escaped
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139    use crate::action::{Action, HttpMethod};
1140    use crate::component::*;
1141    use serde_json::json;
1142
1143    // ── Helpers ─────────────────────────────────────────────────────────
1144
1145    fn text_node(key: &str, content: &str, element: TextElement) -> ComponentNode {
1146        ComponentNode {
1147            key: key.to_string(),
1148            component: Component::Text(TextProps {
1149                content: content.to_string(),
1150                element,
1151            }),
1152            action: None,
1153            visibility: None,
1154        }
1155    }
1156
1157    fn button_node(key: &str, label: &str, variant: ButtonVariant, size: Size) -> ComponentNode {
1158        ComponentNode {
1159            key: key.to_string(),
1160            component: Component::Button(ButtonProps {
1161                label: label.to_string(),
1162                variant,
1163                size,
1164                disabled: None,
1165                icon: None,
1166                icon_position: None,
1167            }),
1168            action: None,
1169            visibility: None,
1170        }
1171    }
1172
1173    fn make_action(handler: &str, method: HttpMethod) -> Action {
1174        Action {
1175            handler: handler.to_string(),
1176            url: None,
1177            method,
1178            confirm: None,
1179            on_success: None,
1180            on_error: None,
1181        }
1182    }
1183
1184    fn make_action_with_url(handler: &str, method: HttpMethod, url: &str) -> Action {
1185        Action {
1186            handler: handler.to_string(),
1187            url: Some(url.to_string()),
1188            method,
1189            confirm: None,
1190            on_success: None,
1191            on_error: None,
1192        }
1193    }
1194
1195    // ── 1. render_to_html produces wrapper div ──────────────────────────
1196
1197    #[test]
1198    fn render_empty_view_produces_wrapper_div() {
1199        let view = JsonUiView::new();
1200        let html = render_to_html(&view, &json!({}));
1201        assert_eq!(html, "<div></div>");
1202    }
1203
1204    #[test]
1205    fn render_view_with_component_wraps_in_div() {
1206        let view = JsonUiView::new().component(text_node("t", "Hello", TextElement::P));
1207        let html = render_to_html(&view, &json!({}));
1208        assert!(html.starts_with("<div>"));
1209        assert!(html.ends_with("</div>"));
1210        assert!(html.contains("<p class=\"text-base text-gray-700\">Hello</p>"));
1211    }
1212
1213    // ── 2. Text variants ────────────────────────────────────────────────
1214
1215    #[test]
1216    fn text_p_variant() {
1217        let view = JsonUiView::new().component(text_node("t", "Paragraph", TextElement::P));
1218        let html = render_to_html(&view, &json!({}));
1219        assert!(html.contains("<p class=\"text-base text-gray-700\">Paragraph</p>"));
1220    }
1221
1222    #[test]
1223    fn text_h1_variant() {
1224        let view = JsonUiView::new().component(text_node("t", "Title", TextElement::H1));
1225        let html = render_to_html(&view, &json!({}));
1226        assert!(html.contains("<h1 class=\"text-3xl font-bold text-gray-900\">Title</h1>"));
1227    }
1228
1229    #[test]
1230    fn text_h2_variant() {
1231        let view = JsonUiView::new().component(text_node("t", "Subtitle", TextElement::H2));
1232        let html = render_to_html(&view, &json!({}));
1233        assert!(html.contains("<h2 class=\"text-2xl font-semibold text-gray-900\">Subtitle</h2>"));
1234    }
1235
1236    #[test]
1237    fn text_h3_variant() {
1238        let view = JsonUiView::new().component(text_node("t", "Section", TextElement::H3));
1239        let html = render_to_html(&view, &json!({}));
1240        assert!(html.contains("<h3 class=\"text-xl font-semibold text-gray-900\">Section</h3>"));
1241    }
1242
1243    #[test]
1244    fn text_span_variant() {
1245        let view = JsonUiView::new().component(text_node("t", "Inline", TextElement::Span));
1246        let html = render_to_html(&view, &json!({}));
1247        assert!(html.contains("<span class=\"text-base text-gray-700\">Inline</span>"));
1248    }
1249
1250    // ── 3. Button variants ──────────────────────────────────────────────
1251
1252    #[test]
1253    fn button_default_variant() {
1254        let view = JsonUiView::new().component(button_node(
1255            "b",
1256            "Click",
1257            ButtonVariant::Default,
1258            Size::Default,
1259        ));
1260        let html = render_to_html(&view, &json!({}));
1261        assert!(html.contains("bg-blue-600 text-white hover:bg-blue-700"));
1262        assert!(html.contains(">Click</button>"));
1263    }
1264
1265    #[test]
1266    fn button_secondary_variant() {
1267        let view = JsonUiView::new().component(button_node(
1268            "b",
1269            "Click",
1270            ButtonVariant::Secondary,
1271            Size::Default,
1272        ));
1273        let html = render_to_html(&view, &json!({}));
1274        assert!(html.contains("bg-gray-100 text-gray-900 hover:bg-gray-200"));
1275    }
1276
1277    #[test]
1278    fn button_destructive_variant() {
1279        let view = JsonUiView::new().component(button_node(
1280            "b",
1281            "Delete",
1282            ButtonVariant::Destructive,
1283            Size::Default,
1284        ));
1285        let html = render_to_html(&view, &json!({}));
1286        assert!(html.contains("bg-red-600 text-white hover:bg-red-700"));
1287    }
1288
1289    #[test]
1290    fn button_outline_variant() {
1291        let view = JsonUiView::new().component(button_node(
1292            "b",
1293            "Click",
1294            ButtonVariant::Outline,
1295            Size::Default,
1296        ));
1297        let html = render_to_html(&view, &json!({}));
1298        assert!(html.contains("border border-gray-300 bg-white text-gray-700"));
1299    }
1300
1301    #[test]
1302    fn button_ghost_variant() {
1303        let view = JsonUiView::new().component(button_node(
1304            "b",
1305            "Click",
1306            ButtonVariant::Ghost,
1307            Size::Default,
1308        ));
1309        let html = render_to_html(&view, &json!({}));
1310        assert!(html.contains("text-gray-700 hover:bg-gray-100"));
1311    }
1312
1313    #[test]
1314    fn button_link_variant() {
1315        let view = JsonUiView::new().component(button_node(
1316            "b",
1317            "Click",
1318            ButtonVariant::Link,
1319            Size::Default,
1320        ));
1321        let html = render_to_html(&view, &json!({}));
1322        assert!(html.contains("text-blue-600 underline hover:text-blue-700"));
1323    }
1324
1325    #[test]
1326    fn button_disabled_state() {
1327        let view = JsonUiView::new().component(ComponentNode {
1328            key: "b".to_string(),
1329            component: Component::Button(ButtonProps {
1330                label: "Disabled".to_string(),
1331                variant: ButtonVariant::Default,
1332                size: Size::Default,
1333                disabled: Some(true),
1334                icon: None,
1335                icon_position: None,
1336            }),
1337            action: None,
1338            visibility: None,
1339        });
1340        let html = render_to_html(&view, &json!({}));
1341        assert!(html.contains("opacity-50 cursor-not-allowed"));
1342        assert!(html.contains(" disabled"));
1343    }
1344
1345    #[test]
1346    fn button_with_icon_left() {
1347        let view = JsonUiView::new().component(ComponentNode {
1348            key: "b".to_string(),
1349            component: Component::Button(ButtonProps {
1350                label: "Save".to_string(),
1351                variant: ButtonVariant::Default,
1352                size: Size::Default,
1353                disabled: None,
1354                icon: Some("save".to_string()),
1355                icon_position: Some(IconPosition::Left),
1356            }),
1357            action: None,
1358            visibility: None,
1359        });
1360        let html = render_to_html(&view, &json!({}));
1361        assert!(html.contains("data-icon=\"save\""));
1362        // Icon span comes before label.
1363        let icon_pos = html.find("data-icon").unwrap();
1364        let label_pos = html.find("Save").unwrap();
1365        assert!(icon_pos < label_pos);
1366    }
1367
1368    #[test]
1369    fn button_with_icon_right() {
1370        let view = JsonUiView::new().component(ComponentNode {
1371            key: "b".to_string(),
1372            component: Component::Button(ButtonProps {
1373                label: "Next".to_string(),
1374                variant: ButtonVariant::Default,
1375                size: Size::Default,
1376                disabled: None,
1377                icon: Some("arrow-right".to_string()),
1378                icon_position: Some(IconPosition::Right),
1379            }),
1380            action: None,
1381            visibility: None,
1382        });
1383        let html = render_to_html(&view, &json!({}));
1384        assert!(html.contains("data-icon=\"arrow-right\""));
1385        // Label comes before icon span.
1386        let label_pos = html.find("Next").unwrap();
1387        let icon_pos = html.find("data-icon").unwrap();
1388        assert!(label_pos < icon_pos);
1389    }
1390
1391    // ── 4. Button sizes ─────────────────────────────────────────────────
1392
1393    #[test]
1394    fn button_size_xs() {
1395        let view =
1396            JsonUiView::new().component(button_node("b", "X", ButtonVariant::Default, Size::Xs));
1397        let html = render_to_html(&view, &json!({}));
1398        assert!(html.contains("px-2 py-1 text-xs"));
1399    }
1400
1401    #[test]
1402    fn button_size_sm() {
1403        let view =
1404            JsonUiView::new().component(button_node("b", "S", ButtonVariant::Default, Size::Sm));
1405        let html = render_to_html(&view, &json!({}));
1406        assert!(html.contains("px-3 py-1.5 text-sm"));
1407    }
1408
1409    #[test]
1410    fn button_size_default() {
1411        let view = JsonUiView::new().component(button_node(
1412            "b",
1413            "D",
1414            ButtonVariant::Default,
1415            Size::Default,
1416        ));
1417        let html = render_to_html(&view, &json!({}));
1418        assert!(html.contains("px-4 py-2 text-sm"));
1419    }
1420
1421    #[test]
1422    fn button_size_lg() {
1423        let view =
1424            JsonUiView::new().component(button_node("b", "L", ButtonVariant::Default, Size::Lg));
1425        let html = render_to_html(&view, &json!({}));
1426        assert!(html.contains("px-6 py-3 text-base"));
1427    }
1428
1429    // ── 5. Badge variants ───────────────────────────────────────────────
1430
1431    #[test]
1432    fn badge_default_variant() {
1433        let view = JsonUiView::new().component(ComponentNode {
1434            key: "bg".to_string(),
1435            component: Component::Badge(BadgeProps {
1436                label: "New".to_string(),
1437                variant: BadgeVariant::Default,
1438            }),
1439            action: None,
1440            visibility: None,
1441        });
1442        let html = render_to_html(&view, &json!({}));
1443        assert!(html.contains("bg-blue-100 text-blue-800"));
1444        assert!(html.contains(">New</span>"));
1445    }
1446
1447    #[test]
1448    fn badge_secondary_variant() {
1449        let view = JsonUiView::new().component(ComponentNode {
1450            key: "bg".to_string(),
1451            component: Component::Badge(BadgeProps {
1452                label: "Draft".to_string(),
1453                variant: BadgeVariant::Secondary,
1454            }),
1455            action: None,
1456            visibility: None,
1457        });
1458        let html = render_to_html(&view, &json!({}));
1459        assert!(html.contains("bg-gray-100 text-gray-800"));
1460    }
1461
1462    #[test]
1463    fn badge_destructive_variant() {
1464        let view = JsonUiView::new().component(ComponentNode {
1465            key: "bg".to_string(),
1466            component: Component::Badge(BadgeProps {
1467                label: "Deleted".to_string(),
1468                variant: BadgeVariant::Destructive,
1469            }),
1470            action: None,
1471            visibility: None,
1472        });
1473        let html = render_to_html(&view, &json!({}));
1474        assert!(html.contains("bg-red-100 text-red-800"));
1475    }
1476
1477    #[test]
1478    fn badge_outline_variant() {
1479        let view = JsonUiView::new().component(ComponentNode {
1480            key: "bg".to_string(),
1481            component: Component::Badge(BadgeProps {
1482                label: "Info".to_string(),
1483                variant: BadgeVariant::Outline,
1484            }),
1485            action: None,
1486            visibility: None,
1487        });
1488        let html = render_to_html(&view, &json!({}));
1489        assert!(html.contains("border border-gray-300 text-gray-700"));
1490    }
1491
1492    #[test]
1493    fn badge_has_base_classes() {
1494        let view = JsonUiView::new().component(ComponentNode {
1495            key: "bg".to_string(),
1496            component: Component::Badge(BadgeProps {
1497                label: "Test".to_string(),
1498                variant: BadgeVariant::Default,
1499            }),
1500            action: None,
1501            visibility: None,
1502        });
1503        let html = render_to_html(&view, &json!({}));
1504        assert!(html
1505            .contains("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"));
1506    }
1507
1508    // ── 6. Alert variants ───────────────────────────────────────────────
1509
1510    #[test]
1511    fn alert_info_variant() {
1512        let view = JsonUiView::new().component(ComponentNode {
1513            key: "a".to_string(),
1514            component: Component::Alert(AlertProps {
1515                message: "Info message".to_string(),
1516                variant: AlertVariant::Info,
1517                title: None,
1518            }),
1519            action: None,
1520            visibility: None,
1521        });
1522        let html = render_to_html(&view, &json!({}));
1523        assert!(html.contains("bg-blue-50 border-blue-200 text-blue-800"));
1524        assert!(html.contains("role=\"alert\""));
1525        assert!(html.contains("<p>Info message</p>"));
1526    }
1527
1528    #[test]
1529    fn alert_success_variant() {
1530        let view = JsonUiView::new().component(ComponentNode {
1531            key: "a".to_string(),
1532            component: Component::Alert(AlertProps {
1533                message: "Done".to_string(),
1534                variant: AlertVariant::Success,
1535                title: None,
1536            }),
1537            action: None,
1538            visibility: None,
1539        });
1540        let html = render_to_html(&view, &json!({}));
1541        assert!(html.contains("bg-green-50 border-green-200 text-green-800"));
1542    }
1543
1544    #[test]
1545    fn alert_warning_variant() {
1546        let view = JsonUiView::new().component(ComponentNode {
1547            key: "a".to_string(),
1548            component: Component::Alert(AlertProps {
1549                message: "Careful".to_string(),
1550                variant: AlertVariant::Warning,
1551                title: None,
1552            }),
1553            action: None,
1554            visibility: None,
1555        });
1556        let html = render_to_html(&view, &json!({}));
1557        assert!(html.contains("bg-yellow-50 border-yellow-200 text-yellow-800"));
1558    }
1559
1560    #[test]
1561    fn alert_error_variant() {
1562        let view = JsonUiView::new().component(ComponentNode {
1563            key: "a".to_string(),
1564            component: Component::Alert(AlertProps {
1565                message: "Failed".to_string(),
1566                variant: AlertVariant::Error,
1567                title: None,
1568            }),
1569            action: None,
1570            visibility: None,
1571        });
1572        let html = render_to_html(&view, &json!({}));
1573        assert!(html.contains("bg-red-50 border-red-200 text-red-800"));
1574    }
1575
1576    #[test]
1577    fn alert_with_title() {
1578        let view = JsonUiView::new().component(ComponentNode {
1579            key: "a".to_string(),
1580            component: Component::Alert(AlertProps {
1581                message: "Details here".to_string(),
1582                variant: AlertVariant::Warning,
1583                title: Some("Warning".to_string()),
1584            }),
1585            action: None,
1586            visibility: None,
1587        });
1588        let html = render_to_html(&view, &json!({}));
1589        assert!(html.contains("<h4 class=\"font-semibold mb-1\">Warning</h4>"));
1590        assert!(html.contains("<p>Details here</p>"));
1591    }
1592
1593    #[test]
1594    fn alert_without_title() {
1595        let view = JsonUiView::new().component(ComponentNode {
1596            key: "a".to_string(),
1597            component: Component::Alert(AlertProps {
1598                message: "No title".to_string(),
1599                variant: AlertVariant::Info,
1600                title: None,
1601            }),
1602            action: None,
1603            visibility: None,
1604        });
1605        let html = render_to_html(&view, &json!({}));
1606        assert!(!html.contains("<h4"));
1607    }
1608
1609    // ── 7. Separator orientations ───────────────────────────────────────
1610
1611    #[test]
1612    fn separator_horizontal() {
1613        let view = JsonUiView::new().component(ComponentNode {
1614            key: "s".to_string(),
1615            component: Component::Separator(SeparatorProps {
1616                orientation: Some(Orientation::Horizontal),
1617            }),
1618            action: None,
1619            visibility: None,
1620        });
1621        let html = render_to_html(&view, &json!({}));
1622        assert!(html.contains("<hr class=\"my-4 border-gray-200\">"));
1623    }
1624
1625    #[test]
1626    fn separator_vertical() {
1627        let view = JsonUiView::new().component(ComponentNode {
1628            key: "s".to_string(),
1629            component: Component::Separator(SeparatorProps {
1630                orientation: Some(Orientation::Vertical),
1631            }),
1632            action: None,
1633            visibility: None,
1634        });
1635        let html = render_to_html(&view, &json!({}));
1636        assert!(html.contains("<div class=\"mx-4 h-full w-px bg-gray-200\"></div>"));
1637    }
1638
1639    #[test]
1640    fn separator_default_is_horizontal() {
1641        let view = JsonUiView::new().component(ComponentNode {
1642            key: "s".to_string(),
1643            component: Component::Separator(SeparatorProps { orientation: None }),
1644            action: None,
1645            visibility: None,
1646        });
1647        let html = render_to_html(&view, &json!({}));
1648        assert!(html.contains("<hr"));
1649    }
1650
1651    // ── 8. Progress ─────────────────────────────────────────────────────
1652
1653    #[test]
1654    fn progress_renders_bar() {
1655        let view = JsonUiView::new().component(ComponentNode {
1656            key: "p".to_string(),
1657            component: Component::Progress(ProgressProps {
1658                value: 50,
1659                max: None,
1660                label: None,
1661            }),
1662            action: None,
1663            visibility: None,
1664        });
1665        let html = render_to_html(&view, &json!({}));
1666        assert!(html.contains("style=\"width: 50%\""));
1667        assert!(html.contains("bg-blue-600 h-2.5"));
1668    }
1669
1670    #[test]
1671    fn progress_with_label() {
1672        let view = JsonUiView::new().component(ComponentNode {
1673            key: "p".to_string(),
1674            component: Component::Progress(ProgressProps {
1675                value: 75,
1676                max: None,
1677                label: Some("Uploading...".to_string()),
1678            }),
1679            action: None,
1680            visibility: None,
1681        });
1682        let html = render_to_html(&view, &json!({}));
1683        assert!(html.contains("Uploading..."));
1684        assert!(html.contains("text-sm text-gray-600"));
1685    }
1686
1687    #[test]
1688    fn progress_with_custom_max() {
1689        let view = JsonUiView::new().component(ComponentNode {
1690            key: "p".to_string(),
1691            component: Component::Progress(ProgressProps {
1692                value: 25,
1693                max: Some(50),
1694                label: None,
1695            }),
1696            action: None,
1697            visibility: None,
1698        });
1699        let html = render_to_html(&view, &json!({}));
1700        // 25/50 = 50%
1701        assert!(html.contains("style=\"width: 50%\""));
1702    }
1703
1704    // ── 9. Avatar ───────────────────────────────────────────────────────
1705
1706    #[test]
1707    fn avatar_with_src() {
1708        let view = JsonUiView::new().component(ComponentNode {
1709            key: "av".to_string(),
1710            component: Component::Avatar(AvatarProps {
1711                src: Some("/img/user.jpg".to_string()),
1712                alt: "User".to_string(),
1713                fallback: None,
1714                size: None,
1715            }),
1716            action: None,
1717            visibility: None,
1718        });
1719        let html = render_to_html(&view, &json!({}));
1720        assert!(html.contains("<img"));
1721        assert!(html.contains("src=\"/img/user.jpg\""));
1722        assert!(html.contains("alt=\"User\""));
1723        assert!(html.contains("rounded-full object-cover"));
1724    }
1725
1726    #[test]
1727    fn avatar_without_src_uses_fallback() {
1728        let view = JsonUiView::new().component(ComponentNode {
1729            key: "av".to_string(),
1730            component: Component::Avatar(AvatarProps {
1731                src: None,
1732                alt: "John Doe".to_string(),
1733                fallback: Some("JD".to_string()),
1734                size: None,
1735            }),
1736            action: None,
1737            visibility: None,
1738        });
1739        let html = render_to_html(&view, &json!({}));
1740        assert!(!html.contains("<img"));
1741        assert!(html.contains("<span"));
1742        assert!(html.contains("bg-gray-200 text-gray-600"));
1743        assert!(html.contains(">JD</span>"));
1744    }
1745
1746    #[test]
1747    fn avatar_without_src_or_fallback_uses_alt_initials() {
1748        let view = JsonUiView::new().component(ComponentNode {
1749            key: "av".to_string(),
1750            component: Component::Avatar(AvatarProps {
1751                src: None,
1752                alt: "Alice".to_string(),
1753                fallback: None,
1754                size: Some(Size::Lg),
1755            }),
1756            action: None,
1757            visibility: None,
1758        });
1759        let html = render_to_html(&view, &json!({}));
1760        assert!(html.contains(">Al</span>"));
1761        assert!(html.contains("h-12 w-12 text-base"));
1762    }
1763
1764    // ── 10. Skeleton ────────────────────────────────────────────────────
1765
1766    #[test]
1767    fn skeleton_default() {
1768        let view = JsonUiView::new().component(ComponentNode {
1769            key: "sk".to_string(),
1770            component: Component::Skeleton(SkeletonProps {
1771                width: None,
1772                height: None,
1773                rounded: None,
1774            }),
1775            action: None,
1776            visibility: None,
1777        });
1778        let html = render_to_html(&view, &json!({}));
1779        assert!(html.contains("animate-pulse bg-gray-200"));
1780        assert!(html.contains("rounded-md"));
1781        assert!(html.contains("width: 100%"));
1782        assert!(html.contains("height: 1rem"));
1783    }
1784
1785    #[test]
1786    fn skeleton_custom_dimensions() {
1787        let view = JsonUiView::new().component(ComponentNode {
1788            key: "sk".to_string(),
1789            component: Component::Skeleton(SkeletonProps {
1790                width: Some("200px".to_string()),
1791                height: Some("40px".to_string()),
1792                rounded: Some(true),
1793            }),
1794            action: None,
1795            visibility: None,
1796        });
1797        let html = render_to_html(&view, &json!({}));
1798        assert!(html.contains("rounded-full"));
1799        assert!(html.contains("width: 200px"));
1800        assert!(html.contains("height: 40px"));
1801    }
1802
1803    // ── 11. Breadcrumb ──────────────────────────────────────────────────
1804
1805    #[test]
1806    fn breadcrumb_items_with_links() {
1807        let view = JsonUiView::new().component(ComponentNode {
1808            key: "bc".to_string(),
1809            component: Component::Breadcrumb(BreadcrumbProps {
1810                items: vec![
1811                    BreadcrumbItem {
1812                        label: "Home".to_string(),
1813                        url: Some("/".to_string()),
1814                    },
1815                    BreadcrumbItem {
1816                        label: "Users".to_string(),
1817                        url: Some("/users".to_string()),
1818                    },
1819                    BreadcrumbItem {
1820                        label: "Edit".to_string(),
1821                        url: None,
1822                    },
1823                ],
1824            }),
1825            action: None,
1826            visibility: None,
1827        });
1828        let html = render_to_html(&view, &json!({}));
1829        assert!(html.contains("<nav"));
1830        assert!(html.contains("<a href=\"/\" class=\"hover:text-gray-700\">Home</a>"));
1831        assert!(html.contains("<a href=\"/users\" class=\"hover:text-gray-700\">Users</a>"));
1832        // Last item is plain span, not a link.
1833        assert!(html.contains("<span class=\"text-gray-900 font-medium\">Edit</span>"));
1834        // Separators between items.
1835        assert!(html.contains("<span>/</span>"));
1836    }
1837
1838    #[test]
1839    fn breadcrumb_single_item() {
1840        let view = JsonUiView::new().component(ComponentNode {
1841            key: "bc".to_string(),
1842            component: Component::Breadcrumb(BreadcrumbProps {
1843                items: vec![BreadcrumbItem {
1844                    label: "Home".to_string(),
1845                    url: Some("/".to_string()),
1846                }],
1847            }),
1848            action: None,
1849            visibility: None,
1850        });
1851        let html = render_to_html(&view, &json!({}));
1852        // Single item is the last item, rendered as font-medium span.
1853        assert!(html.contains("<span class=\"text-gray-900 font-medium\">Home</span>"));
1854        // No separator.
1855        assert!(!html.contains("<span>/</span>"));
1856    }
1857
1858    // ── 12. Pagination ──────────────────────────────────────────────────
1859
1860    #[test]
1861    fn pagination_renders_page_links() {
1862        let view = JsonUiView::new().component(ComponentNode {
1863            key: "pg".to_string(),
1864            component: Component::Pagination(PaginationProps {
1865                current_page: 2,
1866                per_page: 10,
1867                total: 50,
1868                base_url: None,
1869            }),
1870            action: None,
1871            visibility: None,
1872        });
1873        let html = render_to_html(&view, &json!({}));
1874        assert!(html.contains("<nav"));
1875        // Current page has active class.
1876        assert!(html.contains("bg-blue-600 text-white\">2</span>"));
1877        // Other pages are links.
1878        assert!(html.contains("?page=1"));
1879        assert!(html.contains("?page=3"));
1880    }
1881
1882    #[test]
1883    fn pagination_single_page_produces_no_output() {
1884        let view = JsonUiView::new().component(ComponentNode {
1885            key: "pg".to_string(),
1886            component: Component::Pagination(PaginationProps {
1887                current_page: 1,
1888                per_page: 10,
1889                total: 5,
1890                base_url: None,
1891            }),
1892            action: None,
1893            visibility: None,
1894        });
1895        let html = render_to_html(&view, &json!({}));
1896        // Single page: no nav rendered.
1897        assert!(!html.contains("<nav"));
1898    }
1899
1900    #[test]
1901    fn pagination_prev_and_next_buttons() {
1902        let view = JsonUiView::new().component(ComponentNode {
1903            key: "pg".to_string(),
1904            component: Component::Pagination(PaginationProps {
1905                current_page: 3,
1906                per_page: 10,
1907                total: 100,
1908                base_url: None,
1909            }),
1910            action: None,
1911            visibility: None,
1912        });
1913        let html = render_to_html(&view, &json!({}));
1914        // Prev button.
1915        assert!(html.contains("?page=2"));
1916        // Next button.
1917        assert!(html.contains("?page=4"));
1918    }
1919
1920    #[test]
1921    fn pagination_no_prev_on_first_page() {
1922        let view = JsonUiView::new().component(ComponentNode {
1923            key: "pg".to_string(),
1924            component: Component::Pagination(PaginationProps {
1925                current_page: 1,
1926                per_page: 10,
1927                total: 30,
1928                base_url: None,
1929            }),
1930            action: None,
1931            visibility: None,
1932        });
1933        let html = render_to_html(&view, &json!({}));
1934        // Should not have prev link (&laquo;).
1935        assert!(!html.contains("&laquo;"));
1936        // Should have next link.
1937        assert!(html.contains("&raquo;"));
1938    }
1939
1940    #[test]
1941    fn pagination_custom_base_url() {
1942        let view = JsonUiView::new().component(ComponentNode {
1943            key: "pg".to_string(),
1944            component: Component::Pagination(PaginationProps {
1945                current_page: 1,
1946                per_page: 10,
1947                total: 30,
1948                base_url: Some("/users?sort=name&".to_string()),
1949            }),
1950            action: None,
1951            visibility: None,
1952        });
1953        let html = render_to_html(&view, &json!({}));
1954        assert!(html.contains("/users?sort=name&amp;page=2"));
1955    }
1956
1957    // ── 13. DescriptionList ─────────────────────────────────────────────
1958
1959    #[test]
1960    fn description_list_renders_dl_dt_dd() {
1961        let view = JsonUiView::new().component(ComponentNode {
1962            key: "dl".to_string(),
1963            component: Component::DescriptionList(DescriptionListProps {
1964                items: vec![
1965                    DescriptionItem {
1966                        label: "Name".to_string(),
1967                        value: "Alice".to_string(),
1968                        format: None,
1969                    },
1970                    DescriptionItem {
1971                        label: "Email".to_string(),
1972                        value: "alice@example.com".to_string(),
1973                        format: None,
1974                    },
1975                ],
1976                columns: None,
1977            }),
1978            action: None,
1979            visibility: None,
1980        });
1981        let html = render_to_html(&view, &json!({}));
1982        assert!(html.contains("<dl"));
1983        assert!(html.contains("grid-cols-1"));
1984        assert!(html.contains("<dt class=\"text-sm font-medium text-gray-500\">Name</dt>"));
1985        assert!(html.contains("<dd class=\"mt-1 text-sm text-gray-900\">Alice</dd>"));
1986        assert!(html.contains("<dt class=\"text-sm font-medium text-gray-500\">Email</dt>"));
1987    }
1988
1989    #[test]
1990    fn description_list_with_columns() {
1991        let view = JsonUiView::new().component(ComponentNode {
1992            key: "dl".to_string(),
1993            component: Component::DescriptionList(DescriptionListProps {
1994                items: vec![DescriptionItem {
1995                    label: "Status".to_string(),
1996                    value: "Active".to_string(),
1997                    format: None,
1998                }],
1999                columns: Some(3),
2000            }),
2001            action: None,
2002            visibility: None,
2003        });
2004        let html = render_to_html(&view, &json!({}));
2005        assert!(html.contains("grid-cols-3"));
2006    }
2007
2008    // ── 14. XSS prevention ──────────────────────────────────────────────
2009
2010    #[test]
2011    fn xss_script_tags_escaped_in_text() {
2012        let view = JsonUiView::new().component(text_node(
2013            "t",
2014            "<script>alert('xss')</script>",
2015            TextElement::P,
2016        ));
2017        let html = render_to_html(&view, &json!({}));
2018        assert!(!html.contains("<script>"));
2019        assert!(html.contains("&lt;script&gt;"));
2020        assert!(html.contains("&#x27;"));
2021    }
2022
2023    #[test]
2024    fn xss_quotes_escaped_in_attributes() {
2025        let view = JsonUiView::new().component(ComponentNode {
2026            key: "av".to_string(),
2027            component: Component::Avatar(AvatarProps {
2028                src: Some("x\" onload=\"alert(1)".to_string()),
2029                alt: "Test".to_string(),
2030                fallback: None,
2031                size: None,
2032            }),
2033            action: None,
2034            visibility: None,
2035        });
2036        let html = render_to_html(&view, &json!({}));
2037        // Quotes are escaped so the attacker cannot break out of the attribute.
2038        assert!(html.contains("&quot;"));
2039        // The src attribute value stays intact within quotes (no breakout).
2040        assert!(html.contains("src=\"x&quot; onload=&quot;alert(1)\""));
2041    }
2042
2043    #[test]
2044    fn xss_in_button_label() {
2045        let view = JsonUiView::new().component(ComponentNode {
2046            key: "b".to_string(),
2047            component: Component::Button(ButtonProps {
2048                label: "<img src=x onerror=alert(1)>".to_string(),
2049                variant: ButtonVariant::Default,
2050                size: Size::Default,
2051                disabled: None,
2052                icon: None,
2053                icon_position: None,
2054            }),
2055            action: None,
2056            visibility: None,
2057        });
2058        let html = render_to_html(&view, &json!({}));
2059        assert!(!html.contains("<img"));
2060        assert!(html.contains("&lt;img"));
2061    }
2062
2063    #[test]
2064    fn xss_ampersand_in_content() {
2065        let view = JsonUiView::new().component(text_node("t", "Tom & Jerry", TextElement::P));
2066        let html = render_to_html(&view, &json!({}));
2067        assert!(html.contains("Tom &amp; Jerry"));
2068    }
2069
2070    #[test]
2071    fn html_escape_function_covers_all_chars() {
2072        let result = html_escape("&<>\"'normal");
2073        assert_eq!(result, "&amp;&lt;&gt;&quot;&#x27;normal");
2074    }
2075
2076    // ── 15. Action wrapping ─────────────────────────────────────────────
2077
2078    #[test]
2079    fn get_action_wraps_in_anchor() {
2080        let view = JsonUiView::new().component(ComponentNode {
2081            key: "b".to_string(),
2082            component: Component::Button(ButtonProps {
2083                label: "View".to_string(),
2084                variant: ButtonVariant::Default,
2085                size: Size::Default,
2086                disabled: None,
2087                icon: None,
2088                icon_position: None,
2089            }),
2090            action: Some(make_action_with_url(
2091                "users.show",
2092                HttpMethod::Get,
2093                "/users/1",
2094            )),
2095            visibility: None,
2096        });
2097        let html = render_to_html(&view, &json!({}));
2098        assert!(html.contains("<a href=\"/users/1\" class=\"block\">"));
2099        assert!(html.contains("</a>"));
2100        assert!(html.contains("<button"));
2101    }
2102
2103    #[test]
2104    fn post_action_does_not_wrap_in_anchor() {
2105        let view = JsonUiView::new().component(ComponentNode {
2106            key: "b".to_string(),
2107            component: Component::Button(ButtonProps {
2108                label: "Submit".to_string(),
2109                variant: ButtonVariant::Default,
2110                size: Size::Default,
2111                disabled: None,
2112                icon: None,
2113                icon_position: None,
2114            }),
2115            action: Some(make_action_with_url(
2116                "users.store",
2117                HttpMethod::Post,
2118                "/users",
2119            )),
2120            visibility: None,
2121        });
2122        let html = render_to_html(&view, &json!({}));
2123        assert!(!html.contains("<a href="));
2124        assert!(html.contains("<button"));
2125    }
2126
2127    #[test]
2128    fn get_action_without_url_does_not_wrap() {
2129        let view = JsonUiView::new().component(ComponentNode {
2130            key: "b".to_string(),
2131            component: Component::Button(ButtonProps {
2132                label: "View".to_string(),
2133                variant: ButtonVariant::Default,
2134                size: Size::Default,
2135                disabled: None,
2136                icon: None,
2137                icon_position: None,
2138            }),
2139            action: Some(make_action("users.show", HttpMethod::Get)),
2140            visibility: None,
2141        });
2142        let html = render_to_html(&view, &json!({}));
2143        assert!(!html.contains("<a href="));
2144    }
2145
2146    #[test]
2147    fn delete_action_does_not_wrap_in_anchor() {
2148        let view = JsonUiView::new().component(ComponentNode {
2149            key: "b".to_string(),
2150            component: Component::Button(ButtonProps {
2151                label: "Delete".to_string(),
2152                variant: ButtonVariant::Destructive,
2153                size: Size::Default,
2154                disabled: None,
2155                icon: None,
2156                icon_position: None,
2157            }),
2158            action: Some(make_action_with_url(
2159                "users.destroy",
2160                HttpMethod::Delete,
2161                "/users/1",
2162            )),
2163            visibility: None,
2164        });
2165        let html = render_to_html(&view, &json!({}));
2166        assert!(!html.contains("<a href="));
2167    }
2168
2169    #[test]
2170    fn action_url_is_html_escaped() {
2171        let view = JsonUiView::new().component(ComponentNode {
2172            key: "b".to_string(),
2173            component: Component::Button(ButtonProps {
2174                label: "View".to_string(),
2175                variant: ButtonVariant::Default,
2176                size: Size::Default,
2177                disabled: None,
2178                icon: None,
2179                icon_position: None,
2180            }),
2181            action: Some(make_action_with_url(
2182                "users.show",
2183                HttpMethod::Get,
2184                "/users?id=1&name=test",
2185            )),
2186            visibility: None,
2187        });
2188        let html = render_to_html(&view, &json!({}));
2189        assert!(html.contains("href=\"/users?id=1&amp;name=test\""));
2190    }
2191
2192    // ── 16. Card ───────────────────────────────────────────────────────
2193
2194    #[test]
2195    fn card_renders_title_and_description() {
2196        let view = JsonUiView::new().component(ComponentNode {
2197            key: "c".to_string(),
2198            component: Component::Card(CardProps {
2199                title: "My Card".to_string(),
2200                description: Some("A description".to_string()),
2201                children: vec![],
2202                footer: vec![],
2203            }),
2204            action: None,
2205            visibility: None,
2206        });
2207        let html = render_to_html(&view, &json!({}));
2208        assert!(html.contains("rounded-lg border border-gray-200 bg-white shadow-sm"));
2209        assert!(html.contains("<h3 class=\"text-lg font-semibold text-gray-900\">My Card</h3>"));
2210        assert!(html.contains("<p class=\"mt-1 text-sm text-gray-500\">A description</p>"));
2211    }
2212
2213    #[test]
2214    fn card_renders_children_recursively() {
2215        let view = JsonUiView::new().component(ComponentNode {
2216            key: "c".to_string(),
2217            component: Component::Card(CardProps {
2218                title: "Card".to_string(),
2219                description: None,
2220                children: vec![text_node("t", "Child content", TextElement::P)],
2221                footer: vec![],
2222            }),
2223            action: None,
2224            visibility: None,
2225        });
2226        let html = render_to_html(&view, &json!({}));
2227        assert!(html.contains("mt-4 space-y-4"));
2228        assert!(html.contains("Child content"));
2229    }
2230
2231    #[test]
2232    fn card_renders_footer() {
2233        let view = JsonUiView::new().component(ComponentNode {
2234            key: "c".to_string(),
2235            component: Component::Card(CardProps {
2236                title: "Card".to_string(),
2237                description: None,
2238                children: vec![],
2239                footer: vec![button_node(
2240                    "btn",
2241                    "Save",
2242                    ButtonVariant::Default,
2243                    Size::Default,
2244                )],
2245            }),
2246            action: None,
2247            visibility: None,
2248        });
2249        let html = render_to_html(&view, &json!({}));
2250        assert!(html.contains("border-t border-gray-200 px-6 py-4 flex items-center gap-2"));
2251        assert!(html.contains(">Save</button>"));
2252    }
2253
2254    // ── 17. Modal ──────────────────────────────────────────────────────
2255
2256    #[test]
2257    fn modal_renders_details_summary() {
2258        let view = JsonUiView::new().component(ComponentNode {
2259            key: "m".to_string(),
2260            component: Component::Modal(ModalProps {
2261                title: "Confirm".to_string(),
2262                description: Some("Are you sure?".to_string()),
2263                children: vec![text_node("t", "Body text", TextElement::P)],
2264                footer: vec![button_node(
2265                    "ok",
2266                    "OK",
2267                    ButtonVariant::Default,
2268                    Size::Default,
2269                )],
2270                trigger_label: Some("Open Modal".to_string()),
2271            }),
2272            action: None,
2273            visibility: None,
2274        });
2275        let html = render_to_html(&view, &json!({}));
2276        assert!(html.contains("<details class=\"group\">"));
2277        assert!(html.contains("<summary"));
2278        assert!(html.contains("Open Modal</summary>"));
2279        assert!(html.contains("<h3 class=\"text-lg font-semibold text-gray-900\">Confirm</h3>"));
2280        assert!(html.contains("Are you sure?"));
2281        assert!(html.contains("Body text"));
2282        assert!(html.contains(">OK</button>"));
2283        assert!(html.contains("</details>"));
2284    }
2285
2286    #[test]
2287    fn modal_default_trigger_label() {
2288        let view = JsonUiView::new().component(ComponentNode {
2289            key: "m".to_string(),
2290            component: Component::Modal(ModalProps {
2291                title: "Dialog".to_string(),
2292                description: None,
2293                children: vec![],
2294                footer: vec![],
2295                trigger_label: None,
2296            }),
2297            action: None,
2298            visibility: None,
2299        });
2300        let html = render_to_html(&view, &json!({}));
2301        assert!(html.contains("Open</summary>"));
2302    }
2303
2304    // ── 18. Tabs ───────────────────────────────────────────────────────
2305
2306    #[test]
2307    fn tabs_renders_only_default_tab_content() {
2308        let view = JsonUiView::new().component(ComponentNode {
2309            key: "tabs".to_string(),
2310            component: Component::Tabs(TabsProps {
2311                default_tab: "general".to_string(),
2312                tabs: vec![
2313                    Tab {
2314                        value: "general".to_string(),
2315                        label: "General".to_string(),
2316                        children: vec![text_node("t1", "General content", TextElement::P)],
2317                    },
2318                    Tab {
2319                        value: "security".to_string(),
2320                        label: "Security".to_string(),
2321                        children: vec![text_node("t2", "Security content", TextElement::P)],
2322                    },
2323                ],
2324            }),
2325            action: None,
2326            visibility: None,
2327        });
2328        let html = render_to_html(&view, &json!({}));
2329        // Active tab styling.
2330        assert!(html.contains("border-b-2 border-blue-600 text-blue-600"));
2331        assert!(html.contains(">General</span>"));
2332        // Inactive tab styling.
2333        assert!(html.contains("border-transparent text-gray-500"));
2334        assert!(html.contains(">Security</span>"));
2335        // Only default tab content rendered.
2336        assert!(html.contains("General content"));
2337        assert!(!html.contains("Security content"));
2338    }
2339
2340    // ── 19. Form ───────────────────────────────────────────────────────
2341
2342    #[test]
2343    fn form_renders_action_url_and_method() {
2344        let view = JsonUiView::new().component(ComponentNode {
2345            key: "f".to_string(),
2346            component: Component::Form(FormProps {
2347                action: Action {
2348                    handler: "users.store".to_string(),
2349                    url: Some("/users".to_string()),
2350                    method: HttpMethod::Post,
2351                    confirm: None,
2352                    on_success: None,
2353                    on_error: None,
2354                },
2355                fields: vec![],
2356                method: None,
2357            }),
2358            action: None,
2359            visibility: None,
2360        });
2361        let html = render_to_html(&view, &json!({}));
2362        assert!(html.contains("action=\"/users\""));
2363        assert!(html.contains("method=\"post\""));
2364        assert!(html.contains("class=\"space-y-4\""));
2365    }
2366
2367    #[test]
2368    fn form_method_spoofing_for_delete() {
2369        let view = JsonUiView::new().component(ComponentNode {
2370            key: "f".to_string(),
2371            component: Component::Form(FormProps {
2372                action: Action {
2373                    handler: "users.destroy".to_string(),
2374                    url: Some("/users/1".to_string()),
2375                    method: HttpMethod::Delete,
2376                    confirm: None,
2377                    on_success: None,
2378                    on_error: None,
2379                },
2380                fields: vec![],
2381                method: None,
2382            }),
2383            action: None,
2384            visibility: None,
2385        });
2386        let html = render_to_html(&view, &json!({}));
2387        assert!(html.contains("method=\"post\""));
2388        assert!(html.contains("<input type=\"hidden\" name=\"_method\" value=\"DELETE\">"));
2389    }
2390
2391    #[test]
2392    fn form_method_spoofing_for_put() {
2393        let view = JsonUiView::new().component(ComponentNode {
2394            key: "f".to_string(),
2395            component: Component::Form(FormProps {
2396                action: Action {
2397                    handler: "users.update".to_string(),
2398                    url: Some("/users/1".to_string()),
2399                    method: HttpMethod::Put,
2400                    confirm: None,
2401                    on_success: None,
2402                    on_error: None,
2403                },
2404                fields: vec![],
2405                method: Some(HttpMethod::Put),
2406            }),
2407            action: None,
2408            visibility: None,
2409        });
2410        let html = render_to_html(&view, &json!({}));
2411        assert!(html.contains("method=\"post\""));
2412        assert!(html.contains("name=\"_method\" value=\"PUT\""));
2413    }
2414
2415    #[test]
2416    fn form_get_method_no_spoofing() {
2417        let view = JsonUiView::new().component(ComponentNode {
2418            key: "f".to_string(),
2419            component: Component::Form(FormProps {
2420                action: Action {
2421                    handler: "users.index".to_string(),
2422                    url: Some("/users".to_string()),
2423                    method: HttpMethod::Get,
2424                    confirm: None,
2425                    on_success: None,
2426                    on_error: None,
2427                },
2428                fields: vec![],
2429                method: None,
2430            }),
2431            action: None,
2432            visibility: None,
2433        });
2434        let html = render_to_html(&view, &json!({}));
2435        assert!(html.contains("method=\"get\""));
2436        assert!(!html.contains("_method"));
2437    }
2438
2439    // ── 20. Input ──────────────────────────────────────────────────────
2440
2441    #[test]
2442    fn input_renders_label_and_field() {
2443        let view = JsonUiView::new().component(ComponentNode {
2444            key: "i".to_string(),
2445            component: Component::Input(InputProps {
2446                field: "email".to_string(),
2447                label: "Email".to_string(),
2448                input_type: InputType::Email,
2449                placeholder: Some("user@example.com".to_string()),
2450                required: Some(true),
2451                disabled: None,
2452                error: None,
2453                description: Some("Your work email".to_string()),
2454                default_value: None,
2455                data_path: None,
2456                step: None,
2457            }),
2458            action: None,
2459            visibility: None,
2460        });
2461        let html = render_to_html(&view, &json!({}));
2462        assert!(html.contains("for=\"email\""));
2463        assert!(html.contains(">Email</label>"));
2464        assert!(html.contains("Your work email"));
2465        assert!(html.contains("type=\"email\""));
2466        assert!(html.contains("id=\"email\""));
2467        assert!(html.contains("name=\"email\""));
2468        assert!(html.contains("placeholder=\"user@example.com\""));
2469        assert!(html.contains(" required"));
2470        assert!(html.contains("border-gray-300"));
2471    }
2472
2473    #[test]
2474    fn input_renders_error_with_red_border() {
2475        let view = JsonUiView::new().component(ComponentNode {
2476            key: "i".to_string(),
2477            component: Component::Input(InputProps {
2478                field: "name".to_string(),
2479                label: "Name".to_string(),
2480                input_type: InputType::Text,
2481                placeholder: None,
2482                required: None,
2483                disabled: None,
2484                error: Some("Name is required".to_string()),
2485                description: None,
2486                default_value: None,
2487                data_path: None,
2488                step: None,
2489            }),
2490            action: None,
2491            visibility: None,
2492        });
2493        let html = render_to_html(&view, &json!({}));
2494        assert!(html.contains("border-red-500"));
2495        assert!(html.contains("<p class=\"text-sm text-red-600\">Name is required</p>"));
2496    }
2497
2498    #[test]
2499    fn input_resolves_data_path_for_value() {
2500        let data = json!({"user": {"name": "Alice"}});
2501        let view = JsonUiView::new().component(ComponentNode {
2502            key: "i".to_string(),
2503            component: Component::Input(InputProps {
2504                field: "name".to_string(),
2505                label: "Name".to_string(),
2506                input_type: InputType::Text,
2507                placeholder: None,
2508                required: None,
2509                disabled: None,
2510                error: None,
2511                description: None,
2512                default_value: None,
2513                data_path: Some("/user/name".to_string()),
2514                step: None,
2515            }),
2516            action: None,
2517            visibility: None,
2518        });
2519        let html = render_to_html(&view, &data);
2520        assert!(html.contains("value=\"Alice\""));
2521    }
2522
2523    #[test]
2524    fn input_default_value_overrides_data_path() {
2525        let data = json!({"user": {"name": "Alice"}});
2526        let view = JsonUiView::new().component(ComponentNode {
2527            key: "i".to_string(),
2528            component: Component::Input(InputProps {
2529                field: "name".to_string(),
2530                label: "Name".to_string(),
2531                input_type: InputType::Text,
2532                placeholder: None,
2533                required: None,
2534                disabled: None,
2535                error: None,
2536                description: None,
2537                default_value: Some("Bob".to_string()),
2538                data_path: Some("/user/name".to_string()),
2539                step: None,
2540            }),
2541            action: None,
2542            visibility: None,
2543        });
2544        let html = render_to_html(&view, &data);
2545        assert!(html.contains("value=\"Bob\""));
2546        assert!(!html.contains("Alice"));
2547    }
2548
2549    #[test]
2550    fn input_textarea_renders_textarea_element() {
2551        let view = JsonUiView::new().component(ComponentNode {
2552            key: "i".to_string(),
2553            component: Component::Input(InputProps {
2554                field: "bio".to_string(),
2555                label: "Bio".to_string(),
2556                input_type: InputType::Textarea,
2557                placeholder: Some("Tell us about yourself".to_string()),
2558                required: None,
2559                disabled: None,
2560                error: None,
2561                description: None,
2562                default_value: Some("Hello world".to_string()),
2563                data_path: None,
2564                step: None,
2565            }),
2566            action: None,
2567            visibility: None,
2568        });
2569        let html = render_to_html(&view, &json!({}));
2570        assert!(html.contains("<textarea"));
2571        assert!(html.contains(">Hello world</textarea>"));
2572        assert!(html.contains("placeholder=\"Tell us about yourself\""));
2573    }
2574
2575    #[test]
2576    fn input_hidden_renders_hidden_field() {
2577        let view = JsonUiView::new().component(ComponentNode {
2578            key: "i".to_string(),
2579            component: Component::Input(InputProps {
2580                field: "token".to_string(),
2581                label: "Token".to_string(),
2582                input_type: InputType::Hidden,
2583                placeholder: None,
2584                required: None,
2585                disabled: None,
2586                error: None,
2587                description: None,
2588                default_value: Some("abc123".to_string()),
2589                data_path: None,
2590                step: None,
2591            }),
2592            action: None,
2593            visibility: None,
2594        });
2595        let html = render_to_html(&view, &json!({}));
2596        assert!(html.contains("type=\"hidden\""));
2597        assert!(html.contains("value=\"abc123\""));
2598    }
2599
2600    // ── 21. Select ─────────────────────────────────────────────────────
2601
2602    #[test]
2603    fn select_renders_options_with_selected() {
2604        let view = JsonUiView::new().component(ComponentNode {
2605            key: "s".to_string(),
2606            component: Component::Select(SelectProps {
2607                field: "role".to_string(),
2608                label: "Role".to_string(),
2609                options: vec![
2610                    SelectOption {
2611                        value: "admin".to_string(),
2612                        label: "Admin".to_string(),
2613                    },
2614                    SelectOption {
2615                        value: "user".to_string(),
2616                        label: "User".to_string(),
2617                    },
2618                ],
2619                placeholder: Some("Select a role".to_string()),
2620                required: Some(true),
2621                disabled: None,
2622                error: None,
2623                description: None,
2624                default_value: Some("admin".to_string()),
2625                data_path: None,
2626            }),
2627            action: None,
2628            visibility: None,
2629        });
2630        let html = render_to_html(&view, &json!({}));
2631        assert!(html.contains("for=\"role\""));
2632        assert!(html.contains("id=\"role\""));
2633        assert!(html.contains("name=\"role\""));
2634        assert!(html.contains("<option value=\"\">Select a role</option>"));
2635        assert!(html.contains("<option value=\"admin\" selected>Admin</option>"));
2636        assert!(html.contains("<option value=\"user\">User</option>"));
2637        assert!(html.contains(" required"));
2638    }
2639
2640    #[test]
2641    fn select_resolves_data_path_for_selected() {
2642        let data = json!({"user": {"role": "user"}});
2643        let view = JsonUiView::new().component(ComponentNode {
2644            key: "s".to_string(),
2645            component: Component::Select(SelectProps {
2646                field: "role".to_string(),
2647                label: "Role".to_string(),
2648                options: vec![
2649                    SelectOption {
2650                        value: "admin".to_string(),
2651                        label: "Admin".to_string(),
2652                    },
2653                    SelectOption {
2654                        value: "user".to_string(),
2655                        label: "User".to_string(),
2656                    },
2657                ],
2658                placeholder: None,
2659                required: None,
2660                disabled: None,
2661                error: None,
2662                description: None,
2663                default_value: None,
2664                data_path: Some("/user/role".to_string()),
2665            }),
2666            action: None,
2667            visibility: None,
2668        });
2669        let html = render_to_html(&view, &data);
2670        assert!(html.contains("<option value=\"user\" selected>User</option>"));
2671        assert!(!html.contains("<option value=\"admin\" selected>"));
2672    }
2673
2674    #[test]
2675    fn select_renders_error() {
2676        let view = JsonUiView::new().component(ComponentNode {
2677            key: "s".to_string(),
2678            component: Component::Select(SelectProps {
2679                field: "role".to_string(),
2680                label: "Role".to_string(),
2681                options: vec![],
2682                placeholder: None,
2683                required: None,
2684                disabled: None,
2685                error: Some("Role is required".to_string()),
2686                description: None,
2687                default_value: None,
2688                data_path: None,
2689            }),
2690            action: None,
2691            visibility: None,
2692        });
2693        let html = render_to_html(&view, &json!({}));
2694        assert!(html.contains("border-red-500"));
2695        assert!(html.contains("Role is required"));
2696    }
2697
2698    // ── 22. Checkbox ───────────────────────────────────────────────────
2699
2700    #[test]
2701    fn checkbox_renders_checked_state() {
2702        let view = JsonUiView::new().component(ComponentNode {
2703            key: "cb".to_string(),
2704            component: Component::Checkbox(CheckboxProps {
2705                field: "terms".to_string(),
2706                label: "Accept Terms".to_string(),
2707                description: Some("You must accept".to_string()),
2708                checked: Some(true),
2709                data_path: None,
2710                required: Some(true),
2711                disabled: None,
2712                error: None,
2713            }),
2714            action: None,
2715            visibility: None,
2716        });
2717        let html = render_to_html(&view, &json!({}));
2718        assert!(html.contains("type=\"checkbox\""));
2719        assert!(html.contains("id=\"terms\""));
2720        assert!(html.contains("name=\"terms\""));
2721        assert!(html.contains("value=\"1\""));
2722        assert!(html.contains(" checked"));
2723        assert!(html.contains(" required"));
2724        assert!(html.contains("for=\"terms\""));
2725        assert!(html.contains(">Accept Terms</label>"));
2726        assert!(html.contains("ml-6 text-sm text-gray-500"));
2727        assert!(html.contains("You must accept"));
2728    }
2729
2730    #[test]
2731    fn checkbox_resolves_data_path_for_checked() {
2732        let data = json!({"user": {"accepted": true}});
2733        let view = JsonUiView::new().component(ComponentNode {
2734            key: "cb".to_string(),
2735            component: Component::Checkbox(CheckboxProps {
2736                field: "accepted".to_string(),
2737                label: "Accepted".to_string(),
2738                description: None,
2739                checked: None,
2740                data_path: Some("/user/accepted".to_string()),
2741                required: None,
2742                disabled: None,
2743                error: None,
2744            }),
2745            action: None,
2746            visibility: None,
2747        });
2748        let html = render_to_html(&view, &data);
2749        assert!(html.contains(" checked"));
2750    }
2751
2752    #[test]
2753    fn checkbox_renders_error() {
2754        let view = JsonUiView::new().component(ComponentNode {
2755            key: "cb".to_string(),
2756            component: Component::Checkbox(CheckboxProps {
2757                field: "terms".to_string(),
2758                label: "Terms".to_string(),
2759                description: None,
2760                checked: None,
2761                data_path: None,
2762                required: None,
2763                disabled: None,
2764                error: Some("Must accept".to_string()),
2765            }),
2766            action: None,
2767            visibility: None,
2768        });
2769        let html = render_to_html(&view, &json!({}));
2770        assert!(html.contains("ml-6 text-sm text-red-600"));
2771        assert!(html.contains("Must accept"));
2772    }
2773
2774    // ── 23. Switch ─────────────────────────────────────────────────────
2775
2776    #[test]
2777    fn switch_renders_toggle_structure() {
2778        let view = JsonUiView::new().component(ComponentNode {
2779            key: "sw".to_string(),
2780            component: Component::Switch(SwitchProps {
2781                field: "notifications".to_string(),
2782                label: "Notifications".to_string(),
2783                description: Some("Get email updates".to_string()),
2784                checked: Some(true),
2785                data_path: None,
2786                required: None,
2787                disabled: None,
2788                error: None,
2789            }),
2790            action: None,
2791            visibility: None,
2792        });
2793        let html = render_to_html(&view, &json!({}));
2794        assert!(html.contains("sr-only peer"));
2795        assert!(html.contains("id=\"notifications\""));
2796        assert!(html.contains("name=\"notifications\""));
2797        assert!(html.contains("value=\"1\""));
2798        assert!(html.contains(" checked"));
2799        assert!(html.contains("peer-checked:bg-blue-600"));
2800        assert!(html.contains("for=\"notifications\""));
2801        assert!(html.contains(">Notifications</label>"));
2802        assert!(html.contains("Get email updates"));
2803    }
2804
2805    #[test]
2806    fn switch_renders_error() {
2807        let view = JsonUiView::new().component(ComponentNode {
2808            key: "sw".to_string(),
2809            component: Component::Switch(SwitchProps {
2810                field: "agree".to_string(),
2811                label: "Agree".to_string(),
2812                description: None,
2813                checked: None,
2814                data_path: None,
2815                required: None,
2816                disabled: None,
2817                error: Some("Required".to_string()),
2818            }),
2819            action: None,
2820            visibility: None,
2821        });
2822        let html = render_to_html(&view, &json!({}));
2823        assert!(html.contains("text-sm text-red-600"));
2824        assert!(html.contains("Required"));
2825    }
2826
2827    // ── 24. Table ──────────────────────────────────────────────────────
2828
2829    #[test]
2830    fn table_renders_headers_and_data_rows() {
2831        let data = json!({
2832            "users": [
2833                {"name": "Alice", "email": "alice@example.com"},
2834                {"name": "Bob", "email": "bob@example.com"}
2835            ]
2836        });
2837        let view = JsonUiView::new().component(ComponentNode {
2838            key: "t".to_string(),
2839            component: Component::Table(TableProps {
2840                columns: vec![
2841                    Column {
2842                        key: "name".to_string(),
2843                        label: "Name".to_string(),
2844                        format: None,
2845                    },
2846                    Column {
2847                        key: "email".to_string(),
2848                        label: "Email".to_string(),
2849                        format: None,
2850                    },
2851                ],
2852                data_path: "/users".to_string(),
2853                row_actions: None,
2854                empty_message: Some("No users".to_string()),
2855                sortable: None,
2856                sort_column: None,
2857                sort_direction: None,
2858            }),
2859            action: None,
2860            visibility: None,
2861        });
2862        let html = render_to_html(&view, &data);
2863        // Headers.
2864        assert!(html.contains("tracking-wider text-gray-500\">Name</th>"));
2865        assert!(html.contains("tracking-wider text-gray-500\">Email</th>"));
2866        // Data rows.
2867        assert!(html.contains(">Alice</td>"));
2868        assert!(html.contains(">alice@example.com</td>"));
2869        assert!(html.contains(">Bob</td>"));
2870        assert!(html.contains(">bob@example.com</td>"));
2871        // Wrapped in overflow container.
2872        assert!(html.contains("overflow-x-auto"));
2873    }
2874
2875    #[test]
2876    fn table_renders_empty_message() {
2877        let data = json!({"users": []});
2878        let view = JsonUiView::new().component(ComponentNode {
2879            key: "t".to_string(),
2880            component: Component::Table(TableProps {
2881                columns: vec![Column {
2882                    key: "name".to_string(),
2883                    label: "Name".to_string(),
2884                    format: None,
2885                }],
2886                data_path: "/users".to_string(),
2887                row_actions: None,
2888                empty_message: Some("No users found".to_string()),
2889                sortable: None,
2890                sort_column: None,
2891                sort_direction: None,
2892            }),
2893            action: None,
2894            visibility: None,
2895        });
2896        let html = render_to_html(&view, &data);
2897        assert!(html.contains("No users found"));
2898        assert!(html.contains("text-center text-sm text-gray-500"));
2899    }
2900
2901    #[test]
2902    fn table_renders_empty_message_when_path_missing() {
2903        let data = json!({});
2904        let view = JsonUiView::new().component(ComponentNode {
2905            key: "t".to_string(),
2906            component: Component::Table(TableProps {
2907                columns: vec![Column {
2908                    key: "name".to_string(),
2909                    label: "Name".to_string(),
2910                    format: None,
2911                }],
2912                data_path: "/users".to_string(),
2913                row_actions: None,
2914                empty_message: Some("No data".to_string()),
2915                sortable: None,
2916                sort_column: None,
2917                sort_direction: None,
2918            }),
2919            action: None,
2920            visibility: None,
2921        });
2922        let html = render_to_html(&view, &data);
2923        assert!(html.contains("No data"));
2924    }
2925
2926    #[test]
2927    fn table_renders_row_actions() {
2928        let data = json!({"items": [{"name": "Item 1"}]});
2929        let view = JsonUiView::new().component(ComponentNode {
2930            key: "t".to_string(),
2931            component: Component::Table(TableProps {
2932                columns: vec![Column {
2933                    key: "name".to_string(),
2934                    label: "Name".to_string(),
2935                    format: None,
2936                }],
2937                data_path: "/items".to_string(),
2938                row_actions: Some(vec![
2939                    make_action_with_url("items.edit", HttpMethod::Get, "/items/1/edit"),
2940                    make_action_with_url("items.destroy", HttpMethod::Delete, "/items/1"),
2941                ]),
2942                empty_message: None,
2943                sortable: None,
2944                sort_column: None,
2945                sort_direction: None,
2946            }),
2947            action: None,
2948            visibility: None,
2949        });
2950        let html = render_to_html(&view, &data);
2951        // Actions header.
2952        assert!(html.contains(">Actions</th>"));
2953        // Action links.
2954        assert!(html.contains("href=\"/items/1/edit\""));
2955        assert!(html.contains(">edit</a>"));
2956        assert!(html.contains("href=\"/items/1\""));
2957        assert!(html.contains(">destroy</a>"));
2958    }
2959
2960    #[test]
2961    fn table_handles_numeric_and_bool_cells() {
2962        let data = json!({"rows": [{"count": 42, "active": true}]});
2963        let view = JsonUiView::new().component(ComponentNode {
2964            key: "t".to_string(),
2965            component: Component::Table(TableProps {
2966                columns: vec![
2967                    Column {
2968                        key: "count".to_string(),
2969                        label: "Count".to_string(),
2970                        format: None,
2971                    },
2972                    Column {
2973                        key: "active".to_string(),
2974                        label: "Active".to_string(),
2975                        format: None,
2976                    },
2977                ],
2978                data_path: "/rows".to_string(),
2979                row_actions: None,
2980                empty_message: None,
2981                sortable: None,
2982                sort_column: None,
2983                sort_direction: None,
2984            }),
2985            action: None,
2986            visibility: None,
2987        });
2988        let html = render_to_html(&view, &data);
2989        assert!(html.contains(">42</td>"));
2990        assert!(html.contains(">true</td>"));
2991    }
2992
2993    // ── Plugin rendering tests ────────────────────────────────────────
2994
2995    #[test]
2996    fn plugin_renders_error_div_when_not_registered() {
2997        let view = JsonUiView::new().component(ComponentNode {
2998            key: "map-1".to_string(),
2999            component: Component::Plugin(PluginProps {
3000                plugin_type: "UnknownPluginXyz".to_string(),
3001                props: json!({"lat": 0}),
3002            }),
3003            action: None,
3004            visibility: None,
3005        });
3006        let html = render_to_html(&view, &json!({}));
3007        assert!(html.contains("Unknown plugin component: UnknownPluginXyz"));
3008        assert!(html.contains("bg-red-50"));
3009    }
3010
3011    #[test]
3012    fn collect_plugin_types_finds_top_level_plugins() {
3013        let view = JsonUiView::new()
3014            .component(ComponentNode {
3015                key: "map".to_string(),
3016                component: Component::Plugin(PluginProps {
3017                    plugin_type: "Map".to_string(),
3018                    props: json!({}),
3019                }),
3020                action: None,
3021                visibility: None,
3022            })
3023            .component(ComponentNode {
3024                key: "text".to_string(),
3025                component: Component::Text(TextProps {
3026                    content: "Hello".to_string(),
3027                    element: TextElement::P,
3028                }),
3029                action: None,
3030                visibility: None,
3031            });
3032        let types = collect_plugin_types(&view);
3033        assert_eq!(types.len(), 1);
3034        assert!(types.contains("Map"));
3035    }
3036
3037    #[test]
3038    fn collect_plugin_types_finds_nested_in_card() {
3039        let view = JsonUiView::new().component(ComponentNode {
3040            key: "card".to_string(),
3041            component: Component::Card(CardProps {
3042                title: "Test".to_string(),
3043                description: None,
3044                children: vec![ComponentNode {
3045                    key: "chart".to_string(),
3046                    component: Component::Plugin(PluginProps {
3047                        plugin_type: "Chart".to_string(),
3048                        props: json!({}),
3049                    }),
3050                    action: None,
3051                    visibility: None,
3052                }],
3053                footer: vec![],
3054            }),
3055            action: None,
3056            visibility: None,
3057        });
3058        let types = collect_plugin_types(&view);
3059        assert!(types.contains("Chart"));
3060    }
3061
3062    #[test]
3063    fn collect_plugin_types_deduplicates() {
3064        let view = JsonUiView::new()
3065            .component(ComponentNode {
3066                key: "map1".to_string(),
3067                component: Component::Plugin(PluginProps {
3068                    plugin_type: "Map".to_string(),
3069                    props: json!({}),
3070                }),
3071                action: None,
3072                visibility: None,
3073            })
3074            .component(ComponentNode {
3075                key: "map2".to_string(),
3076                component: Component::Plugin(PluginProps {
3077                    plugin_type: "Map".to_string(),
3078                    props: json!({"zoom": 5}),
3079                }),
3080                action: None,
3081                visibility: None,
3082            });
3083        let types = collect_plugin_types(&view);
3084        assert_eq!(types.len(), 1);
3085    }
3086
3087    #[test]
3088    fn collect_plugin_types_empty_for_builtin_only() {
3089        let view = JsonUiView::new().component(ComponentNode {
3090            key: "text".to_string(),
3091            component: Component::Text(TextProps {
3092                content: "Hello".to_string(),
3093                element: TextElement::P,
3094            }),
3095            action: None,
3096            visibility: None,
3097        });
3098        let types = collect_plugin_types(&view);
3099        assert!(types.is_empty());
3100    }
3101
3102    #[test]
3103    fn render_to_html_with_plugins_returns_empty_assets_for_builtin_only() {
3104        let view = JsonUiView::new().component(ComponentNode {
3105            key: "text".to_string(),
3106            component: Component::Text(TextProps {
3107                content: "Hello".to_string(),
3108                element: TextElement::P,
3109            }),
3110            action: None,
3111            visibility: None,
3112        });
3113        let result = render_to_html_with_plugins(&view, &json!({}));
3114        assert!(result.css_head.is_empty());
3115        assert!(result.scripts.is_empty());
3116        assert!(result.html.contains("Hello"));
3117    }
3118
3119    #[test]
3120    fn render_css_tags_generates_link_elements() {
3121        let assets = vec![Asset::new("https://cdn.example.com/style.css")
3122            .integrity("sha256-abc")
3123            .crossorigin("")];
3124        let tags = render_css_tags(&assets);
3125        assert!(tags.contains("rel=\"stylesheet\""));
3126        assert!(tags.contains("href=\"https://cdn.example.com/style.css\""));
3127        assert!(tags.contains("integrity=\"sha256-abc\""));
3128        assert!(tags.contains("crossorigin=\"\""));
3129    }
3130
3131    #[test]
3132    fn render_js_tags_generates_script_elements() {
3133        let assets = vec![Asset::new("https://cdn.example.com/lib.js")];
3134        let init = vec!["initLib();".to_string()];
3135        let tags = render_js_tags(&assets, &init);
3136        assert!(tags.contains("src=\"https://cdn.example.com/lib.js\""));
3137        assert!(tags.contains("<script>initLib();</script>"));
3138    }
3139}