5fa3fed146
improvise-k84e, scoped to what PR #109 left: a frame whose LoadModel SUCCEEDS, rebases every mirror and emits Snapshots, and is THEN rolled back by a later command in the same frame. The document half was already covered by a_rolled_back_load_leaves_no_ghost_view_and_no_false_status. This covers the Sessions half, which nothing touched: mutating_frame clones the whole Sessions before the batch and undo_frame puts the clone back, so the reseed is undone with it and the computed Snapshots are discarded rather than sent. It reproduced nothing — the mechanism is sound, as the bead predicted. Its value is the fault it can see. Injected: `self.sessions = mirrors;` replaced by `let _ = mirrors;`. The test failed on the projection assertion, naming the asker's mirror and printing the whole correcting batch — the restored document being re-sent to a client left holding the discarded one, which is a client hearing about a load that never happened. Three things keep it non-vacuous: a separate control asserting the fixture's load really does rebase both clients, the generation check made before anything is concluded from the rollback, and a bystander seated where a wrong relay could move it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
1760 lines
65 KiB
Rust
1760 lines
65 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`, and only `apply_effects`
|
|
// resets the flag — so after anything else it is a STALE answer.
|
|
let mutation = matches!(cmd.command(), Command::Model { .. });
|
|
out.extend(self.command(id, cmd));
|
|
if mutation && self.app.last_batch_aborted() {
|
|
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"]);
|
|
|
|
// 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!(
|
|
answer
|
|
.iter()
|
|
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
|
|
"the fixture must actually refuse the frame: {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"
|
|
);
|
|
}
|
|
|
|
/// 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"]);
|
|
|
|
// 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!(
|
|
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:?}"
|
|
);
|
|
assert!(
|
|
host.app().view_state.status_msg.starts_with("Load error"),
|
|
"the fixture did not actually fail the load: {:?}",
|
|
host.app().view_state.status_msg
|
|
);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|