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