use crate::{
ClientMessage, ComponentRender, Context, DynamicSlotPatch, Event, Html, LiveResult, LiveView,
NoopTelemetrySink, ServerMessage, SessionId, TelemetryEvent, TelemetryEventKind, TelemetrySink,
};
use serde_json::Value;
use std::{collections::BTreeMap, sync::Arc, time::Instant};
const PROTOCOL_VERSION: &str = "shelly/1";
pub struct LiveSession {
view: Box<dyn LiveView>,
ctx: Context,
revision: u64,
last_render: Option<Html>,
telemetry: Arc<dyn TelemetrySink>,
}
impl LiveSession {
pub fn new(view: Box<dyn LiveView>, target_dom_id: impl Into<String>) -> Self {
Self::new_with_session_id(view, SessionId::new().to_string(), target_dom_id)
}
pub fn new_with_session_id(
view: Box<dyn LiveView>,
session_id: impl Into<String>,
target_dom_id: impl Into<String>,
) -> Self {
Self::new_with_route_and_session_id(view, session_id, target_dom_id, "/", BTreeMap::new())
}
pub fn new_with_route(
view: Box<dyn LiveView>,
target_dom_id: impl Into<String>,
route_path: impl Into<String>,
route_params: BTreeMap<String, String>,
) -> Self {
Self::new_with_route_and_session_id(
view,
SessionId::new().to_string(),
target_dom_id,
route_path,
route_params,
)
}
pub fn new_with_route_and_session_id(
view: Box<dyn LiveView>,
session_id: impl Into<String>,
target_dom_id: impl Into<String>,
route_path: impl Into<String>,
route_params: BTreeMap<String, String>,
) -> Self {
let mut ctx =
Context::new_with_session_id(SessionId::from_string(session_id.into()), target_dom_id);
ctx.set_route(route_path, route_params);
Self {
view,
ctx,
revision: 0,
last_render: None,
telemetry: Arc::new(NoopTelemetrySink),
}
}
pub fn set_telemetry_sink(&mut self, telemetry: Arc<dyn TelemetrySink>) {
self.telemetry = telemetry;
}
pub fn mount(&mut self) -> LiveResult {
let started = Instant::now();
self.ctx.set_connected(true);
let result = self
.view
.mount(&mut self.ctx)
.and_then(|_| self.view.handle_params(&mut self.ctx));
if result.is_err() {
self.ctx.set_connected(false);
let _ = self.ctx.drain_pushes();
let _ = self.ctx.drain_pubsub_commands();
let _ = self.ctx.drain_runtime_commands();
}
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::Mount)
.with_ok(result.is_ok())
.with_latency_ms(started.elapsed().as_millis() as u64),
);
result
}
pub fn drain_pubsub_commands(&mut self) -> Vec<crate::PubSubCommand> {
self.ctx.drain_pubsub_commands()
}
pub fn drain_runtime_commands(&mut self) -> Vec<crate::RuntimeCommand> {
self.ctx.drain_runtime_commands()
}
pub fn session_id(&self) -> &str {
self.ctx.session_id().as_str()
}
pub fn route_path(&self) -> &str {
self.ctx.route_path()
}
pub fn revision(&self) -> u64 {
self.revision
}
fn emit_telemetry(&self, event: TelemetryEvent) {
let _ = self.telemetry.emit(event);
}
fn telemetry_event(&self, kind: TelemetryEventKind) -> TelemetryEvent {
TelemetryEvent::new(kind)
.with_session(self.session_id().to_string())
.with_route(self.ctx.route_path().to_string())
}
pub fn render_html(&self) -> Html {
self.view.render()
}
pub fn patch_route(
&mut self,
route_path: impl Into<String>,
route_params: BTreeMap<String, String>,
) -> LiveResult {
self.ctx.set_route(route_path, route_params);
self.view.handle_params(&mut self.ctx)
}
pub fn hello(&self) -> ServerMessage {
ServerMessage::Hello {
session_id: self.session_id().to_string(),
target: self.ctx.target_dom_id().to_string(),
revision: self.revision,
protocol: PROTOCOL_VERSION.to_string(),
server_revision: None,
resume_status: None,
resume_reason: None,
resume_token: None,
resume_expires_in_ms: None,
}
}
pub fn render_patch(&mut self) -> ServerMessage {
self.revision += 1;
let html = self.view.render();
let html_string = html.as_str().to_string();
let bytes = html_string.len();
self.last_render = Some(html);
let message = ServerMessage::Patch {
target: self.ctx.target_dom_id().to_string(),
html: html_string,
revision: self.revision,
};
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::Patch)
.with_bytes(bytes)
.with_count(1),
);
message
}
pub fn render_update(&mut self) -> ServerMessage {
let next = self.view.render();
let diff = self.diff_against_last_render(&next);
self.revision += 1;
let message = match diff {
Some(slots) => ServerMessage::Diff {
target: self.ctx.target_dom_id().to_string(),
revision: self.revision,
slots,
},
None => ServerMessage::Patch {
target: self.ctx.target_dom_id().to_string(),
html: next.as_str().to_string(),
revision: self.revision,
},
};
match &message {
ServerMessage::Diff { slots, .. } => {
let bytes = slots.iter().map(|slot| slot.html.len()).sum();
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::Diff)
.with_bytes(bytes)
.with_count(slots.len()),
);
}
ServerMessage::Patch { html, .. } => {
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::Patch)
.with_bytes(html.len())
.with_count(1),
);
}
_ => {}
}
self.last_render = Some(next);
message
}
pub fn render_snapshot_patch(&mut self) -> ServerMessage {
let html = self.view.render();
let html_string = html.as_str().to_string();
self.last_render = Some(html);
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::Patch)
.with_bytes(html_string.len())
.with_count(1)
.with_attribute("snapshot".to_string(), Value::Bool(true)),
);
ServerMessage::Patch {
target: self.ctx.target_dom_id().to_string(),
html: html_string,
revision: self.revision,
}
}
pub fn render_component_update(&mut self, render: ComponentRender) -> ServerMessage {
self.revision += 1;
let (id, html) = render.into_parts();
let message = ServerMessage::Patch {
target: id.to_string(),
html: html.as_str().to_string(),
revision: self.revision,
};
if let ServerMessage::Patch { html, .. } = &message {
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::Patch)
.with_bytes(html.len())
.with_count(1)
.with_attribute("component_patch".to_string(), Value::Bool(true)),
);
}
message
}
pub fn handle_client_message(&mut self, message: ClientMessage) -> Vec<ServerMessage> {
let started = Instant::now();
let event_name = match &message {
ClientMessage::Event { event, .. } => Some(event.clone()),
_ => None,
};
let messages = match message {
ClientMessage::Connect { protocol, .. } => {
if protocol == PROTOCOL_VERSION {
Vec::new()
} else {
vec![ServerMessage::Error {
message: format!(
"unsupported protocol in connect: expected {PROTOCOL_VERSION}, got {protocol}"
),
code: Some("unsupported_protocol".to_string()),
}]
}
}
ClientMessage::Ping { nonce } => vec![ServerMessage::Pong { nonce }],
ClientMessage::PatchUrl { to } => vec![ServerMessage::PatchUrl { to }],
ClientMessage::Navigate { to } => vec![ServerMessage::Navigate { to }],
ClientMessage::UploadStart { .. }
| ClientMessage::UploadChunk { .. }
| ClientMessage::UploadComplete { .. } => vec![ServerMessage::Error {
message: "upload protocol messages must be handled by a transport adapter"
.to_string(),
code: Some("unsupported_upload_transport".to_string()),
}],
event_message @ ClientMessage::Event { .. } => match Event::try_from(event_message) {
Err(err) => vec![ServerMessage::Error {
message: err.to_string(),
code: Some("invalid_event".to_string()),
}],
Ok(event) => {
if let Some(target) = event.target.clone() {
match self.view.handle_component_event(
&target,
event.clone(),
&mut self.ctx,
) {
Ok(Some(render)) => {
let mut messages = self.ctx.drain_pushes();
if self.ctx.take_render_after_event() {
messages.push(self.render_component_update(render));
}
messages
}
Ok(None) => {
if let Err(err) = self.view.handle_event(event, &mut self.ctx) {
let _ = self.ctx.drain_pushes();
let _ = self.ctx.drain_pubsub_commands();
let _ = self.ctx.drain_runtime_commands();
let _ = self.ctx.take_render_after_event();
vec![ServerMessage::Error {
message: err.to_string(),
code: Some("event_failed".to_string()),
}]
} else {
let mut messages = self.ctx.drain_pushes();
if self.ctx.take_render_after_event() {
messages.push(self.render_update());
}
messages
}
}
Err(err) => {
let _ = self.ctx.drain_pushes();
let _ = self.ctx.drain_pubsub_commands();
let _ = self.ctx.drain_runtime_commands();
let _ = self.ctx.take_render_after_event();
vec![ServerMessage::Error {
message: err.to_string(),
code: Some("event_failed".to_string()),
}]
}
}
} else if let Err(err) = self.view.handle_event(event, &mut self.ctx) {
let _ = self.ctx.drain_pushes();
let _ = self.ctx.drain_pubsub_commands();
let _ = self.ctx.drain_runtime_commands();
let _ = self.ctx.take_render_after_event();
vec![ServerMessage::Error {
message: err.to_string(),
code: Some("event_failed".to_string()),
}]
} else {
let mut messages = self.ctx.drain_pushes();
if self.ctx.take_render_after_event() {
messages.push(self.render_update());
}
messages
}
}
},
};
for message in &messages {
match message {
ServerMessage::StreamInsert { .. } => {
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::StreamInsert)
.with_count(1),
);
}
ServerMessage::StreamDelete { .. } => {
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::StreamDelete)
.with_count(1),
);
}
ServerMessage::StreamBatch { operations, .. } => {
let inserts = operations
.iter()
.filter(|operation| {
matches!(operation, crate::StreamBatchOperation::Insert { .. })
})
.count();
let deletes = operations
.iter()
.filter(|operation| {
matches!(operation, crate::StreamBatchOperation::Delete { .. })
})
.count();
if inserts > 0 {
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::StreamInsert)
.with_count(inserts),
);
}
if deletes > 0 {
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::StreamDelete)
.with_count(deletes),
);
}
}
ServerMessage::Error { .. } => {
self.emit_telemetry(
self.telemetry_event(TelemetryEventKind::Error)
.with_ok(false)
.with_count(1),
);
}
_ => {}
}
}
let ok = messages
.iter()
.all(|message| !matches!(message, ServerMessage::Error { .. }));
let mut event_telemetry = self
.telemetry_event(TelemetryEventKind::HandleEvent)
.with_ok(ok)
.with_latency_ms(started.elapsed().as_millis() as u64)
.with_count(messages.len());
if let Some(name) = event_name {
event_telemetry = event_telemetry.with_event_name(name);
}
self.emit_telemetry(event_telemetry);
messages
}
fn diff_against_last_render(&self, next: &Html) -> Option<Vec<DynamicSlotPatch>> {
let previous = self.last_render.as_ref()?.template()?;
let next_template = next.template()?;
if !previous.compatible_with(next_template) {
return None;
}
Some(
previous
.dynamic_segments()
.iter()
.zip(next_template.dynamic_segments())
.enumerate()
.filter_map(|(index, (before, after))| {
if before == after {
None
} else {
Some(DynamicSlotPatch {
index,
html: after.clone(),
})
}
})
.collect(),
)
}
}
impl Drop for LiveSession {
fn drop(&mut self) {
self.ctx.set_connected(false);
}
}
#[cfg(test)]
mod tests {
use super::LiveSession;
use crate::{
ClientMessage, ComponentId, ComponentRender, Context, DynamicSlotPatch, Event, Html,
LiveComponent, LiveResult, LiveView, MemoryTelemetrySink, RuntimeCommand, ServerMessage,
ShellyError, StreamBatchOperation, TelemetryEvent, TelemetryEventKind, TelemetrySink,
Template,
};
use serde_json::Value;
use std::sync::Arc;
#[derive(Default)]
struct Counter {
count: i64,
}
impl LiveView for Counter {
fn handle_event(&mut self, event: Event, _ctx: &mut Context) -> LiveResult {
match event.name.as_str() {
"inc" => self.count += 1,
"dec" => self.count -= 1,
_ => {}
}
Ok(())
}
fn render(&self) -> Html {
Html::new(format!("<p>{}</p>", self.count))
}
}
#[test]
fn session_dispatches_event_and_patches() {
let mut session = LiveSession::new(Box::<Counter>::default(), "root");
session.mount().unwrap();
let messages = session.handle_client_message(ClientMessage::Event {
event: "inc".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(messages.len(), 1);
assert_eq!(
messages[0],
ServerMessage::Patch {
target: "root".to_string(),
html: "<p>1</p>".to_string(),
revision: 1,
}
);
}
#[test]
fn session_responds_to_ping_without_rendering() {
let mut session = LiveSession::new(Box::<Counter>::default(), "root");
let messages = session.handle_client_message(ClientMessage::Ping {
nonce: Some("abc".to_string()),
});
assert_eq!(
messages,
vec![ServerMessage::Pong {
nonce: Some("abc".to_string())
}]
);
assert_eq!(session.revision(), 0);
}
#[test]
fn connect_with_supported_protocol_is_noop() {
let mut session = LiveSession::new(Box::<Counter>::default(), "root");
let messages = session.handle_client_message(ClientMessage::Connect {
protocol: "shelly/1".to_string(),
session_id: None,
last_revision: None,
resume_token: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
});
assert!(messages.is_empty());
}
#[test]
fn connect_with_unsupported_protocol_returns_structured_error() {
let mut session = LiveSession::new(Box::<Counter>::default(), "root");
let messages = session.handle_client_message(ClientMessage::Connect {
protocol: "shelly/2".to_string(),
session_id: None,
last_revision: None,
resume_token: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
});
assert_eq!(
messages,
vec![ServerMessage::Error {
message: "unsupported protocol in connect: expected shelly/1, got shelly/2"
.to_string(),
code: Some("unsupported_protocol".to_string()),
}]
);
}
#[test]
fn unknown_events_do_not_panic() {
let mut session = LiveSession::new(Box::<Counter>::default(), "root");
session.mount().unwrap();
let messages = session.handle_client_message(ClientMessage::Event {
event: "unknown".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(
messages,
vec![ServerMessage::Patch {
target: "root".to_string(),
html: "<p>0</p>".to_string(),
revision: 1,
}]
);
}
#[derive(Default)]
struct FailingEvent {
count: i64,
}
impl LiveView for FailingEvent {
fn handle_event(&mut self, event: Event, ctx: &mut Context) -> LiveResult {
match event.name.as_str() {
"fail" => {
ctx.redirect("/should-not-leak");
Err(ShellyError::Event("boom".to_string()))
}
"inc" => {
self.count += 1;
Ok(())
}
_ => Ok(()),
}
}
fn render(&self) -> Html {
Html::new(format!("<p>{}</p>", self.count))
}
}
#[test]
fn event_errors_do_not_increment_revision_or_leak_pushes() {
let mut session = LiveSession::new(Box::<FailingEvent>::default(), "root");
session.mount().unwrap();
let failure = session.handle_client_message(ClientMessage::Event {
event: "fail".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(
failure,
vec![ServerMessage::Error {
message: "event error: boom".to_string(),
code: Some("event_failed".to_string()),
}]
);
assert_eq!(session.revision(), 0);
let success = session.handle_client_message(ClientMessage::Event {
event: "inc".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(
success,
vec![ServerMessage::Patch {
target: "root".to_string(),
html: "<p>1</p>".to_string(),
revision: 1,
}]
);
}
#[derive(Default)]
struct TemplateCounter {
count: i64,
}
impl LiveView for TemplateCounter {
fn handle_event(&mut self, event: Event, _ctx: &mut Context) -> LiveResult {
if event.name == "inc" {
self.count += 1;
}
Ok(())
}
fn render(&self) -> Html {
Template::new(vec!["<p>Count: ", "</p>"], vec![self.count.to_string()]).into()
}
}
#[test]
fn template_updates_send_only_changed_dynamic_slots() {
let mut session = LiveSession::new(Box::<TemplateCounter>::default(), "root");
session.mount().unwrap();
assert_eq!(
session.render_patch(),
ServerMessage::Patch {
target: "root".to_string(),
html: r#"<p>Count: <span data-shelly-slot="0">0</span></p>"#.to_string(),
revision: 1,
}
);
let update = session.handle_client_message(ClientMessage::Event {
event: "inc".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(
update,
vec![ServerMessage::Diff {
target: "root".to_string(),
revision: 2,
slots: vec![DynamicSlotPatch {
index: 0,
html: "1".to_string(),
}],
}]
);
}
#[test]
fn plain_html_keeps_full_patch_fallback() {
let mut session = LiveSession::new(Box::<Counter>::default(), "root");
session.mount().unwrap();
assert!(matches!(
session.render_patch(),
ServerMessage::Patch { .. }
));
let update = session.handle_client_message(ClientMessage::Event {
event: "inc".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert!(matches!(update.as_slice(), [ServerMessage::Patch { .. }]));
}
#[derive(Default)]
struct StreamList {
next_id: usize,
}
impl LiveView for StreamList {
fn handle_event(&mut self, event: Event, ctx: &mut Context) -> LiveResult {
match event.name.as_str() {
"append" => {
self.next_id += 1;
let id = format!("item-{}", self.next_id);
ctx.stream_append(
"items",
id.clone(),
format!(r#"<li id="{id}">Item {}</li>"#, self.next_id),
);
}
"delete" => ctx.stream_delete("items", "item-1"),
_ => {}
}
Ok(())
}
fn render(&self) -> Html {
Html::new(r#"<ul id="items"></ul>"#)
}
}
#[test]
fn stream_events_send_only_stream_operations() {
let mut session = LiveSession::new(Box::<StreamList>::default(), "root");
session.mount().unwrap();
session.render_patch();
let append = session.handle_client_message(ClientMessage::Event {
event: "append".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(
append,
vec![ServerMessage::StreamInsert {
target: "items".to_string(),
id: "item-1".to_string(),
html: r#"<li id="item-1">Item 1</li>"#.to_string(),
at: crate::StreamPosition::Append,
}]
);
let delete = session.handle_client_message(ClientMessage::Event {
event: "delete".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(
delete,
vec![ServerMessage::StreamDelete {
target: "items".to_string(),
id: "item-1".to_string(),
}]
);
}
#[derive(Default)]
struct StreamBatchList {
next_id: usize,
}
impl LiveView for StreamBatchList {
fn handle_event(&mut self, event: Event, ctx: &mut Context) -> LiveResult {
if event.name == "burst_append" {
let mut items = Vec::new();
for _ in 0..3 {
self.next_id += 1;
let id = format!("item-{}", self.next_id);
let html = format!(r#"<li id="{id}">Item {}</li>"#, self.next_id);
items.push((id, html));
}
ctx.stream_append_many("items", items);
}
Ok(())
}
fn render(&self) -> Html {
Html::new(r#"<ul id="items"></ul>"#)
}
}
#[test]
fn stream_burst_events_send_batch_operations() {
let mut session = LiveSession::new(Box::<StreamBatchList>::default(), "root");
session.mount().unwrap();
session.render_patch();
let messages = session.handle_client_message(ClientMessage::Event {
event: "burst_append".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(messages.len(), 1);
assert_eq!(
messages[0],
ServerMessage::StreamBatch {
target: "items".to_string(),
operations: vec![
StreamBatchOperation::Insert {
id: "item-1".to_string(),
html: r#"<li id="item-1">Item 1</li>"#.to_string(),
at: crate::StreamPosition::Append,
},
StreamBatchOperation::Insert {
id: "item-2".to_string(),
html: r#"<li id="item-2">Item 2</li>"#.to_string(),
at: crate::StreamPosition::Append,
},
StreamBatchOperation::Insert {
id: "item-3".to_string(),
html: r#"<li id="item-3">Item 3</li>"#.to_string(),
at: crate::StreamPosition::Append,
},
],
}
);
}
#[derive(Default)]
struct RuntimeScheduler;
impl LiveView for RuntimeScheduler {
fn mount(&mut self, ctx: &mut Context) -> LiveResult {
ctx.schedule_interval("heartbeat", 1000, "tick", Value::Null);
Ok(())
}
fn render(&self) -> Html {
Html::new("<p>scheduler</p>")
}
}
#[test]
fn mount_can_queue_runtime_commands() {
let mut session = LiveSession::new(Box::<RuntimeScheduler>::default(), "root");
session.mount().unwrap();
assert_eq!(
session.drain_runtime_commands(),
vec![RuntimeCommand::ScheduleInterval {
id: "heartbeat".to_string(),
every_ms: 1000,
dispatch: crate::RuntimeEvent::new("tick", Value::Null),
}]
);
}
struct FailingTelemetrySink;
impl TelemetrySink for FailingTelemetrySink {
fn emit(&self, _event: TelemetryEvent) -> Result<(), String> {
Err("telemetry downstream unavailable".to_string())
}
}
#[test]
fn telemetry_sink_failures_do_not_break_runtime_flow() {
let mut session = LiveSession::new(Box::<Counter>::default(), "root");
session.set_telemetry_sink(Arc::new(FailingTelemetrySink));
session.mount().unwrap();
let messages = session.handle_client_message(ClientMessage::Event {
event: "inc".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
assert!(matches!(messages.as_slice(), [ServerMessage::Patch { .. }]));
}
struct TodoItem {
id: ComponentId,
title: String,
done: bool,
}
impl TodoItem {
fn new(id: &str, title: &str) -> Self {
Self {
id: ComponentId::new(id),
title: title.to_string(),
done: false,
}
}
}
impl LiveComponent for TodoItem {
fn id(&self) -> ComponentId {
self.id.clone()
}
fn handle_event(&mut self, event: Event, _ctx: &mut Context) -> LiveResult {
if event.name == "toggle" {
self.done = !self.done;
}
Ok(())
}
fn render(&self) -> Html {
Html::new(format!(
r#"<li id="{id}" data-done="{done}"><span>{title}</span><button shelly-click="toggle" shelly-target="{id}">Toggle</button></li>"#,
id = self.id,
done = self.done,
title = self.title,
))
}
}
struct TodoList {
items: Vec<TodoItem>,
}
impl Default for TodoList {
fn default() -> Self {
Self {
items: vec![
TodoItem::new("todo-1", "Write docs"),
TodoItem::new("todo-2", "Ship components"),
],
}
}
}
impl LiveView for TodoList {
fn handle_component_event(
&mut self,
target: &str,
event: Event,
ctx: &mut Context,
) -> LiveResult<Option<ComponentRender>> {
let Some(item) = self
.items
.iter_mut()
.find(|item| item.id().as_str() == target)
else {
return Ok(None);
};
item.handle_event(event, ctx)?;
Ok(Some(ComponentRender::new(item.id(), item.render())))
}
fn render(&self) -> Html {
let items = self
.items
.iter()
.map(|item| item.render().into_string())
.collect::<String>();
Html::new(format!(r#"<ul>{items}</ul>"#))
}
}
#[test]
fn scoped_component_event_patches_only_target_component() {
let mut session = LiveSession::new(Box::<TodoList>::default(), "root");
session.mount().unwrap();
let initial = session.render_patch();
assert!(matches!(
initial,
ServerMessage::Patch { ref html, .. } if html.contains("todo-1")
));
let update = session.handle_client_message(ClientMessage::Event {
event: "toggle".to_string(),
target: Some("todo-2".to_string()),
value: Value::Null,
metadata: Default::default(),
});
assert_eq!(update.len(), 1);
assert_eq!(
update[0],
ServerMessage::Patch {
target: "todo-2".to_string(),
html: r#"<li id="todo-2" data-done="true"><span>Ship components</span><button shelly-click="toggle" shelly-target="todo-2">Toggle</button></li>"#.to_string(),
revision: 2,
}
);
}
#[test]
fn session_emits_mount_and_event_telemetry() {
let telemetry = Arc::new(MemoryTelemetrySink::new());
let mut session = LiveSession::new(Box::<Counter>::default(), "root");
session.set_telemetry_sink(telemetry.clone());
session.mount().unwrap();
let _ = session.handle_client_message(ClientMessage::Event {
event: "inc".to_string(),
target: None,
value: Value::Null,
metadata: Default::default(),
});
let events = telemetry.events();
assert!(events
.iter()
.any(|event| event.kind == TelemetryEventKind::Mount));
assert!(events
.iter()
.any(|event| event.kind == TelemetryEventKind::HandleEvent));
assert!(events
.iter()
.any(|event| event.kind == TelemetryEventKind::Patch));
}
}