1use crate::{route_matcher, route_renderer, HandlerError};
12use route_matcher::CompiledRouteCache;
13use serde_json::Value;
14use std::collections::{HashMap, HashSet};
15use webui_protocol::{web_ui_fragment::Fragment, WebUIFragmentRoute, WebUIProtocol};
16
17pub struct ProtocolIndex {
25 pub component_index: HashMap<String, u32>,
27 pub route_cache: CompiledRouteCache,
29}
30
31impl ProtocolIndex {
32 #[must_use]
34 pub fn new(protocol: &WebUIProtocol) -> Self {
35 Self {
36 component_index: build_component_index(protocol),
37 route_cache: CompiledRouteCache::new(),
38 }
39 }
40}
41
42pub fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
51 let mut sorted: Vec<&String> = protocol
52 .fragments
53 .keys()
54 .filter(|key| key.contains('-'))
55 .collect();
56 sorted.sort_unstable();
57 let mut index = HashMap::with_capacity(sorted.len());
58 for (i, name) in sorted.into_iter().enumerate() {
59 #[allow(clippy::cast_possible_truncation)]
61 let idx = i as u32;
62 index.insert(name.clone(), idx);
63 }
64 index
65}
66
67fn has_component(inventory: &[u8], index: u32) -> bool {
69 let byte_idx = (index / 8) as usize;
70 let bit_idx = index % 8;
71 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
72}
73
74fn set_component(inventory: &mut Vec<u8>, index: u32) {
76 let byte_idx = (index / 8) as usize;
77 let bit_idx = index % 8;
78 if byte_idx >= inventory.len() {
79 inventory.resize(byte_idx + 1, 0);
80 }
81 inventory[byte_idx] |= 1 << bit_idx;
82}
83
84pub fn parse_inventory(hex: &str) -> Result<Vec<u8>, HandlerError> {
88 if hex.is_empty() {
89 return Ok(Vec::new());
90 }
91 if !hex.len().is_multiple_of(2) {
92 return Err(HandlerError::Protocol(
93 webui_protocol::ProtocolError::Validation(format!(
94 "inventory hex has odd length: {}",
95 hex.len()
96 )),
97 ));
98 }
99 let mut bytes = Vec::with_capacity(hex.len() / 2);
100 let mut chars = hex.bytes();
101 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
102 let h = route_matcher::hex_val(hi).ok_or_else(|| {
103 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
104 "invalid hex digit in inventory: {:#04x}",
105 hi
106 )))
107 })?;
108 let l = route_matcher::hex_val(lo).ok_or_else(|| {
109 HandlerError::Protocol(webui_protocol::ProtocolError::Validation(format!(
110 "invalid hex digit in inventory: {:#04x}",
111 lo
112 )))
113 })?;
114 bytes.push((h << 4) | l);
115 }
116 Ok(bytes)
117}
118
119pub fn encode_inventory(inv: &[u8]) -> String {
121 const HEX: &[u8; 16] = b"0123456789abcdef";
122 let mut encoded = String::with_capacity(inv.len() * 2);
123 for byte in inv {
124 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
125 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
126 }
127 encoded
128}
129
130pub(crate) fn encode_component_inventory(
132 component_names: &HashSet<String>,
133 index: &HashMap<String, u32>,
134) -> String {
135 let mut inventory = Vec::new();
136 for name in component_names {
137 if let Some(&idx) = index.get(name.as_str()) {
138 set_component(&mut inventory, idx);
139 }
140 }
141 encode_inventory(&inventory)
142}
143
144pub fn get_needed_components(
149 protocol: &WebUIProtocol,
150 entry_id: &str,
151 inventory_hex: &str,
152 component_index: &HashMap<String, u32>,
153) -> Result<(Vec<String>, String), HandlerError> {
154 let component_names = collect_inventoryable_components(
155 protocol,
156 entry_id,
157 None,
158 true,
159 &mut CompiledRouteCache::new(),
160 );
161 filter_needed_components(&component_names, inventory_hex, component_index)
162}
163
164pub fn get_needed_components_for_request(
169 protocol: &WebUIProtocol,
170 entry_id: &str,
171 request_path: &str,
172 inventory_hex: &str,
173 component_index: &HashMap<String, u32>,
174) -> Result<(Vec<String>, String), HandlerError> {
175 let component_names = collect_inventoryable_components(
176 protocol,
177 entry_id,
178 Some(request_path),
179 false,
180 &mut CompiledRouteCache::new(),
181 );
182 filter_needed_components(&component_names, inventory_hex, component_index)
183}
184
185pub(crate) fn collect_reachable_components_for_request(
187 protocol: &WebUIProtocol,
188 entry_id: &str,
189 request_path: &str,
190 cache: &mut CompiledRouteCache,
191) -> HashSet<String> {
192 collect_inventoryable_components(protocol, entry_id, Some(request_path), false, cache)
193}
194
195pub fn filter_needed_components(
200 component_names: &HashSet<String>,
201 inventory_hex: &str,
202 index: &HashMap<String, u32>,
203) -> Result<(Vec<String>, String), HandlerError> {
204 let client_inv = parse_inventory(inventory_hex)?;
205 let mut updated_inv = client_inv.clone();
206
207 let mut ordered_names: Vec<&String> = component_names.iter().collect();
208 ordered_names.sort_unstable();
209
210 let mut needed = Vec::with_capacity(ordered_names.len());
211 for name in ordered_names {
212 if let Some(&idx) = index.get(name.as_str()) {
213 if !has_component(&client_inv, idx) {
214 needed.push(name.clone());
215 }
216 set_component(&mut updated_inv, idx);
217 } else {
218 needed.push(name.clone());
222 }
223 }
224
225 Ok((needed, encode_inventory(&updated_inv)))
226}
227
228#[derive(Debug)]
229struct QueuedFragment {
230 id: String,
231 inventoryable: bool,
232 route_base: String,
234}
235
236struct ChildWalkCtx<'a> {
239 request_path: &'a str,
240 protocol: &'a WebUIProtocol,
241 cache: &'a mut CompiledRouteCache,
242}
243
244fn collect_inventoryable_components(
254 protocol: &WebUIProtocol,
255 entry_id: &str,
256 request_path: Option<&str>,
257 root_inventoryable: bool,
258 cache: &mut CompiledRouteCache,
259) -> HashSet<String> {
260 let mut visited_fragments = HashSet::new();
261 let mut component_ids = HashSet::new();
262 let mut stack = vec![QueuedFragment {
263 id: entry_id.to_string(),
264 inventoryable: root_inventoryable,
265 route_base: "/".to_string(),
266 }];
267
268 while let Some(queued) = stack.pop() {
269 if queued.id.is_empty() {
270 continue;
271 }
272
273 if queued.inventoryable {
274 component_ids.insert(queued.id.clone());
275 }
276
277 if !visited_fragments.insert(queued.id.clone()) {
278 continue;
279 }
280
281 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
282 continue;
283 };
284
285 let matched_route = request_path.and_then(|path| {
286 route_renderer::find_best_route_match(
287 &frag_list.fragments,
288 path,
289 &queued.route_base,
290 cache,
291 )
292 });
293
294 for frag in &frag_list.fragments {
295 match frag.fragment.as_ref() {
296 Some(Fragment::Component(component)) => {
297 stack.push(QueuedFragment {
298 id: component.fragment_id.clone(),
299 inventoryable: true,
300 route_base: queued.route_base.clone(),
301 });
302 }
303 Some(Fragment::ForLoop(for_loop)) => {
304 stack.push(QueuedFragment {
305 id: for_loop.fragment_id.clone(),
306 inventoryable: false,
307 route_base: queued.route_base.clone(),
308 });
309 }
310 Some(Fragment::IfCond(if_cond)) => {
311 stack.push(QueuedFragment {
312 id: if_cond.fragment_id.clone(),
313 inventoryable: false,
314 route_base: queued.route_base.clone(),
315 });
316 }
317 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
318 stack.push(QueuedFragment {
319 id: attr.template.clone(),
320 inventoryable: false,
321 route_base: queued.route_base.clone(),
322 });
323 }
324 Some(Fragment::Route(route_frag)) => {
325 let is_selected = matched_route
326 .as_ref()
327 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
328 if is_selected && !route_frag.fragment_id.is_empty() {
329 let child_route_base = if let Some((_, ref rm)) = matched_route {
331 if let Some(path) = request_path {
332 route_matcher::compute_route_base(path, rm.consumed_segments)
333 } else {
334 queued.route_base.clone()
335 }
336 } else {
337 queued.route_base.clone()
338 };
339
340 stack.push(QueuedFragment {
341 id: route_frag.fragment_id.clone(),
342 inventoryable: protocol
343 .components
344 .get(&route_frag.fragment_id)
345 .is_some_and(|c| !c.template.is_empty()),
346 route_base: child_route_base.clone(),
347 });
348
349 if !route_frag.pending_component.is_empty() {
352 stack.push(QueuedFragment {
353 id: route_frag.pending_component.clone(),
354 inventoryable: protocol
355 .components
356 .get(&route_frag.pending_component)
357 .is_some_and(|c| !c.template.is_empty()),
358 route_base: child_route_base.clone(),
359 });
360 }
361 if !route_frag.error_component.is_empty() {
362 stack.push(QueuedFragment {
363 id: route_frag.error_component.clone(),
364 inventoryable: protocol
365 .components
366 .get(&route_frag.error_component)
367 .is_some_and(|c| !c.template.is_empty()),
368 route_base: child_route_base.clone(),
369 });
370 }
371
372 if !route_frag.children.is_empty() {
376 if let Some(path) = request_path {
377 walk_route_children(
378 &route_frag.children,
379 &child_route_base,
380 &mut stack,
381 &mut ChildWalkCtx {
382 request_path: path,
383 protocol,
384 cache,
385 },
386 );
387 }
388 }
389 }
390 }
391 _ => {}
392 }
393 }
394 }
395
396 component_ids
397}
398
399fn select_best_child_route(
404 children: &[WebUIFragmentRoute],
405 request_path: &str,
406 route_base: &str,
407 cache: &mut CompiledRouteCache,
408) -> Option<(usize, route_matcher::RouteMatch)> {
409 let request_segments = route_matcher::split_request_path(request_path);
410 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
411 for (idx, child) in children.iter().enumerate() {
412 let resolved = route_matcher::resolve_route_path_cow(&child.path, route_base);
413 if let Some(m) = route_matcher::match_route_cached_with_segments(
414 cache,
415 resolved.as_ref(),
416 &request_segments,
417 child.exact,
418 ) {
419 let is_better = best
420 .as_ref()
421 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
422 if is_better {
423 best = Some((idx, m));
424 }
425 }
426 }
427 best
428}
429
430fn walk_route_children(
433 children: &[WebUIFragmentRoute],
434 route_base: &str,
435 stack: &mut Vec<QueuedFragment>,
436 ctx: &mut ChildWalkCtx<'_>,
437) {
438 let mut current = children;
439 let mut base = route_base.to_string();
440
441 loop {
442 let Some((idx, ref rm)) =
443 select_best_child_route(current, ctx.request_path, &base, ctx.cache)
444 else {
445 break;
446 };
447 let matched = ¤t[idx];
448 if matched.fragment_id.is_empty() {
449 break;
450 }
451
452 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
453
454 stack.push(QueuedFragment {
455 id: matched.fragment_id.clone(),
456 inventoryable: ctx
457 .protocol
458 .components
459 .get(&matched.fragment_id)
460 .is_some_and(|c| !c.template.is_empty()),
461 route_base: child_base.clone(),
462 });
463
464 if !matched.pending_component.is_empty() {
465 stack.push(QueuedFragment {
466 id: matched.pending_component.clone(),
467 inventoryable: ctx
468 .protocol
469 .components
470 .get(&matched.pending_component)
471 .is_some_and(|c| !c.template.is_empty()),
472 route_base: child_base.clone(),
473 });
474 }
475 if !matched.error_component.is_empty() {
476 stack.push(QueuedFragment {
477 id: matched.error_component.clone(),
478 inventoryable: ctx
479 .protocol
480 .components
481 .get(&matched.error_component)
482 .is_some_and(|c| !c.template.is_empty()),
483 route_base: child_base.clone(),
484 });
485 }
486
487 if matched.children.is_empty() {
488 break;
489 }
490 current = &matched.children;
491 base = child_base;
492 }
493}
494
495pub fn collect_nested_route_params(
500 protocol: &WebUIProtocol,
501 entry_id: &str,
502 request_path: &str,
503 cache: &mut CompiledRouteCache,
504) -> HashMap<String, String> {
505 let mut all_params = HashMap::new();
506 let mut visited_fragments = HashSet::new();
507 let mut stack = vec![QueuedFragment {
508 id: entry_id.to_string(),
509 inventoryable: false,
510 route_base: "/".to_string(),
511 }];
512
513 while let Some(queued) = stack.pop() {
514 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
515 continue;
516 }
517
518 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
519 continue;
520 };
521
522 let matched_route = route_renderer::find_best_route_match(
523 &frag_list.fragments,
524 request_path,
525 &queued.route_base,
526 cache,
527 );
528
529 for frag in &frag_list.fragments {
530 match frag.fragment.as_ref() {
531 Some(Fragment::Component(component)) => {
532 stack.push(QueuedFragment {
533 id: component.fragment_id.clone(),
534 inventoryable: false,
535 route_base: queued.route_base.clone(),
536 });
537 }
538 Some(Fragment::Route(route_frag)) => {
539 let is_selected = matched_route
540 .as_ref()
541 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
542 if is_selected && !route_frag.fragment_id.is_empty() {
543 if let Some((_, ref rm)) = matched_route {
544 all_params.extend(rm.params.clone());
546
547 let child_route_base = route_matcher::compute_route_base(
548 request_path,
549 rm.consumed_segments,
550 );
551
552 stack.push(QueuedFragment {
553 id: route_frag.fragment_id.clone(),
554 inventoryable: false,
555 route_base: child_route_base.clone(),
556 });
557
558 collect_params_from_children(
560 &route_frag.children,
561 request_path,
562 &child_route_base,
563 &mut all_params,
564 cache,
565 );
566 }
567 }
568 }
569 _ => {}
570 }
571 }
572 }
573
574 all_params
575}
576
577fn collect_params_from_children(
579 children: &[WebUIFragmentRoute],
580 request_path: &str,
581 route_base: &str,
582 all_params: &mut HashMap<String, String>,
583 cache: &mut CompiledRouteCache,
584) {
585 let mut current = children;
586 let mut base = route_base.to_string();
587
588 loop {
589 let Some((idx, rm)) = select_best_child_route(current, request_path, &base, cache) else {
590 break;
591 };
592 all_params.extend(rm.params);
593 let matched = ¤t[idx];
594 if matched.children.is_empty() {
595 break;
596 }
597 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
598 current = &matched.children;
599 base = child_base;
600 }
601}
602
603fn resolve_tag_templates(templates: &[String], params: &HashMap<String, String>) -> Vec<String> {
611 let mut resolved = Vec::with_capacity(templates.len());
612 for template in templates {
613 if !template.contains('{') {
614 resolved.push(template.clone());
615 continue;
616 }
617 let mut result = String::with_capacity(template.len());
618 let mut rest = template.as_str();
619 while let Some(open) = rest.find('{') {
620 result.push_str(&rest[..open]);
621 rest = &rest[open + 1..];
622 if let Some(close) = rest.find('}') {
623 let name = &rest[..close];
624 if let Some(value) = params.get(name) {
625 result.push_str(value);
626 } else {
627 result.push('{');
628 result.push_str(name);
629 result.push('}');
630 }
631 rest = &rest[close + 1..];
632 } else {
633 result.push('{');
634 }
635 }
636 result.push_str(rest);
637 resolved.push(result);
638 }
639 resolved
640}
641
642fn collect_inventory_and_chain(
646 protocol: &WebUIProtocol,
647 entry_id: &str,
648 request_path: &str,
649 index: &mut ProtocolIndex,
650) -> (HashSet<String>, Vec<RouteChainEntry>) {
651 let mut visited_fragments = HashSet::new();
652 let mut component_ids = HashSet::new();
653 let mut chain = Vec::new();
654 let mut stack = vec![QueuedFragment {
655 id: entry_id.to_string(),
656 inventoryable: false,
657 route_base: "/".to_string(),
658 }];
659
660 while let Some(queued) = stack.pop() {
661 if queued.id.is_empty() {
662 continue;
663 }
664
665 if queued.inventoryable {
666 component_ids.insert(queued.id.clone());
667 }
668
669 if !visited_fragments.insert(queued.id.clone()) {
670 continue;
671 }
672
673 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
674 continue;
675 };
676
677 let matched_route = route_renderer::find_best_route_match(
678 &frag_list.fragments,
679 request_path,
680 &queued.route_base,
681 &mut index.route_cache,
682 );
683
684 for frag in &frag_list.fragments {
685 match frag.fragment.as_ref() {
686 Some(Fragment::Component(component)) => {
687 stack.push(QueuedFragment {
689 id: component.fragment_id.clone(),
690 inventoryable: true,
691 route_base: queued.route_base.clone(),
692 });
693 }
694 Some(Fragment::ForLoop(for_loop)) => {
695 stack.push(QueuedFragment {
697 id: for_loop.fragment_id.clone(),
698 inventoryable: false,
699 route_base: queued.route_base.clone(),
700 });
701 }
702 Some(Fragment::IfCond(if_cond)) => {
703 stack.push(QueuedFragment {
704 id: if_cond.fragment_id.clone(),
705 inventoryable: false,
706 route_base: queued.route_base.clone(),
707 });
708 }
709 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
710 stack.push(QueuedFragment {
711 id: attr.template.clone(),
712 inventoryable: false,
713 route_base: queued.route_base.clone(),
714 });
715 }
716 Some(Fragment::Route(route_frag)) => {
717 let is_selected = matched_route
718 .as_ref()
719 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
720 if is_selected && !route_frag.fragment_id.is_empty() {
721 if let Some((_, ref rm)) = matched_route {
722 chain.push(RouteChainEntry {
724 component: route_frag.fragment_id.clone(),
725 path: route_frag.path.clone(),
726 params: rm.params.clone(),
727 exact: route_frag.exact,
728 allowed_query: route_frag.allowed_query.clone(),
729 keep_alive: route_frag.keep_alive,
730 cache_tags: route_frag.cache_tags.clone(),
731 invalidates: route_frag.invalidates.clone(),
732 pending_component: route_frag.pending_component.clone(),
733 error_component: route_frag.error_component.clone(),
734 });
735
736 let child_route_base = route_matcher::compute_route_base(
737 request_path,
738 rm.consumed_segments,
739 );
740
741 let is_inventoryable = protocol
743 .components
744 .get(&route_frag.fragment_id)
745 .is_some_and(|c| !c.template.is_empty());
746 stack.push(QueuedFragment {
747 id: route_frag.fragment_id.clone(),
748 inventoryable: is_inventoryable,
749 route_base: child_route_base.clone(),
750 });
751
752 if !route_frag.pending_component.is_empty() {
754 stack.push(QueuedFragment {
755 id: route_frag.pending_component.clone(),
756 inventoryable: protocol
757 .components
758 .get(&route_frag.pending_component)
759 .is_some_and(|c| !c.template.is_empty()),
760 route_base: child_route_base.clone(),
761 });
762 }
763 if !route_frag.error_component.is_empty() {
764 stack.push(QueuedFragment {
765 id: route_frag.error_component.clone(),
766 inventoryable: protocol
767 .components
768 .get(&route_frag.error_component)
769 .is_some_and(|c| !c.template.is_empty()),
770 route_base: child_route_base.clone(),
771 });
772 }
773
774 if !route_frag.children.is_empty() {
776 walk_children_for_inventory_and_chain(
777 &route_frag.children,
778 &child_route_base,
779 &mut stack,
780 &mut chain,
781 &mut ChildWalkCtx {
782 request_path,
783 protocol,
784 cache: &mut index.route_cache,
785 },
786 );
787 }
788 }
789 }
790 }
791 _ => {}
792 }
793 }
794 }
795
796 (component_ids, chain)
797}
798
799fn walk_children_for_inventory_and_chain(
801 children: &[WebUIFragmentRoute],
802 route_base: &str,
803 stack: &mut Vec<QueuedFragment>,
804 chain: &mut Vec<RouteChainEntry>,
805 ctx: &mut ChildWalkCtx<'_>,
806) {
807 let mut current = children;
808 let mut base = route_base.to_string();
809
810 loop {
811 let Some((idx, ref rm)) =
812 select_best_child_route(current, ctx.request_path, &base, ctx.cache)
813 else {
814 break;
815 };
816 let matched = ¤t[idx];
817 if matched.fragment_id.is_empty() {
818 break;
819 }
820
821 let child_base = route_matcher::compute_route_base(ctx.request_path, rm.consumed_segments);
822
823 chain.push(RouteChainEntry {
824 component: matched.fragment_id.clone(),
825 path: matched.path.clone(),
826 params: rm.params.clone(),
827 exact: matched.exact,
828 allowed_query: matched.allowed_query.clone(),
829 keep_alive: matched.keep_alive,
830 cache_tags: matched.cache_tags.clone(),
831 invalidates: matched.invalidates.clone(),
832 pending_component: matched.pending_component.clone(),
833 error_component: matched.error_component.clone(),
834 });
835
836 stack.push(QueuedFragment {
837 id: matched.fragment_id.clone(),
838 inventoryable: ctx
839 .protocol
840 .components
841 .get(&matched.fragment_id)
842 .is_some_and(|c| !c.template.is_empty()),
843 route_base: child_base.clone(),
844 });
845
846 if !matched.pending_component.is_empty() {
847 stack.push(QueuedFragment {
848 id: matched.pending_component.clone(),
849 inventoryable: ctx
850 .protocol
851 .components
852 .get(&matched.pending_component)
853 .is_some_and(|c| !c.template.is_empty()),
854 route_base: child_base.clone(),
855 });
856 }
857 if !matched.error_component.is_empty() {
858 stack.push(QueuedFragment {
859 id: matched.error_component.clone(),
860 inventoryable: ctx
861 .protocol
862 .components
863 .get(&matched.error_component)
864 .is_some_and(|c| !c.template.is_empty()),
865 route_base: child_base.clone(),
866 });
867 }
868
869 if matched.children.is_empty() {
870 break;
871 }
872 current = &matched.children;
873 base = child_base;
874 }
875}
876
877pub fn render_partial(
892 protocol: &WebUIProtocol,
893 entry_id: &str,
894 request_path: &str,
895 inventory_hex: &str,
896 index: &mut ProtocolIndex,
897) -> Result<Value, HandlerError> {
898 let (component_ids, mut chain) =
900 collect_inventory_and_chain(protocol, entry_id, request_path, index);
901
902 let (needed_names, updated_inv) =
903 filter_needed_components(&component_ids, inventory_hex, &index.component_index)?;
904
905 let mut accumulated_params: HashMap<String, String> = HashMap::new();
907 let mut all_resolved_tags: Vec<String> = Vec::new();
908
909 for entry in &mut chain {
910 for (k, v) in &entry.params {
911 accumulated_params.insert(k.clone(), v.clone());
912 }
913 entry.cache_tags = resolve_tag_templates(&entry.cache_tags, &accumulated_params);
914 entry.invalidates = resolve_tag_templates(&entry.invalidates, &accumulated_params);
915 all_resolved_tags.extend(entry.cache_tags.iter().cloned());
916 }
917
918 let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
919 let (style_array, tmpl_array) = collect_component_assets(protocol, &tag_refs);
920
921 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
922
923 let mut result = serde_json::Map::with_capacity(6);
924 result.insert("templateStyles".into(), Value::Array(style_array));
925 result.insert("templates".into(), Value::Array(tmpl_array));
926 result.insert("inventory".into(), Value::String(updated_inv));
927 result.insert("path".into(), Value::String(request_path.to_string()));
928 result.insert("chain".into(), chain_array);
929 if !all_resolved_tags.is_empty() {
930 let mut seen = HashSet::new();
931 let deduped: Vec<Value> = all_resolved_tags
932 .into_iter()
933 .filter(|t| seen.insert(t.clone()))
934 .map(Value::String)
935 .collect();
936 result.insert("cacheTags".into(), Value::Array(deduped));
937 }
938 Ok(Value::Object(result))
939}
940
941pub fn render_action_response(
952 protocol: &WebUIProtocol,
953 state: Value,
954 entry_id: &str,
955 request_path: &str,
956 index: &mut ProtocolIndex,
957) -> Value {
958 let chain = collect_route_chain(protocol, entry_id, request_path, &mut index.route_cache);
959
960 let mut accumulated_params: HashMap<String, String> = HashMap::new();
962 let mut all_invalidates: Vec<String> = Vec::new();
963
964 for entry in &chain {
965 for (k, v) in &entry.params {
966 accumulated_params.insert(k.clone(), v.clone());
967 }
968 let resolved = resolve_tag_templates(&entry.invalidates, &accumulated_params);
969 all_invalidates.extend(resolved);
970 }
971
972 let mut seen = HashSet::new();
974 let deduped: Vec<Value> = all_invalidates
975 .into_iter()
976 .filter(|t| seen.insert(t.clone()))
977 .map(Value::String)
978 .collect();
979
980 let mut result = serde_json::Map::with_capacity(3);
981 result.insert("state".into(), state);
982 result.insert("invalidateTags".into(), Value::Array(deduped));
983 result.insert("path".into(), Value::String(request_path.to_string()));
984 Value::Object(result)
985}
986
987pub fn render_component_templates(
996 protocol: &WebUIProtocol,
997 component_tags: &[&str],
998 inventory_hex: &str,
999 index: &ProtocolIndex,
1000) -> Result<Value, HandlerError> {
1001 let requested: HashSet<String> = component_tags.iter().map(|s| s.to_string()).collect();
1002 let (needed, updated_inv) =
1003 filter_needed_components(&requested, inventory_hex, &index.component_index)?;
1004
1005 let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
1006 let (style_array, tmpl_array) = collect_component_assets(protocol, &tag_refs);
1007
1008 let mut result = serde_json::Map::with_capacity(3);
1009 result.insert("templateStyles".into(), Value::Array(style_array));
1010 result.insert("templates".into(), Value::Array(tmpl_array));
1011 result.insert("inventory".into(), Value::String(updated_inv));
1012 Ok(Value::Object(result))
1013}
1014
1015fn collect_component_assets(protocol: &WebUIProtocol, tags: &[&str]) -> (Vec<Value>, Vec<Value>) {
1017 let mut style_array = Vec::new();
1018 let mut tmpl_array = Vec::new();
1019
1020 let mut sorted_tags: Vec<&str> = tags.to_vec();
1022 sorted_tags.sort_unstable();
1023
1024 for tag in sorted_tags {
1025 let Some(component) = protocol.components.get(tag) else {
1026 continue;
1027 };
1028 if component.template.is_empty() {
1029 continue;
1030 }
1031 if !component.css.is_empty() {
1032 let tag_html = crate::css_module::build_importmap_tag(tag, &component.css, None);
1036 style_array.push(Value::String(tag_html));
1037 }
1038 tmpl_array.push(Value::String(component.template.clone()));
1039 }
1040
1041 (style_array, tmpl_array)
1042}
1043
1044#[derive(Debug, Clone)]
1048pub struct RouteChainEntry {
1049 pub component: String,
1051 pub path: String,
1053 pub params: HashMap<String, String>,
1055 pub exact: bool,
1057 pub allowed_query: String,
1059 pub keep_alive: bool,
1061 pub cache_tags: Vec<String>,
1063 pub invalidates: Vec<String>,
1065 pub pending_component: String,
1067 pub error_component: String,
1069}
1070
1071impl RouteChainEntry {
1072 #[must_use]
1074 pub fn to_json(&self) -> Value {
1075 let mut obj = serde_json::Map::with_capacity(9);
1076 obj.insert("component".into(), Value::String(self.component.clone()));
1077 obj.insert("path".into(), Value::String(self.path.clone()));
1078 if !self.params.is_empty() {
1079 let params_obj: serde_json::Map<String, Value> = self
1080 .params
1081 .iter()
1082 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
1083 .collect();
1084 obj.insert("params".into(), Value::Object(params_obj));
1085 }
1086 if self.exact {
1087 obj.insert("exact".into(), Value::Bool(true));
1088 }
1089 if !self.allowed_query.is_empty() {
1090 obj.insert(
1091 "allowedQuery".into(),
1092 Value::String(self.allowed_query.clone()),
1093 );
1094 }
1095 if self.keep_alive {
1096 obj.insert("keepAlive".into(), Value::Bool(true));
1097 }
1098 if !self.pending_component.is_empty() {
1099 obj.insert(
1100 "pendingComponent".into(),
1101 Value::String(self.pending_component.clone()),
1102 );
1103 }
1104 if !self.error_component.is_empty() {
1105 obj.insert(
1106 "errorComponent".into(),
1107 Value::String(self.error_component.clone()),
1108 );
1109 }
1110 if !self.invalidates.is_empty() {
1111 obj.insert(
1112 "invalidates".into(),
1113 Value::Array(
1114 self.invalidates
1115 .iter()
1116 .map(|s| Value::String(s.clone()))
1117 .collect(),
1118 ),
1119 );
1120 }
1121 Value::Object(obj)
1122 }
1123}
1124
1125pub fn collect_route_chain(
1130 protocol: &WebUIProtocol,
1131 entry_id: &str,
1132 request_path: &str,
1133 cache: &mut CompiledRouteCache,
1134) -> Vec<RouteChainEntry> {
1135 let mut chain = Vec::new();
1136 let mut visited_fragments = HashSet::new();
1137 let mut stack = vec![QueuedFragment {
1138 id: entry_id.to_string(),
1139 inventoryable: false,
1140 route_base: "/".to_string(),
1141 }];
1142
1143 while let Some(queued) = stack.pop() {
1144 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
1145 continue;
1146 }
1147
1148 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1149 continue;
1150 };
1151
1152 let matched_route = route_renderer::find_best_route_match(
1153 &frag_list.fragments,
1154 request_path,
1155 &queued.route_base,
1156 cache,
1157 );
1158
1159 for frag in &frag_list.fragments {
1160 match frag.fragment.as_ref() {
1161 Some(Fragment::Component(component)) => {
1162 stack.push(QueuedFragment {
1163 id: component.fragment_id.clone(),
1164 inventoryable: false,
1165 route_base: queued.route_base.clone(),
1166 });
1167 }
1168 Some(Fragment::Route(route_frag)) => {
1169 let is_selected = matched_route
1170 .as_ref()
1171 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1172 if is_selected && !route_frag.fragment_id.is_empty() {
1173 if let Some((_, ref rm)) = matched_route {
1174 chain.push(RouteChainEntry {
1175 component: route_frag.fragment_id.clone(),
1176 path: route_frag.path.clone(),
1177 params: rm.params.clone(),
1178 exact: route_frag.exact,
1179 allowed_query: route_frag.allowed_query.clone(),
1180 keep_alive: route_frag.keep_alive,
1181 cache_tags: route_frag.cache_tags.clone(),
1182 invalidates: route_frag.invalidates.clone(),
1183 pending_component: route_frag.pending_component.clone(),
1184 error_component: route_frag.error_component.clone(),
1185 });
1186
1187 let child_route_base = route_matcher::compute_route_base(
1188 request_path,
1189 rm.consumed_segments,
1190 );
1191
1192 stack.push(QueuedFragment {
1193 id: route_frag.fragment_id.clone(),
1194 inventoryable: false,
1195 route_base: child_route_base.clone(),
1196 });
1197
1198 collect_chain_from_children(
1200 &route_frag.children,
1201 request_path,
1202 &child_route_base,
1203 &mut chain,
1204 cache,
1205 );
1206 }
1207 }
1208 }
1209 _ => {}
1210 }
1211 }
1212 }
1213
1214 chain
1215}
1216
1217fn collect_chain_from_children(
1219 children: &[WebUIFragmentRoute],
1220 request_path: &str,
1221 route_base: &str,
1222 chain: &mut Vec<RouteChainEntry>,
1223 cache: &mut CompiledRouteCache,
1224) {
1225 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
1226 vec![(children, route_base.to_string())];
1227
1228 while let Some((current, base)) = pending.pop() {
1229 if let Some((idx, rm)) = select_best_child_route(current, request_path, &base, cache) {
1230 let matched = ¤t[idx];
1231 chain.push(RouteChainEntry {
1232 component: matched.fragment_id.clone(),
1233 path: matched.path.clone(),
1234 params: rm.params,
1235 exact: matched.exact,
1236 allowed_query: matched.allowed_query.clone(),
1237 keep_alive: matched.keep_alive,
1238 cache_tags: matched.cache_tags.clone(),
1239 invalidates: matched.invalidates.clone(),
1240 pending_component: matched.pending_component.clone(),
1241 error_component: matched.error_component.clone(),
1242 });
1243 if !matched.children.is_empty() {
1244 let child_base =
1245 route_matcher::compute_route_base(request_path, rm.consumed_segments);
1246 pending.push((&matched.children, child_base));
1247 }
1248 }
1249 }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255 use std::collections::HashMap;
1256 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
1257
1258 #[test]
1259 fn test_parse_encode_inventory_roundtrip() {
1260 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
1261 let hex = encode_inventory(&original);
1262 let decoded = parse_inventory(&hex).unwrap();
1263 assert_eq!(original, decoded);
1264 }
1265
1266 #[test]
1267 fn test_parse_inventory_empty() {
1268 let result = parse_inventory("").unwrap();
1269 assert!(result.is_empty());
1270 }
1271
1272 #[test]
1273 fn test_parse_inventory_rejects_odd_length() {
1274 let result = parse_inventory("abc");
1275 assert!(result.is_err());
1276 }
1277
1278 #[test]
1279 fn test_parse_inventory_rejects_invalid_hex() {
1280 let result = parse_inventory("zz");
1281 assert!(result.is_err());
1282 }
1283
1284 #[test]
1285 fn test_bitfield_set_and_check() {
1286 let mut inv = vec![0u8; 4];
1287 set_component(&mut inv, 0);
1288 set_component(&mut inv, 7);
1289 set_component(&mut inv, 8);
1290 set_component(&mut inv, 15);
1291 assert!(has_component(&inv, 0));
1292 assert!(has_component(&inv, 7));
1293 assert!(has_component(&inv, 8));
1294 assert!(has_component(&inv, 15));
1295 assert!(!has_component(&inv, 1));
1296 assert!(!has_component(&inv, 9));
1297 }
1298
1299 #[test]
1300 fn test_filter_needed_components_no_false_positives() {
1301 let mut index = HashMap::new();
1303 index.insert("email-message".to_string(), 0);
1304 index.insert("o-button".to_string(), 1);
1305 index.insert("o-avatar".to_string(), 2);
1306
1307 let mut names = HashSet::new();
1308 names.insert("email-message".to_string());
1309 names.insert("o-button".to_string());
1310
1311 let mut inv = vec![0u8; 1];
1313 set_component(&mut inv, 1); let inv_hex = encode_inventory(&inv);
1315
1316 let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
1317 assert_eq!(needed.len(), 1);
1318 assert!(
1319 needed.contains(&"email-message".to_string()),
1320 "email-message must be needed: {needed:?}"
1321 );
1322 }
1323
1324 #[test]
1325 fn test_get_needed_components_empty_inventory() {
1326 let mut fragments = HashMap::new();
1327 fragments.insert(
1328 "app-shell".to_string(),
1329 FragmentList {
1330 fragments: vec![WebUIFragment::component("my-card")],
1331 },
1332 );
1333 fragments.insert(
1334 "my-card".to_string(),
1335 FragmentList {
1336 fragments: vec![WebUIFragment::raw("card")],
1337 },
1338 );
1339
1340 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1341 let comp_index = build_component_index(&protocol);
1342 let (needed, _inv) =
1343 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1344 assert!(needed.contains(&"app-shell".to_string()));
1345 assert!(needed.contains(&"my-card".to_string()));
1346 }
1347
1348 #[test]
1349 fn test_get_needed_components_skips_inventory() {
1350 let mut fragments = HashMap::new();
1351 fragments.insert(
1352 "app-shell".to_string(),
1353 FragmentList {
1354 fragments: vec![WebUIFragment::component("my-card")],
1355 },
1356 );
1357 fragments.insert(
1358 "my-card".to_string(),
1359 FragmentList {
1360 fragments: vec![WebUIFragment::raw("card")],
1361 },
1362 );
1363
1364 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1365 protocol
1366 .components
1367 .entry("app-shell".to_string())
1368 .or_default()
1369 .template = "<t></t>".to_string();
1370 protocol
1371 .components
1372 .entry("my-card".to_string())
1373 .or_default()
1374 .template = "<t></t>".to_string();
1375
1376 let comp_index = build_component_index(&protocol);
1377 let (_needed, inv_hex) =
1378 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1379 let (needed2, _) =
1380 get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
1381 assert!(needed2.is_empty());
1382 }
1383
1384 #[test]
1385 fn test_get_needed_components_skips_control_fragments() {
1386 let mut fragments = HashMap::new();
1387 fragments.insert(
1388 "app-shell".to_string(),
1389 FragmentList {
1390 fragments: vec![
1391 WebUIFragment::if_cond(
1392 webui_protocol::ConditionExpr::identifier("showNav"),
1393 "if-shell",
1394 ),
1395 WebUIFragment::for_loop("item", "items", "for-items"),
1396 WebUIFragment::attribute_template("title", "attr-title"),
1397 ],
1398 },
1399 );
1400 fragments.insert(
1401 "if-shell".to_string(),
1402 FragmentList {
1403 fragments: vec![WebUIFragment::component("mp-category-nav")],
1404 },
1405 );
1406 fragments.insert(
1407 "for-items".to_string(),
1408 FragmentList {
1409 fragments: vec![WebUIFragment::component("mp-product-card")],
1410 },
1411 );
1412 fragments.insert(
1413 "attr-title".to_string(),
1414 FragmentList {
1415 fragments: vec![WebUIFragment::raw("Products")],
1416 },
1417 );
1418 fragments.insert(
1419 "mp-category-nav".to_string(),
1420 FragmentList {
1421 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1422 },
1423 );
1424 fragments.insert(
1425 "mp-product-card".to_string(),
1426 FragmentList {
1427 fragments: vec![WebUIFragment::raw("<article></article>")],
1428 },
1429 );
1430
1431 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1432 let comp_index = build_component_index(&protocol);
1433 let needed: HashSet<String> =
1434 get_needed_components(&protocol, "app-shell", "", &comp_index)
1435 .unwrap()
1436 .0
1437 .into_iter()
1438 .collect();
1439
1440 assert!(needed.contains("app-shell"));
1441 assert!(needed.contains("mp-category-nav"));
1442 assert!(needed.contains("mp-product-card"));
1443 assert!(!needed.contains("if-shell"));
1444 assert!(!needed.contains("for-items"));
1445 assert!(!needed.contains("attr-title"));
1446 }
1447
1448 #[test]
1449 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
1450 let mut fragments = HashMap::new();
1451 fragments.insert(
1452 "index.html".to_string(),
1453 FragmentList {
1454 fragments: vec![WebUIFragment::component("mp-app")],
1455 },
1456 );
1457 fragments.insert(
1458 "mp-app".to_string(),
1459 FragmentList {
1460 fragments: vec![
1461 WebUIFragment::component("mp-category-nav"),
1462 WebUIFragment::route_from(WebUiFragmentRoute {
1463 path: "/search/:category".into(),
1464 fragment_id: "mp-search-page".into(),
1465 exact: true,
1466 keep_alive: false,
1467 ..Default::default()
1468 }),
1469 WebUIFragment::route_from(WebUiFragmentRoute {
1470 path: "/product/:handle".into(),
1471 fragment_id: "mp-product-page".into(),
1472 exact: true,
1473 keep_alive: false,
1474 ..Default::default()
1475 }),
1476 ],
1477 },
1478 );
1479 fragments.insert(
1480 "mp-category-nav".to_string(),
1481 FragmentList {
1482 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1483 },
1484 );
1485 fragments.insert(
1486 "mp-search-page".to_string(),
1487 FragmentList {
1488 fragments: vec![WebUIFragment::component("mp-product-grid")],
1489 },
1490 );
1491 fragments.insert(
1492 "mp-product-grid".to_string(),
1493 FragmentList {
1494 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
1495 },
1496 );
1497 fragments.insert(
1498 "mp-product-page".to_string(),
1499 FragmentList {
1500 fragments: vec![WebUIFragment::component("mp-product-detail")],
1501 },
1502 );
1503 fragments.insert(
1504 "mp-product-detail".to_string(),
1505 FragmentList {
1506 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
1507 },
1508 );
1509
1510 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1511 protocol
1512 .components
1513 .entry("mp-search-page".to_string())
1514 .or_default()
1515 .template = "<mp-search-page></mp-search-page>".to_string();
1516 protocol
1517 .components
1518 .entry("mp-product-page".to_string())
1519 .or_default()
1520 .template = "<mp-product-page></mp-product-page>".to_string();
1521
1522 let comp_index = build_component_index(&protocol);
1523 let (needed, _inv) = get_needed_components_for_request(
1524 &protocol,
1525 "index.html",
1526 "/search/shirts",
1527 "",
1528 &comp_index,
1529 )
1530 .unwrap();
1531 let needed: HashSet<String> = needed.into_iter().collect();
1532
1533 assert!(needed.contains("mp-app"));
1534 assert!(needed.contains("mp-category-nav"));
1535 assert!(needed.contains("mp-search-page"));
1536 assert!(needed.contains("mp-product-grid"));
1537 assert!(!needed.contains("mp-product-page"));
1538 assert!(!needed.contains("mp-product-detail"));
1539 }
1540
1541 #[test]
1542 fn test_get_needed_components_for_request_follows_nested_route_chain() {
1543 let mut fragments = HashMap::new();
1544 fragments.insert(
1545 "index.html".to_string(),
1546 FragmentList {
1547 fragments: vec![WebUIFragment::component("mp-app")],
1548 },
1549 );
1550 fragments.insert(
1551 "mp-app".to_string(),
1552 FragmentList {
1553 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1554 path: "/account/*rest".into(),
1555 fragment_id: "mp-account-shell".into(),
1556 exact: false,
1557 keep_alive: false,
1558 ..Default::default()
1559 })],
1560 },
1561 );
1562 fragments.insert(
1563 "mp-account-shell".to_string(),
1564 FragmentList {
1565 fragments: vec![
1566 WebUIFragment::component("mp-account-nav"),
1567 WebUIFragment::route_from(WebUiFragmentRoute {
1568 path: "/account/profile".into(),
1569 fragment_id: "mp-profile-page".into(),
1570 exact: true,
1571 keep_alive: false,
1572 ..Default::default()
1573 }),
1574 WebUIFragment::route_from(WebUiFragmentRoute {
1575 path: "/account/orders/:id".into(),
1576 fragment_id: "mp-order-page".into(),
1577 exact: true,
1578 keep_alive: false,
1579 ..Default::default()
1580 }),
1581 ],
1582 },
1583 );
1584 fragments.insert(
1585 "mp-account-nav".to_string(),
1586 FragmentList {
1587 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1588 },
1589 );
1590 fragments.insert(
1591 "mp-profile-page".to_string(),
1592 FragmentList {
1593 fragments: vec![WebUIFragment::raw("<profile></profile>")],
1594 },
1595 );
1596 fragments.insert(
1597 "mp-order-page".to_string(),
1598 FragmentList {
1599 fragments: vec![WebUIFragment::component("mp-order-detail")],
1600 },
1601 );
1602 fragments.insert(
1603 "mp-order-detail".to_string(),
1604 FragmentList {
1605 fragments: vec![WebUIFragment::raw("<detail></detail>")],
1606 },
1607 );
1608
1609 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1610 protocol
1611 .components
1612 .entry("mp-account-shell".to_string())
1613 .or_default()
1614 .template = "<mp-account-shell></mp-account-shell>".to_string();
1615 protocol
1616 .components
1617 .entry("mp-profile-page".to_string())
1618 .or_default()
1619 .template = "<mp-profile-page></mp-profile-page>".to_string();
1620 protocol
1621 .components
1622 .entry("mp-order-page".to_string())
1623 .or_default()
1624 .template = "<mp-order-page></mp-order-page>".to_string();
1625
1626 let comp_index = build_component_index(&protocol);
1627 let (needed, _inv) = get_needed_components_for_request(
1628 &protocol,
1629 "index.html",
1630 "/account/orders/42",
1631 "",
1632 &comp_index,
1633 )
1634 .unwrap();
1635 let needed: HashSet<String> = needed.into_iter().collect();
1636
1637 assert!(needed.contains("mp-app"));
1638 assert!(needed.contains("mp-account-shell"));
1639 assert!(needed.contains("mp-account-nav"));
1640 assert!(needed.contains("mp-order-page"));
1641 assert!(needed.contains("mp-order-detail"));
1642 assert!(!needed.contains("mp-profile-page"));
1643 }
1644
1645 #[test]
1646 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1647 let mut fragments = HashMap::new();
1648 fragments.insert(
1649 "index.html".to_string(),
1650 FragmentList {
1651 fragments: vec![WebUIFragment::component("mp-app")],
1652 },
1653 );
1654 fragments.insert(
1655 "mp-app".to_string(),
1656 FragmentList {
1657 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1658 path: "/search".into(),
1659 fragment_id: "mp-search-page".into(),
1660 exact: true,
1661 keep_alive: false,
1662 ..Default::default()
1663 })],
1664 },
1665 );
1666 fragments.insert(
1667 "mp-search-page".to_string(),
1668 FragmentList {
1669 fragments: vec![
1670 WebUIFragment::if_cond(
1671 webui_protocol::ConditionExpr::identifier("showFilters"),
1672 "if-filters",
1673 ),
1674 WebUIFragment::for_loop("item", "items", "item-loop"),
1675 ],
1676 },
1677 );
1678 fragments.insert(
1679 "if-filters".to_string(),
1680 FragmentList {
1681 fragments: vec![WebUIFragment::component("mp-filter-panel")],
1682 },
1683 );
1684 fragments.insert(
1685 "item-loop".to_string(),
1686 FragmentList {
1687 fragments: vec![WebUIFragment::component("mp-item-card")],
1688 },
1689 );
1690 fragments.insert(
1691 "mp-filter-panel".to_string(),
1692 FragmentList {
1693 fragments: vec![WebUIFragment::raw("<filters></filters>")],
1694 },
1695 );
1696 fragments.insert(
1697 "mp-item-card".to_string(),
1698 FragmentList {
1699 fragments: vec![WebUIFragment::raw("<item></item>")],
1700 },
1701 );
1702
1703 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1704 protocol
1705 .components
1706 .entry("mp-search-page".to_string())
1707 .or_default()
1708 .template = "<mp-search-page></mp-search-page>".to_string();
1709
1710 let comp_index = build_component_index(&protocol);
1711 let (needed, _inv) =
1712 get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1713 .unwrap();
1714 let needed: HashSet<String> = needed.into_iter().collect();
1715
1716 assert!(needed.contains("mp-app"));
1717 assert!(needed.contains("mp-search-page"));
1718 assert!(needed.contains("mp-filter-panel"));
1719 assert!(needed.contains("mp-item-card"));
1720 }
1721
1722 #[test]
1723 fn test_get_needed_components_for_request_respects_inventory() {
1724 let mut fragments = HashMap::new();
1725 fragments.insert(
1726 "index.html".to_string(),
1727 FragmentList {
1728 fragments: vec![WebUIFragment::component("mp-app")],
1729 },
1730 );
1731 fragments.insert(
1732 "mp-app".to_string(),
1733 FragmentList {
1734 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1735 path: "/search".into(),
1736 fragment_id: "mp-search-page".into(),
1737 exact: true,
1738 keep_alive: false,
1739 ..Default::default()
1740 })],
1741 },
1742 );
1743 fragments.insert(
1744 "mp-search-page".to_string(),
1745 FragmentList {
1746 fragments: vec![WebUIFragment::component("mp-product-grid")],
1747 },
1748 );
1749 fragments.insert(
1750 "mp-product-grid".to_string(),
1751 FragmentList {
1752 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1753 },
1754 );
1755
1756 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1757 for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
1758 protocol
1759 .components
1760 .entry(name.to_string())
1761 .or_default()
1762 .template = format!("<f-template id=\"{name}\"></f-template>");
1763 }
1764
1765 let comp_index = build_component_index(&protocol);
1766 let (_needed, inventory) =
1767 get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1768 .unwrap();
1769 let (needed_again, _) = get_needed_components_for_request(
1770 &protocol,
1771 "index.html",
1772 "/search",
1773 &inventory,
1774 &comp_index,
1775 )
1776 .unwrap();
1777 assert!(needed_again.is_empty());
1778 }
1779
1780 #[test]
1781 fn test_render_partial_separates_module_styles_from_templates() {
1782 let mut fragments = HashMap::new();
1783 fragments.insert(
1784 "index.html".to_string(),
1785 FragmentList {
1786 fragments: vec![WebUIFragment::component("my-page")],
1787 },
1788 );
1789 fragments.insert(
1790 "my-page".to_string(),
1791 FragmentList {
1792 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1793 },
1794 );
1795
1796 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1797 let component = protocol
1798 .components
1799 .entry("my-page".to_string())
1800 .or_default();
1801 component.template =
1802 "(function(){window.__webui.templates['my-page']={h:'<p>page</p>'};})();".to_string();
1803 component.css = ".page{color:red}".to_string();
1804
1805 let mut index = ProtocolIndex::new(&protocol);
1806 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1807 let styles = partial["templateStyles"]
1808 .as_array()
1809 .expect("templateStyles should be an array");
1810 assert_eq!(styles.len(), 1);
1811 let style_html = styles[0].as_str().unwrap_or_default();
1812 assert!(
1813 style_html.starts_with(r#"<script type="importmap""#)
1814 && style_html.contains(r#""my-page":"data:text/css,"#),
1815 "module style entry should be an importmap registering the component specifier: {style_html}"
1816 );
1817 assert!(
1818 style_html.contains(".page{color:red}"),
1819 "module style entry should contain the CSS content verbatim inside the data: URI: {style_html}"
1820 );
1821
1822 let templates = partial["templates"]
1824 .as_array()
1825 .expect("templates should be an array");
1826 assert_eq!(templates.len(), 1);
1827 assert!(
1828 !templates[0].as_str().unwrap_or_default().contains("<style"),
1829 "template should not contain any style tags"
1830 );
1831 assert!(
1832 templates[0]
1833 .as_str()
1834 .unwrap_or_default()
1835 .starts_with("(function()"),
1836 "template should be a raw JS IIFE"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_render_partial_link_strategy_has_empty_template_styles() {
1842 let mut fragments = HashMap::new();
1845 fragments.insert(
1846 "index.html".to_string(),
1847 FragmentList {
1848 fragments: vec![WebUIFragment::component("my-page")],
1849 },
1850 );
1851 fragments.insert(
1852 "my-page".to_string(),
1853 FragmentList {
1854 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1855 },
1856 );
1857
1858 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1859 let component = protocol
1860 .components
1861 .entry("my-page".to_string())
1862 .or_default();
1863 component.template = "(function(){})();".to_string();
1864 component.css_href = "my-page.css".to_string();
1865 let mut index = ProtocolIndex::new(&protocol);
1868 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1869 let styles = partial["templateStyles"]
1870 .as_array()
1871 .expect("templateStyles should be an array");
1872 let templates = partial["templates"]
1873 .as_array()
1874 .expect("templates should be an array");
1875
1876 assert!(
1877 styles.is_empty(),
1878 "Link strategy should produce empty templateStyles: {styles:?}"
1879 );
1880 assert_eq!(templates.len(), 1, "should include the template IIFE");
1881 }
1882
1883 #[test]
1884 fn test_render_partial_style_strategy_has_empty_template_styles() {
1885 let mut fragments = HashMap::new();
1889 fragments.insert(
1890 "index.html".to_string(),
1891 FragmentList {
1892 fragments: vec![WebUIFragment::component("my-page")],
1893 },
1894 );
1895 fragments.insert(
1896 "my-page".to_string(),
1897 FragmentList {
1898 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1899 },
1900 );
1901
1902 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1903 let component = protocol
1904 .components
1905 .entry("my-page".to_string())
1906 .or_default();
1907 component.template =
1909 "(function(){var w=(window.__webui||(window.__webui={})).templates||(window.__webui.templates={});w['my-page']={h:'<style>.p{color:red}</style><p>page</p>'};})();"
1910 .to_string();
1911 let mut index = ProtocolIndex::new(&protocol);
1914 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1915 let styles = partial["templateStyles"]
1916 .as_array()
1917 .expect("templateStyles should be an array");
1918 let templates = partial["templates"]
1919 .as_array()
1920 .expect("templates should be an array");
1921
1922 assert!(
1923 styles.is_empty(),
1924 "Style strategy should produce empty templateStyles: {styles:?}"
1925 );
1926 assert_eq!(templates.len(), 1, "should include the template IIFE");
1927 assert!(
1928 templates[0]
1929 .as_str()
1930 .unwrap_or_default()
1931 .contains("<style>"),
1932 "Style strategy template should contain inline <style> tag"
1933 );
1934 }
1935
1936 #[test]
1937 fn test_render_partial_empty_styles_for_no_css_components() {
1938 let mut fragments = HashMap::new();
1939 fragments.insert(
1940 "index.html".to_string(),
1941 FragmentList {
1942 fragments: vec![WebUIFragment::component("my-page")],
1943 },
1944 );
1945 fragments.insert(
1946 "my-page".to_string(),
1947 FragmentList {
1948 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1949 },
1950 );
1951
1952 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1953 let component = protocol
1954 .components
1955 .entry("my-page".to_string())
1956 .or_default();
1957 component.template = "(function(){})();".to_string();
1958 let mut index = ProtocolIndex::new(&protocol);
1961 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1962 let styles = partial["templateStyles"]
1963 .as_array()
1964 .expect("templateStyles should be an array");
1965 assert!(
1966 styles.is_empty(),
1967 "templateStyles should be empty when components have no CSS"
1968 );
1969 }
1970
1971 #[test]
1972 fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
1973 let mut fragments = HashMap::new();
1974 fragments.insert(
1975 "index.html".to_string(),
1976 FragmentList {
1977 fragments: vec![WebUIFragment::component("my-page")],
1978 },
1979 );
1980 fragments.insert(
1981 "my-page".to_string(),
1982 FragmentList {
1983 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1984 },
1985 );
1986
1987 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1988 let component = protocol
1989 .components
1990 .entry("my-page".to_string())
1991 .or_default();
1992 component.template = "(function(){})();".to_string();
1993 component.css = ".page{color:red}".to_string();
1994
1995 let mut index = ProtocolIndex::new(&protocol);
1996 let partial1 = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1998 let inv = partial1["inventory"].as_str().unwrap_or_default();
1999 assert!(!inv.is_empty());
2000
2001 let partial2 = render_partial(&protocol, "index.html", "/", inv, &mut index).unwrap();
2006 let styles = partial2["templateStyles"]
2007 .as_array()
2008 .expect("templateStyles should be an array");
2009 let templates = partial2["templates"]
2010 .as_array()
2011 .expect("templates should be an array");
2012
2013 assert!(
2014 templates.is_empty(),
2015 "templates should be empty when inventory is full"
2016 );
2017 assert!(
2018 styles.is_empty(),
2019 "module styles should be empty when inventory is full — SSR already placed them"
2020 );
2021 }
2022
2023 #[test]
2024 fn test_non_route_siblings_included_in_needed_components() {
2025 let mut fragments = HashMap::new();
2030
2031 fragments.insert(
2033 "index.html".to_string(),
2034 FragmentList {
2035 fragments: vec![WebUIFragment::component("app-shell")],
2036 },
2037 );
2038
2039 fragments.insert(
2041 "app-shell".to_string(),
2042 FragmentList {
2043 fragments: vec![
2044 WebUIFragment::component("my-navbar"),
2045 WebUIFragment::route_from(WebUiFragmentRoute {
2046 path: "/about".into(),
2047 fragment_id: "page-about".into(),
2048 exact: true,
2049 keep_alive: false,
2050 ..Default::default()
2051 }),
2052 WebUIFragment::component("cart-panel"),
2053 ],
2054 },
2055 );
2056
2057 fragments.insert(
2059 "my-navbar".to_string(),
2060 FragmentList {
2061 fragments: vec![WebUIFragment::raw("<nav/>")],
2062 },
2063 );
2064 fragments.insert(
2065 "page-about".to_string(),
2066 FragmentList {
2067 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
2068 },
2069 );
2070 fragments.insert(
2071 "cart-panel".to_string(),
2072 FragmentList {
2073 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
2074 },
2075 );
2076
2077 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2078 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
2079 let comp = protocol.components.entry(name.to_string()).or_default();
2080 comp.template = format!("(function(){{/* {name} */}})();");
2081 comp.css = format!(".{name}{{display:block}}");
2082 }
2083
2084 let comp_index = build_component_index(&protocol);
2085 let (needed, _inv) =
2086 get_needed_components_for_request(&protocol, "index.html", "/about", "", &comp_index)
2087 .unwrap();
2088
2089 assert!(
2090 needed.contains(&"app-shell".to_string()),
2091 "app-shell should be needed: {needed:?}"
2092 );
2093 assert!(
2094 needed.contains(&"my-navbar".to_string()),
2095 "my-navbar (non-route sibling) should be needed: {needed:?}"
2096 );
2097 assert!(
2098 needed.contains(&"page-about".to_string()),
2099 "page-about (active route) should be needed: {needed:?}"
2100 );
2101 assert!(
2102 needed.contains(&"cart-panel".to_string()),
2103 "cart-panel (non-route sibling) should be needed: {needed:?}"
2104 );
2105 }
2106
2107 #[test]
2110 fn test_chain_entry_to_json_includes_allowed_query() {
2111 let entry = RouteChainEntry {
2112 component: "compose-page".into(),
2113 path: "compose".into(),
2114 params: HashMap::new(),
2115 exact: true,
2116 allowed_query: "action,to,subject".into(),
2117 keep_alive: false,
2118 cache_tags: Vec::new(),
2119 invalidates: Vec::new(),
2120 pending_component: String::new(),
2121 error_component: String::new(),
2122 };
2123 let json = entry.to_json();
2124 assert_eq!(json["allowedQuery"], "action,to,subject");
2125 }
2126
2127 #[test]
2128 fn test_chain_entry_to_json_omits_empty_allowed_query() {
2129 let entry = RouteChainEntry {
2130 component: "home-page".into(),
2131 path: "/".into(),
2132 params: HashMap::new(),
2133 exact: true,
2134 allowed_query: String::new(),
2135 keep_alive: false,
2136 cache_tags: Vec::new(),
2137 invalidates: Vec::new(),
2138 pending_component: String::new(),
2139 error_component: String::new(),
2140 };
2141 let json = entry.to_json();
2142 assert!(
2143 json.get("allowedQuery").is_none(),
2144 "empty allowed_query should not appear in JSON"
2145 );
2146 }
2147
2148 #[test]
2149 fn test_collect_route_chain_carries_allowed_query() {
2150 let mut fragments = HashMap::new();
2151 fragments.insert(
2152 "index.html".to_string(),
2153 FragmentList {
2154 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2155 path: "/".into(),
2156 fragment_id: "app-shell".into(),
2157 exact: false,
2158 children: vec![WebUiFragmentRoute {
2159 path: "compose".into(),
2160 fragment_id: "compose-page".into(),
2161 exact: true,
2162 allowed_query: "action,to".into(),
2163 keep_alive: false,
2164 ..Default::default()
2165 }],
2166 keep_alive: false,
2167 ..Default::default()
2168 })],
2169 },
2170 );
2171 fragments.insert(
2172 "app-shell".to_string(),
2173 FragmentList {
2174 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2175 },
2176 );
2177 fragments.insert(
2178 "compose-page".to_string(),
2179 FragmentList {
2180 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2181 },
2182 );
2183 let protocol = WebUIProtocol::new(fragments);
2184
2185 let chain = collect_route_chain(
2186 &protocol,
2187 "index.html",
2188 "/compose",
2189 &mut CompiledRouteCache::new(),
2190 );
2191 assert_eq!(chain.len(), 2);
2192 assert_eq!(chain[0].component, "app-shell");
2193 assert!(chain[0].allowed_query.is_empty());
2194 assert_eq!(chain[1].component, "compose-page");
2195 assert_eq!(chain[1].allowed_query, "action,to");
2196 }
2197
2198 #[test]
2199 fn test_render_component_templates_returns_template_and_css() {
2200 let mut fragments = HashMap::new();
2201 fragments.insert(
2202 "settings-dialog".to_string(),
2203 FragmentList {
2204 fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
2205 },
2206 );
2207 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2208 let comp = protocol
2209 .components
2210 .entry("settings-dialog".to_string())
2211 .or_default();
2212 comp.template = "(function(){window.__webui.templates['settings-dialog']={h:'<div>Settings</div>'};})();".to_string();
2213 comp.css = ".dialog{position:fixed}".to_string();
2214
2215 let index = ProtocolIndex::new(&protocol);
2216 let result =
2217 render_component_templates(&protocol, &["settings-dialog"], "", &index).unwrap();
2218 let templates = result["templates"].as_array().expect("templates array");
2219 let styles = result["templateStyles"].as_array().expect("styles array");
2220
2221 assert_eq!(templates.len(), 1);
2222 assert!(templates[0].as_str().unwrap().contains("settings-dialog"));
2223 assert_eq!(styles.len(), 1);
2224 let style_html = styles[0].as_str().unwrap();
2225 assert!(
2226 style_html.starts_with(r#"<script type="importmap""#)
2227 && style_html.contains(r#""settings-dialog":"data:text/css,"#),
2228 "templateStyles entry should be an importmap registering settings-dialog: {style_html}"
2229 );
2230 assert!(
2233 style_html.contains(".dialog{position:fixed}"),
2234 "templateStyles entry should contain the CSS content verbatim: {style_html}"
2235 );
2236 }
2237
2238 #[test]
2239 fn test_render_component_templates_respects_inventory() {
2240 let mut fragments = HashMap::new();
2241 fragments.insert(
2242 "my-dialog".to_string(),
2243 FragmentList {
2244 fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
2245 },
2246 );
2247 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2248 let comp = protocol
2249 .components
2250 .entry("my-dialog".to_string())
2251 .or_default();
2252 comp.template = "(function(){})();".to_string();
2253 comp.css = ".d{color:red}".to_string();
2254
2255 let index = ProtocolIndex::new(&protocol);
2256 let result1 = render_component_templates(&protocol, &["my-dialog"], "", &index).unwrap();
2258 let inv = result1["inventory"].as_str().expect("inventory string");
2259 assert_eq!(result1["templates"].as_array().unwrap().len(), 1);
2260
2261 let result2 = render_component_templates(&protocol, &["my-dialog"], inv, &index).unwrap();
2263 assert_eq!(result2["templates"].as_array().unwrap().len(), 0);
2264 assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
2265 }
2266
2267 #[test]
2268 fn test_render_component_templates_unknown_component_returns_empty() {
2269 let fragments = HashMap::new();
2270 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2271
2272 let index = ProtocolIndex::new(&protocol);
2273 let result =
2274 render_component_templates(&protocol, &["nonexistent-widget"], "", &index).unwrap();
2275 assert_eq!(result["templates"].as_array().unwrap().len(), 0);
2276 assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
2277 }
2278
2279 #[test]
2282 fn test_resolve_tag_templates_no_placeholders() {
2283 let tags = vec!["inbox".to_string(), "counts".to_string()];
2284 let params = HashMap::new();
2285 let resolved = resolve_tag_templates(&tags, ¶ms);
2286 assert_eq!(resolved, vec!["inbox", "counts"]);
2287 }
2288
2289 #[test]
2290 fn test_resolve_tag_templates_with_params() {
2291 let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
2292 let mut params = HashMap::new();
2293 params.insert("threadId".to_string(), "42".to_string());
2294 let resolved = resolve_tag_templates(&tags, ¶ms);
2295 assert_eq!(resolved, vec!["thread:42", "inbox"]);
2296 }
2297
2298 #[test]
2299 fn test_resolve_tag_templates_multiple_params() {
2300 let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
2301 let mut params = HashMap::new();
2302 params.insert("folderId".to_string(), "drafts".to_string());
2303 params.insert("userId".to_string(), "abc".to_string());
2304 let resolved = resolve_tag_templates(&tags, ¶ms);
2305 assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
2306 }
2307
2308 #[test]
2309 fn test_resolve_tag_templates_missing_param_left_unresolved() {
2310 let tags = vec!["thread:{threadId}".to_string()];
2311 let params = HashMap::new();
2312 let resolved = resolve_tag_templates(&tags, ¶ms);
2313 assert_eq!(resolved, vec!["thread:{threadId}"]);
2314 }
2315
2316 #[test]
2319 fn test_chain_entry_to_json_includes_new_fields() {
2320 let entry = RouteChainEntry {
2321 component: "mail-thread".into(),
2322 path: "email/:threadId".into(),
2323 params: {
2324 let mut p = HashMap::new();
2325 p.insert("threadId".to_string(), "42".to_string());
2326 p
2327 },
2328 exact: true,
2329 allowed_query: String::new(),
2330 keep_alive: false,
2331 cache_tags: vec!["thread:42".to_string()],
2332 invalidates: vec!["inbox".to_string(), "counts".to_string()],
2333 pending_component: "mail-skeleton".into(),
2334 error_component: "error-page".into(),
2335 };
2336 let json = entry.to_json();
2337 assert_eq!(json["pendingComponent"], "mail-skeleton");
2338 assert_eq!(json["errorComponent"], "error-page");
2339 let inv = json["invalidates"].as_array().unwrap();
2340 assert_eq!(inv.len(), 2);
2341 assert_eq!(inv[0], "inbox");
2342 assert_eq!(inv[1], "counts");
2343 }
2344
2345 #[test]
2346 fn test_chain_entry_to_json_omits_empty_new_fields() {
2347 let entry = RouteChainEntry {
2348 component: "home-page".into(),
2349 path: "/".into(),
2350 params: HashMap::new(),
2351 exact: true,
2352 allowed_query: String::new(),
2353 keep_alive: false,
2354 cache_tags: Vec::new(),
2355 invalidates: Vec::new(),
2356 pending_component: String::new(),
2357 error_component: String::new(),
2358 };
2359 let json = entry.to_json();
2360 assert!(json.get("pendingComponent").is_none());
2361 assert!(json.get("errorComponent").is_none());
2362 assert!(json.get("invalidates").is_none());
2363 }
2364
2365 #[test]
2368 fn test_render_partial_includes_resolved_cache_tags() {
2369 let mut fragments = HashMap::new();
2370 fragments.insert(
2371 "index.html".to_string(),
2372 FragmentList {
2373 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2374 path: "/".into(),
2375 fragment_id: "app-shell".into(),
2376 exact: false,
2377 children: vec![WebUiFragmentRoute {
2378 path: "email/:threadId".into(),
2379 fragment_id: "mail-thread".into(),
2380 exact: true,
2381 cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2382 ..Default::default()
2383 }],
2384 cache_tags: vec!["folders".to_string()],
2385 ..Default::default()
2386 })],
2387 },
2388 );
2389 fragments.insert(
2390 "app-shell".to_string(),
2391 FragmentList {
2392 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2393 },
2394 );
2395 fragments.insert(
2396 "mail-thread".to_string(),
2397 FragmentList {
2398 fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
2399 },
2400 );
2401 let protocol = WebUIProtocol::new(fragments);
2402
2403 let mut index = ProtocolIndex::new(&protocol);
2404 let partial = render_partial(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
2405 let tags = partial["cacheTags"].as_array().unwrap();
2406 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2407 assert!(
2408 tag_strings.contains(&"folders"),
2409 "should contain parent tags"
2410 );
2411 assert!(
2412 tag_strings.contains(&"thread:42"),
2413 "should resolve threadId param to 42"
2414 );
2415 assert!(tag_strings.contains(&"inbox"), "should contain static tags");
2416 }
2417
2418 #[test]
2421 fn test_render_action_response_returns_resolved_invalidation_tags() {
2422 let mut fragments = HashMap::new();
2423 fragments.insert(
2424 "index.html".to_string(),
2425 FragmentList {
2426 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2427 path: "/".into(),
2428 fragment_id: "app-shell".into(),
2429 exact: false,
2430 children: vec![WebUiFragmentRoute {
2431 path: "compose".into(),
2432 fragment_id: "compose-page".into(),
2433 exact: true,
2434 invalidates: vec![
2435 "inbox".to_string(),
2436 "sent".to_string(),
2437 "counts".to_string(),
2438 ],
2439 ..Default::default()
2440 }],
2441 ..Default::default()
2442 })],
2443 },
2444 );
2445 fragments.insert(
2446 "app-shell".to_string(),
2447 FragmentList {
2448 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2449 },
2450 );
2451 fragments.insert(
2452 "compose-page".to_string(),
2453 FragmentList {
2454 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2455 },
2456 );
2457 let protocol = WebUIProtocol::new(fragments);
2458
2459 let mut index = ProtocolIndex::new(&protocol);
2460 let result = render_action_response(
2461 &protocol,
2462 serde_json::json!({"ok": true}),
2463 "index.html",
2464 "/compose",
2465 &mut index,
2466 );
2467
2468 let tags = result["invalidateTags"].as_array().unwrap();
2469 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2470 assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
2471 assert_eq!(result["state"]["ok"], true);
2472 }
2473
2474 #[test]
2475 fn test_render_action_response_resolves_param_placeholders() {
2476 let mut fragments = HashMap::new();
2477 fragments.insert(
2478 "index.html".to_string(),
2479 FragmentList {
2480 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2481 path: "/".into(),
2482 fragment_id: "app-shell".into(),
2483 exact: false,
2484 children: vec![WebUiFragmentRoute {
2485 path: "email/:threadId/reply".into(),
2486 fragment_id: "reply-page".into(),
2487 exact: true,
2488 invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2489 ..Default::default()
2490 }],
2491 ..Default::default()
2492 })],
2493 },
2494 );
2495 fragments.insert(
2496 "app-shell".to_string(),
2497 FragmentList {
2498 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2499 },
2500 );
2501 fragments.insert(
2502 "reply-page".to_string(),
2503 FragmentList {
2504 fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
2505 },
2506 );
2507 let protocol = WebUIProtocol::new(fragments);
2508
2509 let mut index = ProtocolIndex::new(&protocol);
2510 let result = render_action_response(
2511 &protocol,
2512 serde_json::json!({}),
2513 "index.html",
2514 "/email/42/reply",
2515 &mut index,
2516 );
2517
2518 let tags = result["invalidateTags"].as_array().unwrap();
2519 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2520 assert!(tag_strings.contains(&"thread:42"));
2521 assert!(tag_strings.contains(&"inbox"));
2522 }
2523}