1use crate::route_matcher;
12use serde_json::Value;
13use std::collections::{HashMap, HashSet};
14use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIFragmentRoute, WebUIProtocol};
15
16pub fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
25 let mut names: HashSet<&String> = HashSet::new();
26 for key in protocol.fragments.keys() {
27 if key.contains('-') {
28 names.insert(key);
29 }
30 }
31 let mut sorted: Vec<&String> = names.into_iter().collect();
32 sorted.sort_unstable();
33 sorted
34 .iter()
35 .enumerate()
36 .map(|(i, n)| ((*n).clone(), i as u32))
37 .collect()
38}
39
40fn has_component(inventory: &[u8], index: u32) -> bool {
42 let byte_idx = (index / 8) as usize;
43 let bit_idx = index % 8;
44 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
45}
46
47fn set_component(inventory: &mut Vec<u8>, index: u32) {
49 let byte_idx = (index / 8) as usize;
50 let bit_idx = index % 8;
51 if byte_idx >= inventory.len() {
52 inventory.resize(byte_idx + 1, 0);
53 }
54 inventory[byte_idx] |= 1 << bit_idx;
55}
56
57pub fn parse_inventory(hex: &str) -> Vec<u8> {
59 let mut bytes = Vec::with_capacity(hex.len() / 2);
60 let mut chars = hex.bytes();
61 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
62 if let (Some(h), Some(l)) = (hex_nibble(hi), hex_nibble(lo)) {
63 bytes.push((h << 4) | l);
64 }
65 }
66 bytes
67}
68
69fn hex_nibble(byte: u8) -> Option<u8> {
70 match byte {
71 b'0'..=b'9' => Some(byte - b'0'),
72 b'a'..=b'f' => Some(byte - b'a' + 10),
73 b'A'..=b'F' => Some(byte - b'A' + 10),
74 _ => None,
75 }
76}
77
78pub fn encode_inventory(inv: &[u8]) -> String {
80 inv.iter()
81 .fold(String::with_capacity(inv.len() * 2), |mut acc, b| {
82 use std::fmt::Write;
83 let _ = write!(acc, "{b:02x}");
84 acc
85 })
86}
87
88pub fn get_needed_components(
93 protocol: &WebUIProtocol,
94 entry_id: &str,
95 inventory_hex: &str,
96) -> (Vec<String>, String) {
97 let component_names = collect_inventoryable_components(protocol, entry_id, None, true);
98 let index = build_component_index(protocol);
99 filter_needed_components(&component_names, inventory_hex, &index)
100}
101
102pub fn get_needed_components_for_request(
107 protocol: &WebUIProtocol,
108 entry_id: &str,
109 request_path: &str,
110 inventory_hex: &str,
111) -> (Vec<String>, String) {
112 let component_names =
113 collect_inventoryable_components(protocol, entry_id, Some(request_path), false);
114 let index = build_component_index(protocol);
115 filter_needed_components(&component_names, inventory_hex, &index)
116}
117
118#[must_use]
123pub fn filter_needed_components(
124 component_names: &HashSet<String>,
125 inventory_hex: &str,
126 index: &HashMap<String, u32>,
127) -> (Vec<String>, String) {
128 let client_inv = parse_inventory(inventory_hex);
129 let mut updated_inv = client_inv.clone();
130
131 let mut ordered_names: Vec<&String> = component_names.iter().collect();
132 ordered_names.sort_unstable();
133
134 let mut needed = Vec::with_capacity(ordered_names.len());
135 for name in ordered_names {
136 if let Some(&idx) = index.get(name.as_str()) {
137 if !has_component(&client_inv, idx) {
138 needed.push(name.clone());
139 }
140 set_component(&mut updated_inv, idx);
141 } else {
142 needed.push(name.clone());
146 }
147 }
148
149 (needed, encode_inventory(&updated_inv))
150}
151
152#[derive(Debug)]
153struct QueuedFragment {
154 id: String,
155 inventoryable: bool,
156 route_base: String,
158}
159
160fn collect_inventoryable_components(
170 protocol: &WebUIProtocol,
171 entry_id: &str,
172 request_path: Option<&str>,
173 root_inventoryable: bool,
174) -> HashSet<String> {
175 let mut visited_fragments = HashSet::new();
176 let mut component_ids = HashSet::new();
177 let mut stack = vec![QueuedFragment {
178 id: entry_id.to_string(),
179 inventoryable: root_inventoryable,
180 route_base: "/".to_string(),
181 }];
182
183 while let Some(queued) = stack.pop() {
184 if queued.id.is_empty() {
185 continue;
186 }
187
188 if queued.inventoryable {
189 component_ids.insert(queued.id.clone());
190 }
191
192 if !visited_fragments.insert(queued.id.clone()) {
193 continue;
194 }
195
196 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
197 continue;
198 };
199
200 let matched_route = request_path
201 .and_then(|path| find_best_route_match(&frag_list.fragments, path, &queued.route_base));
202
203 for frag in &frag_list.fragments {
204 match frag.fragment.as_ref() {
205 Some(Fragment::Component(component)) => {
206 stack.push(QueuedFragment {
207 id: component.fragment_id.clone(),
208 inventoryable: true,
209 route_base: queued.route_base.clone(),
210 });
211 }
212 Some(Fragment::ForLoop(for_loop)) => {
213 stack.push(QueuedFragment {
214 id: for_loop.fragment_id.clone(),
215 inventoryable: false,
216 route_base: queued.route_base.clone(),
217 });
218 }
219 Some(Fragment::IfCond(if_cond)) => {
220 stack.push(QueuedFragment {
221 id: if_cond.fragment_id.clone(),
222 inventoryable: false,
223 route_base: queued.route_base.clone(),
224 });
225 }
226 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
227 stack.push(QueuedFragment {
228 id: attr.template.clone(),
229 inventoryable: false,
230 route_base: queued.route_base.clone(),
231 });
232 }
233 Some(Fragment::Route(route_frag)) => {
234 let is_selected = matched_route
235 .as_ref()
236 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
237 if is_selected && !route_frag.fragment_id.is_empty() {
238 let child_route_base = if let Some((_, ref rm)) = matched_route {
240 if let Some(path) = request_path {
241 route_matcher::compute_route_base(path, rm.consumed_segments)
242 } else {
243 queued.route_base.clone()
244 }
245 } else {
246 queued.route_base.clone()
247 };
248
249 stack.push(QueuedFragment {
250 id: route_frag.fragment_id.clone(),
251 inventoryable: protocol
252 .components
253 .get(&route_frag.fragment_id)
254 .is_some_and(|c| !c.template.is_empty()),
255 route_base: child_route_base.clone(),
256 });
257
258 if !route_frag.children.is_empty() {
262 if let Some(path) = request_path {
263 walk_route_children(
264 &route_frag.children,
265 path,
266 &child_route_base,
267 protocol,
268 &mut stack,
269 );
270 }
271 }
272 }
273 }
274 _ => {}
275 }
276 }
277 }
278
279 component_ids
280}
281
282fn walk_route_children(
285 children: &[WebUIFragmentRoute],
286 request_path: &str,
287 route_base: &str,
288 protocol: &WebUIProtocol,
289 stack: &mut Vec<QueuedFragment>,
290) {
291 let mut current = children;
292 let mut base = route_base.to_string();
293
294 loop {
295 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
296 for (idx, child) in current.iter().enumerate() {
297 let resolved = route_matcher::resolve_route_path(&child.path, &base);
298 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
299 {
300 let is_better = best
301 .as_ref()
302 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
303 if is_better {
304 best = Some((idx, m));
305 }
306 }
307 }
308
309 let Some((idx, ref rm)) = best else { break };
310 let matched = ¤t[idx];
311 if matched.fragment_id.is_empty() {
312 break;
313 }
314
315 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
316
317 stack.push(QueuedFragment {
318 id: matched.fragment_id.clone(),
319 inventoryable: protocol
320 .components
321 .get(&matched.fragment_id)
322 .is_some_and(|c| !c.template.is_empty()),
323 route_base: child_base.clone(),
324 });
325
326 if matched.children.is_empty() {
327 break;
328 }
329 current = &matched.children;
330 base = child_base;
331 }
332}
333
334fn find_best_route_match(
338 fragments: &[WebUIFragment],
339 request_path: &str,
340 route_base: &str,
341) -> Option<(String, route_matcher::RouteMatch)> {
342 let mut best: Option<(String, route_matcher::RouteMatch)> = None;
343
344 for item in fragments {
345 if let Some(Fragment::Route(route_frag)) = item.fragment.as_ref() {
346 let resolved_path = route_matcher::resolve_route_path(&route_frag.path, route_base);
347 if let Some(m) =
348 route_matcher::match_single_route(&resolved_path, request_path, route_frag.exact)
349 {
350 let is_better = best
351 .as_ref()
352 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
353
354 if is_better {
355 best = Some((route_fragment_key(route_frag).to_string(), m));
356 }
357 }
358 }
359 }
360
361 best
362}
363
364fn route_fragment_key(route_frag: &WebUIFragmentRoute) -> &str {
365 route_frag.fragment_id.as_str()
366}
367
368pub fn collect_nested_route_params(
373 protocol: &WebUIProtocol,
374 entry_id: &str,
375 request_path: &str,
376) -> HashMap<String, String> {
377 let mut all_params = HashMap::new();
378 let mut visited_fragments = HashSet::new();
379 let mut stack = vec![QueuedFragment {
380 id: entry_id.to_string(),
381 inventoryable: false,
382 route_base: "/".to_string(),
383 }];
384
385 while let Some(queued) = stack.pop() {
386 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
387 continue;
388 }
389
390 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
391 continue;
392 };
393
394 let matched_route =
395 find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
396
397 for frag in &frag_list.fragments {
398 match frag.fragment.as_ref() {
399 Some(Fragment::Component(component)) => {
400 stack.push(QueuedFragment {
401 id: component.fragment_id.clone(),
402 inventoryable: false,
403 route_base: queued.route_base.clone(),
404 });
405 }
406 Some(Fragment::Route(route_frag)) => {
407 let is_selected = matched_route
408 .as_ref()
409 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
410 if is_selected && !route_frag.fragment_id.is_empty() {
411 if let Some((_, ref rm)) = matched_route {
412 all_params.extend(rm.params.clone());
414
415 let child_route_base = route_matcher::compute_route_base(
416 request_path,
417 rm.consumed_segments,
418 );
419
420 stack.push(QueuedFragment {
421 id: route_frag.fragment_id.clone(),
422 inventoryable: false,
423 route_base: child_route_base.clone(),
424 });
425
426 collect_params_from_children(
428 &route_frag.children,
429 request_path,
430 &child_route_base,
431 &mut all_params,
432 );
433 }
434 }
435 }
436 _ => {}
437 }
438 }
439 }
440
441 all_params
442}
443
444fn collect_params_from_children(
446 children: &[WebUIFragmentRoute],
447 request_path: &str,
448 route_base: &str,
449 all_params: &mut HashMap<String, String>,
450) {
451 let mut current = children;
452 let mut base = route_base.to_string();
453
454 loop {
455 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
456 for (idx, child) in current.iter().enumerate() {
457 let resolved = route_matcher::resolve_route_path(&child.path, &base);
458 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
459 {
460 let is_better = best
461 .as_ref()
462 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
463 if is_better {
464 best = Some((idx, m));
465 }
466 }
467 }
468
469 let Some((idx, rm)) = best else { break };
470 all_params.extend(rm.params);
471 let matched = ¤t[idx];
472 if matched.children.is_empty() {
473 break;
474 }
475 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
476 current = &matched.children;
477 base = child_base;
478 }
479}
480
481#[must_use]
497pub fn render_partial(
498 protocol: &WebUIProtocol,
499 state: Value,
500 entry_id: &str,
501 request_path: &str,
502 inventory_hex: &str,
503) -> Value {
504 let (needed_names, updated_inv) =
506 get_needed_components_for_request(protocol, entry_id, request_path, inventory_hex);
507
508 let chain = collect_route_chain(protocol, entry_id, request_path);
510
511 let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
513 let (style_array, tmpl_array) = collect_component_assets(protocol, &tag_refs);
514
515 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
516
517 let mut result = serde_json::Map::with_capacity(6);
518 result.insert("state".into(), state);
519 result.insert("templateStyles".into(), Value::Array(style_array));
520 result.insert("templates".into(), Value::Array(tmpl_array));
521 result.insert("inventory".into(), Value::String(updated_inv));
522 result.insert("path".into(), Value::String(request_path.to_string()));
523 result.insert("chain".into(), chain_array);
524 Value::Object(result)
525}
526
527#[must_use]
536pub fn render_component_templates(
537 protocol: &WebUIProtocol,
538 component_tags: &[&str],
539 inventory_hex: &str,
540) -> Value {
541 let requested: HashSet<String> = component_tags.iter().map(|s| s.to_string()).collect();
542 let index = build_component_index(protocol);
543 let (needed, updated_inv) = filter_needed_components(&requested, inventory_hex, &index);
544
545 let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
546 let (style_array, tmpl_array) = collect_component_assets(protocol, &tag_refs);
547
548 let mut result = serde_json::Map::with_capacity(3);
549 result.insert("templateStyles".into(), Value::Array(style_array));
550 result.insert("templates".into(), Value::Array(tmpl_array));
551 result.insert("inventory".into(), Value::String(updated_inv));
552 Value::Object(result)
553}
554
555fn collect_component_assets(protocol: &WebUIProtocol, tags: &[&str]) -> (Vec<Value>, Vec<Value>) {
557 let mut style_array = Vec::new();
558 let mut tmpl_array = Vec::new();
559
560 let mut sorted_tags: Vec<&str> = tags.to_vec();
562 sorted_tags.sort_unstable();
563
564 for tag in sorted_tags {
565 let Some(component) = protocol.components.get(tag) else {
566 continue;
567 };
568 if component.template.is_empty() {
569 continue;
570 }
571 if !component.css.is_empty() {
572 let mut s = String::with_capacity(40 + tag.len() + component.css.len());
573 s.push_str("<style type=\"module\" specifier=\"");
574 s.push_str(tag);
575 s.push_str("\">");
576 s.push_str(&component.css);
577 s.push_str("</style>");
578 style_array.push(Value::String(s));
579 }
580 tmpl_array.push(Value::String(component.template.clone()));
581 }
582
583 (style_array, tmpl_array)
584}
585
586#[derive(Debug, Clone)]
590pub struct RouteChainEntry {
591 pub component: String,
593 pub path: String,
595 pub params: HashMap<String, String>,
597 pub exact: bool,
599 pub allowed_query: String,
601}
602
603impl RouteChainEntry {
604 #[must_use]
606 pub fn to_json(&self) -> Value {
607 let mut obj = serde_json::Map::with_capacity(5);
608 obj.insert("component".into(), Value::String(self.component.clone()));
609 obj.insert("path".into(), Value::String(self.path.clone()));
610 if !self.params.is_empty() {
611 let params_obj: serde_json::Map<String, Value> = self
612 .params
613 .iter()
614 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
615 .collect();
616 obj.insert("params".into(), Value::Object(params_obj));
617 }
618 if self.exact {
619 obj.insert("exact".into(), Value::Bool(true));
620 }
621 if !self.allowed_query.is_empty() {
622 obj.insert(
623 "allowedQuery".into(),
624 Value::String(self.allowed_query.clone()),
625 );
626 }
627 Value::Object(obj)
628 }
629}
630
631#[must_use]
638pub fn collect_route_chain_json(
639 protocol: &WebUIProtocol,
640 entry_id: &str,
641 request_path: &str,
642) -> Value {
643 let chain = collect_route_chain(protocol, entry_id, request_path);
644 Value::Array(chain.iter().map(RouteChainEntry::to_json).collect())
645}
646
647pub fn collect_route_chain(
652 protocol: &WebUIProtocol,
653 entry_id: &str,
654 request_path: &str,
655) -> Vec<RouteChainEntry> {
656 let mut chain = Vec::new();
657 let mut visited_fragments = HashSet::new();
658 let mut stack = vec![QueuedFragment {
659 id: entry_id.to_string(),
660 inventoryable: false,
661 route_base: "/".to_string(),
662 }];
663
664 while let Some(queued) = stack.pop() {
665 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
666 continue;
667 }
668
669 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
670 continue;
671 };
672
673 let matched_route =
674 find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
675
676 for frag in &frag_list.fragments {
677 match frag.fragment.as_ref() {
678 Some(Fragment::Component(component)) => {
679 stack.push(QueuedFragment {
680 id: component.fragment_id.clone(),
681 inventoryable: false,
682 route_base: queued.route_base.clone(),
683 });
684 }
685 Some(Fragment::Route(route_frag)) => {
686 let is_selected = matched_route
687 .as_ref()
688 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
689 if is_selected && !route_frag.fragment_id.is_empty() {
690 if let Some((_, ref rm)) = matched_route {
691 chain.push(RouteChainEntry {
692 component: route_frag.fragment_id.clone(),
693 path: route_frag.path.clone(),
694 params: rm.params.clone(),
695 exact: route_frag.exact,
696 allowed_query: route_frag.allowed_query.clone(),
697 });
698
699 let child_route_base = route_matcher::compute_route_base(
700 request_path,
701 rm.consumed_segments,
702 );
703
704 stack.push(QueuedFragment {
705 id: route_frag.fragment_id.clone(),
706 inventoryable: false,
707 route_base: child_route_base.clone(),
708 });
709
710 collect_chain_from_children(
712 &route_frag.children,
713 request_path,
714 &child_route_base,
715 &mut chain,
716 );
717 }
718 }
719 }
720 _ => {}
721 }
722 }
723 }
724
725 chain
726}
727
728fn collect_chain_from_children(
730 children: &[WebUIFragmentRoute],
731 request_path: &str,
732 route_base: &str,
733 chain: &mut Vec<RouteChainEntry>,
734) {
735 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
736 vec![(children, route_base.to_string())];
737
738 while let Some((current, base)) = pending.pop() {
739 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
740 for (idx, child) in current.iter().enumerate() {
741 let resolved = route_matcher::resolve_route_path(&child.path, &base);
742 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
743 {
744 let is_better = best
745 .as_ref()
746 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
747 if is_better {
748 best = Some((idx, m));
749 }
750 }
751 }
752
753 if let Some((idx, rm)) = best {
754 let matched = ¤t[idx];
755 chain.push(RouteChainEntry {
756 component: matched.fragment_id.clone(),
757 path: matched.path.clone(),
758 params: rm.params,
759 exact: matched.exact,
760 allowed_query: matched.allowed_query.clone(),
761 });
762 if !matched.children.is_empty() {
763 let child_base =
764 route_matcher::compute_route_base(request_path, rm.consumed_segments);
765 pending.push((&matched.children, child_base));
766 }
767 }
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use std::collections::HashMap;
775 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
776
777 #[test]
778 fn test_parse_encode_inventory_roundtrip() {
779 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
780 let hex = encode_inventory(&original);
781 let decoded = parse_inventory(&hex);
782 assert_eq!(original, decoded);
783 }
784
785 #[test]
786 fn test_bitfield_set_and_check() {
787 let mut inv = vec![0u8; 4];
788 set_component(&mut inv, 0);
789 set_component(&mut inv, 7);
790 set_component(&mut inv, 8);
791 set_component(&mut inv, 15);
792 assert!(has_component(&inv, 0));
793 assert!(has_component(&inv, 7));
794 assert!(has_component(&inv, 8));
795 assert!(has_component(&inv, 15));
796 assert!(!has_component(&inv, 1));
797 assert!(!has_component(&inv, 9));
798 }
799
800 #[test]
801 fn test_filter_needed_components_no_false_positives() {
802 let mut index = HashMap::new();
804 index.insert("email-message".to_string(), 0);
805 index.insert("o-button".to_string(), 1);
806 index.insert("o-avatar".to_string(), 2);
807
808 let mut names = HashSet::new();
809 names.insert("email-message".to_string());
810 names.insert("o-button".to_string());
811
812 let mut inv = vec![0u8; 1];
814 set_component(&mut inv, 1); let inv_hex = encode_inventory(&inv);
816
817 let (needed, _) = filter_needed_components(&names, &inv_hex, &index);
818 assert_eq!(needed.len(), 1);
819 assert!(
820 needed.contains(&"email-message".to_string()),
821 "email-message must be needed: {needed:?}"
822 );
823 }
824
825 #[test]
826 fn test_get_needed_components_empty_inventory() {
827 let mut fragments = HashMap::new();
828 fragments.insert(
829 "app-shell".to_string(),
830 FragmentList {
831 fragments: vec![WebUIFragment::component("my-card")],
832 },
833 );
834 fragments.insert(
835 "my-card".to_string(),
836 FragmentList {
837 fragments: vec![WebUIFragment::raw("card")],
838 },
839 );
840
841 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
842 let (needed, _inv) = get_needed_components(&protocol, "app-shell", "");
843 assert!(needed.contains(&"app-shell".to_string()));
844 assert!(needed.contains(&"my-card".to_string()));
845 }
846
847 #[test]
848 fn test_get_needed_components_skips_inventory() {
849 let mut fragments = HashMap::new();
850 fragments.insert(
851 "app-shell".to_string(),
852 FragmentList {
853 fragments: vec![WebUIFragment::component("my-card")],
854 },
855 );
856 fragments.insert(
857 "my-card".to_string(),
858 FragmentList {
859 fragments: vec![WebUIFragment::raw("card")],
860 },
861 );
862
863 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
864 protocol
865 .components
866 .entry("app-shell".to_string())
867 .or_default()
868 .template = "<t></t>".to_string();
869 protocol
870 .components
871 .entry("my-card".to_string())
872 .or_default()
873 .template = "<t></t>".to_string();
874
875 let (_needed, inv_hex) = get_needed_components(&protocol, "app-shell", "");
876 let (needed2, _) = get_needed_components(&protocol, "app-shell", &inv_hex);
877 assert!(needed2.is_empty());
878 }
879
880 #[test]
881 fn test_get_needed_components_skips_control_fragments() {
882 let mut fragments = HashMap::new();
883 fragments.insert(
884 "app-shell".to_string(),
885 FragmentList {
886 fragments: vec![
887 WebUIFragment::if_cond(
888 webui_protocol::ConditionExpr::identifier("showNav"),
889 "if-shell",
890 ),
891 WebUIFragment::for_loop("item", "items", "for-items"),
892 WebUIFragment::attribute_template("title", "attr-title"),
893 ],
894 },
895 );
896 fragments.insert(
897 "if-shell".to_string(),
898 FragmentList {
899 fragments: vec![WebUIFragment::component("mp-category-nav")],
900 },
901 );
902 fragments.insert(
903 "for-items".to_string(),
904 FragmentList {
905 fragments: vec![WebUIFragment::component("mp-product-card")],
906 },
907 );
908 fragments.insert(
909 "attr-title".to_string(),
910 FragmentList {
911 fragments: vec![WebUIFragment::raw("Products")],
912 },
913 );
914 fragments.insert(
915 "mp-category-nav".to_string(),
916 FragmentList {
917 fragments: vec![WebUIFragment::raw("<nav></nav>")],
918 },
919 );
920 fragments.insert(
921 "mp-product-card".to_string(),
922 FragmentList {
923 fragments: vec![WebUIFragment::raw("<article></article>")],
924 },
925 );
926
927 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
928 let needed: HashSet<String> = get_needed_components(&protocol, "app-shell", "")
929 .0
930 .into_iter()
931 .collect();
932
933 assert!(needed.contains("app-shell"));
934 assert!(needed.contains("mp-category-nav"));
935 assert!(needed.contains("mp-product-card"));
936 assert!(!needed.contains("if-shell"));
937 assert!(!needed.contains("for-items"));
938 assert!(!needed.contains("attr-title"));
939 }
940
941 #[test]
942 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
943 let mut fragments = HashMap::new();
944 fragments.insert(
945 "index.html".to_string(),
946 FragmentList {
947 fragments: vec![WebUIFragment::component("mp-app")],
948 },
949 );
950 fragments.insert(
951 "mp-app".to_string(),
952 FragmentList {
953 fragments: vec![
954 WebUIFragment::component("mp-category-nav"),
955 WebUIFragment::route_from(WebUiFragmentRoute {
956 path: "/search/:category".into(),
957 fragment_id: "mp-search-page".into(),
958 exact: true,
959 ..Default::default()
960 }),
961 WebUIFragment::route_from(WebUiFragmentRoute {
962 path: "/product/:handle".into(),
963 fragment_id: "mp-product-page".into(),
964 exact: true,
965 ..Default::default()
966 }),
967 ],
968 },
969 );
970 fragments.insert(
971 "mp-category-nav".to_string(),
972 FragmentList {
973 fragments: vec![WebUIFragment::raw("<nav></nav>")],
974 },
975 );
976 fragments.insert(
977 "mp-search-page".to_string(),
978 FragmentList {
979 fragments: vec![WebUIFragment::component("mp-product-grid")],
980 },
981 );
982 fragments.insert(
983 "mp-product-grid".to_string(),
984 FragmentList {
985 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
986 },
987 );
988 fragments.insert(
989 "mp-product-page".to_string(),
990 FragmentList {
991 fragments: vec![WebUIFragment::component("mp-product-detail")],
992 },
993 );
994 fragments.insert(
995 "mp-product-detail".to_string(),
996 FragmentList {
997 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
998 },
999 );
1000
1001 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1002 protocol
1003 .components
1004 .entry("mp-search-page".to_string())
1005 .or_default()
1006 .template = "<mp-search-page></mp-search-page>".to_string();
1007 protocol
1008 .components
1009 .entry("mp-product-page".to_string())
1010 .or_default()
1011 .template = "<mp-product-page></mp-product-page>".to_string();
1012
1013 let (needed, _inv) =
1014 get_needed_components_for_request(&protocol, "index.html", "/search/shirts", "");
1015 let needed: HashSet<String> = needed.into_iter().collect();
1016
1017 assert!(needed.contains("mp-app"));
1018 assert!(needed.contains("mp-category-nav"));
1019 assert!(needed.contains("mp-search-page"));
1020 assert!(needed.contains("mp-product-grid"));
1021 assert!(!needed.contains("mp-product-page"));
1022 assert!(!needed.contains("mp-product-detail"));
1023 }
1024
1025 #[test]
1026 fn test_get_needed_components_for_request_follows_nested_route_chain() {
1027 let mut fragments = HashMap::new();
1028 fragments.insert(
1029 "index.html".to_string(),
1030 FragmentList {
1031 fragments: vec![WebUIFragment::component("mp-app")],
1032 },
1033 );
1034 fragments.insert(
1035 "mp-app".to_string(),
1036 FragmentList {
1037 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1038 path: "/account/*rest".into(),
1039 fragment_id: "mp-account-shell".into(),
1040 exact: false,
1041 ..Default::default()
1042 })],
1043 },
1044 );
1045 fragments.insert(
1046 "mp-account-shell".to_string(),
1047 FragmentList {
1048 fragments: vec![
1049 WebUIFragment::component("mp-account-nav"),
1050 WebUIFragment::route_from(WebUiFragmentRoute {
1051 path: "/account/profile".into(),
1052 fragment_id: "mp-profile-page".into(),
1053 exact: true,
1054 ..Default::default()
1055 }),
1056 WebUIFragment::route_from(WebUiFragmentRoute {
1057 path: "/account/orders/:id".into(),
1058 fragment_id: "mp-order-page".into(),
1059 exact: true,
1060 ..Default::default()
1061 }),
1062 ],
1063 },
1064 );
1065 fragments.insert(
1066 "mp-account-nav".to_string(),
1067 FragmentList {
1068 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1069 },
1070 );
1071 fragments.insert(
1072 "mp-profile-page".to_string(),
1073 FragmentList {
1074 fragments: vec![WebUIFragment::raw("<profile></profile>")],
1075 },
1076 );
1077 fragments.insert(
1078 "mp-order-page".to_string(),
1079 FragmentList {
1080 fragments: vec![WebUIFragment::component("mp-order-detail")],
1081 },
1082 );
1083 fragments.insert(
1084 "mp-order-detail".to_string(),
1085 FragmentList {
1086 fragments: vec![WebUIFragment::raw("<detail></detail>")],
1087 },
1088 );
1089
1090 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1091 protocol
1092 .components
1093 .entry("mp-account-shell".to_string())
1094 .or_default()
1095 .template = "<mp-account-shell></mp-account-shell>".to_string();
1096 protocol
1097 .components
1098 .entry("mp-profile-page".to_string())
1099 .or_default()
1100 .template = "<mp-profile-page></mp-profile-page>".to_string();
1101 protocol
1102 .components
1103 .entry("mp-order-page".to_string())
1104 .or_default()
1105 .template = "<mp-order-page></mp-order-page>".to_string();
1106
1107 let (needed, _inv) =
1108 get_needed_components_for_request(&protocol, "index.html", "/account/orders/42", "");
1109 let needed: HashSet<String> = needed.into_iter().collect();
1110
1111 assert!(needed.contains("mp-app"));
1112 assert!(needed.contains("mp-account-shell"));
1113 assert!(needed.contains("mp-account-nav"));
1114 assert!(needed.contains("mp-order-page"));
1115 assert!(needed.contains("mp-order-detail"));
1116 assert!(!needed.contains("mp-profile-page"));
1117 }
1118
1119 #[test]
1120 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1121 let mut fragments = HashMap::new();
1122 fragments.insert(
1123 "index.html".to_string(),
1124 FragmentList {
1125 fragments: vec![WebUIFragment::component("mp-app")],
1126 },
1127 );
1128 fragments.insert(
1129 "mp-app".to_string(),
1130 FragmentList {
1131 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1132 path: "/search".into(),
1133 fragment_id: "mp-search-page".into(),
1134 exact: true,
1135 ..Default::default()
1136 })],
1137 },
1138 );
1139 fragments.insert(
1140 "mp-search-page".to_string(),
1141 FragmentList {
1142 fragments: vec![
1143 WebUIFragment::if_cond(
1144 webui_protocol::ConditionExpr::identifier("showFilters"),
1145 "if-filters",
1146 ),
1147 WebUIFragment::for_loop("item", "items", "item-loop"),
1148 ],
1149 },
1150 );
1151 fragments.insert(
1152 "if-filters".to_string(),
1153 FragmentList {
1154 fragments: vec![WebUIFragment::component("mp-filter-panel")],
1155 },
1156 );
1157 fragments.insert(
1158 "item-loop".to_string(),
1159 FragmentList {
1160 fragments: vec![WebUIFragment::component("mp-item-card")],
1161 },
1162 );
1163 fragments.insert(
1164 "mp-filter-panel".to_string(),
1165 FragmentList {
1166 fragments: vec![WebUIFragment::raw("<filters></filters>")],
1167 },
1168 );
1169 fragments.insert(
1170 "mp-item-card".to_string(),
1171 FragmentList {
1172 fragments: vec![WebUIFragment::raw("<item></item>")],
1173 },
1174 );
1175
1176 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1177 protocol
1178 .components
1179 .entry("mp-search-page".to_string())
1180 .or_default()
1181 .template = "<mp-search-page></mp-search-page>".to_string();
1182
1183 let (needed, _inv) =
1184 get_needed_components_for_request(&protocol, "index.html", "/search", "");
1185 let needed: HashSet<String> = needed.into_iter().collect();
1186
1187 assert!(needed.contains("mp-app"));
1188 assert!(needed.contains("mp-search-page"));
1189 assert!(needed.contains("mp-filter-panel"));
1190 assert!(needed.contains("mp-item-card"));
1191 }
1192
1193 #[test]
1194 fn test_get_needed_components_for_request_respects_inventory() {
1195 let mut fragments = HashMap::new();
1196 fragments.insert(
1197 "index.html".to_string(),
1198 FragmentList {
1199 fragments: vec![WebUIFragment::component("mp-app")],
1200 },
1201 );
1202 fragments.insert(
1203 "mp-app".to_string(),
1204 FragmentList {
1205 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1206 path: "/search".into(),
1207 fragment_id: "mp-search-page".into(),
1208 exact: true,
1209 ..Default::default()
1210 })],
1211 },
1212 );
1213 fragments.insert(
1214 "mp-search-page".to_string(),
1215 FragmentList {
1216 fragments: vec![WebUIFragment::component("mp-product-grid")],
1217 },
1218 );
1219 fragments.insert(
1220 "mp-product-grid".to_string(),
1221 FragmentList {
1222 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1223 },
1224 );
1225
1226 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1227 for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
1228 protocol
1229 .components
1230 .entry(name.to_string())
1231 .or_default()
1232 .template = format!("<f-template id=\"{name}\"></f-template>");
1233 }
1234
1235 let (_needed, inventory) =
1236 get_needed_components_for_request(&protocol, "index.html", "/search", "");
1237 let (needed_again, _) =
1238 get_needed_components_for_request(&protocol, "index.html", "/search", &inventory);
1239 assert!(needed_again.is_empty());
1240 }
1241
1242 #[test]
1243 fn test_render_partial_separates_module_styles_from_templates() {
1244 let mut fragments = HashMap::new();
1245 fragments.insert(
1246 "index.html".to_string(),
1247 FragmentList {
1248 fragments: vec![WebUIFragment::component("my-page")],
1249 },
1250 );
1251 fragments.insert(
1252 "my-page".to_string(),
1253 FragmentList {
1254 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1255 },
1256 );
1257
1258 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1259 let component = protocol
1260 .components
1261 .entry("my-page".to_string())
1262 .or_default();
1263 component.template =
1264 "(function(){window.__webui_templates['my-page']={h:'<p>page</p>'};})();".to_string();
1265 component.css = ".page{color:red}".to_string();
1266
1267 let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1268
1269 let styles = partial["templateStyles"]
1271 .as_array()
1272 .expect("templateStyles should be an array");
1273 assert_eq!(styles.len(), 1);
1274 assert!(
1275 styles[0]
1276 .as_str()
1277 .unwrap_or_default()
1278 .contains("specifier=\"my-page\""),
1279 "module style should carry the component specifier"
1280 );
1281 assert!(
1282 styles[0]
1283 .as_str()
1284 .unwrap_or_default()
1285 .contains(".page{color:red}"),
1286 "module style should contain the CSS content"
1287 );
1288
1289 let templates = partial["templates"]
1291 .as_array()
1292 .expect("templates should be an array");
1293 assert_eq!(templates.len(), 1);
1294 assert!(
1295 !templates[0].as_str().unwrap_or_default().contains("<style"),
1296 "template should not contain any style tags"
1297 );
1298 assert!(
1299 templates[0]
1300 .as_str()
1301 .unwrap_or_default()
1302 .starts_with("(function()"),
1303 "template should be a raw JS IIFE"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_render_partial_link_strategy_has_empty_template_styles() {
1309 let mut fragments = HashMap::new();
1312 fragments.insert(
1313 "index.html".to_string(),
1314 FragmentList {
1315 fragments: vec![WebUIFragment::component("my-page")],
1316 },
1317 );
1318 fragments.insert(
1319 "my-page".to_string(),
1320 FragmentList {
1321 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1322 },
1323 );
1324
1325 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1326 let component = protocol
1327 .components
1328 .entry("my-page".to_string())
1329 .or_default();
1330 component.template = "(function(){})();".to_string();
1331 component.css_href = "/my-page.css".to_string();
1332 let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1335 let styles = partial["templateStyles"]
1336 .as_array()
1337 .expect("templateStyles should be an array");
1338 let templates = partial["templates"]
1339 .as_array()
1340 .expect("templates should be an array");
1341
1342 assert!(
1343 styles.is_empty(),
1344 "Link strategy should produce empty templateStyles: {styles:?}"
1345 );
1346 assert_eq!(templates.len(), 1, "should include the template IIFE");
1347 }
1348
1349 #[test]
1350 fn test_render_partial_style_strategy_has_empty_template_styles() {
1351 let mut fragments = HashMap::new();
1355 fragments.insert(
1356 "index.html".to_string(),
1357 FragmentList {
1358 fragments: vec![WebUIFragment::component("my-page")],
1359 },
1360 );
1361 fragments.insert(
1362 "my-page".to_string(),
1363 FragmentList {
1364 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1365 },
1366 );
1367
1368 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1369 let component = protocol
1370 .components
1371 .entry("my-page".to_string())
1372 .or_default();
1373 component.template =
1375 "(function(){var w=window.__webui_templates;w['my-page']={h:'<style>.p{color:red}</style><p>page</p>'};})();"
1376 .to_string();
1377 let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1380 let styles = partial["templateStyles"]
1381 .as_array()
1382 .expect("templateStyles should be an array");
1383 let templates = partial["templates"]
1384 .as_array()
1385 .expect("templates should be an array");
1386
1387 assert!(
1388 styles.is_empty(),
1389 "Style strategy should produce empty templateStyles: {styles:?}"
1390 );
1391 assert_eq!(templates.len(), 1, "should include the template IIFE");
1392 assert!(
1393 templates[0]
1394 .as_str()
1395 .unwrap_or_default()
1396 .contains("<style>"),
1397 "Style strategy template should contain inline <style> tag"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_render_partial_empty_styles_for_no_css_components() {
1403 let mut fragments = HashMap::new();
1404 fragments.insert(
1405 "index.html".to_string(),
1406 FragmentList {
1407 fragments: vec![WebUIFragment::component("my-page")],
1408 },
1409 );
1410 fragments.insert(
1411 "my-page".to_string(),
1412 FragmentList {
1413 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1414 },
1415 );
1416
1417 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1418 let component = protocol
1419 .components
1420 .entry("my-page".to_string())
1421 .or_default();
1422 component.template = "(function(){})();".to_string();
1423 let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1426 let styles = partial["templateStyles"]
1427 .as_array()
1428 .expect("templateStyles should be an array");
1429 assert!(
1430 styles.is_empty(),
1431 "templateStyles should be empty when components have no CSS"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
1437 let mut fragments = HashMap::new();
1438 fragments.insert(
1439 "index.html".to_string(),
1440 FragmentList {
1441 fragments: vec![WebUIFragment::component("my-page")],
1442 },
1443 );
1444 fragments.insert(
1445 "my-page".to_string(),
1446 FragmentList {
1447 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1448 },
1449 );
1450
1451 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1452 let component = protocol
1453 .components
1454 .entry("my-page".to_string())
1455 .or_default();
1456 component.template = "(function(){})();".to_string();
1457 component.css = ".page{color:red}".to_string();
1458
1459 let partial1 = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1461 let inv = partial1["inventory"].as_str().unwrap_or_default();
1462 assert!(!inv.is_empty());
1463
1464 let partial2 = render_partial(&protocol, serde_json::json!({}), "index.html", "/", inv);
1469 let styles = partial2["templateStyles"]
1470 .as_array()
1471 .expect("templateStyles should be an array");
1472 let templates = partial2["templates"]
1473 .as_array()
1474 .expect("templates should be an array");
1475
1476 assert!(
1477 templates.is_empty(),
1478 "templates should be empty when inventory is full"
1479 );
1480 assert!(
1481 styles.is_empty(),
1482 "module styles should be empty when inventory is full — SSR already placed them"
1483 );
1484 }
1485
1486 #[test]
1487 fn test_non_route_siblings_included_in_needed_components() {
1488 let mut fragments = HashMap::new();
1493
1494 fragments.insert(
1496 "index.html".to_string(),
1497 FragmentList {
1498 fragments: vec![WebUIFragment::component("app-shell")],
1499 },
1500 );
1501
1502 fragments.insert(
1504 "app-shell".to_string(),
1505 FragmentList {
1506 fragments: vec![
1507 WebUIFragment::component("my-navbar"),
1508 WebUIFragment::route_from(WebUiFragmentRoute {
1509 path: "/about".into(),
1510 fragment_id: "page-about".into(),
1511 exact: true,
1512 ..Default::default()
1513 }),
1514 WebUIFragment::component("cart-panel"),
1515 ],
1516 },
1517 );
1518
1519 fragments.insert(
1521 "my-navbar".to_string(),
1522 FragmentList {
1523 fragments: vec![WebUIFragment::raw("<nav/>")],
1524 },
1525 );
1526 fragments.insert(
1527 "page-about".to_string(),
1528 FragmentList {
1529 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
1530 },
1531 );
1532 fragments.insert(
1533 "cart-panel".to_string(),
1534 FragmentList {
1535 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
1536 },
1537 );
1538
1539 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1540 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
1541 let comp = protocol.components.entry(name.to_string()).or_default();
1542 comp.template = format!("(function(){{/* {name} */}})();");
1543 comp.css = format!(".{name}{{display:block}}");
1544 }
1545
1546 let (needed, _inv) =
1547 get_needed_components_for_request(&protocol, "index.html", "/about", "");
1548
1549 assert!(
1550 needed.contains(&"app-shell".to_string()),
1551 "app-shell should be needed: {needed:?}"
1552 );
1553 assert!(
1554 needed.contains(&"my-navbar".to_string()),
1555 "my-navbar (non-route sibling) should be needed: {needed:?}"
1556 );
1557 assert!(
1558 needed.contains(&"page-about".to_string()),
1559 "page-about (active route) should be needed: {needed:?}"
1560 );
1561 assert!(
1562 needed.contains(&"cart-panel".to_string()),
1563 "cart-panel (non-route sibling) should be needed: {needed:?}"
1564 );
1565 }
1566
1567 #[test]
1570 fn test_chain_entry_to_json_includes_allowed_query() {
1571 let entry = RouteChainEntry {
1572 component: "compose-page".into(),
1573 path: "compose".into(),
1574 params: HashMap::new(),
1575 exact: true,
1576 allowed_query: "action,to,subject".into(),
1577 };
1578 let json = entry.to_json();
1579 assert_eq!(json["allowedQuery"], "action,to,subject");
1580 }
1581
1582 #[test]
1583 fn test_chain_entry_to_json_omits_empty_allowed_query() {
1584 let entry = RouteChainEntry {
1585 component: "home-page".into(),
1586 path: "/".into(),
1587 params: HashMap::new(),
1588 exact: true,
1589 allowed_query: String::new(),
1590 };
1591 let json = entry.to_json();
1592 assert!(
1593 json.get("allowedQuery").is_none(),
1594 "empty allowed_query should not appear in JSON"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_collect_route_chain_carries_allowed_query() {
1600 let mut fragments = HashMap::new();
1601 fragments.insert(
1602 "index.html".to_string(),
1603 FragmentList {
1604 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1605 path: "/".into(),
1606 fragment_id: "app-shell".into(),
1607 exact: false,
1608 children: vec![WebUiFragmentRoute {
1609 path: "compose".into(),
1610 fragment_id: "compose-page".into(),
1611 exact: true,
1612 allowed_query: "action,to".into(),
1613 ..Default::default()
1614 }],
1615 ..Default::default()
1616 })],
1617 },
1618 );
1619 fragments.insert(
1620 "app-shell".to_string(),
1621 FragmentList {
1622 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
1623 },
1624 );
1625 fragments.insert(
1626 "compose-page".to_string(),
1627 FragmentList {
1628 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
1629 },
1630 );
1631 let protocol = WebUIProtocol::new(fragments);
1632
1633 let chain = collect_route_chain(&protocol, "index.html", "/compose");
1634 assert_eq!(chain.len(), 2);
1635 assert_eq!(chain[0].component, "app-shell");
1636 assert!(chain[0].allowed_query.is_empty());
1637 assert_eq!(chain[1].component, "compose-page");
1638 assert_eq!(chain[1].allowed_query, "action,to");
1639 }
1640
1641 #[test]
1642 fn test_render_component_templates_returns_template_and_css() {
1643 let mut fragments = HashMap::new();
1644 fragments.insert(
1645 "settings-dialog".to_string(),
1646 FragmentList {
1647 fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
1648 },
1649 );
1650 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1651 let comp = protocol
1652 .components
1653 .entry("settings-dialog".to_string())
1654 .or_default();
1655 comp.template = "(function(){window.__webui_templates['settings-dialog']={h:'<div>Settings</div>'};})();".to_string();
1656 comp.css = ".dialog{position:fixed}".to_string();
1657
1658 let result = render_component_templates(&protocol, &["settings-dialog"], "");
1659 let templates = result["templates"].as_array().expect("templates array");
1660 let styles = result["templateStyles"].as_array().expect("styles array");
1661
1662 assert_eq!(templates.len(), 1);
1663 assert!(templates[0].as_str().unwrap().contains("settings-dialog"));
1664 assert_eq!(styles.len(), 1);
1665 assert!(styles[0]
1666 .as_str()
1667 .unwrap()
1668 .contains(".dialog{position:fixed}"));
1669 }
1670
1671 #[test]
1672 fn test_render_component_templates_respects_inventory() {
1673 let mut fragments = HashMap::new();
1674 fragments.insert(
1675 "my-dialog".to_string(),
1676 FragmentList {
1677 fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
1678 },
1679 );
1680 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1681 let comp = protocol
1682 .components
1683 .entry("my-dialog".to_string())
1684 .or_default();
1685 comp.template = "(function(){})();".to_string();
1686 comp.css = ".d{color:red}".to_string();
1687
1688 let result1 = render_component_templates(&protocol, &["my-dialog"], "");
1690 let inv = result1["inventory"].as_str().expect("inventory string");
1691 assert_eq!(result1["templates"].as_array().unwrap().len(), 1);
1692
1693 let result2 = render_component_templates(&protocol, &["my-dialog"], inv);
1695 assert_eq!(result2["templates"].as_array().unwrap().len(), 0);
1696 assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
1697 }
1698
1699 #[test]
1700 fn test_render_component_templates_unknown_component_returns_empty() {
1701 let fragments = HashMap::new();
1702 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1703
1704 let result = render_component_templates(&protocol, &["nonexistent-widget"], "");
1705 assert_eq!(result["templates"].as_array().unwrap().len(), 0);
1706 assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
1707 }
1708}