e497aef110
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
3416 lines
129 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|