From e6d7d13e901c27cff13524f01ba75a39ef0c913a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 21:40:17 -0700 Subject: [PATCH] The advertised-key guard judges by what the fold moved, off every edge (6562) heard() compared command types, so a clamped move counted as acting. It now folds the outcome and compares the view slice (status and disarm aside); a Model command counts as sent. The fixture is a 3x3 grid with the cursor at (1,1), three tiles with the cursor at 1, help page 1. Direction is out of reach for any did-it-move check, so every grid key is also pressed from the centre and must land where its binding says. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/resolve.rs | 116 ++++++++++++++++++++--- 1 file changed, 101 insertions(+), 15 deletions(-) diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index ed774f2f..8ee97edf 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -2039,6 +2039,35 @@ mod tests { // ── what is bound and what is not ─────────────────────────────── + /// A three-by-three grid with `id` at (1,1) and the cursor on it: no move + /// from there is clamped by an edge (improvise-6562). + fn centred_on(id: CellId) -> ClientState { + let mut centre = CellDisplay::number("1200"); + centre.id = Some(id); + let mut cells: Vec<_> = (0..3) + .flat_map(|r| (0..3).map(move |c| ((r, c), CellDisplay::number("7")))) + .collect(); + cells[4].1 = centre; + let mut client = ClientState::default(); + client.bootstrap(Snapshot { + view: ViewState::default(), + seed: vec![ + Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ + "North", "South", "East", + ]))), + Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([ + "Revenue", "Cost", "Profit", + ]))), + Projection::Grid(GridUpdate::Cells(cells)), + Projection::Grid(GridUpdate::Extent(GridExtent { rows: 3, cols: 3 })), + Projection::Basis(published()), + ], + }); + client.window = (3, 3); + client.view.cursor_mut().selected = (1, 1); + client + } + /// A client whose every widget has something in it, so an `Upstream` names /// an **unhandled command** and never a state that said no (improvise-rmvx). fn furnished(mode: AppMode) -> ClientState { @@ -2048,7 +2077,7 @@ mod tests { /// `furnished`, with `cell` under the cursor. A history in both directions /// and a yanked value, so no `Cmd` is refused by an empty stack (3t5x). fn furnished_on(mode: AppMode, cell: CellId) -> ClientState { - let mut state = client_on(cell); + let mut state = centred_on(cell); let frame = |name: &str| crate::ViewFrame { view_name: name.into(), mode: AppMode::Normal, @@ -2062,8 +2091,8 @@ mod tests { state.view.mode = mode; state.grid.page_coords = vec![crate::PageAxis { cat: "Quarter".into(), - selected: "Q1".into(), - items: Some(vec!["Q1".into(), "Q2".into()]), + selected: "Q2".into(), + items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]), }]; // THREE of everything, every panel cursor seated in the MIDDLE: an edge // makes `move-panel-cursor` answer `Ignored` for the wrong reason. @@ -2095,10 +2124,15 @@ mod tests { axes: Vec::new(), }) .collect(); - state.tiles.tiles = vec![crate::TileEntry { - category: "Region".into(), - axis: Axis::Row, - }]; + state.tiles.tiles = ["Region", "Product", "Quarter"] + .into_iter() + .map(|category| crate::TileEntry { + category: category.into(), + axis: Axis::Row, + }) + .collect(); + state.view.tile_cat_idx = 1; + state.view.help_page = 1; state.view.cat_panel_cursor = 1; state.view.formula_cursor = 1; state.view.view_panel_cursor = 1; @@ -2124,19 +2158,24 @@ mod tests { Silent, } - /// Judged by what moved, never by the variant: a chord's second key is - /// `Local` even when all it does is disarm the prefix. - fn heard(outcome: KeyOutcome) -> Heard { + /// Judged by what the fold MOVED, never by a command's type: a clamped move + /// or a lone disarm changes nothing (improvise-6562). `Model` is sent. + fn heard(state: &mut ClientState, outcome: KeyOutcome) -> Heard { let commands = match outcome { KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent, KeyOutcome::Request(_) => return Heard::Acts, KeyOutcome::Local(commands) => commands, }; + let quiet = |view: &ViewState| ViewState { + status_msg: String::new(), + transient_prefix: None, + ..view.clone() + }; + let before = quiet(&state.view); + commands.iter().for_each(|c| state.fold(c)); + let sent = commands.iter().any(|c| matches!(c, Command::Model { .. })); let said = commands.iter().any(|c| matches!(c, Command::SetStatus(_))); - let did = commands - .iter() - .any(|c| !matches!(c, Command::SetStatus(_) | Command::SetTransientPrefix(None))); - match (did, said) { + match (sent || quiet(&state.view) != before, said) { (true, _) => Heard::Acts, (false, true) => Heard::Declines, (false, false) => Heard::Silent, @@ -2196,7 +2235,8 @@ mod tests { commands.iter().for_each(|c| state.fold(c)); } } - heard(resolve(&state, *last)) + let outcome = resolve(&state, *last); + heard(&mut state, outcome) } /// improvise-rmvx, wk18, 3t5x. Every advertised key does what it says, or @@ -2312,6 +2352,52 @@ mod tests { ); } + /// improvise-6562: "it moved" cannot see a move the WRONG way, so every grid + /// key is pressed from the centre and must land where it says. Evidence file. + #[test] + fn every_grid_move_lands_where_its_binding_says() { + let keymap = Keymaps::shared().get(&ModeKey::Normal).expect("Normal"); + let mut moves = Vec::new(); + bound_commands(keymap, &mut moves); + // A move's destination is its own args; a jump's is its name. + let mut expected: Vec<(String, Vec, (usize, usize))> = moves + .into_iter() + .filter(|(name, _)| name == "move-selection") + .map(|(name, args)| { + let d = |i: usize| args[i].parse::().expect("a delta"); + let to = ((1 + d(0)) as usize, (1 + d(1)) as usize); + (name, args, to) + }) + .collect(); + for (name, to) in [ + ("jump-first-row", (0, 1)), + ("jump-last-row", (2, 1)), + ("jump-first-col", (1, 0)), + ("jump-last-col", (1, 2)), + ] { + expected.push((name.to_string(), Vec::new(), to)); + } + let mut pressed = 0; + for (name, args, to) in expected { + let wanted = (!args.is_empty()).then_some(args.as_slice()); + for sequence in sequences_running(keymap, &name, wanted) { + let mut state = furnished(AppMode::Normal); + for key in &sequence { + if let KeyOutcome::Local(commands) = resolve(&state, *key) { + commands.iter().for_each(|c| state.fold(c)); + } + } + assert_eq!( + state.view.cursor().selected, + to, + "{sequence:?} runs `{name} {args:?}` from (1,1)" + ); + pressed += 1; + } + } + assert!(pressed >= 14, "only {pressed} keys pressed; the walk went blind"); + } + /// improvise-rmvx. `step` checks `declined` first, so a name answered /// there makes an arm in `composed` UNREACHABLE. Evidence file. #[test]