1use crate::{Event, ShellyError};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4
5pub const PROTOCOL_VERSION_V1: &str = "shelly/1";
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(tag = "type", rename_all = "snake_case")]
10pub enum ClientMessage {
11 Connect {
13 protocol: String,
14 #[serde(default)]
15 session_id: Option<String>,
16 #[serde(default)]
17 last_revision: Option<u64>,
18 #[serde(default)]
19 resume_token: Option<String>,
20 #[serde(default)]
21 tenant_id: Option<String>,
22 #[serde(default)]
23 trace_id: Option<String>,
24 #[serde(default)]
25 span_id: Option<String>,
26 #[serde(default)]
27 parent_span_id: Option<String>,
28 #[serde(default)]
29 correlation_id: Option<String>,
30 #[serde(default)]
31 request_id: Option<String>,
32 },
33
34 Event {
36 event: String,
37 target: Option<String>,
38 #[serde(default)]
39 value: Value,
40 #[serde(default)]
41 metadata: Map<String, Value>,
42 },
43
44 Ping { nonce: Option<String> },
46
47 PatchUrl { to: String },
49
50 Navigate { to: String },
52
53 UploadStart {
55 upload_id: String,
56 event: String,
57 target: Option<String>,
58 name: String,
59 size: u64,
60 content_type: Option<String>,
61 },
62
63 UploadChunk {
65 upload_id: String,
66 offset: u64,
67 data: String,
68 },
69
70 UploadComplete { upload_id: String },
72}
73
74impl TryFrom<ClientMessage> for Event {
75 type Error = ShellyError;
76
77 fn try_from(message: ClientMessage) -> Result<Self, Self::Error> {
78 match message {
79 ClientMessage::Connect { .. } => Err(ShellyError::InvalidMessage(
80 "connect cannot be converted into a LiveView event".to_string(),
81 )),
82 ClientMessage::Event {
83 event,
84 target,
85 value,
86 metadata,
87 } => Ok(Event {
88 name: event,
89 target,
90 value,
91 metadata,
92 }),
93 ClientMessage::Ping { .. } => Err(ShellyError::InvalidMessage(
94 "ping cannot be converted into a LiveView event".to_string(),
95 )),
96 ClientMessage::PatchUrl { .. } => Err(ShellyError::InvalidMessage(
97 "patch_url cannot be converted into a LiveView event".to_string(),
98 )),
99 ClientMessage::Navigate { .. } => Err(ShellyError::InvalidMessage(
100 "navigate cannot be converted into a LiveView event".to_string(),
101 )),
102 ClientMessage::UploadStart { .. }
103 | ClientMessage::UploadChunk { .. }
104 | ClientMessage::UploadComplete { .. } => Err(ShellyError::InvalidMessage(
105 "upload protocol messages cannot be converted into LiveView events".to_string(),
106 )),
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113#[serde(tag = "type", rename_all = "snake_case")]
114pub enum ServerMessage {
115 Hello {
117 session_id: String,
118 target: String,
119 revision: u64,
120 protocol: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 server_revision: Option<u64>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 resume_status: Option<ResumeStatus>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 resume_reason: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 resume_token: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 resume_expires_in_ms: Option<u64>,
131 },
132
133 Patch {
135 target: String,
136 html: String,
137 revision: u64,
138 },
139
140 Diff {
142 target: String,
143 revision: u64,
144 slots: Vec<DynamicSlotPatch>,
145 },
146
147 StreamInsert {
149 target: String,
150 id: String,
151 html: String,
152 at: StreamPosition,
153 },
154
155 StreamDelete { target: String, id: String },
157
158 StreamBatch {
160 target: String,
161 operations: Vec<StreamBatchOperation>,
162 },
163
164 ChartSeriesAppend {
166 chart: String,
167 series: String,
168 point: ChartPoint,
169 },
170
171 ChartSeriesAppendMany {
173 chart: String,
174 series: String,
175 points: Vec<ChartPoint>,
176 },
177
178 ChartSeriesReplace {
180 chart: String,
181 series: String,
182 points: Vec<ChartPoint>,
183 },
184
185 ChartReset { chart: String },
187
188 ChartAnnotationUpsert {
190 chart: String,
191 annotation: ChartAnnotation,
192 },
193
194 ChartAnnotationDelete { chart: String, id: String },
196
197 ToastPush { toast: Toast },
199
200 ToastDismiss { id: String },
202
203 InboxUpsert { item: InboxItem },
205
206 InboxDelete { id: String },
208
209 GridReplace { grid: String, state: GridState },
211
212 GridRowsReplace {
214 grid: String,
215 window: GridRowsWindow,
216 },
217
218 InteropDispatch { dispatch: JsInteropDispatch },
220
221 Pong { nonce: Option<String> },
223
224 Redirect { to: String },
226
227 PatchUrl { to: String },
229
230 Navigate { to: String },
232
233 UploadProgress {
235 upload_id: String,
236 received: u64,
237 total: u64,
238 },
239
240 UploadComplete {
242 upload_id: String,
243 name: String,
244 size: u64,
245 content_type: Option<String>,
246 },
247
248 UploadError {
250 upload_id: String,
251 message: String,
252 code: Option<String>,
253 },
254
255 Error {
257 message: String,
258 code: Option<String>,
259 },
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct DynamicSlotPatch {
265 pub index: usize,
266 pub html: String,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case")]
272pub enum ResumeStatus {
273 Fresh,
274 Resumed,
275 Fallback,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
280pub struct ChartPoint {
281 pub x: f64,
282 pub y: f64,
283}
284
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287pub struct ChartAnnotation {
288 pub id: String,
289 pub x: f64,
290 pub label: String,
291}
292
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295pub struct Toast {
296 pub id: String,
297 pub level: ToastLevel,
298 pub title: Option<String>,
299 pub message: String,
300 pub ttl_ms: Option<u64>,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "snake_case")]
306pub enum ToastLevel {
307 Info,
308 Success,
309 Warning,
310 Error,
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315pub struct InboxItem {
316 pub id: String,
317 pub title: String,
318 pub body: String,
319 pub read: bool,
320 pub inserted_at: Option<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325pub struct GridState {
326 #[serde(default)]
327 pub columns: Vec<GridColumn>,
328 #[serde(default)]
329 pub rows: Vec<GridRow>,
330 pub total_rows: usize,
331 pub offset: usize,
332 pub limit: usize,
333 #[serde(default)]
334 pub views: Vec<GridSavedView>,
335 pub active_view: Option<String>,
336 pub group_by: Option<String>,
337 pub query: Option<String>,
338 pub sort: Option<GridSort>,
339}
340
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct GridRowsWindow {
344 pub offset: usize,
345 pub total_rows: usize,
346 #[serde(default)]
347 pub rows: Vec<GridRow>,
348}
349
350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352pub struct GridColumn {
353 pub id: String,
354 pub label: String,
355 pub width_px: Option<u16>,
356 pub min_width_px: Option<u16>,
357 #[serde(default)]
358 pub pinned: GridPinned,
359 #[serde(default = "default_true")]
360 pub sortable: bool,
361 #[serde(default = "default_true")]
362 pub resizable: bool,
363 #[serde(default)]
364 pub editable: bool,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
369#[serde(rename_all = "snake_case")]
370pub enum GridPinned {
371 Left,
372 Right,
373 #[default]
374 None,
375}
376
377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379pub struct GridRow {
380 pub id: String,
381 #[serde(default)]
382 pub cells: Map<String, Value>,
383 pub group: Option<String>,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct GridSavedView {
389 pub id: String,
390 pub label: String,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct GridSort {
396 pub column: String,
397 pub direction: GridSortDirection,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum GridSortDirection {
404 Asc,
405 Desc,
406}
407
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
410pub struct JsInteropDispatch {
411 pub target: Option<String>,
412 pub event: String,
413 #[serde(default)]
414 pub detail: Value,
415 #[serde(default = "default_true")]
416 pub bubbles: bool,
417}
418
419fn default_true() -> bool {
420 true
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "snake_case")]
426pub enum StreamPosition {
427 Append,
428 Prepend,
429}
430
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
433#[serde(tag = "op", rename_all = "snake_case")]
434pub enum StreamBatchOperation {
435 Insert {
436 id: String,
437 html: String,
438 at: StreamPosition,
439 },
440 Delete {
441 id: String,
442 },
443}
444
445#[cfg(test)]
446mod tests {
447 use super::{
448 ChartAnnotation, ChartPoint, ClientMessage, DynamicSlotPatch, GridColumn, GridPinned,
449 GridRow, GridRowsWindow, GridSavedView, GridSort, GridSortDirection, GridState, InboxItem,
450 JsInteropDispatch, ServerMessage, StreamBatchOperation, StreamPosition, Toast, ToastLevel,
451 };
452 use crate::Event;
453 use serde_json::{json, Map};
454
455 #[test]
456 fn decodes_client_event() {
457 let raw = r#"{
458 "type": "event",
459 "event": "inc",
460 "target": "counter",
461 "value": {"step": 1},
462 "metadata": {"tag": "BUTTON"}
463 }"#;
464
465 let message: ClientMessage = serde_json::from_str(raw).unwrap();
466 let event = Event::try_from(message).unwrap();
467
468 assert_eq!(event.name, "inc");
469 assert_eq!(event.target.as_deref(), Some("counter"));
470 assert_eq!(event.value, json!({"step": 1}));
471 assert_eq!(event.metadata.get("tag"), Some(&json!("BUTTON")));
472 }
473
474 #[test]
475 fn decodes_protocol_connect() {
476 let message: ClientMessage =
477 serde_json::from_value(json!({"type": "connect", "protocol": "shelly/1"})).unwrap();
478
479 assert_eq!(
480 message,
481 ClientMessage::Connect {
482 protocol: "shelly/1".to_string(),
483 session_id: None,
484 last_revision: None,
485 resume_token: None,
486 tenant_id: None,
487 trace_id: None,
488 span_id: None,
489 parent_span_id: None,
490 correlation_id: None,
491 request_id: None,
492 }
493 );
494 }
495
496 #[test]
497 fn decodes_protocol_connect_resume_hints() {
498 let message: ClientMessage = serde_json::from_value(json!({
499 "type": "connect",
500 "protocol": "shelly/1",
501 "session_id": "sid-1",
502 "last_revision": 7
503 }))
504 .unwrap();
505
506 assert_eq!(
507 message,
508 ClientMessage::Connect {
509 protocol: "shelly/1".to_string(),
510 session_id: Some("sid-1".to_string()),
511 last_revision: Some(7),
512 resume_token: None,
513 tenant_id: None,
514 trace_id: None,
515 span_id: None,
516 parent_span_id: None,
517 correlation_id: None,
518 request_id: None,
519 }
520 );
521 }
522
523 #[test]
524 fn decodes_protocol_connect_resume_token() {
525 let message: ClientMessage = serde_json::from_value(json!({
526 "type": "connect",
527 "protocol": "shelly/1",
528 "session_id": "sid-1",
529 "last_revision": 7,
530 "resume_token": "resume-token-1"
531 }))
532 .unwrap();
533
534 assert_eq!(
535 message,
536 ClientMessage::Connect {
537 protocol: "shelly/1".to_string(),
538 session_id: Some("sid-1".to_string()),
539 last_revision: Some(7),
540 resume_token: Some("resume-token-1".to_string()),
541 tenant_id: None,
542 trace_id: None,
543 span_id: None,
544 parent_span_id: None,
545 correlation_id: None,
546 request_id: None,
547 }
548 );
549 }
550
551 #[test]
552 fn decodes_protocol_connect_correlation_fields() {
553 let message: ClientMessage = serde_json::from_value(json!({
554 "type": "connect",
555 "protocol": "shelly/1",
556 "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
557 "span_id": "00f067aa0ba902b7",
558 "parent_span_id": "89abcdef01234567",
559 "correlation_id": "corr-123",
560 "request_id": "req-123"
561 }))
562 .unwrap();
563
564 assert_eq!(
565 message,
566 ClientMessage::Connect {
567 protocol: "shelly/1".to_string(),
568 session_id: None,
569 last_revision: None,
570 resume_token: None,
571 tenant_id: None,
572 trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
573 span_id: Some("00f067aa0ba902b7".to_string()),
574 parent_span_id: Some("89abcdef01234567".to_string()),
575 correlation_id: Some("corr-123".to_string()),
576 request_id: Some("req-123".to_string()),
577 }
578 );
579 }
580
581 #[test]
582 fn decodes_protocol_connect_tenant_context() {
583 let message: ClientMessage = serde_json::from_value(json!({
584 "type": "connect",
585 "protocol": "shelly/1",
586 "tenant_id": "tenant-a"
587 }))
588 .unwrap();
589
590 assert_eq!(
591 message,
592 ClientMessage::Connect {
593 protocol: "shelly/1".to_string(),
594 session_id: None,
595 last_revision: None,
596 resume_token: None,
597 tenant_id: Some("tenant-a".to_string()),
598 trace_id: None,
599 span_id: None,
600 parent_span_id: None,
601 correlation_id: None,
602 request_id: None,
603 }
604 );
605 }
606
607 #[test]
608 fn decodes_navigation_requests() {
609 assert_eq!(
610 serde_json::from_value::<ClientMessage>(json!({
611 "type": "patch_url",
612 "to": "/pages/intro"
613 }))
614 .unwrap(),
615 ClientMessage::PatchUrl {
616 to: "/pages/intro".to_string()
617 }
618 );
619 assert_eq!(
620 serde_json::from_value::<ClientMessage>(json!({
621 "type": "navigate",
622 "to": "/users/1"
623 }))
624 .unwrap(),
625 ClientMessage::Navigate {
626 to: "/users/1".to_string()
627 }
628 );
629 }
630
631 #[test]
632 fn decodes_upload_requests() {
633 assert_eq!(
634 serde_json::from_value::<ClientMessage>(json!({
635 "type": "upload_start",
636 "upload_id": "up-1",
637 "event": "uploaded",
638 "target": "file",
639 "name": "notes.txt",
640 "size": 12,
641 "content_type": "text/plain"
642 }))
643 .unwrap(),
644 ClientMessage::UploadStart {
645 upload_id: "up-1".to_string(),
646 event: "uploaded".to_string(),
647 target: Some("file".to_string()),
648 name: "notes.txt".to_string(),
649 size: 12,
650 content_type: Some("text/plain".to_string()),
651 }
652 );
653 assert_eq!(
654 serde_json::from_value::<ClientMessage>(json!({
655 "type": "upload_chunk",
656 "upload_id": "up-1",
657 "offset": 0,
658 "data": "aGVsbG8="
659 }))
660 .unwrap(),
661 ClientMessage::UploadChunk {
662 upload_id: "up-1".to_string(),
663 offset: 0,
664 data: "aGVsbG8=".to_string(),
665 }
666 );
667 assert_eq!(
668 serde_json::from_value::<ClientMessage>(json!({
669 "type": "upload_complete",
670 "upload_id": "up-1"
671 }))
672 .unwrap(),
673 ClientMessage::UploadComplete {
674 upload_id: "up-1".to_string()
675 }
676 );
677 }
678
679 #[test]
680 fn encodes_server_patch() {
681 let message = ServerMessage::Patch {
682 target: "shelly-root".to_string(),
683 html: "<p>ok</p>".to_string(),
684 revision: 2,
685 };
686
687 let encoded = serde_json::to_value(message).unwrap();
688 assert_eq!(encoded["type"], "patch");
689 assert_eq!(encoded["target"], "shelly-root");
690 assert_eq!(encoded["revision"], 2);
691 }
692
693 #[test]
694 fn encodes_server_diff() {
695 let message = ServerMessage::Diff {
696 target: "shelly-root".to_string(),
697 revision: 3,
698 slots: vec![DynamicSlotPatch {
699 index: 0,
700 html: "2".to_string(),
701 }],
702 };
703
704 let encoded = serde_json::to_value(message).unwrap();
705 assert_eq!(
706 encoded,
707 json!({
708 "type": "diff",
709 "target": "shelly-root",
710 "revision": 3,
711 "slots": [{"index": 0, "html": "2"}]
712 })
713 );
714 }
715
716 #[test]
717 fn encodes_stream_messages() {
718 assert_eq!(
719 serde_json::to_value(ServerMessage::StreamInsert {
720 target: "messages".to_string(),
721 id: "msg-1".to_string(),
722 html: "<li id=\"msg-1\">hi</li>".to_string(),
723 at: StreamPosition::Append,
724 })
725 .unwrap(),
726 json!({
727 "type": "stream_insert",
728 "target": "messages",
729 "id": "msg-1",
730 "html": "<li id=\"msg-1\">hi</li>",
731 "at": "append"
732 })
733 );
734 assert_eq!(
735 serde_json::to_value(ServerMessage::StreamDelete {
736 target: "messages".to_string(),
737 id: "msg-1".to_string(),
738 })
739 .unwrap(),
740 json!({
741 "type": "stream_delete",
742 "target": "messages",
743 "id": "msg-1"
744 })
745 );
746 assert_eq!(
747 serde_json::to_value(ServerMessage::StreamBatch {
748 target: "messages".to_string(),
749 operations: vec![
750 StreamBatchOperation::Insert {
751 id: "msg-2".to_string(),
752 html: "<li id=\"msg-2\">batch</li>".to_string(),
753 at: StreamPosition::Append,
754 },
755 StreamBatchOperation::Delete {
756 id: "msg-1".to_string(),
757 },
758 ],
759 })
760 .unwrap(),
761 json!({
762 "type": "stream_batch",
763 "target": "messages",
764 "operations": [
765 {
766 "op": "insert",
767 "id": "msg-2",
768 "html": "<li id=\"msg-2\">batch</li>",
769 "at": "append"
770 },
771 {
772 "op": "delete",
773 "id": "msg-1"
774 }
775 ]
776 })
777 );
778 }
779
780 #[test]
781 fn encodes_chart_messages() {
782 assert_eq!(
783 serde_json::to_value(ServerMessage::ChartSeriesAppend {
784 chart: "traffic-chart".to_string(),
785 series: "requests".to_string(),
786 point: ChartPoint { x: 4.0, y: 11.5 },
787 })
788 .unwrap(),
789 json!({
790 "type": "chart_series_append",
791 "chart": "traffic-chart",
792 "series": "requests",
793 "point": {
794 "x": 4.0,
795 "y": 11.5
796 }
797 })
798 );
799
800 assert_eq!(
801 serde_json::to_value(ServerMessage::ChartSeriesReplace {
802 chart: "traffic-chart".to_string(),
803 series: "requests".to_string(),
804 points: vec![
805 ChartPoint { x: 1.0, y: 10.0 },
806 ChartPoint { x: 2.0, y: 11.5 }
807 ],
808 })
809 .unwrap(),
810 json!({
811 "type": "chart_series_replace",
812 "chart": "traffic-chart",
813 "series": "requests",
814 "points": [
815 {"x": 1.0, "y": 10.0},
816 {"x": 2.0, "y": 11.5}
817 ]
818 })
819 );
820 assert_eq!(
821 serde_json::to_value(ServerMessage::ChartSeriesAppendMany {
822 chart: "traffic-chart".to_string(),
823 series: "requests".to_string(),
824 points: vec![
825 ChartPoint { x: 3.0, y: 10.75 },
826 ChartPoint { x: 4.0, y: 11.5 }
827 ],
828 })
829 .unwrap(),
830 json!({
831 "type": "chart_series_append_many",
832 "chart": "traffic-chart",
833 "series": "requests",
834 "points": [
835 {"x": 3.0, "y": 10.75},
836 {"x": 4.0, "y": 11.5}
837 ]
838 })
839 );
840 assert_eq!(
841 serde_json::to_value(ServerMessage::ChartReset {
842 chart: "traffic-chart".to_string(),
843 })
844 .unwrap(),
845 json!({
846 "type": "chart_reset",
847 "chart": "traffic-chart"
848 })
849 );
850
851 assert_eq!(
852 serde_json::to_value(ServerMessage::ChartAnnotationUpsert {
853 chart: "traffic-chart".to_string(),
854 annotation: ChartAnnotation {
855 id: "release-1".to_string(),
856 x: 18.0,
857 label: "Deploy".to_string(),
858 },
859 })
860 .unwrap(),
861 json!({
862 "type": "chart_annotation_upsert",
863 "chart": "traffic-chart",
864 "annotation": {
865 "id": "release-1",
866 "x": 18.0,
867 "label": "Deploy"
868 }
869 })
870 );
871 assert_eq!(
872 serde_json::to_value(ServerMessage::ChartAnnotationDelete {
873 chart: "traffic-chart".to_string(),
874 id: "release-1".to_string(),
875 })
876 .unwrap(),
877 json!({
878 "type": "chart_annotation_delete",
879 "chart": "traffic-chart",
880 "id": "release-1"
881 })
882 );
883 }
884
885 #[test]
886 fn encodes_toast_and_inbox_messages() {
887 assert_eq!(
888 serde_json::to_value(ServerMessage::ToastPush {
889 toast: Toast {
890 id: "toast-1".to_string(),
891 level: ToastLevel::Success,
892 title: Some("Saved".to_string()),
893 message: "Profile updated".to_string(),
894 ttl_ms: Some(2500),
895 },
896 })
897 .unwrap(),
898 json!({
899 "type": "toast_push",
900 "toast": {
901 "id": "toast-1",
902 "level": "success",
903 "title": "Saved",
904 "message": "Profile updated",
905 "ttl_ms": 2500
906 }
907 })
908 );
909 assert_eq!(
910 serde_json::to_value(ServerMessage::ToastDismiss {
911 id: "toast-1".to_string(),
912 })
913 .unwrap(),
914 json!({
915 "type": "toast_dismiss",
916 "id": "toast-1"
917 })
918 );
919 assert_eq!(
920 serde_json::to_value(ServerMessage::InboxUpsert {
921 item: InboxItem {
922 id: "msg-1".to_string(),
923 title: "Welcome".to_string(),
924 body: "Thanks for joining".to_string(),
925 read: false,
926 inserted_at: Some("2026-05-05T12:00:00Z".to_string()),
927 },
928 })
929 .unwrap(),
930 json!({
931 "type": "inbox_upsert",
932 "item": {
933 "id": "msg-1",
934 "title": "Welcome",
935 "body": "Thanks for joining",
936 "read": false,
937 "inserted_at": "2026-05-05T12:00:00Z"
938 }
939 })
940 );
941 assert_eq!(
942 serde_json::to_value(ServerMessage::InboxDelete {
943 id: "msg-1".to_string(),
944 })
945 .unwrap(),
946 json!({
947 "type": "inbox_delete",
948 "id": "msg-1"
949 })
950 );
951
952 assert_eq!(
953 serde_json::to_value(ServerMessage::GridReplace {
954 grid: "enterprise-grid".to_string(),
955 state: GridState {
956 columns: vec![
957 GridColumn {
958 id: "name".to_string(),
959 label: "Name".to_string(),
960 width_px: Some(220),
961 min_width_px: Some(120),
962 pinned: GridPinned::Left,
963 sortable: true,
964 resizable: true,
965 editable: false,
966 },
967 GridColumn {
968 id: "arr".to_string(),
969 label: "ARR".to_string(),
970 width_px: Some(120),
971 min_width_px: Some(80),
972 pinned: GridPinned::None,
973 sortable: true,
974 resizable: true,
975 editable: true,
976 },
977 ],
978 rows: vec![GridRow {
979 id: "acct-1".to_string(),
980 cells: Map::from_iter([
981 ("name".to_string(), json!("Acme")),
982 ("arr".to_string(), json!(125000)),
983 ]),
984 group: Some("Enterprise".to_string()),
985 }],
986 total_rows: 1000,
987 offset: 0,
988 limit: 100,
989 views: vec![GridSavedView {
990 id: "ops".to_string(),
991 label: "Ops".to_string(),
992 }],
993 active_view: Some("ops".to_string()),
994 group_by: Some("segment".to_string()),
995 query: Some("acme".to_string()),
996 sort: Some(GridSort {
997 column: "arr".to_string(),
998 direction: GridSortDirection::Desc,
999 }),
1000 },
1001 })
1002 .unwrap(),
1003 json!({
1004 "type": "grid_replace",
1005 "grid": "enterprise-grid",
1006 "state": {
1007 "columns": [
1008 {
1009 "id": "name",
1010 "label": "Name",
1011 "width_px": 220,
1012 "min_width_px": 120,
1013 "pinned": "left",
1014 "sortable": true,
1015 "resizable": true,
1016 "editable": false
1017 },
1018 {
1019 "id": "arr",
1020 "label": "ARR",
1021 "width_px": 120,
1022 "min_width_px": 80,
1023 "pinned": "none",
1024 "sortable": true,
1025 "resizable": true,
1026 "editable": true
1027 }
1028 ],
1029 "rows": [
1030 {
1031 "id": "acct-1",
1032 "cells": {
1033 "name": "Acme",
1034 "arr": 125000
1035 },
1036 "group": "Enterprise"
1037 }
1038 ],
1039 "total_rows": 1000,
1040 "offset": 0,
1041 "limit": 100,
1042 "views": [
1043 {
1044 "id": "ops",
1045 "label": "Ops"
1046 }
1047 ],
1048 "active_view": "ops",
1049 "group_by": "segment",
1050 "query": "acme",
1051 "sort": {
1052 "column": "arr",
1053 "direction": "desc"
1054 }
1055 }
1056 })
1057 );
1058 assert_eq!(
1059 serde_json::to_value(ServerMessage::GridRowsReplace {
1060 grid: "enterprise-grid".to_string(),
1061 window: GridRowsWindow {
1062 offset: 400,
1063 total_rows: 1000,
1064 rows: vec![GridRow {
1065 id: "acct-401".to_string(),
1066 cells: Map::from_iter([
1067 ("name".to_string(), json!("Acme North")),
1068 ("arr".to_string(), json!(166000)),
1069 ]),
1070 group: Some("Enterprise".to_string()),
1071 }],
1072 },
1073 })
1074 .unwrap(),
1075 json!({
1076 "type": "grid_rows_replace",
1077 "grid": "enterprise-grid",
1078 "window": {
1079 "offset": 400,
1080 "total_rows": 1000,
1081 "rows": [
1082 {
1083 "id": "acct-401",
1084 "cells": {
1085 "name": "Acme North",
1086 "arr": 166000
1087 },
1088 "group": "Enterprise"
1089 }
1090 ]
1091 }
1092 })
1093 );
1094
1095 assert_eq!(
1096 serde_json::to_value(ServerMessage::InteropDispatch {
1097 dispatch: JsInteropDispatch {
1098 target: Some("peer-a".to_string()),
1099 event: "shelly:webrtc-signal".to_string(),
1100 detail: json!({"kind": "offer", "sdp": "v=0..."}),
1101 bubbles: true,
1102 },
1103 })
1104 .unwrap(),
1105 json!({
1106 "type": "interop_dispatch",
1107 "dispatch": {
1108 "target": "peer-a",
1109 "event": "shelly:webrtc-signal",
1110 "detail": {"kind": "offer", "sdp": "v=0..."},
1111 "bubbles": true
1112 }
1113 })
1114 );
1115 }
1116
1117 #[test]
1118 fn encodes_navigation_messages() {
1119 assert_eq!(
1120 serde_json::to_value(ServerMessage::PatchUrl {
1121 to: "/pages/intro".to_string(),
1122 })
1123 .unwrap(),
1124 json!({"type": "patch_url", "to": "/pages/intro"})
1125 );
1126 assert_eq!(
1127 serde_json::to_value(ServerMessage::Navigate {
1128 to: "/users/1".to_string(),
1129 })
1130 .unwrap(),
1131 json!({"type": "navigate", "to": "/users/1"})
1132 );
1133 }
1134
1135 #[test]
1136 fn encodes_upload_status_messages() {
1137 assert_eq!(
1138 serde_json::to_value(ServerMessage::UploadProgress {
1139 upload_id: "up-1".to_string(),
1140 received: 5,
1141 total: 10,
1142 })
1143 .unwrap(),
1144 json!({
1145 "type": "upload_progress",
1146 "upload_id": "up-1",
1147 "received": 5,
1148 "total": 10
1149 })
1150 );
1151 assert_eq!(
1152 serde_json::to_value(ServerMessage::UploadError {
1153 upload_id: "up-1".to_string(),
1154 message: "too large".to_string(),
1155 code: Some("upload_too_large".to_string()),
1156 })
1157 .unwrap(),
1158 json!({
1159 "type": "upload_error",
1160 "upload_id": "up-1",
1161 "message": "too large",
1162 "code": "upload_too_large"
1163 })
1164 );
1165 }
1166
1167 #[test]
1168 fn rejects_unknown_client_message_type() {
1169 let decoded = serde_json::from_value::<ClientMessage>(json!({
1170 "type": "do_the_thing",
1171 "payload": {}
1172 }));
1173 assert!(decoded.is_err());
1174 }
1175
1176 #[test]
1177 fn rejects_event_without_event_name() {
1178 let decoded = serde_json::from_value::<ClientMessage>(json!({
1179 "type": "event",
1180 "target": "counter",
1181 "value": {"step": 1}
1182 }));
1183 assert!(decoded.is_err());
1184 }
1185
1186 #[test]
1187 fn rejects_upload_chunk_without_required_fields() {
1188 let missing_data = serde_json::from_value::<ClientMessage>(json!({
1189 "type": "upload_chunk",
1190 "upload_id": "up-1",
1191 "offset": 0
1192 }));
1193 assert!(missing_data.is_err());
1194
1195 let missing_offset = serde_json::from_value::<ClientMessage>(json!({
1196 "type": "upload_chunk",
1197 "upload_id": "up-1",
1198 "data": "aGVsbG8="
1199 }));
1200 assert!(missing_offset.is_err());
1201 }
1202}