1use crate::interop::surface::UiSurface;
10use crate::schema::*;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum BandwidthMode {
21 #[default]
22 Full,
23 Low,
24}
25
26#[derive(Debug, Clone, Default)]
28pub struct HtmlRenderOptions {
29 pub bandwidth_mode: BandwidthMode,
31 pub class_prefix: Option<String>,
33}
34
35pub fn escape_html(input: &str) -> String {
39 let mut output = String::with_capacity(input.len());
40 for ch in input.chars() {
41 match ch {
42 '<' => output.push_str("<"),
43 '>' => output.push_str(">"),
44 '&' => output.push_str("&"),
45 '"' => output.push_str("""),
46 '\'' => output.push_str("'"),
47 _ => output.push(ch),
48 }
49 }
50 output
51}
52
53fn cls(prefix: &Option<String>, name: &str) -> String {
55 match prefix {
56 Some(p) => format!("{}{}", p, name),
57 None => name.to_string(),
58 }
59}
60
61fn render_component_html(component: &Component, options: &HtmlRenderOptions) -> String {
63 let mode = options.bandwidth_mode;
64 let prefix = &options.class_prefix;
65
66 match component {
67 Component::Text(text) => {
69 let content = escape_html(&text.content);
70 match text.variant {
71 TextVariant::H1 => format!("<h1>{}</h1>", content),
72 TextVariant::H2 => format!("<h2>{}</h2>", content),
73 TextVariant::H3 => format!("<h3>{}</h3>", content),
74 TextVariant::H4 => format!("<h4>{}</h4>", content),
75 TextVariant::Body => format!("<p>{}</p>", content),
76 TextVariant::Caption => format!("<small>{}</small>", content),
77 TextVariant::Code => format!("<code>{}</code>", content),
78 }
79 }
80
81 Component::Button(button) => {
82 let label = escape_html(&button.label);
83 let action_id = escape_html(&button.action_id);
84 let disabled = if button.disabled { " disabled" } else { "" };
85 format!(
86 "<button data-action-id=\"{}\"{}>{}</button>",
87 action_id, disabled, label
88 )
89 }
90
91 Component::Icon(icon) => {
92 let name = escape_html(&icon.name);
93 format!(
94 "<span class=\"{}\" data-icon=\"{}\">{}</span>",
95 cls(prefix, "icon"),
96 name,
97 name
98 )
99 }
100
101 Component::Image(image) => {
102 if mode == BandwidthMode::Low {
103 return String::new();
104 }
105 let src = escape_html(&image.src);
106 let alt = image.alt.as_deref().map(escape_html).unwrap_or_default();
107 format!("<img src=\"{}\" alt=\"{}\">", src, alt)
108 }
109
110 Component::Badge(badge) => {
111 let label = escape_html(&badge.label);
112 let variant = badge_variant_str(&badge.variant);
113 format!(
114 "<span class=\"{} {}\">{}</span>",
115 cls(prefix, "badge"),
116 cls(prefix, &format!("badge-{}", variant)),
117 label
118 )
119 }
120
121 Component::TextInput(input) => {
123 let label = escape_html(&input.label);
124 let name = escape_html(&input.name);
125 let placeholder = input
126 .placeholder
127 .as_deref()
128 .map(|p| format!(" placeholder=\"{}\"", escape_html(p)))
129 .unwrap_or_default();
130 let required = if input.required { " required" } else { "" };
131 let default_val = input
132 .default_value
133 .as_deref()
134 .map(|v| format!(" value=\"{}\"", escape_html(v)))
135 .unwrap_or_default();
136 format!(
137 "<label>{}<input type=\"text\" name=\"{}\"{}{}{}></label>",
138 label, name, placeholder, required, default_val
139 )
140 }
141
142 Component::NumberInput(input) => {
143 let label = escape_html(&input.label);
144 let name = escape_html(&input.name);
145 let min = input
146 .min
147 .map(|v| format!(" min=\"{}\"", v))
148 .unwrap_or_default();
149 let max = input
150 .max
151 .map(|v| format!(" max=\"{}\"", v))
152 .unwrap_or_default();
153 let step = input
154 .step
155 .map(|v| format!(" step=\"{}\"", v))
156 .unwrap_or_default();
157 let required = if input.required { " required" } else { "" };
158 let default_val = input
159 .default_value
160 .map(|v| format!(" value=\"{}\"", v))
161 .unwrap_or_default();
162 format!(
163 "<label>{}<input type=\"number\" name=\"{}\"{}{}{}{}{}></label>",
164 label, name, min, max, step, required, default_val
165 )
166 }
167
168 Component::Select(select) => {
169 let label = escape_html(&select.label);
170 let name = escape_html(&select.name);
171 let required = if select.required { " required" } else { "" };
172 let options_html: String = select
173 .options
174 .iter()
175 .map(|opt| {
176 format!(
177 "<option value=\"{}\">{}</option>",
178 escape_html(&opt.value),
179 escape_html(&opt.label)
180 )
181 })
182 .collect();
183 format!(
184 "<label>{}<select name=\"{}\"{}>{}</select></label>",
185 label, name, required, options_html
186 )
187 }
188
189 Component::MultiSelect(multi) => {
190 let label = escape_html(&multi.label);
191 let name = escape_html(&multi.name);
192 let required = if multi.required { " required" } else { "" };
193 let options_html: String = multi
194 .options
195 .iter()
196 .map(|opt| {
197 format!(
198 "<option value=\"{}\">{}</option>",
199 escape_html(&opt.value),
200 escape_html(&opt.label)
201 )
202 })
203 .collect();
204 format!(
205 "<label>{}<select multiple name=\"{}\"{}>{}</select></label>",
206 label, name, required, options_html
207 )
208 }
209
210 Component::Switch(switch) => {
211 let label = escape_html(&switch.label);
212 let name = escape_html(&switch.name);
213 let checked = if switch.default_checked {
214 " checked"
215 } else {
216 ""
217 };
218 format!(
219 "<label>{}<input type=\"checkbox\" role=\"switch\" name=\"{}\"{}></label>",
220 label, name, checked
221 )
222 }
223
224 Component::DateInput(date) => {
225 let label = escape_html(&date.label);
226 let name = escape_html(&date.name);
227 let required = if date.required { " required" } else { "" };
228 format!(
229 "<label>{}<input type=\"date\" name=\"{}\"{}></label>",
230 label, name, required
231 )
232 }
233
234 Component::Slider(slider) => {
235 let label = escape_html(&slider.label);
236 let name = escape_html(&slider.name);
237 let step = slider
238 .step
239 .map(|v| format!(" step=\"{}\"", v))
240 .unwrap_or_default();
241 let default_val = slider
242 .default_value
243 .map(|v| format!(" value=\"{}\"", v))
244 .unwrap_or_default();
245 format!(
246 "<label>{}<input type=\"range\" name=\"{}\" min=\"{}\" max=\"{}\"{}{}></label>",
247 label, name, slider.min, slider.max, step, default_val
248 )
249 }
250
251 Component::Textarea(textarea) => {
252 let label = escape_html(&textarea.label);
253 let name = escape_html(&textarea.name);
254 let placeholder = textarea
255 .placeholder
256 .as_deref()
257 .map(|p| format!(" placeholder=\"{}\"", escape_html(p)))
258 .unwrap_or_default();
259 let required = if textarea.required { " required" } else { "" };
260 let default_val = textarea
261 .default_value
262 .as_deref()
263 .map(escape_html)
264 .unwrap_or_default();
265 format!(
266 "<label>{}<textarea name=\"{}\" rows=\"{}\"{}{}>{}</textarea></label>",
267 label, name, textarea.rows, placeholder, required, default_val
268 )
269 }
270
271 Component::Stack(stack) => {
273 let dir = match stack.direction {
274 StackDirection::Horizontal => "horizontal",
275 StackDirection::Vertical => "vertical",
276 };
277 let children_html = render_children(&stack.children, options);
278 let style_attr = if mode == BandwidthMode::Low {
279 String::new()
280 } else if stack.gap > 0 {
281 format!(" style=\"gap: {}px\"", stack.gap)
282 } else {
283 String::new()
284 };
285 format!(
286 "<div class=\"{} {}\"{}>{}</div>",
287 cls(prefix, "stack"),
288 cls(prefix, &format!("stack-{}", dir)),
289 style_attr,
290 children_html
291 )
292 }
293
294 Component::Grid(grid) => {
295 let children_html = render_children(&grid.children, options);
296 let style_attr = if mode == BandwidthMode::Low {
297 String::new()
298 } else {
299 format!(
300 " style=\"grid-template-columns: repeat({}, 1fr)\"",
301 grid.columns
302 )
303 };
304 format!(
305 "<div class=\"{}\"{}>{}</div>",
306 cls(prefix, "grid"),
307 style_attr,
308 children_html
309 )
310 }
311
312 Component::Card(card) => {
313 let mut html = format!("<div class=\"{}\">", cls(prefix, "card"));
314 if let Some(title) = &card.title {
315 html.push_str(&format!("<h3>{}</h3>", escape_html(title)));
316 }
317 if let Some(desc) = &card.description {
318 html.push_str(&format!("<p>{}</p>", escape_html(desc)));
319 }
320 if !card.content.is_empty() {
321 html.push_str(&format!("<div class=\"{}\">", cls(prefix, "card-content")));
322 html.push_str(&render_children(&card.content, options));
323 html.push_str("</div>");
324 }
325 if let Some(footer) = &card.footer {
326 html.push_str(&format!("<div class=\"{}\">", cls(prefix, "card-footer")));
327 html.push_str(&render_children(footer, options));
328 html.push_str("</div>");
329 }
330 html.push_str("</div>");
331 html
332 }
333
334 Component::Container(container) => {
335 let children_html = render_children(&container.children, options);
336 let style_attr = if mode == BandwidthMode::Low || container.padding == 0 {
337 String::new()
338 } else {
339 format!(" style=\"padding: {}px\"", container.padding)
340 };
341 format!(
342 "<div class=\"{}\"{}>{}</div>",
343 cls(prefix, "container"),
344 style_attr,
345 children_html
346 )
347 }
348
349 Component::Divider(_) => "<hr>".to_string(),
350
351 Component::Tabs(tabs) => {
352 let mut html = format!("<div class=\"{}\">", cls(prefix, "tabs"));
353 html.push_str(&format!("<div class=\"{}\">", cls(prefix, "tab-buttons")));
355 for (i, tab) in tabs.tabs.iter().enumerate() {
356 html.push_str(&format!(
357 "<button class=\"{}\" data-tab-index=\"{}\">{}</button>",
358 cls(prefix, "tab-button"),
359 i,
360 escape_html(&tab.label)
361 ));
362 }
363 html.push_str("</div>");
364 for (i, tab) in tabs.tabs.iter().enumerate() {
366 html.push_str(&format!(
367 "<div class=\"{}\" data-tab-panel=\"{}\">",
368 cls(prefix, "tab-panel"),
369 i
370 ));
371 html.push_str(&render_children(&tab.content, options));
372 html.push_str("</div>");
373 }
374 html.push_str("</div>");
375 html
376 }
377
378 Component::Table(table) => {
380 let mut html = String::from("<table>");
381 html.push_str("<thead><tr>");
383 for col in &table.columns {
384 html.push_str(&format!("<th>{}</th>", escape_html(&col.header)));
385 }
386 html.push_str("</tr></thead>");
387 html.push_str("<tbody>");
389 for row in &table.data {
390 html.push_str("<tr>");
391 for col in &table.columns {
392 let cell_value = row
393 .get(&col.accessor_key)
394 .map(|v| match v {
395 serde_json::Value::String(s) => escape_html(s),
396 other => escape_html(&other.to_string()),
397 })
398 .unwrap_or_default();
399 html.push_str(&format!("<td>{}</td>", cell_value));
400 }
401 html.push_str("</tr>");
402 }
403 html.push_str("</tbody></table>");
404 html
405 }
406
407 Component::List(list) => {
408 let tag = if list.ordered { "ol" } else { "ul" };
409 let items_html: String = list
410 .items
411 .iter()
412 .map(|item| format!("<li>{}</li>", escape_html(item)))
413 .collect();
414 format!("<{}>{}</{}>", tag, items_html, tag)
415 }
416
417 Component::KeyValue(kv) => {
418 let mut html = String::from("<dl>");
419 for pair in &kv.pairs {
420 html.push_str(&format!(
421 "<dt>{}</dt><dd>{}</dd>",
422 escape_html(&pair.key),
423 escape_html(&pair.value)
424 ));
425 }
426 html.push_str("</dl>");
427 html
428 }
429
430 Component::CodeBlock(code_block) => {
431 let lang_attr = code_block
432 .language
433 .as_deref()
434 .map(|l| format!(" class=\"language-{}\"", escape_html(l)))
435 .unwrap_or_default();
436 format!(
437 "<pre><code{}>{}</code></pre>",
438 lang_attr,
439 escape_html(&code_block.code)
440 )
441 }
442
443 Component::Chart(chart) => {
445 if mode == BandwidthMode::Low {
446 return String::new();
447 }
448 let chart_json = escape_html(&serde_json::to_string(chart).unwrap_or_default());
449 let title_html = chart
450 .title
451 .as_deref()
452 .map(|t| format!("<p>{}</p>", escape_html(t)))
453 .unwrap_or_default();
454 format!(
455 "<div class=\"{}\" data-chart=\"{}\">{}</div>",
456 cls(prefix, "chart-placeholder"),
457 chart_json,
458 title_html
459 )
460 }
461
462 Component::Scene3d(scene) => {
463 let title = scene
464 .title
465 .as_deref()
466 .map(|value| format!("<strong>{}</strong>", escape_html(value)))
467 .unwrap_or_else(|| "<strong>Interactive 3D scene</strong>".to_string());
468 let description = scene
469 .description
470 .as_deref()
471 .or(scene.fallback.as_deref())
472 .map(|value| format!("<p>{}</p>", escape_html(value)))
473 .unwrap_or_default();
474 format!(
475 "<figure class=\"{}\" data-scene-object-count=\"{}\">{}{}</figure>",
476 cls(prefix, "scene-3d-fallback"),
477 scene.objects.len(),
478 title,
479 description
480 )
481 }
482
483 Component::Alert(alert) => {
485 let variant = alert_variant_str(&alert.variant);
486 let desc = alert
487 .description
488 .as_deref()
489 .map(|d| format!("<p>{}</p>", escape_html(d)))
490 .unwrap_or_default();
491 format!(
492 "<div class=\"{} {}\" role=\"alert\"><strong>{}</strong>{}</div>",
493 cls(prefix, "alert"),
494 cls(prefix, &format!("alert-{}", variant)),
495 escape_html(&alert.title),
496 desc
497 )
498 }
499
500 Component::Progress(progress) => {
501 let label = progress
502 .label
503 .as_deref()
504 .map(|l| format!(" aria-label=\"{}\"", escape_html(l)))
505 .unwrap_or_default();
506 format!(
507 "<progress value=\"{}\" max=\"100\"{}></progress>",
508 progress.value, label
509 )
510 }
511
512 Component::Toast(toast) => {
513 let variant = alert_variant_str(&toast.variant);
514 format!(
515 "<div class=\"{} {}\">{}</div>",
516 cls(prefix, "toast"),
517 cls(prefix, &format!("toast-{}", variant)),
518 escape_html(&toast.message)
519 )
520 }
521
522 Component::Modal(modal) => {
523 let mut html = String::from("<dialog>");
524 html.push_str(&format!("<h2>{}</h2>", escape_html(&modal.title)));
525 html.push_str(&render_children(&modal.content, options));
526 if let Some(footer) = &modal.footer {
527 html.push_str(&format!("<div class=\"{}\">", cls(prefix, "modal-footer")));
528 html.push_str(&render_children(footer, options));
529 html.push_str("</div>");
530 }
531 html.push_str("</dialog>");
532 html
533 }
534
535 Component::Spinner(spinner) => {
536 if mode == BandwidthMode::Low {
537 return String::new();
538 }
539 let label = spinner
540 .label
541 .as_deref()
542 .map(|l| format!(" aria-label=\"{}\"", escape_html(l)))
543 .unwrap_or_default();
544 format!(
545 "<div class=\"{}\" role=\"status\"{}></div>",
546 cls(prefix, "spinner"),
547 label
548 )
549 }
550
551 Component::Skeleton(_) => {
552 if mode == BandwidthMode::Low {
553 return String::new();
554 }
555 format!("<div class=\"{}\"></div>", cls(prefix, "skeleton"))
556 }
557 }
558}
559
560fn render_children(children: &[Component], options: &HtmlRenderOptions) -> String {
562 children
563 .iter()
564 .map(|c| render_component_html(c, options))
565 .collect()
566}
567
568fn badge_variant_str(variant: &BadgeVariant) -> &'static str {
570 match variant {
571 BadgeVariant::Default => "default",
572 BadgeVariant::Info => "info",
573 BadgeVariant::Success => "success",
574 BadgeVariant::Warning => "warning",
575 BadgeVariant::Error => "error",
576 BadgeVariant::Secondary => "secondary",
577 BadgeVariant::Outline => "outline",
578 }
579}
580
581fn alert_variant_str(variant: &AlertVariant) -> &'static str {
583 match variant {
584 AlertVariant::Info => "info",
585 AlertVariant::Success => "success",
586 AlertVariant::Warning => "warning",
587 AlertVariant::Error => "error",
588 }
589}
590
591const INLINE_CSS: &str = r#"
593body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 16px; color: #1a1a1a; }
594.stack { display: flex; }
595.stack-vertical { flex-direction: column; }
596.stack-horizontal { flex-direction: row; }
597.grid { display: grid; }
598.card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
599.card-content { margin-top: 8px; }
600.card-footer { margin-top: 12px; border-top: 1px solid #e0e0e0; padding-top: 8px; }
601.container { padding: 16px; }
602.tabs { margin-bottom: 12px; }
603.tab-buttons { display: flex; gap: 4px; border-bottom: 1px solid #e0e0e0; margin-bottom: 8px; }
604.tab-button { background: none; border: none; padding: 8px 16px; cursor: pointer; }
605.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.85em; }
606.badge-default { background: #e0e0e0; }
607.badge-info { background: #dbeafe; color: #1e40af; }
608.badge-success { background: #dcfce7; color: #166534; }
609.badge-warning { background: #fef3c7; color: #92400e; }
610.badge-error { background: #fee2e2; color: #991b1b; }
611.badge-secondary { background: #f3f4f6; color: #374151; }
612.badge-outline { border: 1px solid #d1d5db; background: transparent; }
613.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 12px; }
614.alert-info { background: #dbeafe; color: #1e40af; }
615.alert-success { background: #dcfce7; color: #166534; }
616.alert-warning { background: #fef3c7; color: #92400e; }
617.alert-error { background: #fee2e2; color: #991b1b; }
618.toast { padding: 12px 16px; border-radius: 6px; margin-bottom: 8px; }
619.toast-info { background: #dbeafe; }
620.toast-success { background: #dcfce7; }
621.toast-warning { background: #fef3c7; }
622.toast-error { background: #fee2e2; }
623.spinner { width: 24px; height: 24px; border: 3px solid #e0e0e0; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; }
624@keyframes spin { to { transform: rotate(360deg); } }
625.skeleton { background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; min-height: 20px; }
626@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
627.chart-placeholder { border: 1px dashed #d1d5db; padding: 16px; text-align: center; color: #6b7280; }
628.icon { display: inline-flex; align-items: center; }
629.modal-footer { margin-top: 12px; border-top: 1px solid #e0e0e0; padding-top: 8px; }
630table { width: 100%; border-collapse: collapse; }
631th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #e0e0e0; }
632th { font-weight: 600; }
633progress { width: 100%; }
634label { display: block; margin-bottom: 12px; }
635input, select, textarea { display: block; margin-top: 4px; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 4px; width: 100%; box-sizing: border-box; }
636button { padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; cursor: pointer; background: #3b82f6; color: white; }
637button:disabled { opacity: 0.5; cursor: not-allowed; }
638hr { border: none; border-top: 1px solid #e0e0e0; margin: 12px 0; }
639dl { margin: 0; }
640dt { font-weight: 600; margin-top: 8px; }
641dd { margin-left: 0; margin-bottom: 4px; }
642pre { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; }
643code { font-family: ui-monospace, monospace; }
644dialog { border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px; max-width: 600px; }
645"#;
646
647fn generate_prefixed_css(prefix: &str) -> String {
649 INLINE_CSS.replace('.', &format!(".{}", prefix))
650}
651
652fn wrap_in_shell(body: &str, options: &HtmlRenderOptions) -> String {
654 let css = match &options.class_prefix {
655 Some(p) => {
656 let prefixed = generate_prefixed_css(p);
658 format!("{}\n{}", INLINE_CSS.trim(), prefixed.trim())
659 }
660 None => INLINE_CSS.trim().to_string(),
661 };
662
663 if options.bandwidth_mode == BandwidthMode::Low {
664 format!(
666 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"></head><body>{}</body></html>",
667 body
668 )
669 } else {
670 format!(
671 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><style>{}</style></head><body>{}</body></html>",
672 css, body
673 )
674 }
675}
676
677pub fn render_components_html(components: &[Component], options: &HtmlRenderOptions) -> String {
682 let body: String = components
683 .iter()
684 .map(|c| render_component_html(c, options))
685 .collect();
686 wrap_in_shell(&body, options)
687}
688
689pub fn render_surface_html(surface: &UiSurface, options: &HtmlRenderOptions) -> String {
695 let body: String = surface
696 .components
697 .iter()
698 .map(
699 |value| match serde_json::from_value::<Component>(value.clone()) {
700 Ok(component) => render_component_html(&component, options),
701 Err(_) => "<!-- unknown component -->".to_string(),
702 },
703 )
704 .collect();
705 wrap_in_shell(&body, options)
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use serde_json::json;
712
713 fn default_opts() -> HtmlRenderOptions {
714 HtmlRenderOptions::default()
715 }
716
717 fn low_bw_opts() -> HtmlRenderOptions {
718 HtmlRenderOptions {
719 bandwidth_mode: BandwidthMode::Low,
720 ..Default::default()
721 }
722 }
723
724 fn prefixed_opts(prefix: &str) -> HtmlRenderOptions {
725 HtmlRenderOptions {
726 class_prefix: Some(prefix.to_string()),
727 ..Default::default()
728 }
729 }
730
731 #[test]
734 fn escape_html_escapes_all_special_chars() {
735 assert_eq!(
736 escape_html("<script>alert('xss')&\"</script>"),
737 "<script>alert('xss')&"</script>"
738 );
739 }
740
741 #[test]
742 fn escape_html_passes_through_normal_text() {
743 assert_eq!(escape_html("Hello, world!"), "Hello, world!");
744 }
745
746 #[test]
749 fn bandwidth_mode_default_is_full() {
750 assert_eq!(BandwidthMode::default(), BandwidthMode::Full);
751 }
752
753 #[test]
756 fn text_body_renders_as_p() {
757 let c = Component::Text(Text {
758 id: None,
759 content: "Hello".to_string(),
760 variant: TextVariant::Body,
761 });
762 let html = render_component_html(&c, &default_opts());
763 assert_eq!(html, "<p>Hello</p>");
764 }
765
766 #[test]
767 fn text_h1_renders_as_h1() {
768 let c = Component::Text(Text {
769 id: None,
770 content: "Title".to_string(),
771 variant: TextVariant::H1,
772 });
773 let html = render_component_html(&c, &default_opts());
774 assert_eq!(html, "<h1>Title</h1>");
775 }
776
777 #[test]
778 fn text_caption_renders_as_small() {
779 let c = Component::Text(Text {
780 id: None,
781 content: "Note".to_string(),
782 variant: TextVariant::Caption,
783 });
784 let html = render_component_html(&c, &default_opts());
785 assert_eq!(html, "<small>Note</small>");
786 }
787
788 #[test]
789 fn text_code_renders_as_code() {
790 let c = Component::Text(Text {
791 id: None,
792 content: "let x = 1;".to_string(),
793 variant: TextVariant::Code,
794 });
795 let html = render_component_html(&c, &default_opts());
796 assert_eq!(html, "<code>let x = 1;</code>");
797 }
798
799 #[test]
802 fn button_renders_with_action_id() {
803 let c = Component::Button(Button {
804 id: None,
805 label: "Click me".to_string(),
806 action_id: "btn-1".to_string(),
807 variant: ButtonVariant::Primary,
808 disabled: false,
809 icon: None,
810 });
811 let html = render_component_html(&c, &default_opts());
812 assert!(html.contains("data-action-id=\"btn-1\""));
813 assert!(html.contains("Click me"));
814 }
815
816 #[test]
817 fn button_disabled_renders_disabled_attr() {
818 let c = Component::Button(Button {
819 id: None,
820 label: "Disabled".to_string(),
821 action_id: "btn-2".to_string(),
822 variant: ButtonVariant::Primary,
823 disabled: true,
824 icon: None,
825 });
826 let html = render_component_html(&c, &default_opts());
827 assert!(html.contains(" disabled"));
828 }
829
830 #[test]
833 fn image_renders_in_full_mode() {
834 let c = Component::Image(Image {
835 id: None,
836 src: "https://example.com/img.png".to_string(),
837 alt: Some("A photo".to_string()),
838 });
839 let html = render_component_html(&c, &default_opts());
840 assert!(html.contains("<img"));
841 assert!(html.contains("src=\"https://example.com/img.png\""));
842 assert!(html.contains("alt=\"A photo\""));
843 }
844
845 #[test]
846 fn image_omitted_in_low_bandwidth() {
847 let c = Component::Image(Image {
848 id: None,
849 src: "https://example.com/img.png".to_string(),
850 alt: Some("A photo".to_string()),
851 });
852 let html = render_component_html(&c, &low_bw_opts());
853 assert!(html.is_empty());
854 }
855
856 #[test]
859 fn badge_renders_with_variant() {
860 let c = Component::Badge(Badge {
861 id: None,
862 label: "New".to_string(),
863 variant: BadgeVariant::Success,
864 });
865 let html = render_component_html(&c, &default_opts());
866 assert!(html.contains("badge"));
867 assert!(html.contains("badge-success"));
868 assert!(html.contains("New"));
869 }
870
871 #[test]
874 fn stack_vertical_renders_correctly() {
875 let c = Component::Stack(Stack {
876 id: None,
877 direction: StackDirection::Vertical,
878 children: vec![Component::Text(Text {
879 id: None,
880 content: "Child".to_string(),
881 variant: TextVariant::Body,
882 })],
883 gap: 0,
884 });
885 let html = render_component_html(&c, &default_opts());
886 assert!(html.contains("stack-vertical"));
887 assert!(html.contains("<p>Child</p>"));
888 }
889
890 #[test]
893 fn grid_renders_with_columns() {
894 let c = Component::Grid(Grid {
895 id: None,
896 columns: 3,
897 children: vec![],
898 gap: 0,
899 });
900 let html = render_component_html(&c, &default_opts());
901 assert!(html.contains("grid"));
902 assert!(html.contains("grid-template-columns: repeat(3, 1fr)"));
903 }
904
905 #[test]
906 fn grid_low_bandwidth_strips_style() {
907 let c = Component::Grid(Grid {
908 id: None,
909 columns: 3,
910 children: vec![],
911 gap: 0,
912 });
913 let html = render_component_html(&c, &low_bw_opts());
914 assert!(html.contains("grid"));
915 assert!(!html.contains("style="));
916 }
917
918 #[test]
921 fn card_renders_with_title_and_content() {
922 let c = Component::Card(Card {
923 id: None,
924 title: Some("My Card".to_string()),
925 description: None,
926 content: vec![Component::Text(Text {
927 id: None,
928 content: "Body text".to_string(),
929 variant: TextVariant::Body,
930 })],
931 footer: None,
932 });
933 let html = render_component_html(&c, &default_opts());
934 assert!(html.contains("card"));
935 assert!(html.contains("<h3>My Card</h3>"));
936 assert!(html.contains("<p>Body text</p>"));
937 }
938
939 #[test]
942 fn table_renders_with_headers_and_rows() {
943 let c = Component::Table(Table {
944 id: None,
945 columns: vec![TableColumn {
946 header: "Name".to_string(),
947 accessor_key: "name".to_string(),
948 sortable: true,
949 }],
950 data: vec![{
951 let mut row = std::collections::HashMap::new();
952 row.insert("name".to_string(), json!("Alice"));
953 row
954 }],
955 sortable: false,
956 page_size: None,
957 striped: false,
958 });
959 let html = render_component_html(&c, &default_opts());
960 assert!(html.contains("<table>"));
961 assert!(html.contains("<th>Name</th>"));
962 assert!(html.contains("<td>Alice</td>"));
963 }
964
965 #[test]
968 fn alert_renders_with_role() {
969 let c = Component::Alert(Alert {
970 id: None,
971 title: "Warning!".to_string(),
972 description: Some("Be careful".to_string()),
973 variant: AlertVariant::Warning,
974 });
975 let html = render_component_html(&c, &default_opts());
976 assert!(html.contains("role=\"alert\""));
977 assert!(html.contains("alert-warning"));
978 assert!(html.contains("Warning!"));
979 }
980
981 #[test]
984 fn progress_renders_with_value() {
985 let c = Component::Progress(Progress {
986 id: None,
987 value: 75,
988 label: Some("Loading".to_string()),
989 });
990 let html = render_component_html(&c, &default_opts());
991 assert!(html.contains("<progress"));
992 assert!(html.contains("value=\"75\""));
993 assert!(html.contains("max=\"100\""));
994 }
995
996 #[test]
999 fn chart_omitted_in_low_bandwidth() {
1000 let c = Component::Chart(Chart {
1001 id: None,
1002 title: Some("Sales".to_string()),
1003 kind: ChartKind::Bar,
1004 data: vec![],
1005 x_key: "month".to_string(),
1006 y_keys: vec!["revenue".to_string()],
1007 x_label: None,
1008 y_label: None,
1009 show_legend: true,
1010 colors: None,
1011 });
1012 let html = render_component_html(&c, &low_bw_opts());
1013 assert!(html.is_empty());
1014 }
1015
1016 #[test]
1017 fn chart_renders_placeholder_in_full_mode() {
1018 let c = Component::Chart(Chart {
1019 id: None,
1020 title: Some("Sales".to_string()),
1021 kind: ChartKind::Bar,
1022 data: vec![],
1023 x_key: "month".to_string(),
1024 y_keys: vec!["revenue".to_string()],
1025 x_label: None,
1026 y_label: None,
1027 show_legend: true,
1028 colors: None,
1029 });
1030 let html = render_component_html(&c, &default_opts());
1031 assert!(html.contains("chart-placeholder"));
1032 assert!(html.contains("data-chart="));
1033 }
1034
1035 #[test]
1038 fn spinner_omitted_in_low_bandwidth() {
1039 let c = Component::Spinner(Spinner {
1040 id: None,
1041 size: SpinnerSize::Medium,
1042 label: None,
1043 });
1044 let html = render_component_html(&c, &low_bw_opts());
1045 assert!(html.is_empty());
1046 }
1047
1048 #[test]
1051 fn skeleton_omitted_in_low_bandwidth() {
1052 let c = Component::Skeleton(Skeleton {
1053 id: None,
1054 variant: SkeletonVariant::Text,
1055 width: None,
1056 height: None,
1057 });
1058 let html = render_component_html(&c, &low_bw_opts());
1059 assert!(html.is_empty());
1060 }
1061
1062 #[test]
1065 fn modal_renders_as_dialog() {
1066 let c = Component::Modal(Modal {
1067 id: None,
1068 title: "Confirm".to_string(),
1069 content: vec![Component::Text(Text {
1070 id: None,
1071 content: "Are you sure?".to_string(),
1072 variant: TextVariant::Body,
1073 })],
1074 footer: None,
1075 size: ModalSize::Medium,
1076 closable: true,
1077 });
1078 let html = render_component_html(&c, &default_opts());
1079 assert!(html.contains("<dialog>"));
1080 assert!(html.contains("<h2>Confirm</h2>"));
1081 assert!(html.contains("<p>Are you sure?</p>"));
1082 }
1083
1084 #[test]
1087 fn class_prefix_applied_to_stack() {
1088 let c = Component::Stack(Stack {
1089 id: None,
1090 direction: StackDirection::Vertical,
1091 children: vec![],
1092 gap: 0,
1093 });
1094 let html = render_component_html(&c, &prefixed_opts("adk-"));
1095 assert!(html.contains("adk-stack"));
1096 assert!(html.contains("adk-stack-vertical"));
1097 }
1098
1099 #[test]
1100 fn class_prefix_applied_to_badge() {
1101 let c = Component::Badge(Badge {
1102 id: None,
1103 label: "Test".to_string(),
1104 variant: BadgeVariant::Info,
1105 });
1106 let html = render_component_html(&c, &prefixed_opts("adk-"));
1107 assert!(html.contains("adk-badge"));
1108 assert!(html.contains("adk-badge-info"));
1109 }
1110
1111 #[test]
1114 fn text_content_is_escaped() {
1115 let c = Component::Text(Text {
1116 id: None,
1117 content: "<script>alert('xss')</script>".to_string(),
1118 variant: TextVariant::Body,
1119 });
1120 let html = render_component_html(&c, &default_opts());
1121 assert!(!html.contains("<script>"));
1122 assert!(html.contains("<script>"));
1123 }
1124
1125 #[test]
1128 fn render_components_html_wraps_in_shell() {
1129 let components = vec![Component::Text(Text {
1130 id: None,
1131 content: "Hello".to_string(),
1132 variant: TextVariant::Body,
1133 })];
1134 let html = render_components_html(&components, &default_opts());
1135 assert!(html.contains("<!DOCTYPE html>"));
1136 assert!(html.contains("<html>"));
1137 assert!(html.contains("<body>"));
1138 assert!(html.contains("<p>Hello</p>"));
1139 }
1140
1141 #[test]
1142 fn render_components_html_no_external_resources() {
1143 let components = vec![Component::Text(Text {
1144 id: None,
1145 content: "Test".to_string(),
1146 variant: TextVariant::Body,
1147 })];
1148 let html = render_components_html(&components, &default_opts());
1149 assert!(!html.contains("<link rel=\"stylesheet\""));
1150 assert!(!html.contains("<script src="));
1151 assert!(!html.contains("@import"));
1152 }
1153
1154 #[test]
1157 fn render_surface_html_handles_valid_components() {
1158 let surface = UiSurface::new(
1159 "main",
1160 "catalog",
1161 vec![json!({"type": "text", "content": "Hello", "variant": "body"})],
1162 );
1163 let html = render_surface_html(&surface, &default_opts());
1164 assert!(html.contains("<p>Hello</p>"));
1165 }
1166
1167 #[test]
1168 fn render_surface_html_handles_unknown_components() {
1169 let surface = UiSurface::new(
1170 "main",
1171 "catalog",
1172 vec![json!({"type": "unknown_widget", "data": 42})],
1173 );
1174 let html = render_surface_html(&surface, &default_opts());
1175 assert!(html.contains("<!-- unknown component -->"));
1176 }
1177
1178 #[test]
1179 fn render_surface_html_mixes_valid_and_unknown() {
1180 let surface = UiSurface::new(
1181 "main",
1182 "catalog",
1183 vec![
1184 json!({"type": "text", "content": "Valid", "variant": "body"}),
1185 json!({"type": "bogus"}),
1186 json!({"type": "divider"}),
1187 ],
1188 );
1189 let html = render_surface_html(&surface, &default_opts());
1190 assert!(html.contains("<p>Valid</p>"));
1191 assert!(html.contains("<!-- unknown component -->"));
1192 assert!(html.contains("<hr>"));
1193 }
1194
1195 #[test]
1198 fn low_bandwidth_shell_has_no_style_tag() {
1199 let components = vec![Component::Text(Text {
1200 id: None,
1201 content: "Test".to_string(),
1202 variant: TextVariant::Body,
1203 })];
1204 let html = render_components_html(&components, &low_bw_opts());
1205 assert!(!html.contains("<style>"));
1206 assert!(!html.contains("style="));
1207 }
1208
1209 #[test]
1212 fn select_renders_with_options() {
1213 let c = Component::Select(Select {
1214 id: None,
1215 name: "color".to_string(),
1216 label: "Color".to_string(),
1217 options: vec![
1218 SelectOption {
1219 label: "Red".to_string(),
1220 value: "red".to_string(),
1221 },
1222 SelectOption {
1223 label: "Blue".to_string(),
1224 value: "blue".to_string(),
1225 },
1226 ],
1227 required: false,
1228 error: None,
1229 });
1230 let html = render_component_html(&c, &default_opts());
1231 assert!(html.contains("<select"));
1232 assert!(html.contains("<option value=\"red\">Red</option>"));
1233 assert!(html.contains("<option value=\"blue\">Blue</option>"));
1234 }
1235
1236 #[test]
1239 fn multiselect_renders_with_multiple_attr() {
1240 let c = Component::MultiSelect(MultiSelect {
1241 id: None,
1242 name: "tags".to_string(),
1243 label: "Tags".to_string(),
1244 options: vec![SelectOption {
1245 label: "A".to_string(),
1246 value: "a".to_string(),
1247 }],
1248 required: false,
1249 });
1250 let html = render_component_html(&c, &default_opts());
1251 assert!(html.contains("<select multiple"));
1252 }
1253
1254 #[test]
1257 fn ordered_list_renders_as_ol() {
1258 let c = Component::List(List {
1259 id: None,
1260 items: vec!["First".to_string(), "Second".to_string()],
1261 ordered: true,
1262 });
1263 let html = render_component_html(&c, &default_opts());
1264 assert!(html.contains("<ol>"));
1265 assert!(html.contains("<li>First</li>"));
1266 }
1267
1268 #[test]
1269 fn unordered_list_renders_as_ul() {
1270 let c = Component::List(List {
1271 id: None,
1272 items: vec!["Item".to_string()],
1273 ordered: false,
1274 });
1275 let html = render_component_html(&c, &default_opts());
1276 assert!(html.contains("<ul>"));
1277 }
1278
1279 #[test]
1282 fn keyvalue_renders_as_dl() {
1283 let c = Component::KeyValue(KeyValue {
1284 id: None,
1285 pairs: vec![KeyValuePair {
1286 key: "Name".to_string(),
1287 value: "Alice".to_string(),
1288 }],
1289 });
1290 let html = render_component_html(&c, &default_opts());
1291 assert!(html.contains("<dl>"));
1292 assert!(html.contains("<dt>Name</dt>"));
1293 assert!(html.contains("<dd>Alice</dd>"));
1294 }
1295
1296 #[test]
1299 fn codeblock_renders_as_pre_code() {
1300 let c = Component::CodeBlock(CodeBlock {
1301 id: None,
1302 code: "fn main() {}".to_string(),
1303 language: Some("rust".to_string()),
1304 });
1305 let html = render_component_html(&c, &default_opts());
1306 assert!(html.contains("<pre><code"));
1307 assert!(html.contains("language-rust"));
1308 assert!(html.contains("fn main() {}"));
1309 }
1310
1311 #[test]
1314 fn toast_renders_with_variant() {
1315 let c = Component::Toast(Toast {
1316 id: None,
1317 message: "Saved!".to_string(),
1318 variant: AlertVariant::Success,
1319 duration: 5000,
1320 dismissible: true,
1321 });
1322 let html = render_component_html(&c, &default_opts());
1323 assert!(html.contains("toast"));
1324 assert!(html.contains("toast-success"));
1325 assert!(html.contains("Saved!"));
1326 }
1327
1328 #[test]
1331 fn tabs_renders_buttons_and_panels() {
1332 let c = Component::Tabs(Tabs {
1333 id: None,
1334 tabs: vec![
1335 Tab {
1336 label: "Tab 1".to_string(),
1337 content: vec![Component::Text(Text {
1338 id: None,
1339 content: "Content 1".to_string(),
1340 variant: TextVariant::Body,
1341 })],
1342 },
1343 Tab {
1344 label: "Tab 2".to_string(),
1345 content: vec![],
1346 },
1347 ],
1348 });
1349 let html = render_component_html(&c, &default_opts());
1350 assert!(html.contains("tab-button"));
1351 assert!(html.contains("Tab 1"));
1352 assert!(html.contains("Tab 2"));
1353 assert!(html.contains("tab-panel"));
1354 assert!(html.contains("<p>Content 1</p>"));
1355 }
1356
1357 #[test]
1360 fn divider_renders_as_hr() {
1361 let c = Component::Divider(Divider { id: None });
1362 let html = render_component_html(&c, &default_opts());
1363 assert_eq!(html, "<hr>");
1364 }
1365
1366 #[test]
1369 fn switch_renders_as_checkbox_with_role() {
1370 let c = Component::Switch(Switch {
1371 id: None,
1372 name: "toggle".to_string(),
1373 label: "Enable".to_string(),
1374 default_checked: true,
1375 });
1376 let html = render_component_html(&c, &default_opts());
1377 assert!(html.contains("role=\"switch\""));
1378 assert!(html.contains("type=\"checkbox\""));
1379 assert!(html.contains(" checked"));
1380 }
1381
1382 #[test]
1385 fn date_input_renders_correctly() {
1386 let c = Component::DateInput(DateInput {
1387 id: None,
1388 name: "dob".to_string(),
1389 label: "Date of Birth".to_string(),
1390 required: true,
1391 });
1392 let html = render_component_html(&c, &default_opts());
1393 assert!(html.contains("type=\"date\""));
1394 assert!(html.contains("Date of Birth"));
1395 assert!(html.contains(" required"));
1396 }
1397
1398 #[test]
1401 fn slider_renders_as_range() {
1402 let c = Component::Slider(Slider {
1403 id: None,
1404 name: "volume".to_string(),
1405 label: "Volume".to_string(),
1406 min: 0.0,
1407 max: 100.0,
1408 step: Some(1.0),
1409 default_value: Some(50.0),
1410 });
1411 let html = render_component_html(&c, &default_opts());
1412 assert!(html.contains("type=\"range\""));
1413 assert!(html.contains("min=\"0\""));
1414 assert!(html.contains("max=\"100\""));
1415 }
1416
1417 #[test]
1420 fn textarea_renders_correctly() {
1421 let c = Component::Textarea(Textarea {
1422 id: None,
1423 name: "bio".to_string(),
1424 label: "Bio".to_string(),
1425 placeholder: Some("Tell us about yourself".to_string()),
1426 rows: 4,
1427 required: false,
1428 default_value: None,
1429 error: None,
1430 });
1431 let html = render_component_html(&c, &default_opts());
1432 assert!(html.contains("<textarea"));
1433 assert!(html.contains("name=\"bio\""));
1434 assert!(html.contains("Bio"));
1435 }
1436
1437 #[test]
1440 fn icon_renders_with_data_icon() {
1441 let c = Component::Icon(Icon {
1442 id: None,
1443 name: "heart".to_string(),
1444 size: 24,
1445 });
1446 let html = render_component_html(&c, &default_opts());
1447 assert!(html.contains("data-icon=\"heart\""));
1448 assert!(html.contains("icon"));
1449 }
1450
1451 #[test]
1454 fn container_renders_children() {
1455 let c = Component::Container(Container {
1456 id: None,
1457 children: vec![Component::Divider(Divider { id: None })],
1458 padding: 16,
1459 });
1460 let html = render_component_html(&c, &default_opts());
1461 assert!(html.contains("container"));
1462 assert!(html.contains("<hr>"));
1463 assert!(html.contains("style=\"padding: 16px\""));
1464 }
1465
1466 #[test]
1467 fn container_low_bandwidth_strips_style() {
1468 let c = Component::Container(Container {
1469 id: None,
1470 children: vec![],
1471 padding: 16,
1472 });
1473 let html = render_component_html(&c, &low_bw_opts());
1474 assert!(!html.contains("style="));
1475 }
1476}