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