Files
improvise/crates/improvise-wasm-client/tests/browser_round_trip.rs
T
Edward Langley 5690903d60 improvise-vndi: an inline edit opened before a document swap is refused
EditTarget captures document_generation when the editor opens, and
BasisCache::claim_as_read claims it rather than the current one. The
layout tier stays current, as clients.md argues for 2wu3. A red test
through a real Host over MemoryFs: A opens North/Revenue, B loads a
same-shaped document holding 5555, A commits "42" -> DocumentReplaced,
5555 survives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:48:15 -07:00

986 lines
35 KiB
Rust

//! improvise-1ey's stated scope, asserted end to end without a browser: a real
//! `Host` and a real `Client`. See `context/test-evidence/clients.md`.
use improvise_command::ui::app::App;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::Axis;
use improvise_core::workbook::Workbook;
use improvise_protocol::ClientFrame;
use improvise_wasm_client::render::RenderRow;
use improvise_wasm_client::{BrowserKey, Client, RenderModel};
use improvise_ws_server::host::{Addressed, Host};
/// The document the server opens: two regions by two measures, one cell
/// already filled so the client has something to read before it writes.
fn host() -> Host {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(1200.0),
);
let mut app = App::new(wb, None);
app.rebuild_layout();
Host::new(app)
}
/// The page's own loop, minus the DOM: every frame the host addressed to
/// this session, in order, through the one call that folds them.
fn deliver(client: &mut Client, frames: &[Addressed]) {
for Addressed { msg, .. } in frames {
client
.on_frame(&serde_json::to_string(msg).expect("a server frame serializes"))
.expect("the client must fold every frame the host sends");
}
}
/// The one frame the page sends that is not a command: the handshake.
fn send(
host: &mut Host,
id: improvise_command::ui::project::SessionId,
json: &str,
) -> Vec<Addressed> {
let msg: ClientFrame =
serde_json::from_str(json).expect("the server must be able to read what the page sends");
host.handle(id, msg)
}
/// Where the first settable cell is, in **both** coordinate systems — which
/// are two numbers because they differ. See `context/test-evidence/clients.md`.
struct FirstEditable {
/// Indexes `RenderModel::rows`.
visual_row: usize,
/// Indexes the grid the server projects — what a command names.
data_row: usize,
col: usize,
}
/// Every settable cell the model offers, first per row, in both systems.
fn editable_cells(model: &RenderModel) -> Vec<FirstEditable> {
let mut data_row = 0usize;
let mut found = Vec::new();
for (visual_row, row) in model.rows.iter().enumerate() {
let RenderRow::Data { cells, .. } = row else {
continue;
};
if let Some(col) = cells.iter().position(|cell| cell.editable) {
found.push(FirstEditable {
visual_row,
data_row,
col,
});
}
data_row += 1;
}
found
}
fn first_editable(model: &RenderModel) -> FirstEditable {
editable_cells(model)
.into_iter()
.next()
.expect("a pivot of raw data must offer at least one settable cell")
}
#[test]
fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
let mut host = host();
let mut client = Client::new();
// The handshake. The extent comes first because the snapshot seeds the
// *visible* cells: one built at extent zero is an empty grid.
let handshake: ClientFrame = serde_json::from_str(&client.set_viewport(10, 10))
.expect("the viewport frame must be one the server reads");
let ClientFrame::Viewport { rows, cols } = handshake else {
panic!("set_viewport must produce a Viewport frame");
};
let (id, snapshot) = host.connect(rows, cols);
deliver(&mut client, &[snapshot]);
// ── what the page draws ─────────────────────────────────────────────
let model = client.model();
assert_eq!(
model
.rows
.iter()
.flat_map(|r| r.labels())
.collect::<Vec<_>>(),
vec!["North", "South"],
"the seed must carry the row labels framing the cells"
);
assert_eq!(model.col_levels[0], vec!["Revenue", "Cost"]);
assert_eq!(
model.rows[0].cells()[0].text,
"1,200.00",
"the value arrives *formatted* — the separator is the server's view \
format, and a client that re-derived the text would not have it"
);
assert!(model.rows[0].cells()[0].numeric);
// ── typing a number ─────────────────────────────────────────────────
let at = first_editable(&model);
assert_eq!(
(at.data_row, at.visual_row, at.col),
(0, 0, 0),
"with no category on the None axis every cell is a stored cell, and \
this fixture has no group headers, so the two row coordinates \
coincide here — see a_grouped_row_axis_does_not_shift_the_write"
);
let target = client
.edit_target(at.data_row, at.col)
.expect("serialization must not fail")
.expect("an editable cell has an edit target");
let frame = client
.typed_cell(&target, "7500")
.expect("serialization must not fail")
.expect("an editable cell must compose a write");
let frames = send(&mut host, id, &frame);
// ── the server value updated ────────────────────────────────────────
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
])),
Some(&CellValue::Number(7500.0)),
"the authoritative document is the thing that must have moved"
);
// ── and the projection arrives with the new value ───────────────────
assert!(
!frames.is_empty(),
"a cell write owes this client a grid projection"
);
let before = client.revision();
deliver(&mut client, &frames);
assert!(
client.revision() > before,
"folding the fan-out must move the client"
);
assert_eq!(
client.model().rows[at.visual_row].cells()[at.col].text,
"7,500.00",
"the page redraws from the server's formatting, never from the \
\"7500\" the user typed"
);
}
/// A group header is a row of the render model and **not** a row of the grid,
/// and confusing them writes a different record. Evidence file.
#[test]
fn a_grouped_row_axis_does_not_shift_the_write() {
let mut host = {
let mut wb = Workbook::new("Grouped");
wb.add_category("Region").unwrap();
// West first and ungrouped, so the `Coastal` header falls *between* the
// two rows — a leading header is dropped by `AxisFrame::body_from`.
wb.add_item("Region", "West").unwrap();
wb.add_item_in_group("Region", "East", "Coastal").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);
for region in ["East", "West"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), region.into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(1.0),
);
}
let mut app = App::new(wb, None);
app.rebuild_layout();
Host::new(app)
};
let mut client = Client::new();
let (id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
let model = client.model();
let at = editable_cells(&model)
.pop()
.expect("both rows are stored cells");
assert_eq!(
(at.visual_row, at.data_row),
(2, 1),
"precondition: the Coastal header sits between the two rows, so the \
last row is visual 2 and grid 1 — without this divergence the two \
coordinates coincide and nothing below is being tested"
);
let target = client
.edit_target(at.data_row, at.col)
.expect("serialization must not fail")
.expect("an editable cell has an edit target");
let frame = client
.typed_cell(&target, "4200")
.expect("serialization must not fail")
.expect("an editable cell must compose a write");
send(&mut host, id, &frame);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&CellKey::new(vec![
("Region".into(), "East".into()),
("_Measure".into(), "Revenue".into()),
])),
Some(&CellValue::Number(4200.0)),
"the write must land on grid row 1, East"
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Revenue".into()),
])),
Some(&CellValue::Number(1.0)),
"and must not have shifted: passing the VISUAL index writes nothing \
at all here (row 2 is off the grid), and in a document with one \
more row it would silently write the wrong one"
);
}
/// The other half of ADR 002, end to end: an aggregate renders non-editable,
/// so the page never offers an editor `SetCell` could not be composed for.
#[test]
fn an_aggregated_cell_reaches_the_page_as_not_editable() {
let mut host = {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
// A regular category left on the None axis is what makes a cell an
// aggregate — the same question `EditOrDrill` asks in the TUI.
wb.add_category("Product").unwrap();
wb.add_item("Product", "Shirts").unwrap();
wb.add_item("Product", "Hats").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.active_view_mut().set_axis("Product", Axis::None);
let mut app = App::new(wb, None);
app.rebuild_layout();
Host::new(app)
};
let mut client = Client::new();
let (_id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
let model = client.model();
assert!(
model
.rows
.iter()
.flat_map(|r| r.cells())
.all(|c| !c.editable),
"every cell here aggregates over Product, so none may be written"
);
assert_eq!(
client.edit_target(0, 0).unwrap(),
None,
"and there is no cell to type into, because the id is of the wrong kind"
);
}
/// A resize is a fan-out with nothing dirty, and it is the page's job to send
/// one: no command carries the extent.
#[test]
fn a_resize_reprojects_the_grid_at_the_new_extent() {
let mut host = host();
let mut client = Client::new();
let (id, snapshot) = host.connect(1, 1);
deliver(&mut client, &[snapshot]);
assert_eq!(
client.model().rows.len(),
1,
"a one-row viewport is seeded with one row"
);
let frames = send(&mut host, id, &client.set_viewport(10, 10));
deliver(&mut client, &frames);
assert_eq!(
client.model().rows.len(),
2,
"the resize must bring the rest of the grid"
);
assert_eq!(client.model().col_levels[0], vec!["Revenue", "Cost"]);
}
/// improvise-955n: **the one real producer of a multi-command frame must still
/// be accepted** — and must land. See `context/test-evidence/clients.md`.
#[test]
fn a_commit_keypress_is_one_mutation_among_view_commands_and_is_accepted() {
use improvise_protocol::ServerFrame;
let mut host = host();
let mut client = Client::new();
let handshake: ClientFrame = serde_json::from_str(&client.set_viewport(10, 10))
.expect("the viewport frame must be one the server reads");
let ClientFrame::Viewport { rows, cols } = handshake else {
panic!("set_viewport must produce a Viewport frame");
};
let (id, snapshot) = host.connect(rows, cols);
deliver(&mut client, &[snapshot]);
// Type into the cell under the cursor the way the page does: enter edit
// mode, type, commit.
for key in ["i", "8", "8", "8"] {
if let Some(frame) = client
.on_key_event(key, false, false, false, false)
.unwrap()
{
send(&mut host, id, &frame);
}
}
let commit = client
.on_key_event("Enter", false, false, false, false)
.expect("serialization must not fail")
.expect("a commit must compose a frame");
// The shape under test: several commands, exactly one a mutation —
// asserted, or a `commit` that stopped batching would pass vacuously.
let parsed: ClientFrame = serde_json::from_str(&commit).expect("the commit frame parses");
let ClientFrame::Commands(cmds) = &parsed else {
panic!("a commit must be a Commands frame, got {parsed:?}");
};
assert!(cmds.len() > 1, "the shape under test is a batch: {cmds:?}");
// A characterization of what `keys::commit` emits, **not** a protocol
// constraint: a frame may carry more than one mutation.
assert_eq!(
cmds.iter()
.filter(|c| matches!(c, improvise_protocol::Command::Model { .. }))
.count(),
1,
"keys::commit is expected to emit exactly one mutation: {cmds:?}"
);
let answer = send(&mut host, id, &commit);
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"the batch a real keypress produces must not be refused: {answer:?}"
);
assert_eq!(
host.app()
.model_state
.workbook
.model
.get_cell(&CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
])),
// `i` seeds the edit buffer, so typing 888 appends. Asserting the
// *appended* value is what makes this more than a coincidence.
Some(&CellValue::Number(1_200_888.0)),
"and it must actually land — 'not refused' alone would pass against \
a host that dropped the frame"
);
}
// ── the tile-bar route into records mode (improvise-bd9o) ───────────────
/// Drive one key all the way round: the client resolves it, the host answers
/// whatever it composed, and every frame comes back. Evidence file.
fn press(
host: &mut Host,
id: improvise_command::ui::project::SessionId,
client: &mut Client,
key: &str,
) {
let Some(frame) = client.key(&improvise_wasm_client::BrowserKey::new(key)) else {
return;
};
let json = serde_json::to_string(&frame).expect("a client frame serializes");
let frames = send(host, id, &json);
deliver(client, &frames);
}
/// Walk the tile cursor to a named category and put it on `axis`'s key. The
/// tile bar's own order is the host's, so it is read rather than assumed.
fn tile_to(
host: &mut Host,
id: improvise_command::ui::project::SessionId,
client: &mut Client,
category: &str,
key: &str,
) {
let at = client
.state()
.tiles
.tiles
.iter()
.position(|t| t.category == category)
.unwrap_or_else(|| panic!("the tile bar must carry {category}"));
while client.state().view.tile_cat_idx < at {
press(host, id, client, "l");
}
while client.state().view.tile_cat_idx > at {
press(host, id, client, "h");
}
press(host, id, client, key);
}
/// `toggle-records-mode` is declined and the tile bar reaches records mode
/// anyway (improvise-bd9o). Evidence file.
#[test]
fn the_tile_bar_route_into_records_mode_does_not_strand_the_user() {
let mut host = host();
let mut client = Client::new();
let handshake: ClientFrame = serde_json::from_str(&client.set_viewport(10, 10))
.expect("the viewport frame must be one the server reads");
let ClientFrame::Viewport { rows, cols } = handshake else {
panic!("set_viewport must produce a Viewport frame");
};
let (id, snapshot) = host.connect(rows, cols);
deliver(&mut client, &[snapshot]);
press(&mut host, id, &mut client, "T");
tile_to(&mut host, id, &mut client, "_Index", "r");
tile_to(&mut host, id, &mut client, "_Dim", "c");
press(&mut host, id, &mut client, "Escape");
assert!(
client.state().grid.records,
"the tile-bar route must actually reach records mode, or this test is \
not standing where the ticket stood"
);
// Every column of the row, because the silence had TWO sources and a
// fixture on one of them cannot see the other. Evidence file.
let cols = client.state().grid.extent.cols;
assert!(
cols >= 3,
"the fixture must reach past the coordinate column"
);
for col in 0..cols {
while client.state().view.cursor().selected.1 < col {
press(&mut host, id, &mut client, "l");
}
let kind = format!(
"{:?}",
client.state().grid.cell(0, col).and_then(|c| c.id.clone())
);
// A sentinel rather than a key: clearing the line must not itself be
// a keypress this client might decline.
client.state_mut().view.status_msg = "\u{0}unsaid".to_string();
press(&mut host, id, &mut client, "i");
assert_ne!(
client.state().view.status_msg,
"\u{0}unsaid",
"column {col} ({kind}): a key whose commit this client cannot \
compose must say so"
);
assert!(
!client.state().view.mode.is_editing(),
"column {col} ({kind}): the editor must not open on a cell this \
client cannot commit — an Enter that is a black hole"
);
}
}
// ── Ctrl-S, all the way to storage (improvise-jz6w) ─────────────────────
/// The same document, over `MemoryFs` and knowing where it lives, so a save
/// has somewhere to land and the test can read it back.
fn host_with_a_path() -> (Host, std::path::PathBuf) {
let path = std::path::PathBuf::from("doc.improv");
let mut host = host();
host.app_mut().model_state.file_path = Some(path.clone());
host.app_mut().storage = Box::new(improvise_command::storage::MemoryFs::new());
(host, path)
}
/// Ctrl-S, as a browser reports it.
fn ctrl_s() -> improvise_wasm_client::BrowserKey {
improvise_wasm_client::BrowserKey {
key: "s".to_string(),
ctrl: true,
alt: false,
shift: false,
meta: false,
}
}
/// improvise-jz6w: `ClientFrame::Save` existed and `Host::handle` answered it,
/// but no `KeyOutcome` could name it. See `context/test-evidence/clients.md`.
#[test]
fn ctrl_s_in_the_browser_writes_the_hosts_document() {
let (mut host, path) = host_with_a_path();
let mut client = Client::new();
let handshake: ClientFrame =
serde_json::from_str(&client.set_viewport(10, 10)).expect("the viewport frame parses");
let ClientFrame::Viewport { rows, cols } = handshake else {
panic!("set_viewport must produce a Viewport frame");
};
let (id, snapshot) = host.connect(rows, cols);
deliver(&mut client, &[snapshot]);
assert!(
host.app().storage.read(&path).is_err(),
"nothing may be on storage before the key is pressed"
);
let frame = client
.key(&ctrl_s())
.expect("Ctrl-S must produce a frame to send");
assert_eq!(
frame,
ClientFrame::Save,
"the frame must be the save REQUEST — a Key frame is the black hole \
this ticket is about, and a Commands batch is the atomicity Save is \
a frame to escape"
);
let answer = send(&mut host, id, &serde_json::to_string(&frame).unwrap());
let written = String::from_utf8(
host.app()
.storage
.read(&path)
.expect("the keypress must have reached this host's storage"),
)
.expect("the document is text");
assert!(
written.contains("North"),
"and it must be this document, got:\n{written}"
);
assert!(
!answer
.iter()
.any(|a| matches!(a.msg, improvise_protocol::ServerFrame::Rejected(_))),
"a save that landed is not a rejection: {answer:?}"
);
}
/// A refused frame must leave this client where the **host** says it is, and
/// the host says so in its own mirror. See `context/test-evidence/clients.md`.
#[test]
fn a_refused_commit_leaves_the_client_where_the_host_says_it_is() {
let mut host = host();
let mut client = Client::new();
let handshake: ClientFrame =
serde_json::from_str(&client.set_viewport(10, 10)).expect("a viewport frame");
let ClientFrame::Viewport { rows, cols } = handshake else {
panic!("set_viewport must produce a Viewport frame");
};
let (id, snapshot) = host.connect(rows, cols);
deliver(&mut client, &[snapshot]);
// Typing carries no `Command::Model`, so none of it is refusable: each
// frame lands on the host and moves both ends together.
for key in ["i", "7", "5", "0", "0"] {
let frame = client
.key(&BrowserKey::new(key))
.expect("every key in this run resolves locally");
let answers = send(&mut host, id, &serde_json::to_string(&frame).expect("json"));
deliver(&mut client, &answers);
}
assert_eq!(
client.state().view.mode,
host.sessions().get(id).expect("attached").view().mode,
"the two ends must agree BEFORE the refusal, or the refusal proves nothing"
);
// The host's layout moves under a client that is told nothing, so the
// commit it is about to compose claims a basis that has gone.
host.app_mut()
.apply_effects(vec![Box::new(improvise_command::ui::effect::AddCategory(
"Channel".into(),
))]);
let commit = client
.key(&BrowserKey::new("Enter"))
.expect("a commit frame");
let answers = send(
&mut host,
id,
&serde_json::to_string(&commit).expect("json"),
);
assert!(
answers
.iter()
.any(|a| matches!(a.msg, improvise_protocol::ServerFrame::Rejected(_))),
"this fixture exists to be refused; it was answered {answers:?}"
);
deliver(&mut client, &answers);
// Everything but the sentence, which is this client's own notification and
// is written after the rollback: the mirror has no way to hold one.
let mut told = client.state().view.clone();
let announced = std::mem::take(&mut told.status_msg);
assert_eq!(
&told,
host.sessions().get(id).expect("attached").view(),
"a refused frame moved the host's mirror not at all, so a client that \
kept its optimistic fold holds a view slice the host disowns"
);
assert!(
announced.contains("not applied"),
"and the user must be told, not silently rewound: {announced:?}"
);
assert_eq!(
told.buffers.get("edit").map(String::as_str),
Some("12007500"),
"the rollback restores the typing rather than costing it (improvise-3al1)"
);
}
/// improvise-2wu3: the page freezes the screen while an editor is open, but the
/// fold goes on. Evidence: `context/test-evidence/clients.md`.
#[test]
fn an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{Command, ModelCommand};
let mut host = host();
let mut editor = Client::new();
let (a, snapshot) = host.connect(10, 10);
deliver(&mut editor, &[snapshot]);
let (b, _) = host.connect(10, 10);
// A opens an editor on the North/Revenue cell it is looking at.
let clicked = editor
.record_cell_at(0, 0)
.expect("North/Revenue is stored");
let target = editor
.edit_target(0, 0)
.expect("serialization must not fail")
.expect("a stored cell has an edit target");
// B hides North; A folds the result without repainting, as the page does.
let view = host.app().view_name().to_string();
let hide = caught_up(
host.app(),
ModelCommand::HideItem {
view,
category: "Region".into(),
item: "North".into(),
},
);
let relayed = host.handle(b, ClientFrame::command(hide));
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
deliver(&mut editor, &for_a);
assert_ne!(
editor.record_cell_at(0, 0),
Some(clicked.clone()),
"the precondition: the rows really renumbered under the editor"
);
let json = editor
.typed_cell(&target, "42")
.expect("serialization must not fail")
.expect("a stored cell composes a write");
let ClientFrame::Commands(cmds) = serde_json::from_str(&json).expect("a client frame") else {
panic!("a write is a Commands frame");
};
let written = match cmds.as_slice() {
[
Command::Model {
cmd: ModelCommand::SetCell { cell, .. },
..
},
] => cell.clone(),
other => panic!("expected one SetCell, got {other:?}"),
};
assert_eq!(
written, clicked,
"the write landed on a row the user never looked at"
);
}
/// improvise-0j0f: the editor's identity was captured at open but its basis at
/// commit, so another client's write was lost. Evidence: test-evidence/clients.md.
#[test]
fn an_edit_is_refused_when_the_cell_was_written_while_the_editor_was_open() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
let north = CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut host = host();
let mut editor = Client::new();
let (a, snapshot) = host.connect(10, 10);
deliver(&mut editor, &[snapshot]);
let (b, _) = host.connect(10, 10);
// A opens an editor on North/Revenue, which shows 1200.
let clicked = editor
.record_cell_at(0, 0)
.expect("North/Revenue is stored");
let target = editor
.edit_target(0, 0)
.expect("serialization must not fail")
.expect("a stored cell has an edit target");
// B writes 999 there; A folds the fresh value and basis, and does not repaint.
let write = caught_up(
host.app(),
ModelCommand::SetCell {
cell: clicked,
value: CellValue::Number(999.0),
},
);
let relayed = host.handle(b, ClientFrame::command(write));
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
deliver(&mut editor, &for_a);
let commit = editor
.typed_cell(&target, "42")
.expect("serialization must not fail")
.expect("a stored cell composes a write");
let answer = send(&mut host, a, &commit);
assert!(
answer
.iter()
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::RecordMoved(_)))),
"the edit was composed against 1200 and must be refused: {answer:?}"
);
assert_eq!(
host.app().model_state.workbook.model.get_cell(&north),
Some(&CellValue::Number(999.0)),
"B's write was silently overwritten"
);
}
/// improvise-4img: B CLEARS the cell while A's editor is open. Claimed at
/// commit, A's write re-created it. Evidence: test-evidence/clients.md.
#[test]
fn an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
let north = CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut host = host();
let mut editor = Client::new();
let (a, snapshot) = host.connect(10, 10);
deliver(&mut editor, &[snapshot]);
let (b, _) = host.connect(10, 10);
let clicked = editor
.record_cell_at(0, 0)
.expect("North/Revenue is stored");
let target = editor
.edit_target(0, 0)
.expect("serialization must not fail")
.expect("a stored cell has an edit target");
let clear = caught_up(host.app(), ModelCommand::ClearCell(clicked));
let relayed = host.handle(b, ClientFrame::command(clear));
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
deliver(&mut editor, &for_a);
assert_eq!(
host.app().model_state.workbook.model.get_cell(&north),
None,
"the precondition: B really cleared the cell"
);
let commit = editor
.typed_cell(&target, "42")
.expect("serialization must not fail")
.expect("a stored cell composes a write");
let answer = send(&mut host, a, &commit);
assert!(
answer
.iter()
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::RecordMoved(_)))),
"the edit was composed against 1200 and must be refused: {answer:?}"
);
assert_eq!(
host.app().model_state.workbook.model.get_cell(&north),
None,
"A's commit re-created a cell B had cleared"
);
}
/// improvise-vndi: an editor opened before another client's `LoadModel` must not
/// write into the loaded document. Evidence: test-evidence/clients.md.
#[test]
fn an_edit_opened_before_a_document_swap_is_refused_in_the_new_document() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
let north = CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]);
// The incoming document keeps every name, view included (gotcha 39's
// fixture warning), and differs only in the one value the edit targets.
let path = std::path::PathBuf::from("other.improv");
let mut host = host();
host.app_mut().storage = Box::new(improvise_command::storage::MemoryFs::new());
let mut other = host.app().model_state.workbook.clone();
other
.model
.set_cell(north.clone(), CellValue::Number(5555.0));
improvise_command::persistence::save_to(&*host.app().storage, &other, &path).unwrap();
let mut editor = Client::new();
let (a, snapshot) = host.connect(10, 10);
deliver(&mut editor, &[snapshot]);
let (b, _) = host.connect(10, 10);
// A opens an editor on North/Revenue, which shows 1200.
let target = editor
.edit_target(0, 0)
.expect("serialization must not fail")
.expect("a stored cell has an edit target");
// B loads the other document; A folds its rebase Snapshot, editor still open.
let generation = host.app().document_generation();
let load = caught_up(host.app(), ModelCommand::LoadModel(path));
let relayed = host.handle(b, ClientFrame::command(load));
assert_eq!(
host.app().document_generation(),
generation + 1,
"setup: the load must really replace the document"
);
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
deliver(&mut editor, &for_a);
let commit = editor
.typed_cell(&target, "42")
.expect("serialization must not fail")
.expect("a stored cell composes a write");
let answer = send(&mut host, a, &commit);
assert!(
answer
.iter()
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::DocumentReplaced))),
"the edit was composed against the outgoing document: {answer:?}"
);
assert_eq!(
host.app().model_state.workbook.model.get_cell(&north),
Some(&CellValue::Number(5555.0)),
"the loaded document's value was overwritten by an edit its user never saw"
);
}
// ── numbers across the serialized wire (improvise-6oy4) ─────────────────
/// A value serde_json's default f64 parser reads back one ULP low. Evidence file.
const LOSSY: f64 = 0.248_157_142_857_142_85;
fn south(measure: &str) -> CellKey {
CellKey::new(vec![
("Region".into(), "South".into()),
("_Measure".into(), measure.into()),
])
}
/// `host()` with the lossy value in South/Revenue: row 1, off the cursor's origin.
fn lossy_host() -> Host {
let mut host = host();
host.app_mut()
.model_state
.workbook
.model
.set_cell(south("Revenue"), CellValue::Number(LOSSY));
host.app_mut().rebuild_layout();
host
}
fn bits(value: Option<&CellValue>) -> Option<u64> {
match value {
Some(CellValue::Number(n)) => Some(n.to_bits()),
other => panic!("expected a number, got {other:?}"),
}
}
/// Both hops of `yy`/`p` in a browser are serialized: the value down in the
/// projection, the paste back up in a `SetCell`. Evidence file.
#[test]
fn a_yanked_number_crosses_the_serialized_wire_exactly() {
let mut host = lossy_host();
let mut client = Client::new();
// `set_viewport` records the window; without it the cursor keys do nothing.
client.set_viewport(10, 10);
let (id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
for key in ["j", "y", "y"] {
press(&mut host, id, &mut client, key);
}
assert_eq!(
client.state().view.cursor().selected,
(1, 0),
"the source must be South/Revenue, off index 0"
);
assert_eq!(
bits(client.state().view.yanked.as_ref()),
Some(LOSSY.to_bits()),
"host to client: the projected value must arrive bit for bit"
);
for key in ["l", "p"] {
press(&mut host, id, &mut client, key);
}
assert_eq!(
bits(
host.app()
.model_state
.workbook
.model
.get_cell(&south("Cost"))
),
Some(LOSSY.to_bits()),
"client to host: the pasted value must land bit for bit"
);
}
/// A typed number is parsed exactly in the client and re-parsed from JSON by
/// the host. Evidence file.
#[test]
fn a_typed_number_crosses_the_serialized_wire_exactly() {
let mut host = host();
let mut client = Client::new();
let (id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
let target = client
.edit_target(1, 1)
.expect("serialization must not fail")
.expect("South/Cost is a stored cell");
let frame = client
.typed_cell(&target, "0.24815714285714285")
.expect("serialization must not fail")
.expect("South/Cost is a stored cell");
send(&mut host, id, &frame);
assert_eq!(
bits(
host.app()
.model_state
.workbook
.model
.get_cell(&south("Cost"))
),
Some(LOSSY.to_bits()),
"the host must store the number the user typed, not its neighbour"
);
}