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