//! 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) ); }; client.fold_outgoing(&cmds); 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) ); }; client.fold_outgoing(&cmds); 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 Ctrl-Q: the evidence file. #[test] fn a_forwarded_key_reaches_the_host_unchanged() { let mut client = ClientState::default(); let browser = BrowserKey { ctrl: true, ..BrowserKey::new("q") }; 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"); }; assert_eq!( modifiers, KeyModifiers::CONTROL, "the modifier survives the wire" ); let mut app = host(); reduce_key(&mut app, KeyEvent::new(code, modifiers)); assert_eq!( app.view_state.mode, AppMode::Quit, "the host resolved Ctrl-Q, which the client does not" ); // And the client catches up only by folding what it is told. assert_eq!(client.view.mode, AppMode::Normal); client.fold(&improvise_protocol::Command::ChangeMode(AppMode::Quit)); assert_eq!(client.view.mode, AppMode::Quit); } /// `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) => { client.fold_outgoing(&cmds); } // 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) => { client.fold_outgoing(&cmds); } 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"); }; client.fold_outgoing(&cmds); 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"); }; client.fold_outgoing(&cmds); 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"); }; client.fold_outgoing(&cmds); 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" ); let count = client.tiles.tiles.len(); for press in 0..=count { 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"); }; client.fold_outgoing(&cmds); 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_eq!( client.view.tile_cat_idx, count - 1, "count + 1 presses must end clamped on the last tile (improvise-qnry)" ); } // 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"); }; client.fold_outgoing(&cmds); 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"); }; client.fold_outgoing(&cmds); 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"); }; client.fold_outgoing(&cmds); 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" ); } /// improvise-qnry: Tab at the rightmost column clamps there on both ends, as /// `CommitAndAdvance` does. Evidence: `context/test-evidence/clients.md`. #[test] fn tab_at_the_rightmost_column_stays_there_on_both_ends() { let window = (2, 2); let mut app = precise_host(improvise_protocol::CellValue::Number(4.0)); let mut client = client_ready_to_edit(&app, window); let extent = client.grid.extent; assert!( !client.grid.records, "records mode declines this corner instead" ); assert_eq!( client.view.cursor().selected, (1, extent.cols - 1), "the cursor must start on the rightmost column at a nonzero row" ); for key in ["i", "Tab"] { 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!( client.view.cursor(), app.view_state.cursor(), "{key}: client and host disagree about the cursor" ); assert_eq!( format!("{:?}", client.view.mode), format!("{:?}", app.view_state.mode), "{key}: client and host disagree about the mode" ); } assert!( app.view_state.mode.is_editing(), "Tab must have reopened the editor, or the walk stopped short" ); } // 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"); }; client.fold_outgoing(&cmds); 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}" ); } } /// Records mode: `_Index` holds Row, so a "first tile on Row" rule would cycle /// it and drop the user out of records mode (improvise-52od, clients.md). #[test] fn in_records_mode_tile_select_opens_on_a_regular_category_on_both_ends() { let (mut app, mut twin) = records_pair(); let mut client = furnished_client(&app, (8, 8)); let axis_of = |app: &App, cat: &str| app.view().axis_of(cat); assert!( app.view_state.mode.is_records(), "the fixture must be in records mode" ); assert_eq!(axis_of(&app, "_Index"), Axis::Row); let region_before = axis_of(&app, "Region"); 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::Row, "T Enter moved _Index"); assert_eq!(axis_of(&app, "_Dim"), Axis::Column, "T Enter moved _Dim"); assert_ne!( axis_of(&app, "Region"), region_before, "Region is the tile T opens on" ); assert_eq!(axis_of(&twin, "Region"), axis_of(&app, "Region")); } /// A document with no regular category: tile select must still not open on /// `_Index` (improvise-bqm0). Why `_Measure`: `context/code/command.md`. #[test] fn with_no_regular_category_tile_select_opens_on_measure_on_both_ends() { let bare = || { let mut wb = Workbook::new("Test"); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.model.set_cell( improvise_command::model::cell::CellKey::new(vec![( "_Measure".to_string(), "Revenue".to_string(), )]), improvise_protocol::CellValue::Number(5.0), ); let mut app = App::new(wb, None); app.term_height = 2 + 8; app.rebuild_layout(); app }; let (mut app, mut twin) = (bare(), bare()); let mut client = furnished_client(&app, (2, 1)); let axis_of = |app: &App, cat: &str| app.view().axis_of(cat); let index_before = axis_of(&app, "_Index"); both_ends(&mut app, &mut twin, &mut client, "T"); both_ends(&mut app, &mut twin, &mut client, "Enter"); assert_eq!( axis_of(&app, "_Index"), index_before, "T Enter moved _Index" ); assert_ne!( axis_of(&app, "_Measure"), Axis::Column, "_Measure is the tile T opens on" ); } /// 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 = 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 = 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"); }; client.fold_outgoing(&cmds); 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)> { // `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"); }; client.fold_outgoing(&cmds); 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, 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) => { cmds.iter().for_each(|cmd| client.fold(cmd)); } improvise_protocol::ServerFrame::Rejected(r) => client.refused(r), improvise_protocol::ServerFrame::Accepted => client.accepted(), // Download responses are not processed in this test context (improvise-2mqz). improvise_protocol::ServerFrame::DocumentBytes { .. } | improvise_protocol::ServerFrame::CsvBytes { .. } => {} } } } /// improvise-xoxh: `deliver` models the HOST, so a relay it hands over queues /// behind the awaited head. Evidence: `context/test-evidence/clients.md`. #[test] fn deliver_queues_a_host_relay_behind_the_awaited_head() { use improvise_protocol::Command; use improvise_protocol::client::Sent; let sent = |cmd: Command, awaited: bool, relayed: bool| Sent { cmds: vec![cmd], awaited, relayed, }; let head = sent(Command::SetSelected { row: 1, col: 0 }, true, false); let own = sent(Command::SetSelected { row: 2, col: 0 }, false, false); let mut client = ClientState::default(); client.in_flight.extend([head.clone(), own.clone()]); let session = improvise_command::ui::project::SessionId(0); let relay = Command::SetSelected { row: 3, col: 0 }; let frame = improvise_command::ui::host::Addressed { to: session, msg: improvise_protocol::ServerFrame::Commands(vec![relay.clone()]), }; deliver(vec![frame], session, &mut client); let queued: Vec = client.in_flight.iter().cloned().collect(); assert_eq!(queued, vec![head, sent(relay, false, true), own]); } /// 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), ]; client.fold_outgoing(&composed); 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) -> &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"); }; client.fold_outgoing(&cmds); 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"); }; client.fold_outgoing(&cmds); 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.active_view_mut().set_expand_depth("Month", 1); 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> { [ ("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"); }; client.fold_outgoing(&cmds); 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 = (0..3) .map(|_| sent(&mut client, &mut client_host, tab, window)) .collect(); let awaited: Vec = 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 = ["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 = 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 { 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"); }; client.fold_outgoing(&cmds); apply_on(&mut twin, &cmds); reduce_key(&mut app, event); let awaiting: Vec = client.in_flight.iter().map(|s| s.awaited).collect(); assert_eq!(awaiting, vec![true], "the commit awaits the host's answer"); 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"); } /// `d` in the formula panel, at a NONZERO cursor over three formulas: the /// client names the formula by its id, and both ends step the cursor back. #[test] fn deleting_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, "d"); assert_eq!( raws(&app), vec![FORMULAS[0], FORMULAS[2]], "`d` must remove the formula under cursor 1, not the first or the last" ); assert_eq!(app.view_state.formula_cursor, 0, "and step the cursor back"); } /// The id the client names was deleted by someone else first: the frame is /// refused whole and the document keeps what it had. #[test] fn deleting_a_formula_another_client_already_removed_is_refused_cleanly() { 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 client = furnished_client(&app, (2, 1)); client.formulas = improvise_command::ui::project::formulas_of(&app); let event = BrowserKey::new("d").to_key_event().unwrap(); let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("`d` in the formula panel resolves here"); }; let mut host = improvise_command::ui::host::Host::new(app); let (id, _) = host.connect(2, 1); let model = &mut host.app_mut().model_state.workbook.model; model.remove_formula("Tax", "_Measure"); let before = document_text(host.app()); let replies = host.handle(id, ClientFrame::Commands(cmds)); assert!( replies.iter().any(|a| matches!( a.msg, improvise_protocol::ServerFrame::Rejected( improvise_protocol::Rejection::MutationRefused ) )), "a delete of a formula that no longer exists must be refused: {replies:?}" ); assert_eq!( document_text(host.app()), before, "nothing else was removed" ); assert_eq!(raws(host.app()), vec![FORMULAS[0], FORMULAS[2]]); } // improvise-fo0w: three keys the browser sent to a host that answers nothing. /// Three rows by two columns, a two-row window: `Enter` must scroll, wrap to /// the next column and stop at the bottom-right. fn advancing_host() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); for item in ["North", "South", "East"] { wb.add_item("Region", item).unwrap(); } for m in ["A", "B"] { 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 = 2 + 8; app.rebuild_layout(); app } /// improvise-fo0w: `Enter` mirrors `EnterAdvance` press for press, including /// the stop. Evidence: `context/test-evidence/clients.md`. #[test] fn enter_advances_and_wraps_where_the_host_does() { let window = (2, 2); let mut app = advancing_host(); let mut client = client_of(&app, window); let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); let path = [(1, 0), (2, 0), (0, 1), (1, 1), (2, 1), (2, 1), (2, 1)]; for (press, want) in path.into_iter().enumerate() { match resolve(&client, event) { KeyOutcome::Local(cmds) => cmds.iter().for_each(|c| client.fold(c)), KeyOutcome::Ignored if want == (2, 1) => {} other => panic!("press {press}: the client must answer Enter, got {other:?}"), } reduce_key(&mut app, event); assert_eq!( client.view.cursor(), app.view_state.cursor(), "press {press}: client and host disagree" ); assert_eq!(client.view.cursor().selected, want, "press {press}"); } } /// improvise-fo0w: `H` hides the selected row's item, composed as the one /// `HideItem` the host's effect is. Evidence: `context/test-evidence/clients.md`. #[test] fn hiding_the_selected_row_item_agrees_with_the_host() { let mut app = furnished_host(); let mut twin = twin_of(&app); let mut client = furnished_client(&app, (2, 1)); both_ends(&mut app, &mut twin, &mut client, "j"); let event = BrowserKey::new("H").to_key_event().unwrap(); let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("H: the client must answer this key locally"); }; cmds.iter().for_each(|c| client.fold(c)); apply_on(&mut twin, &cmds); reduce_key(&mut app, event); assert_eq!(document_text(&twin), document_text(&app)); assert_eq!(client.view.status_msg, app.view_state.status_msg); // The re-framed grid resets the cursor on the host, and on the twin that // applied the client's `HideItem`; the client hears it relayed (gotcha 24). assert_eq!(twin.view_state.cursor(), app.view_state.cursor()); let view = app.model_state.workbook.active_view(); assert!( view.is_hidden("Region", "South"), "the row under the cursor, South, must be the item hidden" ); } /// improvise-fo0w: under two row categories `H` hides the OUTER one's item, /// as the host does. Evidence: `context/test-evidence/clients.md`. #[test] fn hiding_under_two_row_categories_hides_the_outer_item() { let mut app = two_row_categories_host(); let mut twin = two_row_categories_host(); let client = furnished_client(&app, (4, 1)); let event = BrowserKey::new("H").to_key_event().unwrap(); let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("H: the client must answer this key locally"); }; apply_on(&mut twin, &cmds); reduce_key(&mut app, event); assert_eq!(document_text(&twin), document_text(&app)); assert!( app.model_state .workbook .active_view() .is_hidden("Region", "South"), "row 2 is (South, Widget): the outer category's item is the one hidden" ); } /// Region then Product on rows, with the cursor on row 2, (South, Widget). fn two_row_categories_host() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); wb.add_category("Product").unwrap(); for (cat, item) in [ ("Region", "North"), ("Region", "South"), ("Product", "Widget"), ("Product", "Gadget"), ] { wb.add_item(cat, item).unwrap(); } wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Product", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); let mut app = App::new(wb, None); app.term_height = 4 + 8; app.rebuild_layout(); app.view_state.cursor_mut().selected = (2, 0); app } /// Region over Product, each with one group (`Coast`, `Kit`), either collapsed. fn nested_groups_host(coast_collapsed: bool, kit_collapsed: bool) -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); wb.add_category("Product").unwrap(); wb.add_item("Region", "North").unwrap(); for item in ["East", "West"] { wb.add_item_in_group("Region", item, "Coast").unwrap(); } wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("Product", "Widget").unwrap(); for item in ["Gadget", "Gizmo"] { wb.add_item_in_group("Product", item, "Kit").unwrap(); } wb.active_view_mut().set_expand_depth("Product", 1); wb.add_item("_Measure", "Revenue").unwrap(); let view = wb.active_view_mut(); view.set_axis("Region", Axis::Row); view.set_axis("Product", Axis::Row); view.set_axis("_Measure", Axis::Column); if coast_collapsed { view.toggle_group_collapse("Region", &["Coast"]); } if kit_collapsed { view.toggle_group_collapse("Product", &["Kit"]); } let mut app = App::new(wb, None); app.term_height = 12 + 8; app.rebuild_layout(); app } /// improvise-4k1j: `H` on every row of two-category grouped grids, whole and /// windowed, agrees with the host. Evidence: `context/test-evidence/clients.md`. #[test] fn hiding_agrees_with_the_host_on_every_row_of_nested_groups() { let mut disagreements = Vec::new(); let mut rows_pressed = 0; for (coast, kit) in [(true, false), (false, true), (true, true), (false, false)] { let rows = nested_groups_host(coast, kit).layout.row_count(); for row in 0..rows { for window in [rows, 2] { let mut app = nested_groups_host(coast, kit); let mut twin = nested_groups_host(coast, kit); let offset = row.saturating_sub(window - 1); app.view_state.cursor_mut().selected = (row, 0); app.view_state.cursor_mut().row_offset = offset; twin.view_state = app.view_state.clone(); twin.rebuild_layout(); let mut client = furnished_client(&app, (window, 1)); let event = BrowserKey::new("H").to_key_event().unwrap(); let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("H must answer locally at coast={coast} kit={kit} row={row}"); }; cmds.iter().for_each(|c| client.fold(c)); apply_on(&mut twin, &cmds); reduce_key(&mut app, event); rows_pressed += 1; if document_text(&twin) != document_text(&app) || client.view.status_msg != app.view_state.status_msg { disagreements.push(format!( "coast={coast} kit={kit} row={row} window={window}: client {cmds:?}, \ host status {:?}", app.view_state.status_msg )); } } } } assert_eq!(rows_pressed, 50, "setup: the four grids have 25 rows"); assert!(disagreements.is_empty(), "{disagreements:#?}"); } /// A collapsed group of Region items: its fold is the second row. fn folded_host() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); wb.add_item("Region", "North").unwrap(); for item in ["East", "West"] { wb.add_item_in_group("Region", item, "Coast").unwrap(); } wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut() .toggle_group_collapse("Region", &["Coast"]); let mut app = App::new(wb, None); app.term_height = 4 + 8; app.rebuild_layout(); app } /// improvise-fo0w: on a fold `H` refuses in the host's own words and hides /// nothing. Evidence: `context/test-evidence/clients.md`. #[test] fn hiding_on_a_fold_refuses_as_the_host_does() { let mut app = folded_host(); let before = document_text(&app); let fold = (0..app.layout.row_count()) .find(|&r| app.layout.row_folds_a_group(r)) .expect("setup: the fixture has a fold row"); app.view_state.cursor_mut().selected = (fold, 0); let client = client_of(&app, (4, 1)); let event = KeyEvent::new(KeyCode::Char('H'), KeyModifiers::NONE); let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("H on a fold must be answered here, out loud"); }; reduce_key(&mut app, event); assert_eq!(document_text(&app), before, "setup: the host hid nothing"); assert_eq!( cmds, vec![improvise_protocol::Command::SetStatus( app.view_state.status_msg.clone() )], "the client must refuse in the host's words and compose nothing else" ); } /// improvise-fo0w: `/` and `n` decline out loud. A search box that can find /// nothing is worse than silence. Evidence: `context/test-evidence/clients.md`. #[test] fn the_search_keys_decline_out_loud_in_a_browser() { let app = furnished_host(); let client = furnished_client(&app, (2, 1)); for key in ["/", "n"] { let event = BrowserKey::new(key).to_key_event().unwrap(); let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("{key}: must be answered here, out loud"); }; let [improvise_protocol::Command::SetStatus(said)] = cmds.as_slice() else { panic!("{key}: must say so and do nothing else, got {cmds:?}"); }; assert!( said.contains("search") && said.contains("browser"), "{key}: {said}" ); } } /// North/South/West/East = 1/2/3/4 on one measure, `prune_empty` on: emptying /// North takes its row away and renumbers the rest (improvise-xd3q). fn four_pruned_regions() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); for region in ["North", "South", "West", "East"] { 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), ("East", 4.0)] { wb.model.set_cell( region_measure(region, "Revenue"), improvise_protocol::CellValue::Number(value), ); } let mut app = App::new(wb, None); app.term_height = 4 + 8; app.rebuild_layout(); app } /// One key resolved by the client and sent to `host`; its answer is returned, /// NOT folded, so the next key can go out first. fn sent_to_host( client: &mut ClientState, host: &mut improvise_command::ui::host::Host, id: improvise_command::ui::project::SessionId, key: &str, ) -> Vec { let event = BrowserKey::new(key).to_key_event().unwrap(); let cmds = match resolve(client, event) { KeyOutcome::Local(cmds) => cmds, other => panic!("{key}: the client must answer this key locally, got {other:?}"), }; client.fold_outgoing(&cmds); host.handle(id, ClientFrame::Commands(cmds)) } /// Fold an answer the way the page does: the host's commands through `fold`. fn heard( client: &mut ClientState, id: improvise_command::ui::project::SessionId, answer: Vec, ) { for addressed in answer.into_iter().filter(|a| a.to == id) { match addressed.msg { improvise_protocol::ServerFrame::Snapshot(snapshot) => client.bootstrap(*snapshot), improvise_protocol::ServerFrame::Commands(cmds) => { cmds.iter().for_each(|c| client.fold(c)) } improvise_protocol::ServerFrame::Rejected(r) => client.refused(r), improvise_protocol::ServerFrame::Accepted => client.accepted(), // Download responses are not processed in this test context (improvise-2mqz). improvise_protocol::ServerFrame::DocumentBytes { .. } | improvise_protocol::ServerFrame::CsvBytes { .. } => {} } } } /// BUG (improvise-xd3q) — see `context/test-evidence/clients.md`. #[test] fn a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes() { let window = (4, 4); let mut host = improvise_command::ui::host::Host::new(four_pruned_regions()); let (id, snapshot) = host.connect(window.0, window.1); let mut client = ClientState { window, ..ClientState::default() }; heard(&mut client, id, vec![snapshot]); let mut tui = four_pruned_regions(); let key = |tui: &mut App, k: &str| reduce_key(tui, BrowserKey::new(k).to_key_event().unwrap()); for k in ["i", "Backspace"] { let answer = sent_to_host(&mut client, &mut host, id, k); heard(&mut client, id, answer); key(&mut tui, k); } // Enter prunes North; Esc and j go out before its answer comes back. let answers: Vec<_> = ["Enter", "Escape", "j"] .into_iter() .map(|k| { key(&mut tui, k); sent_to_host(&mut client, &mut host, id, k) }) .collect(); answers.into_iter().for_each(|a| heard(&mut client, id, a)); let mirror = host.sessions().get(id).unwrap().view().cursor(); assert_eq!( mirror.selected, tui.cursor().selected, "setup: the host's mirror followed the keys the TUI followed" ); assert_eq!( client.view.cursor().selected, mirror.selected, "the pruning commit's reset clobbered where j had put the client" ); for k in ["i", "5", "Enter"] { let answer = sent_to_host(&mut client, &mut host, id, k); heard(&mut client, id, answer); key(&mut tui, k); } assert_eq!( document_text(host.app()), document_text(&tui), "the browser wrote a different record from the one the TUI wrote" ); } /// 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 { 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_or_upstream( host: &mut improvise_command::ui::host::Host, session: improvise_command::ui::project::SessionId, client: &mut ClientState, key: &str, ) -> Vec { 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_or_upstream(&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::::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_or_upstream(&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_or_upstream(&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" ); }