Skip to main content

webui_handler/
route_handler.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Route component inventory management for incremental route rendering.
5//!
6//! These helpers walk the normal render fragment graph. The request-aware path is
7//! route-aware but state-agnostic: it follows the active route chain for the
8//! current request path, while conservatively traversing `if`, `for`, and
9//! attribute-template edges without evaluating runtime state.
10
11use crate::route_matcher;
12use serde_json::Value;
13use std::collections::{HashMap, HashSet};
14use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIFragmentRoute, WebUIProtocol};
15
16// ── Component Inventory ─────────────────────────────────────────────────
17
18/// Build a deterministic component-name → bit-index map from the protocol.
19///
20/// Derives names from fragment keys (hyphenated = custom element) since that
21/// is the source of truth regardless of whether a plugin populated
22/// `protocol.components`. Components are sorted alphabetically; index =
23/// position in that order.
24pub fn build_component_index(protocol: &WebUIProtocol) -> HashMap<String, u32> {
25    let mut names: HashSet<&String> = HashSet::new();
26    for key in protocol.fragments.keys() {
27        if key.contains('-') {
28            names.insert(key);
29        }
30    }
31    let mut sorted: Vec<&String> = names.into_iter().collect();
32    sorted.sort_unstable();
33    sorted
34        .iter()
35        .enumerate()
36        .map(|(i, n)| ((*n).clone(), i as u32))
37        .collect()
38}
39
40/// Check if a component's bit is set in the inventory bitfield.
41fn has_component(inventory: &[u8], index: u32) -> bool {
42    let byte_idx = (index / 8) as usize;
43    let bit_idx = index % 8;
44    byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
45}
46
47/// Set a component's bit in the inventory bitfield.
48fn set_component(inventory: &mut Vec<u8>, index: u32) {
49    let byte_idx = (index / 8) as usize;
50    let bit_idx = index % 8;
51    if byte_idx >= inventory.len() {
52        inventory.resize(byte_idx + 1, 0);
53    }
54    inventory[byte_idx] |= 1 << bit_idx;
55}
56
57/// Parse a hex-encoded inventory string into bytes.
58pub fn parse_inventory(hex: &str) -> Vec<u8> {
59    let mut bytes = Vec::with_capacity(hex.len() / 2);
60    let mut chars = hex.bytes();
61    while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
62        if let (Some(h), Some(l)) = (hex_nibble(hi), hex_nibble(lo)) {
63            bytes.push((h << 4) | l);
64        }
65    }
66    bytes
67}
68
69fn hex_nibble(byte: u8) -> Option<u8> {
70    match byte {
71        b'0'..=b'9' => Some(byte - b'0'),
72        b'a'..=b'f' => Some(byte - b'a' + 10),
73        b'A'..=b'F' => Some(byte - b'A' + 10),
74        _ => None,
75    }
76}
77
78/// Encode an inventory bitfield as a hex string.
79pub fn encode_inventory(inv: &[u8]) -> String {
80    inv.iter()
81        .fold(String::with_capacity(inv.len() * 2), |mut acc, b| {
82            use std::fmt::Write;
83            let _ = write!(acc, "{b:02x}");
84            acc
85        })
86}
87
88/// Walk the protocol fragment graph from `entry_id` and return the names of
89/// all components the route needs that are NOT in the client's inventory.
90///
91/// Returns `(needed_names, updated_inventory_hex)`.
92pub fn get_needed_components(
93    protocol: &WebUIProtocol,
94    entry_id: &str,
95    inventory_hex: &str,
96) -> (Vec<String>, String) {
97    let component_names = collect_inventoryable_components(protocol, entry_id, None, true);
98    let index = build_component_index(protocol);
99    filter_needed_components(&component_names, inventory_hex, &index)
100}
101
102/// Walk the protocol from the persistent `entry_id` and return the component
103/// templates needed for the current `request_path`.
104///
105/// Returns `(needed_names, updated_inventory_hex)`.
106pub fn get_needed_components_for_request(
107    protocol: &WebUIProtocol,
108    entry_id: &str,
109    request_path: &str,
110    inventory_hex: &str,
111) -> (Vec<String>, String) {
112    let component_names =
113        collect_inventoryable_components(protocol, entry_id, Some(request_path), false);
114    let index = build_component_index(protocol);
115    filter_needed_components(&component_names, inventory_hex, &index)
116}
117
118/// Filter components against the client's inventory bitfield using sequential indices.
119/// Zero collisions — each component has a unique bit.
120///
121/// Returns the missing component names and the updated inventory hex string.
122#[must_use]
123pub fn filter_needed_components(
124    component_names: &HashSet<String>,
125    inventory_hex: &str,
126    index: &HashMap<String, u32>,
127) -> (Vec<String>, String) {
128    let client_inv = parse_inventory(inventory_hex);
129    let mut updated_inv = client_inv.clone();
130
131    let mut ordered_names: Vec<&String> = component_names.iter().collect();
132    ordered_names.sort_unstable();
133
134    let mut needed = Vec::with_capacity(ordered_names.len());
135    for name in ordered_names {
136        if let Some(&idx) = index.get(name.as_str()) {
137            if !has_component(&client_inv, idx) {
138                needed.push(name.clone());
139            }
140            set_component(&mut updated_inv, idx);
141        } else {
142            // Component exists in the fragment graph but has no index entry
143            // (no protocol.components record). It can't be tracked in the
144            // bitfield, so we must always send it.
145            needed.push(name.clone());
146        }
147    }
148
149    (needed, encode_inventory(&updated_inv))
150}
151
152#[derive(Debug)]
153struct QueuedFragment {
154    id: String,
155    inventoryable: bool,
156    /// Base path for resolving relative route paths at this level.
157    route_base: String,
158}
159
160/// Walk the fragment graph from `entry_id` and collect all inventoryable component names.
161///
162/// Uses an iterative stack-based traversal. When `request_path` is provided,
163/// the walk follows only the best-matching route at each nesting level (route-aware).
164/// Without a request path, all route branches are followed conservatively.
165///
166/// Components are marked `inventoryable` when they have a corresponding entry
167/// in `protocol.components` with a non-empty template — these are the components
168/// whose f-template HTML the client may need during navigation.
169fn collect_inventoryable_components(
170    protocol: &WebUIProtocol,
171    entry_id: &str,
172    request_path: Option<&str>,
173    root_inventoryable: bool,
174) -> HashSet<String> {
175    let mut visited_fragments = HashSet::new();
176    let mut component_ids = HashSet::new();
177    let mut stack = vec![QueuedFragment {
178        id: entry_id.to_string(),
179        inventoryable: root_inventoryable,
180        route_base: "/".to_string(),
181    }];
182
183    while let Some(queued) = stack.pop() {
184        if queued.id.is_empty() {
185            continue;
186        }
187
188        if queued.inventoryable {
189            component_ids.insert(queued.id.clone());
190        }
191
192        if !visited_fragments.insert(queued.id.clone()) {
193            continue;
194        }
195
196        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
197            continue;
198        };
199
200        let matched_route = request_path
201            .and_then(|path| find_best_route_match(&frag_list.fragments, path, &queued.route_base));
202
203        for frag in &frag_list.fragments {
204            match frag.fragment.as_ref() {
205                Some(Fragment::Component(component)) => {
206                    stack.push(QueuedFragment {
207                        id: component.fragment_id.clone(),
208                        inventoryable: true,
209                        route_base: queued.route_base.clone(),
210                    });
211                }
212                Some(Fragment::ForLoop(for_loop)) => {
213                    stack.push(QueuedFragment {
214                        id: for_loop.fragment_id.clone(),
215                        inventoryable: false,
216                        route_base: queued.route_base.clone(),
217                    });
218                }
219                Some(Fragment::IfCond(if_cond)) => {
220                    stack.push(QueuedFragment {
221                        id: if_cond.fragment_id.clone(),
222                        inventoryable: false,
223                        route_base: queued.route_base.clone(),
224                    });
225                }
226                Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
227                    stack.push(QueuedFragment {
228                        id: attr.template.clone(),
229                        inventoryable: false,
230                        route_base: queued.route_base.clone(),
231                    });
232                }
233                Some(Fragment::Route(route_frag)) => {
234                    let is_selected = matched_route
235                        .as_ref()
236                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
237                    if is_selected && !route_frag.fragment_id.is_empty() {
238                        // Compute new route base from consumed segments
239                        let child_route_base = if let Some((_, ref rm)) = matched_route {
240                            if let Some(path) = request_path {
241                                route_matcher::compute_route_base(path, rm.consumed_segments)
242                            } else {
243                                queued.route_base.clone()
244                            }
245                        } else {
246                            queued.route_base.clone()
247                        };
248
249                        stack.push(QueuedFragment {
250                            id: route_frag.fragment_id.clone(),
251                            inventoryable: protocol
252                                .components
253                                .get(&route_frag.fragment_id)
254                                .is_some_and(|c| !c.template.is_empty()),
255                            route_base: child_route_base.clone(),
256                        });
257
258                        // Walk nested child routes to find the next matched level.
259                        // This mirrors the handler's outlet rendering: match children
260                        // against the request path and follow the matched chain.
261                        if !route_frag.children.is_empty() {
262                            if let Some(path) = request_path {
263                                walk_route_children(
264                                    &route_frag.children,
265                                    path,
266                                    &child_route_base,
267                                    protocol,
268                                    &mut stack,
269                                );
270                            }
271                        }
272                    }
273                }
274                _ => {}
275            }
276        }
277    }
278
279    component_ids
280}
281
282/// Walk nested route children to find matched routes and add their
283/// components to the inventory stack. Mirrors the handler's outlet rendering.
284fn walk_route_children(
285    children: &[WebUIFragmentRoute],
286    request_path: &str,
287    route_base: &str,
288    protocol: &WebUIProtocol,
289    stack: &mut Vec<QueuedFragment>,
290) {
291    let mut current = children;
292    let mut base = route_base.to_string();
293
294    loop {
295        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
296        for (idx, child) in current.iter().enumerate() {
297            let resolved = route_matcher::resolve_route_path(&child.path, &base);
298            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
299            {
300                let is_better = best
301                    .as_ref()
302                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
303                if is_better {
304                    best = Some((idx, m));
305                }
306            }
307        }
308
309        let Some((idx, ref rm)) = best else { break };
310        let matched = &current[idx];
311        if matched.fragment_id.is_empty() {
312            break;
313        }
314
315        let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
316
317        stack.push(QueuedFragment {
318            id: matched.fragment_id.clone(),
319            inventoryable: protocol
320                .components
321                .get(&matched.fragment_id)
322                .is_some_and(|c| !c.template.is_empty()),
323            route_base: child_base.clone(),
324        });
325
326        if matched.children.is_empty() {
327            break;
328        }
329        current = &matched.children;
330        base = child_base;
331    }
332}
333
334/// Pre-scan sibling route fragments and return the best match info.
335///
336/// `route_base` is used to resolve relative paths (starting with `./`).
337fn find_best_route_match(
338    fragments: &[WebUIFragment],
339    request_path: &str,
340    route_base: &str,
341) -> Option<(String, route_matcher::RouteMatch)> {
342    let mut best: Option<(String, route_matcher::RouteMatch)> = None;
343
344    for item in fragments {
345        if let Some(Fragment::Route(route_frag)) = item.fragment.as_ref() {
346            let resolved_path = route_matcher::resolve_route_path(&route_frag.path, route_base);
347            if let Some(m) =
348                route_matcher::match_single_route(&resolved_path, request_path, route_frag.exact)
349            {
350                let is_better = best
351                    .as_ref()
352                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
353
354                if is_better {
355                    best = Some((route_fragment_key(route_frag).to_string(), m));
356                }
357            }
358        }
359    }
360
361    best
362}
363
364fn route_fragment_key(route_frag: &WebUIFragmentRoute) -> &str {
365    route_frag.fragment_id.as_str()
366}
367
368/// Walk the fragment graph following matched routes and collect all route
369/// parameters from every level of the active route chain.
370///
371/// This is used by the dev server to inject nested route params into state.
372pub fn collect_nested_route_params(
373    protocol: &WebUIProtocol,
374    entry_id: &str,
375    request_path: &str,
376) -> HashMap<String, String> {
377    let mut all_params = HashMap::new();
378    let mut visited_fragments = HashSet::new();
379    let mut stack = vec![QueuedFragment {
380        id: entry_id.to_string(),
381        inventoryable: false,
382        route_base: "/".to_string(),
383    }];
384
385    while let Some(queued) = stack.pop() {
386        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
387            continue;
388        }
389
390        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
391            continue;
392        };
393
394        let matched_route =
395            find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
396
397        for frag in &frag_list.fragments {
398            match frag.fragment.as_ref() {
399                Some(Fragment::Component(component)) => {
400                    stack.push(QueuedFragment {
401                        id: component.fragment_id.clone(),
402                        inventoryable: false,
403                        route_base: queued.route_base.clone(),
404                    });
405                }
406                Some(Fragment::Route(route_frag)) => {
407                    let is_selected = matched_route
408                        .as_ref()
409                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
410                    if is_selected && !route_frag.fragment_id.is_empty() {
411                        if let Some((_, ref rm)) = matched_route {
412                            // Collect params from this route level
413                            all_params.extend(rm.params.clone());
414
415                            let child_route_base = route_matcher::compute_route_base(
416                                request_path,
417                                rm.consumed_segments,
418                            );
419
420                            stack.push(QueuedFragment {
421                                id: route_frag.fragment_id.clone(),
422                                inventoryable: false,
423                                route_base: child_route_base.clone(),
424                            });
425
426                            // Walk nested children to collect params from deeper levels
427                            collect_params_from_children(
428                                &route_frag.children,
429                                request_path,
430                                &child_route_base,
431                                &mut all_params,
432                            );
433                        }
434                    }
435                }
436                _ => {}
437            }
438        }
439    }
440
441    all_params
442}
443
444/// Recursively collect route params from nested route children.
445fn collect_params_from_children(
446    children: &[WebUIFragmentRoute],
447    request_path: &str,
448    route_base: &str,
449    all_params: &mut HashMap<String, String>,
450) {
451    let mut current = children;
452    let mut base = route_base.to_string();
453
454    loop {
455        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
456        for (idx, child) in current.iter().enumerate() {
457            let resolved = route_matcher::resolve_route_path(&child.path, &base);
458            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
459            {
460                let is_better = best
461                    .as_ref()
462                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
463                if is_better {
464                    best = Some((idx, m));
465                }
466            }
467        }
468
469        let Some((idx, rm)) = best else { break };
470        all_params.extend(rm.params);
471        let matched = &current[idx];
472        if matched.children.is_empty() {
473            break;
474        }
475        let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
476        current = &matched.children;
477        base = child_base;
478    }
479}
480
481// ── Partial Response ────────────────────────────────────────────────
482
483/// Produce a complete JSON partial response for client-side navigation.
484///
485/// Combines route templates, inventory, and matched route chain into a single
486/// JSON value. Host servers include this directly in their response alongside
487/// the application `state`.
488///
489/// Returns a `serde_json::Value` object with fields:
490/// - `state`: the application state passed through
491/// - `templateStyles`: module CSS definition tags for inventory-new components (empty for Link/Style)
492/// - `templates`: client template payloads the client doesn't already have (inventory-filtered)
493/// - `inventory`: updated hex bitmask
494/// - `path`: the request path
495/// - `chain`: matched route chain array
496#[must_use]
497pub fn render_partial(
498    protocol: &WebUIProtocol,
499    state: Value,
500    entry_id: &str,
501    request_path: &str,
502    inventory_hex: &str,
503) -> Value {
504    // Filter by inventory — only send components the client doesn't have.
505    let (needed_names, updated_inv) =
506        get_needed_components_for_request(protocol, entry_id, request_path, inventory_hex);
507
508    // Build the matched route chain
509    let chain = collect_route_chain(protocol, entry_id, request_path);
510
511    // Collect templates + CSS using the shared helper
512    let tag_refs: Vec<&str> = needed_names.iter().map(|s| s.as_str()).collect();
513    let (style_array, tmpl_array) = collect_component_assets(protocol, &tag_refs);
514
515    let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
516
517    let mut result = serde_json::Map::with_capacity(6);
518    result.insert("state".into(), state);
519    result.insert("templateStyles".into(), Value::Array(style_array));
520    result.insert("templates".into(), Value::Array(tmpl_array));
521    result.insert("inventory".into(), Value::String(updated_inv));
522    result.insert("path".into(), Value::String(request_path.to_string()));
523    result.insert("chain".into(), chain_array);
524    Value::Object(result)
525}
526
527/// Return compiled templates and CSS for specific components by tag name.
528///
529/// This supports on-demand loading of components that aren't part of the
530/// route tree (e.g., dialogs, popovers) — the client calls this before
531/// creating the element so templates + styles are registered without FOUC.
532///
533/// Uses the same inventory bitfield as partial navigation to avoid sending
534/// templates the client already has.
535#[must_use]
536pub fn render_component_templates(
537    protocol: &WebUIProtocol,
538    component_tags: &[&str],
539    inventory_hex: &str,
540) -> Value {
541    let requested: HashSet<String> = component_tags.iter().map(|s| s.to_string()).collect();
542    let index = build_component_index(protocol);
543    let (needed, updated_inv) = filter_needed_components(&requested, inventory_hex, &index);
544
545    let tag_refs: Vec<&str> = needed.iter().map(|s| s.as_str()).collect();
546    let (style_array, tmpl_array) = collect_component_assets(protocol, &tag_refs);
547
548    let mut result = serde_json::Map::with_capacity(3);
549    result.insert("templateStyles".into(), Value::Array(style_array));
550    result.insert("templates".into(), Value::Array(tmpl_array));
551    result.insert("inventory".into(), Value::String(updated_inv));
552    Value::Object(result)
553}
554
555/// Shared helper: collect templates and module CSS styles for a set of component tags.
556fn collect_component_assets(protocol: &WebUIProtocol, tags: &[&str]) -> (Vec<Value>, Vec<Value>) {
557    let mut style_array = Vec::new();
558    let mut tmpl_array = Vec::new();
559
560    // Sort for deterministic output (reproducible responses, cache keys)
561    let mut sorted_tags: Vec<&str> = tags.to_vec();
562    sorted_tags.sort_unstable();
563
564    for tag in sorted_tags {
565        let Some(component) = protocol.components.get(tag) else {
566            continue;
567        };
568        if component.template.is_empty() {
569            continue;
570        }
571        if !component.css.is_empty() {
572            let mut s = String::with_capacity(40 + tag.len() + component.css.len());
573            s.push_str("<style type=\"module\" specifier=\"");
574            s.push_str(tag);
575            s.push_str("\">");
576            s.push_str(&component.css);
577            s.push_str("</style>");
578            style_array.push(Value::String(s));
579        }
580        tmpl_array.push(Value::String(component.template.clone()));
581    }
582
583    (style_array, tmpl_array)
584}
585
586// ── Route Chain ─────────────────────────────────────────────────────
587
588/// A single entry in the matched route chain, one per nesting level.
589#[derive(Debug, Clone)]
590pub struct RouteChainEntry {
591    /// Component tag name for this route level.
592    pub component: String,
593    /// The route path pattern (as declared in the template).
594    pub path: String,
595    /// Bound route parameters at this level.
596    pub params: HashMap<String, String>,
597    /// Whether this route requires an exact match.
598    pub exact: bool,
599    /// Comma-separated allowlist of query parameters forwarded as attributes.
600    pub allowed_query: String,
601}
602
603impl RouteChainEntry {
604    /// Serialize this entry to a JSON value ready for inclusion in a partial response.
605    #[must_use]
606    pub fn to_json(&self) -> Value {
607        let mut obj = serde_json::Map::with_capacity(5);
608        obj.insert("component".into(), Value::String(self.component.clone()));
609        obj.insert("path".into(), Value::String(self.path.clone()));
610        if !self.params.is_empty() {
611            let params_obj: serde_json::Map<String, Value> = self
612                .params
613                .iter()
614                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
615                .collect();
616            obj.insert("params".into(), Value::Object(params_obj));
617        }
618        if self.exact {
619            obj.insert("exact".into(), Value::Bool(true));
620        }
621        if !self.allowed_query.is_empty() {
622            obj.insert(
623                "allowedQuery".into(),
624                Value::String(self.allowed_query.clone()),
625            );
626        }
627        Value::Object(obj)
628    }
629}
630
631/// Collect the matched route chain as a JSON array value.
632///
633/// Convenience wrapper around [`collect_route_chain`] that serializes the
634/// result into a `serde_json::Value::Array` ready for inclusion in a JSON
635/// partial response. Host servers in any language can include this directly
636/// without reimplementing the serialization.
637#[must_use]
638pub fn collect_route_chain_json(
639    protocol: &WebUIProtocol,
640    entry_id: &str,
641    request_path: &str,
642) -> Value {
643    let chain = collect_route_chain(protocol, entry_id, request_path);
644    Value::Array(chain.iter().map(RouteChainEntry::to_json).collect())
645}
646
647/// Collect the matched route chain for a request path.
648///
649/// Walks the fragment graph from `entry_id`, follows the matched route at
650/// each nesting level, and returns a chain entry per matched level.
651pub fn collect_route_chain(
652    protocol: &WebUIProtocol,
653    entry_id: &str,
654    request_path: &str,
655) -> Vec<RouteChainEntry> {
656    let mut chain = Vec::new();
657    let mut visited_fragments = HashSet::new();
658    let mut stack = vec![QueuedFragment {
659        id: entry_id.to_string(),
660        inventoryable: false,
661        route_base: "/".to_string(),
662    }];
663
664    while let Some(queued) = stack.pop() {
665        if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
666            continue;
667        }
668
669        let Some(frag_list) = protocol.fragments.get(&queued.id) else {
670            continue;
671        };
672
673        let matched_route =
674            find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
675
676        for frag in &frag_list.fragments {
677            match frag.fragment.as_ref() {
678                Some(Fragment::Component(component)) => {
679                    stack.push(QueuedFragment {
680                        id: component.fragment_id.clone(),
681                        inventoryable: false,
682                        route_base: queued.route_base.clone(),
683                    });
684                }
685                Some(Fragment::Route(route_frag)) => {
686                    let is_selected = matched_route
687                        .as_ref()
688                        .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
689                    if is_selected && !route_frag.fragment_id.is_empty() {
690                        if let Some((_, ref rm)) = matched_route {
691                            chain.push(RouteChainEntry {
692                                component: route_frag.fragment_id.clone(),
693                                path: route_frag.path.clone(),
694                                params: rm.params.clone(),
695                                exact: route_frag.exact,
696                                allowed_query: route_frag.allowed_query.clone(),
697                            });
698
699                            let child_route_base = route_matcher::compute_route_base(
700                                request_path,
701                                rm.consumed_segments,
702                            );
703
704                            stack.push(QueuedFragment {
705                                id: route_frag.fragment_id.clone(),
706                                inventoryable: false,
707                                route_base: child_route_base.clone(),
708                            });
709
710                            // Walk nested children iteratively
711                            collect_chain_from_children(
712                                &route_frag.children,
713                                request_path,
714                                &child_route_base,
715                                &mut chain,
716                            );
717                        }
718                    }
719                }
720                _ => {}
721            }
722        }
723    }
724
725    chain
726}
727
728/// Iteratively collect chain entries from nested route children.
729fn collect_chain_from_children(
730    children: &[WebUIFragmentRoute],
731    request_path: &str,
732    route_base: &str,
733    chain: &mut Vec<RouteChainEntry>,
734) {
735    let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
736        vec![(children, route_base.to_string())];
737
738    while let Some((current, base)) = pending.pop() {
739        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
740        for (idx, child) in current.iter().enumerate() {
741            let resolved = route_matcher::resolve_route_path(&child.path, &base);
742            if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
743            {
744                let is_better = best
745                    .as_ref()
746                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
747                if is_better {
748                    best = Some((idx, m));
749                }
750            }
751        }
752
753        if let Some((idx, rm)) = best {
754            let matched = &current[idx];
755            chain.push(RouteChainEntry {
756                component: matched.fragment_id.clone(),
757                path: matched.path.clone(),
758                params: rm.params,
759                exact: matched.exact,
760                allowed_query: matched.allowed_query.clone(),
761            });
762            if !matched.children.is_empty() {
763                let child_base =
764                    route_matcher::compute_route_base(request_path, rm.consumed_segments);
765                pending.push((&matched.children, child_base));
766            }
767        }
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use std::collections::HashMap;
775    use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
776
777    #[test]
778    fn test_parse_encode_inventory_roundtrip() {
779        let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
780        let hex = encode_inventory(&original);
781        let decoded = parse_inventory(&hex);
782        assert_eq!(original, decoded);
783    }
784
785    #[test]
786    fn test_bitfield_set_and_check() {
787        let mut inv = vec![0u8; 4];
788        set_component(&mut inv, 0);
789        set_component(&mut inv, 7);
790        set_component(&mut inv, 8);
791        set_component(&mut inv, 15);
792        assert!(has_component(&inv, 0));
793        assert!(has_component(&inv, 7));
794        assert!(has_component(&inv, 8));
795        assert!(has_component(&inv, 15));
796        assert!(!has_component(&inv, 1));
797        assert!(!has_component(&inv, 9));
798    }
799
800    #[test]
801    fn test_filter_needed_components_no_false_positives() {
802        // With sequential indices, no two components share a bit
803        let mut index = HashMap::new();
804        index.insert("email-message".to_string(), 0);
805        index.insert("o-button".to_string(), 1);
806        index.insert("o-avatar".to_string(), 2);
807
808        let mut names = HashSet::new();
809        names.insert("email-message".to_string());
810        names.insert("o-button".to_string());
811
812        // Only o-button (index 1) is loaded — bit 1 set
813        let mut inv = vec![0u8; 1];
814        set_component(&mut inv, 1); // o-button
815        let inv_hex = encode_inventory(&inv);
816
817        let (needed, _) = filter_needed_components(&names, &inv_hex, &index);
818        assert_eq!(needed.len(), 1);
819        assert!(
820            needed.contains(&"email-message".to_string()),
821            "email-message must be needed: {needed:?}"
822        );
823    }
824
825    #[test]
826    fn test_get_needed_components_empty_inventory() {
827        let mut fragments = HashMap::new();
828        fragments.insert(
829            "app-shell".to_string(),
830            FragmentList {
831                fragments: vec![WebUIFragment::component("my-card")],
832            },
833        );
834        fragments.insert(
835            "my-card".to_string(),
836            FragmentList {
837                fragments: vec![WebUIFragment::raw("card")],
838            },
839        );
840
841        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
842        let (needed, _inv) = get_needed_components(&protocol, "app-shell", "");
843        assert!(needed.contains(&"app-shell".to_string()));
844        assert!(needed.contains(&"my-card".to_string()));
845    }
846
847    #[test]
848    fn test_get_needed_components_skips_inventory() {
849        let mut fragments = HashMap::new();
850        fragments.insert(
851            "app-shell".to_string(),
852            FragmentList {
853                fragments: vec![WebUIFragment::component("my-card")],
854            },
855        );
856        fragments.insert(
857            "my-card".to_string(),
858            FragmentList {
859                fragments: vec![WebUIFragment::raw("card")],
860            },
861        );
862
863        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
864        protocol
865            .components
866            .entry("app-shell".to_string())
867            .or_default()
868            .template = "<t></t>".to_string();
869        protocol
870            .components
871            .entry("my-card".to_string())
872            .or_default()
873            .template = "<t></t>".to_string();
874
875        let (_needed, inv_hex) = get_needed_components(&protocol, "app-shell", "");
876        let (needed2, _) = get_needed_components(&protocol, "app-shell", &inv_hex);
877        assert!(needed2.is_empty());
878    }
879
880    #[test]
881    fn test_get_needed_components_skips_control_fragments() {
882        let mut fragments = HashMap::new();
883        fragments.insert(
884            "app-shell".to_string(),
885            FragmentList {
886                fragments: vec![
887                    WebUIFragment::if_cond(
888                        webui_protocol::ConditionExpr::identifier("showNav"),
889                        "if-shell",
890                    ),
891                    WebUIFragment::for_loop("item", "items", "for-items"),
892                    WebUIFragment::attribute_template("title", "attr-title"),
893                ],
894            },
895        );
896        fragments.insert(
897            "if-shell".to_string(),
898            FragmentList {
899                fragments: vec![WebUIFragment::component("mp-category-nav")],
900            },
901        );
902        fragments.insert(
903            "for-items".to_string(),
904            FragmentList {
905                fragments: vec![WebUIFragment::component("mp-product-card")],
906            },
907        );
908        fragments.insert(
909            "attr-title".to_string(),
910            FragmentList {
911                fragments: vec![WebUIFragment::raw("Products")],
912            },
913        );
914        fragments.insert(
915            "mp-category-nav".to_string(),
916            FragmentList {
917                fragments: vec![WebUIFragment::raw("<nav></nav>")],
918            },
919        );
920        fragments.insert(
921            "mp-product-card".to_string(),
922            FragmentList {
923                fragments: vec![WebUIFragment::raw("<article></article>")],
924            },
925        );
926
927        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
928        let needed: HashSet<String> = get_needed_components(&protocol, "app-shell", "")
929            .0
930            .into_iter()
931            .collect();
932
933        assert!(needed.contains("app-shell"));
934        assert!(needed.contains("mp-category-nav"));
935        assert!(needed.contains("mp-product-card"));
936        assert!(!needed.contains("if-shell"));
937        assert!(!needed.contains("for-items"));
938        assert!(!needed.contains("attr-title"));
939    }
940
941    #[test]
942    fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
943        let mut fragments = HashMap::new();
944        fragments.insert(
945            "index.html".to_string(),
946            FragmentList {
947                fragments: vec![WebUIFragment::component("mp-app")],
948            },
949        );
950        fragments.insert(
951            "mp-app".to_string(),
952            FragmentList {
953                fragments: vec![
954                    WebUIFragment::component("mp-category-nav"),
955                    WebUIFragment::route_from(WebUiFragmentRoute {
956                        path: "/search/:category".into(),
957                        fragment_id: "mp-search-page".into(),
958                        exact: true,
959                        ..Default::default()
960                    }),
961                    WebUIFragment::route_from(WebUiFragmentRoute {
962                        path: "/product/:handle".into(),
963                        fragment_id: "mp-product-page".into(),
964                        exact: true,
965                        ..Default::default()
966                    }),
967                ],
968            },
969        );
970        fragments.insert(
971            "mp-category-nav".to_string(),
972            FragmentList {
973                fragments: vec![WebUIFragment::raw("<nav></nav>")],
974            },
975        );
976        fragments.insert(
977            "mp-search-page".to_string(),
978            FragmentList {
979                fragments: vec![WebUIFragment::component("mp-product-grid")],
980            },
981        );
982        fragments.insert(
983            "mp-product-grid".to_string(),
984            FragmentList {
985                fragments: vec![WebUIFragment::raw("<div>grid</div>")],
986            },
987        );
988        fragments.insert(
989            "mp-product-page".to_string(),
990            FragmentList {
991                fragments: vec![WebUIFragment::component("mp-product-detail")],
992            },
993        );
994        fragments.insert(
995            "mp-product-detail".to_string(),
996            FragmentList {
997                fragments: vec![WebUIFragment::raw("<div>detail</div>")],
998            },
999        );
1000
1001        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1002        protocol
1003            .components
1004            .entry("mp-search-page".to_string())
1005            .or_default()
1006            .template = "<mp-search-page></mp-search-page>".to_string();
1007        protocol
1008            .components
1009            .entry("mp-product-page".to_string())
1010            .or_default()
1011            .template = "<mp-product-page></mp-product-page>".to_string();
1012
1013        let (needed, _inv) =
1014            get_needed_components_for_request(&protocol, "index.html", "/search/shirts", "");
1015        let needed: HashSet<String> = needed.into_iter().collect();
1016
1017        assert!(needed.contains("mp-app"));
1018        assert!(needed.contains("mp-category-nav"));
1019        assert!(needed.contains("mp-search-page"));
1020        assert!(needed.contains("mp-product-grid"));
1021        assert!(!needed.contains("mp-product-page"));
1022        assert!(!needed.contains("mp-product-detail"));
1023    }
1024
1025    #[test]
1026    fn test_get_needed_components_for_request_follows_nested_route_chain() {
1027        let mut fragments = HashMap::new();
1028        fragments.insert(
1029            "index.html".to_string(),
1030            FragmentList {
1031                fragments: vec![WebUIFragment::component("mp-app")],
1032            },
1033        );
1034        fragments.insert(
1035            "mp-app".to_string(),
1036            FragmentList {
1037                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1038                    path: "/account/*rest".into(),
1039                    fragment_id: "mp-account-shell".into(),
1040                    exact: false,
1041                    ..Default::default()
1042                })],
1043            },
1044        );
1045        fragments.insert(
1046            "mp-account-shell".to_string(),
1047            FragmentList {
1048                fragments: vec![
1049                    WebUIFragment::component("mp-account-nav"),
1050                    WebUIFragment::route_from(WebUiFragmentRoute {
1051                        path: "/account/profile".into(),
1052                        fragment_id: "mp-profile-page".into(),
1053                        exact: true,
1054                        ..Default::default()
1055                    }),
1056                    WebUIFragment::route_from(WebUiFragmentRoute {
1057                        path: "/account/orders/:id".into(),
1058                        fragment_id: "mp-order-page".into(),
1059                        exact: true,
1060                        ..Default::default()
1061                    }),
1062                ],
1063            },
1064        );
1065        fragments.insert(
1066            "mp-account-nav".to_string(),
1067            FragmentList {
1068                fragments: vec![WebUIFragment::raw("<nav></nav>")],
1069            },
1070        );
1071        fragments.insert(
1072            "mp-profile-page".to_string(),
1073            FragmentList {
1074                fragments: vec![WebUIFragment::raw("<profile></profile>")],
1075            },
1076        );
1077        fragments.insert(
1078            "mp-order-page".to_string(),
1079            FragmentList {
1080                fragments: vec![WebUIFragment::component("mp-order-detail")],
1081            },
1082        );
1083        fragments.insert(
1084            "mp-order-detail".to_string(),
1085            FragmentList {
1086                fragments: vec![WebUIFragment::raw("<detail></detail>")],
1087            },
1088        );
1089
1090        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1091        protocol
1092            .components
1093            .entry("mp-account-shell".to_string())
1094            .or_default()
1095            .template = "<mp-account-shell></mp-account-shell>".to_string();
1096        protocol
1097            .components
1098            .entry("mp-profile-page".to_string())
1099            .or_default()
1100            .template = "<mp-profile-page></mp-profile-page>".to_string();
1101        protocol
1102            .components
1103            .entry("mp-order-page".to_string())
1104            .or_default()
1105            .template = "<mp-order-page></mp-order-page>".to_string();
1106
1107        let (needed, _inv) =
1108            get_needed_components_for_request(&protocol, "index.html", "/account/orders/42", "");
1109        let needed: HashSet<String> = needed.into_iter().collect();
1110
1111        assert!(needed.contains("mp-app"));
1112        assert!(needed.contains("mp-account-shell"));
1113        assert!(needed.contains("mp-account-nav"));
1114        assert!(needed.contains("mp-order-page"));
1115        assert!(needed.contains("mp-order-detail"));
1116        assert!(!needed.contains("mp-profile-page"));
1117    }
1118
1119    #[test]
1120    fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1121        let mut fragments = HashMap::new();
1122        fragments.insert(
1123            "index.html".to_string(),
1124            FragmentList {
1125                fragments: vec![WebUIFragment::component("mp-app")],
1126            },
1127        );
1128        fragments.insert(
1129            "mp-app".to_string(),
1130            FragmentList {
1131                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1132                    path: "/search".into(),
1133                    fragment_id: "mp-search-page".into(),
1134                    exact: true,
1135                    ..Default::default()
1136                })],
1137            },
1138        );
1139        fragments.insert(
1140            "mp-search-page".to_string(),
1141            FragmentList {
1142                fragments: vec![
1143                    WebUIFragment::if_cond(
1144                        webui_protocol::ConditionExpr::identifier("showFilters"),
1145                        "if-filters",
1146                    ),
1147                    WebUIFragment::for_loop("item", "items", "item-loop"),
1148                ],
1149            },
1150        );
1151        fragments.insert(
1152            "if-filters".to_string(),
1153            FragmentList {
1154                fragments: vec![WebUIFragment::component("mp-filter-panel")],
1155            },
1156        );
1157        fragments.insert(
1158            "item-loop".to_string(),
1159            FragmentList {
1160                fragments: vec![WebUIFragment::component("mp-item-card")],
1161            },
1162        );
1163        fragments.insert(
1164            "mp-filter-panel".to_string(),
1165            FragmentList {
1166                fragments: vec![WebUIFragment::raw("<filters></filters>")],
1167            },
1168        );
1169        fragments.insert(
1170            "mp-item-card".to_string(),
1171            FragmentList {
1172                fragments: vec![WebUIFragment::raw("<item></item>")],
1173            },
1174        );
1175
1176        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1177        protocol
1178            .components
1179            .entry("mp-search-page".to_string())
1180            .or_default()
1181            .template = "<mp-search-page></mp-search-page>".to_string();
1182
1183        let (needed, _inv) =
1184            get_needed_components_for_request(&protocol, "index.html", "/search", "");
1185        let needed: HashSet<String> = needed.into_iter().collect();
1186
1187        assert!(needed.contains("mp-app"));
1188        assert!(needed.contains("mp-search-page"));
1189        assert!(needed.contains("mp-filter-panel"));
1190        assert!(needed.contains("mp-item-card"));
1191    }
1192
1193    #[test]
1194    fn test_get_needed_components_for_request_respects_inventory() {
1195        let mut fragments = HashMap::new();
1196        fragments.insert(
1197            "index.html".to_string(),
1198            FragmentList {
1199                fragments: vec![WebUIFragment::component("mp-app")],
1200            },
1201        );
1202        fragments.insert(
1203            "mp-app".to_string(),
1204            FragmentList {
1205                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1206                    path: "/search".into(),
1207                    fragment_id: "mp-search-page".into(),
1208                    exact: true,
1209                    ..Default::default()
1210                })],
1211            },
1212        );
1213        fragments.insert(
1214            "mp-search-page".to_string(),
1215            FragmentList {
1216                fragments: vec![WebUIFragment::component("mp-product-grid")],
1217            },
1218        );
1219        fragments.insert(
1220            "mp-product-grid".to_string(),
1221            FragmentList {
1222                fragments: vec![WebUIFragment::raw("<grid></grid>")],
1223            },
1224        );
1225
1226        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1227        for name in ["mp-app", "mp-search-page", "mp-product-grid"] {
1228            protocol
1229                .components
1230                .entry(name.to_string())
1231                .or_default()
1232                .template = format!("<f-template id=\"{name}\"></f-template>");
1233        }
1234
1235        let (_needed, inventory) =
1236            get_needed_components_for_request(&protocol, "index.html", "/search", "");
1237        let (needed_again, _) =
1238            get_needed_components_for_request(&protocol, "index.html", "/search", &inventory);
1239        assert!(needed_again.is_empty());
1240    }
1241
1242    #[test]
1243    fn test_render_partial_separates_module_styles_from_templates() {
1244        let mut fragments = HashMap::new();
1245        fragments.insert(
1246            "index.html".to_string(),
1247            FragmentList {
1248                fragments: vec![WebUIFragment::component("my-page")],
1249            },
1250        );
1251        fragments.insert(
1252            "my-page".to_string(),
1253            FragmentList {
1254                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1255            },
1256        );
1257
1258        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1259        let component = protocol
1260            .components
1261            .entry("my-page".to_string())
1262            .or_default();
1263        component.template =
1264            "(function(){window.__webui_templates['my-page']={h:'<p>page</p>'};})();".to_string();
1265        component.css = ".page{color:red}".to_string();
1266
1267        let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1268
1269        // templateStyles should contain the separated module style tag
1270        let styles = partial["templateStyles"]
1271            .as_array()
1272            .expect("templateStyles should be an array");
1273        assert_eq!(styles.len(), 1);
1274        assert!(
1275            styles[0]
1276                .as_str()
1277                .unwrap_or_default()
1278                .contains("specifier=\"my-page\""),
1279            "module style should carry the component specifier"
1280        );
1281        assert!(
1282            styles[0]
1283                .as_str()
1284                .unwrap_or_default()
1285                .contains(".page{color:red}"),
1286            "module style should contain the CSS content"
1287        );
1288
1289        // templates should contain only the clean JS IIFE
1290        let templates = partial["templates"]
1291            .as_array()
1292            .expect("templates should be an array");
1293        assert_eq!(templates.len(), 1);
1294        assert!(
1295            !templates[0].as_str().unwrap_or_default().contains("<style"),
1296            "template should not contain any style tags"
1297        );
1298        assert!(
1299            templates[0]
1300                .as_str()
1301                .unwrap_or_default()
1302                .starts_with("(function()"),
1303            "template should be a raw JS IIFE"
1304        );
1305    }
1306
1307    #[test]
1308    fn test_render_partial_link_strategy_has_empty_template_styles() {
1309        // Link-strategy components have css_href but no css content.
1310        // templateStyles should be empty; templates should contain the IIFE.
1311        let mut fragments = HashMap::new();
1312        fragments.insert(
1313            "index.html".to_string(),
1314            FragmentList {
1315                fragments: vec![WebUIFragment::component("my-page")],
1316            },
1317        );
1318        fragments.insert(
1319            "my-page".to_string(),
1320            FragmentList {
1321                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1322            },
1323        );
1324
1325        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1326        let component = protocol
1327            .components
1328            .entry("my-page".to_string())
1329            .or_default();
1330        component.template = "(function(){})();".to_string();
1331        component.css_href = "/my-page.css".to_string();
1332        // css is empty — Link strategy stores href, not content
1333
1334        let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1335        let styles = partial["templateStyles"]
1336            .as_array()
1337            .expect("templateStyles should be an array");
1338        let templates = partial["templates"]
1339            .as_array()
1340            .expect("templates should be an array");
1341
1342        assert!(
1343            styles.is_empty(),
1344            "Link strategy should produce empty templateStyles: {styles:?}"
1345        );
1346        assert_eq!(templates.len(), 1, "should include the template IIFE");
1347    }
1348
1349    #[test]
1350    fn test_render_partial_style_strategy_has_empty_template_styles() {
1351        // Style-strategy components have CSS embedded in the template HTML
1352        // (as <style>...</style>), not in component.css.
1353        // templateStyles should be empty; templates should contain the IIFE.
1354        let mut fragments = HashMap::new();
1355        fragments.insert(
1356            "index.html".to_string(),
1357            FragmentList {
1358                fragments: vec![WebUIFragment::component("my-page")],
1359            },
1360        );
1361        fragments.insert(
1362            "my-page".to_string(),
1363            FragmentList {
1364                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1365            },
1366        );
1367
1368        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1369        let component = protocol
1370            .components
1371            .entry("my-page".to_string())
1372            .or_default();
1373        // Style strategy: CSS is inside the template HTML, not in component.css
1374        component.template =
1375            "(function(){var w=window.__webui_templates;w['my-page']={h:'<style>.p{color:red}</style><p>page</p>'};})();"
1376                .to_string();
1377        // css is empty for Style strategy
1378
1379        let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1380        let styles = partial["templateStyles"]
1381            .as_array()
1382            .expect("templateStyles should be an array");
1383        let templates = partial["templates"]
1384            .as_array()
1385            .expect("templates should be an array");
1386
1387        assert!(
1388            styles.is_empty(),
1389            "Style strategy should produce empty templateStyles: {styles:?}"
1390        );
1391        assert_eq!(templates.len(), 1, "should include the template IIFE");
1392        assert!(
1393            templates[0]
1394                .as_str()
1395                .unwrap_or_default()
1396                .contains("<style>"),
1397            "Style strategy template should contain inline <style> tag"
1398        );
1399    }
1400
1401    #[test]
1402    fn test_render_partial_empty_styles_for_no_css_components() {
1403        let mut fragments = HashMap::new();
1404        fragments.insert(
1405            "index.html".to_string(),
1406            FragmentList {
1407                fragments: vec![WebUIFragment::component("my-page")],
1408            },
1409        );
1410        fragments.insert(
1411            "my-page".to_string(),
1412            FragmentList {
1413                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1414            },
1415        );
1416
1417        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1418        let component = protocol
1419            .components
1420            .entry("my-page".to_string())
1421            .or_default();
1422        component.template = "(function(){})();".to_string();
1423        // No CSS — simulates Link or Style mode
1424
1425        let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1426        let styles = partial["templateStyles"]
1427            .as_array()
1428            .expect("templateStyles should be an array");
1429        assert!(
1430            styles.is_empty(),
1431            "templateStyles should be empty when components have no CSS"
1432        );
1433    }
1434
1435    #[test]
1436    fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
1437        let mut fragments = HashMap::new();
1438        fragments.insert(
1439            "index.html".to_string(),
1440            FragmentList {
1441                fragments: vec![WebUIFragment::component("my-page")],
1442            },
1443        );
1444        fragments.insert(
1445            "my-page".to_string(),
1446            FragmentList {
1447                fragments: vec![WebUIFragment::raw("<p>page</p>")],
1448            },
1449        );
1450
1451        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1452        let component = protocol
1453            .components
1454            .entry("my-page".to_string())
1455            .or_default();
1456        component.template = "(function(){})();".to_string();
1457        component.css = ".page{color:red}".to_string();
1458
1459        // First call to establish inventory
1460        let partial1 = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1461        let inv = partial1["inventory"].as_str().unwrap_or_default();
1462        assert!(!inv.is_empty());
1463
1464        // Second call with the inventory — both templates and styles should be empty
1465        // because the inventory covers this component.  The SSR handler emits all
1466        // module style definitions in <head> for inventoried components, so the
1467        // client already has the CSS definition.
1468        let partial2 = render_partial(&protocol, serde_json::json!({}), "index.html", "/", inv);
1469        let styles = partial2["templateStyles"]
1470            .as_array()
1471            .expect("templateStyles should be an array");
1472        let templates = partial2["templates"]
1473            .as_array()
1474            .expect("templates should be an array");
1475
1476        assert!(
1477            templates.is_empty(),
1478            "templates should be empty when inventory is full"
1479        );
1480        assert!(
1481            styles.is_empty(),
1482            "module styles should be empty when inventory is full — SSR already placed them"
1483        );
1484    }
1485
1486    #[test]
1487    fn test_non_route_siblings_included_in_needed_components() {
1488        // Reproduces the commerce app layout: mp-app has both route children
1489        // (via outlet) AND non-route sibling components (mp-cart-panel, mp-footer).
1490        // All siblings must be in the needed set so their module CSS definitions
1491        // are emitted in <head> during SSR.
1492        let mut fragments = HashMap::new();
1493
1494        // Entry → app-shell component
1495        fragments.insert(
1496            "index.html".to_string(),
1497            FragmentList {
1498                fragments: vec![WebUIFragment::component("app-shell")],
1499            },
1500        );
1501
1502        // app-shell has: navbar (component), route (outlet), cart-panel (component)
1503        fragments.insert(
1504            "app-shell".to_string(),
1505            FragmentList {
1506                fragments: vec![
1507                    WebUIFragment::component("my-navbar"),
1508                    WebUIFragment::route_from(WebUiFragmentRoute {
1509                        path: "/about".into(),
1510                        fragment_id: "page-about".into(),
1511                        exact: true,
1512                        ..Default::default()
1513                    }),
1514                    WebUIFragment::component("cart-panel"),
1515                ],
1516            },
1517        );
1518
1519        // Leaf fragments
1520        fragments.insert(
1521            "my-navbar".to_string(),
1522            FragmentList {
1523                fragments: vec![WebUIFragment::raw("<nav/>")],
1524            },
1525        );
1526        fragments.insert(
1527            "page-about".to_string(),
1528            FragmentList {
1529                fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
1530            },
1531        );
1532        fragments.insert(
1533            "cart-panel".to_string(),
1534            FragmentList {
1535                fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
1536            },
1537        );
1538
1539        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1540        for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
1541            let comp = protocol.components.entry(name.to_string()).or_default();
1542            comp.template = format!("(function(){{/* {name} */}})();");
1543            comp.css = format!(".{name}{{display:block}}");
1544        }
1545
1546        let (needed, _inv) =
1547            get_needed_components_for_request(&protocol, "index.html", "/about", "");
1548
1549        assert!(
1550            needed.contains(&"app-shell".to_string()),
1551            "app-shell should be needed: {needed:?}"
1552        );
1553        assert!(
1554            needed.contains(&"my-navbar".to_string()),
1555            "my-navbar (non-route sibling) should be needed: {needed:?}"
1556        );
1557        assert!(
1558            needed.contains(&"page-about".to_string()),
1559            "page-about (active route) should be needed: {needed:?}"
1560        );
1561        assert!(
1562            needed.contains(&"cart-panel".to_string()),
1563            "cart-panel (non-route sibling) should be needed: {needed:?}"
1564        );
1565    }
1566
1567    // ── RouteChainEntry.to_json + allowed_query tests ────────────────
1568
1569    #[test]
1570    fn test_chain_entry_to_json_includes_allowed_query() {
1571        let entry = RouteChainEntry {
1572            component: "compose-page".into(),
1573            path: "compose".into(),
1574            params: HashMap::new(),
1575            exact: true,
1576            allowed_query: "action,to,subject".into(),
1577        };
1578        let json = entry.to_json();
1579        assert_eq!(json["allowedQuery"], "action,to,subject");
1580    }
1581
1582    #[test]
1583    fn test_chain_entry_to_json_omits_empty_allowed_query() {
1584        let entry = RouteChainEntry {
1585            component: "home-page".into(),
1586            path: "/".into(),
1587            params: HashMap::new(),
1588            exact: true,
1589            allowed_query: String::new(),
1590        };
1591        let json = entry.to_json();
1592        assert!(
1593            json.get("allowedQuery").is_none(),
1594            "empty allowed_query should not appear in JSON"
1595        );
1596    }
1597
1598    #[test]
1599    fn test_collect_route_chain_carries_allowed_query() {
1600        let mut fragments = HashMap::new();
1601        fragments.insert(
1602            "index.html".to_string(),
1603            FragmentList {
1604                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1605                    path: "/".into(),
1606                    fragment_id: "app-shell".into(),
1607                    exact: false,
1608                    children: vec![WebUiFragmentRoute {
1609                        path: "compose".into(),
1610                        fragment_id: "compose-page".into(),
1611                        exact: true,
1612                        allowed_query: "action,to".into(),
1613                        ..Default::default()
1614                    }],
1615                    ..Default::default()
1616                })],
1617            },
1618        );
1619        fragments.insert(
1620            "app-shell".to_string(),
1621            FragmentList {
1622                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
1623            },
1624        );
1625        fragments.insert(
1626            "compose-page".to_string(),
1627            FragmentList {
1628                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
1629            },
1630        );
1631        let protocol = WebUIProtocol::new(fragments);
1632
1633        let chain = collect_route_chain(&protocol, "index.html", "/compose");
1634        assert_eq!(chain.len(), 2);
1635        assert_eq!(chain[0].component, "app-shell");
1636        assert!(chain[0].allowed_query.is_empty());
1637        assert_eq!(chain[1].component, "compose-page");
1638        assert_eq!(chain[1].allowed_query, "action,to");
1639    }
1640
1641    #[test]
1642    fn test_render_component_templates_returns_template_and_css() {
1643        let mut fragments = HashMap::new();
1644        fragments.insert(
1645            "settings-dialog".to_string(),
1646            FragmentList {
1647                fragments: vec![WebUIFragment::raw("<div class='dialog'>Settings</div>")],
1648            },
1649        );
1650        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1651        let comp = protocol
1652            .components
1653            .entry("settings-dialog".to_string())
1654            .or_default();
1655        comp.template = "(function(){window.__webui_templates['settings-dialog']={h:'<div>Settings</div>'};})();".to_string();
1656        comp.css = ".dialog{position:fixed}".to_string();
1657
1658        let result = render_component_templates(&protocol, &["settings-dialog"], "");
1659        let templates = result["templates"].as_array().expect("templates array");
1660        let styles = result["templateStyles"].as_array().expect("styles array");
1661
1662        assert_eq!(templates.len(), 1);
1663        assert!(templates[0].as_str().unwrap().contains("settings-dialog"));
1664        assert_eq!(styles.len(), 1);
1665        assert!(styles[0]
1666            .as_str()
1667            .unwrap()
1668            .contains(".dialog{position:fixed}"));
1669    }
1670
1671    #[test]
1672    fn test_render_component_templates_respects_inventory() {
1673        let mut fragments = HashMap::new();
1674        fragments.insert(
1675            "my-dialog".to_string(),
1676            FragmentList {
1677                fragments: vec![WebUIFragment::raw("<div>Dialog</div>")],
1678            },
1679        );
1680        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1681        let comp = protocol
1682            .components
1683            .entry("my-dialog".to_string())
1684            .or_default();
1685        comp.template = "(function(){})();".to_string();
1686        comp.css = ".d{color:red}".to_string();
1687
1688        // First call: no inventory → should return the component
1689        let result1 = render_component_templates(&protocol, &["my-dialog"], "");
1690        let inv = result1["inventory"].as_str().expect("inventory string");
1691        assert_eq!(result1["templates"].as_array().unwrap().len(), 1);
1692
1693        // Second call with inventory → component already loaded, should skip
1694        let result2 = render_component_templates(&protocol, &["my-dialog"], inv);
1695        assert_eq!(result2["templates"].as_array().unwrap().len(), 0);
1696        assert_eq!(result2["templateStyles"].as_array().unwrap().len(), 0);
1697    }
1698
1699    #[test]
1700    fn test_render_component_templates_unknown_component_returns_empty() {
1701        let fragments = HashMap::new();
1702        let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1703
1704        let result = render_component_templates(&protocol, &["nonexistent-widget"], "");
1705        assert_eq!(result["templates"].as_array().unwrap().len(), 0);
1706        assert_eq!(result["templateStyles"].as_array().unwrap().len(), 0);
1707    }
1708}