b872923331
Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
183 lines
7.1 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|