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