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!(concat!(env!("OUT_DIR"), "/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 ..Default::default()
249 })),
250 }
251 }
252
253 pub fn route_from(route: WebUiFragmentRoute) -> Self {
255 Self {
256 fragment: Some(web_ui_fragment::Fragment::Route(route)),
257 }
258 }
259
260 pub fn outlet() -> Self {
262 Self {
263 fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
264 }
265 }
266}
267
268impl ConditionExpr {
269 pub fn identifier(value: impl Into<String>) -> Self {
271 Self {
272 expr: Some(condition_expr::Expr::Identifier(IdentifierCondition {
273 value: value.into(),
274 })),
275 }
276 }
277
278 pub fn predicate(
280 left: impl Into<String>,
281 operator: ComparisonOperator,
282 right: impl Into<String>,
283 ) -> Self {
284 Self {
285 expr: Some(condition_expr::Expr::Predicate(Predicate {
286 left: left.into(),
287 operator: operator as i32,
288 right: right.into(),
289 })),
290 }
291 }
292
293 pub fn negated(inner: ConditionExpr) -> Self {
295 Self {
296 expr: Some(condition_expr::Expr::Not(Box::new(NotCondition {
297 condition: Some(Box::new(inner)),
298 }))),
299 }
300 }
301
302 pub fn compound(left: ConditionExpr, op: LogicalOperator, right: ConditionExpr) -> Self {
304 Self {
305 expr: Some(condition_expr::Expr::Compound(Box::new(
306 CompoundCondition {
307 left: Some(Box::new(left)),
308 op: op as i32,
309 right: Some(Box::new(right)),
310 },
311 ))),
312 }
313 }
314}
315
316impl WebUiProtocol {
319 pub fn new(fragments: WebUIFragmentRecords) -> Self {
321 Self {
322 fragments,
323 tokens: Vec::new(),
324 components: HashMap::new(),
325 }
326 }
327
328 pub fn with_tokens(fragments: WebUIFragmentRecords, tokens: Vec<String>) -> Self {
330 Self {
331 fragments,
332 tokens,
333 components: HashMap::new(),
334 }
335 }
336}
337
338impl WebUiProtocol {
341 fn validate_protocol(protocol: Self) -> Result<Self> {
343 let fragments = &protocol.fragments;
344
345 let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| {
346 fragment_list
347 .fragments
348 .iter()
349 .find_map(|frag| match frag.fragment.as_ref() {
350 Some(web_ui_fragment::Fragment::Component(comp))
351 if !fragments.contains_key(&comp.fragment_id) =>
352 {
353 Some(ProtocolError::Validation(format!(
354 "Component references non-existent fragment ID: {}",
355 comp.fragment_id
356 )))
357 }
358 Some(web_ui_fragment::Fragment::ForLoop(fl))
359 if !fragments.contains_key(&fl.fragment_id) =>
360 {
361 Some(ProtocolError::Validation(format!(
362 "For loop references non-existent fragment ID: {}",
363 fl.fragment_id
364 )))
365 }
366 Some(web_ui_fragment::Fragment::IfCond(ic))
367 if !fragments.contains_key(&ic.fragment_id) =>
368 {
369 Some(ProtocolError::Validation(format!(
370 "If condition references non-existent fragment ID: {}",
371 ic.fragment_id
372 )))
373 }
374 Some(web_ui_fragment::Fragment::Attribute(attr))
375 if !attr.template.is_empty() && !fragments.contains_key(&attr.template) =>
376 {
377 Some(ProtocolError::Validation(format!(
378 "Attribute references non-existent template fragment ID: {}",
379 attr.template
380 )))
381 }
382 Some(web_ui_fragment::Fragment::Route(route)) => {
383 if !route.fragment_id.is_empty()
384 && !fragments.contains_key(&route.fragment_id)
385 {
386 return Some(ProtocolError::Validation(format!(
387 "Route references non-existent fragment ID: {}",
388 route.fragment_id
389 )));
390 }
391 None
392 }
393 _ => None,
394 })
395 });
396
397 if let Some(err) = invalid_ref {
398 return Err(err);
399 }
400
401 Ok(protocol)
402 }
403
404 pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
406 serde_json::to_string_pretty(self)
407 }
408
409 pub fn to_protobuf(&self) -> Result<Vec<u8>> {
411 let len = self.encoded_len();
412 let mut buf = Vec::with_capacity(len);
413 self.encode(&mut buf)
414 .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?;
415 Ok(buf)
416 }
417
418 pub fn from_protobuf(bytes: &[u8]) -> Result<Self> {
420 let protocol = Self::decode(bytes)
421 .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?;
422 Self::validate_protocol(protocol)
423 }
424
425 pub fn from_protobuf_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
427 let bytes = std::fs::read(path)?;
428 Self::from_protobuf(&bytes)
429 }
430
431 pub fn to_protobuf_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
433 let bytes = self.to_protobuf()?;
434 std::fs::write(path, bytes)?;
435 Ok(())
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 fn sample_protocol() -> WebUIProtocol {
444 let mut fragments = HashMap::new();
445 fragments.insert(
446 "index.html".to_string(),
447 FragmentList {
448 fragments: vec![
449 WebUIFragment::raw("Hello, WebUI!\n"),
450 WebUIFragment::for_loop("person", "people", "for-1"),
451 WebUIFragment::signal("description", true),
452 WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"),
453 ],
454 },
455 );
456 fragments.insert(
457 "for-1".to_string(),
458 FragmentList {
459 fragments: vec![WebUIFragment::signal("person.name", false)],
460 },
461 );
462 fragments.insert(
463 "if-1".to_string(),
464 FragmentList {
465 fragments: vec![WebUIFragment::component("contact-card")],
466 },
467 );
468 fragments.insert(
469 "contact-card".to_string(),
470 FragmentList {
471 fragments: vec![
472 WebUIFragment::raw("Hello, "),
473 WebUIFragment::signal("name", false),
474 ],
475 },
476 );
477 WebUIProtocol::new(fragments)
478 }
479
480 #[test]
481 fn test_protobuf_roundtrip() {
482 let protocol = sample_protocol();
483 let bytes = protocol.to_protobuf().expect("encode failed");
484 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
485 assert_eq!(protocol, decoded);
486 }
487
488 #[test]
489 fn test_protobuf_all_fragment_types() {
490 let mut fragments = HashMap::new();
491 fragments.insert(
492 "main".to_string(),
493 FragmentList {
494 fragments: vec![
495 WebUIFragment::raw("text"),
496 WebUIFragment::component("comp"),
497 WebUIFragment::for_loop("x", "xs", "loop"),
498 WebUIFragment::signal("sig", true),
499 WebUIFragment::if_cond(
500 ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"),
501 "cond",
502 ),
503 ],
504 },
505 );
506 fragments.insert(
507 "comp".to_string(),
508 FragmentList {
509 fragments: vec![WebUIFragment::raw("c")],
510 },
511 );
512 fragments.insert(
513 "loop".to_string(),
514 FragmentList {
515 fragments: vec![WebUIFragment::raw("l")],
516 },
517 );
518 fragments.insert(
519 "cond".to_string(),
520 FragmentList {
521 fragments: vec![WebUIFragment::raw("i")],
522 },
523 );
524
525 let protocol = WebUIProtocol::new(fragments);
526 let bytes = protocol.to_protobuf().unwrap();
527 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
528 assert_eq!(protocol, decoded);
529 }
530
531 #[test]
532 fn test_protobuf_all_comparison_operators() {
533 let ops = [
534 ComparisonOperator::GreaterThan,
535 ComparisonOperator::LessThan,
536 ComparisonOperator::Equal,
537 ComparisonOperator::NotEqual,
538 ComparisonOperator::GreaterThanOrEqual,
539 ComparisonOperator::LessThanOrEqual,
540 ];
541 for op in &ops {
542 let mut fragments = HashMap::new();
543 fragments.insert(
544 "main".to_string(),
545 FragmentList {
546 fragments: vec![WebUIFragment::if_cond(
547 ConditionExpr::predicate("a", *op, "b"),
548 "then",
549 )],
550 },
551 );
552 fragments.insert(
553 "then".to_string(),
554 FragmentList {
555 fragments: vec![WebUIFragment::raw("ok")],
556 },
557 );
558 let p = WebUIProtocol::new(fragments);
559 let bytes = p.to_protobuf().unwrap();
560 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
561 assert_eq!(p, decoded);
562 }
563 }
564
565 #[test]
566 fn test_protobuf_nested_conditions() {
567 let nested = ConditionExpr::compound(
568 ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"),
569 LogicalOperator::And,
570 ConditionExpr::negated(ConditionExpr::predicate(
571 "user.disabled",
572 ComparisonOperator::Equal,
573 "true",
574 )),
575 );
576
577 let mut fragments = HashMap::new();
578 fragments.insert(
579 "main".to_string(),
580 FragmentList {
581 fragments: vec![WebUIFragment::if_cond(nested, "then")],
582 },
583 );
584 fragments.insert(
585 "then".to_string(),
586 FragmentList {
587 fragments: vec![WebUIFragment::raw("ok")],
588 },
589 );
590 let p = WebUIProtocol::new(fragments);
591 let bytes = p.to_protobuf().unwrap();
592 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
593 assert_eq!(p, decoded);
594 }
595
596 #[test]
597 fn test_protobuf_compound_or_condition() {
598 let compound = ConditionExpr::compound(
599 ConditionExpr::identifier("isAdmin"),
600 LogicalOperator::Or,
601 ConditionExpr::identifier("isEditor"),
602 );
603
604 let mut fragments = HashMap::new();
605 fragments.insert(
606 "main".to_string(),
607 FragmentList {
608 fragments: vec![WebUIFragment::if_cond(compound, "body")],
609 },
610 );
611 fragments.insert(
612 "body".to_string(),
613 FragmentList {
614 fragments: vec![WebUIFragment::raw("yes")],
615 },
616 );
617 let p = WebUIProtocol::new(fragments);
618 let bytes = p.to_protobuf().unwrap();
619 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
620 assert_eq!(p, decoded);
621 }
622
623 #[test]
624 fn test_protobuf_invalid_bytes() {
625 let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]);
626 assert!(result.is_err());
627 }
628
629 #[test]
630 fn test_protobuf_empty_bytes() {
631 let result = WebUIProtocol::from_protobuf(&[]);
632 assert!(result.is_ok());
633 assert!(result.unwrap().fragments.is_empty());
634 }
635
636 #[test]
637 fn test_protobuf_file_roundtrip() {
638 let protocol = sample_protocol();
639 let dir = std::env::temp_dir().join("webui-proto-test");
640 std::fs::create_dir_all(&dir).unwrap();
641 let path = dir.join("test.bin");
642
643 protocol.to_protobuf_file(&path).unwrap();
644 let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap();
645 assert_eq!(protocol, decoded);
646
647 std::fs::remove_dir_all(&dir).ok();
648 }
649
650 #[test]
651 fn test_protobuf_validation_catches_missing_reference() {
652 let mut fragments = HashMap::new();
653 fragments.insert(
654 "main".to_string(),
655 FragmentList {
656 fragments: vec![WebUIFragment::component("does-not-exist")],
657 },
658 );
659
660 let protocol = WebUIProtocol::new(fragments);
661 let buf = protocol.to_protobuf().unwrap();
662
663 let result = WebUIProtocol::from_protobuf(&buf);
664 assert!(result.is_err());
665 }
666
667 #[test]
668 fn test_protobuf_validation_catches_missing_for_reference() {
669 let mut fragments = HashMap::new();
670 fragments.insert(
671 "main".to_string(),
672 FragmentList {
673 fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")],
674 },
675 );
676
677 let protocol = WebUIProtocol::new(fragments);
678 let buf = protocol.to_protobuf().unwrap();
679
680 let result = WebUIProtocol::from_protobuf(&buf);
681 assert!(result.is_err());
682 if let Err(ProtocolError::Validation(msg)) = result {
683 assert!(msg.contains("missing-for"));
684 }
685 }
686
687 #[test]
688 fn test_protobuf_validation_catches_missing_if_reference() {
689 let mut fragments = HashMap::new();
690 fragments.insert(
691 "main".to_string(),
692 FragmentList {
693 fragments: vec![WebUIFragment::if_cond(
694 ConditionExpr::identifier("flag"),
695 "missing-if",
696 )],
697 },
698 );
699
700 let protocol = WebUIProtocol::new(fragments);
701 let buf = protocol.to_protobuf().unwrap();
702
703 let result = WebUIProtocol::from_protobuf(&buf);
704 assert!(result.is_err());
705 if let Err(ProtocolError::Validation(msg)) = result {
706 assert!(msg.contains("missing-if"));
707 }
708 }
709
710 #[test]
711 fn test_protobuf_signal_default_raw_false() {
712 let mut fragments = HashMap::new();
713 fragments.insert(
714 "main".to_string(),
715 FragmentList {
716 fragments: vec![WebUIFragment::signal("name", false)],
717 },
718 );
719 let p = WebUIProtocol::new(fragments);
720 let bytes = p.to_protobuf().unwrap();
721 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
722 let frag = &decoded.fragments["main"].fragments[0];
723 match frag.fragment.as_ref() {
724 Some(web_ui_fragment::Fragment::Signal(s)) => assert!(!s.raw),
725 _ => panic!("expected signal"),
726 }
727 }
728
729 #[test]
730 fn test_protobuf_pre_allocated_buffer() {
731 let protocol = sample_protocol();
732 let bytes = protocol.to_protobuf().unwrap();
733 assert_eq!(bytes.len(), protocol.encoded_len());
734 }
735
736 #[test]
737 fn test_protocol_new_has_empty_tokens() {
738 let protocol = WebUIProtocol::new(HashMap::new());
739 assert!(protocol.tokens.is_empty());
740 assert!(protocol.fragments.is_empty());
741 }
742
743 #[test]
744 fn test_protocol_with_tokens() {
745 let tokens = vec!["color-primary".to_string(), "spacing-m".to_string()];
746 let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
747 assert_eq!(protocol.tokens, tokens);
748 }
749
750 #[test]
751 fn test_protobuf_route_fragment_roundtrip() {
752 let mut fragments = HashMap::new();
753 fragments.insert(
754 "main".to_string(),
755 FragmentList {
756 fragments: vec![WebUIFragment::route("/profile/:id", "profile-page")],
757 },
758 );
759 fragments.insert(
760 "profile-page".to_string(),
761 FragmentList {
762 fragments: vec![WebUIFragment::raw("<h1>Profile</h1>")],
763 },
764 );
765 let protocol = WebUIProtocol::new(fragments);
766 let bytes = protocol.to_protobuf().expect("encode failed");
767 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
768 assert_eq!(protocol, decoded);
769
770 let frag = &decoded.fragments["main"].fragments[0];
771 match frag.fragment.as_ref() {
772 Some(web_ui_fragment::Fragment::Route(r)) => {
773 assert_eq!(r.path, "/profile/:id");
774 assert_eq!(r.fragment_id, "profile-page");
775 }
776 _ => panic!("expected route fragment"),
777 }
778 }
779
780 #[test]
781 fn test_protobuf_route_fragment_all_fields() {
782 let mut fragments = HashMap::new();
783 let route_frag = WebUiFragment {
784 fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
785 path: "/users/:id/posts/:postId".to_string(),
786 fragment_id: "user-posts".to_string(),
787 exact: true,
788 children: Vec::new(),
789 allowed_query: "action,to,subject".to_string(),
790 })),
791 };
792 fragments.insert(
793 "main".to_string(),
794 FragmentList {
795 fragments: vec![route_frag],
796 },
797 );
798 fragments.insert(
799 "user-posts".into(),
800 FragmentList {
801 fragments: vec![WebUIFragment::raw("posts")],
802 },
803 );
804
805 let protocol = WebUIProtocol::new(fragments);
806 let bytes = protocol.to_protobuf().expect("encode failed");
807 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
808 assert_eq!(protocol, decoded);
809 }
810
811 #[test]
812 fn test_protobuf_route_validation_missing_fragment() {
813 let mut fragments = HashMap::new();
814 fragments.insert(
815 "main".to_string(),
816 FragmentList {
817 fragments: vec![WebUIFragment::route("/test", "missing-fragment")],
818 },
819 );
820 let protocol = WebUIProtocol::new(fragments);
821 let buf = protocol.to_protobuf().expect("encode failed");
822 let result = WebUIProtocol::from_protobuf(&buf);
823 assert!(result.is_err());
824 if let Err(ProtocolError::Validation(msg)) = result {
825 assert!(msg.contains("missing-fragment"));
826 }
827 }
828
829 #[test]
830 fn test_protobuf_route_no_fragment_id_roundtrip() {
831 let mut fragments = HashMap::new();
832 let route_frag = WebUiFragment {
833 fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
834 path: "/old-path".to_string(),
835 ..Default::default()
836 })),
837 };
838 fragments.insert(
839 "main".to_string(),
840 FragmentList {
841 fragments: vec![route_frag],
842 },
843 );
844 let protocol = WebUIProtocol::new(fragments);
845 let bytes = protocol.to_protobuf().expect("encode failed");
846 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
847 assert_eq!(protocol, decoded);
848 }
849
850 #[test]
851 fn test_protobuf_backward_compat_no_routes() {
852 let protocol = WebUIProtocol::new(HashMap::new());
854 let bytes = protocol.to_protobuf().expect("encode failed");
855 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
856 assert!(decoded.fragments.is_empty());
857 }
858
859 #[test]
860 fn test_protobuf_roundtrip_with_tokens() {
861 let mut fragments = HashMap::new();
862 fragments.insert(
863 "index.html".to_string(),
864 FragmentList {
865 fragments: vec![WebUIFragment::raw("Hello")],
866 },
867 );
868 let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()];
869 let protocol = WebUIProtocol::with_tokens(fragments, tokens.clone());
870
871 let bytes = protocol.to_protobuf().expect("encode failed");
872 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
873
874 assert_eq!(decoded.tokens, tokens);
875 assert!(decoded.fragments.contains_key("index.html"));
876 }
877}