Skip to main content

shelly/
context.rs

1use crate::{
2    ChartAnnotation, ChartPoint, GridRow, GridRowsWindow, GridState, InboxItem, JsInteropDispatch,
3    PubSubCommand, RuntimeCommand, RuntimeEvent, ServerMessage, StreamBatchOperation,
4    StreamPosition, Toast,
5};
6use std::collections::BTreeMap;
7use std::fmt;
8use uuid::Uuid;
9
10/// Opaque LiveView session identifier.
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct SessionId(String);
13
14impl SessionId {
15    /// Generate a new random session id.
16    pub fn new() -> Self {
17        Self(Uuid::new_v4().to_string())
18    }
19
20    pub(crate) fn from_string(value: String) -> Self {
21        Self(value)
22    }
23
24    /// Borrow the id as a string slice.
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28}
29
30impl Default for SessionId {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl fmt::Display for SessionId {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(&self.0)
39    }
40}
41
42/// Mutable context passed into LiveView lifecycle callbacks.
43#[derive(Debug, Clone)]
44pub struct Context {
45    session_id: SessionId,
46    target_dom_id: String,
47    route_path: String,
48    route_params: BTreeMap<String, String>,
49    tenant_id: Option<String>,
50    connected: bool,
51    render_after_event: bool,
52    render_cadence_override_ms: Option<u64>,
53    pushes: Vec<ServerMessage>,
54    pubsub: Vec<PubSubCommand>,
55    runtime: Vec<RuntimeCommand>,
56}
57
58impl Context {
59    /// Create context for a new LiveView session.
60    pub fn new(target_dom_id: impl Into<String>) -> Self {
61        Self::new_with_session_id(SessionId::new(), target_dom_id)
62    }
63
64    pub(crate) fn new_with_session_id(
65        session_id: SessionId,
66        target_dom_id: impl Into<String>,
67    ) -> Self {
68        Self {
69            session_id,
70            target_dom_id: target_dom_id.into(),
71            route_path: "/".to_string(),
72            route_params: BTreeMap::new(),
73            tenant_id: None,
74            connected: false,
75            render_after_event: true,
76            render_cadence_override_ms: None,
77            pushes: Vec::new(),
78            pubsub: Vec::new(),
79            runtime: Vec::new(),
80        }
81    }
82
83    /// The current session id.
84    pub fn session_id(&self) -> &SessionId {
85        &self.session_id
86    }
87
88    /// The DOM id targeted by patches.
89    pub fn target_dom_id(&self) -> &str {
90        &self.target_dom_id
91    }
92
93    /// Current request path for this LiveView session.
94    pub fn route_path(&self) -> &str {
95        &self.route_path
96    }
97
98    /// Current route params matched by the transport adapter.
99    pub fn route_params(&self) -> &BTreeMap<String, String> {
100        &self.route_params
101    }
102
103    /// Borrow one current route param.
104    pub fn route_param(&self, key: &str) -> Option<&str> {
105        self.route_params.get(key).map(String::as_str)
106    }
107
108    /// Tenant context associated with this session.
109    pub fn tenant_id(&self) -> Option<&str> {
110        self.tenant_id.as_deref()
111    }
112
113    /// Set tenant context associated with this session.
114    pub fn set_tenant_id(&mut self, tenant_id: impl Into<String>) {
115        self.tenant_id = normalize_tenant_id(Some(tenant_id.into()));
116    }
117
118    /// Replace tenant context associated with this session.
119    pub fn set_tenant_id_optional(&mut self, tenant_id: Option<String>) {
120        self.tenant_id = normalize_tenant_id(tenant_id);
121    }
122
123    /// Clear tenant context associated with this session.
124    pub fn clear_tenant_id(&mut self) {
125        self.tenant_id = None;
126    }
127
128    pub(crate) fn set_route(&mut self, path: impl Into<String>, params: BTreeMap<String, String>) {
129        self.route_path = path.into();
130        self.route_params = params;
131    }
132
133    /// Whether this context is currently attached to a WebSocket.
134    pub fn is_connected(&self) -> bool {
135        self.connected
136    }
137
138    /// Mark this context as connected or disconnected.
139    pub fn set_connected(&mut self, connected: bool) {
140        self.connected = connected;
141    }
142
143    /// Queue an imperative server message.
144    pub fn push_message(&mut self, message: ServerMessage) {
145        self.pushes.push(message);
146    }
147
148    /// Subscribe this session to an in-process PubSub topic.
149    pub fn subscribe(&mut self, topic: impl Into<String>) {
150        self.pubsub.push(PubSubCommand::Subscribe {
151            topic: topic.into(),
152        });
153    }
154
155    /// Broadcast server messages to all subscribers on a topic.
156    pub fn broadcast(&mut self, topic: impl Into<String>, messages: Vec<ServerMessage>) {
157        self.skip_render();
158        self.pubsub.push(PubSubCommand::Broadcast {
159            topic: topic.into(),
160            messages,
161        });
162    }
163
164    /// Broadcast one server message to all subscribers on a topic.
165    pub fn broadcast_message(&mut self, topic: impl Into<String>, message: ServerMessage) {
166        self.broadcast(topic, vec![message]);
167    }
168
169    /// Schedule one synthetic event to be dispatched after `delay_ms`.
170    pub fn schedule_once(
171        &mut self,
172        id: impl Into<String>,
173        delay_ms: u64,
174        event: impl Into<String>,
175        value: impl Into<serde_json::Value>,
176    ) {
177        self.skip_render();
178        self.runtime.push(RuntimeCommand::ScheduleOnce {
179            id: id.into(),
180            delay_ms,
181            dispatch: RuntimeEvent::new(event, value),
182        });
183    }
184
185    /// Schedule one synthetic scoped event to be dispatched after `delay_ms`.
186    pub fn schedule_once_to(
187        &mut self,
188        id: impl Into<String>,
189        delay_ms: u64,
190        target: impl Into<String>,
191        event: impl Into<String>,
192        value: impl Into<serde_json::Value>,
193    ) {
194        self.skip_render();
195        self.runtime.push(RuntimeCommand::ScheduleOnce {
196            id: id.into(),
197            delay_ms,
198            dispatch: RuntimeEvent::with_target(target, event, value),
199        });
200    }
201
202    /// Schedule a recurring synthetic event every `every_ms`.
203    pub fn schedule_interval(
204        &mut self,
205        id: impl Into<String>,
206        every_ms: u64,
207        event: impl Into<String>,
208        value: impl Into<serde_json::Value>,
209    ) {
210        self.skip_render();
211        self.runtime.push(RuntimeCommand::ScheduleInterval {
212            id: id.into(),
213            every_ms,
214            dispatch: RuntimeEvent::new(event, value),
215        });
216    }
217
218    /// Schedule a recurring scoped synthetic event every `every_ms`.
219    pub fn schedule_interval_to(
220        &mut self,
221        id: impl Into<String>,
222        every_ms: u64,
223        target: impl Into<String>,
224        event: impl Into<String>,
225        value: impl Into<serde_json::Value>,
226    ) {
227        self.skip_render();
228        self.runtime.push(RuntimeCommand::ScheduleInterval {
229            id: id.into(),
230            every_ms,
231            dispatch: RuntimeEvent::with_target(target, event, value),
232        });
233    }
234
235    /// Cancel a previously scheduled runtime command by id.
236    pub fn cancel_schedule(&mut self, id: impl Into<String>) {
237        self.skip_render();
238        self.runtime.push(RuntimeCommand::Cancel { id: id.into() });
239    }
240
241    /// Request a root render after the current event.
242    pub fn request_render(&mut self) {
243        self.render_after_event = true;
244    }
245
246    /// Override root render cadence for this session in milliseconds.
247    ///
248    /// `0` keeps immediate render behavior.
249    pub fn set_render_cadence_ms(&mut self, cadence_ms: u64) {
250        self.render_cadence_override_ms = Some(cadence_ms);
251    }
252
253    /// Clear any session-level render cadence override.
254    pub fn clear_render_cadence_override(&mut self) {
255        self.render_cadence_override_ms = None;
256    }
257
258    /// Suppress the automatic root render after the current event.
259    pub fn skip_render(&mut self) {
260        self.render_after_event = false;
261    }
262
263    /// Queue a browser redirect.
264    pub fn redirect(&mut self, to: impl Into<String>) {
265        self.push_message(ServerMessage::Redirect { to: to.into() });
266    }
267
268    /// Queue a URL patch without replacing the current LiveView.
269    pub fn patch_url(&mut self, to: impl Into<String>) {
270        self.push_message(ServerMessage::PatchUrl { to: to.into() });
271    }
272
273    /// Queue an internal LiveView navigation.
274    pub fn navigate(&mut self, to: impl Into<String>) {
275        self.push_message(ServerMessage::Navigate { to: to.into() });
276    }
277
278    /// Queue insertion of one item into a browser stream.
279    pub fn stream_insert(
280        &mut self,
281        target: impl Into<String>,
282        id: impl Into<String>,
283        html: impl Into<String>,
284        at: StreamPosition,
285    ) {
286        self.skip_render();
287        self.push_message(ServerMessage::StreamInsert {
288            target: target.into(),
289            id: id.into(),
290            html: html.into(),
291            at,
292        });
293    }
294
295    /// Queue append of one item into a browser stream.
296    pub fn stream_append(
297        &mut self,
298        target: impl Into<String>,
299        id: impl Into<String>,
300        html: impl Into<String>,
301    ) {
302        self.stream_insert(target, id, html, StreamPosition::Append);
303    }
304
305    /// Queue prepend of one item into a browser stream.
306    pub fn stream_prepend(
307        &mut self,
308        target: impl Into<String>,
309        id: impl Into<String>,
310        html: impl Into<String>,
311    ) {
312        self.stream_insert(target, id, html, StreamPosition::Prepend);
313    }
314
315    /// Queue deletion of one item from a browser stream.
316    pub fn stream_delete(&mut self, target: impl Into<String>, id: impl Into<String>) {
317        self.skip_render();
318        self.push_message(ServerMessage::StreamDelete {
319            target: target.into(),
320            id: id.into(),
321        });
322    }
323
324    /// Queue many stream operations as one protocol message.
325    pub fn stream_batch(
326        &mut self,
327        target: impl Into<String>,
328        operations: Vec<StreamBatchOperation>,
329    ) {
330        if operations.is_empty() {
331            return;
332        }
333        self.skip_render();
334        self.push_message(ServerMessage::StreamBatch {
335            target: target.into(),
336            operations,
337        });
338    }
339
340    /// Queue append of many items into a browser stream.
341    pub fn stream_append_many(&mut self, target: impl Into<String>, items: Vec<(String, String)>) {
342        if items.is_empty() {
343            return;
344        }
345        self.stream_batch(
346            target,
347            items
348                .into_iter()
349                .map(|(id, html)| StreamBatchOperation::Insert {
350                    id,
351                    html,
352                    at: StreamPosition::Append,
353                })
354                .collect(),
355        );
356    }
357
358    /// Queue prepend of many items into a browser stream.
359    pub fn stream_prepend_many(&mut self, target: impl Into<String>, items: Vec<(String, String)>) {
360        if items.is_empty() {
361            return;
362        }
363        self.stream_batch(
364            target,
365            items
366                .into_iter()
367                .map(|(id, html)| StreamBatchOperation::Insert {
368                    id,
369                    html,
370                    at: StreamPosition::Prepend,
371                })
372                .collect(),
373        );
374    }
375
376    /// Queue append of one point into a browser-managed chart series.
377    pub fn chart_series_append(
378        &mut self,
379        chart: impl Into<String>,
380        series: impl Into<String>,
381        point: ChartPoint,
382    ) {
383        self.skip_render();
384        self.push_message(ServerMessage::ChartSeriesAppend {
385            chart: chart.into(),
386            series: series.into(),
387            point,
388        });
389    }
390
391    /// Queue append of many points into one browser-managed chart series.
392    pub fn chart_series_append_many(
393        &mut self,
394        chart: impl Into<String>,
395        series: impl Into<String>,
396        points: Vec<ChartPoint>,
397    ) {
398        if points.is_empty() {
399            return;
400        }
401        self.skip_render();
402        self.push_message(ServerMessage::ChartSeriesAppendMany {
403            chart: chart.into(),
404            series: series.into(),
405            points,
406        });
407    }
408
409    /// Queue replacement of one chart series with a full point set.
410    pub fn chart_series_replace(
411        &mut self,
412        chart: impl Into<String>,
413        series: impl Into<String>,
414        points: Vec<ChartPoint>,
415    ) {
416        self.skip_render();
417        self.push_message(ServerMessage::ChartSeriesReplace {
418            chart: chart.into(),
419            series: series.into(),
420            points,
421        });
422    }
423
424    /// Queue clearing of all series data for one chart.
425    pub fn chart_reset(&mut self, chart: impl Into<String>) {
426        self.skip_render();
427        self.push_message(ServerMessage::ChartReset {
428            chart: chart.into(),
429        });
430    }
431
432    /// Queue insert or replacement of one chart annotation.
433    pub fn chart_annotation_upsert(
434        &mut self,
435        chart: impl Into<String>,
436        annotation: ChartAnnotation,
437    ) {
438        self.skip_render();
439        self.push_message(ServerMessage::ChartAnnotationUpsert {
440            chart: chart.into(),
441            annotation,
442        });
443    }
444
445    /// Queue deletion of one chart annotation.
446    pub fn chart_annotation_delete(&mut self, chart: impl Into<String>, id: impl Into<String>) {
447        self.skip_render();
448        self.push_message(ServerMessage::ChartAnnotationDelete {
449            chart: chart.into(),
450            id: id.into(),
451        });
452    }
453
454    /// Queue one toast notification for browser display.
455    pub fn toast_push(&mut self, toast: Toast) {
456        self.skip_render();
457        self.push_message(ServerMessage::ToastPush { toast });
458    }
459
460    /// Queue dismissal of one toast notification.
461    pub fn toast_dismiss(&mut self, id: impl Into<String>) {
462        self.skip_render();
463        self.push_message(ServerMessage::ToastDismiss { id: id.into() });
464    }
465
466    /// Queue insert or replacement of one inbox item.
467    pub fn inbox_upsert(&mut self, item: InboxItem) {
468        self.skip_render();
469        self.push_message(ServerMessage::InboxUpsert { item });
470    }
471
472    /// Queue deletion of one inbox item.
473    pub fn inbox_delete(&mut self, id: impl Into<String>) {
474        self.skip_render();
475        self.push_message(ServerMessage::InboxDelete { id: id.into() });
476    }
477
478    /// Queue a full enterprise-grid snapshot update.
479    pub fn grid_replace(&mut self, grid: impl Into<String>, state: GridState) {
480        self.skip_render();
481        self.push_message(ServerMessage::GridReplace {
482            grid: grid.into(),
483            state,
484        });
485    }
486
487    /// Queue replacement of one active enterprise-grid row window.
488    pub fn grid_rows_replace(
489        &mut self,
490        grid: impl Into<String>,
491        offset: usize,
492        total_rows: usize,
493        rows: Vec<GridRow>,
494    ) {
495        self.skip_render();
496        self.push_message(ServerMessage::GridRowsReplace {
497            grid: grid.into(),
498            window: GridRowsWindow {
499                offset,
500                total_rows,
501                rows,
502            },
503        });
504    }
505
506    /// Dispatch one browser interop event to `document`.
507    pub fn interop_dispatch(
508        &mut self,
509        event: impl Into<String>,
510        detail: impl Into<serde_json::Value>,
511    ) {
512        self.interop_dispatch_to(None::<String>, event, detail);
513    }
514
515    /// Dispatch one browser interop event to a specific target id.
516    pub fn interop_dispatch_to(
517        &mut self,
518        target: impl Into<Option<String>>,
519        event: impl Into<String>,
520        detail: impl Into<serde_json::Value>,
521    ) {
522        self.skip_render();
523        self.push_message(ServerMessage::InteropDispatch {
524            dispatch: JsInteropDispatch {
525                target: target.into(),
526                event: event.into(),
527                detail: detail.into(),
528                bubbles: true,
529            },
530        });
531    }
532
533    /// Dispatch one WebRTC signaling payload.
534    pub fn webrtc_signal(&mut self, detail: impl Into<serde_json::Value>) {
535        self.interop_dispatch("shelly:webrtc-signal", detail);
536    }
537
538    /// Dispatch one WebRTC signaling payload to a specific target id.
539    pub fn webrtc_signal_to(
540        &mut self,
541        target: impl Into<String>,
542        detail: impl Into<serde_json::Value>,
543    ) {
544        self.interop_dispatch_to(Some(target.into()), "shelly:webrtc-signal", detail);
545    }
546
547    pub(crate) fn drain_pushes(&mut self) -> Vec<ServerMessage> {
548        std::mem::take(&mut self.pushes)
549    }
550
551    pub(crate) fn drain_pubsub_commands(&mut self) -> Vec<PubSubCommand> {
552        std::mem::take(&mut self.pubsub)
553    }
554
555    pub(crate) fn drain_runtime_commands(&mut self) -> Vec<RuntimeCommand> {
556        std::mem::take(&mut self.runtime)
557    }
558
559    pub(crate) fn schedule_internal_once(
560        &mut self,
561        id: impl Into<String>,
562        delay_ms: u64,
563        event: impl Into<String>,
564        value: impl Into<serde_json::Value>,
565    ) {
566        self.runtime.push(RuntimeCommand::ScheduleOnce {
567            id: id.into(),
568            delay_ms,
569            dispatch: RuntimeEvent::new(event, value),
570        });
571    }
572
573    pub(crate) fn cancel_schedule_internal(&mut self, id: impl Into<String>) {
574        self.runtime.push(RuntimeCommand::Cancel { id: id.into() });
575    }
576
577    pub(crate) fn take_render_after_event(&mut self) -> bool {
578        let render = self.render_after_event;
579        self.render_after_event = true;
580        render
581    }
582
583    pub(crate) fn render_cadence_override_ms(&self) -> Option<u64> {
584        self.render_cadence_override_ms
585    }
586}
587
588fn normalize_tenant_id(tenant_id: Option<String>) -> Option<String> {
589    tenant_id
590        .map(|value| value.trim().to_string())
591        .filter(|value| !value.is_empty())
592}
593
594#[cfg(test)]
595mod tests {
596    use super::Context;
597    use crate::{
598        ChartAnnotation, ChartPoint, GridColumn, GridPinned, GridRow, GridRowsWindow,
599        GridSavedView, GridSort, GridSortDirection, GridState, InboxItem, JsInteropDispatch,
600        PubSubCommand, RuntimeCommand, RuntimeEvent, ServerMessage, StreamBatchOperation,
601        StreamPosition, Toast, ToastLevel,
602    };
603    use serde_json::json;
604
605    #[test]
606    fn context_can_queue_redirects() {
607        let mut ctx = Context::new("root");
608        ctx.redirect("/next");
609
610        assert_eq!(
611            ctx.drain_pushes(),
612            vec![ServerMessage::Redirect {
613                to: "/next".to_string()
614            }]
615        );
616    }
617
618    #[test]
619    fn context_tracks_route_params() {
620        let mut ctx = Context::new("root");
621        ctx.set_route(
622            "/pages/intro",
623            [("slug".to_string(), "intro".to_string())].into(),
624        );
625
626        assert_eq!(ctx.route_path(), "/pages/intro");
627        assert_eq!(ctx.route_param("slug"), Some("intro"));
628        assert_eq!(ctx.route_param("missing"), None);
629    }
630
631    #[test]
632    fn context_tracks_tenant_context() {
633        let mut ctx = Context::new("root");
634        assert_eq!(ctx.tenant_id(), None);
635
636        ctx.set_tenant_id("tenant-a");
637        assert_eq!(ctx.tenant_id(), Some("tenant-a"));
638
639        ctx.set_tenant_id_optional(Some("   ".to_string()));
640        assert_eq!(ctx.tenant_id(), None);
641
642        ctx.set_tenant_id_optional(Some("tenant-b".to_string()));
643        assert_eq!(ctx.tenant_id(), Some("tenant-b"));
644
645        ctx.clear_tenant_id();
646        assert_eq!(ctx.tenant_id(), None);
647    }
648
649    #[test]
650    fn context_can_queue_navigation_messages() {
651        let mut ctx = Context::new("root");
652        ctx.patch_url("/pages/intro");
653        ctx.navigate("/users/1");
654
655        assert_eq!(
656            ctx.drain_pushes(),
657            vec![
658                ServerMessage::PatchUrl {
659                    to: "/pages/intro".to_string()
660                },
661                ServerMessage::Navigate {
662                    to: "/users/1".to_string()
663                },
664            ]
665        );
666    }
667
668    #[test]
669    fn context_can_queue_stream_messages_without_root_render() {
670        let mut ctx = Context::new("root");
671        ctx.stream_append("messages", "msg-1", "<li id=\"msg-1\">hi</li>");
672        ctx.stream_prepend("messages", "msg-0", "<li id=\"msg-0\">first</li>");
673        ctx.stream_delete("messages", "msg-1");
674
675        assert!(!ctx.take_render_after_event());
676        assert_eq!(
677            ctx.drain_pushes(),
678            vec![
679                ServerMessage::StreamInsert {
680                    target: "messages".to_string(),
681                    id: "msg-1".to_string(),
682                    html: "<li id=\"msg-1\">hi</li>".to_string(),
683                    at: StreamPosition::Append,
684                },
685                ServerMessage::StreamInsert {
686                    target: "messages".to_string(),
687                    id: "msg-0".to_string(),
688                    html: "<li id=\"msg-0\">first</li>".to_string(),
689                    at: StreamPosition::Prepend,
690                },
691                ServerMessage::StreamDelete {
692                    target: "messages".to_string(),
693                    id: "msg-1".to_string(),
694                },
695            ]
696        );
697        assert!(ctx.take_render_after_event());
698    }
699
700    #[test]
701    fn context_can_set_and_clear_render_cadence_override() {
702        let mut ctx = Context::new("root");
703        assert_eq!(ctx.render_cadence_override_ms(), None);
704
705        ctx.set_render_cadence_ms(24);
706        assert_eq!(ctx.render_cadence_override_ms(), Some(24));
707
708        ctx.clear_render_cadence_override();
709        assert_eq!(ctx.render_cadence_override_ms(), None);
710    }
711
712    #[test]
713    fn context_can_queue_stream_batch_messages_without_root_render() {
714        let mut ctx = Context::new("root");
715        ctx.stream_batch(
716            "messages",
717            vec![
718                StreamBatchOperation::Insert {
719                    id: "msg-2".to_string(),
720                    html: "<li id=\"msg-2\">batch</li>".to_string(),
721                    at: StreamPosition::Append,
722                },
723                StreamBatchOperation::Delete {
724                    id: "msg-1".to_string(),
725                },
726            ],
727        );
728
729        assert!(!ctx.take_render_after_event());
730        assert_eq!(
731            ctx.drain_pushes(),
732            vec![ServerMessage::StreamBatch {
733                target: "messages".to_string(),
734                operations: vec![
735                    StreamBatchOperation::Insert {
736                        id: "msg-2".to_string(),
737                        html: "<li id=\"msg-2\">batch</li>".to_string(),
738                        at: StreamPosition::Append,
739                    },
740                    StreamBatchOperation::Delete {
741                        id: "msg-1".to_string(),
742                    },
743                ],
744            }]
745        );
746        assert!(ctx.take_render_after_event());
747    }
748
749    #[test]
750    fn context_can_queue_pubsub_commands_without_root_render() {
751        let mut ctx = Context::new("root");
752        ctx.subscribe("chat:lobby");
753        ctx.broadcast_message(
754            "chat:lobby",
755            ServerMessage::StreamDelete {
756                target: "messages".to_string(),
757                id: "msg-1".to_string(),
758            },
759        );
760
761        assert!(!ctx.take_render_after_event());
762        assert_eq!(
763            ctx.drain_pubsub_commands(),
764            vec![
765                PubSubCommand::Subscribe {
766                    topic: "chat:lobby".to_string()
767                },
768                PubSubCommand::Broadcast {
769                    topic: "chat:lobby".to_string(),
770                    messages: vec![ServerMessage::StreamDelete {
771                        target: "messages".to_string(),
772                        id: "msg-1".to_string(),
773                    }],
774                }
775            ]
776        );
777    }
778
779    #[test]
780    fn context_can_queue_runtime_commands_without_root_render() {
781        let mut ctx = Context::new("root");
782        ctx.schedule_once("retry-once", 250, "load_more", json!({"cursor": "a1"}));
783        ctx.schedule_interval_to(
784            "heartbeat",
785            1000,
786            "grid",
787            "tick",
788            json!({"source": "timer"}),
789        );
790        ctx.cancel_schedule("retry-once");
791
792        assert!(!ctx.take_render_after_event());
793        assert_eq!(
794            ctx.drain_runtime_commands(),
795            vec![
796                RuntimeCommand::ScheduleOnce {
797                    id: "retry-once".to_string(),
798                    delay_ms: 250,
799                    dispatch: RuntimeEvent::new("load_more", json!({"cursor": "a1"})),
800                },
801                RuntimeCommand::ScheduleInterval {
802                    id: "heartbeat".to_string(),
803                    every_ms: 1000,
804                    dispatch: RuntimeEvent::with_target("grid", "tick", json!({"source": "timer"}),),
805                },
806                RuntimeCommand::Cancel {
807                    id: "retry-once".to_string(),
808                },
809            ]
810        );
811        assert!(ctx.take_render_after_event());
812    }
813
814    #[test]
815    fn context_can_queue_chart_stream_message_without_root_render() {
816        let mut ctx = Context::new("root");
817        ctx.chart_series_append("traffic", "requests", ChartPoint { x: 1.0, y: 2.5 });
818
819        assert!(!ctx.take_render_after_event());
820        assert_eq!(
821            ctx.drain_pushes(),
822            vec![ServerMessage::ChartSeriesAppend {
823                chart: "traffic".to_string(),
824                series: "requests".to_string(),
825                point: ChartPoint { x: 1.0, y: 2.5 },
826            }]
827        );
828        assert!(ctx.take_render_after_event());
829    }
830
831    #[test]
832    fn context_can_queue_chart_series_replace_and_reset_without_root_render() {
833        let mut ctx = Context::new("root");
834        ctx.chart_series_replace(
835            "traffic",
836            "requests",
837            vec![ChartPoint { x: 1.0, y: 2.5 }, ChartPoint { x: 2.0, y: 3.0 }],
838        );
839        ctx.chart_reset("traffic");
840
841        assert!(!ctx.take_render_after_event());
842        assert_eq!(
843            ctx.drain_pushes(),
844            vec![
845                ServerMessage::ChartSeriesReplace {
846                    chart: "traffic".to_string(),
847                    series: "requests".to_string(),
848                    points: vec![ChartPoint { x: 1.0, y: 2.5 }, ChartPoint { x: 2.0, y: 3.0 }],
849                },
850                ServerMessage::ChartReset {
851                    chart: "traffic".to_string(),
852                },
853            ]
854        );
855        assert!(ctx.take_render_after_event());
856    }
857
858    #[test]
859    fn context_can_queue_chart_append_many_without_root_render() {
860        let mut ctx = Context::new("root");
861        ctx.chart_series_append_many(
862            "traffic",
863            "requests",
864            vec![ChartPoint { x: 3.0, y: 4.0 }, ChartPoint { x: 4.0, y: 5.0 }],
865        );
866
867        assert!(!ctx.take_render_after_event());
868        assert_eq!(
869            ctx.drain_pushes(),
870            vec![ServerMessage::ChartSeriesAppendMany {
871                chart: "traffic".to_string(),
872                series: "requests".to_string(),
873                points: vec![ChartPoint { x: 3.0, y: 4.0 }, ChartPoint { x: 4.0, y: 5.0 }],
874            }]
875        );
876        assert!(ctx.take_render_after_event());
877    }
878
879    #[test]
880    fn context_can_queue_chart_annotation_messages_without_root_render() {
881        let mut ctx = Context::new("root");
882        ctx.chart_annotation_upsert(
883            "traffic",
884            ChartAnnotation {
885                id: "release-1".to_string(),
886                x: 18.0,
887                label: "Deploy".to_string(),
888            },
889        );
890        ctx.chart_annotation_delete("traffic", "release-1");
891
892        assert!(!ctx.take_render_after_event());
893        assert_eq!(
894            ctx.drain_pushes(),
895            vec![
896                ServerMessage::ChartAnnotationUpsert {
897                    chart: "traffic".to_string(),
898                    annotation: ChartAnnotation {
899                        id: "release-1".to_string(),
900                        x: 18.0,
901                        label: "Deploy".to_string(),
902                    },
903                },
904                ServerMessage::ChartAnnotationDelete {
905                    chart: "traffic".to_string(),
906                    id: "release-1".to_string(),
907                },
908            ]
909        );
910        assert!(ctx.take_render_after_event());
911    }
912
913    #[test]
914    fn context_can_queue_toast_and_inbox_messages_without_root_render() {
915        let mut ctx = Context::new("root");
916        ctx.toast_push(Toast {
917            id: "toast-1".to_string(),
918            level: ToastLevel::Success,
919            title: Some("Saved".to_string()),
920            message: "Profile updated".to_string(),
921            ttl_ms: Some(2000),
922        });
923        ctx.toast_dismiss("toast-1");
924        ctx.inbox_upsert(InboxItem {
925            id: "msg-1".to_string(),
926            title: "Welcome".to_string(),
927            body: "Thanks for joining".to_string(),
928            read: false,
929            inserted_at: None,
930        });
931        ctx.inbox_delete("msg-1");
932
933        assert!(!ctx.take_render_after_event());
934        assert_eq!(
935            ctx.drain_pushes(),
936            vec![
937                ServerMessage::ToastPush {
938                    toast: Toast {
939                        id: "toast-1".to_string(),
940                        level: ToastLevel::Success,
941                        title: Some("Saved".to_string()),
942                        message: "Profile updated".to_string(),
943                        ttl_ms: Some(2000),
944                    },
945                },
946                ServerMessage::ToastDismiss {
947                    id: "toast-1".to_string(),
948                },
949                ServerMessage::InboxUpsert {
950                    item: InboxItem {
951                        id: "msg-1".to_string(),
952                        title: "Welcome".to_string(),
953                        body: "Thanks for joining".to_string(),
954                        read: false,
955                        inserted_at: None,
956                    },
957                },
958                ServerMessage::InboxDelete {
959                    id: "msg-1".to_string(),
960                },
961            ]
962        );
963        assert!(ctx.take_render_after_event());
964    }
965
966    #[test]
967    fn context_can_queue_interop_and_webrtc_messages_without_root_render() {
968        let mut ctx = Context::new("root");
969        ctx.interop_dispatch_to(
970            Some("peer-a".to_string()),
971            "map:pan_to",
972            json!({"lat": 42.0, "lng": -71.0}),
973        );
974        ctx.webrtc_signal(json!({"kind": "offer", "sdp": "v=0..."}));
975
976        assert!(!ctx.take_render_after_event());
977        assert_eq!(
978            ctx.drain_pushes(),
979            vec![
980                ServerMessage::InteropDispatch {
981                    dispatch: JsInteropDispatch {
982                        target: Some("peer-a".to_string()),
983                        event: "map:pan_to".to_string(),
984                        detail: json!({"lat": 42.0, "lng": -71.0}),
985                        bubbles: true,
986                    },
987                },
988                ServerMessage::InteropDispatch {
989                    dispatch: JsInteropDispatch {
990                        target: None,
991                        event: "shelly:webrtc-signal".to_string(),
992                        detail: json!({"kind": "offer", "sdp": "v=0..."}),
993                        bubbles: true,
994                    },
995                },
996            ]
997        );
998        assert!(ctx.take_render_after_event());
999    }
1000
1001    #[test]
1002    fn context_can_queue_grid_replace_without_root_render() {
1003        let mut ctx = Context::new("root");
1004        ctx.grid_replace(
1005            "enterprise-grid",
1006            GridState {
1007                columns: vec![GridColumn {
1008                    id: "name".to_string(),
1009                    label: "Name".to_string(),
1010                    width_px: Some(220),
1011                    min_width_px: Some(100),
1012                    pinned: GridPinned::Left,
1013                    sortable: true,
1014                    resizable: true,
1015                    editable: false,
1016                }],
1017                rows: vec![GridRow {
1018                    id: "row-1".to_string(),
1019                    cells: std::iter::once(("name".to_string(), json!("Ada"))).collect(),
1020                    group: Some("Engineering".to_string()),
1021                }],
1022                total_rows: 2000,
1023                offset: 0,
1024                limit: 200,
1025                views: vec![GridSavedView {
1026                    id: "eng".to_string(),
1027                    label: "Engineering".to_string(),
1028                }],
1029                active_view: Some("eng".to_string()),
1030                group_by: Some("department".to_string()),
1031                query: Some("ada".to_string()),
1032                sort: Some(GridSort {
1033                    column: "name".to_string(),
1034                    direction: GridSortDirection::Asc,
1035                }),
1036            },
1037        );
1038
1039        assert!(!ctx.take_render_after_event());
1040        assert_eq!(
1041            ctx.drain_pushes(),
1042            vec![ServerMessage::GridReplace {
1043                grid: "enterprise-grid".to_string(),
1044                state: GridState {
1045                    columns: vec![GridColumn {
1046                        id: "name".to_string(),
1047                        label: "Name".to_string(),
1048                        width_px: Some(220),
1049                        min_width_px: Some(100),
1050                        pinned: GridPinned::Left,
1051                        sortable: true,
1052                        resizable: true,
1053                        editable: false,
1054                    }],
1055                    rows: vec![GridRow {
1056                        id: "row-1".to_string(),
1057                        cells: std::iter::once(("name".to_string(), json!("Ada"))).collect(),
1058                        group: Some("Engineering".to_string()),
1059                    }],
1060                    total_rows: 2000,
1061                    offset: 0,
1062                    limit: 200,
1063                    views: vec![GridSavedView {
1064                        id: "eng".to_string(),
1065                        label: "Engineering".to_string(),
1066                    }],
1067                    active_view: Some("eng".to_string()),
1068                    group_by: Some("department".to_string()),
1069                    query: Some("ada".to_string()),
1070                    sort: Some(GridSort {
1071                        column: "name".to_string(),
1072                        direction: GridSortDirection::Asc,
1073                    }),
1074                },
1075            }]
1076        );
1077        assert!(ctx.take_render_after_event());
1078    }
1079
1080    #[test]
1081    fn context_can_queue_grid_window_replace_without_root_render() {
1082        let mut ctx = Context::new("root");
1083        ctx.grid_rows_replace(
1084            "enterprise-grid",
1085            400,
1086            1000,
1087            vec![GridRow {
1088                id: "acct-401".to_string(),
1089                cells: serde_json::Map::from_iter([
1090                    ("name".to_string(), json!("Acme North")),
1091                    ("arr".to_string(), json!(166000)),
1092                ]),
1093                group: Some("Enterprise".to_string()),
1094            }],
1095        );
1096
1097        assert!(!ctx.take_render_after_event());
1098        assert_eq!(
1099            ctx.drain_pushes(),
1100            vec![ServerMessage::GridRowsReplace {
1101                grid: "enterprise-grid".to_string(),
1102                window: GridRowsWindow {
1103                    offset: 400,
1104                    total_rows: 1000,
1105                    rows: vec![GridRow {
1106                        id: "acct-401".to_string(),
1107                        cells: serde_json::Map::from_iter([
1108                            ("name".to_string(), json!("Acme North")),
1109                            ("arr".to_string(), json!(166000)),
1110                        ]),
1111                        group: Some("Enterprise".to_string()),
1112                    }],
1113                },
1114            }]
1115        );
1116        assert!(ctx.take_render_after_event());
1117    }
1118}