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;
13use std::fmt;
14use std::io;
15use thiserror::Error;
16
17/// Plugin-specific protocol helpers for framework hydration metadata.
18pub mod plugin;
19
20/// Attribute-name ↔ property-name mapping for irregular HTML attributes.
21pub mod attrs;
22
23/// Generated protobuf types from `proto/webui.proto`.
24pub mod proto {
25    include!("gen_webui.rs");
26}
27
28// Re-export all generated types at the crate root.
29pub use plugin::FastElementData;
30pub use plugin::WebUIElementData;
31pub use proto::*;
32
33// Type aliases preserving the `WebUI` naming convention.
34// prost generates `WebUi*` from the proto `WebUI*` messages.
35pub type WebUIProtocol = WebUiProtocol;
36pub type WebUIFragment = WebUiFragment;
37pub type WebUIFragmentRaw = WebUiFragmentRaw;
38pub type WebUIFragmentComponent = WebUiFragmentComponent;
39pub type WebUIFragmentFor = WebUiFragmentFor;
40pub type WebUIFragmentSignal = WebUiFragmentSignal;
41pub type WebUIFragmentIf = WebUiFragmentIf;
42pub type WebUIFragmentAttribute = WebUiFragmentAttribute;
43pub type WebUIFragmentPlugin = WebUiFragmentPlugin;
44pub type WebUIFragmentRoute = WebUiFragmentRoute;
45pub type WebUIFragmentOutlet = WebUiFragmentOutlet;
46pub type ComponentData = proto::ComponentData;
47
48/// A mapping of unique fragment identifiers to their corresponding fragment lists.
49pub type WebUIFragmentRecords = HashMap<String, FragmentList>;
50
51#[derive(Debug, Error)]
52pub enum ProtocolError {
53    #[error("IO error: {0}")]
54    Io(#[from] io::Error),
55
56    #[error("Protocol validation error: {0}")]
57    Validation(String),
58}
59
60pub type Result<T> = std::result::Result<T, ProtocolError>;
61
62// ── Display implementations ─────────────────────────────────────────────
63
64impl fmt::Display for ComparisonOperator {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            ComparisonOperator::GreaterThan => write!(f, ">"),
68            ComparisonOperator::LessThan => write!(f, "<"),
69            ComparisonOperator::Equal => write!(f, "=="),
70            ComparisonOperator::NotEqual => write!(f, "!="),
71            ComparisonOperator::GreaterThanOrEqual => write!(f, ">="),
72            ComparisonOperator::LessThanOrEqual => write!(f, "<="),
73            ComparisonOperator::Unspecified => write!(f, "?"),
74        }
75    }
76}
77
78impl fmt::Display for LogicalOperator {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            LogicalOperator::And => write!(f, "&&"),
82            LogicalOperator::Or => write!(f, "||"),
83            LogicalOperator::Unspecified => write!(f, "?"),
84        }
85    }
86}
87
88impl fmt::Display for ConditionExpr {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match &self.expr {
91            Some(condition_expr::Expr::Identifier(id)) => write!(f, "{}", id.value),
92            Some(condition_expr::Expr::Predicate(pred)) => {
93                let op = ComparisonOperator::try_from(pred.operator)
94                    .unwrap_or(ComparisonOperator::Unspecified);
95                write!(f, "{} {} {}", pred.left, op, pred.right)
96            }
97            Some(condition_expr::Expr::Not(not)) => match &not.condition {
98                Some(inner) => write!(f, "!({})", inner),
99                None => write!(f, "!(?)"),
100            },
101            Some(condition_expr::Expr::Compound(compound)) => {
102                let op =
103                    LogicalOperator::try_from(compound.op).unwrap_or(LogicalOperator::Unspecified);
104                let left_str = compound
105                    .left
106                    .as_ref()
107                    .map(|l| l.to_string())
108                    .unwrap_or_else(|| "?".to_string());
109                let right_str = compound
110                    .right
111                    .as_ref()
112                    .map(|r| r.to_string())
113                    .unwrap_or_else(|| "?".to_string());
114                write!(f, "({} {} {})", left_str, op, right_str)
115            }
116            None => write!(f, "<empty>"),
117        }
118    }
119}
120
121// ── Convenience constructors ────────────────────────────────────────────
122
123impl WebUiFragment {
124    /// Create a raw (static content) fragment.
125    pub fn raw(value: impl Into<String>) -> Self {
126        Self {
127            fragment: Some(web_ui_fragment::Fragment::Raw(WebUiFragmentRaw {
128                value: value.into(),
129            })),
130        }
131    }
132
133    /// Create a component fragment.
134    pub fn component(fragment_id: impl Into<String>) -> Self {
135        Self {
136            fragment: Some(web_ui_fragment::Fragment::Component(
137                WebUiFragmentComponent {
138                    fragment_id: fragment_id.into(),
139                },
140            )),
141        }
142    }
143
144    /// Create a for-loop fragment.
145    pub fn for_loop(
146        item: impl Into<String>,
147        collection: impl Into<String>,
148        fragment_id: impl Into<String>,
149    ) -> Self {
150        Self {
151            fragment: Some(web_ui_fragment::Fragment::ForLoop(WebUiFragmentFor {
152                item: item.into(),
153                collection: collection.into(),
154                fragment_id: fragment_id.into(),
155            })),
156        }
157    }
158
159    /// Create a signal fragment.
160    pub fn signal(value: impl Into<String>, raw: bool) -> Self {
161        Self {
162            fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal {
163                value: value.into(),
164                raw,
165            })),
166        }
167    }
168
169    /// Create an if-condition fragment.
170    pub fn if_cond(condition: ConditionExpr, fragment_id: impl Into<String>) -> Self {
171        Self {
172            fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf {
173                condition: Some(condition),
174                fragment_id: fragment_id.into(),
175            })),
176        }
177    }
178
179    /// Create a simple dynamic attribute fragment (value is a single signal name).
180    pub fn attribute(name: impl Into<String>, value: impl Into<String>) -> Self {
181        Self {
182            fragment: Some(web_ui_fragment::Fragment::Attribute(
183                WebUiFragmentAttribute {
184                    name: name.into(),
185                    value: value.into(),
186                    ..Default::default()
187                },
188            )),
189        }
190    }
191
192    /// Create a template attribute fragment (mixed static + dynamic content).
193    pub fn attribute_template(name: impl Into<String>, template: impl Into<String>) -> Self {
194        Self {
195            fragment: Some(web_ui_fragment::Fragment::Attribute(
196                WebUiFragmentAttribute {
197                    name: name.into(),
198                    template: template.into(),
199                    ..Default::default()
200                },
201            )),
202        }
203    }
204
205    /// Create a complex attribute fragment (:-prefixed).
206    pub fn attribute_complex(name: impl Into<String>, value: impl Into<String>) -> Self {
207        Self {
208            fragment: Some(web_ui_fragment::Fragment::Attribute(
209                WebUiFragmentAttribute {
210                    name: name.into(),
211                    value: value.into(),
212                    complex: true,
213                    ..Default::default()
214                },
215            )),
216        }
217    }
218
219    /// Create a boolean attribute fragment (?-prefixed) with a condition tree.
220    pub fn attribute_boolean(name: impl Into<String>, condition_tree: ConditionExpr) -> Self {
221        Self {
222            fragment: Some(web_ui_fragment::Fragment::Attribute(
223                WebUiFragmentAttribute {
224                    name: name.into(),
225                    condition_tree: Some(condition_tree),
226                    ..Default::default()
227                },
228            )),
229        }
230    }
231
232    /// Create a plugin data fragment with opaque bytes.
233    /// The data is passed through to the handler plugin without interpretation.
234    pub fn plugin(data: Vec<u8>) -> Self {
235        Self {
236            fragment: Some(web_ui_fragment::Fragment::Plugin(WebUiFragmentPlugin {
237                data,
238            })),
239        }
240    }
241
242    /// Create a route fragment linking a URL path template to a fragment.
243    pub fn route(path: impl Into<String>, fragment_id: impl Into<String>) -> Self {
244        Self {
245            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
246                path: path.into(),
247                fragment_id: fragment_id.into(),
248                keep_alive: false,
249                ..Default::default()
250            })),
251        }
252    }
253
254    /// Create a route fragment from a pre-built `WebUiFragmentRoute`.
255    pub fn route_from(route: WebUiFragmentRoute) -> Self {
256        Self {
257            fragment: Some(web_ui_fragment::Fragment::Route(route)),
258        }
259    }
260
261    /// Create an outlet fragment.
262    pub fn outlet() -> Self {
263        Self {
264            fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
265        }
266    }
267}
268
269impl ConditionExpr {
270    /// Create an identifier condition.
271    pub fn identifier(value: impl Into<String>) -> Self {
272        Self {
273            expr: Some(condition_expr::Expr::Identifier(IdentifierCondition {
274                value: value.into(),
275            })),
276        }
277    }
278
279    /// Create a predicate condition.
280    pub fn predicate(
281        left: impl Into<String>,
282        operator: ComparisonOperator,
283        right: impl Into<String>,
284    ) -> Self {
285        Self {
286            expr: Some(condition_expr::Expr::Predicate(Predicate {
287                left: left.into(),
288                operator: operator as i32,
289                right: right.into(),
290            })),
291        }
292    }
293
294    /// Create a negation condition.
295    pub fn negated(inner: ConditionExpr) -> Self {
296        Self {
297            expr: Some(condition_expr::Expr::Not(Box::new(NotCondition {
298                condition: Some(Box::new(inner)),
299            }))),
300        }
301    }
302
303    /// Create a compound condition.
304    pub fn compound(left: ConditionExpr, op: LogicalOperator, right: ConditionExpr) -> Self {
305        Self {
306            expr: Some(condition_expr::Expr::Compound(Box::new(
307                CompoundCondition {
308                    left: Some(Box::new(left)),
309                    op: op as i32,
310                    right: Some(Box::new(right)),
311                },
312            ))),
313        }
314    }
315}
316
317// ── Constructors ────────────────────────────────────────────────────────
318
319impl WebUiProtocol {
320    /// Create a protocol from fragment records with no CSS tokens.
321    pub fn new(fragments: WebUIFragmentRecords) -> Self {
322        Self {
323            fragments,
324            tokens: Vec::new(),
325            components: HashMap::new(),
326            css_strategy: 0,
327            dom_strategy: 0,
328        }
329    }
330
331    /// Create a protocol from fragment records with CSS tokens.
332    pub fn with_tokens(fragments: WebUIFragmentRecords, tokens: Vec<String>) -> Self {
333        Self {
334            fragments,
335            tokens,
336            components: HashMap::new(),
337            css_strategy: 0,
338            dom_strategy: 0,
339        }
340    }
341}
342
343// ── Serialization / deserialization / validation ────────────────────────
344
345impl WebUiProtocol {
346    /// Validate that all fragment references point to existing fragment IDs.
347    fn validate_protocol(protocol: Self) -> Result<Self> {
348        let fragments = &protocol.fragments;
349
350        let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| {
351            fragment_list
352                .fragments
353                .iter()
354                .find_map(|frag| match frag.fragment.as_ref() {
355                    Some(web_ui_fragment::Fragment::Component(comp))
356                        if !fragments.contains_key(&comp.fragment_id) =>
357                    {
358                        Some(ProtocolError::Validation(format!(
359                            "Component references non-existent fragment ID: {}",
360                            comp.fragment_id
361                        )))
362                    }
363                    Some(web_ui_fragment::Fragment::ForLoop(fl))
364                        if !fragments.contains_key(&fl.fragment_id) =>
365                    {
366                        Some(ProtocolError::Validation(format!(
367                            "For loop references non-existent fragment ID: {}",
368                            fl.fragment_id
369                        )))
370                    }
371                    Some(web_ui_fragment::Fragment::IfCond(ic))
372                        if !fragments.contains_key(&ic.fragment_id) =>
373                    {
374                        Some(ProtocolError::Validation(format!(
375                            "If condition references non-existent fragment ID: {}",
376                            ic.fragment_id
377                        )))
378                    }
379                    Some(web_ui_fragment::Fragment::Attribute(attr))
380                        if !attr.template.is_empty() && !fragments.contains_key(&attr.template) =>
381                    {
382                        Some(ProtocolError::Validation(format!(
383                            "Attribute references non-existent template fragment ID: {}",
384                            attr.template
385                        )))
386                    }
387                    Some(web_ui_fragment::Fragment::Route(route)) => {
388                        if !route.fragment_id.is_empty()
389                            && !fragments.contains_key(&route.fragment_id)
390                        {
391                            return Some(ProtocolError::Validation(format!(
392                                "Route references non-existent fragment ID: {}",
393                                route.fragment_id
394                            )));
395                        }
396                        None
397                    }
398                    _ => None,
399                })
400        });
401
402        if let Some(err) = invalid_ref {
403            return Err(err);
404        }
405
406        Ok(protocol)
407    }
408
409    /// Serialize protocol to pretty JSON (for debug/inspect output only).
410    pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
411        serde_json::to_string_pretty(self)
412    }
413
414    /// Serialize protocol to protobuf binary format.
415    pub fn to_protobuf(&self) -> Result<Vec<u8>> {
416        let len = self.encoded_len();
417        let mut buf = Vec::with_capacity(len);
418        self.encode(&mut buf)
419            .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?;
420        Ok(buf)
421    }
422
423    /// Deserialize protocol from protobuf binary bytes with validation.
424    pub fn from_protobuf(bytes: &[u8]) -> Result<Self> {
425        let protocol = Self::decode(bytes)
426            .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?;
427        Self::validate_protocol(protocol)
428    }
429
430    /// Read and deserialize a protobuf file with validation.
431    pub fn from_protobuf_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
432        let bytes = std::fs::read(path)?;
433        Self::from_protobuf(&bytes)
434    }
435
436    /// Write protocol to a protobuf file.
437    pub fn to_protobuf_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
438        let bytes = self.to_protobuf()?;
439        std::fs::write(path, bytes)?;
440        Ok(())
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn sample_protocol() -> WebUIProtocol {
449        let mut fragments = HashMap::new();
450        fragments.insert(
451            "index.html".to_string(),
452            FragmentList {
453                fragments: vec![
454                    WebUIFragment::raw("Hello, WebUI!\n"),
455                    WebUIFragment::for_loop("person", "people", "for-1"),
456                    WebUIFragment::signal("description", true),
457                    WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"),
458                ],
459            },
460        );
461        fragments.insert(
462            "for-1".to_string(),
463            FragmentList {
464                fragments: vec![WebUIFragment::signal("person.name", false)],
465            },
466        );
467        fragments.insert(
468            "if-1".to_string(),
469            FragmentList {
470                fragments: vec![WebUIFragment::component("contact-card")],
471            },
472        );
473        fragments.insert(
474            "contact-card".to_string(),
475            FragmentList {
476                fragments: vec![
477                    WebUIFragment::raw("Hello, "),
478                    WebUIFragment::signal("name", false),
479                ],
480            },
481        );
482        WebUIProtocol::new(fragments)
483    }
484
485    #[test]
486    fn test_protobuf_roundtrip() {
487        let protocol = sample_protocol();
488        let bytes = protocol.to_protobuf().expect("encode failed");
489        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
490        assert_eq!(protocol, decoded);
491    }
492
493    #[test]
494    fn test_protobuf_all_fragment_types() {
495        let mut fragments = HashMap::new();
496        fragments.insert(
497            "main".to_string(),
498            FragmentList {
499                fragments: vec![
500                    WebUIFragment::raw("text"),
501                    WebUIFragment::component("comp"),
502                    WebUIFragment::for_loop("x", "xs", "loop"),
503                    WebUIFragment::signal("sig", true),
504                    WebUIFragment::if_cond(
505                        ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"),
506                        "cond",
507                    ),
508                ],
509            },
510        );
511        fragments.insert(
512            "comp".to_string(),
513            FragmentList {
514                fragments: vec![WebUIFragment::raw("c")],
515            },
516        );
517        fragments.insert(
518            "loop".to_string(),
519            FragmentList {
520                fragments: vec![WebUIFragment::raw("l")],
521            },
522        );
523        fragments.insert(
524            "cond".to_string(),
525            FragmentList {
526                fragments: vec![WebUIFragment::raw("i")],
527            },
528        );
529
530        let protocol = WebUIProtocol::new(fragments);
531        let bytes = protocol.to_protobuf().unwrap();
532        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
533        assert_eq!(protocol, decoded);
534    }
535
536    #[test]
537    fn test_protobuf_all_comparison_operators() {
538        let ops = [
539            ComparisonOperator::GreaterThan,
540            ComparisonOperator::LessThan,
541            ComparisonOperator::Equal,
542            ComparisonOperator::NotEqual,
543            ComparisonOperator::GreaterThanOrEqual,
544            ComparisonOperator::LessThanOrEqual,
545        ];
546        for op in &ops {
547            let mut fragments = HashMap::new();
548            fragments.insert(
549                "main".to_string(),
550                FragmentList {
551                    fragments: vec![WebUIFragment::if_cond(
552                        ConditionExpr::predicate("a", *op, "b"),
553                        "then",
554                    )],
555                },
556            );
557            fragments.insert(
558                "then".to_string(),
559                FragmentList {
560                    fragments: vec![WebUIFragment::raw("ok")],
561                },
562            );
563            let p = WebUIProtocol::new(fragments);
564            let bytes = p.to_protobuf().unwrap();
565            let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
566            assert_eq!(p, decoded);
567        }
568    }
569
570    #[test]
571    fn test_protobuf_nested_conditions() {
572        let nested = ConditionExpr::compound(
573            ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"),
574            LogicalOperator::And,
575            ConditionExpr::negated(ConditionExpr::predicate(
576                "user.disabled",
577                ComparisonOperator::Equal,
578                "true",
579            )),
580        );
581
582        let mut fragments = HashMap::new();
583        fragments.insert(
584            "main".to_string(),
585            FragmentList {
586                fragments: vec![WebUIFragment::if_cond(nested, "then")],
587            },
588        );
589        fragments.insert(
590            "then".to_string(),
591            FragmentList {
592                fragments: vec![WebUIFragment::raw("ok")],
593            },
594        );
595        let p = WebUIProtocol::new(fragments);
596        let bytes = p.to_protobuf().unwrap();
597        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
598        assert_eq!(p, decoded);
599    }
600
601    #[test]
602    fn test_protobuf_compound_or_condition() {
603        let compound = ConditionExpr::compound(
604            ConditionExpr::identifier("isAdmin"),
605            LogicalOperator::Or,
606            ConditionExpr::identifier("isEditor"),
607        );
608
609        let mut fragments = HashMap::new();
610        fragments.insert(
611            "main".to_string(),
612            FragmentList {
613                fragments: vec![WebUIFragment::if_cond(compound, "body")],
614            },
615        );
616        fragments.insert(
617            "body".to_string(),
618            FragmentList {
619                fragments: vec![WebUIFragment::raw("yes")],
620            },
621        );
622        let p = WebUIProtocol::new(fragments);
623        let bytes = p.to_protobuf().unwrap();
624        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
625        assert_eq!(p, decoded);
626    }
627
628    #[test]
629    fn test_protobuf_invalid_bytes() {
630        let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]);
631        assert!(result.is_err());
632    }
633
634    #[test]
635    fn test_protobuf_empty_bytes() {
636        let result = WebUIProtocol::from_protobuf(&[]);
637        assert!(result.is_ok());
638        assert!(result.unwrap().fragments.is_empty());
639    }
640
641    #[test]
642    fn test_protobuf_file_roundtrip() {
643        let protocol = sample_protocol();
644        let dir = std::env::temp_dir().join("webui-proto-test");
645        std::fs::create_dir_all(&dir).unwrap();
646        let path = dir.join("test.bin");
647
648        protocol.to_protobuf_file(&path).unwrap();
649        let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap();
650        assert_eq!(protocol, decoded);
651
652        std::fs::remove_dir_all(&dir).ok();
653    }
654
655    #[test]
656    fn test_protobuf_validation_catches_missing_reference() {
657        let mut fragments = HashMap::new();
658        fragments.insert(
659            "main".to_string(),
660            FragmentList {
661                fragments: vec![WebUIFragment::component("does-not-exist")],
662            },
663        );
664
665        let protocol = WebUIProtocol::new(fragments);
666        let buf = protocol.to_protobuf().unwrap();
667
668        let result = WebUIProtocol::from_protobuf(&buf);
669        assert!(result.is_err());
670    }
671
672    #[test]
673    fn test_protobuf_validation_catches_missing_for_reference() {
674        let mut fragments = HashMap::new();
675        fragments.insert(
676            "main".to_string(),
677            FragmentList {
678                fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")],
679            },
680        );
681
682        let protocol = WebUIProtocol::new(fragments);
683        let buf = protocol.to_protobuf().unwrap();
684
685        let result = WebUIProtocol::from_protobuf(&buf);
686        assert!(result.is_err());
687        if let Err(ProtocolError::Validation(msg)) = result {
688            assert!(msg.contains("missing-for"));
689        }
690    }
691
692    #[test]
693    fn test_protobuf_validation_catches_missing_if_reference() {
694        let mut fragments = HashMap::new();
695        fragments.insert(
696            "main".to_string(),
697            FragmentList {
698                fragments: vec![WebUIFragment::if_cond(
699                    ConditionExpr::identifier("flag"),
700                    "missing-if",
701                )],
702            },
703        );
704
705        let protocol = WebUIProtocol::new(fragments);
706        let buf = protocol.to_protobuf().unwrap();
707
708        let result = WebUIProtocol::from_protobuf(&buf);
709        assert!(result.is_err());
710        if let Err(ProtocolError::Validation(msg)) = result {
711            assert!(msg.contains("missing-if"));
712        }
713    }
714
715    #[test]
716    fn test_protobuf_signal_default_raw_false() {
717        let mut fragments = HashMap::new();
718        fragments.insert(
719            "main".to_string(),
720            FragmentList {
721                fragments: vec![WebUIFragment::signal("name", false)],
722            },
723        );
724        let p = WebUIProtocol::new(fragments);
725        let bytes = p.to_protobuf().unwrap();
726        let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
727        let frag = &decoded.fragments["main"].fragments[0];
728        match frag.fragment.as_ref() {
729            Some(web_ui_fragment::Fragment::Signal(s)) => assert!(!s.raw),
730            _ => panic!("expected signal"),
731        }
732    }
733
734    #[test]
735    fn test_protobuf_pre_allocated_buffer() {
736        let protocol = sample_protocol();
737        let bytes = protocol.to_protobuf().unwrap();
738        assert_eq!(bytes.len(), protocol.encoded_len());
739    }
740
741    #[test]
742    fn test_protocol_new_has_empty_tokens() {
743        let protocol = WebUIProtocol::new(HashMap::new());
744        assert!(protocol.tokens.is_empty());
745        assert!(protocol.fragments.is_empty());
746    }
747
748    #[test]
749    fn test_protocol_with_tokens() {
750        let tokens = vec!["color-primary".to_string(), "spacing-m".to_string()];
751        let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
752        assert_eq!(protocol.tokens, tokens);
753    }
754
755    #[test]
756    fn test_protobuf_route_fragment_roundtrip() {
757        let mut fragments = HashMap::new();
758        fragments.insert(
759            "main".to_string(),
760            FragmentList {
761                fragments: vec![WebUIFragment::route("/profile/:id", "profile-page")],
762            },
763        );
764        fragments.insert(
765            "profile-page".to_string(),
766            FragmentList {
767                fragments: vec![WebUIFragment::raw("<h1>Profile</h1>")],
768            },
769        );
770        let protocol = WebUIProtocol::new(fragments);
771        let bytes = protocol.to_protobuf().expect("encode failed");
772        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
773        assert_eq!(protocol, decoded);
774
775        let frag = &decoded.fragments["main"].fragments[0];
776        match frag.fragment.as_ref() {
777            Some(web_ui_fragment::Fragment::Route(r)) => {
778                assert_eq!(r.path, "/profile/:id");
779                assert_eq!(r.fragment_id, "profile-page");
780            }
781            _ => panic!("expected route fragment"),
782        }
783    }
784
785    #[test]
786    fn test_protobuf_route_fragment_all_fields() {
787        let mut fragments = HashMap::new();
788        let route_frag = WebUiFragment {
789            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
790                path: "/users/:id/posts/:postId".to_string(),
791                fragment_id: "user-posts".to_string(),
792                exact: true,
793                children: Vec::new(),
794                allowed_query: "action,to,subject".to_string(),
795                keep_alive: false,
796                cache_tags: vec!["user:{id}".to_string(), "posts".to_string()],
797                invalidates: vec!["posts".to_string(), "counts".to_string()],
798                pending_component: "loading-skeleton".to_string(),
799                error_component: "error-page".to_string(),
800            })),
801        };
802        fragments.insert(
803            "main".to_string(),
804            FragmentList {
805                fragments: vec![route_frag],
806            },
807        );
808        fragments.insert(
809            "user-posts".into(),
810            FragmentList {
811                fragments: vec![WebUIFragment::raw("posts")],
812            },
813        );
814
815        let protocol = WebUIProtocol::new(fragments);
816        let bytes = protocol.to_protobuf().expect("encode failed");
817        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
818        assert_eq!(protocol, decoded);
819    }
820
821    #[test]
822    fn test_protobuf_route_validation_missing_fragment() {
823        let mut fragments = HashMap::new();
824        fragments.insert(
825            "main".to_string(),
826            FragmentList {
827                fragments: vec![WebUIFragment::route("/test", "missing-fragment")],
828            },
829        );
830        let protocol = WebUIProtocol::new(fragments);
831        let buf = protocol.to_protobuf().expect("encode failed");
832        let result = WebUIProtocol::from_protobuf(&buf);
833        assert!(result.is_err());
834        if let Err(ProtocolError::Validation(msg)) = result {
835            assert!(msg.contains("missing-fragment"));
836        }
837    }
838
839    #[test]
840    fn test_protobuf_route_no_fragment_id_roundtrip() {
841        let mut fragments = HashMap::new();
842        let route_frag = WebUiFragment {
843            fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
844                path: "/old-path".to_string(),
845                keep_alive: false,
846                ..Default::default()
847            })),
848        };
849        fragments.insert(
850            "main".to_string(),
851            FragmentList {
852                fragments: vec![route_frag],
853            },
854        );
855        let protocol = WebUIProtocol::new(fragments);
856        let bytes = protocol.to_protobuf().expect("encode failed");
857        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
858        assert_eq!(protocol, decoded);
859    }
860
861    #[test]
862    fn test_protobuf_backward_compat_no_routes() {
863        // Protocol without any fragments should decode successfully
864        let protocol = WebUIProtocol::new(HashMap::new());
865        let bytes = protocol.to_protobuf().expect("encode failed");
866        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
867        assert!(decoded.fragments.is_empty());
868    }
869
870    #[test]
871    fn test_protobuf_roundtrip_with_tokens() {
872        let mut fragments = HashMap::new();
873        fragments.insert(
874            "index.html".to_string(),
875            FragmentList {
876                fragments: vec![WebUIFragment::raw("Hello")],
877            },
878        );
879        let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()];
880        let protocol = WebUIProtocol::with_tokens(fragments, tokens.clone());
881
882        let bytes = protocol.to_protobuf().expect("encode failed");
883        let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
884
885        assert_eq!(decoded.tokens, tokens);
886        assert!(decoded.fragments.contains_key("index.html"));
887    }
888}