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