1dd6950ed3
Nine ModelCommand variants configure a view and named it implicitly: the effect read App::view_mut(), the HOST's slice. Since improvise-jytm made ViewState::active_view authoritative per client, a client on another view moved the host's view instead of its own -- its grid did not change and every other client's did. Each of the nine now carries `view: String`; model_effects maps it to effect::ViewTarget::Named, and the nine effects carry a ViewTarget rather than reading an ambient one. There is no operandless form left: App::view_mut() is deleted and replaced by view_mut_of(&ViewTarget), so a future geometry effect has to say which view it writes. The host's key path passes ViewTarget::Active, which is right -- the TUI is the host and has no session to ask. A Named view the document has not got is a no-op rather than resolve_view's fallback onto the default. Second half, which the ticket did not predict: naming the view made a reframe of a view the host is NOT on reachable, and adopt_cursor_epoch could not see one -- cursor_epoch is bumped only by the host's own reset_cursor, so those clients were reset by nobody and told nothing (gotcha 24's strictly-worse-than-nothing state). App::last_reframed_elsewhere is the second trigger; its opening frames are taken lazily inside view_mut_of, the one door such a write goes through, so the held-key path still hashes one view and improvise-mcq4's declined cost is not paid. Evidence and the three injected faults: context/test-evidence/command.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
1151 lines
41 KiB
Rust
1151 lines
41 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"
|
|
);
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
|
|
/// 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
|
|
);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
|
|
/// `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()
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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)
|
|
);
|
|
}
|
|
}
|