Files
improvise/crates/improvise-command/src/ui/host.rs
T

3416 lines
129 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();
let mut asker_reset = false;
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 { .. });
let command = cmd.command();
let mut answered = self.command(id, cmd);
// The asker folded this placement BEFORE hearing its reset, so it is owed
// again after it (improvise-wdmp, `context/code/command.md`).
if asker_reset && places_cursor(command) {
answered.insert(
0,
Addressed {
to: id,
msg: ServerFrame::Commands(vec![command.clone()]),
},
);
}
asker_reset |= answered
.iter()
.any(|a| a.to == id && is_cursor_reset(&a.msg));
out.extend(answered);
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()
}
}
/// A command that writes this client's cursor, and so survives a reset only if
/// it is folded after it.
fn places_cursor(cmd: &Command) -> bool {
matches!(
cmd,
Command::SetSelected { .. } | Command::SetRowOffset(_) | Command::SetColOffset(_)
)
}
fn is_cursor_reset(msg: &ServerFrame) -> bool {
matches!(msg, ServerFrame::Commands(cmds) if cmds.contains(&Command::ResetCursor))
}
/// One file per way `ImportJsonHeadless` can fail, plus one that imports; the
/// `absent.*` paths are left unwritten. Shared by the frame and key-path tests.
#[cfg(test)]
pub(crate) fn import_fixture_storage() -> crate::storage::MemoryFs {
let storage = crate::storage::MemoryFs::new();
let files: [(&str, &[u8]); 5] = [
// 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"),
("good.json", br#"[{"Region": "North", "Revenue": 1}]"#),
];
for (name, bytes) in files {
crate::storage::Storage::write(&storage, std::path::Path::new(name), bytes)
.expect("fixture writes");
}
storage
}
#[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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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:?}"
);
}
/// After a refused frame reshaped the grid, the NEXT frame resolves against the
/// restored layout (improvise-8wy9). Evidence: `context/test-evidence/command.md`.
#[test]
fn a_frame_after_a_rolled_back_reshape_resolves_against_the_restored_grid() {
use improvise_protocol::ModelCommand;
let mut host = host();
let (asker, _) = host.connect(10, 10);
assert_eq!(host.app().layout.row_count(), 2, "setup: North and South");
let basis = frame_basis(&host, &["South"]);
let refused = host.handle(
asker,
ClientFrame::Commands(vec![
Command::Model {
basis: basis.clone(),
cmd: ModelCommand::RemoveItem {
category: "Region".into(),
item: "South".into(),
},
},
Command::Model {
basis,
cmd: ModelCommand::SetCell {
view: "Default".into(),
cell: addressed("South"),
value: CellValue::Number(5.0),
},
},
]),
);
assert!(
refused
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::ChainBroken(_)))),
"setup: the frame that removed South must be refused and rolled back: {refused:?}"
);
assert_eq!(
host.app().layout.row_count(),
2,
"the restored document has South again, so its grid must too"
);
// South stores nothing: it is known only by its position in the layout.
let answer = host.model(
asker,
ModelCommand::SetCell {
view: "Default".into(),
cell: addressed("South"),
value: CellValue::Number(7.0),
},
);
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"a write to South was refused against the rolled-back grid: {answer:?}"
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("South")),
Some(&CellValue::Number(7.0))
);
}
/// A rolled-back frame that moved the HOST's view rebuilds the layout of the
/// view it is put back on (improvise-3til). Evidence: test-evidence/command.md.
#[test]
fn a_rolled_back_delete_of_the_hosts_view_rebuilds_that_views_layout() {
use improvise_protocol::ModelCommand;
let mut host = host();
let other = host.app.model_state.workbook.create_view("Other");
other.set_axis("Region", Axis::Column);
other.set_axis("_Measure", Axis::Row);
assert!(
host.app.switch_to_view("Other"),
"setup: the host is on Other"
);
host.app.rebuild_layout();
let (asker, _) = host.connect(10, 10);
let shape = |host: &Host| (host.app().layout.row_count(), host.app().layout.col_count());
assert_eq!(shape(&host), (1, 2), "setup: Other is transposed");
let basis = frame_basis(&host, &["South"]);
let refused = host.handle(
asker,
ClientFrame::Commands(vec![
Command::Model {
basis: basis.clone(),
cmd: ModelCommand::DeleteView("Other".into()),
},
Command::Model {
basis: basis.clone(),
cmd: ModelCommand::RemoveItem {
category: "Region".into(),
item: "South".into(),
},
},
Command::Model {
basis,
cmd: ModelCommand::SetCell {
view: "Default".into(),
cell: addressed("South"),
value: CellValue::Number(5.0),
},
},
]),
);
assert!(
refused
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::ChainBroken(_)))),
"setup: the frame must be refused and rolled back: {refused:?}"
);
assert_eq!(host.app().view_name(), "Other", "the host is back on Other");
assert_eq!(
shape(&host),
(1, 2),
"and its layout must be Other's, not the default's"
);
}
/// 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 {
view: "Default".into(),
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 {
view: "Default".into(),
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..crate::model::MAX_CATEGORIES - 1 {
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("Default", 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 {
view: "Default".into(),
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("Default", 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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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"
);
}
fn month_cell(month: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Month".to_string(), month.to_string()),
("_Measure".to_string(), measure.to_string()),
])
}
/// Months in two quarters on Row, Revenue/Cost and `Profit = Revenue - Cost` on
/// Column, every month funded, Q2 collapsed: rows `[Jan, Feb, Q2]`.
fn host_on_a_fold() -> Host {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap();
for measure in ["Revenue", "Cost"] {
wb.add_item("_Measure", measure).unwrap();
let value = CellValue::Number(10.0);
wb.model.set_cell(month_cell(month, measure), value);
}
}
wb.active_view_mut().set_expand_depth("Month", 1);
let profit = crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure");
wb.model.add_formula(profit.unwrap());
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
let mut app = App::new(wb, None);
app.rebuild_layout();
assert!(
app.layout.folds_a_group(2, 1),
"setup: row 2 is the Q2 fold"
);
Host::new(app)
}
/// One wire write, as a hand-rolled client composes it: a surrogate built from
/// a key, and a basis claiming whatever the host says is current.
fn wire_write(host: &mut Host, key: &CellKey, clear: bool) -> Vec<Addressed> {
use improvise_protocol::ModelCommand;
let cell = RecordCell {
record: RecordHash::of(key),
measure: key.get("_Measure").map(String::from),
};
let cmd = match clear {
true => ModelCommand::ClearCell {
view: "Default".into(),
cell,
},
false => ModelCommand::SetCell {
view: "Default".into(),
cell,
value: CellValue::Number(7.0),
},
};
let (asker, _) = host.connect(10, 10);
let frame = crate::ui::reduce::caught_up(host.app(), cmd);
host.handle(asker, ClientFrame::Commands(vec![frame]))
}
fn refused(answer: &[Addressed]) -> bool {
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::MutationRefused)))
}
fn document_text(host: &Host) -> String {
crate::persistence::format_md(&host.app().model_state.workbook)
}
/// improvise-ciw8 — see context/test-evidence/command.md.
#[test]
fn a_wire_write_to_a_collapsed_fold_is_rejected_and_stores_nothing() {
for clear in [false, true] {
let mut host = host_on_a_fold();
let before = document_text(&host);
let answer = wire_write(&mut host, &month_cell("Q2", "Cost"), clear);
assert!(refused(&answer), "clear={clear}: not refused: {answer:?}");
assert_eq!(
document_text(&host),
before,
"clear={clear}: the fold was written"
);
}
}
/// improvise-nacd: a formula target is a measure, so the refusal names the
/// formula. Evidence: context/test-evidence/command.md.
#[test]
fn a_write_under_a_formula_is_refused_for_the_formula_not_as_a_non_item() {
let host = host_on_a_fold();
let key = month_cell("Feb", "Profit");
let cell = RecordCell {
record: RecordHash::of(&key),
measure: Some("Profit".into()),
};
let why = host.app().unwritable("Default", &cell).expect("refused");
assert!(why.contains("formula"), "refused for another reason: {why}");
}
/// improvise-ciw8: a formula's result is `CellId::Derived`, no write's target.
#[test]
fn a_wire_write_under_a_formula_is_rejected_and_stores_nothing() {
let mut host = host_on_a_fold();
let before = document_text(&host);
let answer = wire_write(&mut host, &month_cell("Feb", "Profit"), false);
assert!(refused(&answer), "not refused: {answer:?}");
assert_eq!(
document_text(&host),
before,
"a value was stored under the formula"
);
}
/// improvise-ciw8: a key the None axis aggregates over is `CellId::Aggregate`.
#[test]
fn a_wire_write_to_a_none_axis_aggregate_is_rejected_and_stores_nothing() {
let mut host = host();
host.app
.model_state
.workbook
.add_category("Product")
.unwrap();
host.app
.model_state
.workbook
.add_item("Product", "Widget")
.unwrap();
let view = host.app.model_state.workbook.active_view_mut();
view.set_axis("Product", Axis::None);
host.app.rebuild_layout();
let before = document_text(&host);
let answer = wire_write(&mut host, &cell("South"), false);
assert!(refused(&answer), "not refused: {answer:?}");
assert_eq!(document_text(&host), before, "the partial key was written");
}
/// improvise-ciw8's second question: a write naming a record that never existed
/// is refused by the frame, not dropped — see context/test-evidence/command.md.
#[test]
fn a_wire_write_to_a_record_that_never_existed_is_rejected() {
use improvise_protocol::ModelCommand;
let mut host = host_on_a_fold();
let before = document_text(&host);
// Claimed by a caught-up basis: structural staleness, answered up front.
let answer = wire_write(&mut host, &month_cell("Nowhere", "Cost"), false);
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::RecordGone(_)))),
"claimed: {answer:?}"
);
// Unclaimed: the create exemption covers only LATER commands in a frame,
// so a lone write is answered up front too.
let key = month_cell("Nowhere", "Cost");
let cmd = ModelCommand::SetCell {
view: "Default".into(),
cell: RecordCell {
record: RecordHash::of(&key),
measure: Some("Cost".into()),
},
value: CellValue::Number(7.0),
};
let basis = improvise_protocol::Basis {
document_generation: host.app().document_generation(),
layout_version: host.app().layout_version(),
records: vec![],
};
let (asker, _) = host.connect(10, 10);
let frame = ClientFrame::Commands(vec![Command::Model { basis, cmd }]);
let answer = host.handle(asker, frame);
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::RecordGone(_)))),
"unclaimed: {answer:?}"
);
assert_eq!(document_text(&host), before);
}
/// The control for the three above: the stored cell beside the fold is written.
#[test]
fn a_wire_write_to_a_stored_cell_beside_a_fold_is_accepted() {
let mut host = host_on_a_fold();
let answer = wire_write(&mut host, &month_cell("Feb", "Cost"), false);
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Accepted)),
"{answer:?}"
);
let model = &host.app().model_state.workbook.model;
let value = model.get_cell(&month_cell("Feb", "Cost"));
assert_eq!(value, Some(&CellValue::Number(7.0)));
}
/// improvise-sjdb: an empty record is judged at its own cell, not at the first
/// column carrying it — see context/test-evidence/command.md.
#[test]
fn an_empty_cell_beside_a_collapsed_measure_group_is_writable() {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for month in ["Jan", "Feb"] {
wb.add_item("Month", month).unwrap();
}
for measure in ["Revenue", "Cost"] {
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
}
wb.active_view_mut().set_expand_depth("_Measure", 1);
wb.add_item("_Measure", "Units").unwrap();
let jan = month_cell("Jan", "Revenue");
wb.model.set_cell(jan, CellValue::Number(10.0));
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut()
.toggle_group_collapse("_Measure", &["Money"]);
let mut app = App::new(wb, None);
app.rebuild_layout();
let target = month_cell("Feb", "Units");
let units = (0..app.layout.col_count())
.find(|&c| app.layout.cell_key(1, c).as_ref() == Some(&target))
.expect("setup: Feb x Units is drawn");
let fold = (0..app.layout.col_count())
.find(|&c| app.layout.folds_a_group(1, c))
.expect("setup: a folded measure column");
assert!(fold < units, "setup: the fold comes first on Feb's row");
let id =
crate::ui::project::cell_id(&app.model_state.workbook.model, &app.layout, 1, units);
let projected = matches!(id, Some(improvise_protocol::CellId::Record(_)));
assert!(projected, "setup: Feb x Units projects as {id:?}");
let mut host = Host::new(app);
let answer = wire_write(&mut host, &target, false);
let accepted = answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Accepted));
assert!(accepted, "a projected Record was refused: {answer:?}");
let model = &host.app().model_state.workbook.model;
assert_eq!(model.get_cell(&target), Some(&CellValue::Number(7.0)));
}
/// improvise-b114: a coordinate naming a group is no item, in any view — see
/// context/test-evidence/command.md.
#[test]
fn a_wire_write_naming_a_measure_group_is_rejected_in_any_view() {
for collapsed in [true, false] {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for month in ["Jan", "Feb"] {
wb.add_item("Month", month).unwrap();
for measure in ["Revenue", "Cost"] {
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
let key = month_cell(month, measure);
wb.model.set_cell(key, CellValue::Number(10.0));
}
}
wb.active_view_mut().set_expand_depth("_Measure", 1);
wb.add_item("_Measure", "Units").unwrap();
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
if collapsed {
wb.active_view_mut()
.toggle_group_collapse("_Measure", &["Money"]);
}
let mut app = App::new(wb, None);
app.rebuild_layout();
let mut host = Host::new(app);
let before = document_text(&host);
let answer = wire_write(&mut host, &month_cell("Feb", "Money"), false);
assert!(refused(&answer), "collapsed={collapsed}: {answer:?}");
let after = document_text(&host);
assert_eq!(after, before, "collapsed={collapsed}: a group was stored");
}
}
/// `Month` Jan/Feb, `_Measure` Revenue and Cost in `Money` under the parent
/// group `Totals`, and the record `{Month=Feb}` stored.
fn host_on_a_stored_record_beside_groups() -> Host {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for month in ["Jan", "Feb"] {
wb.add_item("Month", month).unwrap();
}
for measure in ["Revenue", "Cost"] {
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
}
wb.active_view_mut().set_expand_depth("_Measure", 1);
let measures = wb.model.category_mut("_Measure").unwrap();
measures.add_group(crate::model::category::Group::new("Money").with_parent("Totals"));
wb.model
.set_cell(month_cell("Feb", "Revenue"), CellValue::Number(10.0));
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
let mut app = App::new(wb, None);
app.rebuild_layout();
Host::new(app)
}
/// improvise-pr8t: an item that shares its name with a group of its category
/// is still an item. Evidence: context/test-evidence/command.md.
#[test]
fn a_wire_write_to_an_item_named_like_its_categorys_group_is_accepted() {
let mut host = host_on_a_stored_record_beside_groups();
let measures = host.app.model_state.workbook.model.category_mut("_Measure");
measures.unwrap().add_item("Money");
host.app.rebuild_layout();
let target = month_cell("Feb", "Money");
let answer = wire_write(&mut host, &target, false);
let accepted = answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Accepted));
assert!(accepted, "a real item was refused: {answer:?}");
let model = &host.app().model_state.workbook.model;
assert_eq!(model.get_cell(&target), Some(&CellValue::Number(7.0)));
}
/// improvise-nacd: a stored record's measure must name an item, not merely
/// avoid naming a group. Evidence: context/test-evidence/command.md.
#[test]
fn a_wire_write_naming_no_item_of_its_category_is_rejected() {
for measure in ["Bogus", "Totals"] {
let mut host = host_on_a_stored_record_beside_groups();
let before = document_text(&host);
let answer = wire_write(&mut host, &month_cell("Feb", measure), false);
assert!(refused(&answer), "{measure} accepted: {answer:?}");
assert_eq!(document_text(&host), before, "{measure} was stored");
}
}
/// improvise-xur5 (the reviewer's `probe_fold_hidden_measure`): a fold's record
/// with a measure the host draws nowhere — see context/test-evidence/command.md.
#[test]
fn a_wire_write_to_a_fold_record_with_an_undrawn_measure_is_rejected() {
let hidden = month_cell("Q2", "Cost");
let bogus = month_cell("Q2", "Bogus");
let bare = CellKey::new(vec![("Month".to_string(), "Q2".to_string())]);
for key in [hidden, bogus, bare] {
let mut host = host_on_a_fold();
let view = host.app.model_state.workbook.active_view_mut();
view.hide_item("_Measure", "Cost");
host.app.rebuild_layout();
let before = document_text(&host);
let answer = wire_write(&mut host, &key, false);
assert!(refused(&answer), "{key:?} accepted: {answer:?}");
assert_eq!(document_text(&host), before, "{key:?} stored a group");
}
}
/// A STORED record is writable whatever the host's view does to it — see
/// context/test-evidence/command.md (improvise-sjdb).
#[test]
fn a_stored_record_the_hosts_view_aggregates_is_writable() {
let mut wb = workbook();
wb.model.set_cell(cell("South"), CellValue::Number(1.0));
wb.add_category("Product").unwrap();
wb.add_item("Product", "Widget").unwrap();
wb.active_view_mut().set_axis("Product", Axis::None);
let mut app = App::new(wb, None);
app.rebuild_layout();
let south = (0..app.layout.row_count())
.flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c)))
.find(|&(r, c)| app.layout.cell_key(r, c).as_ref() == Some(&cell("South")))
.expect("setup: South's cell is drawn");
let id = crate::ui::project::cell_id(
&app.model_state.workbook.model,
&app.layout,
south.0,
south.1,
);
let aggregate = matches!(id, Some(improvise_protocol::CellId::Aggregate(_)));
assert!(aggregate, "setup: the host's view aggregates South: {id:?}");
let mut host = Host::new(app);
let answer = wire_write(&mut host, &cell("South"), false);
let accepted = answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Accepted));
assert!(accepted, "a stored record was refused: {answer:?}");
let model = &host.app().model_state.workbook.model;
assert_eq!(
model.get_cell(&cell("South")),
Some(&CellValue::Number(7.0))
);
}
/// 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 {
view: "Default".into(),
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"
);
}
/// improvise-fdk1: a surrogate names a record as it WAS, and a rename ends that
/// name. See `context/test-evidence/command.md`.
#[test]
fn a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain() {
use improvise_protocol::ModelCommand;
let mut host = host();
host.app
.model_state
.workbook
.model
.set_cell(cell("North"), CellValue::Number(1.0));
host.app.rebuild_layout();
// A records host: its layout names positions, so an old surrogate cannot
// resolve through it. A pivot host differs — see the evidence.
crate::ui::reduce::reduce_key(
&mut host.app,
crate::command::key::KeyEvent::new(
crate::command::key::KeyCode::Char('R'),
crate::command::key::KeyModifiers::NONE,
),
);
assert!(host.app.layout.is_records_mode(), "setup: a records host");
let records_view = host.app.view_name().to_string();
let (asker, _) = host.connect(10, 10);
let north = addressed("North").record;
let rename = || ModelCommand::RenameRecordCoordinate {
record: north,
category: "Region".into(),
item: "West".into(),
};
let stored = |host: &Host, region: &str| {
host.app()
.model_state
.workbook
.model
.get_cell(&cell(region))
.cloned()
};
let pair = vec![
crate::ui::reduce::caught_up(host.app(), rename()),
crate::ui::reduce::caught_up(
host.app(),
ModelCommand::SetCell {
view: records_view.clone(),
cell: addressed("North"),
value: CellValue::Number(7.0),
},
),
];
let answer = host.handle(asker, ClientFrame::Commands(pair));
assert!(
answer.iter().any(|a| matches!(
a.msg,
ServerFrame::Rejected(Rejection::ChainBroken(h)) if h == north
)),
"the write names a record the rename moved away: {answer:?}"
);
assert_eq!(stored(&host, "North"), Some(CellValue::Number(1.0)));
assert_eq!(stored(&host, "West"), None, "clause 4 undid the rename too");
// The control: the rename alone is accepted and moves the record, or
// the refusal above is "renames never work" and proves nothing.
let alone = crate::ui::reduce::caught_up(host.app(), rename());
let answer = host.handle(asker, ClientFrame::Commands(vec![alone]));
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Accepted)),
"{answer:?}"
);
assert_eq!(stored(&host, "West"), Some(CellValue::Number(1.0)));
assert_eq!(stored(&host, "North"), None);
}
// ── 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:?}"
);
}
}
/// improvise-wdmp: the asker's own frame placed its cursor after the reframe,
/// and the relayed reset undid it. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed() {
use improvise_protocol::{AppMode, ClientState};
// With a placement after the reframe, and without one: the asker ends where
// the TUI would, and its client agrees with its mirror either way.
for (placed, expected) in [(true, (1, 0)), (false, (0, 0))] {
let mut host = host();
let (asker, snapshot) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let mut client = ClientState::default();
match snapshot.msg {
ServerFrame::Snapshot(s) => client.bootstrap(*s),
other => panic!("setup: connect answers a Snapshot, got {other:?}"),
}
let send = |host: &mut Host, client: &mut ClientState, frame: Vec<Command>| {
client.fold_outgoing(&frame);
let answer = host.handle(asker, ClientFrame::Commands(frame));
for msg in to(&answer, asker) {
match msg {
ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)),
ServerFrame::Accepted => client.accepted(),
other => panic!("the frame must be accepted: {other:?}"),
}
}
answer
};
// Off the origin first, so a reset that never arrived is visible.
send(
&mut host,
&mut client,
vec![Command::SetSelected { row: 1, col: 0 }],
);
// The `+cat` commit's shape: tidy-up, the schema change, then (maybe)
// the cursor put back where the user was (resolve.rs::create_column).
let add = ModelCommand::AddCategory("Product".into());
let mut frame = vec![
Command::ChangeMode(AppMode::Normal),
crate::ui::reduce::caught_up(host.app(), add),
];
if placed {
frame.push(Command::SetSelected { row: 1, col: 0 });
}
let answer = send(&mut host, &mut client, frame);
assert!(
to(&answer, bystander).contains(&ServerFrame::Commands(vec![Command::ResetCursor])),
"setup: the frame must really reframe, or nothing here is at stake"
);
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
mirror.selected, expected,
"setup, placed={placed}: the mirror"
);
assert_eq!(
client.view.cursor(),
mirror,
"placed={placed}: the asker's client and its mirror disagree"
);
}
}
/// A client connected to `host`, bootstrapped from its own `Snapshot`.
fn connected_client(host: &mut Host) -> (SessionId, improvise_protocol::ClientState) {
let (id, snapshot) = host.connect(10, 10);
let mut client = improvise_protocol::ClientState::default();
match snapshot.msg {
ServerFrame::Snapshot(s) => client.bootstrap(*s),
other => panic!("setup: connect answers a Snapshot, got {other:?}"),
}
(id, client)
}
/// A frame sent the way the page sends one, and every answer folded the way the
/// page folds it: accepted, refused or relayed.
fn exchange(
host: &mut Host,
id: SessionId,
client: &mut improvise_protocol::ClientState,
frame: Vec<Command>,
) -> Vec<Addressed> {
client.fold_outgoing(&frame);
let answer = host.handle(id, ClientFrame::Commands(frame));
answered(&answer, id, client);
answer
}
/// Everything but projections one session heard, in arrival order.
fn relayed_to(frames: &[Addressed], id: SessionId) -> Vec<Command> {
to(frames, id)
.into_iter()
.flat_map(|msg| match msg {
ServerFrame::Commands(cmds) => cmds,
_ => Vec::new(),
})
.filter(|c| !matches!(c, Command::Project(_)))
.collect()
}
/// improvise-hv0r: the echo is the asker's alone, is every placement AFTER the
/// reset and nothing before it. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_reframing_frame_echoes_only_its_later_placements_and_only_to_the_asker() {
use improvise_protocol::AppMode;
let mut host = host();
let (asker, mut client) = connected_client(&mut host);
let (bystander, mut other) = connected_client(&mut host);
// The bystander sits where an echo of the asker's placement would move it.
exchange(
&mut host,
bystander,
&mut other,
vec![Command::SetSelected { row: 0, col: 1 }],
);
let add = ModelCommand::AddCategory("Product".into());
let frame = vec![
Command::SetRowOffset(1),
Command::ChangeMode(AppMode::Normal),
crate::ui::reduce::caught_up(host.app(), add),
Command::SetSelected { row: 1, col: 0 },
Command::SetColOffset(1),
];
let answer = exchange(&mut host, asker, &mut client, frame);
assert_eq!(
relayed_to(&answer, asker),
vec![
Command::ResetCursor,
Command::SetSelected { row: 1, col: 0 },
Command::SetColOffset(1),
],
"the asker is owed the reset, then exactly the placements that followed it"
);
assert_eq!(
relayed_to(&answer, bystander),
vec![Command::ResetCursor],
"a bystander is told its cursor is stale, never where somebody else put theirs"
);
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
(mirror.selected, mirror.row_offset, mirror.col_offset),
((1, 0), 0, 1)
);
assert_eq!(
client.view.cursor(),
mirror,
"the asker's client and its mirror disagree"
);
}
/// improvise-jpba: a refusal rolled the client back past the relays it had
/// heard, while its mirror kept them. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_refusal_after_a_reframe_rolls_back_to_where_the_mirror_is() {
use improvise_protocol::AppMode;
// A bare reframe, and the ticket's placed shape with a pre-reset offset.
for placed in [false, true] {
let mut host = host();
let (asker, mut client) = connected_client(&mut host);
exchange(
&mut host,
asker,
&mut client,
vec![Command::SetSelected { row: 1, col: 0 }],
);
// Composed now, sent after the reframe: stale, and so refused.
let other = ModelCommand::AddCategory("Other".into());
let stale = crate::ui::reduce::caught_up(host.app(), other);
let product = ModelCommand::AddCategory("Product".into());
let reframe = crate::ui::reduce::caught_up(host.app(), product);
let frame = match placed {
false => vec![Command::ChangeMode(AppMode::Normal), reframe],
true => vec![
Command::SetRowOffset(1),
Command::ChangeMode(AppMode::Normal),
reframe,
Command::SetSelected { row: 1, col: 0 },
],
};
exchange(&mut host, asker, &mut client, frame);
let answer = exchange(&mut host, asker, &mut client, vec![stale]);
assert!(
to(&answer, asker)
.iter()
.any(|m| matches!(m, ServerFrame::Rejected(_))),
"setup, placed={placed}: the stale frame must be refused: {answer:?}"
);
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
client.view.cursor(),
mirror,
"placed={placed}: the refusal rolled the client back past a relay"
);
assert_eq!(client.settled.cursor(), mirror, "placed={placed}: settled");
}
}
/// Fold the answers `answer` owes `id`, the way the page folds them.
fn answered(answer: &[Addressed], id: SessionId, client: &mut improvise_protocol::ClientState) {
for msg in to(answer, id) {
match msg {
ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)),
ServerFrame::Accepted => client.accepted(),
ServerFrame::Rejected(r) => client.refused(r),
other => panic!("an unexpected answer: {other:?}"),
}
}
}
/// improvise-xd3q: a placement sent before a reframe's answer arrived must
/// survive that answer's reset. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_placement_sent_before_a_reframe_is_answered_outlives_its_reset() {
use improvise_protocol::AppMode;
let mut host = host();
let (asker, mut client) = connected_client(&mut host);
let product = ModelCommand::AddCategory("Product".into());
let reframe = vec![
Command::ChangeMode(AppMode::Normal),
crate::ui::reduce::caught_up(host.app(), product),
Command::SetSelected { row: 1, col: 0 },
];
let placement = vec![Command::SetSelected { row: 2, col: 0 }];
client.fold_outgoing(&reframe);
let first = host.handle(asker, ClientFrame::Commands(reframe));
client.fold_outgoing(&placement);
let second = host.handle(asker, ClientFrame::Commands(placement));
assert!(
relayed_to(&first, asker).contains(&Command::ResetCursor),
"setup: the reframe must reset the asker, or nothing here can clobber"
);
answered(&first, asker, &mut client);
answered(&second, asker, &mut client);
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
mirror.selected,
(2, 0),
"setup: the host applied both frames"
);
assert_eq!(
client.view.cursor(),
mirror,
"the reframe's reset clobbered a placement the client had already sent"
);
assert_eq!(client.settled.cursor(), mirror, "settled");
}
/// improvise-xd3q: what was sent behind a reframe is replayed ONCE, from the
/// settled slice, never re-applied. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_view_step_sent_before_a_reframe_is_answered_steps_back_once() {
use improvise_protocol::AppMode;
let mut host = host();
let (asker, mut client) = connected_client(&mut host);
for name in ["Second", "Third"] {
let create = ModelCommand::CreateView(name.into());
let create = crate::ui::reduce::caught_up(host.app(), create);
exchange(&mut host, asker, &mut client, vec![create]);
exchange(
&mut host,
asker,
&mut client,
vec![Command::SwitchView(name.into())],
);
}
let product = ModelCommand::AddCategory("Product".into());
let reframe = vec![
Command::ChangeMode(AppMode::Normal),
crate::ui::reduce::caught_up(host.app(), product),
];
let back = vec![Command::ViewBack];
client.fold_outgoing(&reframe);
let first = host.handle(asker, ClientFrame::Commands(reframe));
client.fold_outgoing(&back);
let second = host.handle(asker, ClientFrame::Commands(back));
assert!(
relayed_to(&first, asker).contains(&Command::ResetCursor),
"setup: the reframe must relay a reset, or nothing is queued to replay behind"
);
answered(&first, asker, &mut client);
answered(&second, asker, &mut client);
let mirror = host.sessions.get(asker).unwrap().view();
assert_eq!(
mirror.active_view, "Second",
"setup: one step back from Third"
);
assert_eq!(
(&client.view.active_view, &client.view.view_back_stack),
(&mirror.active_view, &mirror.view_back_stack),
"the frame sent behind the reframe was applied more than once"
);
}
/// improvise-jpba: another client's reset, heard while my frame is in flight,
/// outlives that frame's refusal. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_relay_heard_while_a_frame_is_in_flight_survives_its_refusal() {
use improvise_protocol::AppMode;
let mut host = host();
let (asker, mut client) = connected_client(&mut host);
let (other, mut theirs) = connected_client(&mut host);
exchange(
&mut host,
asker,
&mut client,
vec![Command::SetSelected { row: 1, col: 0 }],
);
let mine = ModelCommand::AddCategory("Other".into());
let mine = vec![crate::ui::reduce::caught_up(host.app(), mine)];
client.fold_outgoing(&mine);
let product = ModelCommand::AddCategory("Product".into());
let reframe = crate::ui::reduce::caught_up(host.app(), product);
let frame = vec![Command::ChangeMode(AppMode::Normal), reframe];
let relayed = exchange(&mut host, other, &mut theirs, frame);
for msg in to(&relayed, asker) {
if let ServerFrame::Commands(cmds) = msg {
cmds.iter().for_each(|c| client.fold(c));
}
}
let answer = host.handle(asker, ClientFrame::Commands(mine));
let refusal = to(&answer, asker).into_iter().find_map(|m| match m {
ServerFrame::Rejected(r) => Some(r),
_ => None,
});
client.refused(refusal.expect("setup: my stale frame must be refused"));
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
mirror.selected,
(0, 0),
"setup: the other client's reframe reset me"
);
assert_eq!(
client.view.cursor(),
mirror,
"the refusal undid a relay the mirror kept"
);
}
/// 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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 {
view: "Default".into(),
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 = super::import_fixture_storage();
// 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-we1a: `mutating_frame`'s `|| last_batch_aborted()` arm is redundant
/// only while this holds. Evidence: `context/test-evidence/command.md`.
#[test]
fn every_failing_model_effect_that_aborts_also_refuses() {
let fresh = || {
let mut wb = workbook();
wb.create_view("Scratch");
(0..crate::model::MAX_CATEGORIES)
.for_each(|n| drop(wb.add_category(format!("Filler{n}"))));
let mut app = App::new(wb, None);
app.storage = Box::new(super::import_fixture_storage());
app.rebuild_layout();
app
};
let cases: Vec<(&str, ModelCommand, bool)> = vec![
(
"AddCategory past the limit",
ModelCommand::AddCategory("Extra".into()),
true,
),
(
"AddFormula that does not parse",
ModelCommand::AddFormula {
raw: "Total = (".into(),
target_category: "_Measure".into(),
},
true,
),
(
"LoadModel of an absent file",
ModelCommand::LoadModel(PathBuf::from("absent.improv")),
true,
),
(
"import of an absent JSON",
import("absent.json", None),
true,
),
("import of an absent CSV", import("absent.csv", None), true),
(
"import of a CSV that is not text",
import("bad.csv", None),
true,
),
(
"import of JSON that does not parse",
import("bad.json", None),
true,
),
(
"import at a path with no array",
import("nested.json", Some("nope")),
true,
),
(
"import that builds no model",
import("measures.json", None),
true,
),
(
"AddItem into an absent category",
ModelCommand::AddItem {
category: "Ghost".into(),
item: "East".into(),
},
false,
),
(
"AddItemInGroup into an absent category",
ModelCommand::AddItemInGroup {
category: "Ghost".into(),
item: "East".into(),
group: "Coastal".into(),
},
false,
),
(
"AddFormula into an absent category",
ModelCommand::AddFormula {
raw: "Total = 1".into(),
target_category: "Ghost".into(),
},
false,
),
(
"DeleteView of an absent view",
ModelCommand::DeleteView("Ghost".into()),
false,
),
];
for (what, cmd, aborts) in cases {
let mut app = fresh();
let effects = crate::ui::reduce::model_effects(&app, &cmd);
app.apply_effects(effects);
assert_eq!(
app.last_batch_aborted(),
aborts,
"{what}: which failures abort is part of what this table pins"
);
assert!(
app.last_batch_refused(),
"{what}: failed without refusing, so a frame carrying it is rejected \
only by mutating_frame's `|| last_batch_aborted()` arm"
);
}
}
/// 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"
);
}
}
// ── an empty record resolves against the view that drew it ──────────
/// A host moved onto `Other`, a view hiding South: the client stays on
/// `Default`, where South's empty cell is drawn.
fn host_on_a_view_hiding_south() -> (Host, SessionId) {
let mut host = host();
let other = host.app.model_state.workbook.create_view("Other");
other.set_axis("Region", Axis::Row);
other.set_axis("_Measure", Axis::Column);
other.hide_item("Region", "South");
let (client, bootstrap) = host.connect(10, 10);
let ServerFrame::Snapshot(snapshot) = bootstrap.msg else {
panic!("connect did not send a Snapshot");
};
assert_eq!(
snapshot.view.active_view, "Default",
"setup: client on Default"
);
assert!(host.app.switch_to_view("Other"), "setup: host on Other");
host.app.rebuild_layout();
assert_eq!(host.app().layout.row_count(), 1, "setup: Other hides South");
(host, client)
}
/// improvise-wpxr: a write to an empty record the CLIENT's view draws is
/// accepted whichever view the host sits on. Evidence: test-evidence/command.md.
#[test]
fn a_write_to_an_empty_record_resolves_against_the_clients_view_not_the_hosts() {
let (mut host, client) = host_on_a_view_hiding_south();
let answer = host.model(
client,
ModelCommand::SetCell {
view: "Default".into(),
cell: addressed("South"),
value: CellValue::Number(3.0),
},
);
let refused: Vec<_> = to(&answer, client)
.into_iter()
.filter(|f| matches!(f, ServerFrame::Rejected(_)))
.collect();
assert_eq!(refused, vec![], "the write was refused");
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("South")),
Some(&CellValue::Number(3.0)),
"South was not written"
);
}
/// The mirror image: the host draws South, the view the write names does
/// not. The view named is the one asked. Evidence: test-evidence/command.md.
#[test]
fn a_write_naming_a_view_that_hides_an_empty_record_finds_no_record() {
let mut host = host();
let other = host.app.model_state.workbook.create_view("Other");
other.set_axis("Region", Axis::Row);
other.set_axis("_Measure", Axis::Column);
other.hide_item("Region", "South");
let (client, _) = host.connect(10, 10);
assert_eq!(host.app().layout.row_count(), 2, "setup: host draws South");
let answer = host.model(
client,
ModelCommand::SetCell {
view: "Other".into(),
cell: addressed("South"),
value: CellValue::Number(3.0),
},
);
assert_eq!(
to(&answer, client),
vec![ServerFrame::Rejected(Rejection::RecordGone(
addressed("South").record
))]
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("South")),
None
);
}
/// Writability is asked of the same view as resolution: South is an aggregate
/// in `Default` and hidden in the host's `Other`. Evidence: test-evidence/command.md.
#[test]
fn an_empty_record_its_view_aggregates_is_refused_though_the_host_hides_it() {
let mut host = host();
let wb = &mut host.app.model_state.workbook;
wb.add_category("Product").unwrap();
wb.add_item("Product", "Widget").unwrap();
wb.active_view_mut().set_axis("Product", Axis::None);
let other = wb.create_view("Other");
other.set_axis("Region", Axis::Row);
other.set_axis("_Measure", Axis::Column);
other.set_axis("Product", Axis::Page);
other.hide_item("Region", "South");
host.app.rebuild_layout();
let (client, _) = host.connect(10, 10);
assert!(host.app.switch_to_view("Other"), "setup: host on Other");
host.app.rebuild_layout();
let answer = host.model(
client,
ModelCommand::SetCell {
view: "Default".into(),
cell: addressed("South"),
value: CellValue::Number(3.0),
},
);
assert_eq!(
to(&answer, client),
vec![ServerFrame::Rejected(Rejection::MutationRefused)]
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("South")),
None
);
}
/// improvise-kimw: a frame derives the named view's grid once for its check,
/// and once per command after that. Evidence: test-evidence/command.md.
#[test]
fn a_frame_of_writes_to_another_views_empty_record_derives_its_grid_sparingly() {
use crate::ui::app::GRIDS_DERIVED;
let (mut host, client) = host_on_a_view_hiding_south();
let basis = frame_basis(&host, &["South"]);
let write = |cmd| Command::Model {
basis: basis.clone(),
cmd,
};
let frame = ClientFrame::Commands(vec![
write(ModelCommand::ClearCell {
view: "Default".into(),
cell: addressed("South"),
}),
write(ModelCommand::SetCell {
view: "Default".into(),
cell: addressed("South"),
value: CellValue::Number(4.0),
}),
]);
GRIDS_DERIVED.with(|n| n.set(0));
let answer = host.handle(client, frame);
assert_eq!(to(&answer, client).last(), Some(&ServerFrame::Accepted));
assert_eq!(
GRIDS_DERIVED.with(|n| n.get()),
1 + 2 * 2,
"one for the frame's check, then broken_chain and model_effects per command"
);
}
/// Codex P2 on PR #281: a write naming a view the document does not have is
/// refused, though `Default` draws the record. Evidence: test-evidence/command.md.
#[test]
fn a_write_naming_a_view_the_document_lacks_finds_no_record() {
let mut host = host();
let (client, _) = host.connect(10, 10);
assert_eq!(
host.app().layout.row_count(),
2,
"setup: Default draws South"
);
let answer = host.model(
client,
ModelCommand::SetCell {
view: "Nope".into(),
cell: addressed("South"),
value: CellValue::Number(3.0),
},
);
assert_eq!(
to(&answer, client),
vec![ServerFrame::Rejected(Rejection::RecordGone(
addressed("South").record
))]
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("South")),
None
);
}
/// The same refusal for a record that holds a cell: which view is named is not
/// only a question about empty records. Evidence: test-evidence/command.md.
#[test]
fn a_write_to_a_stored_record_naming_a_view_the_document_lacks_is_refused() {
let mut host = host();
let (client, _) = host.connect(10, 10);
host.model(
client,
ModelCommand::SetCell {
view: "Default".into(),
cell: addressed("North"),
value: CellValue::Number(1.0),
},
);
let answer = host.model(
client,
ModelCommand::ClearCell {
view: "Nope".into(),
cell: addressed("North"),
},
);
assert_eq!(
to(&answer, client),
vec![ServerFrame::Rejected(Rejection::RecordGone(
addressed("North").record
))]
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&cell("North")),
Some(&CellValue::Number(1.0)),
"setup and subject: North was stored, and must still be"
);
}
}