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(
63 component: &Component,
64 options: &HtmlRenderOptions,
65) -> String {
66 let mode = options.bandwidth_mode;
67 let prefix = &options.class_prefix;
68
69 match component {
70 Component::Text(text) => {
72 let content = escape_html(&text.content);
73 match text.variant {
74 TextVariant::H1 => format!("<h1>{}</h1>", content),
75 TextVariant::H2 => format!("<h2>{}</h2>", content),
76 TextVariant::H3 => format!("<h3>{}</h3>", content),
77 TextVariant::H4 => format!("<h4>{}</h4>", content),
78 TextVariant::Body => format!("<p>{}</p>", content),
79 TextVariant::Caption => format!("<small>{}</small>", content),
80 TextVariant::Code => format!("<code>{}</code>", content),
81 }
82 }
83
84 Component::Button(button) => {
85 let label = escape_html(&button.label);
86 let action_id = escape_html(&button.action_id);
87 let disabled = if button.disabled { " disabled" } else { "" };
88 format!(
89 "<button data-action-id=\"{}\"{}>{}</button>",
90 action_id, disabled, label
91 )
92 }
93
94 Component::Icon(icon) => {
95 let name = escape_html(&icon.name);
96 format!(
97 "<span class=\"{}\" data-icon=\"{}\">{}</span>",
98 cls(prefix, "icon"),
99 name,
100 name
101 )
102 }
103
104 Component::Image(image) => {
105 if mode == BandwidthMode::Low {
106 return String::new();
107 }
108 let src = escape_html(&image.src);
109 let alt = image
110 .alt
111 .as_deref()
112 .map(escape_html)
113 .unwrap_or_default();
114 format!("<img src=\"{}\" alt=\"{}\">", src, alt)
115 }
116
117 Component::Badge(badge) => {
118 let label = escape_html(&badge.label);
119 let variant = badge_variant_str(&badge.variant);
120 format!(
121 "<span class=\"{} {}\">{}</span>",
122 cls(prefix, "badge"),
123 cls(prefix, &format!("badge-{}", variant)),
124 label
125 )
126 }
127
128 Component::TextInput(input) => {
130 let label = escape_html(&input.label);
131 let name = escape_html(&input.name);
132 let placeholder = input
133 .placeholder
134 .as_deref()
135 .map(|p| format!(" placeholder=\"{}\"", escape_html(p)))
136 .unwrap_or_default();
137 let required = if input.required { " required" } else { "" };
138 let default_val = input
139 .default_value
140 .as_deref()
141 .map(|v| format!(" value=\"{}\"", escape_html(v)))
142 .unwrap_or_default();
143 format!(
144 "<label>{}<input type=\"text\" name=\"{}\"{}{}{}></label>",
145 label, name, placeholder, required, default_val
146 )
147 }
148
149 Component::NumberInput(input) => {
150 let label = escape_html(&input.label);
151 let name = escape_html(&input.name);
152 let min = input
153 .min
154 .map(|v| format!(" min=\"{}\"", v))
155 .unwrap_or_default();
156 let max = input
157 .max
158 .map(|v| format!(" max=\"{}\"", v))
159 .unwrap_or_default();
160 let step = input
161 .step
162 .map(|v| format!(" step=\"{}\"", v))
163 .unwrap_or_default();
164 let required = if input.required { " required" } else { "" };
165 let default_val = input
166 .default_value
167 .map(|v| format!(" value=\"{}\"", v))
168 .unwrap_or_default();
169 format!(
170 "<label>{}<input type=\"number\" name=\"{}\"{}{}{}{}{}></label>",
171 label, name, min, max, step, required, default_val
172 )
173 }
174
175 Component::Select(select) => {
176 let label = escape_html(&select.label);
177 let name = escape_html(&select.name);
178 let required = if select.required { " required" } else { "" };
179 let options_html: String = select
180 .options
181 .iter()
182 .map(|opt| {
183 format!(
184 "<option value=\"{}\">{}</option>",
185 escape_html(&opt.value),
186 escape_html(&opt.label)
187 )
188 })
189 .collect();
190 format!(
191 "<label>{}<select name=\"{}\"{}>{}</select></label>",
192 label, name, required, options_html
193 )
194 }
195
196 Component::MultiSelect(multi) => {
197 let label = escape_html(&multi.label);
198 let name = escape_html(&multi.name);
199 let required = if multi.required { " required" } else { "" };
200 let options_html: String = multi
201 .options
202 .iter()
203 .map(|opt| {
204 format!(
205 "<option value=\"{}\">{}</option>",
206 escape_html(&opt.value),
207 escape_html(&opt.label)
208 )
209 })
210 .collect();
211 format!(
212 "<label>{}<select multiple name=\"{}\"{}>{}</select></label>",
213 label, name, required, options_html
214 )
215 }
216
217 Component::Switch(switch) => {
218 let label = escape_html(&switch.label);
219 let name = escape_html(&switch.name);
220 let checked = if switch.default_checked {
221 " checked"
222 } else {
223 ""
224 };
225 format!(
226 "<label>{}<input type=\"checkbox\" role=\"switch\" name=\"{}\"{}></label>",
227 label, name, checked
228 )
229 }
230
231 Component::DateInput(date) => {
232 let label = escape_html(&date.label);
233 let name = escape_html(&date.name);
234 let required = if date.required { " required" } else { "" };
235 format!(
236 "<label>{}<input type=\"date\" name=\"{}\"{}></label>",
237 label, name, required
238 )
239 }
240
241 Component::Slider(slider) => {
242 let label = escape_html(&slider.label);
243 let name = escape_html(&slider.name);
244 let step = slider
245 .step
246 .map(|v| format!(" step=\"{}\"", v))
247 .unwrap_or_default();
248 let default_val = slider
249 .default_value
250 .map(|v| format!(" value=\"{}\"", v))
251 .unwrap_or_default();
252 format!(
253 "<label>{}<input type=\"range\" name=\"{}\" min=\"{}\" max=\"{}\"{}{}></label>",
254 label, name, slider.min, slider.max, step, default_val
255 )
256 }
257
258 Component::Textarea(textarea) => {
259 let label = escape_html(&textarea.label);
260 let name = escape_html(&textarea.name);
261 let placeholder = textarea
262 .placeholder
263 .as_deref()
264 .map(|p| format!(" placeholder=\"{}\"", escape_html(p)))
265 .unwrap_or_default();
266 let required = if textarea.required { " required" } else { "" };
267 let default_val = textarea
268 .default_value
269 .as_deref()
270 .map(escape_html)
271 .unwrap_or_default();
272 format!(
273 "<label>{}<textarea name=\"{}\" rows=\"{}\"{}{}>{}</textarea></label>",
274 label, name, textarea.rows, placeholder, required, default_val
275 )
276 }
277
278 Component::Stack(stack) => {
280 let dir = match stack.direction {
281 StackDirection::Horizontal => "horizontal",
282 StackDirection::Vertical => "vertical",
283 };
284 let children_html = render_children(&stack.children, options);
285 let style_attr = if mode == BandwidthMode::Low {
286 String::new()
287 } else if stack.gap > 0 {
288 format!(" style=\"gap: {}px\"", stack.gap)
289 } else {
290 String::new()
291 };
292 format!(
293 "<div class=\"{} {}\"{}>{}</div>",
294 cls(prefix, "stack"),
295 cls(prefix, &format!("stack-{}", dir)),
296 style_attr,
297 children_html
298 )
299 }
300
301 Component::Grid(grid) => {
302 let children_html = render_children(&grid.children, options);
303 let style_attr = if mode == BandwidthMode::Low {
304 String::new()
305 } else {
306 format!(
307 " style=\"grid-template-columns: repeat({}, 1fr)\"",
308 grid.columns
309 )
310 };
311 format!(
312 "<div class=\"{}\"{}>{}</div>",
313 cls(prefix, "grid"),
314 style_attr,
315 children_html
316 )
317 }
318
319 Component::Card(card) => {
320 let mut html = format!("<div class=\"{}\">", cls(prefix, "card"));
321 if let Some(title) = &card.title {
322 html.push_str(&format!("<h3>{}</h3>", escape_html(title)));
323 }
324 if let Some(desc) = &card.description {
325 html.push_str(&format!("<p>{}</p>", escape_html(desc)));
326 }
327 if !card.content.is_empty() {
328 html.push_str(&format!(
329 "<div class=\"{}\">",
330 cls(prefix, "card-content")
331 ));
332 html.push_str(&render_children(&card.content, options));
333 html.push_str("</div>");
334 }
335 if let Some(footer) = &card.footer {
336 html.push_str(&format!(
337 "<div class=\"{}\">",
338 cls(prefix, "card-footer")
339 ));
340 html.push_str(&render_children(footer, options));
341 html.push_str("</div>");
342 }
343 html.push_str("</div>");
344 html
345 }
346
347 Component::Container(container) => {
348 let children_html = render_children(&container.children, options);
349 let style_attr = if mode == BandwidthMode::Low || container.padding == 0 {
350 String::new()
351 } else {
352 format!(" style=\"padding: {}px\"", container.padding)
353 };
354 format!(
355 "<div class=\"{}\"{}>{}</div>",
356 cls(prefix, "container"),
357 style_attr,
358 children_html
359 )
360 }
361
362 Component::Divider(_) => "<hr>".to_string(),
363
364 Component::Tabs(tabs) => {
365 let mut html = format!("<div class=\"{}\">", cls(prefix, "tabs"));
366 html.push_str(&format!(
368 "<div class=\"{}\">",
369 cls(prefix, "tab-buttons")
370 ));
371 for (i, tab) in tabs.tabs.iter().enumerate() {
372 html.push_str(&format!(
373 "<button class=\"{}\" data-tab-index=\"{}\">{}</button>",
374 cls(prefix, "tab-button"),
375 i,
376 escape_html(&tab.label)
377 ));
378 }
379 html.push_str("</div>");
380 for (i, tab) in tabs.tabs.iter().enumerate() {
382 html.push_str(&format!(
383 "<div class=\"{}\" data-tab-panel=\"{}\">",
384 cls(prefix, "tab-panel"),
385 i
386 ));
387 html.push_str(&render_children(&tab.content, options));
388 html.push_str("</div>");
389 }
390 html.push_str("</div>");
391 html
392 }
393
394 Component::Table(table) => {
396 let mut html = String::from("<table>");
397 html.push_str("<thead><tr>");
399 for col in &table.columns {
400 html.push_str(&format!("<th>{}</th>", escape_html(&col.header)));
401 }
402 html.push_str("</tr></thead>");
403 html.push_str("<tbody>");
405 for row in &table.data {
406 html.push_str("<tr>");
407 for col in &table.columns {
408 let cell_value = row
409 .get(&col.accessor_key)
410 .map(|v| match v {
411 serde_json::Value::String(s) => escape_html(s),
412 other => escape_html(&other.to_string()),
413 })
414 .unwrap_or_default();
415 html.push_str(&format!("<td>{}</td>", cell_value));
416 }
417 html.push_str("</tr>");
418 }
419 html.push_str("</tbody></table>");
420 html
421 }
422
423 Component::List(list) => {
424 let tag = if list.ordered { "ol" } else { "ul" };
425 let items_html: String = list
426 .items
427 .iter()
428 .map(|item| format!("<li>{}</li>", escape_html(item)))
429 .collect();
430 format!("<{}>{}</{}>", tag, items_html, tag)
431 }
432
433 Component::KeyValue(kv) => {
434 let mut html = String::from("<dl>");
435 for pair in &kv.pairs {
436 html.push_str(&format!(
437 "<dt>{}</dt><dd>{}</dd>",
438 escape_html(&pair.key),
439 escape_html(&pair.value)
440 ));
441 }
442 html.push_str("</dl>");
443 html
444 }
445
446 Component::CodeBlock(code_block) => {
447 let lang_attr = code_block
448 .language
449 .as_deref()
450 .map(|l| format!(" class=\"language-{}\"", escape_html(l)))
451 .unwrap_or_default();
452 format!(
453 "<pre><code{}>{}</code></pre>",
454 lang_attr,
455 escape_html(&code_block.code)
456 )
457 }
458
459 Component::Chart(chart) => {
461 if mode == BandwidthMode::Low {
462 return String::new();
463 }
464 let chart_json = escape_html(
465 &serde_json::to_string(chart).unwrap_or_default(),
466 );
467 let title_html = chart
468 .title
469 .as_deref()
470 .map(|t| format!("<p>{}</p>", escape_html(t)))
471 .unwrap_or_default();
472 format!(
473 "<div class=\"{}\" data-chart=\"{}\">{}</div>",
474 cls(prefix, "chart-placeholder"),
475 chart_json,
476 title_html
477 )
478 }
479
480 Component::Alert(alert) => {
482 let variant = alert_variant_str(&alert.variant);
483 let desc = alert
484 .description
485 .as_deref()
486 .map(|d| format!("<p>{}</p>", escape_html(d)))
487 .unwrap_or_default();
488 format!(
489 "<div class=\"{} {}\" role=\"alert\"><strong>{}</strong>{}</div>",
490 cls(prefix, "alert"),
491 cls(prefix, &format!("alert-{}", variant)),
492 escape_html(&alert.title),
493 desc
494 )
495 }
496
497 Component::Progress(progress) => {
498 let label = progress
499 .label
500 .as_deref()
501 .map(|l| format!(" aria-label=\"{}\"", escape_html(l)))
502 .unwrap_or_default();
503 format!(
504 "<progress value=\"{}\" max=\"100\"{}></progress>",
505 progress.value, label
506 )
507 }
508
509 Component::Toast(toast) => {
510 let variant = alert_variant_str(&toast.variant);
511 format!(
512 "<div class=\"{} {}\">{}</div>",
513 cls(prefix, "toast"),
514 cls(prefix, &format!("toast-{}", variant)),
515 escape_html(&toast.message)
516 )
517 }
518
519 Component::Modal(modal) => {
520 let mut html = String::from("<dialog>");
521 html.push_str(&format!("<h2>{}</h2>", escape_html(&modal.title)));
522 html.push_str(&render_children(&modal.content, options));
523 if let Some(footer) = &modal.footer {
524 html.push_str(&format!(
525 "<div class=\"{}\">",
526 cls(prefix, "modal-footer")
527 ));
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(|value| {
699 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
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use serde_json::json;
713
714 fn default_opts() -> HtmlRenderOptions {
715 HtmlRenderOptions::default()
716 }
717
718 fn low_bw_opts() -> HtmlRenderOptions {
719 HtmlRenderOptions {
720 bandwidth_mode: BandwidthMode::Low,
721 ..Default::default()
722 }
723 }
724
725 fn prefixed_opts(prefix: &str) -> HtmlRenderOptions {
726 HtmlRenderOptions {
727 class_prefix: Some(prefix.to_string()),
728 ..Default::default()
729 }
730 }
731
732 #[test]
735 fn escape_html_escapes_all_special_chars() {
736 assert_eq!(
737 escape_html("<script>alert('xss')&\"</script>"),
738 "<script>alert('xss')&"</script>"
739 );
740 }
741
742 #[test]
743 fn escape_html_passes_through_normal_text() {
744 assert_eq!(escape_html("Hello, world!"), "Hello, world!");
745 }
746
747 #[test]
750 fn bandwidth_mode_default_is_full() {
751 assert_eq!(BandwidthMode::default(), BandwidthMode::Full);
752 }
753
754 #[test]
757 fn text_body_renders_as_p() {
758 let c = Component::Text(Text {
759 id: None,
760 content: "Hello".to_string(),
761 variant: TextVariant::Body,
762 });
763 let html = render_component_html(&c, &default_opts());
764 assert_eq!(html, "<p>Hello</p>");
765 }
766
767 #[test]
768 fn text_h1_renders_as_h1() {
769 let c = Component::Text(Text {
770 id: None,
771 content: "Title".to_string(),
772 variant: TextVariant::H1,
773 });
774 let html = render_component_html(&c, &default_opts());
775 assert_eq!(html, "<h1>Title</h1>");
776 }
777
778 #[test]
779 fn text_caption_renders_as_small() {
780 let c = Component::Text(Text {
781 id: None,
782 content: "Note".to_string(),
783 variant: TextVariant::Caption,
784 });
785 let html = render_component_html(&c, &default_opts());
786 assert_eq!(html, "<small>Note</small>");
787 }
788
789 #[test]
790 fn text_code_renders_as_code() {
791 let c = Component::Text(Text {
792 id: None,
793 content: "let x = 1;".to_string(),
794 variant: TextVariant::Code,
795 });
796 let html = render_component_html(&c, &default_opts());
797 assert_eq!(html, "<code>let x = 1;</code>");
798 }
799
800 #[test]
803 fn button_renders_with_action_id() {
804 let c = Component::Button(Button {
805 id: None,
806 label: "Click me".to_string(),
807 action_id: "btn-1".to_string(),
808 variant: ButtonVariant::Primary,
809 disabled: false,
810 icon: None,
811 });
812 let html = render_component_html(&c, &default_opts());
813 assert!(html.contains("data-action-id=\"btn-1\""));
814 assert!(html.contains("Click me"));
815 }
816
817 #[test]
818 fn button_disabled_renders_disabled_attr() {
819 let c = Component::Button(Button {
820 id: None,
821 label: "Disabled".to_string(),
822 action_id: "btn-2".to_string(),
823 variant: ButtonVariant::Primary,
824 disabled: true,
825 icon: None,
826 });
827 let html = render_component_html(&c, &default_opts());
828 assert!(html.contains(" disabled"));
829 }
830
831 #[test]
834 fn image_renders_in_full_mode() {
835 let c = Component::Image(Image {
836 id: None,
837 src: "https://example.com/img.png".to_string(),
838 alt: Some("A photo".to_string()),
839 });
840 let html = render_component_html(&c, &default_opts());
841 assert!(html.contains("<img"));
842 assert!(html.contains("src=\"https://example.com/img.png\""));
843 assert!(html.contains("alt=\"A photo\""));
844 }
845
846 #[test]
847 fn image_omitted_in_low_bandwidth() {
848 let c = Component::Image(Image {
849 id: None,
850 src: "https://example.com/img.png".to_string(),
851 alt: Some("A photo".to_string()),
852 });
853 let html = render_component_html(&c, &low_bw_opts());
854 assert!(html.is_empty());
855 }
856
857 #[test]
860 fn badge_renders_with_variant() {
861 let c = Component::Badge(Badge {
862 id: None,
863 label: "New".to_string(),
864 variant: BadgeVariant::Success,
865 });
866 let html = render_component_html(&c, &default_opts());
867 assert!(html.contains("badge"));
868 assert!(html.contains("badge-success"));
869 assert!(html.contains("New"));
870 }
871
872 #[test]
875 fn stack_vertical_renders_correctly() {
876 let c = Component::Stack(Stack {
877 id: None,
878 direction: StackDirection::Vertical,
879 children: vec![Component::Text(Text {
880 id: None,
881 content: "Child".to_string(),
882 variant: TextVariant::Body,
883 })],
884 gap: 0,
885 });
886 let html = render_component_html(&c, &default_opts());
887 assert!(html.contains("stack-vertical"));
888 assert!(html.contains("<p>Child</p>"));
889 }
890
891 #[test]
894 fn grid_renders_with_columns() {
895 let c = Component::Grid(Grid {
896 id: None,
897 columns: 3,
898 children: vec![],
899 gap: 0,
900 });
901 let html = render_component_html(&c, &default_opts());
902 assert!(html.contains("grid"));
903 assert!(html.contains("grid-template-columns: repeat(3, 1fr)"));
904 }
905
906 #[test]
907 fn grid_low_bandwidth_strips_style() {
908 let c = Component::Grid(Grid {
909 id: None,
910 columns: 3,
911 children: vec![],
912 gap: 0,
913 });
914 let html = render_component_html(&c, &low_bw_opts());
915 assert!(html.contains("grid"));
916 assert!(!html.contains("style="));
917 }
918
919 #[test]
922 fn card_renders_with_title_and_content() {
923 let c = Component::Card(Card {
924 id: None,
925 title: Some("My Card".to_string()),
926 description: None,
927 content: vec![Component::Text(Text {
928 id: None,
929 content: "Body text".to_string(),
930 variant: TextVariant::Body,
931 })],
932 footer: None,
933 });
934 let html = render_component_html(&c, &default_opts());
935 assert!(html.contains("card"));
936 assert!(html.contains("<h3>My Card</h3>"));
937 assert!(html.contains("<p>Body text</p>"));
938 }
939
940 #[test]
943 fn table_renders_with_headers_and_rows() {
944 let c = Component::Table(Table {
945 id: None,
946 columns: vec![TableColumn {
947 header: "Name".to_string(),
948 accessor_key: "name".to_string(),
949 sortable: true,
950 }],
951 data: vec![{
952 let mut row = std::collections::HashMap::new();
953 row.insert("name".to_string(), json!("Alice"));
954 row
955 }],
956 sortable: false,
957 page_size: None,
958 striped: false,
959 });
960 let html = render_component_html(&c, &default_opts());
961 assert!(html.contains("<table>"));
962 assert!(html.contains("<th>Name</th>"));
963 assert!(html.contains("<td>Alice</td>"));
964 }
965
966 #[test]
969 fn alert_renders_with_role() {
970 let c = Component::Alert(Alert {
971 id: None,
972 title: "Warning!".to_string(),
973 description: Some("Be careful".to_string()),
974 variant: AlertVariant::Warning,
975 });
976 let html = render_component_html(&c, &default_opts());
977 assert!(html.contains("role=\"alert\""));
978 assert!(html.contains("alert-warning"));
979 assert!(html.contains("Warning!"));
980 }
981
982 #[test]
985 fn progress_renders_with_value() {
986 let c = Component::Progress(Progress {
987 id: None,
988 value: 75,
989 label: Some("Loading".to_string()),
990 });
991 let html = render_component_html(&c, &default_opts());
992 assert!(html.contains("<progress"));
993 assert!(html.contains("value=\"75\""));
994 assert!(html.contains("max=\"100\""));
995 }
996
997 #[test]
1000 fn chart_omitted_in_low_bandwidth() {
1001 let c = Component::Chart(Chart {
1002 id: None,
1003 title: Some("Sales".to_string()),
1004 kind: ChartKind::Bar,
1005 data: vec![],
1006 x_key: "month".to_string(),
1007 y_keys: vec!["revenue".to_string()],
1008 x_label: None,
1009 y_label: None,
1010 show_legend: true,
1011 colors: None,
1012 });
1013 let html = render_component_html(&c, &low_bw_opts());
1014 assert!(html.is_empty());
1015 }
1016
1017 #[test]
1018 fn chart_renders_placeholder_in_full_mode() {
1019 let c = Component::Chart(Chart {
1020 id: None,
1021 title: Some("Sales".to_string()),
1022 kind: ChartKind::Bar,
1023 data: vec![],
1024 x_key: "month".to_string(),
1025 y_keys: vec!["revenue".to_string()],
1026 x_label: None,
1027 y_label: None,
1028 show_legend: true,
1029 colors: None,
1030 });
1031 let html = render_component_html(&c, &default_opts());
1032 assert!(html.contains("chart-placeholder"));
1033 assert!(html.contains("data-chart="));
1034 }
1035
1036 #[test]
1039 fn spinner_omitted_in_low_bandwidth() {
1040 let c = Component::Spinner(Spinner {
1041 id: None,
1042 size: SpinnerSize::Medium,
1043 label: None,
1044 });
1045 let html = render_component_html(&c, &low_bw_opts());
1046 assert!(html.is_empty());
1047 }
1048
1049 #[test]
1052 fn skeleton_omitted_in_low_bandwidth() {
1053 let c = Component::Skeleton(Skeleton {
1054 id: None,
1055 variant: SkeletonVariant::Text,
1056 width: None,
1057 height: None,
1058 });
1059 let html = render_component_html(&c, &low_bw_opts());
1060 assert!(html.is_empty());
1061 }
1062
1063 #[test]
1066 fn modal_renders_as_dialog() {
1067 let c = Component::Modal(Modal {
1068 id: None,
1069 title: "Confirm".to_string(),
1070 content: vec![Component::Text(Text {
1071 id: None,
1072 content: "Are you sure?".to_string(),
1073 variant: TextVariant::Body,
1074 })],
1075 footer: None,
1076 size: ModalSize::Medium,
1077 closable: true,
1078 });
1079 let html = render_component_html(&c, &default_opts());
1080 assert!(html.contains("<dialog>"));
1081 assert!(html.contains("<h2>Confirm</h2>"));
1082 assert!(html.contains("<p>Are you sure?</p>"));
1083 }
1084
1085 #[test]
1088 fn class_prefix_applied_to_stack() {
1089 let c = Component::Stack(Stack {
1090 id: None,
1091 direction: StackDirection::Vertical,
1092 children: vec![],
1093 gap: 0,
1094 });
1095 let html = render_component_html(&c, &prefixed_opts("adk-"));
1096 assert!(html.contains("adk-stack"));
1097 assert!(html.contains("adk-stack-vertical"));
1098 }
1099
1100 #[test]
1101 fn class_prefix_applied_to_badge() {
1102 let c = Component::Badge(Badge {
1103 id: None,
1104 label: "Test".to_string(),
1105 variant: BadgeVariant::Info,
1106 });
1107 let html = render_component_html(&c, &prefixed_opts("adk-"));
1108 assert!(html.contains("adk-badge"));
1109 assert!(html.contains("adk-badge-info"));
1110 }
1111
1112 #[test]
1115 fn text_content_is_escaped() {
1116 let c = Component::Text(Text {
1117 id: None,
1118 content: "<script>alert('xss')</script>".to_string(),
1119 variant: TextVariant::Body,
1120 });
1121 let html = render_component_html(&c, &default_opts());
1122 assert!(!html.contains("<script>"));
1123 assert!(html.contains("<script>"));
1124 }
1125
1126 #[test]
1129 fn render_components_html_wraps_in_shell() {
1130 let components = vec![Component::Text(Text {
1131 id: None,
1132 content: "Hello".to_string(),
1133 variant: TextVariant::Body,
1134 })];
1135 let html = render_components_html(&components, &default_opts());
1136 assert!(html.contains("<!DOCTYPE html>"));
1137 assert!(html.contains("<html>"));
1138 assert!(html.contains("<body>"));
1139 assert!(html.contains("<p>Hello</p>"));
1140 }
1141
1142 #[test]
1143 fn render_components_html_no_external_resources() {
1144 let components = vec![Component::Text(Text {
1145 id: None,
1146 content: "Test".to_string(),
1147 variant: TextVariant::Body,
1148 })];
1149 let html = render_components_html(&components, &default_opts());
1150 assert!(!html.contains("<link rel=\"stylesheet\""));
1151 assert!(!html.contains("<script src="));
1152 assert!(!html.contains("@import"));
1153 }
1154
1155 #[test]
1158 fn render_surface_html_handles_valid_components() {
1159 let surface = UiSurface::new(
1160 "main",
1161 "catalog",
1162 vec![json!({"type": "text", "content": "Hello", "variant": "body"})],
1163 );
1164 let html = render_surface_html(&surface, &default_opts());
1165 assert!(html.contains("<p>Hello</p>"));
1166 }
1167
1168 #[test]
1169 fn render_surface_html_handles_unknown_components() {
1170 let surface = UiSurface::new(
1171 "main",
1172 "catalog",
1173 vec![json!({"type": "unknown_widget", "data": 42})],
1174 );
1175 let html = render_surface_html(&surface, &default_opts());
1176 assert!(html.contains("<!-- unknown component -->"));
1177 }
1178
1179 #[test]
1180 fn render_surface_html_mixes_valid_and_unknown() {
1181 let surface = UiSurface::new(
1182 "main",
1183 "catalog",
1184 vec![
1185 json!({"type": "text", "content": "Valid", "variant": "body"}),
1186 json!({"type": "bogus"}),
1187 json!({"type": "divider"}),
1188 ],
1189 );
1190 let html = render_surface_html(&surface, &default_opts());
1191 assert!(html.contains("<p>Valid</p>"));
1192 assert!(html.contains("<!-- unknown component -->"));
1193 assert!(html.contains("<hr>"));
1194 }
1195
1196 #[test]
1199 fn low_bandwidth_shell_has_no_style_tag() {
1200 let components = vec![Component::Text(Text {
1201 id: None,
1202 content: "Test".to_string(),
1203 variant: TextVariant::Body,
1204 })];
1205 let html = render_components_html(&components, &low_bw_opts());
1206 assert!(!html.contains("<style>"));
1207 assert!(!html.contains("style="));
1208 }
1209
1210 #[test]
1213 fn select_renders_with_options() {
1214 let c = Component::Select(Select {
1215 id: None,
1216 name: "color".to_string(),
1217 label: "Color".to_string(),
1218 options: vec![
1219 SelectOption {
1220 label: "Red".to_string(),
1221 value: "red".to_string(),
1222 },
1223 SelectOption {
1224 label: "Blue".to_string(),
1225 value: "blue".to_string(),
1226 },
1227 ],
1228 required: false,
1229 error: None,
1230 });
1231 let html = render_component_html(&c, &default_opts());
1232 assert!(html.contains("<select"));
1233 assert!(html.contains("<option value=\"red\">Red</option>"));
1234 assert!(html.contains("<option value=\"blue\">Blue</option>"));
1235 }
1236
1237 #[test]
1240 fn multiselect_renders_with_multiple_attr() {
1241 let c = Component::MultiSelect(MultiSelect {
1242 id: None,
1243 name: "tags".to_string(),
1244 label: "Tags".to_string(),
1245 options: vec![SelectOption {
1246 label: "A".to_string(),
1247 value: "a".to_string(),
1248 }],
1249 required: false,
1250 });
1251 let html = render_component_html(&c, &default_opts());
1252 assert!(html.contains("<select multiple"));
1253 }
1254
1255 #[test]
1258 fn ordered_list_renders_as_ol() {
1259 let c = Component::List(List {
1260 id: None,
1261 items: vec!["First".to_string(), "Second".to_string()],
1262 ordered: true,
1263 });
1264 let html = render_component_html(&c, &default_opts());
1265 assert!(html.contains("<ol>"));
1266 assert!(html.contains("<li>First</li>"));
1267 }
1268
1269 #[test]
1270 fn unordered_list_renders_as_ul() {
1271 let c = Component::List(List {
1272 id: None,
1273 items: vec!["Item".to_string()],
1274 ordered: false,
1275 });
1276 let html = render_component_html(&c, &default_opts());
1277 assert!(html.contains("<ul>"));
1278 }
1279
1280 #[test]
1283 fn keyvalue_renders_as_dl() {
1284 let c = Component::KeyValue(KeyValue {
1285 id: None,
1286 pairs: vec![KeyValuePair {
1287 key: "Name".to_string(),
1288 value: "Alice".to_string(),
1289 }],
1290 });
1291 let html = render_component_html(&c, &default_opts());
1292 assert!(html.contains("<dl>"));
1293 assert!(html.contains("<dt>Name</dt>"));
1294 assert!(html.contains("<dd>Alice</dd>"));
1295 }
1296
1297 #[test]
1300 fn codeblock_renders_as_pre_code() {
1301 let c = Component::CodeBlock(CodeBlock {
1302 id: None,
1303 code: "fn main() {}".to_string(),
1304 language: Some("rust".to_string()),
1305 });
1306 let html = render_component_html(&c, &default_opts());
1307 assert!(html.contains("<pre><code"));
1308 assert!(html.contains("language-rust"));
1309 assert!(html.contains("fn main() {}"));
1310 }
1311
1312 #[test]
1315 fn toast_renders_with_variant() {
1316 let c = Component::Toast(Toast {
1317 id: None,
1318 message: "Saved!".to_string(),
1319 variant: AlertVariant::Success,
1320 duration: 5000,
1321 dismissible: true,
1322 });
1323 let html = render_component_html(&c, &default_opts());
1324 assert!(html.contains("toast"));
1325 assert!(html.contains("toast-success"));
1326 assert!(html.contains("Saved!"));
1327 }
1328
1329 #[test]
1332 fn tabs_renders_buttons_and_panels() {
1333 let c = Component::Tabs(Tabs {
1334 id: None,
1335 tabs: vec![
1336 Tab {
1337 label: "Tab 1".to_string(),
1338 content: vec![Component::Text(Text {
1339 id: None,
1340 content: "Content 1".to_string(),
1341 variant: TextVariant::Body,
1342 })],
1343 },
1344 Tab {
1345 label: "Tab 2".to_string(),
1346 content: vec![],
1347 },
1348 ],
1349 });
1350 let html = render_component_html(&c, &default_opts());
1351 assert!(html.contains("tab-button"));
1352 assert!(html.contains("Tab 1"));
1353 assert!(html.contains("Tab 2"));
1354 assert!(html.contains("tab-panel"));
1355 assert!(html.contains("<p>Content 1</p>"));
1356 }
1357
1358 #[test]
1361 fn divider_renders_as_hr() {
1362 let c = Component::Divider(Divider { id: None });
1363 let html = render_component_html(&c, &default_opts());
1364 assert_eq!(html, "<hr>");
1365 }
1366
1367 #[test]
1370 fn switch_renders_as_checkbox_with_role() {
1371 let c = Component::Switch(Switch {
1372 id: None,
1373 name: "toggle".to_string(),
1374 label: "Enable".to_string(),
1375 default_checked: true,
1376 });
1377 let html = render_component_html(&c, &default_opts());
1378 assert!(html.contains("role=\"switch\""));
1379 assert!(html.contains("type=\"checkbox\""));
1380 assert!(html.contains(" checked"));
1381 }
1382
1383 #[test]
1386 fn date_input_renders_correctly() {
1387 let c = Component::DateInput(DateInput {
1388 id: None,
1389 name: "dob".to_string(),
1390 label: "Date of Birth".to_string(),
1391 required: true,
1392 });
1393 let html = render_component_html(&c, &default_opts());
1394 assert!(html.contains("type=\"date\""));
1395 assert!(html.contains("Date of Birth"));
1396 assert!(html.contains(" required"));
1397 }
1398
1399 #[test]
1402 fn slider_renders_as_range() {
1403 let c = Component::Slider(Slider {
1404 id: None,
1405 name: "volume".to_string(),
1406 label: "Volume".to_string(),
1407 min: 0.0,
1408 max: 100.0,
1409 step: Some(1.0),
1410 default_value: Some(50.0),
1411 });
1412 let html = render_component_html(&c, &default_opts());
1413 assert!(html.contains("type=\"range\""));
1414 assert!(html.contains("min=\"0\""));
1415 assert!(html.contains("max=\"100\""));
1416 }
1417
1418 #[test]
1421 fn textarea_renders_correctly() {
1422 let c = Component::Textarea(Textarea {
1423 id: None,
1424 name: "bio".to_string(),
1425 label: "Bio".to_string(),
1426 placeholder: Some("Tell us about yourself".to_string()),
1427 rows: 4,
1428 required: false,
1429 default_value: None,
1430 error: None,
1431 });
1432 let html = render_component_html(&c, &default_opts());
1433 assert!(html.contains("<textarea"));
1434 assert!(html.contains("name=\"bio\""));
1435 assert!(html.contains("Bio"));
1436 }
1437
1438 #[test]
1441 fn icon_renders_with_data_icon() {
1442 let c = Component::Icon(Icon {
1443 id: None,
1444 name: "heart".to_string(),
1445 size: 24,
1446 });
1447 let html = render_component_html(&c, &default_opts());
1448 assert!(html.contains("data-icon=\"heart\""));
1449 assert!(html.contains("icon"));
1450 }
1451
1452 #[test]
1455 fn container_renders_children() {
1456 let c = Component::Container(Container {
1457 id: None,
1458 children: vec![Component::Divider(Divider { id: None })],
1459 padding: 16,
1460 });
1461 let html = render_component_html(&c, &default_opts());
1462 assert!(html.contains("container"));
1463 assert!(html.contains("<hr>"));
1464 assert!(html.contains("style=\"padding: 16px\""));
1465 }
1466
1467 #[test]
1468 fn container_low_bandwidth_strips_style() {
1469 let c = Component::Container(Container {
1470 id: None,
1471 children: vec![],
1472 padding: 16,
1473 });
1474 let html = render_component_html(&c, &low_bw_opts());
1475 assert!(!html.contains("style="));
1476 }
1477}