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 mut s = String::with_capacity(40 + tag.len() + component.css.len());
1033 s.push_str("<style type=\"module\" specifier=\"");
1034 s.push_str(tag);
1035 s.push_str("\">");
1036 s.push_str(&component.css);
1037 s.push_str("</style>");
1038 style_array.push(Value::String(s));
1039 }
1040 tmpl_array.push(Value::String(component.template.clone()));
1041 }
1042
1043 (style_array, tmpl_array)
1044}
1045
1046#[derive(Debug, Clone)]
1050pub struct RouteChainEntry {
1051 pub component: String,
1053 pub path: String,
1055 pub params: HashMap<String, String>,
1057 pub exact: bool,
1059 pub allowed_query: String,
1061 pub keep_alive: bool,
1063 pub cache_tags: Vec<String>,
1065 pub invalidates: Vec<String>,
1067 pub pending_component: String,
1069 pub error_component: String,
1071}
1072
1073impl RouteChainEntry {
1074 #[must_use]
1076 pub fn to_json(&self) -> Value {
1077 let mut obj = serde_json::Map::with_capacity(9);
1078 obj.insert("component".into(), Value::String(self.component.clone()));
1079 obj.insert("path".into(), Value::String(self.path.clone()));
1080 if !self.params.is_empty() {
1081 let params_obj: serde_json::Map<String, Value> = self
1082 .params
1083 .iter()
1084 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
1085 .collect();
1086 obj.insert("params".into(), Value::Object(params_obj));
1087 }
1088 if self.exact {
1089 obj.insert("exact".into(), Value::Bool(true));
1090 }
1091 if !self.allowed_query.is_empty() {
1092 obj.insert(
1093 "allowedQuery".into(),
1094 Value::String(self.allowed_query.clone()),
1095 );
1096 }
1097 if self.keep_alive {
1098 obj.insert("keepAlive".into(), Value::Bool(true));
1099 }
1100 if !self.pending_component.is_empty() {
1101 obj.insert(
1102 "pendingComponent".into(),
1103 Value::String(self.pending_component.clone()),
1104 );
1105 }
1106 if !self.error_component.is_empty() {
1107 obj.insert(
1108 "errorComponent".into(),
1109 Value::String(self.error_component.clone()),
1110 );
1111 }
1112 if !self.invalidates.is_empty() {
1113 obj.insert(
1114 "invalidates".into(),
1115 Value::Array(
1116 self.invalidates
1117 .iter()
1118 .map(|s| Value::String(s.clone()))
1119 .collect(),
1120 ),
1121 );
1122 }
1123 Value::Object(obj)
1124 }
1125}
1126
1127pub fn collect_route_chain(
1132 protocol: &WebUIProtocol,
1133 entry_id: &str,
1134 request_path: &str,
1135 cache: &mut CompiledRouteCache,
1136) -> Vec<RouteChainEntry> {
1137 let mut chain = Vec::new();
1138 let mut visited_fragments = HashSet::new();
1139 let mut stack = vec![QueuedFragment {
1140 id: entry_id.to_string(),
1141 inventoryable: false,
1142 route_base: "/".to_string(),
1143 }];
1144
1145 while let Some(queued) = stack.pop() {
1146 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
1147 continue;
1148 }
1149
1150 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
1151 continue;
1152 };
1153
1154 let matched_route = route_renderer::find_best_route_match(
1155 &frag_list.fragments,
1156 request_path,
1157 &queued.route_base,
1158 cache,
1159 );
1160
1161 for frag in &frag_list.fragments {
1162 match frag.fragment.as_ref() {
1163 Some(Fragment::Component(component)) => {
1164 stack.push(QueuedFragment {
1165 id: component.fragment_id.clone(),
1166 inventoryable: false,
1167 route_base: queued.route_base.clone(),
1168 });
1169 }
1170 Some(Fragment::Route(route_frag)) => {
1171 let is_selected = matched_route
1172 .as_ref()
1173 .is_some_and(|(best_key, _)| best_key == route_frag.fragment_id.as_str());
1174 if is_selected && !route_frag.fragment_id.is_empty() {
1175 if let Some((_, ref rm)) = matched_route {
1176 chain.push(RouteChainEntry {
1177 component: route_frag.fragment_id.clone(),
1178 path: route_frag.path.clone(),
1179 params: rm.params.clone(),
1180 exact: route_frag.exact,
1181 allowed_query: route_frag.allowed_query.clone(),
1182 keep_alive: route_frag.keep_alive,
1183 cache_tags: route_frag.cache_tags.clone(),
1184 invalidates: route_frag.invalidates.clone(),
1185 pending_component: route_frag.pending_component.clone(),
1186 error_component: route_frag.error_component.clone(),
1187 });
1188
1189 let child_route_base = route_matcher::compute_route_base(
1190 request_path,
1191 rm.consumed_segments,
1192 );
1193
1194 stack.push(QueuedFragment {
1195 id: route_frag.fragment_id.clone(),
1196 inventoryable: false,
1197 route_base: child_route_base.clone(),
1198 });
1199
1200 collect_chain_from_children(
1202 &route_frag.children,
1203 request_path,
1204 &child_route_base,
1205 &mut chain,
1206 cache,
1207 );
1208 }
1209 }
1210 }
1211 _ => {}
1212 }
1213 }
1214 }
1215
1216 chain
1217}
1218
1219fn collect_chain_from_children(
1221 children: &[WebUIFragmentRoute],
1222 request_path: &str,
1223 route_base: &str,
1224 chain: &mut Vec<RouteChainEntry>,
1225 cache: &mut CompiledRouteCache,
1226) {
1227 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
1228 vec![(children, route_base.to_string())];
1229
1230 while let Some((current, base)) = pending.pop() {
1231 if let Some((idx, rm)) = select_best_child_route(current, request_path, &base, cache) {
1232 let matched = ¤t[idx];
1233 chain.push(RouteChainEntry {
1234 component: matched.fragment_id.clone(),
1235 path: matched.path.clone(),
1236 params: rm.params,
1237 exact: matched.exact,
1238 allowed_query: matched.allowed_query.clone(),
1239 keep_alive: matched.keep_alive,
1240 cache_tags: matched.cache_tags.clone(),
1241 invalidates: matched.invalidates.clone(),
1242 pending_component: matched.pending_component.clone(),
1243 error_component: matched.error_component.clone(),
1244 });
1245 if !matched.children.is_empty() {
1246 let child_base =
1247 route_matcher::compute_route_base(request_path, rm.consumed_segments);
1248 pending.push((&matched.children, child_base));
1249 }
1250 }
1251 }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 use super::*;
1257 use std::collections::HashMap;
1258 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
1259
1260 #[test]
1261 fn test_parse_encode_inventory_roundtrip() {
1262 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
1263 let hex = encode_inventory(&original);
1264 let decoded = parse_inventory(&hex).unwrap();
1265 assert_eq!(original, decoded);
1266 }
1267
1268 #[test]
1269 fn test_parse_inventory_empty() {
1270 let result = parse_inventory("").unwrap();
1271 assert!(result.is_empty());
1272 }
1273
1274 #[test]
1275 fn test_parse_inventory_rejects_odd_length() {
1276 let result = parse_inventory("abc");
1277 assert!(result.is_err());
1278 }
1279
1280 #[test]
1281 fn test_parse_inventory_rejects_invalid_hex() {
1282 let result = parse_inventory("zz");
1283 assert!(result.is_err());
1284 }
1285
1286 #[test]
1287 fn test_bitfield_set_and_check() {
1288 let mut inv = vec![0u8; 4];
1289 set_component(&mut inv, 0);
1290 set_component(&mut inv, 7);
1291 set_component(&mut inv, 8);
1292 set_component(&mut inv, 15);
1293 assert!(has_component(&inv, 0));
1294 assert!(has_component(&inv, 7));
1295 assert!(has_component(&inv, 8));
1296 assert!(has_component(&inv, 15));
1297 assert!(!has_component(&inv, 1));
1298 assert!(!has_component(&inv, 9));
1299 }
1300
1301 #[test]
1302 fn test_filter_needed_components_no_false_positives() {
1303 let mut index = HashMap::new();
1305 index.insert("email-message".to_string(), 0);
1306 index.insert("o-button".to_string(), 1);
1307 index.insert("o-avatar".to_string(), 2);
1308
1309 let mut names = HashSet::new();
1310 names.insert("email-message".to_string());
1311 names.insert("o-button".to_string());
1312
1313 let mut inv = vec![0u8; 1];
1315 set_component(&mut inv, 1); let inv_hex = encode_inventory(&inv);
1317
1318 let (needed, _) = filter_needed_components(&names, &inv_hex, &index).unwrap();
1319 assert_eq!(needed.len(), 1);
1320 assert!(
1321 needed.contains(&"email-message".to_string()),
1322 "email-message must be needed: {needed:?}"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_get_needed_components_empty_inventory() {
1328 let mut fragments = HashMap::new();
1329 fragments.insert(
1330 "app-shell".to_string(),
1331 FragmentList {
1332 fragments: vec![WebUIFragment::component("my-card")],
1333 },
1334 );
1335 fragments.insert(
1336 "my-card".to_string(),
1337 FragmentList {
1338 fragments: vec![WebUIFragment::raw("card")],
1339 },
1340 );
1341
1342 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1343 let comp_index = build_component_index(&protocol);
1344 let (needed, _inv) =
1345 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1346 assert!(needed.contains(&"app-shell".to_string()));
1347 assert!(needed.contains(&"my-card".to_string()));
1348 }
1349
1350 #[test]
1351 fn test_get_needed_components_skips_inventory() {
1352 let mut fragments = HashMap::new();
1353 fragments.insert(
1354 "app-shell".to_string(),
1355 FragmentList {
1356 fragments: vec![WebUIFragment::component("my-card")],
1357 },
1358 );
1359 fragments.insert(
1360 "my-card".to_string(),
1361 FragmentList {
1362 fragments: vec![WebUIFragment::raw("card")],
1363 },
1364 );
1365
1366 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1367 protocol
1368 .components
1369 .entry("app-shell".to_string())
1370 .or_default()
1371 .template = "<t></t>".to_string();
1372 protocol
1373 .components
1374 .entry("my-card".to_string())
1375 .or_default()
1376 .template = "<t></t>".to_string();
1377
1378 let comp_index = build_component_index(&protocol);
1379 let (_needed, inv_hex) =
1380 get_needed_components(&protocol, "app-shell", "", &comp_index).unwrap();
1381 let (needed2, _) =
1382 get_needed_components(&protocol, "app-shell", &inv_hex, &comp_index).unwrap();
1383 assert!(needed2.is_empty());
1384 }
1385
1386 #[test]
1387 fn test_get_needed_components_skips_control_fragments() {
1388 let mut fragments = HashMap::new();
1389 fragments.insert(
1390 "app-shell".to_string(),
1391 FragmentList {
1392 fragments: vec![
1393 WebUIFragment::if_cond(
1394 webui_protocol::ConditionExpr::identifier("showNav"),
1395 "if-shell",
1396 ),
1397 WebUIFragment::for_loop("item", "items", "for-items"),
1398 WebUIFragment::attribute_template("title", "attr-title"),
1399 ],
1400 },
1401 );
1402 fragments.insert(
1403 "if-shell".to_string(),
1404 FragmentList {
1405 fragments: vec![WebUIFragment::component("mp-category-nav")],
1406 },
1407 );
1408 fragments.insert(
1409 "for-items".to_string(),
1410 FragmentList {
1411 fragments: vec![WebUIFragment::component("mp-product-card")],
1412 },
1413 );
1414 fragments.insert(
1415 "attr-title".to_string(),
1416 FragmentList {
1417 fragments: vec![WebUIFragment::raw("Products")],
1418 },
1419 );
1420 fragments.insert(
1421 "mp-category-nav".to_string(),
1422 FragmentList {
1423 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1424 },
1425 );
1426 fragments.insert(
1427 "mp-product-card".to_string(),
1428 FragmentList {
1429 fragments: vec![WebUIFragment::raw("<article></article>")],
1430 },
1431 );
1432
1433 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1434 let comp_index = build_component_index(&protocol);
1435 let needed: HashSet<String> =
1436 get_needed_components(&protocol, "app-shell", "", &comp_index)
1437 .unwrap()
1438 .0
1439 .into_iter()
1440 .collect();
1441
1442 assert!(needed.contains("app-shell"));
1443 assert!(needed.contains("mp-category-nav"));
1444 assert!(needed.contains("mp-product-card"));
1445 assert!(!needed.contains("if-shell"));
1446 assert!(!needed.contains("for-items"));
1447 assert!(!needed.contains("attr-title"));
1448 }
1449
1450 #[test]
1451 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
1452 let mut fragments = HashMap::new();
1453 fragments.insert(
1454 "index.html".to_string(),
1455 FragmentList {
1456 fragments: vec![WebUIFragment::component("mp-app")],
1457 },
1458 );
1459 fragments.insert(
1460 "mp-app".to_string(),
1461 FragmentList {
1462 fragments: vec![
1463 WebUIFragment::component("mp-category-nav"),
1464 WebUIFragment::route_from(WebUiFragmentRoute {
1465 path: "/search/:category".into(),
1466 fragment_id: "mp-search-page".into(),
1467 exact: true,
1468 keep_alive: false,
1469 ..Default::default()
1470 }),
1471 WebUIFragment::route_from(WebUiFragmentRoute {
1472 path: "/product/:handle".into(),
1473 fragment_id: "mp-product-page".into(),
1474 exact: true,
1475 keep_alive: false,
1476 ..Default::default()
1477 }),
1478 ],
1479 },
1480 );
1481 fragments.insert(
1482 "mp-category-nav".to_string(),
1483 FragmentList {
1484 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1485 },
1486 );
1487 fragments.insert(
1488 "mp-search-page".to_string(),
1489 FragmentList {
1490 fragments: vec![WebUIFragment::component("mp-product-grid")],
1491 },
1492 );
1493 fragments.insert(
1494 "mp-product-grid".to_string(),
1495 FragmentList {
1496 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
1497 },
1498 );
1499 fragments.insert(
1500 "mp-product-page".to_string(),
1501 FragmentList {
1502 fragments: vec![WebUIFragment::component("mp-product-detail")],
1503 },
1504 );
1505 fragments.insert(
1506 "mp-product-detail".to_string(),
1507 FragmentList {
1508 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
1509 },
1510 );
1511
1512 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1513 protocol
1514 .components
1515 .entry("mp-search-page".to_string())
1516 .or_default()
1517 .template = "<mp-search-page></mp-search-page>".to_string();
1518 protocol
1519 .components
1520 .entry("mp-product-page".to_string())
1521 .or_default()
1522 .template = "<mp-product-page></mp-product-page>".to_string();
1523
1524 let comp_index = build_component_index(&protocol);
1525 let (needed, _inv) = get_needed_components_for_request(
1526 &protocol,
1527 "index.html",
1528 "/search/shirts",
1529 "",
1530 &comp_index,
1531 )
1532 .unwrap();
1533 let needed: HashSet<String> = needed.into_iter().collect();
1534
1535 assert!(needed.contains("mp-app"));
1536 assert!(needed.contains("mp-category-nav"));
1537 assert!(needed.contains("mp-search-page"));
1538 assert!(needed.contains("mp-product-grid"));
1539 assert!(!needed.contains("mp-product-page"));
1540 assert!(!needed.contains("mp-product-detail"));
1541 }
1542
1543 #[test]
1544 fn test_get_needed_components_for_request_follows_nested_route_chain() {
1545 let mut fragments = HashMap::new();
1546 fragments.insert(
1547 "index.html".to_string(),
1548 FragmentList {
1549 fragments: vec![WebUIFragment::component("mp-app")],
1550 },
1551 );
1552 fragments.insert(
1553 "mp-app".to_string(),
1554 FragmentList {
1555 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1556 path: "/account/*rest".into(),
1557 fragment_id: "mp-account-shell".into(),
1558 exact: false,
1559 keep_alive: false,
1560 ..Default::default()
1561 })],
1562 },
1563 );
1564 fragments.insert(
1565 "mp-account-shell".to_string(),
1566 FragmentList {
1567 fragments: vec![
1568 WebUIFragment::component("mp-account-nav"),
1569 WebUIFragment::route_from(WebUiFragmentRoute {
1570 path: "/account/profile".into(),
1571 fragment_id: "mp-profile-page".into(),
1572 exact: true,
1573 keep_alive: false,
1574 ..Default::default()
1575 }),
1576 WebUIFragment::route_from(WebUiFragmentRoute {
1577 path: "/account/orders/:id".into(),
1578 fragment_id: "mp-order-page".into(),
1579 exact: true,
1580 keep_alive: false,
1581 ..Default::default()
1582 }),
1583 ],
1584 },
1585 );
1586 fragments.insert(
1587 "mp-account-nav".to_string(),
1588 FragmentList {
1589 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1590 },
1591 );
1592 fragments.insert(
1593 "mp-profile-page".to_string(),
1594 FragmentList {
1595 fragments: vec![WebUIFragment::raw("<profile></profile>")],
1596 },
1597 );
1598 fragments.insert(
1599 "mp-order-page".to_string(),
1600 FragmentList {
1601 fragments: vec![WebUIFragment::component("mp-order-detail")],
1602 },
1603 );
1604 fragments.insert(
1605 "mp-order-detail".to_string(),
1606 FragmentList {
1607 fragments: vec![WebUIFragment::raw("<detail></detail>")],
1608 },
1609 );
1610
1611 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1612 protocol
1613 .components
1614 .entry("mp-account-shell".to_string())
1615 .or_default()
1616 .template = "<mp-account-shell></mp-account-shell>".to_string();
1617 protocol
1618 .components
1619 .entry("mp-profile-page".to_string())
1620 .or_default()
1621 .template = "<mp-profile-page></mp-profile-page>".to_string();
1622 protocol
1623 .components
1624 .entry("mp-order-page".to_string())
1625 .or_default()
1626 .template = "<mp-order-page></mp-order-page>".to_string();
1627
1628 let comp_index = build_component_index(&protocol);
1629 let (needed, _inv) = get_needed_components_for_request(
1630 &protocol,
1631 "index.html",
1632 "/account/orders/42",
1633 "",
1634 &comp_index,
1635 )
1636 .unwrap();
1637 let needed: HashSet<String> = needed.into_iter().collect();
1638
1639 assert!(needed.contains("mp-app"));
1640 assert!(needed.contains("mp-account-shell"));
1641 assert!(needed.contains("mp-account-nav"));
1642 assert!(needed.contains("mp-order-page"));
1643 assert!(needed.contains("mp-order-detail"));
1644 assert!(!needed.contains("mp-profile-page"));
1645 }
1646
1647 #[test]
1648 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1649 let mut fragments = HashMap::new();
1650 fragments.insert(
1651 "index.html".to_string(),
1652 FragmentList {
1653 fragments: vec![WebUIFragment::component("mp-app")],
1654 },
1655 );
1656 fragments.insert(
1657 "mp-app".to_string(),
1658 FragmentList {
1659 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1660 path: "/search".into(),
1661 fragment_id: "mp-search-page".into(),
1662 exact: true,
1663 keep_alive: false,
1664 ..Default::default()
1665 })],
1666 },
1667 );
1668 fragments.insert(
1669 "mp-search-page".to_string(),
1670 FragmentList {
1671 fragments: vec![
1672 WebUIFragment::if_cond(
1673 webui_protocol::ConditionExpr::identifier("showFilters"),
1674 "if-filters",
1675 ),
1676 WebUIFragment::for_loop("item", "items", "item-loop"),
1677 ],
1678 },
1679 );
1680 fragments.insert(
1681 "if-filters".to_string(),
1682 FragmentList {
1683 fragments: vec![WebUIFragment::component("mp-filter-panel")],
1684 },
1685 );
1686 fragments.insert(
1687 "item-loop".to_string(),
1688 FragmentList {
1689 fragments: vec![WebUIFragment::component("mp-item-card")],
1690 },
1691 );
1692 fragments.insert(
1693 "mp-filter-panel".to_string(),
1694 FragmentList {
1695 fragments: vec![WebUIFragment::raw("<filters></filters>")],
1696 },
1697 );
1698 fragments.insert(
1699 "mp-item-card".to_string(),
1700 FragmentList {
1701 fragments: vec![WebUIFragment::raw("<item></item>")],
1702 },
1703 );
1704
1705 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1706 protocol
1707 .components
1708 .entry("mp-search-page".to_string())
1709 .or_default()
1710 .template = "<mp-search-page></mp-search-page>".to_string();
1711
1712 let comp_index = build_component_index(&protocol);
1713 let (needed, _inv) =
1714 get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1715 .unwrap();
1716 let needed: HashSet<String> = needed.into_iter().collect();
1717
1718 assert!(needed.contains("mp-app"));
1719 assert!(needed.contains("mp-search-page"));
1720 assert!(needed.contains("mp-filter-panel"));
1721 assert!(needed.contains("mp-item-card"));
1722 }
1723
1724 #[test]
1725 fn test_get_needed_components_for_request_respects_inventory() {
1726 let mut fragments = HashMap::new();
1727 fragments.insert(
1728 "index.html".to_string(),
1729 FragmentList {
1730 fragments: vec![WebUIFragment::component("mp-app")],
1731 },
1732 );
1733 fragments.insert(
1734 "mp-app".to_string(),
1735 FragmentList {
1736 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1737 path: "/search".into(),
1738 fragment_id: "mp-search-page".into(),
1739 exact: true,
1740 keep_alive: false,
1741 ..Default::default()
1742 })],
1743 },
1744 );
1745 fragments.insert(
1746 "mp-search-page".to_string(),
1747 FragmentList {
1748 fragments: vec![WebUIFragment::component("mp-product-grid")],
1749 },
1750 );
1751 fragments.insert(
1752 "mp-product-grid".to_string(),
1753 FragmentList {
1754 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1755 },
1756 );
1757
1758 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1759 for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
1760 protocol
1761 .components
1762 .entry(name.to_string())
1763 .or_default()
1764 .template = format!("<f-template id=\"{name}\"></f-template>");
1765 }
1766
1767 let comp_index = build_component_index(&protocol);
1768 let (_needed, inventory) =
1769 get_needed_components_for_request(&protocol, "index.html", "/search", "", &comp_index)
1770 .unwrap();
1771 let (needed_again, _) = get_needed_components_for_request(
1772 &protocol,
1773 "index.html",
1774 "/search",
1775 &inventory,
1776 &comp_index,
1777 )
1778 .unwrap();
1779 assert!(needed_again.is_empty());
1780 }
1781
1782 #[test]
1783 fn test_render_partial_separates_module_styles_from_templates() {
1784 let mut fragments = HashMap::new();
1785 fragments.insert(
1786 "index.html".to_string(),
1787 FragmentList {
1788 fragments: vec![WebUIFragment::component("my-page")],
1789 },
1790 );
1791 fragments.insert(
1792 "my-page".to_string(),
1793 FragmentList {
1794 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1795 },
1796 );
1797
1798 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1799 let component = protocol
1800 .components
1801 .entry("my-page".to_string())
1802 .or_default();
1803 component.template =
1804 "(function(){window.__webui.templates['my-page']={h:'<p>page</p>'};})();".to_string();
1805 component.css = ".page{color:red}".to_string();
1806
1807 let mut index = ProtocolIndex::new(&protocol);
1808 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1809 let styles = partial["templateStyles"]
1810 .as_array()
1811 .expect("templateStyles should be an array");
1812 assert_eq!(styles.len(), 1);
1813 assert!(
1814 styles[0]
1815 .as_str()
1816 .unwrap_or_default()
1817 .contains("specifier=\"my-page\""),
1818 "module style should carry the component specifier"
1819 );
1820 assert!(
1821 styles[0]
1822 .as_str()
1823 .unwrap_or_default()
1824 .contains(".page{color:red}"),
1825 "module style should contain the CSS content"
1826 );
1827
1828 let templates = partial["templates"]
1830 .as_array()
1831 .expect("templates should be an array");
1832 assert_eq!(templates.len(), 1);
1833 assert!(
1834 !templates[0].as_str().unwrap_or_default().contains("<style"),
1835 "template should not contain any style tags"
1836 );
1837 assert!(
1838 templates[0]
1839 .as_str()
1840 .unwrap_or_default()
1841 .starts_with("(function()"),
1842 "template should be a raw JS IIFE"
1843 );
1844 }
1845
1846 #[test]
1847 fn test_render_partial_link_strategy_has_empty_template_styles() {
1848 let mut fragments = HashMap::new();
1851 fragments.insert(
1852 "index.html".to_string(),
1853 FragmentList {
1854 fragments: vec![WebUIFragment::component("my-page")],
1855 },
1856 );
1857 fragments.insert(
1858 "my-page".to_string(),
1859 FragmentList {
1860 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1861 },
1862 );
1863
1864 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1865 let component = protocol
1866 .components
1867 .entry("my-page".to_string())
1868 .or_default();
1869 component.template = "(function(){})();".to_string();
1870 component.css_href = "my-page.css".to_string();
1871 let mut index = ProtocolIndex::new(&protocol);
1874 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1875 let styles = partial["templateStyles"]
1876 .as_array()
1877 .expect("templateStyles should be an array");
1878 let templates = partial["templates"]
1879 .as_array()
1880 .expect("templates should be an array");
1881
1882 assert!(
1883 styles.is_empty(),
1884 "Link strategy should produce empty templateStyles: {styles:?}"
1885 );
1886 assert_eq!(templates.len(), 1, "should include the template IIFE");
1887 }
1888
1889 #[test]
1890 fn test_render_partial_style_strategy_has_empty_template_styles() {
1891 let mut fragments = HashMap::new();
1895 fragments.insert(
1896 "index.html".to_string(),
1897 FragmentList {
1898 fragments: vec![WebUIFragment::component("my-page")],
1899 },
1900 );
1901 fragments.insert(
1902 "my-page".to_string(),
1903 FragmentList {
1904 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1905 },
1906 );
1907
1908 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1909 let component = protocol
1910 .components
1911 .entry("my-page".to_string())
1912 .or_default();
1913 component.template =
1915 "(function(){var w=(window.__webui||(window.__webui={})).templates||(window.__webui.templates={});w['my-page']={h:'<style>.p{color:red}</style><p>page</p>'};})();"
1916 .to_string();
1917 let mut index = ProtocolIndex::new(&protocol);
1920 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1921 let styles = partial["templateStyles"]
1922 .as_array()
1923 .expect("templateStyles should be an array");
1924 let templates = partial["templates"]
1925 .as_array()
1926 .expect("templates should be an array");
1927
1928 assert!(
1929 styles.is_empty(),
1930 "Style strategy should produce empty templateStyles: {styles:?}"
1931 );
1932 assert_eq!(templates.len(), 1, "should include the template IIFE");
1933 assert!(
1934 templates[0]
1935 .as_str()
1936 .unwrap_or_default()
1937 .contains("<style>"),
1938 "Style strategy template should contain inline <style> tag"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_render_partial_empty_styles_for_no_css_components() {
1944 let mut fragments = HashMap::new();
1945 fragments.insert(
1946 "index.html".to_string(),
1947 FragmentList {
1948 fragments: vec![WebUIFragment::component("my-page")],
1949 },
1950 );
1951 fragments.insert(
1952 "my-page".to_string(),
1953 FragmentList {
1954 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1955 },
1956 );
1957
1958 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1959 let component = protocol
1960 .components
1961 .entry("my-page".to_string())
1962 .or_default();
1963 component.template = "(function(){})();".to_string();
1964 let mut index = ProtocolIndex::new(&protocol);
1967 let partial = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
1968 let styles = partial["templateStyles"]
1969 .as_array()
1970 .expect("templateStyles should be an array");
1971 assert!(
1972 styles.is_empty(),
1973 "templateStyles should be empty when components have no CSS"
1974 );
1975 }
1976
1977 #[test]
1978 fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
1979 let mut fragments = HashMap::new();
1980 fragments.insert(
1981 "index.html".to_string(),
1982 FragmentList {
1983 fragments: vec![WebUIFragment::component("my-page")],
1984 },
1985 );
1986 fragments.insert(
1987 "my-page".to_string(),
1988 FragmentList {
1989 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1990 },
1991 );
1992
1993 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1994 let component = protocol
1995 .components
1996 .entry("my-page".to_string())
1997 .or_default();
1998 component.template = "(function(){})();".to_string();
1999 component.css = ".page{color:red}".to_string();
2000
2001 let mut index = ProtocolIndex::new(&protocol);
2002 let partial1 = render_partial(&protocol, "index.html", "/", "", &mut index).unwrap();
2004 let inv = partial1["inventory"].as_str().unwrap_or_default();
2005 assert!(!inv.is_empty());
2006
2007 let partial2 = render_partial(&protocol, "index.html", "/", inv, &mut index).unwrap();
2012 let styles = partial2["templateStyles"]
2013 .as_array()
2014 .expect("templateStyles should be an array");
2015 let templates = partial2["templates"]
2016 .as_array()
2017 .expect("templates should be an array");
2018
2019 assert!(
2020 templates.is_empty(),
2021 "templates should be empty when inventory is full"
2022 );
2023 assert!(
2024 styles.is_empty(),
2025 "module styles should be empty when inventory is full — SSR already placed them"
2026 );
2027 }
2028
2029 #[test]
2030 fn test_non_route_siblings_included_in_needed_components() {
2031 let mut fragments = HashMap::new();
2036
2037 fragments.insert(
2039 "index.html".to_string(),
2040 FragmentList {
2041 fragments: vec![WebUIFragment::component("app-shell")],
2042 },
2043 );
2044
2045 fragments.insert(
2047 "app-shell".to_string(),
2048 FragmentList {
2049 fragments: vec![
2050 WebUIFragment::component("my-navbar"),
2051 WebUIFragment::route_from(WebUiFragmentRoute {
2052 path: "/about".into(),
2053 fragment_id: "page-about".into(),
2054 exact: true,
2055 keep_alive: false,
2056 ..Default::default()
2057 }),
2058 WebUIFragment::component("cart-panel"),
2059 ],
2060 },
2061 );
2062
2063 fragments.insert(
2065 "my-navbar".to_string(),
2066 FragmentList {
2067 fragments: vec![WebUIFragment::raw("<nav/>")],
2068 },
2069 );
2070 fragments.insert(
2071 "page-about".to_string(),
2072 FragmentList {
2073 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
2074 },
2075 );
2076 fragments.insert(
2077 "cart-panel".to_string(),
2078 FragmentList {
2079 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
2080 },
2081 );
2082
2083 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2084 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
2085 let comp = protocol.components.entry(name.to_string()).or_default();
2086 comp.template = format!("(function(){{/* {name} */}})();");
2087 comp.css = format!(".{name}{{display:block}}");
2088 }
2089
2090 let comp_index = build_component_index(&protocol);
2091 let (needed, _inv) =
2092 get_needed_components_for_request(&protocol, "index.html", "/about", "", &comp_index)
2093 .unwrap();
2094
2095 assert!(
2096 needed.contains(&"app-shell".to_string()),
2097 "app-shell should be needed: {needed:?}"
2098 );
2099 assert!(
2100 needed.contains(&"my-navbar".to_string()),
2101 "my-navbar (non-route sibling) should be needed: {needed:?}"
2102 );
2103 assert!(
2104 needed.contains(&"page-about".to_string()),
2105 "page-about (active route) should be needed: {needed:?}"
2106 );
2107 assert!(
2108 needed.contains(&"cart-panel".to_string()),
2109 "cart-panel (non-route sibling) should be needed: {needed:?}"
2110 );
2111 }
2112
2113 #[test]
2116 fn test_chain_entry_to_json_includes_allowed_query() {
2117 let entry = RouteChainEntry {
2118 component: "compose-page".into(),
2119 path: "compose".into(),
2120 params: HashMap::new(),
2121 exact: true,
2122 allowed_query: "action,to,subject".into(),
2123 keep_alive: false,
2124 cache_tags: Vec::new(),
2125 invalidates: Vec::new(),
2126 pending_component: String::new(),
2127 error_component: String::new(),
2128 };
2129 let json = entry.to_json();
2130 assert_eq!(json["allowedQuery"], "action,to,subject");
2131 }
2132
2133 #[test]
2134 fn test_chain_entry_to_json_omits_empty_allowed_query() {
2135 let entry = RouteChainEntry {
2136 component: "home-page".into(),
2137 path: "/".into(),
2138 params: HashMap::new(),
2139 exact: true,
2140 allowed_query: String::new(),
2141 keep_alive: false,
2142 cache_tags: Vec::new(),
2143 invalidates: Vec::new(),
2144 pending_component: String::new(),
2145 error_component: String::new(),
2146 };
2147 let json = entry.to_json();
2148 assert!(
2149 json.get("allowedQuery").is_none(),
2150 "empty allowed_query should not appear in JSON"
2151 );
2152 }
2153
2154 #[test]
2155 fn test_collect_route_chain_carries_allowed_query() {
2156 let mut fragments = HashMap::new();
2157 fragments.insert(
2158 "index.html".to_string(),
2159 FragmentList {
2160 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2161 path: "/".into(),
2162 fragment_id: "app-shell".into(),
2163 exact: false,
2164 children: vec![WebUiFragmentRoute {
2165 path: "compose".into(),
2166 fragment_id: "compose-page".into(),
2167 exact: true,
2168 allowed_query: "action,to".into(),
2169 keep_alive: false,
2170 ..Default::default()
2171 }],
2172 keep_alive: false,
2173 ..Default::default()
2174 })],
2175 },
2176 );
2177 fragments.insert(
2178 "app-shell".to_string(),
2179 FragmentList {
2180 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2181 },
2182 );
2183 fragments.insert(
2184 "compose-page".to_string(),
2185 FragmentList {
2186 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2187 },
2188 );
2189 let protocol = WebUIProtocol::new(fragments);
2190
2191 let chain = collect_route_chain(
2192 &protocol,
2193 "index.html",
2194 "/compose",
2195 &mut CompiledRouteCache::new(),
2196 );
2197 assert_eq!(chain.len(), 2);
2198 assert_eq!(chain[0].component, "app-shell");
2199 assert!(chain[0].allowed_query.is_empty());
2200 assert_eq!(chain[1].component, "compose-page");
2201 assert_eq!(chain[1].allowed_query, "action,to");
2202 }
2203
2204 #[test]
2205 fn test_render_component_templates_returns_template_and_css() {
2206 let mut fragments = HashMap::new();
2207 fragments.insert(
2208 "settings-dialog".to_string(),
2209 FragmentList {
2210 fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
2211 },
2212 );
2213 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2214 let comp = protocol
2215 .components
2216 .entry("settings-dialog".to_string())
2217 .or_default();
2218 comp.template = "(function(){window.__webui.templates['settings-dialog']={h:'<div>Settings</div>'};})();".to_string();
2219 comp.css = ".dialog{position:fixed}".to_string();
2220
2221 let index = ProtocolIndex::new(&protocol);
2222 let result =
2223 render_component_templates(&protocol, &["settings-dialog"], "", &index).unwrap();
2224 let templates = result["templates"].as_array().expect("templates array");
2225 let styles = result["templateStyles"].as_array().expect("styles array");
2226
2227 assert_eq!(templates.len(), 1);
2228 assert!(templates[0].as_str().unwrap().contains("settings-dialog"));
2229 assert_eq!(styles.len(), 1);
2230 assert!(styles[0]
2231 .as_str()
2232 .unwrap()
2233 .contains(".dialog{position:fixed}"));
2234 }
2235
2236 #[test]
2237 fn test_render_component_templates_respects_inventory() {
2238 let mut fragments = HashMap::new();
2239 fragments.insert(
2240 "my-dialog".to_string(),
2241 FragmentList {
2242 fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
2243 },
2244 );
2245 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2246 let comp = protocol
2247 .components
2248 .entry("my-dialog".to_string())
2249 .or_default();
2250 comp.template = "(function(){})();".to_string();
2251 comp.css = ".d{color:red}".to_string();
2252
2253 let index = ProtocolIndex::new(&protocol);
2254 let result1 = render_component_templates(&protocol, &["my-dialog"], "", &index).unwrap();
2256 let inv = result1["inventory"].as_str().expect("inventory string");
2257 assert_eq!(result1["templates"].as_array().unwrap().len(), 1);
2258
2259 let result2 = render_component_templates(&protocol, &["my-dialog"], inv, &index).unwrap();
2261 assert_eq!(result2["templates"].as_array().unwrap().len(), 0);
2262 assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
2263 }
2264
2265 #[test]
2266 fn test_render_component_templates_unknown_component_returns_empty() {
2267 let fragments = HashMap::new();
2268 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
2269
2270 let index = ProtocolIndex::new(&protocol);
2271 let result =
2272 render_component_templates(&protocol, &["nonexistent-widget"], "", &index).unwrap();
2273 assert_eq!(result["templates"].as_array().unwrap().len(), 0);
2274 assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
2275 }
2276
2277 #[test]
2280 fn test_resolve_tag_templates_no_placeholders() {
2281 let tags = vec!["inbox".to_string(), "counts".to_string()];
2282 let params = HashMap::new();
2283 let resolved = resolve_tag_templates(&tags, ¶ms);
2284 assert_eq!(resolved, vec!["inbox", "counts"]);
2285 }
2286
2287 #[test]
2288 fn test_resolve_tag_templates_with_params() {
2289 let tags = vec!["thread:{threadId}".to_string(), "inbox".to_string()];
2290 let mut params = HashMap::new();
2291 params.insert("threadId".to_string(), "42".to_string());
2292 let resolved = resolve_tag_templates(&tags, ¶ms);
2293 assert_eq!(resolved, vec!["thread:42", "inbox"]);
2294 }
2295
2296 #[test]
2297 fn test_resolve_tag_templates_multiple_params() {
2298 let tags = vec!["folder:{folderId}:user:{userId}".to_string()];
2299 let mut params = HashMap::new();
2300 params.insert("folderId".to_string(), "drafts".to_string());
2301 params.insert("userId".to_string(), "abc".to_string());
2302 let resolved = resolve_tag_templates(&tags, ¶ms);
2303 assert_eq!(resolved, vec!["folder:drafts:user:abc"]);
2304 }
2305
2306 #[test]
2307 fn test_resolve_tag_templates_missing_param_left_unresolved() {
2308 let tags = vec!["thread:{threadId}".to_string()];
2309 let params = HashMap::new();
2310 let resolved = resolve_tag_templates(&tags, ¶ms);
2311 assert_eq!(resolved, vec!["thread:{threadId}"]);
2312 }
2313
2314 #[test]
2317 fn test_chain_entry_to_json_includes_new_fields() {
2318 let entry = RouteChainEntry {
2319 component: "mail-thread".into(),
2320 path: "email/:threadId".into(),
2321 params: {
2322 let mut p = HashMap::new();
2323 p.insert("threadId".to_string(), "42".to_string());
2324 p
2325 },
2326 exact: true,
2327 allowed_query: String::new(),
2328 keep_alive: false,
2329 cache_tags: vec!["thread:42".to_string()],
2330 invalidates: vec!["inbox".to_string(), "counts".to_string()],
2331 pending_component: "mail-skeleton".into(),
2332 error_component: "error-page".into(),
2333 };
2334 let json = entry.to_json();
2335 assert_eq!(json["pendingComponent"], "mail-skeleton");
2336 assert_eq!(json["errorComponent"], "error-page");
2337 let inv = json["invalidates"].as_array().unwrap();
2338 assert_eq!(inv.len(), 2);
2339 assert_eq!(inv[0], "inbox");
2340 assert_eq!(inv[1], "counts");
2341 }
2342
2343 #[test]
2344 fn test_chain_entry_to_json_omits_empty_new_fields() {
2345 let entry = RouteChainEntry {
2346 component: "home-page".into(),
2347 path: "/".into(),
2348 params: HashMap::new(),
2349 exact: true,
2350 allowed_query: String::new(),
2351 keep_alive: false,
2352 cache_tags: Vec::new(),
2353 invalidates: Vec::new(),
2354 pending_component: String::new(),
2355 error_component: String::new(),
2356 };
2357 let json = entry.to_json();
2358 assert!(json.get("pendingComponent").is_none());
2359 assert!(json.get("errorComponent").is_none());
2360 assert!(json.get("invalidates").is_none());
2361 }
2362
2363 #[test]
2366 fn test_render_partial_includes_resolved_cache_tags() {
2367 let mut fragments = HashMap::new();
2368 fragments.insert(
2369 "index.html".to_string(),
2370 FragmentList {
2371 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2372 path: "/".into(),
2373 fragment_id: "app-shell".into(),
2374 exact: false,
2375 children: vec![WebUiFragmentRoute {
2376 path: "email/:threadId".into(),
2377 fragment_id: "mail-thread".into(),
2378 exact: true,
2379 cache_tags: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2380 ..Default::default()
2381 }],
2382 cache_tags: vec!["folders".to_string()],
2383 ..Default::default()
2384 })],
2385 },
2386 );
2387 fragments.insert(
2388 "app-shell".to_string(),
2389 FragmentList {
2390 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2391 },
2392 );
2393 fragments.insert(
2394 "mail-thread".to_string(),
2395 FragmentList {
2396 fragments: vec![WebUIFragment::raw("<p>Thread</p>")],
2397 },
2398 );
2399 let protocol = WebUIProtocol::new(fragments);
2400
2401 let mut index = ProtocolIndex::new(&protocol);
2402 let partial = render_partial(&protocol, "index.html", "/email/42", "", &mut index).unwrap();
2403 let tags = partial["cacheTags"].as_array().unwrap();
2404 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2405 assert!(
2406 tag_strings.contains(&"folders"),
2407 "should contain parent tags"
2408 );
2409 assert!(
2410 tag_strings.contains(&"thread:42"),
2411 "should resolve threadId param to 42"
2412 );
2413 assert!(tag_strings.contains(&"inbox"), "should contain static tags");
2414 }
2415
2416 #[test]
2419 fn test_render_action_response_returns_resolved_invalidation_tags() {
2420 let mut fragments = HashMap::new();
2421 fragments.insert(
2422 "index.html".to_string(),
2423 FragmentList {
2424 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2425 path: "/".into(),
2426 fragment_id: "app-shell".into(),
2427 exact: false,
2428 children: vec![WebUiFragmentRoute {
2429 path: "compose".into(),
2430 fragment_id: "compose-page".into(),
2431 exact: true,
2432 invalidates: vec![
2433 "inbox".to_string(),
2434 "sent".to_string(),
2435 "counts".to_string(),
2436 ],
2437 ..Default::default()
2438 }],
2439 ..Default::default()
2440 })],
2441 },
2442 );
2443 fragments.insert(
2444 "app-shell".to_string(),
2445 FragmentList {
2446 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2447 },
2448 );
2449 fragments.insert(
2450 "compose-page".to_string(),
2451 FragmentList {
2452 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
2453 },
2454 );
2455 let protocol = WebUIProtocol::new(fragments);
2456
2457 let mut index = ProtocolIndex::new(&protocol);
2458 let result = render_action_response(
2459 &protocol,
2460 serde_json::json!({"ok": true}),
2461 "index.html",
2462 "/compose",
2463 &mut index,
2464 );
2465
2466 let tags = result["invalidateTags"].as_array().unwrap();
2467 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2468 assert_eq!(tag_strings, vec!["inbox", "sent", "counts"]);
2469 assert_eq!(result["state"]["ok"], true);
2470 }
2471
2472 #[test]
2473 fn test_render_action_response_resolves_param_placeholders() {
2474 let mut fragments = HashMap::new();
2475 fragments.insert(
2476 "index.html".to_string(),
2477 FragmentList {
2478 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
2479 path: "/".into(),
2480 fragment_id: "app-shell".into(),
2481 exact: false,
2482 children: vec![WebUiFragmentRoute {
2483 path: "email/:threadId/reply".into(),
2484 fragment_id: "reply-page".into(),
2485 exact: true,
2486 invalidates: vec!["thread:{threadId}".to_string(), "inbox".to_string()],
2487 ..Default::default()
2488 }],
2489 ..Default::default()
2490 })],
2491 },
2492 );
2493 fragments.insert(
2494 "app-shell".to_string(),
2495 FragmentList {
2496 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
2497 },
2498 );
2499 fragments.insert(
2500 "reply-page".to_string(),
2501 FragmentList {
2502 fragments: vec![WebUIFragment::raw("<p>Reply</p>")],
2503 },
2504 );
2505 let protocol = WebUIProtocol::new(fragments);
2506
2507 let mut index = ProtocolIndex::new(&protocol);
2508 let result = render_action_response(
2509 &protocol,
2510 serde_json::json!({}),
2511 "index.html",
2512 "/email/42/reply",
2513 &mut index,
2514 );
2515
2516 let tags = result["invalidateTags"].as_array().unwrap();
2517 let tag_strings: Vec<&str> = tags.iter().map(|v| v.as_str().unwrap()).collect();
2518 assert!(tag_strings.contains(&"thread:42"));
2519 assert!(tag_strings.contains(&"inbox"));
2520 }
2521}