1use prost::Message;
12use std::collections::HashMap;
13use std::fmt;
14use std::io;
15use thiserror::Error;
16
17pub mod plugin;
19
20pub mod attrs;
22
23pub mod proto {
25 include!("gen_webui.rs");
26}
27
28pub use plugin::FastElementData;
30pub use plugin::WebUIElementData;
31pub use proto::*;
32
33pub 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
48pub 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
62impl 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 ¬.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
121impl WebUiFragment {
124 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 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 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 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 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 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 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 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 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 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 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 pub fn route_from(route: WebUiFragmentRoute) -> Self {
256 Self {
257 fragment: Some(web_ui_fragment::Fragment::Route(route)),
258 }
259 }
260
261 pub fn outlet() -> Self {
263 Self {
264 fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
265 }
266 }
267}
268
269impl ConditionExpr {
270 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 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 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 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
317impl WebUiProtocol {
320 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 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
343impl WebUiProtocol {
346 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 pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
411 serde_json::to_string_pretty(self)
412 }
413
414 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 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 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 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 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}