Files
improvise/crates/improvise-protocol/src/frame.rs
T
2026-09-27 16:51:50 +00:00

183 lines
7.1 KiB
Rust

//! Which of this crate's payloads a given JSON blob is. **One pair, two
//! deployments** (improvise-xks6) — `context/code/protocol.md` §4.
use serde::{Deserialize, Serialize};
use crate::{Command, KeyCode, KeyEvent, KeyModifiers, Rejection, Snapshot};
/// A frame from a client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ClientFrame {
/// How many rows and columns this client can display. The handshake:
/// sent first, and again on every resize.
Viewport { rows: usize, cols: usize },
/// User intent, in the shared vocabulary. **The frame is the unit of
/// atomicity, not the command** — `context/code/protocol.md` §4.
Commands(Vec<Command>),
/// Write the document back (improvise-368h). **A request carrying no
/// path, and a frame not a [`Command`]** — §4 says why on both counts.
Save,
/// A keypress this client could not resolve. **`Host::handle` answers it
/// with nothing** — a black hole, not a slow path; see §4.
Key {
code: KeyCode,
modifiers: KeyModifiers,
},
/// Ask for document bytes named by SavePrompt; the browser downloads them
/// via Blob + `<a download>` (improvise-2mqz).
DownloadDocument { filename: String },
/// Ask for active-view CSV bytes named by ExportPrompt; the browser downloads
/// them via Blob + `<a download>` (improvise-2mqz).
DownloadCsv { filename: String },
}
impl ClientFrame {
/// The one-command case, which is most of them.
pub fn command(cmd: Command) -> Self {
Self::Commands(vec![cmd])
}
pub fn key(ev: KeyEvent) -> Self {
Self::Key {
code: ev.code,
modifiers: ev.modifiers,
}
}
}
/// A frame to a client. Three of the four are the shapes `Sessions::apply`
/// hands back (gotcha 39); the fourth answers a frame — `protocol.md` §4.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ServerFrame {
/// Drop everything and start from this: on connect, and again to *every*
/// client on a document swap (gotcha 24). Boxed — see §4.
Snapshot(Box<Snapshot>),
/// Fold these, **in order**: a transport must deliver a batch whole and
/// in sequence, because an overtaking update is dropped (gotcha 33).
Commands(Vec<Command>),
/// Nothing was applied, and here is why. Addressed to the asker alone
/// and carrying no new state — the [`Rejection`] variants are the list.
Rejected(Rejection),
/// The asker's mutating frame landed whole. Carries no state: everything
/// it moved arrived as projections first — `context/code/protocol.md` §4.
Accepted,
/// Downloadable `.improv` bytes addressed to the asker alone; the filename
/// is echoed from its request (improvise-2mqz).
DocumentBytes { filename: String, bytes: Vec<u8> },
/// Downloadable active-view CSV bytes addressed to the asker alone; the
/// filename is echoed from its request (improvise-2mqz).
CsvBytes { filename: String, bytes: Vec<u8> },
}
impl ServerFrame {
pub fn bootstrap(snapshot: Snapshot) -> Self {
Self::Snapshot(Box::new(snapshot))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AxisFrame, GridUpdate, Projection, ViewState};
#[test]
fn a_client_frame_round_trips_through_json() {
for frame in [
ClientFrame::Viewport { rows: 24, cols: 8 },
ClientFrame::command(Command::SetSelected { row: 2, col: 3 }),
ClientFrame::Commands(vec![
Command::SetSelected { row: 9, col: 0 },
Command::SetRowOffset(4),
]),
ClientFrame::key(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
)),
ClientFrame::key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
ClientFrame::Save,
ClientFrame::DownloadDocument {
filename: "doc.improv".into(),
},
ClientFrame::DownloadCsv {
filename: "export.csv".into(),
},
] {
let json = serde_json::to_string(&frame).expect("serializes");
assert_eq!(
serde_json::from_str::<ClientFrame>(&json).expect("round trips"),
frame,
"round trip changed {frame:?}"
);
}
}
#[test]
fn a_server_frame_round_trips_through_json() {
for frame in [
ServerFrame::bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![Projection::Formulas(vec!["Profit = Revenue".into()])],
}),
ServerFrame::Commands(vec![Command::Project(Projection::Grid(
GridUpdate::RowFrame(AxisFrame::flat(["North"])),
))]),
ServerFrame::Rejected(Rejection::LayoutMoved),
ServerFrame::Accepted,
ServerFrame::DocumentBytes {
filename: "test.improv".into(),
bytes: b"v2025-04-09\n# Test\n".to_vec(),
},
ServerFrame::CsvBytes {
filename: "export.csv".into(),
bytes: b"col1,col2\na,b\n".to_vec(),
},
] {
let json = serde_json::to_string(&frame).expect("serializes");
assert_eq!(
serde_json::from_str::<ServerFrame>(&json).expect("round trips"),
frame,
"round trip changed {frame:?}"
);
}
}
/// The *deleted* envelopes' names, which must not resurrect as serde
/// aliases. See `context/test-evidence/protocol.md`.
#[test]
fn the_retired_spellings_do_not_parse() {
let ours = serde_json::to_string(&ServerFrame::Commands(vec![Command::SetStatus(
"saved".into(),
)]))
.expect("serializes");
let renamed = ours.replacen("\"Commands\"", "\"Command\"", 1);
assert!(
serde_json::from_str::<serde_json::Value>(&renamed).is_ok(),
"the injected fault must be a rename, not malformed JSON: {renamed}"
);
for blob in [renamed.as_str(), r#"{"Bootstrap":{"view":null,"seed":[]}}"#] {
let err = serde_json::from_str::<ServerFrame>(blob)
.expect_err("a retired spelling must fail to parse");
assert!(
err.to_string().contains("unknown variant"),
"and it must fail because of the NAME, not because the payload \
happened to be unreadable: {blob} -> {err}"
);
}
// The client half, retired by improvise-bpq2. Spelled as the old
// variant really was — the payload directly, not wrapped in a list.
let old = r#"{"Command":{"SetRowOffset":4}}"#;
assert!(
serde_json::from_str::<serde_json::Value>(old).is_ok(),
"the injected fault must be well-formed JSON: {old}"
);
let err = serde_json::from_str::<ClientFrame>(old)
.expect_err("the retired singleton spelling must fail to parse");
assert!(
err.to_string().contains("unknown variant"),
"and because of the NAME: {err}"
);
}
}