bce496b715
This branch carries #282 and #286 (5m9z/7mro). The other side now carries #227 and #272 (fy9o/p6v6). This applies the #227 x #295 recipe (resolve-b/fy9o-with-295, 3918b126) on top of 8xq0's captured-target commit. - client.rs: `settle_awaited_seed()` then `settle_declined_editor()`. One `settle_awaited_seed`, which needs both no unanswered write and a projected cell (`projected_seed`). - resolve.rs: - `opened_on(state, ..)` and `seeded` from p6v6. 8xq0's `opened_at_cursor` is kept. - 8xq0's `written` over the captured `OpenedCell` and `still_waiting` are kept, and fy9o's `cleared` is added. The position-based `written` and `record_at_cursor` from the other side are dropped, because 8xq0 replaced them. - `commit_value` and `rename_coordinate`: `cleared` after `ChangeMode(Normal)`, after every decline's early return (joed). `advanced(.., writes)`. - `reopened_at(.., writes)`: `declined_at` first, then `awaiting_seed` after a write or on an unprojected landing, else `opened_on(state, ..)`. One `awaiting_seed`. - The capture still reads through `projected_at` (`cell_at_cursor`), and `refused_here`'s 3h9w arm is kept (`state.opened.is_some()`). - lockstep: #286's `sent` renamed `sent_to_host` (E0428). The held-cell test delivers its held replies before it reads an awaiting editor. - Ids for the three p6v6 fixture cells that had none. - context/test-evidence/clients.md: both sides kept, plus the held-cell amendment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
3523 lines
126 KiB
Rust
3523 lines
126 KiB
Rust
//! The client's local key resolution, held against the host that owns the
|
|
//! keymap. What that buys and what it costs: the evidence file.
|
|
|
|
use improvise_command::ui::app::App;
|
|
use improvise_command::ui::project::view_summaries;
|
|
use improvise_command::ui::project::{Viewport, category_tree_of, grid_of, tile_entries};
|
|
use improvise_command::ui::reduce::{Checked, apply_command, caught_up, reduce_key};
|
|
use improvise_command::view::Axis;
|
|
use improvise_command::workbook::Workbook;
|
|
use improvise_protocol::{
|
|
AppMode, ClientFrame, KeyCode, KeyEvent, KeyModifiers, ViewState, flatten_cat_tree, reduce_view,
|
|
};
|
|
use improvise_wasm_client::{BrowserKey, ClientState, KeyOutcome, resolve};
|
|
|
|
/// Two rows on the row axis, so a `Down` has somewhere to go — a one-item
|
|
/// fixture cannot tell "resolved" from "nothing happened".
|
|
fn host() -> App {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item("Region", "North").unwrap();
|
|
wb.add_item("Region", "South").unwrap();
|
|
wb.active_view_mut().set_axis("Region", Axis::Row);
|
|
App::new(wb, None)
|
|
}
|
|
|
|
/// The headline claim: for every keypress the client answers itself, the host
|
|
/// lands on the same buffers. Why every mode: the evidence file.
|
|
#[test]
|
|
fn a_locally_typed_character_lands_where_the_host_would_put_it() {
|
|
for mode in AppMode::text_entry() {
|
|
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
|
|
|
|
let mut app = host();
|
|
app.view_state.mode = mode.clone();
|
|
app.view_state.buffers.insert(buffer.clone(), "Reg".into());
|
|
|
|
let mut client = ClientState {
|
|
view: app.view_state.clone(),
|
|
..ClientState::default()
|
|
};
|
|
|
|
let event = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!(
|
|
"{} should type locally",
|
|
improvise_protocol::mode_label(&mode)
|
|
);
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(
|
|
client.view.buffers,
|
|
app.view_state.buffers,
|
|
"{}: client and host disagree about the buffers after typing",
|
|
improvise_protocol::mode_label(&mode)
|
|
);
|
|
assert_eq!(
|
|
client.view.buffers.get(&buffer).map(String::as_str),
|
|
Some("Regi"),
|
|
"{}: the character must be appended, not replace the buffer",
|
|
improvise_protocol::mode_label(&mode)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The same, for the pop. CommandMode is excluded because it does not resolve
|
|
/// locally at all — the next test is about that.
|
|
#[test]
|
|
fn a_locally_popped_character_lands_where_the_host_would_put_it() {
|
|
for mode in AppMode::text_entry() {
|
|
if matches!(mode, AppMode::CommandMode { .. }) {
|
|
continue;
|
|
}
|
|
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
|
|
|
|
let mut app = host();
|
|
app.view_state.mode = mode.clone();
|
|
app.view_state.buffers.insert(buffer.clone(), "Regi".into());
|
|
|
|
let mut client = ClientState {
|
|
view: app.view_state.clone(),
|
|
..ClientState::default()
|
|
};
|
|
|
|
let event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!(
|
|
"{} should pop locally",
|
|
improvise_protocol::mode_label(&mode)
|
|
);
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(
|
|
client.view.buffers,
|
|
app.view_state.buffers,
|
|
"{}: client and host disagree about the buffers after a pop",
|
|
improvise_protocol::mode_label(&mode)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Why CommandMode's Backspace is upstream, demonstrated rather than asserted:
|
|
/// on the host it *leaves the mode* when the buffer is empty.
|
|
#[test]
|
|
fn command_mode_backspace_on_an_empty_buffer_changes_the_mode_on_the_host() {
|
|
let mut app = host();
|
|
app.view_state.mode = AppMode::command_mode();
|
|
app.view_state
|
|
.buffers
|
|
.insert("command".into(), String::new());
|
|
|
|
let event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
|
|
assert_eq!(
|
|
resolve(
|
|
&ClientState {
|
|
view: app.view_state.clone(),
|
|
..ClientState::default()
|
|
},
|
|
event
|
|
),
|
|
KeyOutcome::Upstream(event),
|
|
"the client must not answer this key"
|
|
);
|
|
|
|
reduce_key(&mut app, event);
|
|
assert!(
|
|
matches!(app.view_state.mode, AppMode::Normal),
|
|
"the host leaves command mode on an empty backspace, got {:?}",
|
|
app.view_state.mode
|
|
);
|
|
}
|
|
|
|
/// An unresolvable key must reach the host as a keypress, carrying its code
|
|
/// and modifiers intact. Why `Enter` and not an arrow: the evidence file.
|
|
#[test]
|
|
fn a_forwarded_key_reaches_the_host_unchanged() {
|
|
let mut client = ClientState::default();
|
|
let browser = BrowserKey::new("Enter");
|
|
let event = browser.to_key_event().unwrap();
|
|
assert_eq!(resolve(&client, event), KeyOutcome::Upstream(event));
|
|
|
|
let wire = serde_json::to_string(&ClientFrame::key(event)).unwrap();
|
|
let ClientFrame::Key { code, modifiers } = serde_json::from_str(&wire).unwrap() else {
|
|
panic!("a forwarded key must stay a key");
|
|
};
|
|
|
|
let mut app = host();
|
|
reduce_key(&mut app, KeyEvent::new(code, modifiers));
|
|
assert_eq!(
|
|
app.view_state.cursor().selected,
|
|
(1, 0),
|
|
"the host resolved the arrow against the document the client does not have"
|
|
);
|
|
|
|
// And the client catches up only by folding what it is told.
|
|
assert_eq!(client.view.cursor().selected, (0, 0));
|
|
client.fold(&improvise_protocol::Command::SetSelected { row: 1, col: 0 });
|
|
assert_eq!(client.view.cursor().selected, (1, 0));
|
|
}
|
|
|
|
/// `reduce_view` is the shared fold, not a second implementation — so a command
|
|
/// the host folds into its mirror moves this client identically.
|
|
#[test]
|
|
fn the_client_folds_a_command_exactly_as_the_shared_reducer_does() {
|
|
let cmds = [
|
|
improvise_protocol::Command::SwitchView("Second".into()),
|
|
improvise_protocol::Command::SetSelected { row: 3, col: 4 },
|
|
improvise_protocol::Command::SetStatus("saved".into()),
|
|
improvise_protocol::Command::ToggleCatExpand("Region".into()),
|
|
];
|
|
|
|
let mut client = ClientState::default();
|
|
let mut mirror = ViewState::default();
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
reduce_view(&mut mirror, cmd);
|
|
}
|
|
assert_eq!(format!("{:?}", client.view), format!("{mirror:?}"));
|
|
}
|
|
|
|
// The cursors (improvise-bpq2).
|
|
|
|
/// Six rows and one column, so a cursor has somewhere to scroll to and the
|
|
/// *column* offset can never move. Why that shape: the evidence file.
|
|
fn tall_host() -> App {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
for item in ["North", "South", "East", "West", "Mid", "Far"] {
|
|
wb.add_item("Region", item).unwrap();
|
|
}
|
|
wb.add_item("_Measure", "Revenue").unwrap();
|
|
wb.active_view_mut().set_axis("Region", Axis::Row);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
let mut app = App::new(wb, None);
|
|
// The client's window is three rows; give the host the same one.
|
|
app.term_height = 3 + 8;
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// A client seeded the way a real one is: its cache built by the server's own
|
|
/// producer, over the window it announced.
|
|
fn client_of(app: &App, window: (usize, usize)) -> ClientState {
|
|
ClientState {
|
|
view: app.view_state.clone(),
|
|
grid: grid_of(
|
|
app,
|
|
Viewport {
|
|
row_offset: app.view_state.cursor().row_offset,
|
|
col_offset: app.view_state.cursor().col_offset,
|
|
rows: window.0,
|
|
cols: window.1,
|
|
},
|
|
),
|
|
window,
|
|
..ClientState::default()
|
|
}
|
|
}
|
|
|
|
/// A cursor key resolved on the client lands where the host would have put it,
|
|
/// including the scroll and the clamp. The fixture: the evidence file.
|
|
#[test]
|
|
fn a_locally_resolved_cursor_move_lands_where_the_host_would_put_it() {
|
|
let window = (3, 1);
|
|
let mut app = tall_host();
|
|
let mut client = client_of(&app, window);
|
|
|
|
// Eight presses for six rows: the last three are the clamp.
|
|
for press in 0..8 {
|
|
let event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
|
match resolve(&client, event) {
|
|
KeyOutcome::Local(cmds) => {
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
}
|
|
// At the bottom the move is a no-op and there is nothing to say.
|
|
KeyOutcome::Ignored => {}
|
|
other => panic!("press {press}: the client must answer this key, got {other:?}"),
|
|
}
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(
|
|
client.view.cursor(),
|
|
app.view_state.cursor(),
|
|
"press {press}: client and host disagree about the cursor"
|
|
);
|
|
}
|
|
|
|
let cursor = client.view.cursor();
|
|
assert_eq!(
|
|
cursor.selected,
|
|
(5, 0),
|
|
"the cursor must clamp at the last row"
|
|
);
|
|
assert_eq!(
|
|
cursor.row_offset, 3,
|
|
"and the window must have followed it, or the cell is off screen"
|
|
);
|
|
}
|
|
|
|
/// Twenty rows by three columns, the host's window eight rows by three: not
|
|
/// square, so a rule keyed off the wrong axis lands somewhere else.
|
|
fn paging_host() -> App {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
for i in 0..20 {
|
|
wb.add_item("Region", format!("R{i:02}")).unwrap();
|
|
}
|
|
for m in ["A", "B", "C"] {
|
|
wb.add_item("_Measure", m).unwrap();
|
|
}
|
|
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.term_height = 8 + 8;
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// Page and half-page scrolls land in the same place on both ends, and in the
|
|
/// place the TUI's rule names. Why the fixture and the pinned rows: the evidence file.
|
|
#[test]
|
|
fn a_locally_resolved_page_scroll_lands_where_the_host_would_put_it() {
|
|
let window = (8, 3);
|
|
let mut app = paging_host();
|
|
let mut client = client_of(&app, window);
|
|
let key = |code, mods| KeyEvent::new(code, mods);
|
|
let none = KeyModifiers::NONE;
|
|
let ctrl = KeyModifiers::CONTROL;
|
|
let presses = [
|
|
(key(KeyCode::Char('j'), none), 1),
|
|
(key(KeyCode::Char('j'), none), 2),
|
|
(key(KeyCode::PageDown, none), 8),
|
|
(key(KeyCode::PageDown, none), 14),
|
|
(key(KeyCode::PageUp, none), 8),
|
|
(key(KeyCode::Char('d'), ctrl), 13),
|
|
(key(KeyCode::PageDown, none), 19),
|
|
(key(KeyCode::Char('u'), ctrl), 14),
|
|
(key(KeyCode::PageUp, none), 8),
|
|
(key(KeyCode::PageUp, none), 2),
|
|
];
|
|
for (press, (event, want_row)) in presses.into_iter().enumerate() {
|
|
match resolve(&client, event) {
|
|
KeyOutcome::Local(cmds) => {
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
}
|
|
other => panic!("press {press}: the client must answer {event:?}, got {other:?}"),
|
|
}
|
|
reduce_key(&mut app, event);
|
|
assert_eq!(
|
|
client.view.cursor(),
|
|
app.view_state.cursor(),
|
|
"press {press} ({event:?}): client and host disagree"
|
|
);
|
|
assert_eq!(
|
|
client.view.cursor().selected,
|
|
(want_row, 0),
|
|
"press {press} ({event:?}): not the row the TUI's page rule names"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The same for `G`, the key `GridExtent` exists for: it is *defined* as "the
|
|
/// last row", so a window-only client would have nothing to jump to.
|
|
#[test]
|
|
fn a_locally_resolved_jump_to_the_last_row_agrees_with_the_host() {
|
|
let window = (3, 1);
|
|
let mut app = tall_host();
|
|
let mut client = client_of(&app, window);
|
|
|
|
let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("G must resolve locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(client.view.cursor().selected, (5, 0));
|
|
assert_eq!(client.view.cursor(), app.view_state.cursor());
|
|
}
|
|
|
|
/// The two-key chord, which nothing here exercised before improvise-hs0u: a
|
|
/// prefix is a `Command` on both ends, so the host and the client agree.
|
|
#[test]
|
|
fn the_g_chord_returns_to_the_first_row_on_both_ends() {
|
|
let window = (3, 1);
|
|
let mut app = tall_host();
|
|
let mut client = client_of(&app, window);
|
|
|
|
for event in [
|
|
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE),
|
|
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
|
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
|
] {
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{event:?} must resolve locally; an Upstream is a black hole");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
assert_eq!(
|
|
client.view.transient_prefix, app.view_state.transient_prefix,
|
|
"{event:?}: the two ends disagree about the armed prefix"
|
|
);
|
|
assert_eq!(
|
|
client.view.cursor(),
|
|
app.view_state.cursor(),
|
|
"{event:?}: the two ends disagree about the cursor"
|
|
);
|
|
}
|
|
|
|
assert_eq!(
|
|
client.view.cursor().selected,
|
|
(0, 0),
|
|
"`G` then `gg` must come back to the first row"
|
|
);
|
|
assert_eq!(client.view.cursor().row_offset, 0, "and the window with it");
|
|
}
|
|
|
|
/// A client that has not announced a window resolves no cursor key: it answers
|
|
/// `Ignored` rather than guessing a window or forwarding into silence.
|
|
#[test]
|
|
fn a_client_with_no_window_answers_no_cursor_key() {
|
|
let app = tall_host();
|
|
let mut client = client_of(&app, (3, 1));
|
|
client.window = (0, 0);
|
|
|
|
assert_eq!(
|
|
resolve(
|
|
&client,
|
|
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)
|
|
),
|
|
KeyOutcome::Ignored
|
|
);
|
|
}
|
|
|
|
/// improvise-bpq2's second named gap, which turned out not to be one: this is
|
|
/// one derivation, not two agreeing by luck. See the evidence file.
|
|
#[test]
|
|
fn a_locally_resolved_panel_cursor_agrees_with_the_host() {
|
|
let mut app = tall_host();
|
|
app.view_state.mode = AppMode::CategoryPanel;
|
|
// Expanded, so the flattening is longer than the category count and a
|
|
// client that counted categories would stop several rows early.
|
|
app.view_state.expanded_cats.insert("Region".to_string());
|
|
|
|
let mut client = ClientState {
|
|
view: app.view_state.clone(),
|
|
categories: category_tree_of(&app),
|
|
..ClientState::default()
|
|
};
|
|
|
|
for press in 0..4 {
|
|
let event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("press {press}: the category panel cursor must resolve locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
assert_eq!(
|
|
client.view.cat_panel_cursor, app.view_state.cat_panel_cursor,
|
|
"press {press}: client and host disagree about the panel cursor"
|
|
);
|
|
}
|
|
assert!(
|
|
client.view.cat_panel_cursor > 0,
|
|
"the fixture moved nothing, so it would pass with no resolution at all"
|
|
);
|
|
}
|
|
|
|
/// And the tile cursor, whose bound is `TileBarCache::tiles` — the list the
|
|
/// host builds from `View::tileable_cats`.
|
|
#[test]
|
|
fn a_locally_resolved_tile_cursor_agrees_with_the_host() {
|
|
let mut app = tall_host();
|
|
app.view_state.mode = AppMode::TileSelect;
|
|
|
|
let mut client = ClientState {
|
|
view: app.view_state.clone(),
|
|
tiles: improvise_protocol::TileBarCache {
|
|
tiles: tile_entries(&app, app.view()),
|
|
},
|
|
..ClientState::default()
|
|
};
|
|
assert!(
|
|
client.tiles.tiles.len() > 1,
|
|
"a one-tile fixture clamps immediately and proves nothing"
|
|
);
|
|
|
|
for press in 0..3 {
|
|
let event = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE);
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("press {press}: the tile cursor must resolve locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
assert_eq!(
|
|
client.view.tile_cat_idx, app.view_state.tile_cat_idx,
|
|
"press {press}: client and host disagree about the tile cursor"
|
|
);
|
|
}
|
|
assert!(client.view.tile_cat_idx > 0, "the fixture moved nothing");
|
|
}
|
|
|
|
// The editing path (improvise-bpq2's second half).
|
|
|
|
/// A document whose cells are **records**, so `i` has something to edit: the
|
|
/// shape gotcha 46 built `web/demo.improv` to satisfy.
|
|
fn editable_host() -> App {
|
|
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.active_view_mut().set_axis("Region", Axis::Row);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
wb.model.set_cell(
|
|
improvise_command::model::cell::CellKey::new(vec![
|
|
("Region".to_string(), "North".to_string()),
|
|
("_Measure".to_string(), "Revenue".to_string()),
|
|
]),
|
|
improvise_protocol::CellValue::Number(1200.0),
|
|
);
|
|
let mut app = App::new(wb, None);
|
|
app.term_height = 2 + 8;
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// The basis a real server publishes for this window, never one built to the
|
|
/// same recipe (improvise-1l9o). Evidence: `context/test-evidence/clients.md`.
|
|
fn published_basis(app: &App, window: (usize, usize)) -> improvise_protocol::Basis {
|
|
let mut sessions = improvise_command::ui::project::Sessions::default();
|
|
let (_id, snapshot) = sessions.connect(app, window.0, window.1);
|
|
snapshot
|
|
.seed
|
|
.into_iter()
|
|
.find_map(|projection| match projection {
|
|
improvise_protocol::Projection::Basis(basis) => Some(basis),
|
|
_ => None,
|
|
})
|
|
.expect("a bootstrap publishes the basis its client may claim against")
|
|
}
|
|
|
|
/// [`client_of`], plus the published basis a real client folds before it may
|
|
/// compose a write: `claim` withholds until it has one. Evidence file.
|
|
fn client_ready_to_edit(app: &App, window: (usize, usize)) -> ClientState {
|
|
let mut client = client_of(app, window);
|
|
let basis = published_basis(app, window);
|
|
assert!(
|
|
!basis.records.is_empty(),
|
|
"the fixture must project at least one Record cell, or nothing here is testing editing"
|
|
);
|
|
improvise_protocol::WidgetCache::apply(&mut client.basis, &basis);
|
|
client
|
|
}
|
|
|
|
/// `i`, three characters and `Esc` leave the client and the host in the same
|
|
/// mode with the same buffers. Why `Esc` and not `Enter`: the evidence file.
|
|
#[test]
|
|
fn entering_typing_and_cancelling_an_edit_agrees_with_the_host() {
|
|
let window = (2, 1);
|
|
let mut app = editable_host();
|
|
let mut client = client_ready_to_edit(&app, window);
|
|
|
|
for key in ["i", "9", "9", "9", "Escape"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{key}: the client must answer this key");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", app.view_state.mode),
|
|
"{key}: client and host disagree about the mode"
|
|
);
|
|
assert_eq!(
|
|
client.view.buffers, app.view_state.buffers,
|
|
"{key}: client and host disagree about the buffers"
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
matches!(app.view_state.mode, AppMode::Normal),
|
|
"Esc must have left editing, or the walk above proved nothing"
|
|
);
|
|
}
|
|
|
|
/// improvise-1l9o: a fixture that takes its basis from the server tracks a
|
|
/// host past zero; a fabricated one claims zero forever. Evidence file.
|
|
#[test]
|
|
fn the_fixtures_basis_tracks_a_host_whose_layout_has_moved() {
|
|
let window = (2, 1);
|
|
let mut app = editable_host();
|
|
let bump = caught_up(
|
|
&app,
|
|
improvise_protocol::ModelCommand::HideItem {
|
|
view: app.view_name().to_string(),
|
|
category: "Region".into(),
|
|
item: "South".into(),
|
|
},
|
|
);
|
|
apply_on(&mut app, &[bump]);
|
|
assert_ne!(
|
|
app.layout_version(),
|
|
0,
|
|
"precondition: a tier must have MOVED, or a fabricated zero basis is \
|
|
indistinguishable from the published one and this proves nothing"
|
|
);
|
|
|
|
let client = client_ready_to_edit(&app, window);
|
|
let claim = client
|
|
.basis
|
|
.claim([])
|
|
.expect("a client seeded from a real bootstrap may claim");
|
|
assert_eq!(
|
|
(claim.document_generation, claim.layout_version),
|
|
(app.document_generation(), app.layout_version()),
|
|
"the fixture claimed a basis the host would refuse as LayoutMoved"
|
|
);
|
|
}
|
|
|
|
/// `i` seeds the edit buffer from the cell's **display value**, which the two
|
|
/// ends reach by different paths. See the evidence file.
|
|
#[test]
|
|
fn the_buffer_i_seeds_is_the_value_the_host_would_seed() {
|
|
let window = (2, 1);
|
|
let mut app = editable_host();
|
|
let mut client = client_ready_to_edit(&app, window);
|
|
|
|
let event = BrowserKey::new("i").to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("i on a record cell must resolve locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(
|
|
client.view.buffers.get("edit"),
|
|
app.view_state.buffers.get("edit"),
|
|
"the seeded buffer must be the host's display value"
|
|
);
|
|
assert_eq!(
|
|
client.view.buffers.get("edit").map(String::as_str),
|
|
Some("1200"),
|
|
"and the fixture must actually have a value, or this passes on two empties"
|
|
);
|
|
}
|
|
|
|
/// The commit: `Enter` in `Editing` moves both ends to the same mode, cursor
|
|
/// and buffers. What is deliberately *not* compared: the evidence file.
|
|
#[test]
|
|
fn a_locally_committed_edit_leaves_both_ends_in_the_same_place() {
|
|
let window = (2, 1);
|
|
let mut app = editable_host();
|
|
let mut client = client_ready_to_edit(&app, window);
|
|
|
|
for key in ["i", "7", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{key}: the client must answer this key");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", app.view_state.mode),
|
|
"{key}: client and host disagree about the mode"
|
|
);
|
|
assert_eq!(
|
|
client.view.cursor(),
|
|
app.view_state.cursor(),
|
|
"{key}: client and host disagree about the cursor"
|
|
);
|
|
assert_eq!(
|
|
client.view.buffers, app.view_state.buffers,
|
|
"{key}: client and host disagree about the buffers"
|
|
);
|
|
}
|
|
|
|
assert_eq!(
|
|
app.view_state.cursor().selected,
|
|
(1, 0),
|
|
"the commit must have advanced a row, or the walk proved nothing"
|
|
);
|
|
}
|
|
|
|
// The second tranche (improvise-e24v).
|
|
|
|
/// A document with two categories, two views and a value, so every widget the
|
|
/// second tranche reads has more than one entry in it.
|
|
fn furnished_host() -> App {
|
|
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_category("Product").unwrap();
|
|
wb.add_item("Product", "Widget").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.create_view("Second");
|
|
wb.switch_view("Default").unwrap();
|
|
wb.model.set_cell(
|
|
north_revenue(),
|
|
improvise_protocol::CellValue::Number(1200.0),
|
|
);
|
|
let mut app = App::new(wb, None);
|
|
app.term_height = 2 + 8;
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// The one stored cell, named by **every** coordinate: `add_category` puts a
|
|
/// new category on `Axis::Column`, so `Product` is part of the address.
|
|
fn north_revenue() -> improvise_command::model::cell::CellKey {
|
|
improvise_command::model::cell::CellKey::new(vec![
|
|
("Region".to_string(), "North".to_string()),
|
|
("_Measure".to_string(), "Revenue".to_string()),
|
|
("Product".to_string(), "Widget".to_string()),
|
|
])
|
|
}
|
|
|
|
/// A client whose every cache the host's own producer built, plus the basis a
|
|
/// real one folds before it may compose a write.
|
|
fn furnished_client(app: &App, window: (usize, usize)) -> ClientState {
|
|
let mut client = client_of(app, window);
|
|
client.categories = category_tree_of(app);
|
|
client.views = improvise_protocol::ViewListCache {
|
|
views: view_summaries(app),
|
|
};
|
|
client.tiles = improvise_protocol::TileBarCache {
|
|
tiles: tile_entries(app, app.view()),
|
|
};
|
|
improvise_protocol::WidgetCache::apply(&mut client.basis, &published_basis(app, window));
|
|
client
|
|
}
|
|
|
|
/// A second authoritative host, identical to the first. `App` is not `Clone`,
|
|
/// so the fixture is built twice and the session slice copied across.
|
|
fn twin_of(app: &App) -> App {
|
|
let mut twin = furnished_host();
|
|
twin.view_state = app.view_state.clone();
|
|
twin.rebuild_layout();
|
|
twin
|
|
}
|
|
|
|
/// Apply exactly what the client composed to a second authoritative host. Why
|
|
/// the basis is restated against it: `context/code/clients.md`.
|
|
fn apply_on(app: &mut App, cmds: &[improvise_protocol::Command]) {
|
|
for cmd in cmds {
|
|
let restated = match cmd {
|
|
improvise_protocol::Command::Model { cmd, .. } => caught_up(app, cmd.clone()),
|
|
other => other.clone(),
|
|
};
|
|
let checked = Checked::against(app, &restated)
|
|
.unwrap_or_else(|e| panic!("the twin host refused {restated:?}: {e:?}"));
|
|
apply_command(app, checked);
|
|
}
|
|
}
|
|
|
|
/// The document as `.improv` text. `Debug` cannot serve: it prints the symbol
|
|
/// table's `HashMap`s, so two equal documents differ per process (gotcha 18).
|
|
fn document_text(app: &App) -> String {
|
|
improvise_command::persistence::format_md(&app.model_state.workbook)
|
|
}
|
|
|
|
/// One key on both ends: the host resolves it through the `Cmd`/`Effect` path,
|
|
/// a twin applies what the client composed, and the two are compared.
|
|
fn both_ends(app: &mut App, twin: &mut App, client: &mut ClientState, key: &str) {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(client, event) else {
|
|
panic!("{key}: the client must answer this key locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
apply_on(twin, &cmds);
|
|
reduce_key(app, event);
|
|
|
|
assert_eq!(
|
|
document_text(twin),
|
|
document_text(app),
|
|
"{key}: the document the client asked for is not the one the host built"
|
|
);
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", app.view_state.mode),
|
|
"{key}: client and host disagree about the mode"
|
|
);
|
|
assert_eq!(
|
|
client.view.status_msg, app.view_state.status_msg,
|
|
"{key}: client and host disagree about the status line"
|
|
);
|
|
assert_eq!(
|
|
client.view.help_page, app.view_state.help_page,
|
|
"{key}: client and host disagree about the help page"
|
|
);
|
|
// Every cursor, because a command whose ONLY decision is to move one
|
|
// otherwise diverges with the document, the mode and the status agreeing.
|
|
assert_eq!(
|
|
(
|
|
client.view.cat_panel_cursor,
|
|
client.view.view_panel_cursor,
|
|
client.view.formula_cursor,
|
|
client.view.tile_cat_idx,
|
|
client.view.cursor(),
|
|
),
|
|
(
|
|
app.view_state.cat_panel_cursor,
|
|
app.view_state.view_panel_cursor,
|
|
app.view_state.formula_cursor,
|
|
app.view_state.tile_cat_idx,
|
|
app.view_state.cursor(),
|
|
),
|
|
"{key}: client and host disagree about a cursor"
|
|
);
|
|
}
|
|
|
|
/// `Tab` in Normal with two panels open: `cycle-panel-focus`, whose whole
|
|
/// decision is which panel comes *after* the focused one.
|
|
#[test]
|
|
fn a_locally_cycled_panel_focus_agrees_with_the_host() {
|
|
let mut app = furnished_host();
|
|
app.view_state.formula_panel_open = true;
|
|
app.view_state.category_panel_open = true;
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "Tab");
|
|
assert!(
|
|
matches!(app.view_state.mode, AppMode::FormulaPanel),
|
|
"Tab from Normal focuses the first open panel: {:?}",
|
|
app.view_state.mode
|
|
);
|
|
|
|
// A second Tab is not a second cycle: `PanelKeymap`'s spine binds Tab to
|
|
// `enter-mode normal` (gotcha 12), so the wrap arms are unreachable by key.
|
|
both_ends(&mut app, &mut twin, &mut client, "Tab");
|
|
assert!(
|
|
matches!(app.view_state.mode, AppMode::Normal),
|
|
"Tab inside a panel leaves it: {:?}",
|
|
app.view_state.mode
|
|
);
|
|
}
|
|
|
|
/// Tile select: `r` sets an axis outright and `Enter` cycles the next one, so
|
|
/// both of `TileAxisOp`'s names are exercised against the document.
|
|
#[test]
|
|
fn a_locally_resolved_tile_axis_change_agrees_with_the_host() {
|
|
let mut app = furnished_host();
|
|
app.view_state.mode = AppMode::TileSelect;
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
assert!(
|
|
client.tiles.tiles.len() > 1,
|
|
"a one-tile fixture clamps immediately and proves nothing"
|
|
);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "r");
|
|
both_ends(&mut app, &mut twin, &mut client, "l");
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
|
|
assert!(
|
|
app.view_state.status_msg.contains('\u{2192}'),
|
|
"the axis status must name the new axis: {:?}",
|
|
app.view_state.status_msg
|
|
);
|
|
}
|
|
|
|
/// `T` then `Enter` from Normal: the tile cursor must open on a regular
|
|
/// category, not `_Index` (improvise-oplp). Why these rivals: the evidence file.
|
|
#[test]
|
|
fn tile_select_opens_on_a_regular_category_on_both_ends() {
|
|
let mut app = furnished_host();
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
let axis_of = |app: &App, cat: &str| app.view().axis_of(cat);
|
|
assert_eq!(
|
|
axis_of(&app, "_Index"),
|
|
Axis::None,
|
|
"the fixture's _Index is off-grid"
|
|
);
|
|
assert_eq!(axis_of(&app, "Region"), Axis::Row);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "T");
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
|
|
assert_eq!(
|
|
axis_of(&app, "_Index"),
|
|
Axis::None,
|
|
"T Enter moved _Index; the tile cursor opened on a virtual category"
|
|
);
|
|
assert_eq!(
|
|
axis_of(&app, "Region"),
|
|
Axis::Column,
|
|
"the first regular category is the one T Enter should cycle"
|
|
);
|
|
|
|
// The virtuals stay tileable, and reachable from where the cursor opens
|
|
// (#192): the browser's only way into records mode.
|
|
let names: Vec<&str> = client
|
|
.tiles
|
|
.tiles
|
|
.iter()
|
|
.map(|t| t.category.as_str())
|
|
.collect();
|
|
let opened = client.view.tile_cat_idx;
|
|
for virtual_cat in ["_Index", "_Dim"] {
|
|
let at = names.iter().position(|n| *n == virtual_cat);
|
|
assert!(
|
|
at.is_some_and(|i| i < opened),
|
|
"{virtual_cat} must stay a tile left of {opened}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The category panel's four bindings, each of which reads the flattened tree
|
|
/// both ends share (improvise-edp): Space, `a`, Enter and `d`.
|
|
#[test]
|
|
fn the_category_panel_bindings_agree_with_the_host() {
|
|
let mut app = furnished_host();
|
|
app.view_state.mode = AppMode::CategoryPanel;
|
|
app.view_state.expanded_cats.insert("Region".to_string());
|
|
app.view_state.cat_panel_cursor = 3;
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
assert!(
|
|
matches!(
|
|
flatten_cat_tree(&client.categories, &client.view.expanded_cats)
|
|
.get(app.view_state.cat_panel_cursor),
|
|
Some(improvise_protocol::CatTreeEntry::Category { .. })
|
|
),
|
|
"the fixture must start on a category header, or `a` has nothing to open"
|
|
);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, " ");
|
|
both_ends(&mut app, &mut twin, &mut client, "a");
|
|
assert!(
|
|
matches!(app.view_state.mode, AppMode::ItemAdd { .. }),
|
|
"`a` must have opened the item prompt, got {:?}",
|
|
app.view_state.mode
|
|
);
|
|
|
|
// An item row, so both arms of `FilterToItem` and of
|
|
// `DeleteCategoryAtCursor` are reached rather than only the header one.
|
|
resume_in_panel(&mut app, &mut twin, &mut client, 4);
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
both_ends(&mut app, &mut twin, &mut client, "d");
|
|
assert!(
|
|
app.view_state.status_msg.starts_with("Deleted item"),
|
|
"`d` on an item row removes the item: {:?}",
|
|
app.view_state.status_msg
|
|
);
|
|
|
|
// And a header row, which deletes the whole category.
|
|
resume_in_panel(&mut app, &mut twin, &mut client, 5);
|
|
assert!(
|
|
matches!(
|
|
flatten_cat_tree(&client.categories, &client.view.expanded_cats).get(5),
|
|
Some(improvise_protocol::CatTreeEntry::Category { .. })
|
|
),
|
|
"the second delete must land on a header, or its arm is untested"
|
|
);
|
|
both_ends(&mut app, &mut twin, &mut client, "d");
|
|
assert!(
|
|
app.view_state.status_msg.starts_with("Deleted category"),
|
|
"`d` on a header removes the category: {:?}",
|
|
app.view_state.status_msg
|
|
);
|
|
}
|
|
|
|
/// Put all three ends back in the category panel at one cursor, and re-project
|
|
/// the client's tree: the document moved under it.
|
|
fn resume_in_panel(app: &mut App, twin: &mut App, client: &mut ClientState, cursor: usize) {
|
|
for view in [&mut app.view_state, &mut twin.view_state, &mut client.view] {
|
|
view.mode = AppMode::CategoryPanel;
|
|
view.cat_panel_cursor = cursor;
|
|
}
|
|
client.categories = category_tree_of(app);
|
|
}
|
|
|
|
/// The view panel: create, switch and delete, all three of which read the flat
|
|
/// `ViewListCache` in the workbook's own order.
|
|
#[test]
|
|
fn the_view_panel_bindings_agree_with_the_host() {
|
|
let mut app = furnished_host();
|
|
app.view_state.mode = AppMode::ViewPanel;
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
assert!(
|
|
client.views.views.len() > 1,
|
|
"a one-view fixture cannot tell a cursor from a constant"
|
|
);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "n");
|
|
assert_eq!(
|
|
app.model_state.workbook.views.len(),
|
|
3,
|
|
"`n` must have created a view"
|
|
);
|
|
|
|
app.view_state.mode = AppMode::ViewPanel;
|
|
twin.view_state.mode = AppMode::ViewPanel;
|
|
client.view.mode = AppMode::ViewPanel;
|
|
client.views = improvise_protocol::ViewListCache {
|
|
views: view_summaries(&app),
|
|
};
|
|
app.view_state.view_panel_cursor = 1;
|
|
twin.view_state.view_panel_cursor = 1;
|
|
client.view.view_panel_cursor = 1;
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
// The CLIENT's active view, not the document's: since improvise-jytm the
|
|
// workbook's copy is the default a fresh client opens (command.md).
|
|
assert_eq!(
|
|
app.view_state.active_view, "Second",
|
|
"Enter must have switched to the view under the cursor"
|
|
);
|
|
assert_eq!(
|
|
client.view.active_view, app.view_state.active_view,
|
|
"and the client must have folded the same switch"
|
|
);
|
|
|
|
// And `d`, whose only decision is the cursor step-back it makes away from
|
|
// the top of the list. Pressed at index 1 so that decision is reached.
|
|
for view in [&mut app.view_state, &mut twin.view_state, &mut client.view] {
|
|
view.mode = AppMode::ViewPanel;
|
|
view.view_panel_cursor = 1;
|
|
}
|
|
client.views = improvise_protocol::ViewListCache {
|
|
views: view_summaries(&app),
|
|
};
|
|
both_ends(&mut app, &mut twin, &mut client, "d");
|
|
assert_eq!(
|
|
app.model_state.workbook.views.len(),
|
|
2,
|
|
"`d` must have deleted a view"
|
|
);
|
|
assert_eq!(
|
|
app.view_state.view_panel_cursor, 0,
|
|
"and stepped the cursor back, which is the whole of what it decides"
|
|
);
|
|
}
|
|
|
|
/// `furnished_host` with a GAP: `View 3` created, configured `View 4` created,
|
|
/// `View 3` deleted. So `views.len() + 1` names a view that already exists.
|
|
fn gapped_host() -> App {
|
|
let mut app = furnished_host();
|
|
let wb = &mut app.model_state.workbook;
|
|
wb.create_view("View 3");
|
|
wb.create_view("View 4").number_format = ",.4".to_string();
|
|
wb.delete_view("View 3").unwrap();
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// improvise-vw47: the new view's name is derived on BOTH ends, so the two
|
|
/// must agree AND both be right. Why a gap is the fixture: the evidence file.
|
|
#[test]
|
|
fn a_new_view_named_over_a_gap_agrees_with_the_host_and_spares_the_survivor() {
|
|
let mut app = gapped_host();
|
|
app.view_state.mode = AppMode::ViewPanel;
|
|
let mut twin = gapped_host();
|
|
twin.view_state = app.view_state.clone();
|
|
twin.rebuild_layout();
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
|
|
let names: Vec<String> = app.model_state.workbook.views.keys().cloned().collect();
|
|
assert!(
|
|
app.model_state.workbook.views.len() == 3
|
|
&& app.model_state.workbook.views.contains_key("View 4"),
|
|
"the fixture must leave `len + 1` TAKEN, or a wrong derivation is \
|
|
free to be right by accident: {names:?}"
|
|
);
|
|
|
|
// The agreement. A fix applied to one resolver and not the other diverges
|
|
// here, on the document text, before either assertion below is reached.
|
|
both_ends(&mut app, &mut twin, &mut client, "n");
|
|
|
|
let names: Vec<String> = app.model_state.workbook.views.keys().cloned().collect();
|
|
assert_eq!(
|
|
app.model_state.workbook.views.len(),
|
|
4,
|
|
"`n` must ADD a view; naming one that exists makes `create_view` \
|
|
replace it instead: {names:?}"
|
|
);
|
|
assert_eq!(
|
|
app.model_state.workbook.views["View 4"].number_format, ",.4",
|
|
"and the view the user configured must be untouched"
|
|
);
|
|
}
|
|
|
|
/// improvise-cfly: the wave shipped `Command::ViewBack` with no key reaching
|
|
/// it. Both arms of `<`, against the host — see the evidence file.
|
|
#[test]
|
|
fn a_locally_resolved_view_step_lands_where_the_host_would_land() {
|
|
let mut app = furnished_host();
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
|
|
// The empty arm first. `reduce_view`'s fold is SILENT on an empty stack,
|
|
// so a client sending the bare command would drop the host's sentence.
|
|
both_ends(&mut app, &mut twin, &mut client, "<");
|
|
assert_eq!(
|
|
app.view_state.status_msg, "No previous view",
|
|
"the fixture must really have no history, or this arm is untested"
|
|
);
|
|
assert_eq!(
|
|
(
|
|
app.view_state.active_view.as_str(),
|
|
client.view.active_view.as_str()
|
|
),
|
|
("Default", "Default"),
|
|
"and neither end may have moved"
|
|
);
|
|
|
|
// Now a real step to go back FROM, made through the view panel so both
|
|
// ends record the history the way a user does.
|
|
for view in [&mut app.view_state, &mut twin.view_state, &mut client.view] {
|
|
view.mode = AppMode::ViewPanel;
|
|
view.view_panel_cursor = 1;
|
|
}
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
assert_eq!(
|
|
app.view_state.active_view, "Second",
|
|
"precondition: the switch must have happened on the host"
|
|
);
|
|
assert!(
|
|
!client.view.view_back_stack.is_empty(),
|
|
"and the client must have recorded it, or `<` below tests the empty arm twice"
|
|
);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "<");
|
|
assert_eq!(
|
|
app.view_state.active_view, "Default",
|
|
"`<` must step the host back"
|
|
);
|
|
assert_eq!(
|
|
client.view.active_view, app.view_state.active_view,
|
|
"and the client must land on the same view"
|
|
);
|
|
assert_eq!(
|
|
(
|
|
&client.view.view_back_stack,
|
|
&client.view.view_forward_stack
|
|
),
|
|
(
|
|
&app.view_state.view_back_stack,
|
|
&app.view_state.view_forward_stack
|
|
),
|
|
"both stacks, because a step that pops without pushing agrees on the \
|
|
active view and diverges on the next press"
|
|
);
|
|
}
|
|
|
|
/// `commit-buffer`, all three kinds. The status is optimistic and comes first,
|
|
/// so a client that emitted it last would read as agreeing and not be.
|
|
#[test]
|
|
fn a_locally_committed_minibuffer_agrees_with_the_host() {
|
|
for (mode, seed, keys) in [
|
|
(AppMode::category_add(), "", vec!["A", "B", "Enter"]),
|
|
(
|
|
AppMode::item_add("Region".into()),
|
|
"",
|
|
vec!["E", "a", "Enter"],
|
|
),
|
|
// Formula is the only kind whose `on_commit()` is `Some`. Seeded and
|
|
// valid; the evidence file says what an invalid one does.
|
|
(AppMode::formula_edit(), "Profit = Revenue", vec!["Enter"]),
|
|
] {
|
|
let mut app = furnished_host();
|
|
app.view_state.mode = mode.clone();
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
client.view.mode = mode.clone();
|
|
if let Some(buffer) = mode.minibuffer().map(|m| m.buffer_key.clone()) {
|
|
for view in [&mut app.view_state, &mut twin.view_state, &mut client.view] {
|
|
view.buffers.insert(buffer.clone(), seed.to_string());
|
|
}
|
|
}
|
|
|
|
for key in keys {
|
|
both_ends(&mut app, &mut twin, &mut client, key);
|
|
}
|
|
assert!(
|
|
!app.view_state.status_msg.is_empty(),
|
|
"{:?}: the commit must say what it did: {:?}",
|
|
improvise_protocol::mode_label(&mode),
|
|
app.view_state.status_msg
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The one thing a client cannot mirror about `commit-buffer formula`: whether
|
|
/// the text parses. See `context/test-evidence/clients.md` (improvise-cl7y).
|
|
#[test]
|
|
fn a_formula_the_client_cannot_parse_diverges_on_the_status_line() {
|
|
let mut app = furnished_host();
|
|
app.view_state.mode = AppMode::formula_edit();
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
client.view.mode = AppMode::formula_edit();
|
|
for view in [&mut app.view_state, &mut client.view] {
|
|
view.buffers.insert("formula".into(), "P=".into());
|
|
}
|
|
|
|
let event = BrowserKey::new("Enter").to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("Enter in FormulaEdit resolves here");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(
|
|
client.view.status_msg, "Formula added",
|
|
"the client says what `CommitBuffer` optimistically says"
|
|
);
|
|
assert!(
|
|
app.view_state.status_msg.starts_with("Formula error"),
|
|
"and the host, which can parse, overwrites it: {:?}",
|
|
app.view_state.status_msg
|
|
);
|
|
}
|
|
|
|
/// `x` and `p` over a record cell: the two cell writes ADR 002 lets a thin
|
|
/// client name, and the only two in this tranche that claim a record.
|
|
#[test]
|
|
fn a_locally_resolved_clear_and_paste_agree_with_the_host() {
|
|
let mut app = furnished_host();
|
|
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(42.0));
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
client.view.yanked = app.view_state.yanked.clone();
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "x");
|
|
both_ends(&mut app, &mut twin, &mut client, "p");
|
|
|
|
assert_eq!(
|
|
app.model_state.workbook.model.get_cell(&north_revenue()),
|
|
Some(&improvise_protocol::CellValue::Number(42.0)),
|
|
"the paste must have landed, or the walk above cleared and stopped"
|
|
);
|
|
}
|
|
|
|
/// Walking past the last help page. The two ends reached the bound by
|
|
/// different arithmetic — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn walking_off_the_end_of_the_help_pages_agrees_with_the_host() {
|
|
let mut app = furnished_host();
|
|
app.view_state.mode = AppMode::Help;
|
|
let mut twin = twin_of(&app);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
client.view.mode = AppMode::Help;
|
|
|
|
for _ in 0..improvise_protocol::HELP_PAGE_COUNT + 1 {
|
|
both_ends(&mut app, &mut twin, &mut client, "l");
|
|
}
|
|
assert_eq!(
|
|
app.view_state.help_page,
|
|
improvise_protocol::HELP_PAGE_COUNT - 1,
|
|
"the host stops at the last page rather than wrapping"
|
|
);
|
|
|
|
for _ in 0..improvise_protocol::HELP_PAGE_COUNT + 1 {
|
|
both_ends(&mut app, &mut twin, &mut client, "h");
|
|
}
|
|
assert_eq!(
|
|
app.view_state.help_page, 0,
|
|
"and stops at the first going back"
|
|
);
|
|
}
|
|
|
|
/// Two page axes, so the odometer's carry is exercised rather than assumed.
|
|
fn paged_host() -> App {
|
|
let mut wb = Workbook::new("Paged");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item("Region", "North").unwrap();
|
|
wb.add_category("Year").unwrap();
|
|
wb.add_category("Quarter").unwrap();
|
|
for year in ["2024", "2025"] {
|
|
wb.add_item("Year", year).unwrap();
|
|
}
|
|
for q in ["Q1", "Q2", "Q3", "Q4"] {
|
|
wb.add_item("Quarter", q).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("Year", Axis::Page);
|
|
wb.active_view_mut().set_axis("Quarter", Axis::Page);
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// A client whose basis is published, as a real one's is: `Command::Model`
|
|
/// is unconstructible without one (gotcha 43).
|
|
fn paged_client(app: &App) -> ClientState {
|
|
let mut client = client_of(app, (4, 4));
|
|
improvise_protocol::WidgetCache::apply(
|
|
&mut client.basis,
|
|
&improvise_protocol::basis::published(
|
|
app.document_generation(),
|
|
app.layout_version(),
|
|
Default::default(),
|
|
),
|
|
);
|
|
client
|
|
}
|
|
|
|
/// improvise-p1ym: `[`/`]` reached the host and did nothing, because nothing
|
|
/// projected the item list. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_locally_resolved_page_turn_lands_where_the_host_would_put_it() {
|
|
for key in ['[', ']'] {
|
|
let mut by_client = improvise_command::ui::host::Host::new(paged_host());
|
|
let (session, _) = by_client.connect(4, 4);
|
|
let mut by_host = paged_host();
|
|
let mut client = paged_client(&by_host);
|
|
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE);
|
|
|
|
// Six presses: more than `Quarter`'s four, so the carry into `Year`
|
|
// and the wrap back are both crossed.
|
|
for _ in 0..6 {
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{key} must resolve on the client");
|
|
};
|
|
by_client.handle(session, ClientFrame::Commands(cmds));
|
|
reduce_key(&mut by_host, event);
|
|
client = paged_client(by_client.app());
|
|
}
|
|
|
|
assert_eq!(
|
|
page_selections(by_client.app()),
|
|
page_selections(&by_host),
|
|
"{key}: six presses composed by the client land somewhere else than \
|
|
six presses resolved by the host"
|
|
);
|
|
assert_ne!(
|
|
page_selections(&by_host),
|
|
page_selections(&paged_host()),
|
|
"{key}: the fixture must actually move, or the comparison is vacuous"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Where each page axis is pointing, off the document itself — never off a
|
|
/// projection, which is what made a first version of this test vacuous.
|
|
fn page_selections(app: &App) -> Vec<(String, Option<String>)> {
|
|
// `App::view()`, not `workbook.active_view()`: since improvise-jytm the
|
|
// latter is the document's DEFAULT, and SetPageSelection writes the former.
|
|
let view = app.view();
|
|
["Year", "Quarter"]
|
|
.into_iter()
|
|
.map(|cat| (cat.to_string(), view.page_selection(cat).map(String::from)))
|
|
.collect()
|
|
}
|
|
|
|
/// improvise-6p8q: the TUI yanked and pasted through the synthetic records key.
|
|
/// See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn yy_then_p_in_records_mode_writes_the_record_the_host_writes() {
|
|
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
|
let two_records = || {
|
|
let mut app = furnished_host();
|
|
let south = north_revenue().with("Region", "South");
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.set_cell(south, improvise_protocol::CellValue::Number(900.0));
|
|
app.rebuild_layout();
|
|
reduce_key(&mut app, r);
|
|
app
|
|
};
|
|
let (mut app, mut twin) = (two_records(), two_records());
|
|
let revenue = (0..app.layout.col_count())
|
|
.find(|&c| app.layout.col_label(c) == "Revenue")
|
|
.expect("a Revenue column");
|
|
let south = 1;
|
|
assert_eq!(
|
|
app.layout.record_row(south).unwrap().coords.get("Region"),
|
|
Some("South")
|
|
);
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (0, revenue);
|
|
}
|
|
let mut client = records_client(&app);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "y");
|
|
both_ends(&mut app, &mut twin, &mut client, "y");
|
|
assert_eq!(
|
|
(client.view.yanked.clone(), app.view_state.yanked.clone()),
|
|
(
|
|
Some(improvise_protocol::CellValue::Number(1200.0)),
|
|
Some(improvise_protocol::CellValue::Number(1200.0))
|
|
),
|
|
"both ends yank North's stored Revenue"
|
|
);
|
|
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (south, revenue);
|
|
}
|
|
client.view.cursor_mut().selected = (south, revenue);
|
|
both_ends(&mut app, &mut twin, &mut client, "p");
|
|
assert_eq!(
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(&north_revenue().with("Region", "South")),
|
|
Some(&improvise_protocol::CellValue::Number(1200.0)),
|
|
"the paste must land on South's real record"
|
|
);
|
|
|
|
// Off the measure columns the two ends still yank one value.
|
|
for label in ["Region", "+"] {
|
|
let col = (0..app.layout.col_count())
|
|
.find(|&c| app.layout.col_label(c) == label)
|
|
.unwrap();
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (south, col);
|
|
}
|
|
client.view.cursor_mut().selected = (south, col);
|
|
both_ends(&mut app, &mut twin, &mut client, "y");
|
|
both_ends(&mut app, &mut twin, &mut client, "y");
|
|
assert_eq!(client.view.yanked, app.view_state.yanked, "{label}");
|
|
}
|
|
assert_eq!(
|
|
app.view_state.yanked,
|
|
Some(improvise_protocol::CellValue::Text(String::new())),
|
|
"the last yank must have run, over the blank creating column"
|
|
);
|
|
}
|
|
|
|
/// Both hosts driven into records mode the way a user gets there, so the
|
|
/// `_Records` view and its `_Index`/`_Dim` axes are the real ones.
|
|
fn records_pair() -> (App, App) {
|
|
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
|
let mut app = furnished_host();
|
|
let mut twin = furnished_host();
|
|
reduce_key(&mut app, r);
|
|
reduce_key(&mut twin, r);
|
|
(app, twin)
|
|
}
|
|
|
|
/// One key on both ends, the client refreshed from a real projection rather
|
|
/// than from the host's own slice — which is what lets them drift.
|
|
fn in_lockstep(app: &mut App, client: &mut ClientState, key: KeyEvent, what: &str) {
|
|
let KeyOutcome::Local(cmds) = resolve(client, key) else {
|
|
panic!("{what}: the client must answer this key itself");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(app, key);
|
|
let window = client.window;
|
|
let fresh = grid_of(
|
|
app,
|
|
Viewport {
|
|
row_offset: client.view.cursor().row_offset,
|
|
col_offset: client.view.cursor().col_offset,
|
|
rows: window.0,
|
|
cols: window.1,
|
|
},
|
|
);
|
|
for update in fresh.seed() {
|
|
improvise_protocol::WidgetCache::apply(&mut client.grid, &update);
|
|
}
|
|
}
|
|
|
|
/// improvise-e5pf: `AppMode::Normal` is settled against the GRID, so the client
|
|
/// must settle it too. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn leaving_a_panel_in_records_mode_lands_where_the_host_lands() {
|
|
let (mut app, _) = records_pair();
|
|
assert!(
|
|
app.view_state.mode.is_records(),
|
|
"the fixture must really be in records mode, or nothing separates the \
|
|
two answers"
|
|
);
|
|
let mut client = records_client(&app);
|
|
assert!(
|
|
client.grid.records,
|
|
"and the client must be told so by the projection, not by its own mode"
|
|
);
|
|
|
|
for (key, what) in [
|
|
(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), "open"),
|
|
(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), "close"),
|
|
] {
|
|
in_lockstep(&mut app, &mut client, key, what);
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", app.view_state.mode),
|
|
"{what}: the client and the host disagree about the mode"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Deliver everything the host said to this session, in arrival order — the
|
|
/// whole reply, never a hand-picked projection.
|
|
fn deliver(
|
|
frames: Vec<improvise_command::ui::host::Addressed>,
|
|
session: improvise_command::ui::project::SessionId,
|
|
client: &mut ClientState,
|
|
) {
|
|
for addressed in frames.into_iter().filter(|a| a.to == session) {
|
|
match addressed.msg {
|
|
improvise_protocol::ServerFrame::Snapshot(snapshot) => client.bootstrap(*snapshot),
|
|
improvise_protocol::ServerFrame::Commands(cmds) => {
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
}
|
|
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
|
|
improvise_protocol::ServerFrame::Accepted => client.accepted(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// improvise-e5pf, the route the first fix missed: a **view switch** changes
|
|
/// records-ness. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn switching_between_a_records_view_and_a_pivot_view_settles_both_ways() {
|
|
let (host_app, _) = records_pair();
|
|
let records_view = host_app.view_name().to_string();
|
|
let mut host = improvise_command::ui::host::Host::new(host_app);
|
|
let (session, welcome) = host.connect(8, 8);
|
|
let mut client = ClientState {
|
|
window: (8, 8),
|
|
..ClientState::default()
|
|
};
|
|
deliver(vec![welcome], session, &mut client);
|
|
|
|
// A fresh session opens the DOCUMENT's default view, not the host's
|
|
// (gotcha 29), so the client starts on the pivot and the host on records.
|
|
assert!(
|
|
!client.grid.records,
|
|
"precondition: the client opens on a pivot"
|
|
);
|
|
assert_eq!(format!("{:?}", client.view.mode), "Normal");
|
|
|
|
// Two separate facts, so a broken mode cannot be masked by a precondition
|
|
// derived from the same expectation — `context/test-evidence/clients.md`.
|
|
for (target, records, expected) in [
|
|
(records_view.as_str(), true, "RecordsNormal"),
|
|
("Default", false, "Normal"),
|
|
] {
|
|
// The WORST a composition site can do, and what `switch_view_at` really
|
|
// did: settle against the grid the client still holds — the outgoing one.
|
|
let stale = improvise_protocol::settled_mode(&AppMode::Normal, client.grid.records);
|
|
let composed = vec![
|
|
improvise_protocol::Command::SwitchView(target.to_string()),
|
|
improvise_protocol::Command::ChangeMode(stale),
|
|
];
|
|
for cmd in &composed {
|
|
client.fold(cmd);
|
|
}
|
|
deliver(
|
|
host.handle(session, ClientFrame::Commands(composed)),
|
|
session,
|
|
&mut client,
|
|
);
|
|
|
|
assert_eq!(
|
|
client.view.active_view, target,
|
|
"the client must really be on {target}, or nothing below separates the two"
|
|
);
|
|
assert_eq!(
|
|
client.grid.records, records,
|
|
"{target}: the projection must really change records-ness"
|
|
);
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
expected,
|
|
"{target}: the client settled against the view it LEFT"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The host standing on each of those views holds the same mode the client
|
|
/// does. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn the_mode_a_switch_lands_in_is_the_one_a_host_on_that_view_holds() {
|
|
let (mut app, _) = records_pair();
|
|
let records_view = app.view_name().to_string();
|
|
|
|
let mut held = Vec::new();
|
|
for target in ["Default", &records_view] {
|
|
app.switch_to_view(target);
|
|
app.apply_effects(vec![]);
|
|
held.push(format!("{:?}", app.view_state.mode));
|
|
}
|
|
assert_eq!(
|
|
held,
|
|
vec!["Normal".to_string(), "RecordsNormal".to_string()],
|
|
"the host itself must answer differently per view, or the client has \
|
|
nothing to agree with"
|
|
);
|
|
}
|
|
|
|
/// improvise-e5pf: the commit declines off the GRID, so a client whose mode has
|
|
/// drifted still declines. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_records_commit_is_declined_off_the_grid_and_not_off_the_mode() {
|
|
let (app, _) = records_pair();
|
|
let mut drifted = records_client(&app);
|
|
// The drift itself, stated rather than reached: a plain `Editing` over a
|
|
// records grid is what the substitution produced.
|
|
drifted.view.mode = AppMode::editing();
|
|
drifted
|
|
.view
|
|
.buffers
|
|
.insert("edit".into(), "typed".to_string());
|
|
drifted.view.cursor_mut().selected = stored_cell_at(&drifted);
|
|
|
|
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
|
assert!(
|
|
!composes_a_write(&drifted, enter),
|
|
"a records commit this client cannot compose was composed anyway"
|
|
);
|
|
|
|
// The control: the same mode over a REGULAR grid must still commit, or the
|
|
// decline above is "this client never writes" and proves nothing.
|
|
let plain = furnished_host();
|
|
// Seeded as `records_client` seeds, basis included, over a regular grid;
|
|
// opened through the fold, which captures the cell the commit writes.
|
|
let mut ordinary = records_client(&plain);
|
|
ordinary.view.cursor_mut().selected = stored_cell_at(&ordinary);
|
|
ordinary.fold(&improvise_protocol::Command::ChangeMode(AppMode::editing()));
|
|
ordinary
|
|
.view
|
|
.buffers
|
|
.insert("edit".into(), "42".to_string());
|
|
assert!(!ordinary.grid.records, "the control must be a regular grid");
|
|
assert!(
|
|
composes_a_write(&ordinary, enter),
|
|
"the control never wrote either, so the decline above is vacuous"
|
|
);
|
|
}
|
|
|
|
/// A records client seeded the way a real one is: from a projection, with a
|
|
/// basis published, so a declined commit is declined for the stated reason.
|
|
fn records_client(app: &App) -> ClientState {
|
|
let mut client = client_of(app, (8, 8));
|
|
improvise_protocol::WidgetCache::apply(
|
|
&mut client.basis,
|
|
&improvise_protocol::basis::published(
|
|
app.document_generation(),
|
|
app.layout_version(),
|
|
client
|
|
.grid
|
|
.cells
|
|
.iter()
|
|
.flatten()
|
|
.filter_map(|cell| match &cell.id {
|
|
Some(improvise_protocol::CellId::Record(rc)) => Some((rc.record, 0)),
|
|
_ => None,
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
client
|
|
}
|
|
|
|
/// The first cell the client's own projection says is a stored record.
|
|
fn stored_cell_at(client: &ClientState) -> (usize, usize) {
|
|
client
|
|
.grid
|
|
.cells
|
|
.iter()
|
|
.enumerate()
|
|
.find_map(|(r, row)| {
|
|
row.iter()
|
|
.position(|cell| matches!(cell.id, Some(improvise_protocol::CellId::Record(_))))
|
|
.map(|c| (r, c))
|
|
})
|
|
.expect("the fixture must project a stored cell")
|
|
}
|
|
|
|
fn composes_a_write(client: &ClientState, key: KeyEvent) -> bool {
|
|
match resolve(client, key) {
|
|
KeyOutcome::Local(cmds) => cmds
|
|
.iter()
|
|
.any(|c| matches!(c, improvise_protocol::Command::Model { .. })),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Where the client's own projected grid puts a creating column. Asked of the
|
|
/// projection, never re-derived from the axes — that is ADR 002's whole point.
|
|
fn creating_cell(client: &ClientState, kind: improvise_protocol::CreateKind) -> (usize, usize) {
|
|
for (r, row) in client.grid.cells.iter().enumerate() {
|
|
for (c, cell) in row.iter().enumerate() {
|
|
if cell.id.as_ref() == Some(&improvise_protocol::CellId::Create(kind)) {
|
|
return (r, c);
|
|
}
|
|
}
|
|
}
|
|
panic!("records mode must project a {kind:?} creating column");
|
|
}
|
|
|
|
/// Put all three ends on one cell with one buffer, mid-edit.
|
|
fn typing_at(
|
|
app: &mut App,
|
|
twin: &mut App,
|
|
client: &mut ClientState,
|
|
at: (usize, usize),
|
|
text: &str,
|
|
) {
|
|
for host in [&mut *app, &mut *twin] {
|
|
host.view_state.mode = AppMode::records_editing();
|
|
host.view_state.cursor_mut().selected = at;
|
|
host.view_state.buffers.insert("edit".into(), text.into());
|
|
host.rebuild_layout();
|
|
}
|
|
client.view = app.view_state.clone();
|
|
}
|
|
|
|
/// improvise-gcvb. The records-mode early return was broader than its reason.
|
|
/// Why `+cat` must NOT advance: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_locally_committed_creating_column_agrees_with_the_host() {
|
|
for (kind, typed) in [
|
|
(improvise_protocol::CreateKind::Category, "Season"),
|
|
(improvise_protocol::CreateKind::Measure, "Cost"),
|
|
] {
|
|
let (mut app, mut twin) = records_pair();
|
|
let mut client = furnished_client(&app, (12, 12));
|
|
let at = creating_cell(&client, kind);
|
|
typing_at(&mut app, &mut twin, &mut client, at, typed);
|
|
client = furnished_client(&app, (12, 12));
|
|
client.view = app.view_state.clone();
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
|
|
assert!(
|
|
document_text(&app).contains(typed),
|
|
"{kind:?}: the commit must reach the document: {}",
|
|
document_text(&app)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// improvise-5wai: `+cat` reopens nothing, so its commit's own clear is all
|
|
/// that empties the buffer, on both ends. Evidence file.
|
|
#[test]
|
|
fn a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends() {
|
|
let (mut app, mut twin) = records_pair();
|
|
let client = furnished_client(&app, (12, 12));
|
|
let at = creating_cell(&client, improvise_protocol::CreateKind::Category);
|
|
let mut client = client;
|
|
typing_at(&mut app, &mut twin, &mut client, at, "Season");
|
|
client = furnished_client(&app, (12, 12));
|
|
client.view = app.view_state.clone();
|
|
assert_eq!(
|
|
read_edit(&client.view.buffers),
|
|
"Season",
|
|
"nothing was typed, so this proves nothing"
|
|
);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
|
|
assert!(
|
|
document_text(&app).contains("Season"),
|
|
"the commit did not land"
|
|
);
|
|
assert_eq!(
|
|
read_edit(&app.view_state.buffers),
|
|
"",
|
|
"the host left the name behind"
|
|
);
|
|
assert_eq!(
|
|
read_edit(&client.view.buffers),
|
|
"",
|
|
"the browser left the name behind"
|
|
);
|
|
}
|
|
|
|
fn read_edit(buffers: &std::collections::HashMap<String, String>) -> &str {
|
|
buffers.get("edit").map(String::as_str).unwrap_or("")
|
|
}
|
|
|
|
/// The twin of the commit test above, for the door rather than the exit
|
|
/// (improvise-bd9o). Evidence file.
|
|
#[test]
|
|
fn opening_an_editor_is_declined_off_the_grid_and_not_off_the_mode() {
|
|
let (app, _) = records_pair();
|
|
let mut drifted = records_client(&app);
|
|
// The drift stated rather than reached: a plain `Editing`/`Normal` pair over
|
|
// a records grid is what the substitution produces.
|
|
drifted.view.mode = AppMode::Normal;
|
|
drifted.view.cursor_mut().selected = stored_cell_at(&drifted);
|
|
|
|
let i = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
|
|
assert!(
|
|
!opens_an_editor(&drifted, i),
|
|
"an editor whose Enter this client cannot commit was opened anyway: a \
|
|
guard reading the MODE cannot see this client, whose mode has drifted \
|
|
off its own records grid"
|
|
);
|
|
|
|
// The control: the same mode over a REGULAR grid must still open, or the
|
|
// decline above is "this client never edits" and proves nothing.
|
|
let plain = furnished_host();
|
|
let mut ordinary = paged_client(&plain);
|
|
ordinary.view.mode = AppMode::Normal;
|
|
ordinary.view.cursor_mut().selected = stored_cell_at(&ordinary);
|
|
assert!(!ordinary.grid.records, "the control must be a regular grid");
|
|
assert!(
|
|
opens_an_editor(&ordinary, i),
|
|
"the control never opened either, so the decline above is vacuous"
|
|
);
|
|
}
|
|
|
|
fn opens_an_editor(client: &ClientState, key: KeyEvent) -> bool {
|
|
match resolve(client, key) {
|
|
KeyOutcome::Local(cmds) => cmds
|
|
.iter()
|
|
.any(|c| matches!(c, improvise_protocol::Command::ChangeMode(m) if m.is_editing())),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
// Yank and paste (improvise-dsxq).
|
|
|
|
fn region_measure(region: &str, measure: &str) -> improvise_command::model::cell::CellKey {
|
|
improvise_command::model::cell::CellKey::new(vec![
|
|
("Region".to_string(), region.to_string()),
|
|
("_Measure".to_string(), measure.to_string()),
|
|
])
|
|
}
|
|
|
|
/// Four rows and four measures, the last a formula, holding values on which the
|
|
/// edit text and the value disagree. Evidence file.
|
|
fn yank_host() -> App {
|
|
use improvise_protocol::CellValue::{Number, Text};
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
for item in ["West", "North", "South", "East"] {
|
|
wb.add_item("Region", item).unwrap();
|
|
}
|
|
for item in ["Units", "Revenue", "Cost"] {
|
|
wb.add_item("_Measure", item).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);
|
|
for (region, measure, value) in [
|
|
("North", "Revenue", Number(1.0)),
|
|
("North", "Cost", Number(2.0)),
|
|
("South", "Revenue", Number(1.23456789)),
|
|
("South", "Cost", Text("007".into())),
|
|
("East", "Revenue", Number(10.123456)),
|
|
("East", "Cost", Number(0.5)),
|
|
] {
|
|
wb.model.set_cell(region_measure(region, measure), value);
|
|
}
|
|
let mut app = App::new(wb, None);
|
|
app.term_height = 4 + 8;
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// One unmodified character on both ends: the host through its keymap, a twin
|
|
/// through exactly what the client composed.
|
|
fn press(app: &mut App, twin: &mut App, client: &mut ClientState, key: char) {
|
|
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE);
|
|
let KeyOutcome::Local(cmds) = resolve(client, event) else {
|
|
panic!("{key}: the client must answer this key locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
apply_on(twin, &cmds);
|
|
reduce_key(app, event);
|
|
}
|
|
|
|
fn seat(app: &mut App, client: &mut ClientState, at: (usize, usize)) {
|
|
app.cursor_mut().selected = at;
|
|
client.view.cursor_mut().selected = at;
|
|
}
|
|
|
|
/// `yy` then `p` copies the value the TUI copies, not its edit text re-parsed.
|
|
/// Never an integer: the one kind on which the two agree. Evidence file.
|
|
#[test]
|
|
fn yy_then_p_copies_the_value_the_host_copies() {
|
|
use improvise_protocol::CellValue::{Number, Text};
|
|
let cases = [
|
|
(
|
|
"a non-whole number",
|
|
(2, 1),
|
|
(1, 1),
|
|
"Revenue",
|
|
Number(1.23456789),
|
|
),
|
|
(
|
|
"number-like text",
|
|
(2, 2),
|
|
(1, 2),
|
|
"Cost",
|
|
Text("007".into()),
|
|
),
|
|
(
|
|
"a formula's value",
|
|
(3, 3),
|
|
(1, 1),
|
|
"Revenue",
|
|
Number(10.123456 - 0.5),
|
|
),
|
|
];
|
|
for (what, from, to, measure, expected) in cases {
|
|
let mut app = yank_host();
|
|
let mut twin = yank_host();
|
|
let mut client = client_ready_to_edit(&app, (4, 4));
|
|
|
|
seat(&mut app, &mut client, from);
|
|
press(&mut app, &mut twin, &mut client, 'y');
|
|
press(&mut app, &mut twin, &mut client, 'y');
|
|
assert_eq!(
|
|
app.view_state.yanked,
|
|
Some(expected.clone()),
|
|
"{what}: the host must yank the value itself, or the fixture proves nothing"
|
|
);
|
|
assert_eq!(
|
|
client.view.yanked, app.view_state.yanked,
|
|
"{what}: client and host yanked different values"
|
|
);
|
|
|
|
seat(&mut app, &mut client, to);
|
|
press(&mut app, &mut twin, &mut client, 'p');
|
|
assert_eq!(
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(®ion_measure("North", measure)),
|
|
Some(&expected),
|
|
"{what}: the host's paste must have landed"
|
|
);
|
|
assert_eq!(
|
|
document_text(&twin),
|
|
document_text(&app),
|
|
"{what}: the client's paste wrote a different document"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A true aggregate: `Product` on no axis, so the cell sums two records and has
|
|
/// no stored value. Yank only: an all-aggregate grid has nowhere to paste.
|
|
#[test]
|
|
fn yy_over_an_aggregate_copies_the_sum_the_host_copies() {
|
|
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_category("Product").unwrap();
|
|
wb.add_item("Product", "Widget").unwrap();
|
|
wb.add_item("Product", "Gadget").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);
|
|
for (product, value) in [("Widget", 1.23456789), ("Gadget", 1.0)] {
|
|
wb.model.set_cell(
|
|
region_measure("South", "Revenue").with("Product", product),
|
|
improvise_protocol::CellValue::Number(value),
|
|
);
|
|
}
|
|
let mut app = App::new(wb, None);
|
|
app.term_height = 2 + 8;
|
|
app.rebuild_layout();
|
|
let mut client = client_of(&app, (2, 1));
|
|
|
|
app.cursor_mut().selected = (1, 0);
|
|
client.view.cursor_mut().selected = (1, 0);
|
|
for _ in 0..2 {
|
|
let event = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("y must resolve locally over an aggregate too");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
}
|
|
assert_eq!(
|
|
app.view_state.yanked,
|
|
Some(improvise_protocol::CellValue::Number(1.23456789 + 1.0)),
|
|
"the host yanks the sum, or the fixture is not an aggregate"
|
|
);
|
|
assert_eq!(
|
|
client.view.yanked, app.view_state.yanked,
|
|
"client and host yanked different values over an aggregate"
|
|
);
|
|
}
|
|
|
|
// The cursor's cell as a write target (improvise-lx7w, improvise-54g1, improvise-gw18).
|
|
|
|
/// improvise-lx7w: `x` on a records measure clears the real record on both ends.
|
|
/// Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn x_in_records_mode_clears_the_record_the_browser_clears() {
|
|
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
|
let two_records = || {
|
|
let mut app = furnished_host();
|
|
let wb = &mut app.model_state.workbook;
|
|
wb.add_item("_Measure", "Cost").unwrap();
|
|
// Two measures, so clearing one leaves South's row where the cursor is.
|
|
for (measure, n) in [("Revenue", 900.0), ("Cost", 40.0)] {
|
|
let key = north_revenue()
|
|
.with("Region", "South")
|
|
.with("_Measure", measure);
|
|
wb.model
|
|
.set_cell(key, improvise_protocol::CellValue::Number(n));
|
|
}
|
|
app.rebuild_layout();
|
|
reduce_key(&mut app, r);
|
|
app
|
|
};
|
|
let (mut app, mut twin) = (two_records(), two_records());
|
|
let revenue = (0..app.layout.col_count())
|
|
.find(|&c| app.layout.col_label(c) == "Revenue")
|
|
.expect("a Revenue column");
|
|
assert_eq!(
|
|
app.layout.record_row(1).unwrap().coords.get("Region"),
|
|
Some("South"),
|
|
"setup: row 1 is South"
|
|
);
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (1, revenue);
|
|
}
|
|
let mut client = records_client(&app);
|
|
client.view.cursor_mut().selected = (1, revenue);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "x");
|
|
let model = &app.model_state.workbook.model;
|
|
assert_eq!(
|
|
model.get_cell(&north_revenue().with("Region", "South")),
|
|
None
|
|
);
|
|
assert!(
|
|
model.get_cell(&north_revenue()).is_some(),
|
|
"North is untouched"
|
|
);
|
|
}
|
|
|
|
/// improvise-q9eg: `yy` then `p` onto a record that lacks a later category,
|
|
/// on both ends. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn p_onto_a_sparse_record_writes_what_the_browser_writes() {
|
|
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
|
let sparse = || {
|
|
let mut app = furnished_host();
|
|
let south = north_revenue().with("Region", "South");
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.set_cell(south, improvise_protocol::CellValue::Number(900.0));
|
|
app.rebuild_layout();
|
|
reduce_key(&mut app, r);
|
|
// As `+cat` does: every existing record now lacks `Season`.
|
|
app.model_state.workbook.add_category("Season").unwrap();
|
|
app.rebuild_layout();
|
|
app
|
|
};
|
|
let (mut app, mut twin) = (sparse(), sparse());
|
|
assert!(
|
|
app.layout.none_cats.iter().any(|c| c == "Season"),
|
|
"setup: the new category is aggregated away"
|
|
);
|
|
let revenue = (0..app.layout.col_count())
|
|
.find(|&c| app.layout.col_label(c) == "Revenue")
|
|
.expect("a Revenue column");
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (0, revenue);
|
|
}
|
|
let mut client = records_client(&app);
|
|
client.view.cursor_mut().selected = (0, revenue);
|
|
both_ends(&mut app, &mut twin, &mut client, "y");
|
|
both_ends(&mut app, &mut twin, &mut client, "y");
|
|
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (1, revenue);
|
|
}
|
|
client.view.cursor_mut().selected = (1, revenue);
|
|
both_ends(&mut app, &mut twin, &mut client, "p");
|
|
assert_eq!(
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(&north_revenue().with("Region", "South")),
|
|
Some(&improvise_protocol::CellValue::Number(1200.0)),
|
|
"the paste must land, or both ends agreeing is both declining"
|
|
);
|
|
}
|
|
|
|
/// `key` at `at` on a host and on a client seeded from it: neither writes.
|
|
fn declines_on_both_ends(mut app: App, at: (usize, usize), key: char) {
|
|
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE);
|
|
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
|
|
app.cursor_mut().selected = at;
|
|
let client = client_ready_to_edit(&app, (6, 6));
|
|
assert!(
|
|
!composes_a_write(&client, event),
|
|
"the browser composed a write"
|
|
);
|
|
let before = document_text(&app);
|
|
reduce_key(&mut app, event);
|
|
assert_eq!(
|
|
document_text(&app),
|
|
before,
|
|
"the TUI wrote where the browser declines"
|
|
);
|
|
assert!(
|
|
!app.view_state.status_msg.is_empty(),
|
|
"the TUI declined silently"
|
|
);
|
|
}
|
|
|
|
/// improvise-54g1: `p` on a formula-owned cell. The control is the stored cell
|
|
/// beside it. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn p_onto_a_formula_owned_cell_declines_on_both_ends() {
|
|
let app = yank_host();
|
|
assert_eq!(
|
|
app.layout.col_label(3),
|
|
"Profit",
|
|
"setup: column 3 is the formula"
|
|
);
|
|
declines_on_both_ends(app, (1, 3), 'p');
|
|
|
|
let mut app = yank_host();
|
|
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
|
|
app.cursor_mut().selected = (1, 2);
|
|
let client = client_ready_to_edit(&app, (6, 6));
|
|
let p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
|
|
assert!(
|
|
composes_a_write(&client, p),
|
|
"the control: Cost is settable"
|
|
);
|
|
}
|
|
|
|
/// improvise-gw18/l4mp: `p` and `x` on a collapsed group's fold, off the origin.
|
|
#[test]
|
|
fn a_write_onto_a_fold_declines_on_both_ends() {
|
|
declines_on_both_ends(fold_host(), (2, 1), 'p');
|
|
declines_on_both_ends(fold_host(), (2, 1), 'x');
|
|
}
|
|
|
|
/// Months grouped into quarters on Row, two measures on Column, Q2 collapsed:
|
|
/// rows `[Jan, Feb, Q2]`, so the fold is row 2 and off the origin.
|
|
fn fold_host() -> App {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Month").unwrap();
|
|
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
|
wb.add_item_in_group("Month", month, quarter).unwrap();
|
|
}
|
|
wb.add_item("_Measure", "Revenue").unwrap();
|
|
wb.add_item("_Measure", "Cost").unwrap();
|
|
for month in ["Jan", "Feb", "Apr", "May"] {
|
|
for measure in ["Revenue", "Cost"] {
|
|
let key = improvise_command::model::cell::CellKey::new(vec![
|
|
("Month".to_string(), month.to_string()),
|
|
("_Measure".to_string(), measure.to_string()),
|
|
]);
|
|
wb.model
|
|
.set_cell(key, improvise_protocol::CellValue::Number(3.0));
|
|
}
|
|
}
|
|
wb.active_view_mut().set_axis("Month", Axis::Row);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2");
|
|
app
|
|
}
|
|
|
|
/// Measures on Row (`Profit` both stored and a formula's), months on Column,
|
|
/// so a `Down` from the bottom row wraps to the top of the next column.
|
|
fn wrapping_host() -> App {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Month").unwrap();
|
|
for month in ["Jan", "Feb"] {
|
|
wb.add_item("Month", month).unwrap();
|
|
}
|
|
for measure in ["Revenue", "Profit", "Cost"] {
|
|
wb.add_item("_Measure", measure).unwrap();
|
|
}
|
|
wb.model.add_formula(
|
|
improvise_command::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
|
|
);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Row);
|
|
wb.active_view_mut().set_axis("Month", Axis::Column);
|
|
for month in ["Jan", "Feb"] {
|
|
for (measure, n) in [("Revenue", 10.0), ("Cost", 4.0)] {
|
|
let key = improvise_command::model::cell::CellKey::new(vec![
|
|
("Month".to_string(), month.to_string()),
|
|
("_Measure".to_string(), measure.to_string()),
|
|
]);
|
|
wb.model
|
|
.set_cell(key, improvise_protocol::CellValue::Number(n));
|
|
}
|
|
}
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// improvise-y5eb: the settle must not read the cache through offsets it has not
|
|
/// caught up with. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_wrapping_advance_settles_on_the_cell_it_lands_on_not_a_stale_one() {
|
|
let mut tui = wrapping_host();
|
|
tui.cursor_mut().selected = (2, 0);
|
|
tui.cursor_mut().row_offset = 1;
|
|
for k in ["i", "Enter"] {
|
|
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
|
|
}
|
|
assert_eq!(
|
|
tui.view_state.cursor().selected,
|
|
(0, 1),
|
|
"setup: Enter wrapped"
|
|
);
|
|
assert!(
|
|
tui.view_state.mode.is_editing(),
|
|
"setup: Revenue/Feb is editable"
|
|
);
|
|
|
|
let mut host = improvise_command::ui::host::Host::new(wrapping_host());
|
|
let (session, welcome) = host.connect(2, 6);
|
|
let mut client = ClientState {
|
|
window: (2, 6),
|
|
..ClientState::default()
|
|
};
|
|
deliver(vec![welcome], session, &mut client);
|
|
let placed = vec![
|
|
improvise_protocol::Command::SetSelected { row: 2, col: 0 },
|
|
improvise_protocol::Command::SetRowOffset(1),
|
|
];
|
|
client.fold_outgoing(&placed);
|
|
deliver(
|
|
host.handle(session, ClientFrame::Commands(placed)),
|
|
session,
|
|
&mut client,
|
|
);
|
|
for k in ["i", "Enter"] {
|
|
let event = BrowserKey::new(k).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{k}: the client must answer this key locally");
|
|
};
|
|
client.fold_outgoing(&cmds);
|
|
deliver(
|
|
host.handle(session, ClientFrame::Commands(cmds)),
|
|
session,
|
|
&mut client,
|
|
);
|
|
}
|
|
assert_eq!(client.view.cursor().selected, (0, 1));
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", tui.view_state.mode),
|
|
"the client settled on a cell not under its cursor: {:?}",
|
|
client.view.status_msg
|
|
);
|
|
}
|
|
|
|
/// improvise-ik6l: Enter on a typed-into editor over a cell `i` declines says
|
|
/// so on both ends. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends() {
|
|
type Case = (fn() -> App, (usize, usize), &'static str);
|
|
let cases: [Case; 4] = [
|
|
(fold_host, (2, 1), "Enter"),
|
|
(yank_host, (1, 3), "Enter"),
|
|
(fold_host, (2, 1), "Tab"),
|
|
(yank_host, (1, 3), "Tab"),
|
|
];
|
|
for (make, at, key) in cases {
|
|
let mut app = make();
|
|
app.cursor_mut().selected = at;
|
|
app.view_state.mode = AppMode::editing();
|
|
app.view_state.buffers.insert("edit".into(), "7".into());
|
|
app.view_state.opened_editor = Some(improvise_protocol::OpenedEditor {
|
|
seed: String::new(),
|
|
touched: true,
|
|
awaiting: false,
|
|
});
|
|
let before = document_text(&app);
|
|
let mut client = client_ready_to_edit(&app, (6, 6));
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{at:?}: {key} went upstream, where nothing answers it");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
assert_eq!(document_text(&app), before, "setup: the TUI wrote nothing");
|
|
assert!(
|
|
cmds.iter()
|
|
.all(|c| !matches!(c, improvise_protocol::Command::Model { .. })),
|
|
"{at:?}: the browser composed a write"
|
|
);
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", app.view_state.mode),
|
|
"{at:?}: the two ends disagree about the mode"
|
|
);
|
|
assert_eq!(client.view.buffers, app.view_state.buffers, "{at:?}");
|
|
assert!(
|
|
!app.view_state.status_msg.is_empty() && !client.view.status_msg.is_empty(),
|
|
"{at:?}: a silent decline: TUI {:?}, browser {:?}",
|
|
app.view_state.status_msg,
|
|
client.view.status_msg
|
|
);
|
|
}
|
|
}
|
|
|
|
/// improvise-5m9z's other half: what the user typed before the host answered
|
|
/// is not thrown away. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn typing_before_the_host_answers_keeps_the_editor_past_the_window() {
|
|
let mut host = improvise_command::ui::host::Host::new(fold_host());
|
|
let (session, welcome) = host.connect(2, 6);
|
|
let mut client = ClientState {
|
|
window: (2, 6),
|
|
..ClientState::default()
|
|
};
|
|
deliver(vec![welcome], session, &mut client);
|
|
let placed = vec![improvise_protocol::Command::SetSelected { row: 1, col: 1 }];
|
|
client.fold_outgoing(&placed);
|
|
deliver(
|
|
host.handle(session, ClientFrame::Commands(placed)),
|
|
session,
|
|
&mut client,
|
|
);
|
|
let mut held = Vec::new();
|
|
for k in ["i", "Enter", "7"] {
|
|
let event = BrowserKey::new(k).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{k}: the client must answer this key locally");
|
|
};
|
|
client.fold_outgoing(&cmds);
|
|
held.push(host.handle(session, ClientFrame::Commands(cmds)));
|
|
}
|
|
assert_eq!(client.view.cursor().selected, (2, 1), "setup: on the fold");
|
|
for reply in held {
|
|
deliver(reply, session, &mut client);
|
|
}
|
|
assert!(
|
|
client.view.mode.is_editing(),
|
|
"the typed 7 was thrown away: {:?}",
|
|
client.view.mode
|
|
);
|
|
assert_eq!(
|
|
client.view.buffers.get("edit").map(String::as_str),
|
|
Some("7")
|
|
);
|
|
}
|
|
|
|
/// improvise-5m9z: an advance past the client's window lands on a cell it holds
|
|
/// no id for, and must settle as the TUI does. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn advancing_the_editor_past_the_window_settles_where_the_tui_does() {
|
|
let expanded = || {
|
|
let mut app = fold_host();
|
|
let view = app.model_state.workbook.active_view_mut();
|
|
view.toggle_group_collapse("Month", "Q2");
|
|
app.rebuild_layout();
|
|
app
|
|
};
|
|
type Case = (
|
|
fn() -> App,
|
|
(usize, usize),
|
|
(usize, usize),
|
|
&'static str,
|
|
bool,
|
|
);
|
|
let cases: [Case; 3] = [
|
|
(fold_host, (2, 6), (1, 1), "Enter", false),
|
|
(yank_host, (6, 3), (1, 2), "Tab", false),
|
|
(expanded, (2, 6), (1, 1), "Enter", true),
|
|
];
|
|
for (make, window, at, key, editing) in cases {
|
|
let mut tui = make();
|
|
tui.cursor_mut().selected = at;
|
|
for k in ["i", key] {
|
|
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
|
|
}
|
|
assert_eq!(tui.view_state.mode.is_editing(), editing, "setup: the TUI");
|
|
|
|
let mut host = improvise_command::ui::host::Host::new(make());
|
|
let (session, welcome) = host.connect(window.0, window.1);
|
|
let mut client = ClientState {
|
|
window,
|
|
..ClientState::default()
|
|
};
|
|
deliver(vec![welcome], session, &mut client);
|
|
let placed = vec![improvise_protocol::Command::SetSelected {
|
|
row: at.0,
|
|
col: at.1,
|
|
}];
|
|
client.fold_outgoing(&placed);
|
|
deliver(
|
|
host.handle(session, ClientFrame::Commands(placed)),
|
|
session,
|
|
&mut client,
|
|
);
|
|
for k in ["i", key] {
|
|
let event = BrowserKey::new(k).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{k}: the client must answer this key locally");
|
|
};
|
|
client.fold_outgoing(&cmds);
|
|
deliver(
|
|
host.handle(session, ClientFrame::Commands(cmds)),
|
|
session,
|
|
&mut client,
|
|
);
|
|
}
|
|
assert_eq!(
|
|
client.view.cursor().selected,
|
|
tui.view_state.cursor().selected,
|
|
"{key}: the advance must land past the window on both ends"
|
|
);
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", tui.view_state.mode),
|
|
"{key} from {at:?} in a {window:?} window: the client and the TUI disagree"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// improvise-enz6: a commit that advances onto a cell `i` declines re-opens no
|
|
/// editor there, on either end. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn advancing_the_editor_onto_a_cell_i_declines_leaves_both_ends_out_of_it() {
|
|
let cases = [
|
|
(fold_host(), (0, 1), "Enter", (2, 1)),
|
|
(yank_host(), (1, 1), "Tab", (1, 3)),
|
|
];
|
|
for (mut app, at, key, lands) in cases {
|
|
app.cursor_mut().selected = at;
|
|
let mut client = client_ready_to_edit(&app, (6, 6));
|
|
for (step, key) in ["i", key, key].into_iter().enumerate() {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{key}: the client must answer this key locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
reduce_key(&mut app, event);
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", app.view_state.mode),
|
|
"step {step} ({key}): client and host disagree about the mode"
|
|
);
|
|
assert_eq!(client.view.cursor(), app.view_state.cursor(), "step {step}");
|
|
assert_eq!(client.view.buffers, app.view_state.buffers, "step {step}");
|
|
}
|
|
assert_eq!(
|
|
app.view_state.cursor().selected,
|
|
lands,
|
|
"setup: {key} landed"
|
|
);
|
|
assert!(
|
|
!app.view_state.mode.is_editing() && !client.view.mode.is_editing(),
|
|
"{key}: an editor is open on a cell i declines"
|
|
);
|
|
assert!(
|
|
!client.view.status_msg.is_empty(),
|
|
"{key}: the browser declined silently"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Two regions by two measures, `value` at (South, Revenue): row 1, column 1,
|
|
/// so "the cell under the cursor" is not "the first cell".
|
|
fn precise_host(value: improvise_protocol::CellValue) -> App {
|
|
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", "Cost").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, measure, v) in [
|
|
("North", "Cost", improvise_protocol::CellValue::Number(1.0)),
|
|
(
|
|
"North",
|
|
"Revenue",
|
|
improvise_protocol::CellValue::Number(2.0),
|
|
),
|
|
("South", "Cost", improvise_protocol::CellValue::Number(3.0)),
|
|
("South", "Revenue", value),
|
|
] {
|
|
wb.model.set_cell(region_measure(region, measure), v);
|
|
}
|
|
let mut app = App::new(wb, None);
|
|
app.term_height = 2 + 8;
|
|
app.rebuild_layout();
|
|
app.cursor_mut().selected = (1, 1);
|
|
app
|
|
}
|
|
|
|
/// BUG (improvise-umi4) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_untouched_browser_edit_stores_the_identical_value() {
|
|
use improvise_protocol::CellValue::{Number, Text};
|
|
for value in [
|
|
Number(1.23456789),
|
|
Number(0.1 + 0.2),
|
|
Number(98765.4321987),
|
|
Number(123456789012345.67),
|
|
Number(-0.00001234),
|
|
Number(-0.0),
|
|
Number(f64::NEG_INFINITY),
|
|
Text("0.1 + 0.2".into()),
|
|
] {
|
|
untouched_browser_edit(value);
|
|
}
|
|
}
|
|
|
|
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_untouched_browser_edit_of_a_misread_value_writes_nothing() {
|
|
use improvise_protocol::CellValue::{Error, Text};
|
|
for value in [
|
|
Text("007".into()),
|
|
Text("1e3".into()),
|
|
Text("inf".into()),
|
|
Error("boom".into()),
|
|
] {
|
|
untouched_browser_edit(value);
|
|
}
|
|
}
|
|
|
|
/// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn enter_enter_leaves_every_cell_unchanged_on_both_ends() {
|
|
let every_cell = |app: &App| -> Vec<Option<improvise_protocol::CellValue>> {
|
|
[
|
|
("North", "Cost"),
|
|
("North", "Revenue"),
|
|
("South", "Cost"),
|
|
("South", "Revenue"),
|
|
]
|
|
.iter()
|
|
.map(|(r, m)| {
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(®ion_measure(r, m))
|
|
.cloned()
|
|
})
|
|
.collect()
|
|
};
|
|
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
client_host.cursor_mut().selected = (1, 0);
|
|
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
key_host.cursor_mut().selected = (1, 0);
|
|
let before = every_cell(&client_host);
|
|
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
|
for key in ["i", "Enter", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{key}: the client must answer this key locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
apply_on(&mut client_host, &cmds);
|
|
reduce_key(&mut key_host, event);
|
|
assert_eq!(
|
|
client.view.buffers, key_host.view_state.buffers,
|
|
"{key}: client and host disagree about the buffers"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
every_cell(&client_host),
|
|
before,
|
|
"the browser's Enter, Enter wrote"
|
|
);
|
|
assert_eq!(
|
|
every_cell(&key_host),
|
|
before,
|
|
"the host's Enter, Enter wrote"
|
|
);
|
|
}
|
|
|
|
/// A commit that reopens nothing still leaves no text behind, on either end:
|
|
/// the clear moved from the keymap into the commit (improvise-fy9o).
|
|
#[test]
|
|
fn a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends() {
|
|
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
assert_eq!(
|
|
key_host.cursor().selected,
|
|
(1, 1),
|
|
"the fixture is not at the corner"
|
|
);
|
|
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
|
for key in ["i", "9", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{key}: the client must answer this key locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
apply_on(&mut client_host, &cmds);
|
|
reduce_key(&mut key_host, event);
|
|
}
|
|
assert!(
|
|
!key_host.view_state.mode.is_editing(),
|
|
"the corner must not reopen"
|
|
);
|
|
for (end, buffers) in [
|
|
("browser", &client.view.buffers),
|
|
("host", &key_host.view_state.buffers),
|
|
] {
|
|
assert_eq!(
|
|
buffers.get("edit").map(String::as_str),
|
|
Some(""),
|
|
"{end}: the committed text was left in the buffer"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// BUG (improvise-3tt4) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn tab_at_the_rightmost_column_reopens_on_the_value_just_written() {
|
|
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
client_host.cursor_mut().selected = (0, 1);
|
|
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
key_host.cursor_mut().selected = (0, 1);
|
|
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
|
for key in ["i", "9", "Tab", "5", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
sent_and_answered(&mut client, &mut client_host, event, (2, 2));
|
|
reduce_key(&mut key_host, event);
|
|
assert_eq!(
|
|
client.view.buffers, key_host.view_state.buffers,
|
|
"{key}: client and host disagree about the buffers"
|
|
);
|
|
}
|
|
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
|
|
assert_eq!(
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(®ion_measure("North", "Revenue")),
|
|
Some(&improvise_protocol::CellValue::Number(295.0)),
|
|
"{end}: the reopened editor did not extend the value Tab had just written"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The reopened seed is the STORED value's edit text, not the typed string:
|
|
/// `007` is stored as 7 on both ends (improvise-3tt4). Evidence file.
|
|
#[test]
|
|
fn tab_at_the_rightmost_column_reopens_on_the_stored_spelling() {
|
|
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
client_host.cursor_mut().selected = (0, 1);
|
|
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
key_host.cursor_mut().selected = (0, 1);
|
|
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
|
for key in ["i", "Backspace", "0", "0", "7", "Tab"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
sent_and_answered(&mut client, &mut client_host, event, (2, 2));
|
|
reduce_key(&mut key_host, event);
|
|
}
|
|
assert_eq!(
|
|
key_host.view_state.buffers.get("edit").map(String::as_str),
|
|
Some("7")
|
|
);
|
|
assert_eq!(client.view.buffers, key_host.view_state.buffers);
|
|
}
|
|
|
|
/// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn typing_into_the_reopened_editor_extends_the_cell_on_both_ends() {
|
|
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
client_host.cursor_mut().selected = (1, 0);
|
|
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
key_host.cursor_mut().selected = (1, 0);
|
|
let mut client = client_ready_to_edit(&client_host, (2, 2));
|
|
for key in ["i", "Enter", "5", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{key}: the client must answer this key locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
apply_on(&mut client_host, &cmds);
|
|
reduce_key(&mut key_host, event);
|
|
}
|
|
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
|
|
assert_eq!(
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(®ion_measure("North", "Revenue")),
|
|
Some(&improvise_protocol::CellValue::Number(25.0)),
|
|
"{end}: the digit typed into the reopened editor did not extend its seed"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `i` then `Enter` on (South, Revenue) holding `value`, through the resolver
|
|
/// and a real host: the seed is the stored value's, and the cell is unchanged.
|
|
fn untouched_browser_edit(value: improvise_protocol::CellValue) {
|
|
use improvise_protocol::CellValue::Number;
|
|
let mut host = precise_host(value.clone());
|
|
let mut client = client_ready_to_edit(&host, (2, 2));
|
|
for key in ["i", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("{key}: the client must answer this key locally");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
apply_on(&mut host, &cmds);
|
|
if key == "i" {
|
|
assert_eq!(
|
|
improvise_protocol::read_buffer(&client.view, "edit"),
|
|
value.edit_text(),
|
|
"the editor was not seeded from the stored value (improvise-umi4)"
|
|
);
|
|
}
|
|
}
|
|
assert!(
|
|
!client.view.mode.is_editing(),
|
|
"Enter did not commit, so this proves nothing"
|
|
);
|
|
let stored = host
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(®ion_measure("South", "Revenue"))
|
|
.cloned();
|
|
let same = match (&stored, &value) {
|
|
(Some(Number(a)), Number(b)) => a.to_bits() == b.to_bits(),
|
|
(stored, value) => stored.as_ref() == Some(value),
|
|
};
|
|
assert!(same, "i then Enter on {value:?} stored {stored:?}");
|
|
}
|
|
|
|
/// BUG (improvise-umi4) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn every_projected_edit_text_commits_back_to_its_value() {
|
|
use improvise_protocol::CellValue::Number;
|
|
let value = Number(1.23456789);
|
|
for records in [false, true] {
|
|
let mut app = precise_host(value.clone());
|
|
if records {
|
|
reduce_key(
|
|
&mut app,
|
|
KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE),
|
|
);
|
|
}
|
|
assert_eq!(app.layout.is_records_mode(), records, "wrong fixture mode");
|
|
let grid = grid_of(
|
|
&app,
|
|
Viewport {
|
|
row_offset: 0,
|
|
col_offset: 0,
|
|
rows: 8,
|
|
cols: 8,
|
|
},
|
|
);
|
|
let cells: Vec<_> = grid
|
|
.cells
|
|
.iter()
|
|
.flatten()
|
|
.filter(|cell| matches!(cell.id, Some(improvise_protocol::CellId::Record(_))))
|
|
.collect();
|
|
assert!(
|
|
cells.iter().any(|cell| cell.value.as_ref() == Some(&value)),
|
|
"records={records}: the precise value was never projected, so this proves nothing"
|
|
);
|
|
for cell in cells {
|
|
let text = cell.edit_text.clone().unwrap_or_default();
|
|
assert_eq!(
|
|
improvise_protocol::typed(&text),
|
|
cell.value,
|
|
"records={records}: edit text {text:?} does not commit back to {:?}",
|
|
cell.value
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A coordinate rename (improvise-fdk1).
|
|
|
|
/// Two records, North renamed past South so the sorted records order moves.
|
|
/// Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_locally_committed_coordinate_rename_agrees_with_the_host() {
|
|
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
|
let two_records = || {
|
|
let mut app = furnished_host();
|
|
let south = north_revenue().with("Region", "South");
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.set_cell(south, improvise_protocol::CellValue::Number(900.0));
|
|
app.rebuild_layout();
|
|
reduce_key(&mut app, r);
|
|
app
|
|
};
|
|
let (mut app, mut twin) = (two_records(), two_records());
|
|
let region = (0..app.layout.col_count())
|
|
.find(|&c| app.layout.col_label(c) == "Region")
|
|
.expect("a Region column");
|
|
let north = 0;
|
|
assert_eq!(
|
|
app.layout.record_row(north).unwrap().coords.get("Region"),
|
|
Some("North"),
|
|
"the fixture renames North, at row 0"
|
|
);
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (north, region);
|
|
}
|
|
let mut client = records_client(&app);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "i");
|
|
assert_eq!(
|
|
client.view.buffers.get("edit").map(String::as_str),
|
|
Some("North"),
|
|
"the editor opens on the coordinate it renames"
|
|
);
|
|
for key in [
|
|
"Backspace",
|
|
"Backspace",
|
|
"Backspace",
|
|
"Backspace",
|
|
"Backspace",
|
|
"Z",
|
|
"e",
|
|
"d",
|
|
] {
|
|
both_ends(&mut app, &mut twin, &mut client, key);
|
|
}
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
|
|
let text = document_text(&app);
|
|
assert!(
|
|
text.contains("Region=Zed") && !text.contains("Region=North"),
|
|
"the rename must move North's cells to Zed: {text}"
|
|
);
|
|
assert!(
|
|
text.contains("Region=South"),
|
|
"South is a bystander and must survive: {text}"
|
|
);
|
|
}
|
|
|
|
/// `i` then `Enter` on a coordinate: both ends open one editor and write
|
|
/// nothing. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_untouched_coordinate_enter_writes_nothing_on_either_end() {
|
|
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
|
let records = || {
|
|
let mut app = furnished_host();
|
|
app.rebuild_layout();
|
|
reduce_key(&mut app, r);
|
|
app
|
|
};
|
|
let (mut app, mut twin) = (records(), records());
|
|
let region = (0..app.layout.col_count())
|
|
.find(|&c| app.layout.col_label(c) == "Region")
|
|
.expect("a Region column");
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (0, region);
|
|
}
|
|
let mut client = records_client(&app);
|
|
let before = document_text(&app);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "i");
|
|
assert_eq!(
|
|
client.view.opened_editor, app.view_state.opened_editor,
|
|
"the two ends captured different editors"
|
|
);
|
|
assert_eq!(
|
|
client.view.opened_editor,
|
|
Some(improvise_protocol::OpenedEditor::on("North".into())),
|
|
"the coordinate editor recorded no opening"
|
|
);
|
|
let enter = BrowserKey::new("Enter").to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, enter) else {
|
|
panic!("Enter: the client must answer this key locally");
|
|
};
|
|
assert!(
|
|
!cmds
|
|
.iter()
|
|
.any(|c| matches!(c, improvise_protocol::Command::Model { .. })),
|
|
"an untouched coordinate Enter composed a write: {cmds:?}"
|
|
);
|
|
assert!(
|
|
cmds.contains(&improvise_protocol::Command::ChangeMode(AppMode::Normal)),
|
|
"Enter did not take the commit path, so this proves nothing: {cmds:?}"
|
|
);
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
assert_eq!(
|
|
document_text(&app),
|
|
before,
|
|
"an untouched Enter changed the document"
|
|
);
|
|
}
|
|
|
|
/// An empty coordinate is refused on the host's key path, which says why and
|
|
/// advances; the browser must say the same. Evidence: `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_empty_coordinate_rename_is_refused_the_way_the_host_refuses_it() {
|
|
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
|
let records = || {
|
|
let mut app = furnished_host();
|
|
reduce_key(&mut app, r);
|
|
app
|
|
};
|
|
let (mut app, mut twin) = (records(), records());
|
|
let region = (0..app.layout.col_count())
|
|
.find(|&c| app.layout.col_label(c) == "Region")
|
|
.expect("a Region column");
|
|
for host in [&mut app, &mut twin] {
|
|
host.cursor_mut().selected = (0, region);
|
|
}
|
|
let mut client = records_client(&app);
|
|
let before = document_text(&app);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "i");
|
|
for key in ["Backspace"; 5] {
|
|
both_ends(&mut app, &mut twin, &mut client, key);
|
|
}
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
|
|
assert_eq!(
|
|
document_text(&app),
|
|
before,
|
|
"an empty rename writes nothing"
|
|
);
|
|
assert!(
|
|
app.view_state.status_msg.contains("cannot be empty"),
|
|
"the host must have refused, or this proves nothing: {:?}",
|
|
app.view_state.status_msg
|
|
);
|
|
}
|
|
|
|
/// North/South/West = 1/2/3 on one measure, with `prune_empty` on: emptying a
|
|
/// row's only value takes the row away and renumbers the rest.
|
|
fn pruned_host() -> App {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
for region in ["North", "South", "West"] {
|
|
wb.add_item("Region", region).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().prune_empty = true;
|
|
for (region, value) in [("North", 1.0), ("South", 2.0), ("West", 3.0)] {
|
|
wb.model.set_cell(
|
|
region_measure(region, "Revenue"),
|
|
improvise_protocol::CellValue::Number(value),
|
|
);
|
|
}
|
|
let mut app = App::new(wb, None);
|
|
app.term_height = 3 + 8;
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// One key the way a real browser sends it: folded as an outgoing frame, applied
|
|
/// by the host, then answered with the host's own projection and `Accepted`.
|
|
fn sent_and_answered(
|
|
client: &mut ClientState,
|
|
host: &mut App,
|
|
event: KeyEvent,
|
|
window: (usize, usize),
|
|
) {
|
|
let KeyOutcome::Local(cmds) = resolve(client, event) else {
|
|
panic!("{event:?}: the client must answer this key locally");
|
|
};
|
|
client.fold_outgoing(&cmds);
|
|
apply_on(host, &cmds);
|
|
client.grid = client_of(host, window).grid;
|
|
improvise_protocol::WidgetCache::apply(&mut client.basis, &published_basis(host, window));
|
|
client.accepted();
|
|
}
|
|
|
|
/// BUG (improvise-3uzg) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up() {
|
|
let window = (3, 3);
|
|
let mut client_host = pruned_host();
|
|
let mut key_host = pruned_host();
|
|
let mut client = client_ready_to_edit(&client_host, window);
|
|
for key in ["i", "Backspace", "Enter", "5", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
sent_and_answered(&mut client, &mut client_host, event, window);
|
|
reduce_key(&mut key_host, event);
|
|
assert_eq!(
|
|
client.view.buffers, key_host.view_state.buffers,
|
|
"{key}: client and host disagree about the buffers"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
document_text(&client_host),
|
|
document_text(&key_host),
|
|
"the browser wrote a value built from another record's number"
|
|
);
|
|
assert!(
|
|
document_text(&key_host).contains("= 35"),
|
|
"setup: the host's reopened editor extends West's 3"
|
|
);
|
|
}
|
|
|
|
/// What the host owes a frame it has applied: its projection, its basis, and
|
|
/// `Accepted` when the frame was awaited. Held back so the next key overtakes it.
|
|
struct Unanswered {
|
|
grid: improvise_protocol::GridCache,
|
|
basis: improvise_protocol::Basis,
|
|
awaited: bool,
|
|
}
|
|
|
|
fn sent(
|
|
client: &mut ClientState,
|
|
host: &mut App,
|
|
event: KeyEvent,
|
|
window: (usize, usize),
|
|
) -> Unanswered {
|
|
let KeyOutcome::Local(cmds) = resolve(client, event) else {
|
|
panic!("{event:?}: the client must answer this key locally");
|
|
};
|
|
client.fold_outgoing(&cmds);
|
|
apply_on(host, &cmds);
|
|
let awaited = cmds
|
|
.iter()
|
|
.any(|c| matches!(c, improvise_protocol::Command::Model { .. }));
|
|
let (grid, basis) = (client_of(host, window).grid, published_basis(host, window));
|
|
Unanswered {
|
|
grid,
|
|
basis,
|
|
awaited,
|
|
}
|
|
}
|
|
|
|
fn answered(client: &mut ClientState, answer: Unanswered) {
|
|
client.grid = answer.grid;
|
|
improvise_protocol::WidgetCache::apply(&mut client.basis, &answer.basis);
|
|
if answer.awaited {
|
|
client.accepted();
|
|
}
|
|
}
|
|
|
|
/// BUG (improvise-mdfc) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write() {
|
|
let window = (2, 2);
|
|
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
client_host.cursor_mut().selected = (0, 1);
|
|
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
key_host.cursor_mut().selected = (0, 1);
|
|
let mut client = client_ready_to_edit(&client_host, window);
|
|
let tab = BrowserKey::new("Tab").to_key_event().unwrap();
|
|
for key in ["i", "9"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
sent_and_answered(&mut client, &mut client_host, event, window);
|
|
reduce_key(&mut key_host, event);
|
|
}
|
|
// Three Tabs, so a frame that writes nothing sits between the write and the last.
|
|
let answers: Vec<Unanswered> = (0..3)
|
|
.map(|_| sent(&mut client, &mut client_host, tab, window))
|
|
.collect();
|
|
let awaited: Vec<bool> = answers.iter().map(|a| a.awaited).collect();
|
|
assert_eq!(
|
|
awaited,
|
|
[true, false, false],
|
|
"setup: only the first Tab writes"
|
|
);
|
|
for answer in answers {
|
|
reduce_key(&mut key_host, tab);
|
|
answered(&mut client, answer);
|
|
}
|
|
assert_eq!(
|
|
client.view.buffers, key_host.view_state.buffers,
|
|
"the second editor opened on the cache the first write had made stale"
|
|
);
|
|
for key in ["5", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
sent_and_answered(&mut client, &mut client_host, event, window);
|
|
reduce_key(&mut key_host, event);
|
|
}
|
|
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
|
|
assert_eq!(
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(®ion_measure("North", "Revenue")),
|
|
Some(&improvise_protocol::CellValue::Number(295.0)),
|
|
"{end}: the browser overwrote the value Tab had just written"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// BUG (improvise-p6v6) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_editor_opened_with_i_before_the_last_write_is_answered_opens_on_that_write() {
|
|
let window = (2, 2);
|
|
let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
client_host.cursor_mut().selected = (0, 1);
|
|
let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0));
|
|
key_host.cursor_mut().selected = (0, 1);
|
|
let mut client = client_ready_to_edit(&client_host, window);
|
|
for key in ["i", "9"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
sent_and_answered(&mut client, &mut client_host, event, window);
|
|
reduce_key(&mut key_host, event);
|
|
}
|
|
// Tab writes 29; Esc and i reopen before its answer comes back.
|
|
let answers: Vec<Unanswered> = ["Tab", "Escape", "i"]
|
|
.into_iter()
|
|
.map(|key| {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
reduce_key(&mut key_host, event);
|
|
sent(&mut client, &mut client_host, event, window)
|
|
})
|
|
.collect();
|
|
let awaited: Vec<bool> = answers.iter().map(|a| a.awaited).collect();
|
|
assert_eq!(awaited, [true, false, false], "setup: only the Tab writes");
|
|
answers
|
|
.into_iter()
|
|
.for_each(|answer| answered(&mut client, answer));
|
|
assert_eq!(
|
|
client.view.buffers, key_host.view_state.buffers,
|
|
"i opened on the cache the unanswered write had made stale"
|
|
);
|
|
for key in ["5", "Enter"] {
|
|
let event = BrowserKey::new(key).to_key_event().unwrap();
|
|
sent_and_answered(&mut client, &mut client_host, event, window);
|
|
reduce_key(&mut key_host, event);
|
|
}
|
|
for (end, app) in [("browser", &client_host), ("host", &key_host)] {
|
|
assert_eq!(
|
|
app.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(®ion_measure("North", "Revenue")),
|
|
Some(&improvise_protocol::CellValue::Number(295.0)),
|
|
"{end}: the browser overwrote the value Tab had just written"
|
|
);
|
|
}
|
|
}
|
|
|
|
const FORMULAS: [&str; 3] = [
|
|
"Profit = Revenue * 2",
|
|
"Tax = Revenue * 0.1",
|
|
"Margin = Profit / Revenue",
|
|
];
|
|
|
|
fn add_formulas(app: &mut App) {
|
|
for raw in FORMULAS {
|
|
let f = improvise_command::formula::parse_formula(raw, "_Measure").unwrap();
|
|
app.model_state.workbook.model.add_formula(f);
|
|
}
|
|
app.rebuild_layout();
|
|
}
|
|
|
|
fn raws(app: &App) -> Vec<String> {
|
|
let formulas = app.model_state.workbook.model.formulas();
|
|
formulas.iter().map(|f| f.raw.clone()).collect()
|
|
}
|
|
|
|
/// The formula panel's edit (improvise-km71), opened at a NONZERO cursor over
|
|
/// three formulas, typed into and committed, with both ends agreeing throughout.
|
|
#[test]
|
|
fn editing_a_formula_from_the_panel_agrees_with_the_host() {
|
|
let mut app = furnished_host();
|
|
add_formulas(&mut app);
|
|
app.view_state.formula_panel_open = true;
|
|
app.view_state.mode = AppMode::FormulaPanel;
|
|
app.view_state.formula_cursor = 1;
|
|
let mut twin = twin_of(&app);
|
|
add_formulas(&mut twin);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
client.formulas = improvise_command::ui::project::formulas_of(&app);
|
|
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
assert_eq!(
|
|
client.view.buffers.get("formula"),
|
|
app.view_state.buffers.get("formula"),
|
|
"the two ends seeded the editor differently"
|
|
);
|
|
assert_eq!(
|
|
app.view_state.buffers.get("formula").map(String::as_str),
|
|
Some(FORMULAS[1]),
|
|
"Enter must seed the formula under cursor 1"
|
|
);
|
|
for key in ["Backspace", "2", "Enter"] {
|
|
both_ends(&mut app, &mut twin, &mut client, key);
|
|
}
|
|
assert_eq!(
|
|
raws(&app),
|
|
vec![FORMULAS[0], "Tax = Revenue * 0.2", FORMULAS[2]],
|
|
"the commit must replace the formula it opened on, in place"
|
|
);
|
|
}
|
|
|
|
/// A rename through the browser: the one input that separates "replace" from
|
|
/// "add", since `add_formula` upserts by target. The status diverges on purpose.
|
|
#[test]
|
|
fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() {
|
|
let mut app = furnished_host();
|
|
add_formulas(&mut app);
|
|
app.view_state.formula_panel_open = true;
|
|
app.view_state.mode = AppMode::FormulaPanel;
|
|
app.view_state.formula_cursor = 1;
|
|
let mut twin = twin_of(&app);
|
|
add_formulas(&mut twin);
|
|
let mut client = furnished_client(&app, (2, 1));
|
|
client.formulas = improvise_command::ui::project::formulas_of(&app);
|
|
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
|
for view in [&mut app.view_state, &mut twin.view_state, &mut client.view] {
|
|
view.buffers
|
|
.insert("formula".into(), "Levy = Revenue * 0.1".into());
|
|
}
|
|
|
|
let event = BrowserKey::new("Enter").to_key_event().unwrap();
|
|
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
|
panic!("Enter in the formula editor resolves here");
|
|
};
|
|
for cmd in &cmds {
|
|
client.fold(cmd);
|
|
}
|
|
apply_on(&mut twin, &cmds);
|
|
reduce_key(&mut app, event);
|
|
|
|
assert_eq!(document_text(&twin), document_text(&app));
|
|
assert_eq!(
|
|
raws(&app),
|
|
vec![FORMULAS[0], "Levy = Revenue * 0.1", FORMULAS[2]]
|
|
);
|
|
assert_eq!(client.view.mode, app.view_state.mode);
|
|
assert_eq!(client.view.status_msg, "Formula updated");
|
|
assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy");
|
|
}
|
|
|
|
/// One scroll-then-edit case: the window, where the cursor starts, and the keys.
|
|
type Pipelined = (
|
|
&'static str,
|
|
(usize, usize),
|
|
((usize, usize), usize, usize),
|
|
&'static [&'static str],
|
|
);
|
|
|
|
/// When the host's replies reach the client.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum Replies {
|
|
/// Each before the next key.
|
|
Delivered,
|
|
/// All held to the end, so every key reads the first window.
|
|
Held,
|
|
/// Held until the last two keys, which then run delivered.
|
|
HeldThenTyped,
|
|
}
|
|
|
|
/// `wrapping_host`'s cells that differ from how it was built, as `Month/Measure`.
|
|
fn written_cells(app: &App) -> Vec<String> {
|
|
let model = &app.model_state.workbook.model;
|
|
let mut written = Vec::new();
|
|
for month in ["Jan", "Feb"] {
|
|
for (measure, built) in [
|
|
("Revenue", Some(10.0)),
|
|
("Profit", None),
|
|
("Cost", Some(4.0)),
|
|
] {
|
|
let key = improvise_command::model::cell::CellKey::new(vec![
|
|
("Month".to_string(), month.to_string()),
|
|
("_Measure".to_string(), measure.to_string()),
|
|
]);
|
|
let now = model.get_cell(&key).cloned();
|
|
if now != built.map(improvise_protocol::CellValue::Number) {
|
|
written.push(format!("{month}/{measure}"));
|
|
}
|
|
}
|
|
}
|
|
written
|
|
}
|
|
|
|
/// One key on the client, its frame sent to the host; the reply comes back for
|
|
/// the caller to deliver or hold. An upstream key reaches a host that answers nothing.
|
|
fn sent_to_host(
|
|
host: &mut improvise_command::ui::host::Host,
|
|
session: improvise_command::ui::project::SessionId,
|
|
client: &mut ClientState,
|
|
key: &str,
|
|
) -> Vec<improvise_command::ui::host::Addressed> {
|
|
match resolve(client, BrowserKey::new(key).to_key_event().unwrap()) {
|
|
KeyOutcome::Local(cmds) => {
|
|
client.fold_outgoing(&cmds);
|
|
host.handle(session, ClientFrame::Commands(cmds))
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// A host over `make()` and a client on it, the cursor placed and answered.
|
|
fn placed_client(
|
|
make: fn() -> App,
|
|
window: (usize, usize),
|
|
(at, row_offset, col_offset): ((usize, usize), usize, usize),
|
|
) -> (
|
|
improvise_command::ui::host::Host,
|
|
improvise_command::ui::project::SessionId,
|
|
ClientState,
|
|
) {
|
|
let mut host = improvise_command::ui::host::Host::new(make());
|
|
let (session, welcome) = host.connect(window.0, window.1);
|
|
let mut client = ClientState {
|
|
window,
|
|
..ClientState::default()
|
|
};
|
|
deliver(vec![welcome], session, &mut client);
|
|
let placed = vec![
|
|
improvise_protocol::Command::SetSelected {
|
|
row: at.0,
|
|
col: at.1,
|
|
},
|
|
improvise_protocol::Command::SetRowOffset(row_offset),
|
|
improvise_protocol::Command::SetColOffset(col_offset),
|
|
];
|
|
client.fold_outgoing(&placed);
|
|
deliver(
|
|
host.handle(session, ClientFrame::Commands(placed)),
|
|
session,
|
|
&mut client,
|
|
);
|
|
(host, session, client)
|
|
}
|
|
|
|
/// improvise-7mro: the offset-aliasing matrix, with the host's replies delivered,
|
|
/// held, and held until the typing. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() {
|
|
let cases: [Pipelined; 7] = [
|
|
(
|
|
"wrap",
|
|
(2, 6),
|
|
((2, 0), 1, 0),
|
|
&["i", "Enter", "8", "Enter"],
|
|
),
|
|
(
|
|
"horizontal wrap",
|
|
(2, 1),
|
|
((2, 0), 1, 0),
|
|
&["i", "Enter", "8", "Enter"],
|
|
),
|
|
("G", (2, 6), ((0, 0), 0, 0), &["G", "i", "7", "Enter"]),
|
|
("$", (2, 1), ((0, 0), 0, 0), &["$", "i", "7", "Enter"]),
|
|
("gg", (2, 6), ((2, 0), 1, 0), &["g", "g", "i", "7", "Enter"]),
|
|
("$ x", (2, 1), ((0, 0), 0, 0), &["$", "x"]),
|
|
("gg p", (2, 6), ((2, 0), 1, 0), &["y", "y", "g", "g", "p"]),
|
|
];
|
|
for (name, window, start, keys) in cases {
|
|
let mut tui = wrapping_host();
|
|
tui.cursor_mut().selected = start.0;
|
|
for &k in keys {
|
|
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
|
|
}
|
|
assert_eq!(
|
|
written_cells(&tui).len(),
|
|
1,
|
|
"setup: {name}: the TUI wrote one cell"
|
|
);
|
|
for replies in [Replies::Delivered, Replies::Held, Replies::HeldThenTyped] {
|
|
let what = format!("{name} ({keys:?}, replies {replies:?})");
|
|
let (mut host, session, mut client) = placed_client(wrapping_host, window, start);
|
|
let typed_from = match replies {
|
|
Replies::Delivered => 0,
|
|
Replies::Held => keys.len(),
|
|
Replies::HeldThenTyped => keys.len().saturating_sub(2),
|
|
};
|
|
let mut held = Vec::new();
|
|
for (i, &k) in keys.iter().enumerate() {
|
|
if i == typed_from {
|
|
deliver(std::mem::take(&mut held), session, &mut client);
|
|
}
|
|
held.extend(sent_to_host(&mut host, session, &mut client, k));
|
|
if i >= typed_from {
|
|
deliver(std::mem::take(&mut held), session, &mut client);
|
|
}
|
|
}
|
|
if replies == Replies::Held {
|
|
assert_eq!(
|
|
written_cells(host.app()),
|
|
Vec::<String>::new(),
|
|
"{what}: a write was composed before its target was projected"
|
|
);
|
|
assert!(
|
|
client.view.mode.is_editing() == keys.contains(&"i")
|
|
&& !client.view.status_msg.is_empty(),
|
|
"{what}: the last key must wait out loud, any editor open: {:?}, {:?}",
|
|
client.view.mode,
|
|
client.view.status_msg
|
|
);
|
|
deliver(std::mem::take(&mut held), session, &mut client);
|
|
let retry = keys.last().expect("a key");
|
|
let reply = sent_to_host(&mut host, session, &mut client, retry);
|
|
deliver(reply, session, &mut client);
|
|
}
|
|
assert_eq!(
|
|
written_cells(host.app()),
|
|
written_cells(&tui),
|
|
"{what}: the browser wrote a different cell from the TUI"
|
|
);
|
|
if replies != Replies::Held {
|
|
assert_eq!(document_text(host.app()), document_text(&tui), "{what}");
|
|
assert_eq!(
|
|
client.view.buffers.get("edit"),
|
|
tui.view_state.buffers.get("edit"),
|
|
"{what}: the editor's buffer"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
client.view.cursor().selected,
|
|
tui.view_state.cursor().selected,
|
|
"{what}: the cursor"
|
|
);
|
|
assert_eq!(
|
|
format!("{:?}", client.view.mode),
|
|
format!("{:?}", tui.view_state.mode),
|
|
"{what}: the mode; browser status {:?}",
|
|
client.view.status_msg
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Four regions by two measures, every cell stored and no two alike, so a cell
|
|
/// read at the wrong row or column is a different value.
|
|
fn four_by_two_host() -> App {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
for region in ["R0", "R1", "R2", "R3"] {
|
|
wb.add_item("Region", region).unwrap();
|
|
}
|
|
for measure in ["A", "B"] {
|
|
wb.add_item("_Measure", measure).unwrap();
|
|
}
|
|
wb.active_view_mut().set_axis("Region", Axis::Row);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
for (r, region) in ["R0", "R1", "R2", "R3"].into_iter().enumerate() {
|
|
for (base, measure) in [(10.0, "A"), (20.0, "B")] {
|
|
let key = improvise_command::model::cell::CellKey::new(vec![
|
|
("Region".to_string(), region.to_string()),
|
|
("_Measure".to_string(), measure.to_string()),
|
|
]);
|
|
wb.model
|
|
.set_cell(key, improvise_protocol::CellValue::Number(base + r as f64));
|
|
}
|
|
}
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
app
|
|
}
|
|
|
|
/// improvise-7mro: a cell inside the held window, read while the client's offsets
|
|
/// differ from the frame's origin. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_held_cell_is_read_at_its_position_and_not_its_cache_index() {
|
|
for keys in [
|
|
&["k", "k", "j", "i", "7", "Enter"][..],
|
|
&["k", "k", "j", "i", "Tab"][..],
|
|
] {
|
|
let mut tui = four_by_two_host();
|
|
tui.cursor_mut().selected = (3, 0);
|
|
for &k in keys {
|
|
reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap());
|
|
}
|
|
let (mut host, session, mut client) =
|
|
placed_client(four_by_two_host, (2, 6), ((3, 0), 2, 0));
|
|
let mut held = Vec::new();
|
|
for (i, &k) in keys.iter().enumerate() {
|
|
if i == 3 {
|
|
let cursor = client.view.cursor();
|
|
assert_eq!(
|
|
(
|
|
cursor.selected,
|
|
cursor.row_offset,
|
|
client.grid.row_frame.first
|
|
),
|
|
((2, 0), 1, 2),
|
|
"setup: inside the held window, the offsets not its origin"
|
|
);
|
|
}
|
|
held.extend(sent_to_host(&mut host, session, &mut client, k));
|
|
}
|
|
assert_eq!(document_text(host.app()), document_text(&tui), "{keys:?}");
|
|
assert_eq!(
|
|
client.view.cursor().selected,
|
|
tui.view_state.cursor().selected
|
|
);
|
|
// An editor reopened after a write awaits the host's seed (p6v6): read it
|
|
// only once the held replies arrive; an unawaited one is read now as well.
|
|
let awaiting = client
|
|
.view
|
|
.opened_editor
|
|
.as_ref()
|
|
.is_some_and(|o| o.awaiting);
|
|
if !awaiting {
|
|
assert_editor_agrees(&client, &tui, keys, "held");
|
|
}
|
|
deliver(held, session, &mut client);
|
|
assert_editor_agrees(&client, &tui, keys, "delivered");
|
|
}
|
|
}
|
|
|
|
fn assert_editor_agrees(client: &ClientState, tui: &App, keys: &[&str], when: &str) {
|
|
assert_eq!(
|
|
client.view.buffers.get("edit"),
|
|
tui.view_state.buffers.get("edit"),
|
|
"{keys:?}, {when}"
|
|
);
|
|
assert_eq!(
|
|
client.view.opened_editor, tui.view_state.opened_editor,
|
|
"{keys:?}, {when}: the editor opened on another cell's seed"
|
|
);
|
|
}
|