use serde_json::json;
use shelly::{
ChartAnnotation, ChartPoint, ClientMessage, DynamicSlotPatch, GridColumn, GridPinned, GridRow,
GridRowsWindow, GridSavedView, GridSort, GridSortDirection, GridState, InboxItem,
JsInteropDispatch, ServerMessage, StreamBatchOperation, StreamPosition, Toast, ToastLevel,
};
use std::{fs, path::PathBuf};
fn read_fixture(path: &str) -> serde_json::Value {
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(path);
let source = fs::read_to_string(&fixture_path)
.unwrap_or_else(|err| panic!("failed to read fixture {}: {err}", fixture_path.display()));
serde_json::from_str(&source)
.unwrap_or_else(|err| panic!("failed to parse fixture {}: {err}", fixture_path.display()))
}
#[test]
fn protocol_event_shape_is_stable() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "event",
"event": "submit",
"target": "profile-form",
"value": {"name": "Ada"},
"metadata": {"tag": "FORM"}
}))
.unwrap();
match message {
ClientMessage::Event {
event,
target,
value,
metadata,
} => {
assert_eq!(event, "submit");
assert_eq!(target.as_deref(), Some("profile-form"));
assert_eq!(value["name"], "Ada");
assert_eq!(metadata["tag"], "FORM");
}
other => panic!("unexpected message: {other:?}"),
}
}
#[test]
fn protocol_patch_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::Patch {
target: "shelly-root".to_string(),
html: "<main>ok</main>".to_string(),
revision: 7,
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "patch",
"target": "shelly-root",
"html": "<main>ok</main>",
"revision": 7
})
);
}
#[test]
fn protocol_connect_shape_is_stable() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "connect",
"protocol": "shelly/1"
}))
.unwrap();
assert_eq!(
message,
ClientMessage::Connect {
protocol: "shelly/1".to_string(),
session_id: None,
last_revision: None,
resume_token: None,
tenant_id: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
}
);
}
#[test]
fn protocol_connect_resume_shape_is_stable() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "connect",
"protocol": "shelly/1",
"session_id": "sid-1",
"last_revision": 12,
"resume_token": "resume-token-1"
}))
.unwrap();
assert_eq!(
message,
ClientMessage::Connect {
protocol: "shelly/1".to_string(),
session_id: Some("sid-1".to_string()),
last_revision: Some(12),
resume_token: Some("resume-token-1".to_string()),
tenant_id: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
}
);
}
#[test]
fn protocol_diff_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::Diff {
target: "shelly-root".to_string(),
revision: 8,
slots: vec![DynamicSlotPatch {
index: 0,
html: "1".to_string(),
}],
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "diff",
"target": "shelly-root",
"revision": 8,
"slots": [{"index": 0, "html": "1"}]
})
);
}
#[test]
fn protocol_patch_url_shape_is_stable() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "patch_url",
"to": "/pages/docs"
}))
.unwrap();
assert_eq!(
message,
ClientMessage::PatchUrl {
to: "/pages/docs".to_string()
}
);
assert_eq!(
serde_json::to_value(ServerMessage::PatchUrl {
to: "/pages/docs".to_string()
})
.unwrap(),
json!({"type": "patch_url", "to": "/pages/docs"})
);
}
#[test]
fn protocol_navigate_shape_is_stable() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "navigate",
"to": "/users/42"
}))
.unwrap();
assert_eq!(
message,
ClientMessage::Navigate {
to: "/users/42".to_string()
}
);
assert_eq!(
serde_json::to_value(ServerMessage::Navigate {
to: "/users/42".to_string()
})
.unwrap(),
json!({"type": "navigate", "to": "/users/42"})
);
}
#[test]
fn protocol_stream_insert_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::StreamInsert {
target: "messages".to_string(),
id: "msg-1".to_string(),
html: "<li id=\"msg-1\">Hi</li>".to_string(),
at: StreamPosition::Append,
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "stream_insert",
"target": "messages",
"id": "msg-1",
"html": "<li id=\"msg-1\">Hi</li>",
"at": "append"
})
);
}
#[test]
fn protocol_stream_delete_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::StreamDelete {
target: "messages".to_string(),
id: "msg-1".to_string(),
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "stream_delete",
"target": "messages",
"id": "msg-1"
})
);
}
#[test]
fn protocol_stream_batch_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::StreamBatch {
target: "messages".to_string(),
operations: vec![
StreamBatchOperation::Insert {
id: "msg-2".to_string(),
html: "<li id=\"msg-2\">Batch</li>".to_string(),
at: StreamPosition::Append,
},
StreamBatchOperation::Delete {
id: "msg-1".to_string(),
},
],
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "stream_batch",
"target": "messages",
"operations": [
{
"op": "insert",
"id": "msg-2",
"html": "<li id=\"msg-2\">Batch</li>",
"at": "append"
},
{
"op": "delete",
"id": "msg-1"
}
]
})
);
}
#[test]
fn protocol_chart_series_append_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::ChartSeriesAppend {
chart: "traffic-chart".to_string(),
series: "requests".to_string(),
point: ChartPoint { x: 5.0, y: 14.0 },
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "chart_series_append",
"chart": "traffic-chart",
"series": "requests",
"point": {
"x": 5.0,
"y": 14.0
}
})
);
}
#[test]
fn protocol_chart_series_append_many_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::ChartSeriesAppendMany {
chart: "traffic-chart".to_string(),
series: "requests".to_string(),
points: vec![
ChartPoint { x: 5.0, y: 14.0 },
ChartPoint { x: 6.0, y: 15.2 },
],
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "chart_series_append_many",
"chart": "traffic-chart",
"series": "requests",
"points": [
{"x": 5.0, "y": 14.0},
{"x": 6.0, "y": 15.2}
]
})
);
}
#[test]
fn protocol_chart_annotation_shapes_are_stable() {
let upsert = serde_json::to_value(ServerMessage::ChartAnnotationUpsert {
chart: "traffic-chart".to_string(),
annotation: ChartAnnotation {
id: "release-1".to_string(),
x: 18.0,
label: "Deploy".to_string(),
},
})
.unwrap();
assert_eq!(
upsert,
json!({
"type": "chart_annotation_upsert",
"chart": "traffic-chart",
"annotation": {
"id": "release-1",
"x": 18.0,
"label": "Deploy"
}
})
);
let delete = serde_json::to_value(ServerMessage::ChartAnnotationDelete {
chart: "traffic-chart".to_string(),
id: "release-1".to_string(),
})
.unwrap();
assert_eq!(
delete,
json!({
"type": "chart_annotation_delete",
"chart": "traffic-chart",
"id": "release-1"
})
);
}
#[test]
fn protocol_chart_series_replace_and_reset_shapes_are_stable() {
let replace = serde_json::to_value(ServerMessage::ChartSeriesReplace {
chart: "traffic-chart".to_string(),
series: "requests".to_string(),
points: vec![
ChartPoint { x: 10.0, y: 88.0 },
ChartPoint { x: 11.0, y: 90.5 },
],
})
.unwrap();
assert_eq!(
replace,
json!({
"type": "chart_series_replace",
"chart": "traffic-chart",
"series": "requests",
"points": [
{"x": 10.0, "y": 88.0},
{"x": 11.0, "y": 90.5}
]
})
);
let reset = serde_json::to_value(ServerMessage::ChartReset {
chart: "traffic-chart".to_string(),
})
.unwrap();
assert_eq!(
reset,
json!({
"type": "chart_reset",
"chart": "traffic-chart"
})
);
}
#[test]
fn protocol_toast_and_inbox_shapes_are_stable() {
let toast_push = serde_json::to_value(ServerMessage::ToastPush {
toast: Toast {
id: "toast-1".to_string(),
level: ToastLevel::Warning,
title: Some("Heads up".to_string()),
message: "Disk nearing limit".to_string(),
ttl_ms: Some(4000),
},
})
.unwrap();
assert_eq!(
toast_push,
json!({
"type": "toast_push",
"toast": {
"id": "toast-1",
"level": "warning",
"title": "Heads up",
"message": "Disk nearing limit",
"ttl_ms": 4000
}
})
);
let toast_dismiss = serde_json::to_value(ServerMessage::ToastDismiss {
id: "toast-1".to_string(),
})
.unwrap();
assert_eq!(
toast_dismiss,
json!({
"type": "toast_dismiss",
"id": "toast-1"
})
);
let inbox_upsert = serde_json::to_value(ServerMessage::InboxUpsert {
item: InboxItem {
id: "msg-1".to_string(),
title: "Welcome".to_string(),
body: "Thanks for joining".to_string(),
read: false,
inserted_at: Some("2026-05-05T12:00:00Z".to_string()),
},
})
.unwrap();
assert_eq!(
inbox_upsert,
json!({
"type": "inbox_upsert",
"item": {
"id": "msg-1",
"title": "Welcome",
"body": "Thanks for joining",
"read": false,
"inserted_at": "2026-05-05T12:00:00Z"
}
})
);
let inbox_delete = serde_json::to_value(ServerMessage::InboxDelete {
id: "msg-1".to_string(),
})
.unwrap();
assert_eq!(
inbox_delete,
json!({
"type": "inbox_delete",
"id": "msg-1"
})
);
}
#[test]
fn protocol_upload_shape_is_stable() {
let start: ClientMessage = serde_json::from_value(json!({
"type": "upload_start",
"upload_id": "up-1",
"event": "uploaded",
"target": "file",
"name": "notes.txt",
"size": 5,
"content_type": "text/plain"
}))
.unwrap();
assert_eq!(
start,
ClientMessage::UploadStart {
upload_id: "up-1".to_string(),
event: "uploaded".to_string(),
target: Some("file".to_string()),
name: "notes.txt".to_string(),
size: 5,
content_type: Some("text/plain".to_string()),
}
);
assert_eq!(
serde_json::to_value(ServerMessage::UploadProgress {
upload_id: "up-1".to_string(),
received: 5,
total: 5,
})
.unwrap(),
json!({
"type": "upload_progress",
"upload_id": "up-1",
"received": 5,
"total": 5
})
);
}
#[test]
fn protocol_interop_dispatch_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::InteropDispatch {
dispatch: JsInteropDispatch {
target: Some("peer-a".to_string()),
event: "shelly:webrtc-signal".to_string(),
detail: json!({"kind": "candidate", "candidate": "cand-1"}),
bubbles: true,
},
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "interop_dispatch",
"dispatch": {
"target": "peer-a",
"event": "shelly:webrtc-signal",
"detail": {"kind": "candidate", "candidate": "cand-1"},
"bubbles": true
}
})
);
}
#[test]
fn protocol_grid_replace_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::GridReplace {
grid: "enterprise-grid".to_string(),
state: GridState {
columns: vec![
GridColumn {
id: "name".to_string(),
label: "Name".to_string(),
width_px: Some(220),
min_width_px: Some(120),
pinned: GridPinned::Left,
sortable: true,
resizable: true,
editable: false,
},
GridColumn {
id: "arr".to_string(),
label: "ARR".to_string(),
width_px: Some(120),
min_width_px: Some(80),
pinned: GridPinned::None,
sortable: true,
resizable: true,
editable: true,
},
],
rows: vec![GridRow {
id: "row-1".to_string(),
cells: std::iter::once(("name".to_string(), json!("Acme"))).collect(),
group: Some("Enterprise".to_string()),
}],
total_rows: 2500,
offset: 0,
limit: 200,
views: vec![GridSavedView {
id: "ops".to_string(),
label: "Ops".to_string(),
}],
active_view: Some("ops".to_string()),
group_by: Some("segment".to_string()),
query: Some("acme".to_string()),
sort: Some(GridSort {
column: "arr".to_string(),
direction: GridSortDirection::Desc,
}),
},
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "grid_replace",
"grid": "enterprise-grid",
"state": {
"columns": [
{
"id": "name",
"label": "Name",
"width_px": 220,
"min_width_px": 120,
"pinned": "left",
"sortable": true,
"resizable": true,
"editable": false
},
{
"id": "arr",
"label": "ARR",
"width_px": 120,
"min_width_px": 80,
"pinned": "none",
"sortable": true,
"resizable": true,
"editable": true
}
],
"rows": [
{
"id": "row-1",
"cells": {
"name": "Acme"
},
"group": "Enterprise"
}
],
"total_rows": 2500,
"offset": 0,
"limit": 200,
"views": [
{
"id": "ops",
"label": "Ops"
}
],
"active_view": "ops",
"group_by": "segment",
"query": "acme",
"sort": {
"column": "arr",
"direction": "desc"
}
}
})
);
}
#[test]
fn protocol_grid_rows_replace_shape_is_stable() {
let encoded = serde_json::to_value(ServerMessage::GridRowsReplace {
grid: "enterprise-grid".to_string(),
window: GridRowsWindow {
offset: 400,
total_rows: 1000,
rows: vec![GridRow {
id: "acct-401".to_string(),
cells: serde_json::Map::from_iter([
("name".to_string(), json!("Acme North")),
("arr".to_string(), json!(166000)),
]),
group: Some("Enterprise".to_string()),
}],
},
})
.unwrap();
assert_eq!(
encoded,
json!({
"type": "grid_rows_replace",
"grid": "enterprise-grid",
"window": {
"offset": 400,
"total_rows": 1000,
"rows": [
{
"id": "acct-401",
"cells": {
"name": "Acme North",
"arr": 166000
},
"group": "Enterprise"
}
]
}
})
);
}
#[test]
fn protocol_client_event_fixture_matches_contract() {
let message: ClientMessage =
serde_json::from_value(read_fixture("protocol/client_event_submit.json")).unwrap();
assert_eq!(
message,
ClientMessage::Event {
event: "submit".to_string(),
target: Some("profile-form".to_string()),
value: json!({"name": "Ada"}),
metadata: serde_json::Map::from_iter([("tag".to_string(), json!("FORM"))]),
}
);
}
#[test]
fn protocol_patch_fixture_matches_contract() {
let expected = read_fixture("protocol/server_patch_counter.json");
let encoded = serde_json::to_value(ServerMessage::Patch {
target: "shelly-root".to_string(),
html: "<main>ok</main>".to_string(),
revision: 7,
})
.unwrap();
assert_eq!(encoded, expected);
}
#[test]
fn protocol_diff_fixture_matches_contract() {
let expected = read_fixture("protocol/server_diff_counter.json");
let encoded = serde_json::to_value(ServerMessage::Diff {
target: "shelly-root".to_string(),
revision: 8,
slots: vec![DynamicSlotPatch {
index: 0,
html: "1".to_string(),
}],
})
.unwrap();
assert_eq!(encoded, expected);
}