From fb246a83310a57fa5ba14f59393676415a95bc5b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:24:09 -0700 Subject: [PATCH] improvise-7mro: the seed settles on a projected cell, and the matrix holds replies three ways An awaited seed waits for a cell the host projected (id present), never the blank a relabel leaves between a frame and its cells. The advance's reopen keeps #282's shape: on this base the welded clear-buffer empties it anyway. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/client.rs | 4 +- crates/improvise-protocol/src/resolve.rs | 14 +- .../improvise-wasm-client/tests/lockstep.rs | 254 ++++++++++++------ 3 files changed, 177 insertions(+), 95 deletions(-) diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index a34b1d0f..f07aeed3 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -182,15 +182,13 @@ impl ClientState { /// An untouched editor opened on a cell not projected yet takes its seed /// once the host projects that cell, and never over typing (improvise-7mro). fn settle_awaited_seed(&mut self) { - let (row, col) = self.view.cursor().selected; let waiting = self.view.mode.is_editing() && self .view .opened_editor .as_ref() .is_some_and(|opened| opened.awaiting && !opened.touched); - if waiting && !crate::resolve::unprojected(self, row, col) { - let seed = crate::resolve::edit_seed(self).unwrap_or_default(); + if let (true, Some(seed)) = (waiting, crate::resolve::projected_seed(self)) { self.view.buffers.insert("edit".to_string(), seed.clone()); self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); } diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index b6c6eab9..b6719eca 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1041,6 +1041,13 @@ pub(crate) fn edit_seed(state: &ClientState) -> Option { cell_at_cursor(state)?.edit_text.clone() } +/// What an editor awaiting its seed takes once the host projects the cell under +/// the cursor: `None` until then, a relabel's blank included (improvise-7mro). +pub(crate) fn projected_seed(state: &ClientState) -> Option { + let cell = cell_at_cursor(state).filter(|cell| cell.id.is_some())?; + Some(cell.edit_text.clone().unwrap_or_default()) +} + /// The edit text the host projected at an absolute grid position. fn seed_at(state: &ClientState, row: usize, col: usize) -> String { projected_at(state, row, col) @@ -1051,10 +1058,9 @@ fn seed_at(state: &ClientState, row: usize, col: usize) -> String { /// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i` /// would decline that cell. The host asks `uneditable`; this asks the same id. fn reopened_at(state: &ClientState, row: usize, col: usize, mode: AppMode) -> Vec { - match (declined_at(state, row, col), unprojected(state, row, col)) { - (Some(refusal), _) => vec![Command::SetStatus(refusal)], - (None, true) => awaiting_seed(mode), - (None, false) => opened_on(seed_at(state, row, col), mode), + match declined_at(state, row, col) { + Some(refusal) => vec![Command::SetStatus(refusal)], + None => opened_on(seed_at(state, row, col), mode), } } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 08badb7f..9e410cb3 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2672,12 +2672,27 @@ type Pipelined = ( &'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))] { + 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()), @@ -2691,8 +2706,8 @@ fn written_cells(app: &App) -> Vec { 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. +/// 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( host: &mut improvise_command::ui::host::Host, session: improvise_command::ui::project::SessionId, @@ -2708,58 +2723,93 @@ fn sent( } } -/// improvise-7mro: the offset-aliasing matrix, replies delivered and replies -/// held. See `context/test-evidence/clients.md`. +/// 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"]), + ( + "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, (at, row_offset, col_offset), keys) in cases { + for (name, window, start, keys) in cases { let mut tui = wrapping_host(); - tui.cursor_mut().selected = at; + 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 held in [false, true] { - let what = format!("{name} ({keys:?}, replies held: {held})"); - let mut host = improvise_command::ui::host::Host::new(wrapping_host()); - let (session, welcome) = host.connect(window.0, window.1); - let mut client = ClientState { - window, - ..ClientState::default() + 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), }; - 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, - ); - let mut replies = Vec::new(); - for &k in keys { - let reply = sent(&mut host, session, &mut client, k); - match held { - true => replies.extend(reply), - false => deliver(reply, session, &mut client), + 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(&mut host, session, &mut client, k)); + if i >= typed_from { + deliver(std::mem::take(&mut held), session, &mut client); } } - if held { + if replies == Replies::Held { assert_eq!( written_cells(host.app()), Vec::::new(), @@ -2772,7 +2822,7 @@ fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { client.view.mode, client.view.status_msg ); - deliver(replies, session, &mut client); + deliver(std::mem::take(&mut held), session, &mut client); let retry = keys.last().expect("a key"); let reply = sent(&mut host, session, &mut client, retry); deliver(reply, session, &mut client); @@ -2782,8 +2832,13 @@ fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { written_cells(&tui), "{what}: the browser wrote a different cell from the TUI" ); - if !held { + 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, @@ -2796,58 +2851,81 @@ fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { "{what}: the mode; browser status {:?}", client.view.status_msg ); - assert_eq!( - client.view.buffers.get("edit"), - tui.view_state.buffers.get("edit"), - "{what}: the editor's buffer" - ); } } } -/// improvise-7mro: a cell inside the held window whose offsets the client has -/// moved is read at its own position. See `context/test-evidence/clients.md`. +/// 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() { - let keys = ["k", "k", "j", "i"]; - let mut tui = wrapping_host(); - tui.cursor_mut().selected = (2, 0); - for k in keys { - reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap()); + 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(&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 + ); + assert_eq!( + client.view.buffers.get("edit"), + tui.view_state.buffers.get("edit") + ); + assert_eq!( + client.view.opened_editor, tui.view_state.opened_editor, + "{keys:?}: the editor opened on another cell's seed" + ); } - assert_eq!(tui.view_state.cursor().selected, (1, 0), "setup: on Profit/Jan"); - - 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 keys { - let _held = sent(&mut host, session, &mut client, k); - } - let cursor = client.view.cursor(); - assert_eq!( - (cursor.selected, cursor.row_offset, client.grid.row_frame.first), - ((1, 0), 0, 1), - "setup: inside the held window, offsets ahead of it" - ); - assert_eq!( - format!("{:?}", client.view.mode), - format!("{:?}", tui.view_state.mode), - "the client read another row's cell: {:?}", - client.view.buffers.get("edit") - ); - assert_eq!(client.view.status_msg, tui.view_state.status_msg); }