//! 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), /// 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 + `` (improvise-2mqz). DownloadDocument { filename: String }, /// Ask for active-view CSV bytes named by ExportPrompt; the browser downloads /// them via Blob + `` (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), /// Fold these, **in order**: a transport must deliver a batch whole and /// in sequence, because an overtaking update is dropped (gotcha 33). Commands(Vec), /// 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 }, /// Downloadable active-view CSV bytes addressed to the asker alone; the /// filename is echoed from its request (improvise-2mqz). CsvBytes { filename: String, bytes: Vec }, } 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::(&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::(&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::(&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::(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::(old).is_ok(), "the injected fault must be well-formed JSON: {old}" ); let err = serde_json::from_str::(old) .expect_err("the retired singleton spelling must fail to parse"); assert!( err.to_string().contains("unknown variant"), "and because of the NAME: {err}" ); } }