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) =
565 get_route_templates_for_request(protocol, entry_id, request_path, inventory_hex);
566
567 let chain = collect_route_chain(protocol, entry_id, request_path);
569
570 let tmpl_array: Vec<Value> = templates
571 .into_iter()
572 .map(|(_, js)| Value::String(js))
573 .collect();
574
575 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
576
577 let mut result = serde_json::Map::with_capacity(5);
578 result.insert("state".into(), state);
579 result.insert("templates".into(), Value::Array(tmpl_array));
580 result.insert("inventory".into(), Value::String(updated_inv));
581 result.insert("path".into(), Value::String(request_path.to_string()));
582 result.insert("chain".into(), chain_array);
583 Value::Object(result)
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}
600
601impl RouteChainEntry {
602 #[must_use]
604 pub fn to_json(&self) -> Value {
605 let mut obj = serde_json::Map::with_capacity(4);
606 obj.insert("component".into(), Value::String(self.component.clone()));
607 obj.insert("path".into(), Value::String(self.path.clone()));
608 if !self.params.is_empty() {
609 let params_obj: serde_json::Map<String, Value> = self
610 .params
611 .iter()
612 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
613 .collect();
614 obj.insert("params".into(), Value::Object(params_obj));
615 }
616 if self.exact {
617 obj.insert("exact".into(), Value::Bool(true));
618 }
619 Value::Object(obj)
620 }
621}
622
623#[must_use]
630pub fn collect_route_chain_json(
631 protocol: &WebUIProtocol,
632 entry_id: &str,
633 request_path: &str,
634) -> Value {
635 let chain = collect_route_chain(protocol, entry_id, request_path);
636 Value::Array(chain.iter().map(RouteChainEntry::to_json).collect())
637}
638
639pub fn collect_route_chain(
644 protocol: &WebUIProtocol,
645 entry_id: &str,
646 request_path: &str,
647) -> Vec<RouteChainEntry> {
648 let mut chain = Vec::new();
649 let mut visited_fragments = HashSet::new();
650 let mut stack = vec![QueuedFragment {
651 id: entry_id.to_string(),
652 inventoryable: false,
653 route_base: "/".to_string(),
654 }];
655
656 while let Some(queued) = stack.pop() {
657 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
658 continue;
659 }
660
661 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
662 continue;
663 };
664
665 let matched_route =
666 find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
667
668 for frag in &frag_list.fragments {
669 match frag.fragment.as_ref() {
670 Some(Fragment::Component(component)) => {
671 stack.push(QueuedFragment {
672 id: component.fragment_id.clone(),
673 inventoryable: false,
674 route_base: queued.route_base.clone(),
675 });
676 }
677 Some(Fragment::Route(route_frag)) => {
678 let is_selected = matched_route
679 .as_ref()
680 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
681 if is_selected && !route_frag.fragment_id.is_empty() {
682 if let Some((_, ref rm)) = matched_route {
683 chain.push(RouteChainEntry {
684 component: route_frag.fragment_id.clone(),
685 path: route_frag.path.clone(),
686 params: rm.params.clone(),
687 exact: route_frag.exact,
688 });
689
690 let child_route_base = route_matcher::compute_route_base(
691 request_path,
692 rm.consumed_segments,
693 );
694
695 stack.push(QueuedFragment {
696 id: route_frag.fragment_id.clone(),
697 inventoryable: false,
698 route_base: child_route_base.clone(),
699 });
700
701 collect_chain_from_children(
703 &route_frag.children,
704 request_path,
705 &child_route_base,
706 &mut chain,
707 );
708 }
709 }
710 }
711 _ => {}
712 }
713 }
714 }
715
716 chain
717}
718
719fn collect_chain_from_children(
721 children: &[WebUIFragmentRoute],
722 request_path: &str,
723 route_base: &str,
724 chain: &mut Vec<RouteChainEntry>,
725) {
726 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
727 vec![(children, route_base.to_string())];
728
729 while let Some((current, base)) = pending.pop() {
730 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
731 for (idx, child) in current.iter().enumerate() {
732 let resolved = route_matcher::resolve_route_path(&child.path, &base);
733 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
734 {
735 let is_better = best
736 .as_ref()
737 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
738 if is_better {
739 best = Some((idx, m));
740 }
741 }
742 }
743
744 if let Some((idx, rm)) = best {
745 let matched = ¤t[idx];
746 chain.push(RouteChainEntry {
747 component: matched.fragment_id.clone(),
748 path: matched.path.clone(),
749 params: rm.params,
750 exact: matched.exact,
751 });
752 if !matched.children.is_empty() {
753 let child_base =
754 route_matcher::compute_route_base(request_path, rm.consumed_segments);
755 pending.push((&matched.children, child_base));
756 }
757 }
758 }
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use std::collections::HashMap;
765 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
766
767 #[test]
768 fn test_component_bit_position_deterministic() {
769 let pos1 = component_bit_position("my-component");
770 let pos2 = component_bit_position("my-component");
771 assert_eq!(pos1, pos2);
772 assert!(pos1 < 256);
773 }
774
775 #[test]
776 fn test_has_component_present() {
777 let mut inv = vec![0u8; 32];
778 let bit = component_bit_position("test-comp");
779 let byte_idx = (bit / 8) as usize;
780 let bit_idx = bit % 8;
781 inv[byte_idx] |= 1 << bit_idx;
782 assert!(has_component(&inv, "test-comp"));
783 }
784
785 #[test]
786 fn test_has_component_absent() {
787 let inv = vec![0u8; 32];
788 assert!(!has_component(&inv, "test-comp"));
789 }
790
791 #[test]
792 fn test_parse_encode_inventory_roundtrip() {
793 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
794 let hex = encode_inventory(&original);
795 let decoded = parse_inventory(&hex);
796 assert_eq!(original, decoded);
797 }
798
799 #[test]
800 fn test_get_needed_components_empty_inventory() {
801 let mut fragments = HashMap::new();
802 fragments.insert(
803 "app-shell".to_string(),
804 FragmentList {
805 fragments: vec![WebUIFragment::component("my-card")],
806 },
807 );
808 fragments.insert(
809 "my-card".to_string(),
810 FragmentList {
811 fragments: vec![WebUIFragment::raw("card")],
812 },
813 );
814
815 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
816 let (needed, _inv) = get_needed_components(&protocol, "app-shell", "");
817 assert!(needed.contains(&"app-shell".to_string()));
818 assert!(needed.contains(&"my-card".to_string()));
819 }
820
821 #[test]
822 fn test_get_route_templates() {
823 let mut fragments = HashMap::new();
824 fragments.insert(
825 "my-page".to_string(),
826 FragmentList {
827 fragments: vec![WebUIFragment::raw("<p>page</p>")],
828 },
829 );
830
831 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
832 protocol
833 .components
834 .entry("my-page".to_string())
835 .or_default()
836 .template = "<p>page</p>".to_string();
837
838 let (templates, _inv) = get_route_templates(&protocol, "my-page", "");
839 assert_eq!(templates.len(), 1);
840 assert_eq!(templates[0].0, "my-page");
841 }
842
843 #[test]
844 fn test_get_needed_components_skips_inventory() {
845 let mut fragments = HashMap::new();
846 fragments.insert(
847 "app-shell".to_string(),
848 FragmentList {
849 fragments: vec![WebUIFragment::component("my-card")],
850 },
851 );
852 fragments.insert(
853 "my-card".to_string(),
854 FragmentList {
855 fragments: vec![WebUIFragment::raw("card")],
856 },
857 );
858
859 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
860
861 let (_needed, inv_hex) = get_needed_components(&protocol, "app-shell", "");
862 let (needed2, _) = get_needed_components(&protocol, "app-shell", &inv_hex);
863 assert!(needed2.is_empty());
864 }
865
866 #[test]
867 fn test_get_needed_components_skips_control_fragments() {
868 let mut fragments = HashMap::new();
869 fragments.insert(
870 "app-shell".to_string(),
871 FragmentList {
872 fragments: vec![
873 WebUIFragment::if_cond(
874 webui_protocol::ConditionExpr::identifier("showNav"),
875 "if-shell",
876 ),
877 WebUIFragment::for_loop("item", "items", "for-items"),
878 WebUIFragment::attribute_template("title", "attr-title"),
879 ],
880 },
881 );
882 fragments.insert(
883 "if-shell".to_string(),
884 FragmentList {
885 fragments: vec![WebUIFragment::component("mp-category-nav")],
886 },
887 );
888 fragments.insert(
889 "for-items".to_string(),
890 FragmentList {
891 fragments: vec![WebUIFragment::component("mp-product-card")],
892 },
893 );
894 fragments.insert(
895 "attr-title".to_string(),
896 FragmentList {
897 fragments: vec![WebUIFragment::raw("Products")],
898 },
899 );
900 fragments.insert(
901 "mp-category-nav".to_string(),
902 FragmentList {
903 fragments: vec![WebUIFragment::raw("<nav></nav>")],
904 },
905 );
906 fragments.insert(
907 "mp-product-card".to_string(),
908 FragmentList {
909 fragments: vec![WebUIFragment::raw("<article></article>")],
910 },
911 );
912
913 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
914 let needed: HashSet<String> = get_needed_components(&protocol, "app-shell", "")
915 .0
916 .into_iter()
917 .collect();
918
919 assert!(needed.contains("app-shell"));
920 assert!(needed.contains("mp-category-nav"));
921 assert!(needed.contains("mp-product-card"));
922 assert!(!needed.contains("if-shell"));
923 assert!(!needed.contains("for-items"));
924 assert!(!needed.contains("attr-title"));
925 }
926
927 #[test]
928 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
929 let mut fragments = HashMap::new();
930 fragments.insert(
931 "index.html".to_string(),
932 FragmentList {
933 fragments: vec![WebUIFragment::component("mp-app")],
934 },
935 );
936 fragments.insert(
937 "mp-app".to_string(),
938 FragmentList {
939 fragments: vec![
940 WebUIFragment::component("mp-category-nav"),
941 WebUIFragment::route_from(WebUiFragmentRoute {
942 path: "/search/:category".into(),
943 fragment_id: "mp-search-page".into(),
944 exact: true,
945 ..Default::default()
946 }),
947 WebUIFragment::route_from(WebUiFragmentRoute {
948 path: "/product/:handle".into(),
949 fragment_id: "mp-product-page".into(),
950 exact: true,
951 ..Default::default()
952 }),
953 ],
954 },
955 );
956 fragments.insert(
957 "mp-category-nav".to_string(),
958 FragmentList {
959 fragments: vec![WebUIFragment::raw("<nav></nav>")],
960 },
961 );
962 fragments.insert(
963 "mp-search-page".to_string(),
964 FragmentList {
965 fragments: vec![WebUIFragment::component("mp-product-grid")],
966 },
967 );
968 fragments.insert(
969 "mp-product-grid".to_string(),
970 FragmentList {
971 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
972 },
973 );
974 fragments.insert(
975 "mp-product-page".to_string(),
976 FragmentList {
977 fragments: vec![WebUIFragment::component("mp-product-detail")],
978 },
979 );
980 fragments.insert(
981 "mp-product-detail".to_string(),
982 FragmentList {
983 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
984 },
985 );
986
987 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
988 protocol
989 .components
990 .entry("mp-search-page".to_string())
991 .or_default()
992 .template = "<mp-search-page></mp-search-page>".to_string();
993 protocol
994 .components
995 .entry("mp-product-page".to_string())
996 .or_default()
997 .template = "<mp-product-page></mp-product-page>".to_string();
998
999 let (needed, _inv) =
1000 get_needed_components_for_request(&protocol, "index.html", "/search/shirts", "");
1001 let needed: HashSet<String> = needed.into_iter().collect();
1002
1003 assert!(needed.contains("mp-app"));
1004 assert!(needed.contains("mp-category-nav"));
1005 assert!(needed.contains("mp-search-page"));
1006 assert!(needed.contains("mp-product-grid"));
1007 assert!(!needed.contains("mp-product-page"));
1008 assert!(!needed.contains("mp-product-detail"));
1009 }
1010
1011 #[test]
1012 fn test_get_needed_components_for_request_follows_nested_route_chain() {
1013 let mut fragments = HashMap::new();
1014 fragments.insert(
1015 "index.html".to_string(),
1016 FragmentList {
1017 fragments: vec![WebUIFragment::component("mp-app")],
1018 },
1019 );
1020 fragments.insert(
1021 "mp-app".to_string(),
1022 FragmentList {
1023 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1024 path: "/account/*rest".into(),
1025 fragment_id: "mp-account-shell".into(),
1026 exact: false,
1027 ..Default::default()
1028 })],
1029 },
1030 );
1031 fragments.insert(
1032 "mp-account-shell".to_string(),
1033 FragmentList {
1034 fragments: vec![
1035 WebUIFragment::component("mp-account-nav"),
1036 WebUIFragment::route_from(WebUiFragmentRoute {
1037 path: "/account/profile".into(),
1038 fragment_id: "mp-profile-page".into(),
1039 exact: true,
1040 ..Default::default()
1041 }),
1042 WebUIFragment::route_from(WebUiFragmentRoute {
1043 path: "/account/orders/:id".into(),
1044 fragment_id: "mp-order-page".into(),
1045 exact: true,
1046 ..Default::default()
1047 }),
1048 ],
1049 },
1050 );
1051 fragments.insert(
1052 "mp-account-nav".to_string(),
1053 FragmentList {
1054 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1055 },
1056 );
1057 fragments.insert(
1058 "mp-profile-page".to_string(),
1059 FragmentList {
1060 fragments: vec![WebUIFragment::raw("<profile></profile>")],
1061 },
1062 );
1063 fragments.insert(
1064 "mp-order-page".to_string(),
1065 FragmentList {
1066 fragments: vec![WebUIFragment::component("mp-order-detail")],
1067 },
1068 );
1069 fragments.insert(
1070 "mp-order-detail".to_string(),
1071 FragmentList {
1072 fragments: vec![WebUIFragment::raw("<detail></detail>")],
1073 },
1074 );
1075
1076 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1077 protocol
1078 .components
1079 .entry("mp-account-shell".to_string())
1080 .or_default()
1081 .template = "<mp-account-shell></mp-account-shell>".to_string();
1082 protocol
1083 .components
1084 .entry("mp-profile-page".to_string())
1085 .or_default()
1086 .template = "<mp-profile-page></mp-profile-page>".to_string();
1087 protocol
1088 .components
1089 .entry("mp-order-page".to_string())
1090 .or_default()
1091 .template = "<mp-order-page></mp-order-page>".to_string();
1092
1093 let (needed, _inv) =
1094 get_needed_components_for_request(&protocol, "index.html", "/account/orders/42", "");
1095 let needed: HashSet<String> = needed.into_iter().collect();
1096
1097 assert!(needed.contains("mp-app"));
1098 assert!(needed.contains("mp-account-shell"));
1099 assert!(needed.contains("mp-account-nav"));
1100 assert!(needed.contains("mp-order-page"));
1101 assert!(needed.contains("mp-order-detail"));
1102 assert!(!needed.contains("mp-profile-page"));
1103 }
1104
1105 #[test]
1106 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1107 let mut fragments = HashMap::new();
1108 fragments.insert(
1109 "index.html".to_string(),
1110 FragmentList {
1111 fragments: vec![WebUIFragment::component("mp-app")],
1112 },
1113 );
1114 fragments.insert(
1115 "mp-app".to_string(),
1116 FragmentList {
1117 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1118 path: "/search".into(),
1119 fragment_id: "mp-search-page".into(),
1120 exact: true,
1121 ..Default::default()
1122 })],
1123 },
1124 );
1125 fragments.insert(
1126 "mp-search-page".to_string(),
1127 FragmentList {
1128 fragments: vec![
1129 WebUIFragment::if_cond(
1130 webui_protocol::ConditionExpr::identifier("showFilters"),
1131 "if-filters",
1132 ),
1133 WebUIFragment::for_loop("item", "items", "item-loop"),
1134 ],
1135 },
1136 );
1137 fragments.insert(
1138 "if-filters".to_string(),
1139 FragmentList {
1140 fragments: vec![WebUIFragment::component("mp-filter-panel")],
1141 },
1142 );
1143 fragments.insert(
1144 "item-loop".to_string(),
1145 FragmentList {
1146 fragments: vec![WebUIFragment::component("mp-item-card")],
1147 },
1148 );
1149 fragments.insert(
1150 "mp-filter-panel".to_string(),
1151 FragmentList {
1152 fragments: vec![WebUIFragment::raw("<filters></filters>")],
1153 },
1154 );
1155 fragments.insert(
1156 "mp-item-card".to_string(),
1157 FragmentList {
1158 fragments: vec![WebUIFragment::raw("<item></item>")],
1159 },
1160 );
1161
1162 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1163 protocol
1164 .components
1165 .entry("mp-search-page".to_string())
1166 .or_default()
1167 .template = "<mp-search-page></mp-search-page>".to_string();
1168
1169 let (needed, _inv) =
1170 get_needed_components_for_request(&protocol, "index.html", "/search", "");
1171 let needed: HashSet<String> = needed.into_iter().collect();
1172
1173 assert!(needed.contains("mp-app"));
1174 assert!(needed.contains("mp-search-page"));
1175 assert!(needed.contains("mp-filter-panel"));
1176 assert!(needed.contains("mp-item-card"));
1177 }
1178
1179 #[test]
1180 fn test_get_needed_components_for_request_respects_inventory() {
1181 let mut fragments = HashMap::new();
1182 fragments.insert(
1183 "index.html".to_string(),
1184 FragmentList {
1185 fragments: vec![WebUIFragment::component("mp-app")],
1186 },
1187 );
1188 fragments.insert(
1189 "mp-app".to_string(),
1190 FragmentList {
1191 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1192 path: "/search".into(),
1193 fragment_id: "mp-search-page".into(),
1194 exact: true,
1195 ..Default::default()
1196 })],
1197 },
1198 );
1199 fragments.insert(
1200 "mp-search-page".to_string(),
1201 FragmentList {
1202 fragments: vec![WebUIFragment::component("mp-product-grid")],
1203 },
1204 );
1205 fragments.insert(
1206 "mp-product-grid".to_string(),
1207 FragmentList {
1208 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1209 },
1210 );
1211
1212 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1213 protocol
1214 .components
1215 .entry("mp-search-page".to_string())
1216 .or_default()
1217 .template = "<mp-search-page></mp-search-page>".to_string();
1218
1219 let (_needed, inventory) =
1220 get_needed_components_for_request(&protocol, "index.html", "/search", "");
1221 let (needed_again, _) =
1222 get_needed_components_for_request(&protocol, "index.html", "/search", &inventory);
1223 assert!(needed_again.is_empty());
1224 }
1225
1226 #[test]
1227 fn test_get_route_templates_for_request_returns_only_active_route_templates() {
1228 let mut fragments = HashMap::new();
1229 fragments.insert(
1230 "index.html".to_string(),
1231 FragmentList {
1232 fragments: vec![WebUIFragment::component("mp-app")],
1233 },
1234 );
1235 fragments.insert(
1236 "mp-app".to_string(),
1237 FragmentList {
1238 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1239 path: "/search".into(),
1240 fragment_id: "mp-search-page".into(),
1241 exact: true,
1242 ..Default::default()
1243 })],
1244 },
1245 );
1246 fragments.insert(
1247 "mp-search-page".to_string(),
1248 FragmentList {
1249 fragments: vec![WebUIFragment::component("mp-product-grid")],
1250 },
1251 );
1252 fragments.insert(
1253 "mp-product-grid".to_string(),
1254 FragmentList {
1255 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1256 },
1257 );
1258
1259 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1260 protocol
1261 .components
1262 .entry("mp-app".to_string())
1263 .or_default()
1264 .template = "<f-template id=app></f-template>".to_string();
1265 protocol
1266 .components
1267 .entry("mp-search-page".to_string())
1268 .or_default()
1269 .template = "<f-template id=search></f-template>".to_string();
1270 protocol
1271 .components
1272 .entry("mp-product-grid".to_string())
1273 .or_default()
1274 .template = "<f-template id=grid></f-template>".to_string();
1275
1276 let (templates, _inv) =
1277 get_route_templates_for_request(&protocol, "index.html", "/search", "");
1278
1279 assert_eq!(templates.len(), 3);
1280 assert_eq!(templates[0].0, "mp-app");
1281 assert_eq!(templates[1].0, "mp-product-grid");
1282 assert_eq!(templates[2].0, "mp-search-page");
1283 }
1284}