Skip to main content

webui_protocol/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI Protocol implementation.
5//!
6//! This crate defines the protocol used by the WebUI framework for cross-platform
7//! representation of UI components and templates. Types are generated directly
8//! from `proto/webui.proto` using prost for optimal runtime performance —
9//! no conversion layer between domain types and protobuf types.
10
11use prost::Message;
12use std::collections::{HashMap, HashSet};
13use std::fmt;
14use std::io;
15use thiserror::Error;
16
17/// Plugin-specific protocol helpers for framework hydration metadata.
18pub mod plugin;
19
20/// Bundler-neutral state-projection manifest contract.
21#[cfg(feature = "projection-manifest")]
22pub mod projection_manifest;
23
24/// Attribute-name ↔ property-name mapping for irregular HTML attributes.
25pub mod attrs;
26
27/// Generated protobuf types from `proto/webui.proto`.
28pub mod proto {
29    include!("gen_webui.rs");
30}
31
32// Re-export all generated types at the crate root.
33pub use plugin::FastElementData;
34pub use plugin::WebUIElementData;
35pub use proto::*;
36
37// Type aliases preserving the `WebUI` naming convention.
38// prost generates `WebUi*` from the proto `WebUI*` messages.
39pub type WebUIProtocol = WebUiProtocol;
40pub type WebUIFragment = WebUiFragment;
41pub type WebUIFragmentRaw = WebUiFragmentRaw;
42pub type WebUIFragmentComponent = WebUiFragmentComponent;
43pub type WebUIFragmentFor = WebUiFragmentFor;
44pub type WebUIFragmentSignal = WebUiFragmentSignal;
45pub type WebUIFragmentIf = WebUiFragmentIf;
46pub type WebUIFragmentAttribute = WebUiFragmentAttribute;
47pub type WebUIFragmentPlugin = WebUiFragmentPlugin;
48pub type WebUIFragmentRoute = WebUiFragmentRoute;
49pub type WebUIFragmentOutlet = WebUiFragmentOutlet;
50pub type WebUIFragmentBoundary = WebUiFragmentBoundary;
51pub type ComponentData = proto::ComponentData;
52pub type ComponentStyleClosure = proto::ComponentStyleClosure;
53pub type StyleChunk = proto::StyleChunk;
54
55/// A mapping of unique fragment identifiers to their corresponding fragment lists.
56pub type WebUIFragmentRecords = HashMap<String, FragmentList>;
57
58#[derive(Debug, Error)]
59pub enum ProtocolError {
60    #[error("IO error: {0}")]
61    Io(#[from] io::Error),
62
63    #[error("Protocol validation error: {0}")]
64    Validation(String),
65}
66
67pub type Result<T> = std::result::Result<T, ProtocolError>;
68
69// ── Display implementations ─────────────────────────────────────────────
70
71impl fmt::Display for ComparisonOperator {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            ComparisonOperator::GreaterThan => write!(f, ">"),
75            ComparisonOperator::LessThan => write!(f, "<"),
76            ComparisonOperator::Equal => write!(f, "=="),
77            ComparisonOperator::NotEqual => write!(f, "!="),
78            ComparisonOperator::GreaterThanOrEqual => write!(f, ">="),
79            ComparisonOperator::LessThanOrEqual => write!(f, "<="),
80            ComparisonOperator::Unspecified => write!(f, "?"),
81        }
82    }
83}
84
85impl fmt::Display for LogicalOperator {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            LogicalOperator::And => write!(f, "&&"),
89            LogicalOperator::Or => write!(f, "||"),
90            LogicalOperator::Unspecified => write!(f, "?"),
91        }
92    }
93}
94
95impl fmt::Display for ConditionExpr {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match &self.expr {
98            Some(condition_expr::Expr::Identifier(id)) => write!(f, "{}", id.value),
99            Some(condition_expr::Expr::Predicate(pred)) => {
100                let op = ComparisonOperator::try_from(pred.operator)
101                    .unwrap_or(ComparisonOperator::Unspecified);
102                write!(f, "{} {} {}", pred.left, op, pred.right)
103            }
104            Some(condition_expr::Expr::Not(not)) => match &not.condition {
105                Some(inner) => write!(f, "!({})", inner),
106                None => write!(f, "!(?)"),
107            },
108            Some(condition_expr::Expr::Compound(compound)) => {
109                let op =
110                    LogicalOperator::try_from(compound.op).unwrap_or(LogicalOperator::Unspecified);
111                let left_str = compound
112                    .left
113                    .as_ref()
114                    .map(|l| l.to_string())
115                    .unwrap_or_else(|| "?".to_string());
116                let right_str = compound
117                    .right
118                    .as_ref()
119                    .map(|r| r.to_string())
120                    .unwrap_or_else(|| "?".to_string());
121                write!(f, "({} {} {})", left_str, op, right_str)
122            }
123            None => write!(f, "<empty>"),
124        }
125    }
126}
127
128// ── Convenience constructors ────────────────────────────────────────────
129
130impl WebUiFragment {
131    /// Create a raw (static content) fragment.
132    pub fn raw(value: impl Into<String>) -> Self {
133        Self {
134            fragment: Some(web_ui_fragment::Fragment::Raw(WebUiFragmentRaw {
135                value: value.into(),
136            })),
137        }
138    }
139
140    /// Create a component fragment.
141    pub fn component(fragment_id: impl Into<String>) -> Self {
142        Self {
143            fragment: Some(web_ui_fragment::Fragment::Component(
144                WebUiFragmentComponent {
145                    fragment_id: fragment_id.into(),
146                },
147            )),
148        }
149    }
150
151    /// Create a for-loop fragment.
152    pub fn for_loop(
153        item: impl Into<String>,
154        collection: impl Into<String>,
155        fragment_id: impl Into<String>,
156    ) -> Self {
157        Self {
158            fragment: Some(web_ui_fragment::Fragment::ForLoop(WebUiFragmentFor {
159                item: item.into(),
160                collection: collection.into(),
161                fragment_id: fragment_id.into(),
162            })),
163        }
164    }
165
166    /// Create a signal fragment.
167    pub fn signal(value: impl Into<String>, raw: bool) -> Self {
168        Self {
169            fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal {
170                value: value.into(),
171                raw,
172                raw_text_context: false,
173            })),
174        }
175    }
176
177    /// Create a signal inside an HTML raw-text context such as `<style>`.
178    ///
179    /// `raw_text_context` is scoped to **marker ownership only**: it tells the
180    /// handler that comment/text sibling markers cannot be inserted around this
181    /// signal because they would be inert plain text in a raw-text/RCDATA
182    /// element (`<script>`, `<style>`, `<xmp>`, `<title>`, `<textarea>`, ...).
183    /// It does **not** change escaping. The rendered value still follows plain
184    /// `raw` semantics: `raw = false` (`{{value}}`) HTML-encodes the value via
185    /// `encode_safe`, `raw = true` (`{{{value}}}`) writes it verbatim.
186    ///
187    /// This distinction matters because HTML raw-text elements (`<script>`,
188    /// `<style>`, `<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`) never decode
189    /// character references, unlike RCDATA elements (`<title>`, `<textarea>`)
190    /// which do. Authors who bind a value that may contain `&`, `<`, `>`, or
191    /// quotes inside a raw-text element must use the raw (`{{{value}}}`) form;
192    /// otherwise the HTML-encoded entities (e.g. `&amp;`) are emitted as literal
193    /// text and are never decoded back by the browser, corrupting the CSS/JS.
194    /// The parser does not currently reject an escaped binding in these
195    /// contexts — this is a known authoring footgun, not a parser bug.
196    pub fn raw_text_signal(value: impl Into<String>, raw: bool) -> Self {
197        Self {
198            fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal {
199                value: value.into(),
200                raw,
201                raw_text_context: true,
202            })),
203        }
204    }
205
206    /// Create an if-condition fragment.
207    pub fn if_cond(condition: ConditionExpr, fragment_id: impl Into<String>) -> Self {
208        Self {
209            fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf {
210                condition: Some(condition),
211                fragment_id: fragment_id.into(),
212            })),
213        }
214    }
215
216    /// Create the start marker of an inline streaming boundary tape.
217    ///
218    /// The body fragments follow this marker in the same record and are closed
219    /// by [`Self::boundary_end`] carrying the same `declaration_id`.
220    pub fn boundary(
221        declaration_id: u32,
222        owner_fragment_id: impl Into<String>,
223        name: impl Into<String>,
224        key: Option<String>,
225    ) -> Self {
226        Self {
227            fragment: Some(web_ui_fragment::Fragment::Boundary(WebUiFragmentBoundary {
228                declaration_id,
229                owner_fragment_id: owner_fragment_id.into(),
230                name: name.into(),
231                key,
232                may_repeat: false,
233                phase: BoundaryPhase::Start as i32,
234            })),
235        }
236    }
237
238    /// Create the end marker that closes an inline streaming boundary tape.
239    #[must_use]
240    pub fn boundary_end(declaration_id: u32) -> Self {
241        Self {
242            fragment: Some(web_ui_fragment::Fragment::Boundary(WebUiFragmentBoundary {
243                declaration_id,
244                phase: BoundaryPhase::End as i32,
245                ..Default::default()
246            })),
247        }
248    }
249
250    /// Create a simple dynamic attribute fragment (value is a single signal name).
251    pub fn attribute(name: impl Into<String>, value: impl Into<String>) -> Self {
252        Self {
253            fragment: Some(web_ui_fragment::Fragment::Attribute(
254                WebUiFragmentAttribute {
255                    name: name.into(),
256                    value: value.into(),
257                    ..Default::default()
258                },
259            )),
260        }
261    }
262
263    /// Create a template attribute fragment (mixed static + dynamic content).
264    pub fn attribute_template(name: impl Into<String>, template: impl Into<String>) -> Self {
265        Self {
266            fragment: Some(web_ui_fragment::Fragment::Attribute(
267                WebUiFragmentAttribute {
268                    name: name.into(),
269                    template: template.into(),
270                    ..Default::default()
271                },
272            )),
273        }
274    }
275
276    /// Create a complex attribute fragment (:-prefixed).
277    pub fn attribute_complex(name: impl Into<String>, value: impl Into<String>) -> Self {
278        Self {
279            fragment: Some(web_ui_fragment::Fragment::Attribute(
280                WebUiFragmentAttribute {
281                    name: name.into(),
282                    value: value.into(),
283                    complex: true,
284                    ..Default::default()
285                },
286            )),
287        }
288    }
289
290    /// Create a boolean attribute fragment (?-prefixed) with a condition tree.
291    pub fn attribute_boolean(name: impl Into<String>, condition_tree: ConditionExpr) -> Self {
292        Self {
293            fragment: Some(web_ui_fragment::Fragment::Attribute(
294                WebUiFragmentAttribute {
295                    name: name.into(),
296                    condition_tree: Some(condition_tree),
297                    ..Default::default()
298                },
299            )),
300        }
301    }
302
303    /// Create a plugin data fragment with opaque bytes.
304    /// The data is passed through to the handler plugin without interpretation.
305    pub fn plugin(data: Vec<u8>) -> Self {
306        Self {
307            fragment: Some(web_ui_fragment::Fragment::Plugin(WebUiFragmentPlugin {
308                data,
309            })),
310        }
311    }
312
313    /// Create a route fragment linking a URL path template to a fragment.
314    pub fn route(path: impl Into<String>, fragment_id: impl Into<String>) -> Self {
315        Self {
316            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
317                path: path.into(),
318                fragment_id: fragment_id.into(),
319                keep_alive: false,
320                ..Default::default()
321            })),
322        }
323    }
324
325    /// Create a route fragment from a pre-built `WebUiFragmentRoute`.
326    pub fn route_from(route: WebUiFragmentRoute) -> Self {
327        Self {
328            fragment: Some(web_ui_fragment::Fragment::Route(route)),
329        }
330    }
331
332    /// Create an outlet fragment.
333    pub fn outlet() -> Self {
334        Self {
335            fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
336        }
337    }
338}
339
340impl ConditionExpr {
341    /// Create an identifier condition.
342    pub fn identifier(value: impl Into<String>) -> Self {
343        Self {
344            expr: Some(condition_expr::Expr::Identifier(IdentifierCondition {
345                value: value.into(),
346            })),
347        }
348    }
349
350    /// Create a predicate condition.
351    pub fn predicate(
352        left: impl Into<String>,
353        operator: ComparisonOperator,
354        right: impl Into<String>,
355    ) -> Self {
356        Self {
357            expr: Some(condition_expr::Expr::Predicate(Predicate {
358                left: left.into(),
359                operator: operator as i32,
360                right: right.into(),
361            })),
362        }
363    }
364
365    /// Create a negation condition.
366    pub fn negated(inner: ConditionExpr) -> Self {
367        Self {
368            expr: Some(condition_expr::Expr::Not(Box::new(NotCondition {
369                condition: Some(Box::new(inner)),
370            }))),
371        }
372    }
373
374    /// Create a compound condition.
375    pub fn compound(left: ConditionExpr, op: LogicalOperator, right: ConditionExpr) -> Self {
376        Self {
377            expr: Some(condition_expr::Expr::Compound(Box::new(
378                CompoundCondition {
379                    left: Some(Box::new(left)),
380                    op: op as i32,
381                    right: Some(Box::new(right)),
382                },
383            ))),
384        }
385    }
386}
387
388// ── Constructors ────────────────────────────────────────────────────────
389
390enum StyleClosureOp<'a> {
391    Fragment(&'a str),
392    Component(&'a str),
393}
394
395/// One delivery unit of a style closure, resolved in cascade order.
396///
397/// A bundled build delivers merged chunks; otherwise every component delivers
398/// its own stylesheet. Both walk the closure in the same order, so the emitted
399/// cascade is identical either way.
400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
401pub struct StyleClosureUnit<'a> {
402    /// Name the client installs this resource under: a chunk name, or a tag.
403    pub name: &'a str,
404    /// The delivered resource, or `None` when the protocol holds none.
405    pub resource: Option<&'a str>,
406    /// Covering chunk index, or `None` for a standalone component stylesheet.
407    pub chunk: Option<u32>,
408}
409
410impl CssStrategy {
411    /// Return the lowercase wire name the client runtime expects.
412    ///
413    /// prost's generated `as_str_name` yields the uppercase proto identifier
414    /// (`"LINK"`), so it cannot serve the JSON payloads. Every host that writes
415    /// a strategy onto the wire routes through this so the spellings agree.
416    #[must_use]
417    pub fn wire_name(self) -> &'static str {
418        match self {
419            CssStrategy::Link => "link",
420            CssStrategy::Style => "style",
421            CssStrategy::Module => "module",
422        }
423    }
424}
425
426impl WebUiProtocol {
427    /// Return whether a component authored a declarative Shadow DOM root.
428    #[must_use]
429    pub fn component_uses_shadow_dom(&self, tag_name: &str) -> bool {
430        self.components
431            .get(tag_name)
432            .is_some_and(|component| component.uses_shadow_dom)
433    }
434
435    /// Return the stored style resource for a component.
436    ///
437    /// Link protocols identify a resource by `css_href`; Style and Module
438    /// protocols retain the compiled CSS bytes. An empty field means that the
439    /// component has no paired style resource.
440    #[must_use]
441    pub fn component_style_resource(&self, tag_name: &str) -> Option<&str> {
442        let component = self.components.get(tag_name)?;
443        let resource = match self.css_strategy() {
444            CssStrategy::Link => component.css_href.as_str(),
445            CssStrategy::Style | CssStrategy::Module => component.css.as_str(),
446        };
447        (!resource.is_empty()).then_some(resource)
448    }
449
450    /// Return a precomputed style closure for `root`.
451    #[must_use]
452    pub fn style_closure(&self, root: &str) -> Option<&[String]> {
453        self.style_closures
454            .get(root)
455            .map(|closure| closure.component_tags.as_slice())
456    }
457
458    /// Precompute ordered style-resource closures for entry fragments and every
459    /// compiled component root.
460    ///
461    /// Traversal is iterative, follows source order, stops at Shadow
462    /// children and route activation edges, and deduplicates resources at first
463    /// discovery. Matched route roots install their own stored closures.
464    pub fn populate_style_closures(&mut self, entry_fragments: &[&str]) {
465        // Chunks are planned from the closures this call rebuilds, so any
466        // previously planned bundle is stale the moment closures change.
467        self.style_chunks.clear();
468        let mut roots: Vec<String> = entry_fragments
469            .iter()
470            .map(|root| (*root).to_string())
471            .collect();
472        roots.extend(
473            self.components
474                .keys()
475                .filter(|tag| self.fragments.contains_key(*tag))
476                .cloned(),
477        );
478        roots.sort_unstable();
479        roots.dedup();
480
481        let has_style_resources = match self.css_strategy() {
482            CssStrategy::Link => self
483                .components
484                .values()
485                .any(|component| !component.css_href.is_empty()),
486            CssStrategy::Style | CssStrategy::Module => self
487                .components
488                .values()
489                .any(|component| !component.css.is_empty()),
490        };
491        if !has_style_resources {
492            self.style_closures = roots
493                .into_iter()
494                .map(|root| (root, ComponentStyleClosure::default()))
495                .collect();
496            return;
497        }
498
499        self.style_closures = roots
500            .into_iter()
501            .map(|root| {
502                let closure = self.build_style_closure(&root);
503                (root, closure)
504            })
505            .collect();
506    }
507
508    fn build_style_closure<'a>(&'a self, root: &'a str) -> ComponentStyleClosure {
509        let mut component_tags = Vec::new();
510        let mut seen_styles = HashSet::new();
511        let mut visited_fragments = HashSet::new();
512        let mut work = Vec::new();
513
514        // A component-root closure owns its paired CSS regardless of whether
515        // callers normally encounter that component as a Light or Shadow child.
516        if self.component_style_resource(root).is_some() {
517            seen_styles.insert(root);
518            component_tags.push(root.to_string());
519        }
520        work.push(StyleClosureOp::Fragment(root));
521
522        while let Some(op) = work.pop() {
523            match op {
524                StyleClosureOp::Fragment(fragment_id) => {
525                    if !visited_fragments.insert(fragment_id) {
526                        continue;
527                    }
528                    let Some(fragment_list) = self.fragments.get(fragment_id) else {
529                        continue;
530                    };
531                    for fragment in fragment_list.fragments.iter().rev() {
532                        match fragment.fragment.as_ref() {
533                            Some(web_ui_fragment::Fragment::Component(component)) => {
534                                work.push(StyleClosureOp::Component(&component.fragment_id));
535                            }
536                            Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
537                                work.push(StyleClosureOp::Fragment(&for_loop.fragment_id));
538                            }
539                            Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
540                                work.push(StyleClosureOp::Fragment(&if_cond.fragment_id));
541                            }
542                            Some(web_ui_fragment::Fragment::Attribute(attribute))
543                                if !attribute.template.is_empty() =>
544                            {
545                                work.push(StyleClosureOp::Fragment(&attribute.template));
546                            }
547                            _ => {}
548                        }
549                    }
550                }
551                StyleClosureOp::Component(tag_name) => {
552                    if self.component_uses_shadow_dom(tag_name) {
553                        continue;
554                    }
555                    if self.component_style_resource(tag_name).is_some()
556                        && seen_styles.insert(tag_name)
557                    {
558                        component_tags.push(tag_name.to_string());
559                    }
560                    work.push(StyleClosureOp::Fragment(tag_name));
561                }
562            }
563        }
564
565        ComponentStyleClosure {
566            component_tags,
567            style_chunks: Vec::new(),
568        }
569    }
570
571    /// Return the delivered style resource for a bundled chunk index.
572    ///
573    /// Mirrors [`Self::component_style_resource`]: Link protocols identify a
574    /// chunk by `css_href`, Style and Module protocols carry the concatenated
575    /// CSS bytes.
576    #[must_use]
577    pub fn style_chunk_resource(&self, chunk: u32) -> Option<(&str, &str)> {
578        let chunk = self.style_chunks.get(chunk as usize)?;
579        let resource = match self.css_strategy() {
580            CssStrategy::Link => chunk.css_href.as_str(),
581            CssStrategy::Style | CssStrategy::Module => chunk.css.as_str(),
582        };
583        (!resource.is_empty()).then_some((chunk.name.as_str(), resource))
584    }
585
586    /// Return the component tags merged into `chunk`, in cascade order.
587    #[must_use]
588    pub fn style_chunk_members(&self, chunk: u32) -> Option<&[String]> {
589        self.style_chunks
590            .get(chunk as usize)
591            .map(|chunk| chunk.component_tags.as_slice())
592    }
593
594    /// Map every component whose stylesheet a bundled chunk already ships to
595    /// that chunk's index.
596    ///
597    /// This is the single definition of "already covered by a chunk", and every
598    /// delivery path must agree on it. A closure carries `style_chunks` only
599    /// when the bundler treated it as a root; other closures still list their
600    /// members individually, and delivering those members verbatim in a bundled
601    /// build re-ships bytes the covering chunk already contains. Resolving a
602    /// member through this map instead keeps each closure self-sufficient, so
603    /// correctness never depends on which closure happens to install first.
604    ///
605    /// Empty for unbundled builds. Build it once per render: it allocates.
606    #[must_use]
607    pub fn style_chunk_index(&self) -> HashMap<&str, u32> {
608        let mut index = HashMap::new();
609        for (position, chunk) in self.style_chunks.iter().enumerate() {
610            let Ok(position) = u32::try_from(position) else {
611                break;
612            };
613            index.reserve(chunk.component_tags.len());
614            for tag in &chunk.component_tags {
615                index.insert(tag.as_str(), position);
616            }
617        }
618        index
619    }
620
621    /// Return the bundled chunk indices for `root`, in cascade order.
622    ///
623    /// Empty when the build did not bundle CSS, in which case callers deliver
624    /// [`Self::style_closure`] component resources individually.
625    #[must_use]
626    pub fn style_closure_chunks(&self, root: &str) -> Option<&[u32]> {
627        self.style_closures
628            .get(root)
629            .map(|closure| closure.style_chunks.as_slice())
630    }
631
632    /// Number of delivery units `closure` walks, in cascade order.
633    ///
634    /// A closure the bundler treated as a root ships one unit per chunk;
635    /// every other closure ships one unit per member.
636    #[must_use]
637    pub fn style_closure_unit_count(closure: &ComponentStyleClosure) -> usize {
638        if closure.style_chunks.is_empty() {
639            closure.component_tags.len()
640        } else {
641            closure.style_chunks.len()
642        }
643    }
644
645    /// Resolve one position of `closure` to the resource that delivers it.
646    ///
647    /// This is the single walk shared by every delivery path, so all of them
648    /// agree on [`Self::style_chunk_index`]'s definition of "already covered by
649    /// a chunk". A closure that was not a bundler root uses a chunk only when
650    /// the chunk's complete ordered membership is present; otherwise it falls
651    /// back to the individual component resource. `chunk_index` must come from
652    /// that accessor; pass an empty map for an unbundled protocol.
653    ///
654    /// Returns `None` only when `position` is out of range. A unit whose
655    /// `resource` is `None` names a resource the protocol does not hold, which
656    /// callers report or skip according to their own delivery contract.
657    #[must_use]
658    pub fn style_closure_unit<'a>(
659        &'a self,
660        closure: &'a ComponentStyleClosure,
661        chunk_index: &HashMap<&str, u32>,
662        position: usize,
663    ) -> Option<StyleClosureUnit<'a>> {
664        let chunk = if closure.style_chunks.is_empty() {
665            let tag = closure.component_tags.get(position)?.as_str();
666            let fallback = || {
667                Some(StyleClosureUnit {
668                    name: tag,
669                    resource: self.component_style_resource(tag),
670                    chunk: None,
671                })
672            };
673            let Some(index) = chunk_index.get(tag).copied() else {
674                return fallback();
675            };
676            let Some(members) = self.style_chunk_members(index) else {
677                return fallback();
678            };
679            let Some(member_position) = members.iter().position(|member| member == tag) else {
680                return fallback();
681            };
682            let Some(start) = position.checked_sub(member_position) else {
683                return fallback();
684            };
685            let Some(end) = start.checked_add(members.len()) else {
686                return fallback();
687            };
688            if closure.component_tags.get(start..end) != Some(members) {
689                return fallback();
690            }
691            index
692        } else {
693            *closure.style_chunks.get(position)?
694        };
695        let record = self.style_chunks.get(chunk as usize)?;
696        Some(StyleClosureUnit {
697            name: record.name.as_str(),
698            resource: self
699                .style_chunk_resource(chunk)
700                .map(|(_, resource)| resource),
701            chunk: Some(chunk),
702        })
703    }
704
705    /// Create a protocol from fragment records with no CSS tokens.
706    pub fn new(fragments: WebUIFragmentRecords) -> Self {
707        Self {
708            fragments,
709            tokens: Vec::new(),
710            components: HashMap::new(),
711            css_strategy: 0,
712            initial_state_strategy: InitialStateStrategy::Full as i32,
713            module_preloads: Vec::new(),
714            component_render_css: String::new(),
715            style_closures: HashMap::new(),
716            style_chunks: Vec::new(),
717            component_asset_style_preloads: Vec::new(),
718        }
719    }
720
721    /// Create a protocol from fragment records with CSS tokens.
722    pub fn with_tokens(fragments: WebUIFragmentRecords, tokens: Vec<String>) -> Self {
723        Self {
724            fragments,
725            tokens,
726            components: HashMap::new(),
727            css_strategy: 0,
728            initial_state_strategy: InitialStateStrategy::Full as i32,
729            module_preloads: Vec::new(),
730            component_render_css: String::new(),
731            style_closures: HashMap::new(),
732            style_chunks: Vec::new(),
733            component_asset_style_preloads: Vec::new(),
734        }
735    }
736}
737
738// ── Serialization / deserialization / validation ────────────────────────
739
740impl WebUiProtocol {
741    /// Validate that all fragment references point to existing fragment IDs.
742    fn validate_protocol(protocol: Self) -> Result<Self> {
743        CssStrategy::try_from(protocol.css_strategy).map_err(|_| {
744            ProtocolError::Validation(format!(
745                "unknown CSS strategy value: {}",
746                protocol.css_strategy
747            ))
748        })?;
749        let fragments = &protocol.fragments;
750
751        let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| {
752            fragment_list
753                .fragments
754                .iter()
755                .find_map(|frag| match frag.fragment.as_ref() {
756                    Some(web_ui_fragment::Fragment::Component(comp))
757                        if !fragments.contains_key(&comp.fragment_id) =>
758                    {
759                        Some(ProtocolError::Validation(format!(
760                            "Component references non-existent fragment ID: {}",
761                            comp.fragment_id
762                        )))
763                    }
764                    Some(web_ui_fragment::Fragment::ForLoop(fl))
765                        if !fragments.contains_key(&fl.fragment_id) =>
766                    {
767                        Some(ProtocolError::Validation(format!(
768                            "For loop references non-existent fragment ID: {}",
769                            fl.fragment_id
770                        )))
771                    }
772                    Some(web_ui_fragment::Fragment::IfCond(ic))
773                        if !fragments.contains_key(&ic.fragment_id) =>
774                    {
775                        Some(ProtocolError::Validation(format!(
776                            "If condition references non-existent fragment ID: {}",
777                            ic.fragment_id
778                        )))
779                    }
780                    Some(web_ui_fragment::Fragment::Attribute(attr))
781                        if !attr.template.is_empty() && !fragments.contains_key(&attr.template) =>
782                    {
783                        Some(ProtocolError::Validation(format!(
784                            "Attribute references non-existent template fragment ID: {}",
785                            attr.template
786                        )))
787                    }
788                    Some(web_ui_fragment::Fragment::Boundary(boundary))
789                        if boundary.phase() == BoundaryPhase::Start
790                            && !fragments.contains_key(&boundary.owner_fragment_id) =>
791                    {
792                        Some(Self::missing_boundary_owner_error(boundary))
793                    }
794                    Some(web_ui_fragment::Fragment::Route(route)) => {
795                        Self::validate_route_references(route, fragments)
796                    }
797                    _ => None,
798                })
799        });
800
801        if let Some(err) = invalid_ref {
802            return Err(err);
803        }
804
805        let mut declaration_ids = HashSet::new();
806        let mut owner_names = HashSet::new();
807        for fragment_list in fragments.values() {
808            let mut open: Option<u32> = None;
809            for fragment in &fragment_list.fragments {
810                let Some(web_ui_fragment::Fragment::Boundary(boundary)) =
811                    fragment.fragment.as_ref()
812                else {
813                    continue;
814                };
815                if boundary.phase() == BoundaryPhase::End {
816                    match open.take() {
817                        Some(declaration_id) if declaration_id == boundary.declaration_id => {}
818                        _ => return Err(Self::unbalanced_boundary_tape_error(boundary)),
819                    }
820                    continue;
821                }
822                if open.is_some() {
823                    return Err(Self::unbalanced_boundary_tape_error(boundary));
824                }
825                open = Some(boundary.declaration_id);
826                if boundary.name.trim().is_empty() {
827                    return Err(Self::empty_boundary_name_error(boundary));
828                }
829                if boundary
830                    .key
831                    .as_ref()
832                    .is_some_and(|key| key.trim().is_empty())
833                {
834                    return Err(Self::empty_boundary_key_error(boundary));
835                }
836                if !declaration_ids.insert(boundary.declaration_id) {
837                    return Err(Self::duplicate_boundary_id_error(boundary));
838                }
839                if !owner_names.insert((&boundary.owner_fragment_id, &boundary.name)) {
840                    return Err(Self::duplicate_boundary_name_error(boundary));
841                }
842            }
843            if let Some(declaration_id) = open {
844                return Err(Self::unterminated_boundary_tape_error(declaration_id));
845            }
846        }
847
848        Ok(protocol)
849    }
850
851    fn validate_route_references(
852        root: &WebUiFragmentRoute,
853        fragments: &WebUIFragmentRecords,
854    ) -> Option<ProtocolError> {
855        let mut pending = vec![root];
856        while let Some(route) = pending.pop() {
857            for (kind, fragment_id) in [
858                ("component", route.fragment_id.as_str()),
859                ("content", route.content_fragment_id.as_str()),
860            ] {
861                if !fragment_id.is_empty() && !fragments.contains_key(fragment_id) {
862                    return Some(Self::missing_route_reference_error(kind, fragment_id));
863                }
864            }
865            pending.extend(route.children.iter());
866        }
867        None
868    }
869
870    #[cold]
871    #[inline(never)]
872    fn unbalanced_boundary_tape_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
873        ProtocolError::Validation(format!(
874            "Boundary declaration {} has an unbalanced inline tape marker",
875            boundary.declaration_id
876        ))
877    }
878
879    #[cold]
880    #[inline(never)]
881    fn unterminated_boundary_tape_error(declaration_id: u32) -> ProtocolError {
882        ProtocolError::Validation(format!(
883            "Boundary declaration {declaration_id} is missing its end marker"
884        ))
885    }
886
887    #[cold]
888    #[inline(never)]
889    fn missing_boundary_owner_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
890        ProtocolError::Validation(format!(
891            "Boundary declaration {} references non-existent owner fragment ID: {}",
892            boundary.declaration_id, boundary.owner_fragment_id
893        ))
894    }
895
896    #[cold]
897    #[inline(never)]
898    fn empty_boundary_name_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
899        ProtocolError::Validation(format!(
900            "Boundary declaration {} has an empty authored name",
901            boundary.declaration_id
902        ))
903    }
904
905    #[cold]
906    #[inline(never)]
907    fn empty_boundary_key_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
908        ProtocolError::Validation(format!(
909            "Boundary declaration {} has an empty key expression",
910            boundary.declaration_id
911        ))
912    }
913
914    #[cold]
915    #[inline(never)]
916    fn duplicate_boundary_id_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
917        ProtocolError::Validation(format!(
918            "Duplicate boundary declaration ID: {}",
919            boundary.declaration_id
920        ))
921    }
922
923    #[cold]
924    #[inline(never)]
925    fn duplicate_boundary_name_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
926        ProtocolError::Validation(format!(
927            "Duplicate boundary name '{}' in owner fragment '{}'",
928            boundary.name, boundary.owner_fragment_id
929        ))
930    }
931
932    #[cold]
933    #[inline(never)]
934    fn missing_route_reference_error(kind: &str, fragment_id: &str) -> ProtocolError {
935        ProtocolError::Validation(format!(
936            "Route {kind} references non-existent fragment ID: {fragment_id}"
937        ))
938    }
939
940    /// Serialize protocol to pretty JSON (for debug/inspect output only).
941    pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
942        serde_json::to_string_pretty(self)
943    }
944
945    /// Serialize protocol to protobuf binary format.
946    pub fn to_protobuf(&self) -> Result<Vec<u8>> {
947        let len = self.encoded_len();
948        let mut buf = Vec::with_capacity(len);
949        self.encode(&mut buf)
950            .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?;
951        Ok(buf)
952    }
953
954    /// Deserialize protocol from protobuf binary bytes with validation.
955    pub fn from_protobuf(bytes: &[u8]) -> Result<Self> {
956        let protocol = Self::decode(bytes)
957            .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?;
958        Self::validate_protocol(protocol)
959    }
960
961    /// Read and deserialize a protobuf file with validation.
962    pub fn from_protobuf_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
963        let bytes = std::fs::read(path)?;
964        Self::from_protobuf(&bytes)
965    }
966
967    /// Write protocol to a protobuf file.
968    pub fn to_protobuf_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
969        let bytes = self.to_protobuf()?;
970        std::fs::write(path, bytes)?;
971        Ok(())
972    }
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    fn sample_protocol() -> WebUIProtocol {
980        let mut fragments = HashMap::new();
981        fragments.insert(
982            "index.html".to_string(),
983            FragmentList {
984                fragments: vec![
985                    WebUIFragment::raw("Hello, WebUI!\n"),
986                    WebUIFragment::for_loop("person", "people", "for-1"),
987                    WebUIFragment::signal("description", true),
988                    WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"),
989                ],
990                contains_boundary: false,
991            },
992        );
993        fragments.insert(
994            "for-1".to_string(),
995            FragmentList {
996                fragments: vec![WebUIFragment::signal("person.name", false)],
997                contains_boundary: false,
998            },
999        );
1000        fragments.insert(
1001            "if-1".to_string(),
1002            FragmentList {
1003                fragments: vec![WebUIFragment::component("contact-card")],
1004                contains_boundary: false,
1005            },
1006        );
1007        fragments.insert(
1008            "contact-card".to_string(),
1009            FragmentList {
1010                fragments: vec![
1011                    WebUIFragment::raw("Hello, "),
1012                    WebUIFragment::signal("name", false),
1013                ],
1014                contains_boundary: false,
1015            },
1016        );
1017        WebUIProtocol::new(fragments)
1018    }
1019
1020    fn add_style_component(
1021        protocol: &mut WebUIProtocol,
1022        tag: &str,
1023        uses_shadow_dom: bool,
1024        has_css: bool,
1025    ) {
1026        protocol
1027            .fragments
1028            .entry(tag.to_string())
1029            .or_insert_with(|| FragmentList {
1030                fragments: Vec::new(),
1031                contains_boundary: false,
1032            });
1033        protocol.components.insert(
1034            tag.to_string(),
1035            ComponentData {
1036                css: has_css.then(|| format!(".{tag}{{}}")).unwrap_or_default(),
1037                uses_shadow_dom,
1038                ..Default::default()
1039            },
1040        );
1041    }
1042
1043    #[test]
1044    fn style_closure_preserves_order_deduplicates_and_cuts_at_shadow() {
1045        let mut fragments = HashMap::new();
1046        fragments.insert(
1047            "index.html".to_string(),
1048            FragmentList {
1049                fragments: vec![
1050                    WebUIFragment::component("light-a"),
1051                    WebUIFragment::component("light-b"),
1052                    WebUIFragment::component("no-css"),
1053                    WebUIFragment::component("shadow-cut"),
1054                    WebUIFragment::component("after-cut"),
1055                ],
1056                contains_boundary: false,
1057            },
1058        );
1059        fragments.insert(
1060            "light-a".to_string(),
1061            FragmentList {
1062                fragments: vec![WebUIFragment::component("shared-light")],
1063                contains_boundary: false,
1064            },
1065        );
1066        fragments.insert(
1067            "light-b".to_string(),
1068            FragmentList {
1069                fragments: vec![WebUIFragment::component("shared-light")],
1070                contains_boundary: false,
1071            },
1072        );
1073        fragments.insert(
1074            "no-css".to_string(),
1075            FragmentList {
1076                fragments: vec![WebUIFragment::component("styled-descendant")],
1077                contains_boundary: false,
1078            },
1079        );
1080        fragments.insert(
1081            "shadow-cut".to_string(),
1082            FragmentList {
1083                fragments: vec![WebUIFragment::component("shadow-descendant")],
1084                contains_boundary: false,
1085            },
1086        );
1087        let mut protocol = WebUIProtocol::new(fragments);
1088        protocol.set_css_strategy(CssStrategy::Style);
1089        for tag in [
1090            "light-a",
1091            "light-b",
1092            "shared-light",
1093            "styled-descendant",
1094            "shadow-descendant",
1095            "after-cut",
1096        ] {
1097            add_style_component(&mut protocol, tag, false, true);
1098        }
1099        add_style_component(&mut protocol, "no-css", false, false);
1100        add_style_component(&mut protocol, "shadow-cut", true, true);
1101
1102        protocol.populate_style_closures(&["index.html"]);
1103
1104        assert_eq!(
1105            protocol.style_closure("index.html").expect("entry closure"),
1106            [
1107                "light-a",
1108                "shared-light",
1109                "light-b",
1110                "styled-descendant",
1111                "after-cut",
1112            ]
1113        );
1114        assert_eq!(
1115            protocol
1116                .style_closure("shadow-cut")
1117                .expect("Shadow closure"),
1118            ["shadow-cut", "shadow-descendant"]
1119        );
1120    }
1121
1122    #[test]
1123    fn style_closure_follows_dynamic_dependencies_but_not_routes() {
1124        let nested_route = WebUiFragmentRoute {
1125            fragment_id: "nested-route".to_string(),
1126            ..Default::default()
1127        };
1128        let route = WebUiFragmentRoute {
1129            fragment_id: "route-body".to_string(),
1130            children: vec![nested_route],
1131            pending_component: "route-pending".to_string(),
1132            error_component: "route-error".to_string(),
1133            ..Default::default()
1134        };
1135        let mut fragments = HashMap::new();
1136        fragments.insert(
1137            "index.html".to_string(),
1138            FragmentList {
1139                fragments: vec![
1140                    WebUIFragment::for_loop("item", "items", "for-body"),
1141                    WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
1142                    WebUIFragment::attribute_template("title", "attribute-body"),
1143                    WebUIFragment::route_from(route),
1144                ],
1145                contains_boundary: false,
1146            },
1147        );
1148        for (fragment, component) in [
1149            ("for-body", "for-card"),
1150            ("if-body", "if-card"),
1151            ("attribute-body", "attribute-card"),
1152        ] {
1153            fragments.insert(
1154                fragment.to_string(),
1155                FragmentList {
1156                    fragments: vec![WebUIFragment::component(component)],
1157                    contains_boundary: false,
1158                },
1159            );
1160        }
1161        let mut protocol = WebUIProtocol::new(fragments);
1162        protocol.set_css_strategy(CssStrategy::Style);
1163        for tag in [
1164            "for-card",
1165            "if-card",
1166            "attribute-card",
1167            "route-body",
1168            "nested-route",
1169            "route-pending",
1170            "route-error",
1171        ] {
1172            add_style_component(&mut protocol, tag, false, true);
1173        }
1174        protocol.fragments.insert(
1175            "route-body".to_string(),
1176            FragmentList {
1177                fragments: vec![WebUIFragment::outlet()],
1178                contains_boundary: false,
1179            },
1180        );
1181
1182        protocol.populate_style_closures(&["index.html"]);
1183
1184        assert_eq!(
1185            protocol.style_closure("index.html").expect("entry closure"),
1186            ["for-card", "if-card", "attribute-card"]
1187        );
1188        assert_eq!(
1189            protocol
1190                .style_closure("route-body")
1191                .expect("route body closure"),
1192            ["route-body"]
1193        );
1194        assert_eq!(
1195            protocol
1196                .style_closure("nested-route")
1197                .expect("nested route closure"),
1198            ["nested-route"]
1199        );
1200    }
1201
1202    #[test]
1203    fn style_closure_keeps_nested_routes_out_of_shadow_outlet_tree() {
1204        let child_route = WebUiFragmentRoute {
1205            fragment_id: "dashboard-page".to_string(),
1206            pending_component: "dashboard-pending".to_string(),
1207            error_component: "dashboard-error".to_string(),
1208            ..Default::default()
1209        };
1210        let route = WebUiFragmentRoute {
1211            fragment_id: "app-shell".to_string(),
1212            children: vec![child_route],
1213            pending_component: "shell-pending".to_string(),
1214            error_component: "shell-error".to_string(),
1215            ..Default::default()
1216        };
1217        let mut fragments = HashMap::new();
1218        fragments.insert(
1219            "index.html".to_string(),
1220            FragmentList {
1221                fragments: vec![WebUIFragment::route_from(route)],
1222                contains_boundary: false,
1223            },
1224        );
1225        fragments.insert(
1226            "app-shell".to_string(),
1227            FragmentList {
1228                fragments: vec![
1229                    WebUIFragment::component("shell-header"),
1230                    WebUIFragment::component("route-layout"),
1231                    WebUIFragment::component("shell-footer"),
1232                ],
1233                contains_boundary: false,
1234            },
1235        );
1236        fragments.insert(
1237            "route-layout".to_string(),
1238            FragmentList {
1239                fragments: vec![WebUIFragment::outlet()],
1240                contains_boundary: false,
1241            },
1242        );
1243        fragments.insert(
1244            "dashboard-page".to_string(),
1245            FragmentList {
1246                fragments: vec![WebUIFragment::component("contact-card")],
1247                contains_boundary: false,
1248            },
1249        );
1250        let mut protocol = WebUIProtocol::new(fragments);
1251        protocol.set_css_strategy(CssStrategy::Style);
1252        for tag in [
1253            "shell-header",
1254            "route-layout",
1255            "shell-footer",
1256            "dashboard-page",
1257            "contact-card",
1258            "dashboard-pending",
1259            "dashboard-error",
1260            "shell-pending",
1261            "shell-error",
1262        ] {
1263            add_style_component(&mut protocol, tag, false, true);
1264        }
1265        add_style_component(&mut protocol, "app-shell", true, true);
1266
1267        protocol.populate_style_closures(&["index.html"]);
1268
1269        assert!(protocol
1270            .style_closure("index.html")
1271            .expect("entry closure")
1272            .is_empty());
1273        assert_eq!(
1274            protocol
1275                .style_closure("app-shell")
1276                .expect("Shadow shell closure"),
1277            ["app-shell", "shell-header", "route-layout", "shell-footer"]
1278        );
1279        assert_eq!(
1280            protocol
1281                .style_closure("dashboard-page")
1282                .expect("active route closure"),
1283            ["dashboard-page", "contact-card"]
1284        );
1285    }
1286
1287    #[test]
1288    fn style_closure_keeps_routes_out_of_nested_shadow_outlet_component() {
1289        let route = WebUiFragmentRoute {
1290            fragment_id: "light-shell".to_string(),
1291            children: vec![WebUiFragmentRoute {
1292                fragment_id: "dashboard-page".to_string(),
1293                ..Default::default()
1294            }],
1295            ..Default::default()
1296        };
1297        let mut fragments = HashMap::new();
1298        fragments.insert(
1299            "index.html".to_string(),
1300            FragmentList {
1301                fragments: vec![WebUIFragment::route_from(route)],
1302                contains_boundary: false,
1303            },
1304        );
1305        fragments.insert(
1306            "light-shell".to_string(),
1307            FragmentList {
1308                fragments: vec![WebUIFragment::component("shadow-layout")],
1309                contains_boundary: false,
1310            },
1311        );
1312        fragments.insert(
1313            "shadow-layout".to_string(),
1314            FragmentList {
1315                fragments: vec![WebUIFragment::outlet()],
1316                contains_boundary: false,
1317            },
1318        );
1319        fragments.insert(
1320            "dashboard-page".to_string(),
1321            FragmentList {
1322                fragments: vec![WebUIFragment::component("contact-card")],
1323                contains_boundary: false,
1324            },
1325        );
1326        let mut protocol = WebUIProtocol::new(fragments);
1327        protocol.set_css_strategy(CssStrategy::Style);
1328        for tag in ["light-shell", "dashboard-page", "contact-card"] {
1329            add_style_component(&mut protocol, tag, false, true);
1330        }
1331        add_style_component(&mut protocol, "shadow-layout", true, true);
1332
1333        protocol.populate_style_closures(&["index.html"]);
1334
1335        assert!(protocol
1336            .style_closure("index.html")
1337            .expect("entry closure")
1338            .is_empty());
1339        assert_eq!(
1340            protocol
1341                .style_closure("light-shell")
1342                .expect("route shell closure"),
1343            ["light-shell"]
1344        );
1345        assert_eq!(
1346            protocol
1347                .style_closure("shadow-layout")
1348                .expect("nested Shadow closure"),
1349            ["shadow-layout"]
1350        );
1351        assert_eq!(
1352            protocol
1353                .style_closure("dashboard-page")
1354                .expect("active route closure"),
1355            ["dashboard-page", "contact-card"]
1356        );
1357    }
1358
1359    #[test]
1360    fn style_closure_guards_same_tag_cycles_and_is_deterministic() {
1361        let mut fragments = HashMap::new();
1362        fragments.insert(
1363            "index.html".to_string(),
1364            FragmentList {
1365                fragments: vec![WebUIFragment::component("cycle-card")],
1366                contains_boundary: false,
1367            },
1368        );
1369        fragments.insert(
1370            "cycle-card".to_string(),
1371            FragmentList {
1372                fragments: vec![WebUIFragment::component("cycle-card")],
1373                contains_boundary: false,
1374            },
1375        );
1376        let mut protocol = WebUIProtocol::new(fragments);
1377        protocol.set_css_strategy(CssStrategy::Style);
1378        add_style_component(&mut protocol, "cycle-card", false, true);
1379
1380        protocol.populate_style_closures(&["index.html"]);
1381        let first = protocol.style_closures.clone();
1382        protocol.populate_style_closures(&["index.html"]);
1383
1384        assert_eq!(
1385            protocol.style_closure("index.html").expect("entry closure"),
1386            ["cycle-card"]
1387        );
1388        assert_eq!(
1389            protocol
1390                .style_closure("cycle-card")
1391                .expect("component closure"),
1392            ["cycle-card"]
1393        );
1394        assert_eq!(protocol.style_closures, first);
1395    }
1396
1397    #[test]
1398    fn style_closure_populates_empty_roots_without_resources() {
1399        let mut fragments = HashMap::new();
1400        fragments.insert(
1401            "index.html".to_string(),
1402            FragmentList {
1403                fragments: vec![WebUIFragment::component("light-shell")],
1404                contains_boundary: false,
1405            },
1406        );
1407        fragments.insert(
1408            "light-shell".to_string(),
1409            FragmentList {
1410                fragments: vec![WebUIFragment::component("shadow-card")],
1411                contains_boundary: false,
1412            },
1413        );
1414        fragments.insert("shadow-card".to_string(), FragmentList::default());
1415        let mut protocol = WebUIProtocol::new(fragments);
1416        add_style_component(&mut protocol, "light-shell", false, false);
1417        add_style_component(&mut protocol, "shadow-card", true, false);
1418
1419        protocol.populate_style_closures(&["index.html"]);
1420
1421        assert_eq!(protocol.style_closures.len(), 3);
1422        for root in ["index.html", "light-shell", "shadow-card"] {
1423            assert_eq!(protocol.style_closure(root), Some([].as_slice()));
1424        }
1425    }
1426
1427    #[test]
1428    fn style_closure_roundtrips() {
1429        let mut protocol = sample_protocol();
1430        protocol.set_css_strategy(CssStrategy::Link);
1431        protocol.components.insert(
1432            "contact-card".to_string(),
1433            ComponentData {
1434                css_href: "/contact-card.css".to_string(),
1435                ..Default::default()
1436            },
1437        );
1438        protocol.populate_style_closures(&["index.html"]);
1439
1440        let bytes = protocol.to_protobuf().expect("encode failed");
1441        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1442        assert_eq!(
1443            decoded.style_closure("index.html").expect("entry closure"),
1444            ["contact-card"]
1445        );
1446    }
1447
1448    #[test]
1449    fn current_json_requires_style_and_component_shadow_metadata() {
1450        let mut protocol = sample_protocol();
1451        protocol.components.insert(
1452            "my-card".to_string(),
1453            ComponentData {
1454                uses_shadow_dom: false,
1455                ..Default::default()
1456            },
1457        );
1458        protocol.populate_style_closures(&["index.html"]);
1459        let current = serde_json::to_value(&protocol).expect("JSON encode failed");
1460
1461        let mut missing_closures = current.clone();
1462        missing_closures
1463            .as_object_mut()
1464            .expect("protocol JSON object")
1465            .remove("style_closures");
1466        assert!(serde_json::from_value::<WebUIProtocol>(missing_closures).is_err());
1467
1468        let mut missing_shadow_dom = current;
1469        missing_shadow_dom["components"]["my-card"]
1470            .as_object_mut()
1471            .expect("component JSON object")
1472            .remove("uses_shadow_dom");
1473        assert!(serde_json::from_value::<WebUIProtocol>(missing_shadow_dom).is_err());
1474
1475        let mut legacy_root = serde_json::to_value(&protocol).expect("JSON encode failed");
1476        legacy_root["dom_strategy"] = serde_json::json!(0);
1477        assert!(serde_json::from_value::<WebUIProtocol>(legacy_root).is_err());
1478
1479        let mut legacy_component = serde_json::to_value(&protocol).expect("JSON encode failed");
1480        legacy_component["components"]["my-card"]["effective_dom_strategy"] = serde_json::json!(0);
1481        assert!(serde_json::from_value::<WebUIProtocol>(legacy_component).is_err());
1482    }
1483
1484    #[test]
1485    fn component_style_resource_uses_the_active_strategy_field() {
1486        let mut protocol = WebUIProtocol::new(HashMap::new());
1487        protocol.components.insert(
1488            "my-card".to_string(),
1489            ComponentData {
1490                css: ".card{}".to_string(),
1491                css_href: "/my-card.css".to_string(),
1492                ..Default::default()
1493            },
1494        );
1495
1496        protocol.set_css_strategy(CssStrategy::Link);
1497        assert_eq!(
1498            protocol.component_style_resource("my-card"),
1499            Some("/my-card.css")
1500        );
1501        protocol.set_css_strategy(CssStrategy::Style);
1502        assert_eq!(
1503            protocol.component_style_resource("my-card"),
1504            Some(".card{}")
1505        );
1506        protocol.set_css_strategy(CssStrategy::Module);
1507        assert_eq!(
1508            protocol.component_style_resource("my-card"),
1509            Some(".card{}")
1510        );
1511        assert_eq!(protocol.component_style_resource("missing-card"), None);
1512    }
1513
1514    #[test]
1515    fn declared_link_strategy_does_not_infer_module_from_css_bytes() {
1516        let mut protocol = WebUIProtocol::default();
1517        protocol.components.insert(
1518            "my-card".to_string(),
1519            ComponentData {
1520                css: ".card{}".to_string(),
1521                ..Default::default()
1522            },
1523        );
1524        assert_eq!(protocol.css_strategy(), CssStrategy::Link);
1525        assert_eq!(protocol.component_style_resource("my-card"), None);
1526    }
1527
1528    #[test]
1529    fn test_protobuf_roundtrip() {
1530        let protocol = sample_protocol();
1531        let bytes = protocol.to_protobuf().expect("encode failed");
1532        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1533        assert_eq!(protocol, decoded);
1534    }
1535
1536    #[test]
1537    fn test_protobuf_module_preloads_roundtrip_preserves_order() {
1538        // Order is load-bearing: preloads are issued in document order over one
1539        // connection, and a measured 125 ms swing separates largest-first from
1540        // smallest-first. A reordering encoder would silently erase the win.
1541        let mut protocol = sample_protocol();
1542        protocol.module_preloads = vec![
1543            "/chunk-big.js".to_string(),
1544            "/chunk-mid.js".to_string(),
1545            "/chunk-small.js".to_string(),
1546        ];
1547
1548        let bytes = protocol.to_protobuf().expect("encode failed");
1549        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1550
1551        assert_eq!(decoded.module_preloads, protocol.module_preloads);
1552        assert_eq!(protocol, decoded);
1553    }
1554
1555    #[test]
1556    fn test_protobuf_no_module_preloads_is_absent_from_the_wire() {
1557        // Builds without hints must not pay a byte for the field.
1558        let protocol = sample_protocol();
1559        let bytes = protocol.to_protobuf().expect("encode failed");
1560        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1561
1562        assert!(decoded.module_preloads.is_empty());
1563        assert!(
1564            !bytes.windows(2).any(|w| w == [0x3a, 0x00]),
1565            "an empty repeated field must not be encoded"
1566        );
1567    }
1568
1569    #[test]
1570    fn test_component_asset_style_preloads_roundtrip_preserves_generated_hrefs() {
1571        let mut protocol = sample_protocol();
1572        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
1573            root: "lazy-panel".to_string(),
1574            style_hrefs: vec![
1575                "/assets/lazy-panel-e5f6a7b8.css".to_string(),
1576                "/assets/shared-detail-10203040.css".to_string(),
1577            ],
1578        }];
1579
1580        let bytes = protocol.to_protobuf().expect("encode failed");
1581        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1582
1583        assert_eq!(
1584            decoded.component_asset_style_preloads,
1585            protocol.component_asset_style_preloads
1586        );
1587        assert_eq!(protocol, decoded);
1588    }
1589
1590    #[test]
1591    fn test_protobuf_boundary_fragment_roundtrip() {
1592        let mut protocol = sample_protocol();
1593        let main = protocol
1594            .fragments
1595            .get_mut("index.html")
1596            .expect("sample entry exists");
1597        main.fragments.push(WebUIFragment::boundary(
1598            7,
1599            "index.html",
1600            "weather shell",
1601            Some("forecast.id".to_string()),
1602        ));
1603        main.fragments
1604            .push(WebUIFragment::raw("<weather-panel></weather-panel>"));
1605        main.fragments.push(WebUIFragment::boundary_end(7));
1606        main.contains_boundary = true;
1607
1608        let bytes = protocol.to_protobuf().expect("encode failed");
1609        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1610
1611        let markers: Vec<&WebUiFragmentBoundary> = decoded.fragments["index.html"]
1612            .fragments
1613            .iter()
1614            .filter_map(|fragment| match fragment.fragment.as_ref() {
1615                Some(web_ui_fragment::Fragment::Boundary(boundary)) => Some(boundary),
1616                _ => None,
1617            })
1618            .collect();
1619        assert_eq!(markers.len(), 2, "the tape survives roundtrip as a pair");
1620        let boundary = markers[0];
1621        assert_eq!(boundary.phase(), BoundaryPhase::Start);
1622        assert_eq!(boundary.declaration_id, 7);
1623        assert_eq!(boundary.owner_fragment_id, "index.html");
1624        assert_eq!(boundary.name, "weather shell");
1625        assert_eq!(boundary.key.as_deref(), Some("forecast.id"));
1626        assert_eq!(markers[1].phase(), BoundaryPhase::End);
1627        assert_eq!(markers[1].declaration_id, 7);
1628        assert!(decoded.fragments["index.html"].contains_boundary);
1629        assert_eq!(protocol, decoded);
1630    }
1631
1632    #[test]
1633    fn test_protocol_rejects_unbalanced_boundary_tape() {
1634        let tape_cases: [(&str, Vec<WebUIFragment>); 3] = [
1635            (
1636                "start without end",
1637                vec![WebUIFragment::boundary(0, "index.html", "ready", None)],
1638            ),
1639            ("end without start", vec![WebUIFragment::boundary_end(0)]),
1640            (
1641                "nested start",
1642                vec![
1643                    WebUIFragment::boundary(0, "index.html", "outer", None),
1644                    WebUIFragment::boundary(1, "index.html", "inner", None),
1645                    WebUIFragment::boundary_end(1),
1646                    WebUIFragment::boundary_end(0),
1647                ],
1648            ),
1649        ];
1650        for (label, fragments) in tape_cases {
1651            let protocol = WebUIProtocol::new(HashMap::from([(
1652                "index.html".to_string(),
1653                FragmentList {
1654                    fragments,
1655                    contains_boundary: true,
1656                },
1657            )]));
1658            let bytes = protocol.to_protobuf().expect("encode failed");
1659            let error =
1660                WebUIProtocol::from_protobuf(&bytes).expect_err("unbalanced tape must be rejected");
1661            assert!(
1662                error.to_string().contains("Boundary"),
1663                "{label}: unexpected error {error}"
1664            );
1665        }
1666    }
1667
1668    #[test]
1669    fn test_protocol_has_no_fixed_streaming_boundary_table() {
1670        let json = sample_protocol().to_json_pretty().expect("serialize JSON");
1671        assert!(!json.contains("streaming_boundaries"));
1672        assert!(!json.contains("StreamingBoundaryList"));
1673    }
1674
1675    #[test]
1676    fn test_protobuf_all_fragment_types() {
1677        let mut fragments = HashMap::new();
1678        fragments.insert(
1679            "main".to_string(),
1680            FragmentList {
1681                fragments: vec![
1682                    WebUIFragment::raw("text"),
1683                    WebUIFragment::component("comp"),
1684                    WebUIFragment::for_loop("x", "xs", "loop"),
1685                    WebUIFragment::signal("sig", true),
1686                    WebUIFragment::if_cond(
1687                        ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"),
1688                        "cond",
1689                    ),
1690                    WebUIFragment::boundary(0, "main", "ready", None),
1691                    WebUIFragment::boundary_end(0),
1692                ],
1693                contains_boundary: true,
1694            },
1695        );
1696        fragments.insert(
1697            "comp".to_string(),
1698            FragmentList {
1699                fragments: vec![WebUIFragment::raw("c")],
1700                contains_boundary: false,
1701            },
1702        );
1703        fragments.insert(
1704            "loop".to_string(),
1705            FragmentList {
1706                fragments: vec![WebUIFragment::raw("l")],
1707                contains_boundary: false,
1708            },
1709        );
1710        fragments.insert(
1711            "cond".to_string(),
1712            FragmentList {
1713                fragments: vec![WebUIFragment::raw("i")],
1714                contains_boundary: false,
1715            },
1716        );
1717        fragments.insert(
1718            "boundary".to_string(),
1719            FragmentList {
1720                fragments: vec![WebUIFragment::raw("b")],
1721                contains_boundary: false,
1722            },
1723        );
1724
1725        let protocol = WebUIProtocol::new(fragments);
1726        let bytes = protocol.to_protobuf().unwrap();
1727        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1728        assert_eq!(protocol, decoded);
1729    }
1730
1731    #[test]
1732    fn test_protobuf_all_comparison_operators() {
1733        let ops = [
1734            ComparisonOperator::GreaterThan,
1735            ComparisonOperator::LessThan,
1736            ComparisonOperator::Equal,
1737            ComparisonOperator::NotEqual,
1738            ComparisonOperator::GreaterThanOrEqual,
1739            ComparisonOperator::LessThanOrEqual,
1740        ];
1741        for op in &ops {
1742            let mut fragments = HashMap::new();
1743            fragments.insert(
1744                "main".to_string(),
1745                FragmentList {
1746                    fragments: vec![WebUIFragment::if_cond(
1747                        ConditionExpr::predicate("a", *op, "b"),
1748                        "then",
1749                    )],
1750                    contains_boundary: false,
1751                },
1752            );
1753            fragments.insert(
1754                "then".to_string(),
1755                FragmentList {
1756                    fragments: vec![WebUIFragment::raw("ok")],
1757                    contains_boundary: false,
1758                },
1759            );
1760            let p = WebUIProtocol::new(fragments);
1761            let bytes = p.to_protobuf().unwrap();
1762            let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1763            assert_eq!(p, decoded);
1764        }
1765    }
1766
1767    #[test]
1768    fn test_protobuf_nested_conditions() {
1769        let nested = ConditionExpr::compound(
1770            ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"),
1771            LogicalOperator::And,
1772            ConditionExpr::negated(ConditionExpr::predicate(
1773                "user.disabled",
1774                ComparisonOperator::Equal,
1775                "true",
1776            )),
1777        );
1778
1779        let mut fragments = HashMap::new();
1780        fragments.insert(
1781            "main".to_string(),
1782            FragmentList {
1783                fragments: vec![WebUIFragment::if_cond(nested, "then")],
1784                contains_boundary: false,
1785            },
1786        );
1787        fragments.insert(
1788            "then".to_string(),
1789            FragmentList {
1790                fragments: vec![WebUIFragment::raw("ok")],
1791                contains_boundary: false,
1792            },
1793        );
1794        let p = WebUIProtocol::new(fragments);
1795        let bytes = p.to_protobuf().unwrap();
1796        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1797        assert_eq!(p, decoded);
1798    }
1799
1800    #[test]
1801    fn test_protobuf_compound_or_condition() {
1802        let compound = ConditionExpr::compound(
1803            ConditionExpr::identifier("isAdmin"),
1804            LogicalOperator::Or,
1805            ConditionExpr::identifier("isEditor"),
1806        );
1807
1808        let mut fragments = HashMap::new();
1809        fragments.insert(
1810            "main".to_string(),
1811            FragmentList {
1812                fragments: vec![WebUIFragment::if_cond(compound, "body")],
1813                contains_boundary: false,
1814            },
1815        );
1816        fragments.insert(
1817            "body".to_string(),
1818            FragmentList {
1819                fragments: vec![WebUIFragment::raw("yes")],
1820                contains_boundary: false,
1821            },
1822        );
1823        let p = WebUIProtocol::new(fragments);
1824        let bytes = p.to_protobuf().unwrap();
1825        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1826        assert_eq!(p, decoded);
1827    }
1828
1829    #[test]
1830    fn test_protobuf_invalid_bytes() {
1831        let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]);
1832        assert!(result.is_err());
1833    }
1834
1835    #[test]
1836    fn test_protobuf_empty_bytes() {
1837        let result = WebUIProtocol::from_protobuf(&[]);
1838        assert!(result.is_ok());
1839        assert!(result.unwrap().fragments.is_empty());
1840    }
1841
1842    #[test]
1843    fn test_protobuf_file_roundtrip() {
1844        let protocol = sample_protocol();
1845        let dir = std::env::temp_dir().join("webui-proto-test");
1846        std::fs::create_dir_all(&dir).unwrap();
1847        let path = dir.join("test.bin");
1848
1849        protocol.to_protobuf_file(&path).unwrap();
1850        let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap();
1851        assert_eq!(protocol, decoded);
1852
1853        std::fs::remove_dir_all(&dir).ok();
1854    }
1855
1856    #[test]
1857    fn test_protobuf_validation_catches_missing_reference() {
1858        let mut fragments = HashMap::new();
1859        fragments.insert(
1860            "main".to_string(),
1861            FragmentList {
1862                fragments: vec![WebUIFragment::component("does-not-exist")],
1863                contains_boundary: false,
1864            },
1865        );
1866
1867        let protocol = WebUIProtocol::new(fragments);
1868        let buf = protocol.to_protobuf().unwrap();
1869
1870        let result = WebUIProtocol::from_protobuf(&buf);
1871        assert!(result.is_err());
1872    }
1873
1874    #[test]
1875    fn test_protobuf_validation_catches_missing_for_reference() {
1876        let mut fragments = HashMap::new();
1877        fragments.insert(
1878            "main".to_string(),
1879            FragmentList {
1880                fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")],
1881                contains_boundary: false,
1882            },
1883        );
1884
1885        let protocol = WebUIProtocol::new(fragments);
1886        let buf = protocol.to_protobuf().unwrap();
1887
1888        let result = WebUIProtocol::from_protobuf(&buf);
1889        assert!(result.is_err());
1890        if let Err(ProtocolError::Validation(msg)) = result {
1891            assert!(msg.contains("missing-for"));
1892        }
1893    }
1894
1895    #[test]
1896    fn test_protobuf_validation_catches_missing_if_reference() {
1897        let mut fragments = HashMap::new();
1898        fragments.insert(
1899            "main".to_string(),
1900            FragmentList {
1901                fragments: vec![WebUIFragment::if_cond(
1902                    ConditionExpr::identifier("flag"),
1903                    "missing-if",
1904                )],
1905                contains_boundary: false,
1906            },
1907        );
1908
1909        let protocol = WebUIProtocol::new(fragments);
1910        let buf = protocol.to_protobuf().unwrap();
1911
1912        let result = WebUIProtocol::from_protobuf(&buf);
1913        assert!(result.is_err());
1914        if let Err(ProtocolError::Validation(msg)) = result {
1915            assert!(msg.contains("missing-if"));
1916        }
1917    }
1918
1919    #[test]
1920    fn test_protobuf_signal_default_raw_false() {
1921        let mut fragments = HashMap::new();
1922        fragments.insert(
1923            "main".to_string(),
1924            FragmentList {
1925                fragments: vec![WebUIFragment::signal("name", false)],
1926                contains_boundary: false,
1927            },
1928        );
1929        let p = WebUIProtocol::new(fragments);
1930        let bytes = p.to_protobuf().unwrap();
1931        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1932        let frag = &decoded.fragments["main"].fragments[0];
1933        match frag.fragment.as_ref() {
1934            Some(web_ui_fragment::Fragment::Signal(s)) => {
1935                assert!(!s.raw);
1936                assert!(!s.raw_text_context);
1937            }
1938            _ => panic!("expected signal"),
1939        }
1940    }
1941
1942    #[test]
1943    fn raw_text_signal_does_not_own_html_range() {
1944        let fragment = WebUIFragment::raw_text_signal("tokens.light", true);
1945        match fragment.fragment.as_ref() {
1946            Some(web_ui_fragment::Fragment::Signal(signal)) => {
1947                assert!(signal.raw);
1948                assert!(signal.raw_text_context);
1949            }
1950            _ => panic!("expected signal"),
1951        }
1952    }
1953
1954    #[test]
1955    fn test_protobuf_pre_allocated_buffer() {
1956        let protocol = sample_protocol();
1957        let bytes = protocol.to_protobuf().unwrap();
1958        assert_eq!(bytes.len(), protocol.encoded_len());
1959    }
1960
1961    #[test]
1962    fn test_protocol_new_has_empty_tokens() {
1963        let protocol = WebUIProtocol::new(HashMap::new());
1964        assert!(protocol.tokens.is_empty());
1965        assert!(protocol.fragments.is_empty());
1966        assert_eq!(
1967            protocol.initial_state_strategy,
1968            InitialStateStrategy::Full as i32
1969        );
1970    }
1971
1972    #[test]
1973    fn test_projection_metadata_roundtrips() {
1974        let mut protocol = WebUIProtocol::new(HashMap::new());
1975        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
1976        protocol.components.insert(
1977            "my-card".to_string(),
1978            ComponentData {
1979                hydration_keys: vec!["name".to_string()],
1980                hydration_mode: StateProjectionMode::Keys as i32,
1981                navigation_mode: Some(StateProjectionMode::All as i32),
1982                ..Default::default()
1983            },
1984        );
1985
1986        let bytes = protocol.to_protobuf().unwrap();
1987        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1988        assert_eq!(
1989            decoded.initial_state_strategy,
1990            InitialStateStrategy::Components as i32
1991        );
1992        let component = &decoded.components["my-card"];
1993        assert_eq!(component.hydration_mode, StateProjectionMode::Keys as i32);
1994        assert_eq!(component.hydration_keys, ["name"]);
1995        assert_eq!(
1996            component.navigation_mode,
1997            Some(StateProjectionMode::All as i32)
1998        );
1999        assert!(component.navigation_keys.is_empty());
2000    }
2001
2002    #[test]
2003    fn test_component_uses_shadow_dom_roundtrips() {
2004        let mut protocol = WebUIProtocol::new(HashMap::new());
2005        protocol.components.insert(
2006            "my-card".to_string(),
2007            ComponentData {
2008                uses_shadow_dom: true,
2009                ..Default::default()
2010            },
2011        );
2012
2013        let bytes = protocol.to_protobuf().unwrap();
2014        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
2015        assert!(decoded.component_uses_shadow_dom("my-card"));
2016        assert!(!decoded.component_uses_shadow_dom("missing-card"));
2017    }
2018
2019    #[test]
2020    fn test_protocol_with_tokens() {
2021        let tokens = vec!["color-primary".to_string(), "spacing-m".to_string()];
2022        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
2023        assert_eq!(protocol.tokens, tokens);
2024    }
2025
2026    #[test]
2027    fn test_protobuf_route_fragment_roundtrip() {
2028        let mut fragments = HashMap::new();
2029        fragments.insert(
2030            "main".to_string(),
2031            FragmentList {
2032                fragments: vec![WebUIFragment::route("/profile/:id", "profile-page")],
2033                contains_boundary: false,
2034            },
2035        );
2036        fragments.insert(
2037            "profile-page".to_string(),
2038            FragmentList {
2039                fragments: vec![WebUIFragment::raw("<h1>Profile</h1>")],
2040                contains_boundary: false,
2041            },
2042        );
2043        let protocol = WebUIProtocol::new(fragments);
2044        let bytes = protocol.to_protobuf().expect("encode failed");
2045        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2046        assert_eq!(protocol, decoded);
2047
2048        let frag = &decoded.fragments["main"].fragments[0];
2049        match frag.fragment.as_ref() {
2050            Some(web_ui_fragment::Fragment::Route(r)) => {
2051                assert_eq!(r.path, "/profile/:id");
2052                assert_eq!(r.fragment_id, "profile-page");
2053            }
2054            _ => panic!("expected route fragment"),
2055        }
2056    }
2057
2058    #[test]
2059    fn test_protobuf_route_fragment_all_fields() {
2060        let mut fragments = HashMap::new();
2061        let route_frag = WebUiFragment {
2062            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
2063                path: "/users/:id/posts/:postId".to_string(),
2064                fragment_id: "user-posts".to_string(),
2065                exact: true,
2066                children: Vec::new(),
2067                allowed_query: "action,to,subject".to_string(),
2068                keep_alive: false,
2069                cache_tags: vec!["user:{id}".to_string(), "posts".to_string()],
2070                invalidates: vec!["posts".to_string(), "counts".to_string()],
2071                pending_component: "loading-skeleton".to_string(),
2072                error_component: "error-page".to_string(),
2073                content_fragment_id: String::new(),
2074            })),
2075        };
2076        fragments.insert(
2077            "main".to_string(),
2078            FragmentList {
2079                fragments: vec![route_frag],
2080                contains_boundary: false,
2081            },
2082        );
2083        fragments.insert(
2084            "user-posts".into(),
2085            FragmentList {
2086                fragments: vec![WebUIFragment::raw("posts")],
2087                contains_boundary: false,
2088            },
2089        );
2090
2091        let protocol = WebUIProtocol::new(fragments);
2092        let bytes = protocol.to_protobuf().expect("encode failed");
2093        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2094        assert_eq!(protocol, decoded);
2095    }
2096
2097    #[test]
2098    fn test_protobuf_route_validation_missing_fragment() {
2099        let mut fragments = HashMap::new();
2100        fragments.insert(
2101            "main".to_string(),
2102            FragmentList {
2103                fragments: vec![WebUIFragment::route("/test", "missing-fragment")],
2104                contains_boundary: false,
2105            },
2106        );
2107        let protocol = WebUIProtocol::new(fragments);
2108        let buf = protocol.to_protobuf().expect("encode failed");
2109        let result = WebUIProtocol::from_protobuf(&buf);
2110        assert!(result.is_err());
2111        if let Err(ProtocolError::Validation(msg)) = result {
2112            assert!(msg.contains("missing-fragment"));
2113        }
2114    }
2115
2116    #[test]
2117    fn test_protobuf_route_no_fragment_id_roundtrip() {
2118        let mut fragments = HashMap::new();
2119        let route_frag = WebUiFragment {
2120            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
2121                path: "/old-path".to_string(),
2122                keep_alive: false,
2123                ..Default::default()
2124            })),
2125        };
2126        fragments.insert(
2127            "main".to_string(),
2128            FragmentList {
2129                fragments: vec![route_frag],
2130                contains_boundary: false,
2131            },
2132        );
2133        let protocol = WebUIProtocol::new(fragments);
2134        let bytes = protocol.to_protobuf().expect("encode failed");
2135        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2136        assert_eq!(protocol, decoded);
2137    }
2138
2139    #[test]
2140    fn test_protobuf_backward_compat_no_routes() {
2141        // Protocol without any fragments should decode successfully
2142        let protocol = WebUIProtocol::new(HashMap::new());
2143        let bytes = protocol.to_protobuf().expect("encode failed");
2144        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2145        assert!(decoded.fragments.is_empty());
2146    }
2147
2148    #[test]
2149    fn test_protobuf_roundtrip_with_tokens() {
2150        let mut fragments = HashMap::new();
2151        fragments.insert(
2152            "index.html".to_string(),
2153            FragmentList {
2154                fragments: vec![WebUIFragment::raw("Hello")],
2155                contains_boundary: false,
2156            },
2157        );
2158        let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()];
2159        let protocol = WebUIProtocol::with_tokens(fragments, tokens.clone());
2160
2161        let bytes = protocol.to_protobuf().expect("encode failed");
2162        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2163
2164        assert_eq!(decoded.tokens, tokens);
2165        assert!(decoded.fragments.contains_key("index.html"));
2166    }
2167}