1use prost::Message;
12use std::collections::HashMap;
13use std::fmt;
14use std::io;
15use thiserror::Error;
16
17pub mod plugin;
19
20pub mod projection_manifest;
22
23pub mod attrs;
25
26pub mod proto {
28 include!("gen_webui.rs");
29}
30
31pub use plugin::FastElementData;
33pub use plugin::WebUIElementData;
34pub use proto::*;
35
36pub 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
51pub 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
65impl 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 ¬.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
124impl WebUiFragment {
127 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 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 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 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 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 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 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 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 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 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 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 pub fn route_from(route: WebUiFragmentRoute) -> Self {
259 Self {
260 fragment: Some(web_ui_fragment::Fragment::Route(route)),
261 }
262 }
263
264 pub fn outlet() -> Self {
266 Self {
267 fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
268 }
269 }
270}
271
272impl ConditionExpr {
273 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 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 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 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
320impl WebUiProtocol {
323 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 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
348impl WebUiProtocol {
351 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 pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
416 serde_json::to_string_pretty(self)
417 }
418
419 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 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 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 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 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}