Skip to main content

shelly/
protocol.rs

1use crate::{Event, ShellyError};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4
5pub const PROTOCOL_VERSION_V1: &str = "shelly/1";
6
7/// Message received from the browser runtime.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(tag = "type", rename_all = "snake_case")]
10pub enum ClientMessage {
11    /// Initial protocol negotiation from the browser runtime.
12    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    /// Browser event captured by the Shelly JavaScript client.
35    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    /// Health check from the browser.
45    Ping { nonce: Option<String> },
46
47    /// Request an internal URL patch on the current LiveView route.
48    PatchUrl { to: String },
49
50    /// Request an internal navigation to another LiveView route.
51    Navigate { to: String },
52
53    /// Negotiate a new upload before sending chunks.
54    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    /// Send one base64-encoded upload chunk.
64    UploadChunk {
65        upload_id: String,
66        offset: u64,
67        data: String,
68    },
69
70    /// Finish an upload after all chunks have been sent.
71    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/// Message sent from the Rust runtime to the browser runtime.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113#[serde(tag = "type", rename_all = "snake_case")]
114pub enum ServerMessage {
115    /// Sent when the WebSocket is established.
116    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    /// Replace or morph a target node's inner content.
134    Patch {
135        target: String,
136        html: String,
137        revision: u64,
138    },
139
140    /// Replace changed dynamic slots within a previously patched template.
141    Diff {
142        target: String,
143        revision: u64,
144        slots: Vec<DynamicSlotPatch>,
145    },
146
147    /// Insert one rendered item into a browser stream.
148    StreamInsert {
149        target: String,
150        id: String,
151        html: String,
152        at: StreamPosition,
153    },
154
155    /// Delete one rendered item from a browser stream.
156    StreamDelete { target: String, id: String },
157
158    /// Apply many stream operations in one message.
159    StreamBatch {
160        target: String,
161        operations: Vec<StreamBatchOperation>,
162    },
163
164    /// Append one point into a browser-managed chart series.
165    ChartSeriesAppend {
166        chart: String,
167        series: String,
168        point: ChartPoint,
169    },
170
171    /// Append many points into one browser-managed chart series.
172    ChartSeriesAppendMany {
173        chart: String,
174        series: String,
175        points: Vec<ChartPoint>,
176    },
177
178    /// Replace one chart series with a full point set.
179    ChartSeriesReplace {
180        chart: String,
181        series: String,
182        points: Vec<ChartPoint>,
183    },
184
185    /// Clear all chart series data for one chart.
186    ChartReset { chart: String },
187
188    /// Insert or replace one chart annotation.
189    ChartAnnotationUpsert {
190        chart: String,
191        annotation: ChartAnnotation,
192    },
193
194    /// Delete one chart annotation.
195    ChartAnnotationDelete { chart: String, id: String },
196
197    /// Push one toast notification.
198    ToastPush { toast: Toast },
199
200    /// Dismiss one toast notification by id.
201    ToastDismiss { id: String },
202
203    /// Insert or replace one inbox item.
204    InboxUpsert { item: InboxItem },
205
206    /// Delete one inbox item.
207    InboxDelete { id: String },
208
209    /// Replace one enterprise grid state snapshot.
210    GridReplace { grid: String, state: GridState },
211
212    /// Replace only the active row window of one enterprise grid.
213    GridRowsReplace {
214        grid: String,
215        window: GridRowsWindow,
216    },
217
218    /// Dispatch one browser-side interop event.
219    InteropDispatch { dispatch: JsInteropDispatch },
220
221    /// Reply to a ping.
222    Pong { nonce: Option<String> },
223
224    /// Navigate the browser to another URL.
225    Redirect { to: String },
226
227    /// Patch the browser URL using history.pushState without replacing the view.
228    PatchUrl { to: String },
229
230    /// Navigate to another internal LiveView route using history.pushState.
231    Navigate { to: String },
232
233    /// Report upload transfer progress.
234    UploadProgress {
235        upload_id: String,
236        received: u64,
237        total: u64,
238    },
239
240    /// Report a completed upload.
241    UploadComplete {
242        upload_id: String,
243        name: String,
244        size: u64,
245        content_type: Option<String>,
246    },
247
248    /// Report an upload-specific validation or transport error.
249    UploadError {
250        upload_id: String,
251        message: String,
252        code: Option<String>,
253    },
254
255    /// Report a recoverable server-side error.
256    Error {
257        message: String,
258        code: Option<String>,
259    },
260}
261
262/// Dynamic slot update sent by a `diff` server message.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct DynamicSlotPatch {
265    pub index: usize,
266    pub html: String,
267}
268
269/// Resume handshake outcome communicated in `hello`.
270#[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/// One numeric chart point.
279#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
280pub struct ChartPoint {
281    pub x: f64,
282    pub y: f64,
283}
284
285/// One chart annotation marker.
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287pub struct ChartAnnotation {
288    pub id: String,
289    pub x: f64,
290    pub label: String,
291}
292
293/// One toast message shown by the browser runtime.
294#[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/// Visual severity for a toast.
304#[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/// One inbox item shown by the browser runtime.
314#[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/// Full browser-managed data-grid state.
324#[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/// Active window rows for one grid snapshot.
342#[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/// One grid column descriptor.
351#[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/// Grid column pin side.
368#[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/// One grid row record.
378#[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/// One saved grid view.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct GridSavedView {
389    pub id: String,
390    pub label: String,
391}
392
393/// Active grid sort definition.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct GridSort {
396    pub column: String,
397    pub direction: GridSortDirection,
398}
399
400/// Grid sort direction.
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum GridSortDirection {
404    Asc,
405    Desc,
406}
407
408/// One browser interop dispatch instruction.
409#[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/// Position used by a `stream_insert` server message.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "snake_case")]
426pub enum StreamPosition {
427    Append,
428    Prepend,
429}
430
431/// One stream operation used by `stream_batch`.
432#[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}