1use crate::route_matcher;
12use serde_json::Value;
13use std::collections::{HashMap, HashSet};
14use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIFragmentRoute, WebUIProtocol};
15
16fn component_bit_position(name: &str) -> u32 {
21 let mut hash: u32 = 0x811c_9dc5;
22 for byte in name.as_bytes() {
23 hash ^= u32::from(*byte);
24 hash = hash.wrapping_mul(0x0100_0193);
25 }
26 hash % 256
27}
28
29fn has_component(inventory: &[u8], name: &str) -> bool {
31 let bit = component_bit_position(name);
32 let byte_idx = (bit / 8) as usize;
33 let bit_idx = bit % 8;
34 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
35}
36
37pub fn parse_inventory(hex: &str) -> Vec<u8> {
39 let mut bytes = Vec::with_capacity(hex.len() / 2);
40 let mut chars = hex.bytes();
41 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
42 if let (Some(hi_nibble), Some(lo_nibble)) = (hex_nibble(hi), hex_nibble(lo)) {
43 bytes.push((hi_nibble << 4) | lo_nibble);
44 }
45 }
46 bytes
47}
48
49fn hex_nibble(byte: u8) -> Option<u8> {
50 match byte {
51 b'0'..=b'9' => Some(byte - b'0'),
52 b'a'..=b'f' => Some(byte - b'a' + 10),
53 b'A'..=b'F' => Some(byte - b'A' + 10),
54 _ => None,
55 }
56}
57
58pub fn encode_inventory(inv: &[u8]) -> String {
60 inv.iter()
61 .fold(String::with_capacity(inv.len() * 2), |mut acc, b| {
62 use std::fmt::Write;
63 let _ = write!(acc, "{b:02x}");
64 acc
65 })
66}
67
68pub fn get_needed_components(
76 protocol: &WebUIProtocol,
77 entry_id: &str,
78 inventory_hex: &str,
79) -> (Vec<String>, String) {
80 let component_names = collect_inventoryable_components(protocol, entry_id, None, true);
81 filter_needed_components(&component_names, inventory_hex)
82}
83
84pub fn get_needed_components_for_request(
97 protocol: &WebUIProtocol,
98 entry_id: &str,
99 request_path: &str,
100 inventory_hex: &str,
101) -> (Vec<String>, String) {
102 let component_names =
103 collect_inventoryable_components(protocol, entry_id, Some(request_path), false);
104 filter_needed_components(&component_names, inventory_hex)
105}
106
107#[must_use]
111pub fn filter_needed_components(
112 component_names: &HashSet<String>,
113 inventory_hex: &str,
114) -> (Vec<String>, String) {
115 let mut updated_inv = parse_inventory(inventory_hex);
116 updated_inv.resize(32, 0);
117
118 let mut ordered_names: Vec<&String> = component_names.iter().collect();
119 ordered_names.sort_unstable();
120
121 let mut needed = Vec::with_capacity(ordered_names.len());
122 for name in ordered_names {
123 if has_component(&updated_inv, name) {
124 continue;
125 }
126
127 let bit = component_bit_position(name);
128 let byte_idx = (bit / 8) as usize;
129 let bit_idx = bit % 8;
130 if byte_idx < updated_inv.len() {
131 updated_inv[byte_idx] |= 1 << bit_idx;
132 }
133 needed.push(name.clone());
134 }
135
136 (needed, encode_inventory(&updated_inv))
137}
138
139pub fn get_route_templates(
148 protocol: &WebUIProtocol,
149 entry_id: &str,
150 inventory_hex: &str,
151) -> (Vec<(String, String)>, String) {
152 let (needed_names, updated_inv) = get_needed_components(protocol, entry_id, inventory_hex);
153
154 let templates: Vec<(String, String)> = needed_names
155 .iter()
156 .filter_map(|name| {
157 protocol
158 .components
159 .get(name)
160 .filter(|c| !c.template.is_empty())
161 .map(|c| (name.clone(), prepend_css_module(name, &c.css, &c.template)))
162 })
163 .collect();
164
165 (templates, updated_inv)
166}
167
168pub fn get_route_templates_for_request(
173 protocol: &WebUIProtocol,
174 entry_id: &str,
175 request_path: &str,
176 inventory_hex: &str,
177) -> (Vec<(String, String)>, String) {
178 let (needed_names, updated_inv) =
179 get_needed_components_for_request(protocol, entry_id, request_path, inventory_hex);
180
181 let templates: Vec<(String, String)> = needed_names
182 .iter()
183 .filter_map(|name| {
184 protocol
185 .components
186 .get(name)
187 .filter(|c| !c.template.is_empty())
188 .map(|c| (name.clone(), prepend_css_module(name, &c.css, &c.template)))
189 })
190 .collect();
191
192 (templates, updated_inv)
193}
194
195fn prepend_css_module(name: &str, css: &str, tmpl: &str) -> String {
198 if css.is_empty() {
199 return tmpl.to_string();
200 }
201 let mut s = String::with_capacity(40 + name.len() + css.len() + tmpl.len());
202 s.push_str("<style type=\"module\" specifier=\"");
203 s.push_str(name);
204 s.push_str("\">");
205 s.push_str(css);
206 s.push_str("</style>");
207 s.push_str(tmpl);
208 s
209}
210
211#[derive(Debug)]
212struct QueuedFragment {
213 id: String,
214 inventoryable: bool,
215 route_base: String,
217}
218
219fn collect_inventoryable_components(
229 protocol: &WebUIProtocol,
230 entry_id: &str,
231 request_path: Option<&str>,
232 root_inventoryable: bool,
233) -> HashSet<String> {
234 let mut visited_fragments = HashSet::new();
235 let mut component_ids = HashSet::new();
236 let mut stack = vec![QueuedFragment {
237 id: entry_id.to_string(),
238 inventoryable: root_inventoryable,
239 route_base: "/".to_string(),
240 }];
241
242 while let Some(queued) = stack.pop() {
243 if queued.id.is_empty() {
244 continue;
245 }
246
247 if queued.inventoryable {
248 component_ids.insert(queued.id.clone());
249 }
250
251 if !visited_fragments.insert(queued.id.clone()) {
252 continue;
253 }
254
255 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
256 continue;
257 };
258
259 let matched_route = request_path
260 .and_then(|path| find_best_route_match(&frag_list.fragments, path, &queued.route_base));
261
262 for frag in &frag_list.fragments {
263 match frag.fragment.as_ref() {
264 Some(Fragment::Component(component)) => {
265 stack.push(QueuedFragment {
266 id: component.fragment_id.clone(),
267 inventoryable: true,
268 route_base: queued.route_base.clone(),
269 });
270 }
271 Some(Fragment::ForLoop(for_loop)) => {
272 stack.push(QueuedFragment {
273 id: for_loop.fragment_id.clone(),
274 inventoryable: false,
275 route_base: queued.route_base.clone(),
276 });
277 }
278 Some(Fragment::IfCond(if_cond)) => {
279 stack.push(QueuedFragment {
280 id: if_cond.fragment_id.clone(),
281 inventoryable: false,
282 route_base: queued.route_base.clone(),
283 });
284 }
285 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
286 stack.push(QueuedFragment {
287 id: attr.template.clone(),
288 inventoryable: false,
289 route_base: queued.route_base.clone(),
290 });
291 }
292 Some(Fragment::Route(route_frag)) => {
293 let is_selected = matched_route
294 .as_ref()
295 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
296 if is_selected && !route_frag.fragment_id.is_empty() {
297 let child_route_base = if let Some((_, ref rm)) = matched_route {
299 if let Some(path) = request_path {
300 route_matcher::compute_route_base(path, rm.consumed_segments)
301 } else {
302 queued.route_base.clone()
303 }
304 } else {
305 queued.route_base.clone()
306 };
307
308 stack.push(QueuedFragment {
309 id: route_frag.fragment_id.clone(),
310 inventoryable: protocol
311 .components
312 .get(&route_frag.fragment_id)
313 .is_some_and(|c| !c.template.is_empty()),
314 route_base: child_route_base.clone(),
315 });
316
317 if !route_frag.children.is_empty() {
321 if let Some(path) = request_path {
322 walk_route_children(
323 &route_frag.children,
324 path,
325 &child_route_base,
326 protocol,
327 &mut stack,
328 );
329 }
330 }
331 }
332 }
333 _ => {}
334 }
335 }
336 }
337
338 component_ids
339}
340
341fn walk_route_children(
344 children: &[WebUIFragmentRoute],
345 request_path: &str,
346 route_base: &str,
347 protocol: &WebUIProtocol,
348 stack: &mut Vec<QueuedFragment>,
349) {
350 let mut current = children;
351 let mut base = route_base.to_string();
352
353 loop {
354 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
355 for (idx, child) in current.iter().enumerate() {
356 let resolved = route_matcher::resolve_route_path(&child.path, &base);
357 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
358 {
359 let is_better = best
360 .as_ref()
361 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
362 if is_better {
363 best = Some((idx, m));
364 }
365 }
366 }
367
368 let Some((idx, ref rm)) = best else { break };
369 let matched = ¤t[idx];
370 if matched.fragment_id.is_empty() {
371 break;
372 }
373
374 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
375
376 stack.push(QueuedFragment {
377 id: matched.fragment_id.clone(),
378 inventoryable: protocol
379 .components
380 .get(&matched.fragment_id)
381 .is_some_and(|c| !c.template.is_empty()),
382 route_base: child_base.clone(),
383 });
384
385 if matched.children.is_empty() {
386 break;
387 }
388 current = &matched.children;
389 base = child_base;
390 }
391}
392
393fn find_best_route_match(
397 fragments: &[WebUIFragment],
398 request_path: &str,
399 route_base: &str,
400) -> Option<(String, route_matcher::RouteMatch)> {
401 let mut best: Option<(String, route_matcher::RouteMatch)> = None;
402
403 for item in fragments {
404 if let Some(Fragment::Route(route_frag)) = item.fragment.as_ref() {
405 let resolved_path = route_matcher::resolve_route_path(&route_frag.path, route_base);
406 if let Some(m) =
407 route_matcher::match_single_route(&resolved_path, request_path, route_frag.exact)
408 {
409 let is_better = best
410 .as_ref()
411 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
412
413 if is_better {
414 best = Some((route_fragment_key(route_frag).to_string(), m));
415 }
416 }
417 }
418 }
419
420 best
421}
422
423fn route_fragment_key(route_frag: &WebUIFragmentRoute) -> &str {
424 route_frag.fragment_id.as_str()
425}
426
427pub fn collect_nested_route_params(
432 protocol: &WebUIProtocol,
433 entry_id: &str,
434 request_path: &str,
435) -> HashMap<String, String> {
436 let mut all_params = HashMap::new();
437 let mut visited_fragments = HashSet::new();
438 let mut stack = vec![QueuedFragment {
439 id: entry_id.to_string(),
440 inventoryable: false,
441 route_base: "/".to_string(),
442 }];
443
444 while let Some(queued) = stack.pop() {
445 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
446 continue;
447 }
448
449 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
450 continue;
451 };
452
453 let matched_route =
454 find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
455
456 for frag in &frag_list.fragments {
457 match frag.fragment.as_ref() {
458 Some(Fragment::Component(component)) => {
459 stack.push(QueuedFragment {
460 id: component.fragment_id.clone(),
461 inventoryable: false,
462 route_base: queued.route_base.clone(),
463 });
464 }
465 Some(Fragment::Route(route_frag)) => {
466 let is_selected = matched_route
467 .as_ref()
468 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
469 if is_selected && !route_frag.fragment_id.is_empty() {
470 if let Some((_, ref rm)) = matched_route {
471 all_params.extend(rm.params.clone());
473
474 let child_route_base = route_matcher::compute_route_base(
475 request_path,
476 rm.consumed_segments,
477 );
478
479 stack.push(QueuedFragment {
480 id: route_frag.fragment_id.clone(),
481 inventoryable: false,
482 route_base: child_route_base.clone(),
483 });
484
485 collect_params_from_children(
487 &route_frag.children,
488 request_path,
489 &child_route_base,
490 &mut all_params,
491 );
492 }
493 }
494 }
495 _ => {}
496 }
497 }
498 }
499
500 all_params
501}
502
503fn collect_params_from_children(
505 children: &[WebUIFragmentRoute],
506 request_path: &str,
507 route_base: &str,
508 all_params: &mut HashMap<String, String>,
509) {
510 let mut current = children;
511 let mut base = route_base.to_string();
512
513 loop {
514 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
515 for (idx, child) in current.iter().enumerate() {
516 let resolved = route_matcher::resolve_route_path(&child.path, &base);
517 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
518 {
519 let is_better = best
520 .as_ref()
521 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
522 if is_better {
523 best = Some((idx, m));
524 }
525 }
526 }
527
528 let Some((idx, rm)) = best else { break };
529 all_params.extend(rm.params);
530 let matched = ¤t[idx];
531 if matched.children.is_empty() {
532 break;
533 }
534 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
535 current = &matched.children;
536 base = child_base;
537 }
538}
539
540#[must_use]
555pub fn render_partial(
556 protocol: &WebUIProtocol,
557 state: Value,
558 entry_id: &str,
559 request_path: &str,
560 inventory_hex: &str,
561) -> Value {
562 let (templates, updated_inv) =
566 get_route_templates_for_request(protocol, entry_id, request_path, inventory_hex);
567
568 let chain = collect_route_chain(protocol, entry_id, request_path);
570
571 let tmpl_array: Vec<Value> = templates
573 .into_iter()
574 .map(|(_, html)| Value::String(html))
575 .collect();
576
577 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
578
579 let mut result = serde_json::Map::with_capacity(5);
580 result.insert("state".into(), state);
581 result.insert("templates".into(), Value::Array(tmpl_array));
582 result.insert("inventory".into(), Value::String(updated_inv));
583 result.insert("path".into(), Value::String(request_path.to_string()));
584 result.insert("chain".into(), chain_array);
585 Value::Object(result)
586}
587
588#[derive(Debug, Clone)]
592pub struct RouteChainEntry {
593 pub component: String,
595 pub path: String,
597 pub params: HashMap<String, String>,
599 pub exact: bool,
601}
602
603impl RouteChainEntry {
604 #[must_use]
606 pub fn to_json(&self) -> Value {
607 let mut obj = serde_json::Map::with_capacity(4);
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 Value::Object(obj)
622 }
623}
624
625#[must_use]
632pub fn collect_route_chain_json(
633 protocol: &WebUIProtocol,
634 entry_id: &str,
635 request_path: &str,
636) -> Value {
637 let chain = collect_route_chain(protocol, entry_id, request_path);
638 Value::Array(chain.iter().map(RouteChainEntry::to_json).collect())
639}
640
641pub fn collect_route_chain(
646 protocol: &WebUIProtocol,
647 entry_id: &str,
648 request_path: &str,
649) -> Vec<RouteChainEntry> {
650 let mut chain = Vec::new();
651 let mut visited_fragments = HashSet::new();
652 let mut stack = vec![QueuedFragment {
653 id: entry_id.to_string(),
654 inventoryable: false,
655 route_base: "/".to_string(),
656 }];
657
658 while let Some(queued) = stack.pop() {
659 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
660 continue;
661 }
662
663 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
664 continue;
665 };
666
667 let matched_route =
668 find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
669
670 for frag in &frag_list.fragments {
671 match frag.fragment.as_ref() {
672 Some(Fragment::Component(component)) => {
673 stack.push(QueuedFragment {
674 id: component.fragment_id.clone(),
675 inventoryable: false,
676 route_base: queued.route_base.clone(),
677 });
678 }
679 Some(Fragment::Route(route_frag)) => {
680 let is_selected = matched_route
681 .as_ref()
682 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
683 if is_selected && !route_frag.fragment_id.is_empty() {
684 if let Some((_, ref rm)) = matched_route {
685 chain.push(RouteChainEntry {
686 component: route_frag.fragment_id.clone(),
687 path: route_frag.path.clone(),
688 params: rm.params.clone(),
689 exact: route_frag.exact,
690 });
691
692 let child_route_base = route_matcher::compute_route_base(
693 request_path,
694 rm.consumed_segments,
695 );
696
697 stack.push(QueuedFragment {
698 id: route_frag.fragment_id.clone(),
699 inventoryable: false,
700 route_base: child_route_base.clone(),
701 });
702
703 collect_chain_from_children(
705 &route_frag.children,
706 request_path,
707 &child_route_base,
708 &mut chain,
709 );
710 }
711 }
712 }
713 _ => {}
714 }
715 }
716 }
717
718 chain
719}
720
721fn collect_chain_from_children(
723 children: &[WebUIFragmentRoute],
724 request_path: &str,
725 route_base: &str,
726 chain: &mut Vec<RouteChainEntry>,
727) {
728 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
729 vec![(children, route_base.to_string())];
730
731 while let Some((current, base)) = pending.pop() {
732 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
733 for (idx, child) in current.iter().enumerate() {
734 let resolved = route_matcher::resolve_route_path(&child.path, &base);
735 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
736 {
737 let is_better = best
738 .as_ref()
739 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
740 if is_better {
741 best = Some((idx, m));
742 }
743 }
744 }
745
746 if let Some((idx, rm)) = best {
747 let matched = ¤t[idx];
748 chain.push(RouteChainEntry {
749 component: matched.fragment_id.clone(),
750 path: matched.path.clone(),
751 params: rm.params,
752 exact: matched.exact,
753 });
754 if !matched.children.is_empty() {
755 let child_base =
756 route_matcher::compute_route_base(request_path, rm.consumed_segments);
757 pending.push((&matched.children, child_base));
758 }
759 }
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766 use std::collections::HashMap;
767 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
768
769 #[test]
770 fn test_component_bit_position_deterministic() {
771 let pos1 = component_bit_position("my-component");
772 let pos2 = component_bit_position("my-component");
773 assert_eq!(pos1, pos2);
774 assert!(pos1 < 256);
775 }
776
777 #[test]
778 fn test_has_component_present() {
779 let mut inv = vec![0u8; 32];
780 let bit = component_bit_position("test-comp");
781 let byte_idx = (bit / 8) as usize;
782 let bit_idx = bit % 8;
783 inv[byte_idx] |= 1 << bit_idx;
784 assert!(has_component(&inv, "test-comp"));
785 }
786
787 #[test]
788 fn test_has_component_absent() {
789 let inv = vec![0u8; 32];
790 assert!(!has_component(&inv, "test-comp"));
791 }
792
793 #[test]
794 fn test_parse_encode_inventory_roundtrip() {
795 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
796 let hex = encode_inventory(&original);
797 let decoded = parse_inventory(&hex);
798 assert_eq!(original, decoded);
799 }
800
801 #[test]
802 fn test_get_needed_components_empty_inventory() {
803 let mut fragments = HashMap::new();
804 fragments.insert(
805 "app-shell".to_string(),
806 FragmentList {
807 fragments: vec![WebUIFragment::component("my-card")],
808 },
809 );
810 fragments.insert(
811 "my-card".to_string(),
812 FragmentList {
813 fragments: vec![WebUIFragment::raw("card")],
814 },
815 );
816
817 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
818 let (needed, _inv) = get_needed_components(&protocol, "app-shell", "");
819 assert!(needed.contains(&"app-shell".to_string()));
820 assert!(needed.contains(&"my-card".to_string()));
821 }
822
823 #[test]
824 fn test_get_route_templates() {
825 let mut fragments = HashMap::new();
826 fragments.insert(
827 "my-page".to_string(),
828 FragmentList {
829 fragments: vec![WebUIFragment::raw("<p>page</p>")],
830 },
831 );
832
833 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
834 protocol
835 .components
836 .entry("my-page".to_string())
837 .or_default()
838 .template = "<p>page</p>".to_string();
839
840 let (templates, _inv) = get_route_templates(&protocol, "my-page", "");
841 assert_eq!(templates.len(), 1);
842 assert_eq!(templates[0].0, "my-page");
843 }
844
845 #[test]
846 fn test_get_needed_components_skips_inventory() {
847 let mut fragments = HashMap::new();
848 fragments.insert(
849 "app-shell".to_string(),
850 FragmentList {
851 fragments: vec![WebUIFragment::component("my-card")],
852 },
853 );
854 fragments.insert(
855 "my-card".to_string(),
856 FragmentList {
857 fragments: vec![WebUIFragment::raw("card")],
858 },
859 );
860
861 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
862
863 let (_needed, inv_hex) = get_needed_components(&protocol, "app-shell", "");
864 let (needed2, _) = get_needed_components(&protocol, "app-shell", &inv_hex);
865 assert!(needed2.is_empty());
866 }
867
868 #[test]
869 fn test_get_needed_components_skips_control_fragments() {
870 let mut fragments = HashMap::new();
871 fragments.insert(
872 "app-shell".to_string(),
873 FragmentList {
874 fragments: vec![
875 WebUIFragment::if_cond(
876 webui_protocol::ConditionExpr::identifier("showNav"),
877 "if-shell",
878 ),
879 WebUIFragment::for_loop("item", "items", "for-items"),
880 WebUIFragment::attribute_template("title", "attr-title"),
881 ],
882 },
883 );
884 fragments.insert(
885 "if-shell".to_string(),
886 FragmentList {
887 fragments: vec![WebUIFragment::component("mp-category-nav")],
888 },
889 );
890 fragments.insert(
891 "for-items".to_string(),
892 FragmentList {
893 fragments: vec![WebUIFragment::component("mp-product-card")],
894 },
895 );
896 fragments.insert(
897 "attr-title".to_string(),
898 FragmentList {
899 fragments: vec![WebUIFragment::raw("Products")],
900 },
901 );
902 fragments.insert(
903 "mp-category-nav".to_string(),
904 FragmentList {
905 fragments: vec![WebUIFragment::raw("<nav></nav>")],
906 },
907 );
908 fragments.insert(
909 "mp-product-card".to_string(),
910 FragmentList {
911 fragments: vec![WebUIFragment::raw("<article></article>")],
912 },
913 );
914
915 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
916 let needed: HashSet<String> = get_needed_components(&protocol, "app-shell", "")
917 .0
918 .into_iter()
919 .collect();
920
921 assert!(needed.contains("app-shell"));
922 assert!(needed.contains("mp-category-nav"));
923 assert!(needed.contains("mp-product-card"));
924 assert!(!needed.contains("if-shell"));
925 assert!(!needed.contains("for-items"));
926 assert!(!needed.contains("attr-title"));
927 }
928
929 #[test]
930 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
931 let mut fragments = HashMap::new();
932 fragments.insert(
933 "index.html".to_string(),
934 FragmentList {
935 fragments: vec![WebUIFragment::component("mp-app")],
936 },
937 );
938 fragments.insert(
939 "mp-app".to_string(),
940 FragmentList {
941 fragments: vec![
942 WebUIFragment::component("mp-category-nav"),
943 WebUIFragment::route_from(WebUiFragmentRoute {
944 path: "/search/:category".into(),
945 fragment_id: "mp-search-page".into(),
946 exact: true,
947 ..Default::default()
948 }),
949 WebUIFragment::route_from(WebUiFragmentRoute {
950 path: "/product/:handle".into(),
951 fragment_id: "mp-product-page".into(),
952 exact: true,
953 ..Default::default()
954 }),
955 ],
956 },
957 );
958 fragments.insert(
959 "mp-category-nav".to_string(),
960 FragmentList {
961 fragments: vec![WebUIFragment::raw("<nav></nav>")],
962 },
963 );
964 fragments.insert(
965 "mp-search-page".to_string(),
966 FragmentList {
967 fragments: vec![WebUIFragment::component("mp-product-grid")],
968 },
969 );
970 fragments.insert(
971 "mp-product-grid".to_string(),
972 FragmentList {
973 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
974 },
975 );
976 fragments.insert(
977 "mp-product-page".to_string(),
978 FragmentList {
979 fragments: vec![WebUIFragment::component("mp-product-detail")],
980 },
981 );
982 fragments.insert(
983 "mp-product-detail".to_string(),
984 FragmentList {
985 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
986 },
987 );
988
989 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
990 protocol
991 .components
992 .entry("mp-search-page".to_string())
993 .or_default()
994 .template = "<mp-search-page></mp-search-page>".to_string();
995 protocol
996 .components
997 .entry("mp-product-page".to_string())
998 .or_default()
999 .template = "<mp-product-page></mp-product-page>".to_string();
1000
1001 let (needed, _inv) =
1002 get_needed_components_for_request(&protocol, "index.html", "/search/shirts", "");
1003 let needed: HashSet<String> = needed.into_iter().collect();
1004
1005 assert!(needed.contains("mp-app"));
1006 assert!(needed.contains("mp-category-nav"));
1007 assert!(needed.contains("mp-search-page"));
1008 assert!(needed.contains("mp-product-grid"));
1009 assert!(!needed.contains("mp-product-page"));
1010 assert!(!needed.contains("mp-product-detail"));
1011 }
1012
1013 #[test]
1014 fn test_get_needed_components_for_request_follows_nested_route_chain() {
1015 let mut fragments = HashMap::new();
1016 fragments.insert(
1017 "index.html".to_string(),
1018 FragmentList {
1019 fragments: vec![WebUIFragment::component("mp-app")],
1020 },
1021 );
1022 fragments.insert(
1023 "mp-app".to_string(),
1024 FragmentList {
1025 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1026 path: "/account/*rest".into(),
1027 fragment_id: "mp-account-shell".into(),
1028 exact: false,
1029 ..Default::default()
1030 })],
1031 },
1032 );
1033 fragments.insert(
1034 "mp-account-shell".to_string(),
1035 FragmentList {
1036 fragments: vec![
1037 WebUIFragment::component("mp-account-nav"),
1038 WebUIFragment::route_from(WebUiFragmentRoute {
1039 path: "/account/profile".into(),
1040 fragment_id: "mp-profile-page".into(),
1041 exact: true,
1042 ..Default::default()
1043 }),
1044 WebUIFragment::route_from(WebUiFragmentRoute {
1045 path: "/account/orders/:id".into(),
1046 fragment_id: "mp-order-page".into(),
1047 exact: true,
1048 ..Default::default()
1049 }),
1050 ],
1051 },
1052 );
1053 fragments.insert(
1054 "mp-account-nav".to_string(),
1055 FragmentList {
1056 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1057 },
1058 );
1059 fragments.insert(
1060 "mp-profile-page".to_string(),
1061 FragmentList {
1062 fragments: vec![WebUIFragment::raw("<profile></profile>")],
1063 },
1064 );
1065 fragments.insert(
1066 "mp-order-page".to_string(),
1067 FragmentList {
1068 fragments: vec![WebUIFragment::component("mp-order-detail")],
1069 },
1070 );
1071 fragments.insert(
1072 "mp-order-detail".to_string(),
1073 FragmentList {
1074 fragments: vec![WebUIFragment::raw("<detail></detail>")],
1075 },
1076 );
1077
1078 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1079 protocol
1080 .components
1081 .entry("mp-account-shell".to_string())
1082 .or_default()
1083 .template = "<mp-account-shell></mp-account-shell>".to_string();
1084 protocol
1085 .components
1086 .entry("mp-profile-page".to_string())
1087 .or_default()
1088 .template = "<mp-profile-page></mp-profile-page>".to_string();
1089 protocol
1090 .components
1091 .entry("mp-order-page".to_string())
1092 .or_default()
1093 .template = "<mp-order-page></mp-order-page>".to_string();
1094
1095 let (needed, _inv) =
1096 get_needed_components_for_request(&protocol, "index.html", "/account/orders/42", "");
1097 let needed: HashSet<String> = needed.into_iter().collect();
1098
1099 assert!(needed.contains("mp-app"));
1100 assert!(needed.contains("mp-account-shell"));
1101 assert!(needed.contains("mp-account-nav"));
1102 assert!(needed.contains("mp-order-page"));
1103 assert!(needed.contains("mp-order-detail"));
1104 assert!(!needed.contains("mp-profile-page"));
1105 }
1106
1107 #[test]
1108 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1109 let mut fragments = HashMap::new();
1110 fragments.insert(
1111 "index.html".to_string(),
1112 FragmentList {
1113 fragments: vec![WebUIFragment::component("mp-app")],
1114 },
1115 );
1116 fragments.insert(
1117 "mp-app".to_string(),
1118 FragmentList {
1119 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1120 path: "/search".into(),
1121 fragment_id: "mp-search-page".into(),
1122 exact: true,
1123 ..Default::default()
1124 })],
1125 },
1126 );
1127 fragments.insert(
1128 "mp-search-page".to_string(),
1129 FragmentList {
1130 fragments: vec![
1131 WebUIFragment::if_cond(
1132 webui_protocol::ConditionExpr::identifier("showFilters"),
1133 "if-filters",
1134 ),
1135 WebUIFragment::for_loop("item", "items", "item-loop"),
1136 ],
1137 },
1138 );
1139 fragments.insert(
1140 "if-filters".to_string(),
1141 FragmentList {
1142 fragments: vec![WebUIFragment::component("mp-filter-panel")],
1143 },
1144 );
1145 fragments.insert(
1146 "item-loop".to_string(),
1147 FragmentList {
1148 fragments: vec![WebUIFragment::component("mp-item-card")],
1149 },
1150 );
1151 fragments.insert(
1152 "mp-filter-panel".to_string(),
1153 FragmentList {
1154 fragments: vec![WebUIFragment::raw("<filters></filters>")],
1155 },
1156 );
1157 fragments.insert(
1158 "mp-item-card".to_string(),
1159 FragmentList {
1160 fragments: vec![WebUIFragment::raw("<item></item>")],
1161 },
1162 );
1163
1164 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1165 protocol
1166 .components
1167 .entry("mp-search-page".to_string())
1168 .or_default()
1169 .template = "<mp-search-page></mp-search-page>".to_string();
1170
1171 let (needed, _inv) =
1172 get_needed_components_for_request(&protocol, "index.html", "/search", "");
1173 let needed: HashSet<String> = needed.into_iter().collect();
1174
1175 assert!(needed.contains("mp-app"));
1176 assert!(needed.contains("mp-search-page"));
1177 assert!(needed.contains("mp-filter-panel"));
1178 assert!(needed.contains("mp-item-card"));
1179 }
1180
1181 #[test]
1182 fn test_get_needed_components_for_request_respects_inventory() {
1183 let mut fragments = HashMap::new();
1184 fragments.insert(
1185 "index.html".to_string(),
1186 FragmentList {
1187 fragments: vec![WebUIFragment::component("mp-app")],
1188 },
1189 );
1190 fragments.insert(
1191 "mp-app".to_string(),
1192 FragmentList {
1193 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1194 path: "/search".into(),
1195 fragment_id: "mp-search-page".into(),
1196 exact: true,
1197 ..Default::default()
1198 })],
1199 },
1200 );
1201 fragments.insert(
1202 "mp-search-page".to_string(),
1203 FragmentList {
1204 fragments: vec![WebUIFragment::component("mp-product-grid")],
1205 },
1206 );
1207 fragments.insert(
1208 "mp-product-grid".to_string(),
1209 FragmentList {
1210 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1211 },
1212 );
1213
1214 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1215 protocol
1216 .components
1217 .entry("mp-search-page".to_string())
1218 .or_default()
1219 .template = "<mp-search-page></mp-search-page>".to_string();
1220
1221 let (_needed, inventory) =
1222 get_needed_components_for_request(&protocol, "index.html", "/search", "");
1223 let (needed_again, _) =
1224 get_needed_components_for_request(&protocol, "index.html", "/search", &inventory);
1225 assert!(needed_again.is_empty());
1226 }
1227
1228 #[test]
1229 fn test_get_route_templates_for_request_returns_only_active_route_templates() {
1230 let mut fragments = HashMap::new();
1231 fragments.insert(
1232 "index.html".to_string(),
1233 FragmentList {
1234 fragments: vec![WebUIFragment::component("mp-app")],
1235 },
1236 );
1237 fragments.insert(
1238 "mp-app".to_string(),
1239 FragmentList {
1240 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1241 path: "/search".into(),
1242 fragment_id: "mp-search-page".into(),
1243 exact: true,
1244 ..Default::default()
1245 })],
1246 },
1247 );
1248 fragments.insert(
1249 "mp-search-page".to_string(),
1250 FragmentList {
1251 fragments: vec![WebUIFragment::component("mp-product-grid")],
1252 },
1253 );
1254 fragments.insert(
1255 "mp-product-grid".to_string(),
1256 FragmentList {
1257 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1258 },
1259 );
1260
1261 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1262 protocol
1263 .components
1264 .entry("mp-app".to_string())
1265 .or_default()
1266 .template = "<f-template id=app></f-template>".to_string();
1267 protocol
1268 .components
1269 .entry("mp-search-page".to_string())
1270 .or_default()
1271 .template = "<f-template id=search></f-template>".to_string();
1272 protocol
1273 .components
1274 .entry("mp-product-grid".to_string())
1275 .or_default()
1276 .template = "<f-template id=grid></f-template>".to_string();
1277
1278 let (templates, _inv) =
1279 get_route_templates_for_request(&protocol, "index.html", "/search", "");
1280
1281 assert_eq!(templates.len(), 3);
1282 assert_eq!(templates[0].0, "mp-app");
1283 assert_eq!(templates[1].0, "mp-product-grid");
1284 assert_eq!(templates[2].0, "mp-search-page");
1285 }
1286}