c355e5e844
Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
2235 lines
79 KiB
Rust
2235 lines
79 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.active_view_mut().set_expand_depth("Region", 1);
|
|
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}"));
|
|
let limit = client.state().tiles.tiles.len() + 1;
|
|
walk_to(host, id, client, "the tile cursor", at, limit, |c| {
|
|
c.state().view.tile_cat_idx
|
|
});
|
|
press(host, id, client, key);
|
|
}
|
|
|
|
/// Press `l` or `h` until `read` gives `target`, failing on a stuck press or
|
|
/// after `limit` presses rather than hanging (improvise-ybum). Evidence file.
|
|
fn walk_to(
|
|
host: &mut Host,
|
|
id: improvise_command::ui::project::SessionId,
|
|
client: &mut Client,
|
|
what: &str,
|
|
target: usize,
|
|
limit: usize,
|
|
read: impl Fn(&Client) -> usize,
|
|
) {
|
|
for _ in 0..limit {
|
|
let now = read(client);
|
|
if now == target {
|
|
return;
|
|
}
|
|
let key = if now < target { "l" } else { "h" };
|
|
press(host, id, client, key);
|
|
assert_ne!(
|
|
read(client),
|
|
now,
|
|
"{what} is stuck at {now}: `{key}` did not move it toward {target}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
read(client),
|
|
target,
|
|
"{what} did not reach {target} in {limit} presses"
|
|
);
|
|
}
|
|
|
|
/// `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"
|
|
);
|
|
let mut coordinates = 0;
|
|
for col in 0..cols {
|
|
walk_to(
|
|
&mut host,
|
|
id,
|
|
&mut client,
|
|
"the column cursor",
|
|
col,
|
|
cols + 1,
|
|
|c| c.state().view.cursor().selected.1,
|
|
);
|
|
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");
|
|
// A coordinate's Enter composes a rename since improvise-fdk1, so its
|
|
// editor is a door rather than a black hole.
|
|
if kind.starts_with("Some(Coordinate") {
|
|
coordinates += 1;
|
|
assert!(
|
|
client.state().view.mode.is_editing(),
|
|
"column {col} ({kind}): a coordinate's editor must open"
|
|
);
|
|
press(&mut host, id, &mut client, "Escape");
|
|
continue;
|
|
}
|
|
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"
|
|
);
|
|
}
|
|
assert!(
|
|
coordinates > 0 && coordinates < cols,
|
|
"the walk must cover both a coordinate and a declined column"
|
|
);
|
|
}
|
|
|
|
// ── 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:?}"
|
|
);
|
|
}
|
|
|
|
/// Codex on PR #329: committing a download closed the browser's prompt but
|
|
/// left the host mirror in it, so a later projection could restore stale state.
|
|
#[test]
|
|
fn a_download_commit_keeps_the_host_session_mirror_in_lockstep() {
|
|
use improvise_command::ui::reduce::caught_up;
|
|
use improvise_protocol::{AppMode, ModelCommand, ServerFrame};
|
|
|
|
let mut host = host();
|
|
let mut client = Client::new();
|
|
let (id, snapshot) = host.connect(10, 10);
|
|
deliver(&mut client, &[snapshot]);
|
|
|
|
let open = client
|
|
.key(&BrowserKey {
|
|
key: "w".into(),
|
|
ctrl: true,
|
|
alt: false,
|
|
shift: false,
|
|
meta: false,
|
|
})
|
|
.expect("Ctrl-W opens the save prompt");
|
|
let opened = host.handle(id, open);
|
|
deliver(&mut client, &opened);
|
|
for key in ["d", "o", "c", ".", "i", "m", "p", "r", "o", "v"] {
|
|
press(&mut host, id, &mut client, key);
|
|
}
|
|
assert!(matches!(
|
|
client.state().view.mode,
|
|
AppMode::SavePrompt { .. }
|
|
));
|
|
assert_eq!(
|
|
host.sessions().get(id).expect("attached").view().mode,
|
|
client.state().view.mode,
|
|
"setup: both ends must be in the prompt before Enter"
|
|
);
|
|
|
|
let commit = client
|
|
.key(&BrowserKey::new("Enter"))
|
|
.expect("Enter composes a download request");
|
|
assert!(
|
|
matches!(
|
|
&commit,
|
|
ClientFrame::DownloadDocument { filename } if filename == "doc.improv"
|
|
),
|
|
"the USER RULING is a bytes request, got {commit:?}"
|
|
);
|
|
let answer = host.handle(id, commit);
|
|
assert!(
|
|
answer
|
|
.iter()
|
|
.any(|frame| matches!(frame.msg, ServerFrame::DocumentBytes { .. })),
|
|
"the request must still return bytes: {answer:?}"
|
|
);
|
|
deliver(&mut client, &answer);
|
|
|
|
let mirrored = host.sessions().get(id).expect("attached").view();
|
|
assert_eq!(
|
|
mirrored.mode,
|
|
AppMode::Normal,
|
|
"the host must close the prompt in its session mirror"
|
|
);
|
|
assert_eq!(
|
|
mirrored.buffers.get("save").map(String::as_str),
|
|
Some(""),
|
|
"the host must clear the same minibuffer as the client"
|
|
);
|
|
|
|
// Force a subsequent document projection from another session, then fold
|
|
// what it addresses to the requester.
|
|
let (bystander, _) = host.connect(10, 10);
|
|
let write = caught_up(
|
|
host.app(),
|
|
ModelCommand::SetCell {
|
|
view: "Default".into(),
|
|
cell: client
|
|
.record_cell_at(0, 0)
|
|
.expect("North/Revenue is stored"),
|
|
value: CellValue::Number(77.0),
|
|
},
|
|
);
|
|
let projected = host.handle(bystander, ClientFrame::command(write));
|
|
let for_requester: Vec<Addressed> = projected
|
|
.into_iter()
|
|
.filter(|frame| frame.to == id)
|
|
.collect();
|
|
assert!(
|
|
!for_requester.is_empty(),
|
|
"the regression must process a later projection"
|
|
);
|
|
deliver(&mut client, &for_requester);
|
|
assert_eq!(
|
|
&client.state().view,
|
|
host.sessions().get(id).expect("attached").view(),
|
|
"the later projection must leave the browser and host mirror in lockstep"
|
|
);
|
|
}
|
|
|
|
/// 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 {
|
|
view: "Default".into(),
|
|
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 {
|
|
view: "Default".into(),
|
|
cell: 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"
|
|
);
|
|
}
|
|
|
|
// ── a coordinate rename over the wire (improvise-fdk1) ───────────────────
|
|
|
|
/// Every frame the host addressed, to whichever of the two clients it names.
|
|
fn route(
|
|
frames: &[Addressed],
|
|
a: (improvise_command::ui::project::SessionId, &mut Client),
|
|
b: (improvise_command::ui::project::SessionId, &mut Client),
|
|
) {
|
|
for Addressed { to, msg } in frames {
|
|
let json = serde_json::to_string(msg).expect("a server frame serializes");
|
|
if *to == a.0 {
|
|
a.1.on_frame(&json).expect("a folds every frame");
|
|
}
|
|
if *to == b.0 {
|
|
b.1.on_frame(&json).expect("b folds every frame");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What client B does while A's rename editor is open.
|
|
#[derive(PartialEq)]
|
|
enum Race {
|
|
Quiet,
|
|
WriteRevenue,
|
|
SwapDocument,
|
|
}
|
|
|
|
/// Client A renames North's `Region` to `Zed` in records mode, while B does
|
|
/// `race`. Evidence file.
|
|
fn rename_north(race: Race) -> (Host, Vec<Addressed>) {
|
|
let mut host = host();
|
|
let path = std::path::PathBuf::from("other.improv");
|
|
host.app_mut().storage = Box::new(improvise_command::storage::MemoryFs::new());
|
|
let other = host.app().model_state.workbook.clone();
|
|
improvise_command::persistence::save_to(&*host.app().storage, &other, &path).unwrap();
|
|
let (mut a, mut b) = (Client::new(), Client::new());
|
|
let (a_id, a_snap) = host.connect(10, 10);
|
|
let (b_id, b_snap) = host.connect(10, 10);
|
|
a.set_viewport(10, 10);
|
|
b.set_viewport(10, 10);
|
|
deliver(&mut a, &[a_snap]);
|
|
deliver(&mut b, &[b_snap]);
|
|
|
|
let press_a = |host: &mut Host, a: &mut Client, b: &mut Client, key: &str| {
|
|
let Some(frame) = a.key(&BrowserKey::new(key)) else {
|
|
return Vec::new();
|
|
};
|
|
let json = serde_json::to_string(&frame).expect("a client frame serializes");
|
|
let frames = send(host, a_id, &json);
|
|
route(&frames, (a_id, &mut *a), (b_id, &mut *b));
|
|
frames
|
|
};
|
|
|
|
press_a(&mut host, &mut a, &mut b, "T");
|
|
for (category, key) in [("_Index", "r"), ("_Dim", "c")] {
|
|
let at = a
|
|
.state()
|
|
.tiles
|
|
.tiles
|
|
.iter()
|
|
.position(|t| t.category == category)
|
|
.unwrap_or_else(|| panic!("the tile bar must carry {category}"));
|
|
while a.state().view.tile_cat_idx < at {
|
|
press_a(&mut host, &mut a, &mut b, "l");
|
|
}
|
|
while a.state().view.tile_cat_idx > at {
|
|
press_a(&mut host, &mut a, &mut b, "h");
|
|
}
|
|
press_a(&mut host, &mut a, &mut b, key);
|
|
}
|
|
press_a(&mut host, &mut a, &mut b, "Escape");
|
|
assert!(a.state().grid.records, "A must be standing in records mode");
|
|
|
|
let coordinate = (0..a.state().grid.extent.cols)
|
|
.find(|&c| {
|
|
format!("{:?}", a.state().grid.cell(0, c).and_then(|x| x.id.clone()))
|
|
.starts_with("Some(Coordinate")
|
|
})
|
|
.expect("records mode projects a coordinate column");
|
|
while a.state().view.cursor().selected.1 < coordinate {
|
|
press_a(&mut host, &mut a, &mut b, "l");
|
|
}
|
|
while a.state().view.cursor().selected.1 > coordinate {
|
|
press_a(&mut host, &mut a, &mut b, "h");
|
|
}
|
|
press_a(&mut host, &mut a, &mut b, "i");
|
|
assert!(
|
|
a.state().view.mode.is_editing(),
|
|
"A's editor must open on the coordinate, or nothing here is renamed"
|
|
);
|
|
assert_eq!(
|
|
a.state().view.buffers.get("edit").map(String::as_str),
|
|
Some("North")
|
|
);
|
|
|
|
if race == Race::SwapDocument {
|
|
let generation = host.app().document_generation();
|
|
let load = improvise_command::ui::reduce::caught_up(
|
|
host.app(),
|
|
improvise_protocol::ModelCommand::LoadModel(path),
|
|
);
|
|
let frames = host.handle(b_id, ClientFrame::command(load));
|
|
assert_eq!(
|
|
host.app().document_generation(),
|
|
generation + 1,
|
|
"setup: the load must really replace the document"
|
|
);
|
|
route(&frames, (a_id, &mut a), (b_id, &mut b));
|
|
assert!(
|
|
!a.state().view.mode.is_editing() && a.state().opened.is_none(),
|
|
"the rebase must close A's rename editor and drop its capture"
|
|
);
|
|
return (host, frames);
|
|
}
|
|
if race == Race::WriteRevenue {
|
|
let revenue = (0..b.state().grid.extent.cols)
|
|
.find(|&c| {
|
|
b.record_cell_at(0, c)
|
|
.is_some_and(|cell| cell.measure.as_deref() == Some("Revenue"))
|
|
})
|
|
.expect("B sees North's Revenue");
|
|
let frame = b
|
|
.typed_cell_frame(0, revenue, "999")
|
|
.expect("B composes a write");
|
|
let frames = send(
|
|
&mut host,
|
|
b_id,
|
|
&serde_json::to_string(&frame).expect("json"),
|
|
);
|
|
assert!(
|
|
frames.iter().any(|f| f.to == b_id
|
|
&& matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"B's write must land, or A has nothing to be stale against: {frames:?}"
|
|
);
|
|
route(&frames, (a_id, &mut a), (b_id, &mut b));
|
|
}
|
|
|
|
for key in ["Backspace"; 5] {
|
|
press_a(&mut host, &mut a, &mut b, key);
|
|
}
|
|
for key in ["Z", "e", "d"] {
|
|
press_a(&mut host, &mut a, &mut b, key);
|
|
}
|
|
let answers = press_a(&mut host, &mut a, &mut b, "Enter");
|
|
(host, answers)
|
|
}
|
|
|
|
/// improvise-vndi for a rename: a document swap closes the rename editor, so
|
|
/// no rename crosses it. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_document_swap_closes_an_open_rename_editor() {
|
|
let (host, _) = rename_north(Race::SwapDocument);
|
|
assert_eq!(
|
|
region(&host, "Zed"),
|
|
None,
|
|
"the loaded document was renamed"
|
|
);
|
|
assert!(
|
|
region(&host, "North").is_some(),
|
|
"North must survive the swap"
|
|
);
|
|
}
|
|
|
|
fn region(host: &Host, item: &str) -> Option<CellValue> {
|
|
host.app()
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(&CellKey::new(vec![
|
|
("Region".into(), item.into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
]))
|
|
.cloned()
|
|
}
|
|
|
|
/// improvise-fdk1: the rename moves the record, claimed at the version the
|
|
/// editor opened on. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_browser_renames_a_records_coordinate_and_the_host_moves_the_record() {
|
|
let (host, answers) = rename_north(Race::Quiet);
|
|
assert!(
|
|
answers
|
|
.iter()
|
|
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"the rename must be accepted: {answers:?}"
|
|
);
|
|
assert_eq!(region(&host, "Zed"), Some(CellValue::Number(1200.0)));
|
|
assert_eq!(region(&host, "North"), None, "the record moved, not copied");
|
|
}
|
|
|
|
/// improvise-fdk1 / improvise-0j0f: another client wrote the record while the
|
|
/// editor was open, so the rename is stale. Evidence file.
|
|
#[test]
|
|
fn a_coordinate_rename_another_client_moved_is_refused() {
|
|
let (host, answers) = rename_north(Race::WriteRevenue);
|
|
assert!(
|
|
answers.iter().any(|f| matches!(
|
|
f.msg,
|
|
improvise_protocol::ServerFrame::Rejected(improvise_protocol::Rejection::RecordMoved(
|
|
_
|
|
))
|
|
)),
|
|
"a rename composed against a record someone else wrote must be refused: {answers:?}"
|
|
);
|
|
assert_eq!(
|
|
region(&host, "North"),
|
|
Some(CellValue::Number(999.0)),
|
|
"the other client's write must survive"
|
|
);
|
|
assert_eq!(region(&host, "Zed"), None, "nothing was renamed");
|
|
}
|
|
|
|
/// Three records, so a rename that re-sorts the rows moves another record under
|
|
/// the cursor.
|
|
fn three_records_host() -> Host {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item("_Measure", "Revenue").unwrap();
|
|
for (item, value) in [("North", 1.0), ("South", 2.0), ("West", 3.0)] {
|
|
wb.add_item("Region", item).unwrap();
|
|
wb.model.set_cell(
|
|
CellKey::new(vec![
|
|
("Region".into(), item.into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
]),
|
|
CellValue::Number(value),
|
|
);
|
|
}
|
|
wb.active_view_mut().set_axis("Region", 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-fdk1: the editor's target is captured once the host has answered
|
|
/// this client's own rename, not from the cache it rolled forward. Evidence file.
|
|
#[test]
|
|
fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() {
|
|
let mut host = three_records_host();
|
|
let mut a = Client::new();
|
|
a.set_viewport(10, 10);
|
|
let (id, snapshot) = host.connect(10, 10);
|
|
deliver(&mut a, &[snapshot]);
|
|
press(&mut host, id, &mut a, "T");
|
|
tile_to(&mut host, id, &mut a, "_Index", "r");
|
|
tile_to(&mut host, id, &mut a, "_Dim", "c");
|
|
press(&mut host, id, &mut a, "Escape");
|
|
assert!(a.state().grid.records, "setup: records mode");
|
|
let coordinate = (0..a.state().grid.extent.cols)
|
|
.find(|&c| {
|
|
format!("{:?}", a.state().grid.cell(0, c).and_then(|x| x.id.clone()))
|
|
.starts_with("Some(Coordinate")
|
|
})
|
|
.expect("a coordinate column");
|
|
while a.state().view.cursor().selected.1 < coordinate {
|
|
press(&mut host, id, &mut a, "l");
|
|
}
|
|
let text_at = |a: &Client, row: usize| {
|
|
a.state()
|
|
.grid
|
|
.cell(row, coordinate)
|
|
.map(|c| c.text.clone())
|
|
.unwrap_or_default()
|
|
};
|
|
assert_eq!(text_at(&a, 1), "South", "setup: South is row 1");
|
|
assert_eq!(text_at(&a, 2), "West", "setup: West is row 2");
|
|
press(&mut host, id, &mut a, "j");
|
|
|
|
// South -> Zed re-sorts the rows; the commit re-enters editing one row
|
|
// down, on West as this client's cache stood before the host answered.
|
|
press(&mut host, id, &mut a, "i");
|
|
for key in ["Backspace"; 5] {
|
|
press(&mut host, id, &mut a, key);
|
|
}
|
|
for key in ["Z", "e", "d", "Enter"] {
|
|
press(&mut host, id, &mut a, key);
|
|
}
|
|
assert!(
|
|
a.state().view.mode.is_editing(),
|
|
"the commit re-enters editing"
|
|
);
|
|
let row = a.state().view.cursor().selected.0;
|
|
let shown = text_at(&a, row);
|
|
assert_ne!(
|
|
shown, "West",
|
|
"the answered grid must put another record under the cursor than the \
|
|
stale re-entry did, or nothing separates the two captures"
|
|
);
|
|
|
|
// Since improvise-fy9o/p6v6 the reopened editor is seeded from the answered cell.
|
|
assert_eq!(
|
|
a.state().view.buffers.get("edit"),
|
|
Some(&shown),
|
|
"the reopened editor must hold the answered cell, not the stale one"
|
|
);
|
|
let seeded = shown.chars().count();
|
|
for key in std::iter::repeat_n("Backspace", seeded).chain(["A", "l", "p", "h", "a", "Enter"]) {
|
|
press(&mut host, id, &mut a, key);
|
|
}
|
|
// Zed is reachable since improvise-wdmp: the asker keeps its own placement.
|
|
let values = [("North", 1.0), ("Zed", 2.0), ("West", 3.0)];
|
|
let (_, moved) = values
|
|
.iter()
|
|
.find(|(name, _)| *name == shown)
|
|
.unwrap_or_else(|| panic!("the cursor shows {shown:?}"));
|
|
assert_eq!(
|
|
region(&host, "Alpha"),
|
|
Some(CellValue::Number(*moved)),
|
|
"the second rename must move {shown}, the record the client showed"
|
|
);
|
|
assert_eq!(region(&host, &shown), None);
|
|
assert_eq!(
|
|
region(&host, "West").is_some(),
|
|
shown != "West",
|
|
"West untouched"
|
|
);
|
|
}
|
|
|
|
// ── the keyboard editor's target (improvise-8xq0) ────────────────────────
|
|
|
|
/// What client B does while A's keyboard editor is open on North/Cost.
|
|
#[derive(PartialEq)]
|
|
enum Meanwhile {
|
|
HideNorth,
|
|
WriteNorth,
|
|
HideBoth,
|
|
HideRevenue,
|
|
}
|
|
|
|
/// The commands B sends for `meanwhile`, in order.
|
|
fn meanwhile_commands(
|
|
host: &Host,
|
|
a: &Client,
|
|
meanwhile: &Meanwhile,
|
|
) -> Vec<improvise_protocol::ModelCommand> {
|
|
use improvise_protocol::ModelCommand;
|
|
let hide = |item: &str| ModelCommand::HideItem {
|
|
view: host.app().view_name().to_string(),
|
|
category: "Region".into(),
|
|
item: item.into(),
|
|
};
|
|
match meanwhile {
|
|
Meanwhile::HideNorth => vec![hide("North")],
|
|
Meanwhile::HideBoth => vec![hide("North"), hide("South")],
|
|
Meanwhile::HideRevenue => vec![ModelCommand::HideItem {
|
|
view: host.app().view_name().to_string(),
|
|
category: "_Measure".into(),
|
|
item: "Revenue".into(),
|
|
}],
|
|
Meanwhile::WriteNorth => vec![ModelCommand::SetCell {
|
|
view: host.app().view_name().to_string(),
|
|
cell: a.record_cell_at(0, 0).expect("North/Revenue is stored"),
|
|
value: CellValue::Number(999.0),
|
|
}],
|
|
}
|
|
}
|
|
|
|
/// Client A presses `i` on North/Cost (0,1) and types `42`; B does `meanwhile`
|
|
/// before A presses Enter. Evidence file.
|
|
fn commit_north_cost(meanwhile: Meanwhile) -> (Host, Vec<Addressed>) {
|
|
use improvise_command::ui::reduce::caught_up;
|
|
|
|
let mut host = host();
|
|
let (mut a, mut b) = (Client::new(), Client::new());
|
|
let (a_id, a_snap) = host.connect(10, 10);
|
|
let (b_id, b_snap) = host.connect(10, 10);
|
|
a.set_viewport(10, 10);
|
|
b.set_viewport(10, 10);
|
|
deliver(&mut a, &[a_snap]);
|
|
deliver(&mut b, &[b_snap]);
|
|
for key in ["l", "i", "4", "2"] {
|
|
press(&mut host, a_id, &mut a, key);
|
|
}
|
|
assert_eq!(
|
|
a.state().view.cursor().selected,
|
|
(0, 1),
|
|
"setup: on North/Cost"
|
|
);
|
|
assert!(
|
|
a.state().view.mode.is_editing(),
|
|
"setup: the editor is open"
|
|
);
|
|
|
|
for cmd in meanwhile_commands(&host, &a, &meanwhile) {
|
|
let relayed = host.handle(b_id, ClientFrame::command(caught_up(host.app(), cmd)));
|
|
route(&relayed, (a_id, &mut a), (b_id, &mut b));
|
|
}
|
|
assert!(a.state().view.mode.is_editing(), "setup: still editing");
|
|
if meanwhile == Meanwhile::HideBoth {
|
|
assert!(
|
|
a.state().grid.cell(0, 0).is_none(),
|
|
"setup: no cell is left under the cursor"
|
|
);
|
|
}
|
|
if meanwhile == Meanwhile::HideNorth {
|
|
assert_eq!(
|
|
a.state().grid.row_frame.leaf_labels().first().cloned(),
|
|
Some("South".to_string()),
|
|
"setup: the rows renumbered under the open editor"
|
|
);
|
|
}
|
|
|
|
let frame = a
|
|
.key(&BrowserKey::new("Enter"))
|
|
.expect("a commit composes a frame");
|
|
let json = serde_json::to_string(&frame).expect("a client frame serializes");
|
|
let answers = send(&mut host, a_id, &json);
|
|
(host, answers)
|
|
}
|
|
|
|
fn cell(host: &Host, region: &str, measure: &str) -> Option<CellValue> {
|
|
host.app()
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(&CellKey::new(vec![
|
|
("Region".into(), region.into()),
|
|
("_Measure".into(), measure.into()),
|
|
]))
|
|
.cloned()
|
|
}
|
|
|
|
/// improvise-8xq0: B hid the row under A's open editor, and Enter wrote the
|
|
/// cell under the cursor at Enter. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_keyboard_edit_writes_the_cell_it_opened_on_after_the_rows_renumber() {
|
|
let (host, answers) = commit_north_cost(Meanwhile::HideNorth);
|
|
assert!(
|
|
answers
|
|
.iter()
|
|
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"the write must be accepted: {answers:?}"
|
|
);
|
|
assert_eq!(cell(&host, "South", "Revenue"), None, "South was written");
|
|
assert_eq!(cell(&host, "South", "Cost"), None, "South was written");
|
|
assert_eq!(
|
|
cell(&host, "North", "Cost"),
|
|
Some(CellValue::Number(42.0)),
|
|
"the edit must land on the cell the editor opened on"
|
|
);
|
|
}
|
|
|
|
/// improvise-8xq0: the editor an advance reopens commits to the cell it landed
|
|
/// on, not the one the last commit captured. Evidence file.
|
|
#[test]
|
|
fn a_second_keyboard_commit_writes_the_cell_the_advance_landed_on() {
|
|
let mut host = host();
|
|
let mut a = Client::new();
|
|
a.set_viewport(10, 10);
|
|
let (id, snapshot) = host.connect(10, 10);
|
|
deliver(&mut a, &[snapshot]);
|
|
for key in ["i", "5", "Enter"] {
|
|
press(&mut host, id, &mut a, key);
|
|
}
|
|
assert_eq!(a.state().view.cursor().selected, (1, 0), "setup: advanced");
|
|
assert!(a.state().view.mode.is_editing(), "setup: reopened");
|
|
for key in ["7", "Enter"] {
|
|
press(&mut host, id, &mut a, key);
|
|
}
|
|
assert_eq!(
|
|
cell(&host, "North", "Revenue"),
|
|
Some(CellValue::Number(12005.0))
|
|
);
|
|
assert_eq!(
|
|
cell(&host, "South", "Revenue"),
|
|
Some(CellValue::Number(7.0)),
|
|
"the second commit must write the cell the advance landed on"
|
|
);
|
|
}
|
|
|
|
/// improvise-xz2z, 0j0f's rule on the keyboard path: the basis is the one read
|
|
/// when the editor opened. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_keyboard_edit_is_refused_when_its_record_was_written_while_open() {
|
|
let (host, answers) = commit_north_cost(Meanwhile::WriteNorth);
|
|
assert!(
|
|
answers.iter().any(|f| matches!(
|
|
f.msg,
|
|
improvise_protocol::ServerFrame::Rejected(improvise_protocol::Rejection::RecordMoved(
|
|
_
|
|
))
|
|
)),
|
|
"an edit composed against a record someone else wrote must be refused: {answers:?}"
|
|
);
|
|
assert_eq!(
|
|
cell(&host, "North", "Revenue"),
|
|
Some(CellValue::Number(999.0))
|
|
);
|
|
assert_eq!(cell(&host, "North", "Cost"), None, "nothing was written");
|
|
}
|
|
|
|
/// A's Enter on North/Cost CROSSES B's `meanwhile`: B's relays reach A only
|
|
/// after A has sent. Then A presses Enter again. Evidence file.
|
|
fn cross_north_cost(meanwhile: Meanwhile) -> (Host, Vec<Addressed>, Client) {
|
|
cross_north(&["l", "i", "4", "2"], meanwhile)
|
|
}
|
|
|
|
/// `cross_north_cost`, with the keys that open the editor and type into it.
|
|
fn cross_north(keys: &[&str], meanwhile: Meanwhile) -> (Host, Vec<Addressed>, Client) {
|
|
use improvise_command::ui::reduce::caught_up;
|
|
|
|
let mut host = host();
|
|
let (mut a, mut b) = (Client::new(), Client::new());
|
|
let (a_id, a_snap) = host.connect(10, 10);
|
|
let (b_id, b_snap) = host.connect(10, 10);
|
|
a.set_viewport(10, 10);
|
|
b.set_viewport(10, 10);
|
|
deliver(&mut a, &[a_snap]);
|
|
deliver(&mut b, &[b_snap]);
|
|
for key in keys {
|
|
press(&mut host, a_id, &mut a, key);
|
|
}
|
|
let mut held = Vec::new();
|
|
for cmd in meanwhile_commands(&host, &a, &meanwhile) {
|
|
held.extend(host.handle(b_id, ClientFrame::command(caught_up(host.app(), cmd))));
|
|
}
|
|
let first = a
|
|
.key(&BrowserKey::new("Enter"))
|
|
.expect("a commit composes a frame");
|
|
let json = serde_json::to_string(&first).expect("a client frame serializes");
|
|
let answered = send(&mut host, a_id, &json);
|
|
assert!(
|
|
answered
|
|
.iter()
|
|
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Rejected(_))),
|
|
"setup: the crossing Enter is refused: {answered:?}"
|
|
);
|
|
route(&held, (a_id, &mut a), (b_id, &mut b));
|
|
route(&answered, (a_id, &mut a), (b_id, &mut b));
|
|
assert!(
|
|
a.state().view.mode.is_editing(),
|
|
"setup: the refusal reopens the editor"
|
|
);
|
|
|
|
let retry = a.key(&BrowserKey::new("Enter"));
|
|
let answers = match retry {
|
|
Some(frame) => {
|
|
let json = serde_json::to_string(&frame).expect("a client frame serializes");
|
|
send(&mut host, a_id, &json)
|
|
}
|
|
None => Vec::new(),
|
|
};
|
|
(host, answers, a)
|
|
}
|
|
|
|
/// improvise-8phw: the crossing refusal reopened the editor over South, and the
|
|
/// retry wrote 42 into South/Cost. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_keyboard_edit_retried_after_a_crossing_hide_writes_no_other_cell() {
|
|
let (host, answers, a) = cross_north_cost(Meanwhile::HideNorth);
|
|
assert_eq!(cell(&host, "South", "Cost"), None, "South/Cost was written");
|
|
assert_eq!(
|
|
cell(&host, "South", "Revenue"),
|
|
None,
|
|
"South/Revenue was written"
|
|
);
|
|
assert!(
|
|
!answers
|
|
.iter()
|
|
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"the retry must send no write: {answers:?}"
|
|
);
|
|
assert!(
|
|
a.state().view.mode.is_editing(),
|
|
"the retry declines and leaves the editor open"
|
|
);
|
|
assert!(
|
|
a.state().view.status_msg.contains("has changed"),
|
|
"and says so, rather than going silently upstream: {:?}",
|
|
a.state().view.status_msg
|
|
);
|
|
}
|
|
|
|
/// improvise-8phw, the measure half: the crossing hide leaves the SAME record
|
|
/// under the cursor in another column. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_keyboard_edit_retried_after_a_crossing_column_hide_writes_no_other_measure() {
|
|
let (host, answers, _) = cross_north(&["i", "4", "2"], Meanwhile::HideRevenue);
|
|
assert_eq!(cell(&host, "North", "Cost"), None, "North/Cost was written");
|
|
assert_eq!(
|
|
cell(&host, "North", "Revenue"),
|
|
Some(CellValue::Number(1200.0)),
|
|
"the opened cell is hidden, so nothing may land on it either"
|
|
);
|
|
assert!(
|
|
!answers
|
|
.iter()
|
|
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"the retry must send no write: {answers:?}"
|
|
);
|
|
}
|
|
|
|
/// improvise-8phw's control: a crossing WRITE to the record is refused, and the
|
|
/// retry is the one-keystroke force onto the same cell (gotcha 43).
|
|
#[test]
|
|
fn a_keyboard_edit_retried_after_a_crossing_write_forces_the_same_cell() {
|
|
let (host, answers, _) = cross_north_cost(Meanwhile::WriteNorth);
|
|
assert!(
|
|
answers
|
|
.iter()
|
|
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"the retry is a force: {answers:?}"
|
|
);
|
|
assert_eq!(cell(&host, "North", "Cost"), Some(CellValue::Number(42.0)));
|
|
assert_eq!(
|
|
cell(&host, "North", "Revenue"),
|
|
Some(CellValue::Number(999.0))
|
|
);
|
|
}
|
|
|
|
/// Codex on PR #274: with no cell left under the cursor, Enter went upstream
|
|
/// instead of writing the cell the editor opened on. Evidence file.
|
|
#[test]
|
|
fn a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on() {
|
|
let (host, answers) = commit_north_cost(Meanwhile::HideBoth);
|
|
assert!(
|
|
answers
|
|
.iter()
|
|
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"the write must be sent and accepted: {answers:?}"
|
|
);
|
|
assert_eq!(cell(&host, "North", "Cost"), Some(CellValue::Number(42.0)));
|
|
}
|
|
|
|
// ── races on a coordinate rename (improvise-20ek, improvise-milr) ───────
|
|
|
|
type Sid = improvise_command::ui::project::SessionId;
|
|
|
|
/// A client driven into records mode through the tile bar, its cursor on the
|
|
/// coordinate column of row 0. Returns the session, the client and the column.
|
|
fn records_client_at_coordinate(host: &mut Host) -> (Sid, Client, usize) {
|
|
let mut a = Client::new();
|
|
a.set_viewport(10, 10);
|
|
let (id, snapshot) = host.connect(10, 10);
|
|
deliver(&mut a, &[snapshot]);
|
|
press(host, id, &mut a, "T");
|
|
tile_to(host, id, &mut a, "_Index", "r");
|
|
tile_to(host, id, &mut a, "_Dim", "c");
|
|
press(host, id, &mut a, "Escape");
|
|
assert!(a.state().grid.records, "setup: records mode");
|
|
let coordinate = (0..a.state().grid.extent.cols)
|
|
.find(|&c| {
|
|
format!("{:?}", a.state().grid.cell(0, c).and_then(|x| x.id.clone()))
|
|
.starts_with("Some(Coordinate")
|
|
})
|
|
.expect("a coordinate column");
|
|
while a.state().view.cursor().selected.1 < coordinate {
|
|
press(host, id, &mut a, "l");
|
|
}
|
|
(id, a, coordinate)
|
|
}
|
|
|
|
fn retype(host: &mut Host, id: Sid, a: &mut Client, text: &str) {
|
|
for _ in 0..8 {
|
|
press(host, id, a, "Backspace");
|
|
}
|
|
for ch in text.chars() {
|
|
press(host, id, a, &ch.to_string());
|
|
}
|
|
}
|
|
|
|
/// Another session's frame, applied, with what it owes `a` delivered to `a`.
|
|
fn from_another_client(host: &mut Host, a_id: Sid, a: &mut Client, cmd: ModelCommandArg) {
|
|
let (b_id, _) = host.connect(10, 10);
|
|
let frame = improvise_command::ui::reduce::caught_up(host.app(), cmd);
|
|
let relayed = host.handle(b_id, ClientFrame::command(frame));
|
|
assert!(
|
|
relayed
|
|
.iter()
|
|
.any(|f| f.to == b_id && matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"the other client's write must land: {relayed:?}"
|
|
);
|
|
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a_id).collect();
|
|
deliver(a, &for_a);
|
|
}
|
|
|
|
type ModelCommandArg = improvise_protocol::ModelCommand;
|
|
|
|
/// improvise-20ek: the record an editor opened on is gone, and a retry must not
|
|
/// rename whatever moved under the cursor. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_retry_after_record_gone_renames_no_other_record() {
|
|
use improvise_protocol::{CellId, ModelCommand, Rejection, ServerFrame};
|
|
let mut host = three_records_host();
|
|
let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host);
|
|
assert_eq!(a.state().grid.cell(0, coordinate).unwrap().text, "North");
|
|
let Some(CellId::Coordinate { record: north, .. }) =
|
|
a.state().grid.cell(0, coordinate).unwrap().id.clone()
|
|
else {
|
|
panic!("setup: a coordinate id")
|
|
};
|
|
press(&mut host, a_id, &mut a, "i");
|
|
retype(&mut host, a_id, &mut a, "Zed");
|
|
|
|
from_another_client(
|
|
&mut host,
|
|
a_id,
|
|
&mut a,
|
|
ModelCommand::RenameRecordCoordinate {
|
|
record: north,
|
|
category: "Region".into(),
|
|
item: "Yak".into(),
|
|
},
|
|
);
|
|
|
|
let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes");
|
|
let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap());
|
|
deliver(&mut a, &answer);
|
|
assert!(
|
|
answer
|
|
.iter()
|
|
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::RecordGone(_)))),
|
|
"the first Enter is RecordGone: {answer:?}"
|
|
);
|
|
let row = a.state().view.cursor().selected.0;
|
|
assert!(
|
|
a.state().view.mode.is_editing()
|
|
&& a.state().grid.cell(row, coordinate).unwrap().text != "Yak",
|
|
"setup: the editor is back, over a record other than the one renamed away"
|
|
);
|
|
|
|
press(&mut host, a_id, &mut a, "Enter");
|
|
assert_eq!(
|
|
region(&host, "Zed"),
|
|
None,
|
|
"the retry renamed a record never opened"
|
|
);
|
|
assert_eq!(region(&host, "South"), Some(CellValue::Number(2.0)));
|
|
assert_eq!(region(&host, "West"), Some(CellValue::Number(3.0)));
|
|
assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0)));
|
|
}
|
|
|
|
/// improvise-milr: the rename's destination is a record too, and a write to it
|
|
/// this client never saw must refuse the rename. Evidence file.
|
|
#[test]
|
|
fn a_rename_onto_a_record_another_client_just_wrote_is_refused() {
|
|
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
|
|
let mut host = three_records_host();
|
|
let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host);
|
|
let revenue = (0..a.state().grid.extent.cols)
|
|
.find(|&c| {
|
|
a.record_cell_at(1, c)
|
|
.is_some_and(|x| x.measure.as_deref() == Some("Revenue"))
|
|
})
|
|
.expect("South's revenue");
|
|
let south = a.record_cell_at(1, revenue).unwrap();
|
|
let a_view = a.state().view.active_view.clone();
|
|
assert_eq!(a.state().grid.cell(1, coordinate).unwrap().text, "South");
|
|
press(&mut host, a_id, &mut a, "i");
|
|
retype(&mut host, a_id, &mut a, "South");
|
|
|
|
from_another_client(
|
|
&mut host,
|
|
a_id,
|
|
&mut a,
|
|
ModelCommand::SetCell {
|
|
view: a_view,
|
|
cell: south.clone(),
|
|
value: CellValue::Number(999.0),
|
|
},
|
|
);
|
|
|
|
let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes");
|
|
let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap());
|
|
assert!(
|
|
answer.iter().any(|f| matches!(
|
|
f.msg,
|
|
ServerFrame::Rejected(Rejection::RecordMoved(h)) if h == south.record
|
|
)),
|
|
"the destination moved under this client: {answer:?}"
|
|
);
|
|
assert_eq!(region(&host, "South"), Some(CellValue::Number(999.0)));
|
|
assert_eq!(region(&host, "North"), Some(CellValue::Number(1.0)));
|
|
}
|
|
|
|
/// The control for the test above: a destination this client HAS seen at its
|
|
/// current version is overwritten, which is improvise-kndq's policy, unchanged.
|
|
#[test]
|
|
fn a_rename_onto_a_record_this_client_has_seen_still_overwrites_it() {
|
|
let mut host = three_records_host();
|
|
let (a_id, mut a, _) = records_client_at_coordinate(&mut host);
|
|
press(&mut host, a_id, &mut a, "i");
|
|
retype(&mut host, a_id, &mut a, "South");
|
|
let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes");
|
|
let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap());
|
|
assert!(
|
|
answer
|
|
.iter()
|
|
.any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"{answer:?}"
|
|
);
|
|
assert_eq!(
|
|
region(&host, "South"),
|
|
Some(CellValue::Number(1.0)),
|
|
"North's value"
|
|
);
|
|
assert_eq!(region(&host, "North"), None);
|
|
}
|
|
|
|
/// Another session's frame, applied, with what it owes `a` HELD rather than
|
|
/// delivered: `a` has not heard it yet (improvise-9ww8).
|
|
fn held_from_another_client(host: &mut Host, a_id: Sid, cmd: ModelCommandArg) -> Vec<Addressed> {
|
|
let (b_id, _) = host.connect(10, 10);
|
|
let frame = improvise_command::ui::reduce::caught_up(host.app(), cmd);
|
|
let relayed = host.handle(b_id, ClientFrame::command(frame));
|
|
assert!(
|
|
relayed
|
|
.iter()
|
|
.any(|f| f.to == b_id && matches!(f.msg, improvise_protocol::ServerFrame::Accepted)),
|
|
"the other client's write must land: {relayed:?}"
|
|
);
|
|
relayed.into_iter().filter(|f| f.to == a_id).collect()
|
|
}
|
|
|
|
/// A's rename crosses B's rename of North in flight: A composes Enter before
|
|
/// hearing B, then hears B's relays and its own refusal, in that order.
|
|
fn crossing_rename(row: usize) -> Host {
|
|
use improvise_protocol::{CellId, ModelCommand, Rejection, ServerFrame};
|
|
let mut host = three_records_host();
|
|
let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host);
|
|
let Some(CellId::Coordinate { record: north, .. }) =
|
|
a.state().grid.cell(0, coordinate).unwrap().id.clone()
|
|
else {
|
|
panic!("setup: North's coordinate id")
|
|
};
|
|
for _ in 0..row {
|
|
press(&mut host, a_id, &mut a, "j");
|
|
}
|
|
press(&mut host, a_id, &mut a, "i");
|
|
retype(&mut host, a_id, &mut a, "Zed");
|
|
|
|
let held = held_from_another_client(
|
|
&mut host,
|
|
a_id,
|
|
ModelCommand::RenameRecordCoordinate {
|
|
record: north,
|
|
category: "Region".into(),
|
|
item: "Yak".into(),
|
|
},
|
|
);
|
|
let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes");
|
|
let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap());
|
|
deliver(&mut a, &held);
|
|
deliver(&mut a, &answer);
|
|
assert!(
|
|
answer
|
|
.iter()
|
|
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::LayoutMoved))),
|
|
"setup: the crossing is refused on the layout tier, before any record: {answer:?}"
|
|
);
|
|
assert!(
|
|
a.state().view.mode.is_editing(),
|
|
"setup: the editor is back"
|
|
);
|
|
|
|
press(&mut host, a_id, &mut a, "Enter");
|
|
host
|
|
}
|
|
|
|
/// improvise-9ww8, the source gone: B renamed the very record A opened on.
|
|
/// Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_retry_after_a_crossing_rename_of_its_record_renames_no_other_record() {
|
|
let host = crossing_rename(0);
|
|
assert_eq!(
|
|
region(&host, "Zed"),
|
|
None,
|
|
"the retry renamed a record never opened"
|
|
);
|
|
assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0)));
|
|
assert_eq!(region(&host, "South"), Some(CellValue::Number(2.0)));
|
|
assert_eq!(region(&host, "West"), Some(CellValue::Number(3.0)));
|
|
}
|
|
|
|
/// improvise-9ww8, the source alive: B's rename only moves A's record off the
|
|
/// row under the cursor. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_retry_after_a_crossing_reorder_renames_no_other_record() {
|
|
let host = crossing_rename(1);
|
|
assert_eq!(
|
|
region(&host, "West"),
|
|
Some(CellValue::Number(3.0)),
|
|
"the retry renamed West, the record that moved under the cursor"
|
|
);
|
|
assert_ne!(region(&host, "Zed"), Some(CellValue::Number(3.0)));
|
|
assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0)));
|
|
}
|
|
|
|
/// The control for the two crossings above: `RecordMoved` on the SAME record is
|
|
/// still a one-keystroke force. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_retry_after_a_crossing_write_to_its_own_record_is_a_force() {
|
|
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
|
|
let mut host = three_records_host();
|
|
let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host);
|
|
assert_eq!(a.state().grid.cell(0, coordinate).unwrap().text, "North");
|
|
let revenue = (0..a.state().grid.extent.cols)
|
|
.find(|&c| {
|
|
a.record_cell_at(0, c)
|
|
.is_some_and(|x| x.measure.as_deref() == Some("Revenue"))
|
|
})
|
|
.expect("North's revenue");
|
|
let north = a.record_cell_at(0, revenue).unwrap();
|
|
press(&mut host, a_id, &mut a, "i");
|
|
retype(&mut host, a_id, &mut a, "Zed");
|
|
|
|
let held = held_from_another_client(
|
|
&mut host,
|
|
a_id,
|
|
ModelCommand::SetCell {
|
|
view: a.state().view.active_view.clone(),
|
|
cell: north.clone(),
|
|
value: CellValue::Number(9.0),
|
|
},
|
|
);
|
|
let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes");
|
|
let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap());
|
|
deliver(&mut a, &held);
|
|
deliver(&mut a, &answer);
|
|
assert!(
|
|
answer.iter().any(|f| matches!(
|
|
f.msg,
|
|
ServerFrame::Rejected(Rejection::RecordMoved(h)) if h == north.record
|
|
)),
|
|
"setup: the crossing write moves the record A is renaming: {answer:?}"
|
|
);
|
|
|
|
press(&mut host, a_id, &mut a, "Enter");
|
|
assert_eq!(
|
|
region(&host, "Zed"),
|
|
Some(CellValue::Number(9.0)),
|
|
"the retry must rename North, carrying B's write"
|
|
);
|
|
assert_eq!(region(&host, "North"), None);
|
|
}
|
|
|
|
/// improvise-joed on the rename path: a second rename typed before the host
|
|
/// answers the first waits, and keeps its text. Evidence file.
|
|
#[test]
|
|
fn a_rename_that_waits_for_the_host_keeps_the_typed_text() {
|
|
let mut host = three_records_host();
|
|
let mut a = Client::new();
|
|
a.set_viewport(10, 10);
|
|
let (id, snapshot) = host.connect(10, 10);
|
|
deliver(&mut a, &[snapshot]);
|
|
press(&mut host, id, &mut a, "T");
|
|
tile_to(&mut host, id, &mut a, "_Index", "r");
|
|
tile_to(&mut host, id, &mut a, "_Dim", "c");
|
|
press(&mut host, id, &mut a, "Escape");
|
|
assert!(a.state().grid.records, "setup: records mode");
|
|
let coordinate = (0..a.state().grid.extent.cols)
|
|
.find(|&c| {
|
|
format!("{:?}", a.state().grid.cell(0, c).and_then(|x| x.id.clone()))
|
|
.starts_with("Some(Coordinate")
|
|
})
|
|
.expect("a coordinate column");
|
|
while a.state().view.cursor().selected.1 < coordinate {
|
|
press(&mut host, id, &mut a, "l");
|
|
}
|
|
press(&mut host, id, &mut a, "j");
|
|
press(&mut host, id, &mut a, "i");
|
|
for key in ["Backspace"; 5] {
|
|
press(&mut host, id, &mut a, key);
|
|
}
|
|
for key in ["Z", "e", "d"] {
|
|
press(&mut host, id, &mut a, key);
|
|
}
|
|
// The first rename is composed and folded as sent, and never answered.
|
|
let first = a
|
|
.key(&BrowserKey::new("Enter"))
|
|
.expect("the rename composes");
|
|
assert!(
|
|
matches!(&first, ClientFrame::Commands(c) if c.iter().any(|c| matches!(c, improvise_protocol::Command::Model { .. }))),
|
|
"setup: the first Enter renames"
|
|
);
|
|
assert!(
|
|
a.state().view.mode.is_editing(),
|
|
"setup: the commit reopened"
|
|
);
|
|
for key in ["Backspace", "Backspace", "Q"] {
|
|
a.key(&BrowserKey::new(key));
|
|
}
|
|
let typed = a.state().view.buffers.get("edit").cloned();
|
|
assert!(
|
|
typed.as_deref().is_some_and(|t| !t.is_empty()),
|
|
"setup: there is typed text to lose: {typed:?}"
|
|
);
|
|
|
|
let second = a.key(&BrowserKey::new("Enter"));
|
|
assert!(
|
|
a.state().view.status_msg.starts_with("still waiting"),
|
|
"setup: this is the waiting decline: {:?}",
|
|
a.state().view.status_msg
|
|
);
|
|
assert!(a.state().view.mode.is_editing(), "the editor stays open");
|
|
assert_eq!(
|
|
a.state().view.buffers.get("edit").cloned(),
|
|
typed,
|
|
"the text the user typed must survive the decline: {second:?}"
|
|
);
|
|
}
|
|
|
|
/// improvise-joed on #251's `Gone` decline: the retry declined, and the welded
|
|
/// `clear-buffer` still emptied the editor. Evidence file.
|
|
#[test]
|
|
fn a_retry_whose_cell_is_gone_keeps_the_typed_text() {
|
|
let (_, _, a) = cross_north_cost(Meanwhile::HideNorth);
|
|
assert!(
|
|
a.state().view.status_msg.contains("has changed"),
|
|
"setup: this is the Gone decline: {:?}",
|
|
a.state().view.status_msg
|
|
);
|
|
assert_eq!(
|
|
a.state().view.buffers.get("edit").map(String::as_str),
|
|
Some("42"),
|
|
"the text the user typed must survive the decline"
|
|
);
|
|
}
|
|
|
|
/// `host()` with `Profit = Revenue - Cost`, so a grid with Region on None
|
|
/// shows aggregates and a derived column, and no stored cell.
|
|
fn host_with_profit() -> 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.model.add_formula(
|
|
improvise_command::formula::parse_formula("Profit = Revenue - Cost", "_Measure").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)
|
|
}
|
|
|
|
/// improvise-3h9w: B's reframe left A's cursor on an aggregate, and Enter was
|
|
/// refused for THAT cell instead of writing the captured one. Evidence file.
|
|
#[test]
|
|
fn a_captured_edit_commits_when_the_cursor_now_sits_on_an_aggregate() {
|
|
use improvise_command::ui::reduce::caught_up;
|
|
use improvise_protocol::ModelCommand;
|
|
|
|
let mut host = host_with_profit();
|
|
let (mut a, mut b) = (Client::new(), Client::new());
|
|
let (a_id, a_snap) = host.connect(10, 10);
|
|
let (b_id, b_snap) = host.connect(10, 10);
|
|
a.set_viewport(10, 10);
|
|
b.set_viewport(10, 10);
|
|
deliver(&mut a, &[a_snap]);
|
|
deliver(&mut b, &[b_snap]);
|
|
for key in ["l", "i", "4", "2"] {
|
|
press(&mut host, a_id, &mut a, key);
|
|
}
|
|
let region_off = ModelCommand::SetAxis {
|
|
view: host.app().view_name().to_string(),
|
|
category: "Region".into(),
|
|
axis: Axis::None,
|
|
};
|
|
let relayed = host.handle(
|
|
b_id,
|
|
ClientFrame::command(caught_up(host.app(), region_off)),
|
|
);
|
|
route(&relayed, (a_id, &mut a), (b_id, &mut b));
|
|
assert!(a.state().view.mode.is_editing(), "setup: still editing");
|
|
let (row, col) = a.state().view.cursor().selected;
|
|
assert!(
|
|
matches!(
|
|
a.state().grid.cell(row, col).and_then(|c| c.id.clone()),
|
|
Some(improvise_protocol::CellId::Aggregate(_))
|
|
),
|
|
"setup: the cursor now sits on an aggregate"
|
|
);
|
|
|
|
press(&mut host, a_id, &mut a, "Enter");
|
|
assert_eq!(
|
|
cell(&host, "North", "Cost"),
|
|
Some(CellValue::Number(42.0)),
|
|
"the captured cell must be written: status {:?}",
|
|
a.state().view.status_msg
|
|
);
|
|
}
|