Files
improvise/crates/improvise-command/src/ui/host.rs
T
Edward Langley 12171fb335 Merge remote-tracking branch 'gh/main' into wire2/bok4
# Conflicts:
#	crates/improvise-command/src/ui/effect.rs
2026-09-22 21:47:23 -07:00

2064 lines
76 KiB
Rust

//! The session loop: messages in, addressed messages out. One `App` per
//! *document*, many clients per `App` — see `context/code/command.md`.
use crate::ui::app::App;
use crate::ui::effect;
use crate::ui::project::{Applied, Broadcast, Dirty, SessionId, Sessions};
use crate::ui::reduce::{self, Checked, CheckedBatch};
use improvise_protocol::{Command, Rejection};
use improvise_protocol::{ClientFrame, ServerFrame};
/// One outbound frame and the connection it goes to. The host addresses by
/// [`SessionId`]; the transport owns the map from a session to a connection.
#[derive(Debug, Clone, PartialEq)]
pub struct Addressed {
pub to: SessionId,
pub msg: ServerFrame,
}
/// One authoritative document and every client attached to it — a value, never a
/// global. Not `Debug`: `App` is not, and a log line of the document is not wanted.
pub struct Host {
app: App,
sessions: Sessions,
}
impl Host {
pub fn new(app: App) -> Self {
Self {
app,
sessions: Sessions::default(),
}
}
pub fn app(&self) -> &App {
&self.app
}
/// The authoritative `App`, for work the **host itself** initiates. Not a second
/// door: a client's `Command` goes through [`Host::handle`] (gotchas 36, 43).
pub fn app_mut(&mut self) -> &mut App {
&mut self.app
}
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
/// The mirrors, read-only. Not a second door: `&Sessions` exposes no way to
/// fold, so a client's command still arrives through [`Host::handle`].
pub fn sessions(&self) -> &Sessions {
&self.sessions
}
/// Attach a client that has reported an extent, and hand back its bootstrap.
/// Total, and nothing is owed to the other clients.
pub fn connect(&mut self, rows: usize, cols: usize) -> (SessionId, Addressed) {
let (id, snapshot) = self.sessions.connect(&self.app, rows, cols);
(
id,
Addressed {
to: id,
msg: ServerFrame::Snapshot(Box::new(snapshot)),
},
)
}
/// A model command from a client that is fully caught up, for the tests whose
/// subject is not the basis. The staleness tests build their own `Basis`.
#[cfg(test)]
fn model(&mut self, id: SessionId, cmd: improvise_protocol::ModelCommand) -> Vec<Addressed> {
let wire = crate::ui::reduce::caught_up(&self.app, cmd);
self.handle(id, ClientFrame::command(wire))
}
/// Drop a client. Its mirror goes with it; the document does not care.
pub fn disconnect(&mut self, id: SessionId) {
self.sessions.disconnect(id);
}
/// One frame from one client, and everything that owes as a result.
pub fn handle(&mut self, id: SessionId, msg: ClientFrame) -> Vec<Addressed> {
match msg {
ClientFrame::Viewport { rows, cols } => self.resize(id, rows, cols),
ClientFrame::Commands(cmds) => self.commands(id, &cmds),
// Answering nothing is the settled answer, not a gap: host-side key
// resolution diverges (improvise-bpq2, gotcha 47).
ClientFrame::Key { .. } => Vec::new(),
ClientFrame::Save => self.save(id),
}
}
/// Write the document back, because a client asked (improvise-368h). No session
/// state moves and nobody else is told — see `context/code/command.md`.
fn save(&mut self, id: SessionId) -> Vec<Addressed> {
let refused = match self.app.model_state.file_path.is_none() {
true => Some(Rejection::NoDocumentPath),
false => {
self.app.apply_effects(vec![Box::new(effect::Save)]);
self.app
.last_batch_aborted()
.then_some(Rejection::SaveFailed)
}
};
match refused {
Some(reason) => vec![Addressed {
to: id,
msg: ServerFrame::Rejected(reason),
}],
// A save moves the dirty marker and nothing else, and no `EffectKind`
// names that widget (improvise-z754) — so it is projected, not silent.
None => self.project_all(),
}
}
/// Fan out whatever moved, for a host that changed the document outside
/// `handle` — a worker's own persist is the caller (improvise-z754).
pub fn project_all(&mut self) -> Vec<Addressed> {
self.project(Dirty::NOTHING)
}
/// A resize dirties no document state, but it moves this client's viewport, and
/// `project` emits the grid on that alone. Same fan-out, nothing dirty.
fn resize(&mut self, id: SessionId, rows: usize, cols: usize) -> Vec<Addressed> {
match self.sessions.get_mut(id) {
Some(session) => session.set_extent(rows, cols),
// A frame for a session that has already gone: there is no connection
// left to answer.
None => return Vec::new(),
}
self.project(Dirty::NOTHING)
}
/// A batch: **answered whole, then applied whole** (improvise-955n). [`CheckedBatch`]
/// carries what a basis means and the one residue this does not close.
fn commands(&mut self, id: SessionId, cmds: &[Command]) -> Vec<Addressed> {
let batch = match CheckedBatch::against(&self.app, cmds) {
Ok(batch) => batch,
// Addressed to the asker alone, exactly as a single command's
// refusal is: nothing happened, so nobody else is owed anything.
Err(reason) => {
return vec![Addressed {
to: id,
msg: ServerFrame::Rejected(reason),
}];
}
};
// Only a mutation can fail, so a frame of pure view commands pays for
// neither the snapshot nor the checks — `context/code/command.md`.
match cmds.iter().any(|c| matches!(c, Command::Model { .. })) {
true => self.mutating_frame(id, batch),
false => batch
.into_iter()
.flat_map(|cmd| self.command(id, cmd))
.collect(),
}
}
/// Clause 4: the document AND every mirror go back if any of it fails, so
/// nothing a failed frame did is observable — `context/code/command.md`.
fn mutating_frame(&mut self, id: SessionId, batch: CheckedBatch<'_>) -> Vec<Addressed> {
let undo = self.app.snapshot();
let mirrors = self.sessions.clone();
let mut out = Vec::new();
for cmd in batch {
if let Some(reason) = reduce::broken_chain(&self.app, cmd.command()) {
return self.undo_frame(id, undo, mirrors, reason);
}
// Only a mutation runs `apply_effects`, which resets both flags — so
// after anything else they are STALE. A refusal need not abort (cz8d).
let mutation = matches!(cmd.command(), Command::Model { .. });
out.extend(self.command(id, cmd));
let failed = self.app.last_batch_refused() || self.app.last_batch_aborted();
if mutation && failed {
return self.undo_frame(id, undo, mirrors, Rejection::MutationRefused);
}
}
// Last, after everything the frame owed: a client folds the answer to
// discard its undo, and the projections describe what it is discarding.
out.push(Addressed {
to: id,
msg: ServerFrame::Accepted,
});
out
}
/// Put the document and every mirror back, and tell the asker alone: the
/// frame did nothing, so nobody else is owed anything (ADR 001 clause 4).
fn undo_frame(
&mut self,
id: SessionId,
undo: crate::ui::app::AppSnapshot,
mirrors: Sessions,
reason: Rejection,
) -> Vec<Addressed> {
self.app.restore(undo);
self.sessions = mirrors;
vec![Addressed {
to: id,
msg: ServerFrame::Rejected(reason),
}]
}
/// The one door (gotcha 36); the match that follows is the mechanism (gotcha 39):
/// asking for the `Dirty` means naming the case where a `Snapshot` is owed.
fn command(&mut self, id: SessionId, cmd: Checked<'_>) -> Vec<Addressed> {
match self.sessions.apply_checked(&mut self.app, id, cmd) {
// The document was replaced: nothing to diff and nothing to broadcast,
// each snapshot carrying a whole view slice and its seed.
Applied::Rebased { snapshots } => snapshots
.into_iter()
.map(|(to, snapshot)| Addressed {
to,
msg: ServerFrame::Snapshot(Box::new(snapshot)),
})
.collect(),
Applied::Diff { dirty, broadcast } => {
// The broadcast precedes the projections, so a client's slice is in
// the state the grid it is about to get was built from (gotcha 36).
let mut out = self.relay(broadcast);
out.extend(self.project(dirty));
out
}
// Unreachable through this door since improvise-955n, but a host that
// wants the `Dirty` must name every case (gotcha 39).
Applied::Rejected { reason } => vec![Addressed {
to: id,
msg: ServerFrame::Rejected(reason),
}],
}
}
/// Who hears a relayed command is the relay's own business: [`Broadcast`] names
/// its audience and the match is what makes the host honour it (improvise-gvnb).
fn relay(&self, broadcast: Vec<Broadcast>) -> Vec<Addressed> {
broadcast
.into_iter()
.flat_map(|item| {
let (cmd, only) = match item {
Broadcast::ToAll(cmd) => (cmd, None),
Broadcast::To(id, cmd) => (cmd, Some(id)),
};
self.sessions
.ids()
.into_iter()
.filter(move |id| only.is_none_or(|only| only == *id))
.map(move |to| Addressed {
to,
msg: ServerFrame::Commands(vec![cmd.clone()]),
})
})
.collect()
}
/// Drain the outbound projections. Clients with nothing to hear are omitted by
/// `project_all` and so get no frame.
fn project(&mut self, dirty: Dirty) -> Vec<Addressed> {
self.sessions
.project_all(&self.app, dirty)
.into_iter()
.map(|(to, projections)| Addressed {
to,
msg: ServerFrame::Commands(projections.into_iter().map(Command::Project).collect()),
})
.collect()
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use crate::storage::MemoryFs;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::record::RecordHash;
use improvise_core::view::Axis;
use improvise_core::workbook::Workbook;
use improvise_protocol::RecordCell;
use improvise_protocol::{
GridCache, GridUpdate, ModelCommand, Projection, ViewState, WidgetCache, reduce_view,
};
use super::*;
fn workbook() -> Workbook {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
for item in ["North", "South"] {
wb.add_item("Region", item).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb
}
fn host() -> Host {
let mut app = App::new(workbook(), None);
app.rebuild_layout();
Host::new(app)
}
/// A host whose document can be replaced out from under its clients: a second
/// document in the storage backend under a name `LoadModel` can reach.
fn host_over_a_loadable_document() -> (Host, PathBuf) {
let storage = MemoryFs::new();
let mut other = Workbook::new("Other");
other.add_category("Product").unwrap();
other.add_item("Product", "Widget").unwrap();
other.active_view_mut().set_axis("Product", Axis::Row);
// The incoming view is RENAMED, so a rolled-back load that leaves
// `active_view` behind names a view neither document has.
let mut view = other.active_view().clone();
view.name = "Loaded".to_string();
other.views.clear();
other.views.insert("Loaded".to_string(), view);
other.active_view = "Loaded".to_string();
crate::persistence::save_to(&storage, &other, Path::new("other.improv"))
.expect("the fixture document must save");
let mut app = App::new(workbook(), None);
app.storage = Box::new(storage);
app.rebuild_layout();
(Host::new(app), PathBuf::from("other.improv"))
}
fn cell(region: &str) -> CellKey {
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
])
}
/// The same cell as a client names it: the projected surrogate, which is the
/// only handle a client has (ADR 002). A `CellKey` is not constructible there.
fn addressed(region: &str) -> RecordCell {
RecordCell {
record: RecordHash::of(&cell(region)),
measure: Some("Revenue".to_string()),
}
}
/// The basis a caught-up client states for a **frame** naming `regions`. It
/// fabricates `(hash, 0)` for a record that is absent — see the evidence.
fn frame_basis(host: &Host, regions: &[&str]) -> improvise_protocol::Basis {
improvise_protocol::Basis {
document_generation: host.app().document_generation(),
layout_version: host.app().layout_version(),
records: regions
.iter()
.map(|r| {
let hash = addressed(r).record;
(hash, host.app().record_version_of(hash))
})
.collect(),
}
}
/// Everything one client was told, in arrival order.
fn to(frames: &[Addressed], id: SessionId) -> Vec<ServerFrame> {
frames
.iter()
.filter(|f| f.to == id)
.map(|f| f.msg.clone())
.collect()
}
/// The grid as a client that folds `frames` would hold it.
fn folded_grid(seed: &[ServerFrame]) -> GridCache {
let mut grid = GridCache::default();
for msg in seed {
match msg {
ServerFrame::Snapshot(s) => {
grid = GridCache::default();
for projection in &s.seed {
if let Projection::Grid(u) = projection {
grid.apply(u);
}
}
}
ServerFrame::Commands(cmds) => {
for cmd in cmds {
if let Command::Project(Projection::Grid(u)) = cmd {
grid.apply(u);
}
}
}
// Neither answer carries state: one applied nothing, and what
// the other applied arrived as projections before it.
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
}
}
grid
}
fn cell_text(grid: &GridCache, row: usize, col: usize) -> String {
grid.cells
.get(row)
.and_then(|r| r.get(col))
.map(|c| c.text.clone())
.unwrap_or_default()
}
// ── connect ─────────────────────────────────────────────────────────
/// AC 2: the bootstrap is sent on connect, addressed to the new client, and it
/// is **not empty** — an empty seed would satisfy "a snapshot was sent".
#[test]
fn connecting_hands_that_client_a_snapshot_that_draws_the_document() {
let mut host = host();
let (id, frame) = host.connect(10, 10);
assert_eq!(frame.to, id);
let ServerFrame::Snapshot(snapshot) = &frame.msg else {
panic!("connect did not send a Snapshot: {:?}", frame.msg);
};
assert_eq!(snapshot.view.active_view, host.app().view_state.active_view);
let grid = folded_grid(std::slice::from_ref(&frame.msg));
assert_eq!(
grid.row_frame.leaf_labels(),
vec!["North".to_string(), "South".to_string()],
"the seed did not carry the document's rows"
);
}
/// The extent is what the snapshot is built at, which is what makes `Viewport`
/// the handshake rather than an afterthought.
#[test]
fn the_reported_extent_bounds_the_snapshot() {
let mut host = host();
let (_, frame) = host.connect(1, 1);
assert_eq!(folded_grid(&[frame.msg]).row_frame.len(), 1);
}
/// A resize with no document change still owes this client a grid: the
/// viewport moved even though nothing is dirty.
#[test]
fn a_resize_re_emits_the_grid_at_the_new_extent() {
let mut host = host();
let (id, connect) = host.connect(1, 1);
let frames = host.handle(id, ClientFrame::Viewport { rows: 2, cols: 2 });
let mut seen = vec![connect.msg];
seen.extend(to(&frames, id));
assert_eq!(
folded_grid(&seen).row_frame.leaf_labels(),
vec!["North".to_string(), "South".to_string()],
"growing the window did not deliver the row that came into view"
);
}
// ── a command round-trips ───────────────────────────────────────────
/// AC 3/5: a `SetCell` in, the value back out as a grid projection.
#[test]
fn a_set_cell_round_trips_to_a_grid_projection() {
let mut host = host();
let (id, connect) = host.connect(10, 10);
let frames = host.model(
id,
ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(42.0),
},
);
let mut seen = vec![connect.msg];
seen.extend(to(&frames, id));
assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "42.00");
}
/// `doctrine(check_the_checker)`: inject the fault — never send the command —
/// and the assertion above must fail.
#[test]
fn without_the_command_the_cell_is_empty() {
let mut host = host();
let (_, connect) = host.connect(10, 10);
assert_eq!(cell_text(&folded_grid(&[connect.msg]), 0, 0), "");
}
/// A cell edit is the whole document's, so a client that did not ask is told
/// too. The projection is per-session because viewports differ.
#[test]
fn a_bystander_is_projected_another_clients_edit() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let (bystander, bootstrap) = host.connect(10, 10);
let frames = host.model(
asker,
ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(7.0),
},
);
let mut seen = vec![bootstrap.msg];
seen.extend(to(&frames, bystander));
assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "7.00");
}
/// A view-only command reaches nobody else — the counterpart that keeps the
/// test above from passing for the wrong reason.
#[test]
fn a_scroll_reaches_only_the_client_that_scrolled() {
let mut host = host();
let (asker, _) = host.connect(1, 10);
let (bystander, _) = host.connect(1, 10);
let frames = host.handle(asker, ClientFrame::command(Command::SetRowOffset(1)));
assert!(
!to(&frames, asker).is_empty(),
"the scroller was not sent the rows it scrolled to"
);
assert!(
to(&frames, bystander).is_empty(),
"a scroll was fanned out to a client it says nothing about"
);
}
/// One keypress is up to three commands and the host must take all of them
/// (improvise-bpq2). The assertion is the *offset*, not the selection.
#[test]
fn a_batch_of_commands_is_applied_whole_and_in_order() {
let mut host = host();
let (asker, _) = host.connect(1, 10);
host.handle(
asker,
ClientFrame::Commands(vec![
Command::SetSelected { row: 2, col: 0 },
Command::SetRowOffset(2),
]),
);
let mirror = host.sessions.get_mut(asker).unwrap().view().cursor();
assert_eq!(mirror.selected, (2, 0));
assert_eq!(
mirror.row_offset, 2,
"the host folded the selection but not the scroll that followed \
it, so it will project this client the wrong window"
);
}
/// A refused batch applies nothing *after* the refusal. Nothing *before* it is
/// the next test; the two halves fail independently.
#[test]
fn a_batch_is_abandoned_at_the_first_rejection() {
use improvise_protocol::{Basis, ModelCommand};
let mut host = host();
let (asker, _) = host.connect(10, 10);
let stale = Command::Model {
basis: Basis {
document_generation: host.app().document_generation(),
layout_version: host.app().layout_version() + 1,
records: Vec::new(),
},
cmd: ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(42.0),
},
};
host.handle(
asker,
ClientFrame::Commands(vec![stale, Command::SetStatus("after".into())]),
);
assert_ne!(
host.sessions.get_mut(asker).unwrap().view().status_msg,
"after",
"a command after a refused one was applied, so the batch ran on \
past a basis the document had just contradicted"
);
}
/// improvise-955n/08wb: `Checked` is per **command**, so a frame whose second
/// command was refused had already applied its first. See the evidence.
#[test]
fn a_refused_batch_leaves_the_commands_before_it_unapplied() {
use improvise_protocol::ModelCommand;
let edit = |value: f64| ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(value),
};
let mut refused = host();
let (asker, _) = refused.connect(10, 10);
// Built from `caught_up` and then bumped in a single tier, so the refusal
// can only be about the tier that moved.
let Command::Model { mut basis, cmd } =
crate::ui::reduce::caught_up(refused.app(), edit(42.0))
else {
unreachable!("caught_up builds a Command::Model")
};
basis.document_generation += 1;
let stale = Command::Model { basis, cmd };
let answer = refused.handle(
asker,
ClientFrame::Commands(vec![Command::SetSelected { row: 1, col: 0 }, stale]),
);
assert_eq!(
refused
.sessions
.get_mut(asker)
.unwrap()
.view()
.cursor()
.selected,
(0, 0),
"the command before the refused one reached the mirror, so the \
batch was applied partially"
);
assert!(
matches!(
to(&answer, asker).as_slice(),
[ServerFrame::Rejected(Rejection::DocumentReplaced)]
),
"and the answer must be the refusal and nothing else: {answer:?}"
);
// The other history: the same leading command, a basis that is
// current, and both must land.
let mut accepted = host();
let (asker, _) = accepted.connect(10, 10);
let fresh = crate::ui::reduce::caught_up(accepted.app(), edit(7.0));
accepted.handle(
asker,
ClientFrame::Commands(vec![Command::SetSelected { row: 1, col: 0 }, fresh]),
);
assert_eq!(
accepted
.sessions
.get_mut(asker)
.unwrap()
.view()
.cursor()
.selected,
(1, 0),
"an accepted batch must still apply its earlier commands"
);
assert_eq!(
accepted
.app()
.model_state
.workbook
.model
.get_cell(&cell("North")),
Some(&CellValue::Number(7.0)),
);
}
/// improvise-955n: hoisting the basis check to the frame hoisted *all* of it,
/// and the record tier does not hoist. A lost update — see the evidence.
#[test]
fn a_later_write_in_a_chain_still_answers_for_its_own_record() {
use improvise_protocol::ModelCommand;
let write = |region: &str, value: f64| ModelCommand::SetCell {
cell: addressed(region),
value: CellValue::Number(value),
};
// ── arm 1: the second record moved under the frame ──────────────
let mut moved = host();
let (a, _) = moved.connect(10, 10);
let (b, _) = moved.connect(10, 10);
// A seeds both records so its basis can state a counter for each,
// then composes a frame naming both.
moved.model(a, write("North", 1.0));
moved.model(a, write("South", 2.0));
let basis = frame_basis(&moved, &["North", "South"]);
let frame = vec![
Command::Model {
basis: basis.clone(),
cmd: write("North", 10.0),
},
Command::Model {
basis,
cmd: write("South", 20.0),
},
];
// B moves South. `SetCell` is `CellData`, so only the record tier can
// discriminate here — which is what makes this a test of that tier.
moved.model(b, write("South", 99.0));
let answer = moved.handle(a, ClientFrame::Commands(frame));
assert!(
to(&answer, a)
.iter()
.any(|f| matches!(f, ServerFrame::Rejected(Rejection::RecordMoved(_)))),
"A's frame names a record B has moved and must be refused: {answer:?}"
);
assert_eq!(
moved
.app()
.model_state
.workbook
.model
.get_cell(&cell("South")),
Some(&CellValue::Number(99.0)),
"B's write must survive — this is the lost update the tier exists for"
);
assert_eq!(
moved
.app()
.model_state
.workbook
.model
.get_cell(&cell("North")),
Some(&CellValue::Number(1.0)),
"and the refusal is whole-frame: the *first* command must not have \
landed either"
);
// ── arm 2: nothing moved, so a multi-record frame lands ─────────
let mut fresh = host();
let (asker, _) = fresh.connect(10, 10);
fresh.model(asker, write("North", 1.0));
fresh.model(asker, write("South", 2.0));
let basis = frame_basis(&fresh, &["North", "South"]);
let answer = fresh.handle(
asker,
ClientFrame::Commands(vec![
Command::Model {
basis: basis.clone(),
cmd: write("North", 10.0),
},
Command::Model {
basis,
cmd: write("South", 20.0),
},
]),
);
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"a frame whose records are all current must land: {answer:?}"
);
assert_eq!(
fresh
.app()
.model_state
.workbook
.model
.get_cell(&cell("South")),
Some(&CellValue::Number(20.0)),
);
// ── arm 3: the same record twice in one chain ───────────────────
// **Read the evidence before closing clause 4**: interleaving sells this.
let mut twice = host();
let (asker, _) = twice.connect(10, 10);
twice.model(asker, write("North", 1.0));
let basis = frame_basis(&twice, &["North"]);
let answer = twice.handle(
asker,
ClientFrame::Commands(vec![
Command::Model {
basis: basis.clone(),
cmd: write("North", 10.0),
},
Command::Model {
basis,
cmd: write("North", 11.0),
},
]),
);
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"a chain may write one record twice: {answer:?}"
);
assert_eq!(
twice
.app()
.model_state
.workbook
.model
.get_cell(&cell("North")),
Some(&CellValue::Number(11.0)),
"and the last write in the chain is the one that stands"
);
}
/// **The capability the contract is for**: a data-dependent chain, in order.
/// The second assertion is the one that earns it — see the evidence.
#[test]
fn a_data_dependent_chain_lands_in_order() {
use improvise_protocol::ModelCommand;
let mut host = host();
let (asker, _) = host.connect(10, 10);
let add = crate::ui::reduce::caught_up(
host.app(),
ModelCommand::AddItem {
category: "Region".into(),
item: "East".into(),
},
);
let write = crate::ui::reduce::caught_up(
host.app(),
ModelCommand::SetCell {
cell: addressed("East"),
value: CellValue::Number(9.0),
},
);
let answer = host.handle(asker, ClientFrame::Commands(vec![add, write]));
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"a data-dependent chain must not be refused: {answer:?}"
);
assert!(
host.app()
.model_state
.workbook
.model
.category("Region")
.is_some_and(|c| c.ordered_item_names().contains(&"East")),
"the first command must land"
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("East")),
Some(&CellValue::Number(9.0)),
"and the second must land on top of it — a write to a record the \
frame itself created is the whole point"
);
}
/// ADR 001 clause 4. The inversion of the hole this characterized until
/// improvise-yqpd — see `context/test-evidence/command.md`.
#[test]
fn a_frame_whose_later_mutation_fails_is_undone_whole() {
use improvise_protocol::ModelCommand;
let mut host = host();
let (asker, _) = host.connect(10, 10);
host.model(
asker,
ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(1.0),
},
);
// The frame claims North at its current counter, so it passes the check and
// fails at *apply* time — the only way to reach the hole.
let before_version = host.app().record_version_of(addressed("North").record);
let basis = frame_basis(&host, &["North"]);
let answer = host.handle(
asker,
ClientFrame::Commands(vec![
Command::Model {
basis: basis.clone(),
cmd: ModelCommand::RemoveItem {
category: "Region".into(),
item: "North".into(),
},
},
Command::Model {
basis,
cmd: ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(5.0),
},
},
]),
);
assert!(
host.app()
.model_state
.workbook
.model
.category("Region")
.is_some_and(|c| c.ordered_item_names().contains(&"North")),
"the first command must be UNDONE: the frame failed, so the base \
it was composed against is what the document must read as"
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("North")),
Some(&CellValue::Number(1.0)),
"and the cell reads its PRE-FRAME value — not 5.0 (the frame's \
own write landing) and not None (the removal surviving). This is \
the assertion that separates a rollback from an abort."
);
assert_eq!(
host.app().record_version_of(addressed("North").record),
before_version,
"the record tier goes back too, or the client's next batch is \
refused for a write that never happened"
);
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::ChainBroken(_)))),
"and the frame is refused OUT LOUD, naming the chain it broke: {answer:?}"
);
}
/// The rollback reaches the MIRRORS, not only the document — the cost the
/// ticket called its least obvious. See `context/test-evidence/command.md`.
#[test]
fn a_failed_frame_puts_the_askers_mirror_back_on_the_view_it_was_on() {
use improvise_protocol::ModelCommand;
let mut host = host();
let (asker, _) = host.connect(10, 10);
host.app_mut()
.model_state
.workbook
.views
.insert("Second".to_string(), crate::view::View::new("Second"));
host.model(
asker,
ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(1.0),
},
);
let was = host
.sessions
.get_mut(asker)
.map(|s| s.view().active_view.clone());
assert_eq!(
was,
Some("Default".to_string()),
"the fixture must start somewhere the switch can move it FROM, or \
the assertion below holds for a mirror nothing ever wrote"
);
// A switch moves ONLY the asker's mirror and is relayed to nobody
// (gotcha 36), so nothing in the stream can ever correct a stale one.
let basis = frame_basis(&host, &["North"]);
host.handle(
asker,
ClientFrame::Commands(vec![
Command::SwitchView("Second".into()),
Command::Model {
basis: basis.clone(),
cmd: ModelCommand::RemoveItem {
category: "Region".into(),
item: "North".into(),
},
},
Command::Model {
basis,
cmd: ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(5.0),
},
},
]),
);
assert_eq!(
host.sessions
.get_mut(asker)
.map(|s| s.view().active_view.clone()),
was,
"the switch is part of the frame, so a refused frame leaves the \
mirror where it started — and an accepted switch is relayed to \
nobody, so a surviving one diverges from the client permanently"
);
}
/// The `MutationRefused` arm, and the batch-outcome flags going back with
/// the rest. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_refused_mutation_rolls_back_its_predecessor_and_the_abort_flag() {
use improvise_protocol::ModelCommand;
let mut host = host();
// Fill to MAX_CATEGORIES, so the frame's second command is refused by
// `Model::add_category` — a DUPLICATE add is idempotent and vacuous.
for n in 0..11 {
host.app_mut()
.model_state
.workbook
.add_category(format!("Filler{n}"))
.expect("under the limit");
}
host.app_mut().rebuild_layout();
let (asker, _) = host.connect(10, 10);
let was_aborted = host.app().last_batch_aborted();
// The predecessor names no record, so the record tier cannot refuse the
// frame first and hide the arm this test is about.
let add_item = crate::ui::reduce::caught_up(
host.app(),
ModelCommand::AddItem {
category: "Region".into(),
item: "West".into(),
},
);
let overflow =
crate::ui::reduce::caught_up(host.app(), ModelCommand::AddCategory("Overflow".into()));
let answer = host.handle(asker, ClientFrame::Commands(vec![add_item, overflow]));
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::MutationRefused))),
"the fixture must reach the refusal arm: {answer:?}"
);
assert!(
!host
.app()
.model_state
.workbook
.model
.category("Region")
.is_some_and(|c| c.ordered_item_names().contains(&"West")),
"the item added before the refusal must be undone"
);
assert_eq!(
host.app().last_batch_aborted(),
was_aborted,
"and `batch_aborted` goes back with everything else — a rolled-back \
frame must leave no trace a later frame could read"
);
}
/// Clause 4 reaches the host's own session slice, not only the document.
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn a_rolled_back_load_leaves_no_ghost_view_and_no_false_status() {
use improvise_protocol::ModelCommand;
let (mut host, path) = host_over_a_loadable_document();
let (asker, _) = host.connect(10, 10);
let was_view = host.app().view_state.active_view.clone();
let was_status = host.app().view_state.status_msg.clone();
let basis = frame_basis(&host, &["North"]);
assert!(
host.app()
.resolve_record(addressed("North").record)
.is_some(),
"North must resolve BEFORE the load, or a refusal naming it proves nothing"
);
// The load lands, then the write names a record the incoming document
// has never had — so the frame fails at its second command.
let answer = host.handle(
asker,
ClientFrame::Commands(vec![
Command::Model {
basis: basis.clone(),
cmd: ModelCommand::LoadModel(path),
},
Command::Model {
basis,
cmd: ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(5.0),
},
},
]),
);
assert_eq!(
to(&answer, asker),
vec![the_load_ran_then_the_write_broke()],
"only a load that RAN can refuse the write this way: {answer:?}"
);
assert_eq!(
host.app().view_state.active_view,
was_view,
"a rolled-back load must not leave `active_view` naming a view of \
the document that was discarded — the fixture renames the \
incoming view precisely so this cannot pass by accident"
);
assert!(
host.app()
.model_state
.workbook
.views
.contains_key(&host.app().view_state.active_view),
"and whatever it names, the document must have it"
);
assert_eq!(
host.app().view_state.status_msg,
was_status,
"a frame that did nothing must not claim it loaded anything"
);
}
/// `North` resolves in the outgoing document, so only `broken_chain` AFTER the
/// load applied can answer this. Evidence: `context/test-evidence/command.md`.
fn the_load_ran_then_the_write_broke() -> ServerFrame {
ServerFrame::Rejected(Rejection::ChainBroken(addressed("North").record))
}
/// Everything one client was told, flattened to the projections it carries.
fn projections(frames: &[Addressed], id: SessionId) -> Vec<Projection> {
to(frames, id)
.into_iter()
.flat_map(|msg| match msg {
ServerFrame::Commands(cmds) => cmds,
_ => Vec::new(),
})
.filter_map(|cmd| match cmd {
Command::Project(p) => Some(p),
_ => None,
})
.collect()
}
/// The control for the test below: the fixture's load really does rebase,
/// so a rollback of it has something to undo.
#[test]
fn the_load_this_fixture_performs_rebases_every_client() {
let (mut host, path) = host_over_a_loadable_document();
let (asker, _) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let was = host.app().document_generation();
let answer = host.model(asker, ModelCommand::LoadModel(path));
assert_eq!(
host.app().document_generation(),
was + 1,
"the fixture's load did not replace the document: {answer:?}"
);
for (who, id) in [("asker", asker), ("bystander", bystander)] {
assert!(
to(&answer, id)
.iter()
.any(|m| matches!(m, ServerFrame::Snapshot(_))),
"{who} was owed a fresh Snapshot by the rebase: {answer:?}"
);
}
}
/// Clause 4 over the `Applied::Rebased` path: the Snapshots were computed
/// and are discarded. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_rolled_back_load_tells_nobody_and_puts_every_mirror_back() {
let (mut host, path) = host_over_a_loadable_document();
let (asker, _) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let was_generation = host.app().document_generation();
let basis = frame_basis(&host, &["North"]);
assert!(
host.app()
.resolve_record(addressed("North").record)
.is_some(),
"North must resolve BEFORE the load, or a refusal naming it proves nothing"
);
// The load lands and rebases every mirror; the write then names a
// record the incoming document has never had, so the frame fails.
let answer = host.handle(
asker,
ClientFrame::Commands(vec![
Command::Model {
basis: basis.clone(),
cmd: ModelCommand::LoadModel(path),
},
Command::Model {
basis,
cmd: ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(5.0),
},
},
]),
);
assert_eq!(
to(&answer, asker),
vec![the_load_ran_then_the_write_broke()],
"only a load that RAN can refuse the write this way: {answer:?}"
);
assert_eq!(
host.app().document_generation(),
was_generation,
"the document did not go back, so nothing below is about a rollback"
);
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Snapshot(_))),
"a rolled-back load let its Snapshots out: {answer:?}"
);
assert!(
to(&answer, bystander).is_empty(),
"a client that asked for nothing was told about a frame that did \
nothing: {answer:?}"
);
// Nothing moved, so the next fan-out owes nobody anything. An
// unrestored mirror is corrected here instead — test-evidence.
let after = host.project_all();
for (who, id) in [("asker", asker), ("bystander", bystander)] {
assert!(
projections(&after, id).is_empty(),
"{who}'s mirror was left describing the discarded document, so \
the next projection corrects it: {:?}",
projections(&after, id)
);
}
}
/// `batch_aborted` is reset only inside `apply_effects`, so a frame that
/// runs none reads a STALE flag. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_frame_of_view_commands_is_not_refused_by_an_earlier_batchs_abort() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
// A save with no path aborts (gotcha 21), leaving the flag raised on
// the `App` with nothing in a later frame to reset it.
host.app_mut().apply_effects(vec![Box::new(effect::Save)]);
assert!(
host.app().last_batch_aborted(),
"the fixture must actually leave the flag raised, or this test \
asserts nothing"
);
let answer = host.handle(
asker,
ClientFrame::Commands(vec![Command::SetSelected { row: 1, col: 0 }]),
);
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"a frame that runs no effects cannot have aborted — reading the \
flag unconditionally refuses an innocent frame: {answer:?}"
);
// The MIXED frame, which frame-level gating alone does not reach: the
// view command runs no effects, so the flag is still the stale one.
host.app_mut().apply_effects(vec![Box::new(effect::Save)]);
let mixed = host.handle(
asker,
ClientFrame::Commands(vec![
Command::SetSelected { row: 0, col: 0 },
crate::ui::reduce::caught_up(
host.app(),
improvise_protocol::ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(3.0),
},
),
]),
);
assert!(
!mixed
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"the abort must be read only after a command that RAN effects: {mixed:?}"
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("North")),
Some(&CellValue::Number(3.0)),
"and the mutation behind the view command must have landed"
);
}
/// The exemption belongs at CHECK time and not at APPLY time — found by
/// review. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_frame_whose_write_would_land_nowhere_is_refused_rather_than_dropped() {
use improvise_protocol::ModelCommand;
let mut host = host();
let (asker, _) = host.connect(10, 10);
// Created and then hidden by the frame itself, so at the write's own
// turn the record resolves nowhere and `resolve_cell` returns `None`.
let add = crate::ui::reduce::caught_up(
host.app(),
ModelCommand::AddItem {
category: "Region".into(),
item: "West".into(),
},
);
let hide = crate::ui::reduce::caught_up(
host.app(),
ModelCommand::HideItem {
view: "Default".into(),
category: "Region".into(),
item: "West".into(),
},
);
let write = crate::ui::reduce::caught_up(
host.app(),
ModelCommand::SetCell {
cell: addressed("West"),
value: CellValue::Number(7.0),
},
);
let answer = host.handle(asker, ClientFrame::Commands(vec![add, hide, write]));
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::ChainBroken(_)))),
"the write cannot land, so the frame must be refused OUT LOUD — \
reported accepted it is the partial-and-silent failure clause 4 \
exists to eliminate: {answer:?}"
);
assert!(
!host
.app()
.model_state
.workbook
.model
.category("Region")
.is_some_and(|c| c.ordered_item_names().contains(&"West")),
"and the item the frame added is rolled back with the rest of it"
);
}
/// Clause 4 must not fire on a frame that merely names a record it is
/// creating. See `context/test-evidence/command.md`.
#[test]
fn a_frame_that_creates_the_record_it_writes_is_not_a_broken_chain() {
use improvise_protocol::ModelCommand;
let mut host = host();
let (asker, _) = host.connect(10, 10);
let add = crate::ui::reduce::caught_up(
host.app(),
ModelCommand::AddItem {
category: "Region".into(),
item: "West".into(),
},
);
let write = crate::ui::reduce::caught_up(
host.app(),
ModelCommand::SetCell {
cell: addressed("West"),
value: CellValue::Number(7.0),
},
);
let answer = host.handle(asker, ClientFrame::Commands(vec![add, write]));
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"the exemption is a property — a record neither resolving nor \
claimed is one the frame creates: {answer:?}"
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("West")),
Some(&CellValue::Number(7.0)),
"and clause 4 must not have rolled it back"
);
}
// ── the broadcast ───────────────────────────────────────────────────
/// Which view a client is on is that client's (improvise-jytm): an accepted
/// switch reaches nobody, not even back to the asker that folded it.
#[test]
fn an_accepted_switch_is_relayed_to_nobody() {
let mut host = host();
host.app.model_state.workbook.create_view("Second");
let (asker, _) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let frames = host.handle(
asker,
ClientFrame::command(Command::SwitchView("Second".into())),
);
let relay = ServerFrame::Commands(vec![Command::SwitchView("Second".into())]);
for (who, id) in [("asker", asker), ("bystander", bystander)] {
assert!(
!to(&frames, id).contains(&relay),
"the {who} was relayed a switch it is not owed: {frames:?}"
);
}
assert!(
to(&frames, bystander).is_empty(),
"a bystander was repainted for somebody else's switch: {frames:?}"
);
}
/// The other audience, and the reason [`Broadcast`] carries one: a geometry
/// change invalidates every cursor, the asker's included (improvise-gvnb).
#[test]
fn a_geometry_command_relays_the_cursor_reset_to_the_asker_too() {
use improvise_protocol::ModelCommand;
let mut host = host();
let (asker, _) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let view = host.app().view_name().to_string();
let frames = host.model(asker, ModelCommand::TransposeAxes { view });
let reset = ServerFrame::Commands(vec![Command::ResetCursor]);
for (who, id) in [("asker", asker), ("bystander", bystander)] {
assert!(
to(&frames, id).contains(&reset),
"the {who} was not told its cursor is invalid: {frames:?}"
);
}
}
/// A refused batch is answered to the **asker and nobody else** (ADR 001):
/// nothing happened, so there is nothing to tell the others.
#[test]
fn a_stale_batch_is_refused_to_the_asker_alone() {
use improvise_protocol::{Basis, Rejection};
let mut host = host();
let (asker, _) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let stale = Command::Model {
basis: Basis {
document_generation: host.app().document_generation(),
// The one thing moved: this client looked at a shape the
// document has left behind.
layout_version: host.app().layout_version() + 1,
records: Vec::new(),
},
cmd: ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(42.0),
},
};
let frames = host.handle(asker, ClientFrame::command(stale));
assert_eq!(
to(&frames, asker),
vec![ServerFrame::Rejected(Rejection::LayoutMoved)]
);
assert!(
to(&frames, bystander).is_empty(),
"a batch that did nothing was fanned out to somebody: {frames:?}"
);
}
/// What the [`Broadcast::To`] audience is *for*: a client that folded a
/// name the document refused is put back, through the host (improvise-6c6q).
#[test]
fn folding_the_relay_puts_a_refused_asker_back() {
let mut host = host();
host.app.model_state.workbook.create_view("Second");
let (asker, bootstrap) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let ServerFrame::Snapshot(snapshot) = bootstrap.msg else {
panic!("connect did not send a Snapshot");
};
let mut client: ViewState = snapshot.view;
let asked = Command::SwitchView("Nonexistent".into());
reduce_view(&mut client, &asked);
assert_eq!(client.active_view, "Nonexistent", "the asker folded it");
let frames = host.handle(asker, ClientFrame::command(asked));
for msg in to(&frames, asker) {
if let ServerFrame::Commands(cmds) = msg {
for cmd in &cmds {
reduce_view(&mut client, cmd);
}
}
}
assert_eq!(client.active_view, "Default");
assert!(
to(&frames, bystander).is_empty(),
"a bystander heard about somebody else's refused switch: {frames:?}"
);
}
// ── the document swap ───────────────────────────────────────────────
/// AC of gotcha 39: a command that replaced the document owes *every* client a
/// fresh `Snapshot`, not a diff — including the client that did not ask.
#[test]
fn a_document_swap_sends_every_client_a_fresh_snapshot() {
let (mut host, path) = host_over_a_loadable_document();
let (asker, _) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let frames = host.model(asker, ModelCommand::LoadModel(path));
for id in [asker, bystander] {
let msgs = to(&frames, id);
assert!(
msgs.iter().any(|m| matches!(m, ServerFrame::Snapshot(_))),
"client {id:?} was not rebased: {msgs:?}"
);
assert_eq!(
folded_grid(&msgs).row_frame.leaf_labels(),
vec!["Widget".to_string()],
"client {id:?} was rebased onto the wrong document"
);
}
}
/// `doctrine(check_the_checker)`: inject the fault — a load that does not
/// happen — and the snapshot must not appear.
#[test]
fn a_load_that_fails_is_not_a_rebase() {
let (mut host, _) = host_over_a_loadable_document();
let (asker, _) = host.connect(10, 10);
let frames = host.model(
asker,
ModelCommand::LoadModel(PathBuf::from("absent.improv")),
);
assert!(
!frames
.iter()
.any(|f| matches!(f.msg, ServerFrame::Snapshot(_))),
"a failed load cost every client its cursors: {frames:?}"
);
// Since improvise-bok4 a failed load is REFUSED, so the refusal is the
// witness that it failed; the host's status line is rolled back with it.
assert_eq!(
to(&frames, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"the fixture did not actually fail the load"
);
}
// ── saving ──────────────────────────────────────────────────────────
/// A host over `MemoryFs` that already knows where its document lives,
/// so a client's `Save` has somewhere to land.
fn host_with_a_path() -> (Host, PathBuf) {
let path = PathBuf::from("doc.improv");
let mut app = App::new(workbook(), Some(path.clone()));
app.storage = Box::new(MemoryFs::new());
app.rebuild_layout();
(Host::new(app), path)
}
/// What is actually on this host's storage, if anything.
fn stored(host: &Host, path: &Path) -> Option<String> {
host.app()
.storage
.read(path)
.ok()
.map(|bytes| String::from_utf8(bytes).expect("the fixture writes text"))
}
/// improvise-368h: `effect::Save` is `ViewOnly`, so the `ModelCommand` derivation
/// could not reach it and no client could make its host write.
#[test]
fn a_client_can_ask_its_host_to_save() {
let (mut host, path) = host_with_a_path();
let (client, _) = host.connect(10, 10);
host.model(
client,
ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(41.0),
},
);
assert!(
stored(&host, &path).is_none(),
"nothing should have been written before the client asked"
);
let answer = host.handle(client, ClientFrame::Save);
let written = stored(&host, &path).expect("the client's save must reach storage");
assert!(
written.contains("41"),
"the written document must carry the client's edit, got:\n{written}"
);
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"a save that landed is not a rejection: {answer:?}"
);
}
/// Every `DocumentCache` a batch of frames carries, in order.
fn document_updates(frames: &[Addressed]) -> Vec<&improvise_protocol::DocumentCache> {
frames
.iter()
.filter_map(|a| match &a.msg {
ServerFrame::Commands(cmds) => Some(cmds),
_ => None,
})
.flatten()
.filter_map(|c| match c {
Command::Project(Projection::Document(d)) => Some(d),
_ => None,
})
.collect()
}
/// improvise-z754: the dirty marker clears outside any projection, so the
/// client kept showing `[+]`. `context/test-evidence/command.md`.
#[test]
fn a_save_tells_the_client_the_document_is_clean() {
let (mut host, _) = host_with_a_path();
let (client, _) = host.connect(10, 10);
let edited = host.model(
client,
ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(41.0),
},
);
assert_eq!(
document_updates(&edited)
.iter()
.map(|d| d.dirty)
.collect::<Vec<_>>(),
vec![true],
"an edit must first tell the client the document is dirty"
);
let answer = host.handle(client, ClientFrame::Save);
assert!(
!host.app().model_state.dirty,
"the fixture must really have saved, or this proves nothing"
);
assert_eq!(
document_updates(&answer)
.iter()
.map(|d| d.dirty)
.collect::<Vec<_>>(),
vec![false],
"and the save must tell it the marker is gone: {answer:?}"
);
}
/// improvise-368h, the "no path" half: refused with a reason, to the asker alone
/// (gotcha 39). The second client asserts the refusal does not leak.
#[test]
fn a_save_with_nowhere_to_write_is_refused_and_told_only_to_the_asker() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
assert!(
host.app().model_state.file_path.is_none(),
"the fixture must genuinely have no path, or this proves nothing"
);
let answer = host.handle(asker, ClientFrame::Save);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::NoDocumentPath)],
);
assert!(
to(&answer, bystander).is_empty(),
"nothing happened, so nobody else is owed anything: {answer:?}"
);
}
/// improvise-368h: an attempted write that failed must not be reported as a
/// success. The fixture fails for a real reason — an absent directory.
#[test]
fn a_write_that_fails_is_refused_rather_than_reported_as_saved() {
let absent = std::env::temp_dir().join("improvise-no-such-directory-368h");
assert!(
!absent.exists(),
"the fixture only fails if the directory really is absent"
);
let mut app = App::new(workbook(), Some(absent.join("doc.improv")));
app.rebuild_layout();
let mut host = Host::new(app);
let (client, _) = host.connect(10, 10);
let answer = host.handle(client, ClientFrame::Save);
assert_eq!(
to(&answer, client),
vec![ServerFrame::Rejected(Rejection::SaveFailed)],
);
}
// ── disconnect ──────────────────────────────────────────────────────
/// AC 4: a dropped client stops being projected to, and a late frame
/// from it is not an error the host has to have an opinion about.
#[test]
fn a_disconnected_client_is_projected_nothing() {
let mut host = host();
let (gone, _) = host.connect(10, 10);
let (stays, _) = host.connect(10, 10);
host.disconnect(gone);
let frames = host.model(
stays,
ModelCommand::SetCell {
cell: addressed("North"),
value: CellValue::Number(1.0),
},
);
assert!(to(&frames, gone).is_empty(), "{frames:?}");
assert!(!to(&frames, stays).is_empty());
assert!(
host.handle(gone, ClientFrame::Viewport { rows: 5, cols: 5 })
.is_empty()
);
host.disconnect(stays);
assert!(host.is_empty());
}
// ── the frames themselves ───────────────────────────────────────────
/// The wire is JSON, so both directions must survive a round trip through it —
/// including the one point at which `ViewState` is serialized (gotcha 25).
#[test]
fn every_frame_survives_a_json_round_trip() {
let mut host = host();
let (id, connect) = host.connect(10, 10);
let mut frames = vec![connect];
frames.extend(host.model(
id,
ModelCommand::SetCell {
cell: addressed("South"),
value: CellValue::Number(3.5),
},
));
assert!(
frames
.iter()
.any(|f| matches!(&f.msg, ServerFrame::Commands(c)
if c.iter().any(|c| matches!(c, Command::Project(Projection::Grid(GridUpdate::Cells(_))))))),
"the fixture produced no cell update to round-trip: {frames:?}"
);
for frame in &frames {
let json = serde_json::to_string(&frame.msg).expect("a frame must serialize");
let back: ServerFrame = serde_json::from_str(&json).expect("a frame must deserialize");
assert_eq!(back, frame.msg);
}
// A payload-bearing variant on each side of the envelope
// (`doctrine(populate_before_roundtrip)`) — see the evidence.
for inbound in [
ClientFrame::Viewport { rows: 3, cols: 4 },
ClientFrame::command(crate::ui::reduce::caught_up(
Host::new(App::new(workbook(), None)).app(),
ModelCommand::SetCell {
cell: addressed("South"),
value: CellValue::Text("pending".into()),
},
)),
] {
let json = serde_json::to_string(&inbound).unwrap();
assert_eq!(serde_json::from_str::<ClientFrame>(&json).unwrap(), inbound);
}
let inbound = ClientFrame::command(Command::SetSelected { row: 1, col: 0 });
let json = serde_json::to_string(&inbound).unwrap();
assert_eq!(serde_json::from_str::<ClientFrame>(&json).unwrap(), inbound);
}
/// Two model commands, each caught up against the document as the client
/// saw it, so no basis tier can refuse the frame first (improvise-bok4).
fn two_command_frame(host: &Host, first: ModelCommand, then: ModelCommand) -> ClientFrame {
ClientFrame::Commands(vec![
crate::ui::reduce::caught_up(host.app(), first),
crate::ui::reduce::caught_up(host.app(), then),
])
}
/// improvise-bok4, the view-addressed case: the second command's view is
/// gone. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_frame_writing_a_view_its_own_first_command_deleted_is_refused() {
let mut host = host();
host.app_mut().model_state.workbook.create_view("Scratch");
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::DeleteView("Scratch".into()),
ModelCommand::SetNumberFormat {
view: "Scratch".into(),
format: ",.2f".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"half the frame did nothing and was reported accepted: {answer:?}"
);
assert!(
host.app()
.model_state
.workbook
.views
.contains_key("Scratch"),
"the refused frame's deletion must be undone"
);
}
/// improvise-bok4, the category-addressed case: it narrated its failure into
/// the HOST's status line and told the client nothing.
#[test]
fn a_frame_adding_an_item_to_a_category_its_first_command_removed_is_refused() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::RemoveCategory("Region".into()),
ModelCommand::AddItem {
category: "Region".into(),
item: "East".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"half the frame did nothing and was reported accepted: {answer:?}"
);
assert!(
host.app()
.model_state
.workbook
.model
.category("Region")
.is_some(),
"the refused frame's removal must be undone"
);
}
/// The one-command case of the same class: deleting the last view was
/// swallowed with `let _`, so the frame was accepted having done nothing.
#[test]
fn deleting_the_documents_last_view_is_refused_rather_than_accepted() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let only = host.app().view_name().to_string();
let answer = host.model(asker, ModelCommand::DeleteView(only.clone()));
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"a deletion the workbook refused was reported accepted: {answer:?}"
);
assert!(host.app().model_state.workbook.views.contains_key(&only));
}
/// The control: two commands that both land are accepted, so the refusals
/// above are about a failure and not about the frame's shape.
#[test]
fn a_frame_whose_commands_all_land_is_accepted() {
let mut host = host();
host.app_mut().model_state.workbook.create_view("Scratch");
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::SetNumberFormat {
view: "Scratch".into(),
format: ",.2f".into(),
},
ModelCommand::AddItem {
category: "Region".into(),
item: "East".into(),
},
),
);
assert!(
to(&answer, asker).contains(&ServerFrame::Accepted),
"a frame with nothing to refuse was refused: {answer:?}"
);
}
/// improvise-40j4: the formula was stored for a category the frame's own
/// first command removed, and it survived save and reload.
#[test]
fn a_frame_adding_a_formula_to_a_category_its_first_command_removed_is_refused() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::RemoveCategory("Region".into()),
ModelCommand::AddFormula {
raw: "Total = 1".into(),
target_category: "Region".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"a formula for a vanished category was accepted: {answer:?}"
);
let model = &host.app().model_state.workbook.model;
assert!(
model.category("Region").is_some(),
"the removal must be undone"
);
assert!(
model.formulas().iter().all(|f| f.target != "Total"),
"and the formula must not be stored"
);
}
/// A host whose storage holds one file per way `ImportJsonHeadless` can
/// fail, plus nothing at the two paths that must fail to read.
fn host_with_import_fixtures() -> Host {
let storage = MemoryFs::new();
let files: [(&str, &[u8]); 4] = [
// Every field numeric, so no category is accepted and the build fails.
("measures.json", br#"[{"x": 1}, {"x": 2}]"#),
("bad.json", b"{not json"),
("nested.json", br#"{"data": [{"Region": "North"}]}"#),
// A record that is not UTF-8 is a CSV read error, not a warning.
("bad.csv", b"Region,Revenue\n\xff\xfe,1\n"),
];
for (name, bytes) in files {
crate::storage::Storage::write(&storage, Path::new(name), bytes)
.expect("fixture writes");
}
// A second view, so deleting an absent one is not refused as "the last".
let mut wb = workbook();
wb.create_view("Scratch");
let mut app = App::new(wb, None);
app.storage = Box::new(storage);
app.rebuild_layout();
Host::new(app)
}
fn import(path: &str, array_path: Option<&str>) -> ModelCommand {
ModelCommand::ImportJsonHeadless {
path: PathBuf::from(path),
model_name: None,
array_path: array_path.map(String::from),
}
}
/// improvise-9fpl: one case per refusal #181 converted that no other test
/// witnesses. Evidence: `context/test-evidence/command.md`.
#[test]
fn every_failing_model_command_refuses_its_whole_frame() {
let cases: Vec<(&str, ModelCommand)> = vec![
(
"AddItemInGroup into an absent category",
ModelCommand::AddItemInGroup {
category: "Ghost".into(),
item: "East".into(),
group: "Coastal".into(),
},
),
(
"AddFormula that does not parse",
ModelCommand::AddFormula {
raw: "Total = (".into(),
target_category: "_Measure".into(),
},
),
(
"ImportJsonHeadless of an absent JSON",
import("absent.json", None),
),
(
"ImportJsonHeadless of an absent CSV",
import("absent.csv", None),
),
(
"ImportJsonHeadless of a CSV that is not text",
import("bad.csv", None),
),
(
"ImportJsonHeadless of JSON that does not parse",
import("bad.json", None),
),
(
"ImportJsonHeadless at a path with no array",
import("nested.json", Some("nope")),
),
(
"ImportJsonHeadless that builds no model",
import("measures.json", None),
),
// improvise-40j4: `delete_view` answers Ok for a name it lacks.
(
"DeleteView of a view the document lacks",
ModelCommand::DeleteView("Ghost".into()),
),
(
"AddFormula into a category the document lacks",
ModelCommand::AddFormula {
raw: "Total = 1".into(),
target_category: "Ghost".into(),
},
),
];
for (what, failing) in cases {
let mut host = host_with_import_fixtures();
let (asker, _) = host.connect(10, 10);
let landed = ModelCommand::AddItem {
category: "Region".into(),
item: "Landed".into(),
};
let answer = host.handle(asker, two_command_frame(&host, landed, failing));
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"{what}: the frame was not refused: {answer:?}"
);
assert!(
!host
.app()
.model_state
.workbook
.model
.category("Region")
.is_some_and(|c| c.ordered_item_names().contains(&"Landed")),
"{what}: the half of the frame that landed was not undone"
);
}
}
}