Files
improvise/crates/improvise-protocol/src/resolve.rs
T
Edward Langley 9512a2db1f The grid-move walk stands far from every edge, so it sees magnitude (5kdr)
21x21 grid, 8x8 window, start (10,10): moves land at 10+d, page-scroll at
10+page_rows(8)*d, jumps at 0/20. grid_move's page rule is page_rows(),
which the test reads rather than restating. Doubled move-selection, a
one-row scroll-rows and a page-size-blind page-scroll each fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:39:36 -07:00

3641 lines
144 KiB
Rust

//! One keypress → the `Command`s a thin client may compose for itself. Shared
//! by every front end; every decision here is in `context/code/clients.md`.
use improvise_core::view::Axis;
use crate::client::ClientState;
use crate::frame::ClientFrame;
use crate::keymap::{Binding, Keymaps, ModeKey};
use crate::viewmodel::{CatTreeEntry, axis_change_status, flatten_cat_tree};
use crate::{
AppMode, CellId, CellValue, Command, CreateKind, GridExtent, HelpPage, KeyCode, KeyEvent,
ModelCommand, Panel, RecordCell, ViewState, records_named,
};
/// The virtual category a new measure is filed under, spelled as
/// `commit.rs::add_measure` spells it.
const MEASURE_CATEGORY: &str = "_Measure";
/// What this client decided to do with a keypress.
#[derive(Debug, Clone, PartialEq)]
pub enum KeyOutcome {
/// Resolved here: the caller folds them locally *and* sends them, as one
/// frame, in order. Never empty. See `context/code/clients.md`.
Local(Vec<Command>),
/// Resolved here as a **request**: the caller sends the frame and folds
/// nothing. Why it is not a `Command`: `context/code/clients.md`.
Request(Request),
/// Bound to a command this client cannot compose. Forwarded to the host,
/// which today answers nothing — `context/code/clients.md`.
Upstream(KeyEvent),
/// Nothing to send: either nothing is bound, or what is bound resolves
/// to no change.
Ignored,
}
/// What a key may ask its **host** to do, as opposed to what it may compose.
/// Narrow on purpose — the argument is in `context/code/clients.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Request {
/// Write the document back where the host keeps it (improvise-368h).
Save,
}
impl Request {
/// The frame that carries it. The one mapping, so a resolver never names
/// a [`ClientFrame`] and cannot compose one this outcome does not mean.
pub fn frame(self) -> ClientFrame {
match self {
Request::Save => ClientFrame::Save,
}
}
}
impl KeyOutcome {
/// `Local` when there is something to say, `Ignored` when not — the one
/// constructor, so "never empty" is a property rather than a rule.
fn of(commands: Vec<Command>) -> Self {
match commands.is_empty() {
true => KeyOutcome::Ignored,
false => KeyOutcome::Local(commands),
}
}
}
/// Decide a keypress against this client's own state. Pure, and the lookup is
/// the shared one; only what happens to the **name** differs.
pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome {
// `Quit` has no keymap, on purpose: `ModeKey::from_app_mode` is exhaustive,
// so a new mode without bindings is a compile error.
let Some(mode) = ModeKey::from_app_mode(&state.view.mode, state.view.search_mode) else {
return KeyOutcome::Ignored;
};
// An armed prefix takes priority and is spent either way, mirroring
// `App::dispatch_key` (improvise-jc8) — `context/code/clients.md`.
if let Some((key, mods)) = state.view.transient_prefix {
return prefixed(state, &mode, event, key, mods);
}
// Nothing bound is not "the host might know better" — the host looks the
// key up in this same table. It is `Ignored`, and that is new.
let Some(binding) = Keymaps::shared().lookup(&mode, event.code, event.modifiers) else {
return KeyOutcome::Ignored;
};
match binding {
// Arming is a `Command`, so the second key resolves against a state
// this client really holds — improvise-hs0u, `context/code/clients.md`.
Binding::Prefix(_) => KeyOutcome::Local(vec![Command::SetTransientPrefix(Some((
event.code,
event.modifiers,
)))]),
Binding::Cmd { name, args } => match step(state, event, name, args) {
Step::Local(commands) | Step::Declined(commands) => KeyOutcome::of(commands),
Step::Request(request) => KeyOutcome::Request(request),
Step::Upstream => KeyOutcome::Upstream(event),
},
// All or nothing, each step resolved against the *unchanged* state.
// See `context/code/clients.md`.
Binding::Sequence(steps) => match sequence(state, event, steps) {
Some(commands) => KeyOutcome::of(commands),
None => KeyOutcome::Upstream(event),
},
}
}
/// The second key of a chord, resolved in the sub-keymap the first one
/// opened. Why every outcome carries the disarm: `context/code/clients.md`.
fn prefixed(
state: &ClientState,
mode: &ModeKey,
event: KeyEvent,
key: KeyCode,
mods: crate::KeyModifiers,
) -> KeyOutcome {
let binding = Keymaps::shared()
.prefix_keymap(mode, key, mods)
.and_then(|sub| sub.lookup(event.code, event.modifiers).cloned());
chord(state, event, binding.as_ref())
}
/// The second key's outcome with the disarm in front of it. Split from
/// [`prefixed`] so a test can hand it a binding — `context/code/clients.md`.
fn chord(state: &ClientState, event: KeyEvent, binding: Option<&Binding>) -> KeyOutcome {
// Unmatched is not `Ignored`: leaving the prefix armed strands every key
// after it in a sub-keymap the user has forgotten about.
let resolved = match binding {
None => None,
// Flat by `prefix_keymaps_are_flat`, so a second prefix cannot occur.
Some(Binding::Prefix(_)) => None,
Some(Binding::Cmd { name, args }) => match step(state, event, name, args) {
Step::Local(commands) | Step::Declined(commands) => Some(commands),
// DISCARDED, which is why the guard refuses both classes rather
// than counting them answered — `context/code/clients.md`.
Step::Request(_) | Step::Upstream => None,
},
Some(Binding::Sequence(steps)) => sequence(state, event, steps),
};
let mut commands = vec![Command::SetTransientPrefix(None)];
commands.extend(resolved.unwrap_or_default());
KeyOutcome::Local(commands)
}
/// What a pointer did, resolved here for the same reason a keypress is: a
/// front end paints and wires, it does not decide. `context/code/clients.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Click {
/// A row of the category panel, indexing the flattened tree.
CategoryRow(usize),
/// A row of the view panel, indexing `ViewListCache::views`.
ViewRow(usize),
/// A row of the formula panel, indexing `FormulaCache::formulas`.
FormulaRow(usize),
/// A tile, indexing `TileBarCache::tiles`.
Tile(usize),
/// A grid cell, in **viewport** coordinates as the page painted it.
Cell { row: usize, col: usize },
/// An item of a page axis, both named by index: `axis` into
/// `GridCache::page_coords`, `item` into that axis's own item list.
PageItem { axis: usize, item: usize },
}
/// One rule for all the widgets: **go there, then do what `Enter` does
/// there.** Why that rather than one answer each: `context/code/clients.md`.
pub fn click(state: &ClientState, target: Click) -> Vec<Command> {
// `None` here means the whole click composed nothing, which is the
// invariant rather than a breach of it (improvise-hkr8) — clients.md.
let resolved = match target {
Click::CategoryRow(index) => click_category_row(state, index),
Click::ViewRow(index) => click_view_row(state, index),
Click::FormulaRow(index) => click_formula_row(state, index),
Click::Tile(index) => click_tile(state, index),
Click::Cell { row, col } => click_cell(state, row, col),
Click::PageItem { axis, item } => click_page_item(state, axis, item),
};
resolved.unwrap_or_default()
}
/// Focus the panel, move its cursor, then switch — what `V` then `Enter` do,
/// in one gesture. `switch-view-at-cursor` is the activation.
fn click_view_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
let mut commands = focus(state, Panel::View);
commands.push(Command::SetPanelCursor {
panel: Panel::View,
cursor: index,
});
// Off `index`, never the cursor: `SetPanelCursor` is not folded yet.
commands.extend(switch_view_at(state, index)?);
Some(commands)
}
/// The formula panel binds **no** `Enter`, so the rule yields navigation and
/// nothing else. That is the rule applying, not an omission — clients.md.
fn click_formula_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
state.formulas.formulas.get(index)?;
let mut commands = focus(state, Panel::Formula);
commands.push(Command::SetPanelCursor {
panel: Panel::Formula,
cursor: index,
});
Some(commands)
}
/// Focus the panel, move its cursor, then activate — the three things the
/// keyboard needs three keystrokes for, and `filter-to-item` is the activation.
fn click_category_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
// A click past the last row is a click on the panel's empty space, which
// is not a row and must not move the cursor onto one.
cat_tree_entry_at(state, index)?;
let mut commands = focus(state, Panel::Category);
commands.push(Command::SetPanelCursor {
panel: Panel::Category,
cursor: index,
});
// Against `index`, never the cursor: the `SetPanelCursor` above is not
// folded yet, so the cursor still reads the old row. Evidence file.
commands.extend(filter_to_item_at(state, index)?);
Some(commands)
}
/// Select the tile; a **second** click on the already-selected one cycles its
/// axis. Why a click never mutates on its own: `context/code/clients.md`.
fn click_tile(state: &ClientState, index: usize) -> Option<Vec<Command>> {
state.tiles.tiles.get(index)?;
// Mode *and* index, because `tile_cat_idx` is 0 before anything selects
// anything — index alone would cycle tile 0 on its first click.
let already_selected =
state.view.mode == AppMode::TileSelect && state.view.tile_cat_idx == index;
match already_selected {
true => tile_axis_at(state, index, None),
false => {
let mut commands = vec![Command::SetTileCatIdx(index)];
if state.view.mode != AppMode::TileSelect {
commands.push(Command::ChangeMode(AppMode::TileSelect));
}
Some(commands)
}
}
}
/// Move the selection to a clicked cell. Activation is the page's inline
/// editor, which is the pointer's `i` — `context/code/clients.md`.
fn click_cell(state: &ClientState, row: usize, col: usize) -> Option<Vec<Command>> {
let cursor = state.view.cursor();
let GridExtent { rows, cols } = state.grid.extent;
// The page paints a window; `SetSelected` is absolute (gotcha 42).
let (absolute_row, absolute_col) = (row + cursor.row_offset, col + cursor.col_offset);
if absolute_row >= rows || absolute_col >= cols {
return None;
}
Some(scrolled_to(state, absolute_row, absolute_col))
}
/// Open a panel and focus it, emitting only what is not already true — so a
/// click inside the focused panel is not a mode change every time.
fn focus(state: &ClientState, panel: Panel) -> Vec<Command> {
let mut commands = Vec::new();
if !state.view.panel_open(panel) {
commands.push(Command::SetPanelOpen { panel, open: true });
}
if state.view.mode.panel() != Some(panel) {
commands.push(Command::ChangeMode(panel.mode()));
}
commands
}
/// Every step of a `Binding::Sequence`, or none of them. A declining step takes
/// the whole sequence with it — `context/code/clients.md` (gotcha 21).
fn sequence(
state: &ClientState,
event: KeyEvent,
steps: &[(&'static str, Vec<String>)],
) -> Option<Vec<Command>> {
let mut commands = Vec::new();
for (name, args) in steps {
match step(state, event, name, args) {
Step::Local(more) => commands.extend(more),
Step::Declined(said) => return Some(said),
// A request is a frame of its own and a sequence is one frame, so
// there is nowhere to put it. No binding does — clients.md.
Step::Request(_) | Step::Upstream => return None,
}
}
Some(commands)
}
/// What one command name resolved to. `Declined` is composed here like
/// `Local`, and differs only in what it does to a sequence.
enum Step {
Local(Vec<Command>),
Declined(Vec<Command>),
Request(Request),
Upstream,
}
/// One name, answered. Each list is checked before the next, so no name is in
/// two of them and [`composed`] has no arm for any earlier one.
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step {
match requested(name) {
Some(request) => Step::Request(request),
None => match declined(name, args) {
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
None => match composed(state, event, name, args) {
Some(commands) => Step::Local(commands),
None => Step::Upstream,
},
},
}
}
/// The command names this client answers by **asking its host**, rather than
/// by composing anything. See `context/code/clients.md` (improvise-jz6w).
fn requested(name: &str) -> Option<Request> {
match name {
"save" => Some(Request::Save),
_ => None,
}
}
/// The commands this client cannot compose and answers **out loud**; the one
/// home for the wording. Why silence was worse: clients.md (improvise-rmvx).
fn declined(name: &str, args: &[String]) -> Option<String> {
let what = match name {
"drill-into-cell" => "drill",
"toggle-prune-empty" => "hiding empty rows and columns",
"toggle-records-mode" => "records mode",
"add-record-row" => "adding a record row",
"delete-formula-at-cursor" => "deleting a formula",
// Reachable only behind a prefix, and a chord must be answered or it
// strands its own disarm — improvise-hs0u, `context/code/clients.md`.
"toggle-col-group-under-cursor" => "collapsing a column group",
"wq" => "saving and quitting",
// A door this client will not open still has to say so: entering is
// resolvable and the commit behind it is not.
"enter-mode" => stranding(&AppMode::from_name(args.first()?)?)?,
_ => return None,
};
Some(not_yet(what))
}
/// The one home for the decline sentence. A conditional arm of [`composed`]
/// reaches it too, which is why it is not inlined — clients.md.
fn not_yet(what: &str) -> String {
format!("{what} is not available in the browser yet")
}
/// The subset of command names this client can turn into `Command`s; `None`
/// means "the host's". Each arm mirrors its `Cmd` line for line — clients.md.
fn composed(
state: &ClientState,
event: KeyEvent,
name: &str,
args: &[String],
) -> Option<Vec<Command>> {
match name {
// ── the grid cursor ──────────────────────────────────────────
"move-selection" => grid_move(state, GridMove::Relative(num(args, 0)?, num(args, 1)?)),
"scroll-rows" => grid_move(state, GridMove::Relative(num(args, 0)?, 0)),
"jump-first-row" => grid_move(state, GridMove::ToStart(true)),
"jump-first-col" => grid_move(state, GridMove::ToStart(false)),
"jump-last-row" => grid_move(state, GridMove::ToEnd(true)),
"jump-last-col" => grid_move(state, GridMove::ToEnd(false)),
"page-scroll" => grid_move(state, GridMove::Page(num(args, 0)?)),
// ── the other two cursors ────────────────────────────────────
"move-panel-cursor" => panel_cursor(state, args.first()?, num(args, 1)?),
"move-tile-cursor" => tile_cursor(state, num(args, 0)?),
// ── minibuffer text entry ────────────────────────────────────
"append-char" => {
let KeyCode::Char(c) = event.code else {
return Some(Vec::new());
};
let mut value = read_buffer(&state.view, args.first()?);
value.push(c);
Some(vec![write_buffer(args.first()?, value)])
}
"pop-char" => {
let mut value = read_buffer(&state.view, args.first()?);
value.pop();
Some(vec![write_buffer(args.first()?, value)])
}
"clear-buffer" => Some(vec![write_buffer(args.first()?, String::new())]),
// ── mode entry ───────────────────────────────────────────────
"enter-mode" => {
let mode = AppMode::from_name(args.first()?)?;
// `declined` answers this first, and both halves hold the rule:
// no path opens a room this client cannot commit.
stranding(&mode).is_none().then(|| entered(mode))
}
// ── panels ───────────────────────────────────────────────────
"cycle-panel-focus" => Some(cycle_panel_focus(&state.view)),
"toggle-panel-visibility" => {
let panel = named_panel(args.first()?)?;
Some(vec![Command::SetPanelOpen {
panel,
open: !state.view.panel_open(panel),
}])
}
// The interactive arm of `TogglePanelAndFocus`: open-and-focus unless
// this panel is already both. See `context/code/clients.md`.
"toggle-panel-and-focus" => {
let panel = named_panel(args.first()?)?;
let closing = state.view.panel_open(panel) && state.view.mode.panel() == Some(panel);
Some(vec![
Command::SetPanelOpen {
panel,
open: !closing,
},
Command::ChangeMode(match closing {
true => AppMode::Normal,
false => panel.mode(),
}),
])
}
// `EnterTileSelect`'s gate is invariantly true, so there is nothing
// here to mirror it against. See `context/code/clients.md`.
"enter-tile-select" => Some(vec![
Command::SetTileCatIdx(0),
Command::ChangeMode(AppMode::TileSelect),
]),
// The view's shape. Neither the dirty flag nor the cursor reset is
// this client's to send; both are the host's — clients.md.
"transpose" => Some(vec![Command::Model {
basis: state.basis.claim([])?,
cmd: ModelCommand::TransposeAxes {
view: state.view.active_view.clone(),
},
}]),
// ── editing ──────────────────────────────────────────────────
"edit-or-drill" => edit_or_drill(state, AppMode::from_name(args.first()?)?),
"commit-cell-edit" => commit(state, AppMode::from_name(args.first()?)?, Advance::Down),
"commit-and-advance-right" => {
commit(state, AppMode::from_name(args.first()?)?, Advance::Right)
}
// ── the category panel ───────────────────────────────────────
"cycle-axis-at-cursor" => Some(match cat_at_cursor(state) {
Some(cat) => {
let cycled = cat_axis(state, &cat).map(Axis::next);
let mut commands = vec![model(
state,
ModelCommand::CycleAxis {
view: state.view.active_view.clone(),
category: cat.clone(),
},
)?];
commands
.extend(cycled.map(|axis| Command::SetStatus(axis_change_status(&cat, axis))));
commands
}
None => vec![Command::SetStatus(
"Move cursor to a category header to change axis".to_string(),
)],
}),
"open-item-add-at-cursor" => Some(match cat_at_cursor(state) {
Some(cat) => vec![Command::ChangeMode(AppMode::item_add(cat))],
None => vec![Command::SetStatus(
"No category selected. Press n to add a category first.".to_string(),
)],
}),
// Both arms answered HERE, the unresolvable one out loud: the
// host-needed list is a property of the name — clients.md, gotcha 47.
"search-or-category-add" => Some(match state.view.search_query.is_empty() {
true => vec![
Command::SetPanelOpen {
panel: Panel::Category,
open: true,
},
Command::ChangeMode(AppMode::category_add()),
],
false => vec![Command::SetStatus(not_yet(
"stepping back through search matches",
))],
}),
"toggle-cat-expand" => Some(toggle_cat_expand(state)),
"filter-to-item" => filter_to_item(state),
"delete-category-at-cursor" => delete_at_cat_cursor(state),
// ── the view panel ───────────────────────────────────────────
"switch-view-at-cursor" => {
Some(switch_view_at(state, state.view.view_panel_cursor).unwrap_or_default())
}
"create-and-switch-view" => {
let name = improvise_core::view::unused_view_name(
state.views.views.iter().map(|v| v.name.as_str()),
);
Some(vec![
model(state, ModelCommand::CreateView(name.clone()))?,
Command::SwitchView(name),
Command::ChangeMode(AppMode::Normal),
])
}
"delete-view-at-cursor" => delete_view_at_cursor(state),
// ── view history ─────────────────────────────────────────────
// Asymmetric in the `Cmd` and mirrored as such; why the drill commit
// is not reconstructed here: `context/code/clients.md`.
"view-back" => Some(step_history(
&state.view.view_back_stack,
Command::ViewBack,
"No previous view",
)),
"view-forward" => Some(step_history(
&state.view.view_forward_stack,
Command::ViewForward,
"No forward view",
)),
// ── tile select ──────────────────────────────────────────────
"cycle-axis-for-tile" => tile_axis(state, None),
"set-axis-for-tile" => tile_axis(state, Some(parse_axis(args.first()?)?)),
// ── the help overlay ─────────────────────────────────────────
"page-next" => page_cycle(state, Odometer::Next),
"page-prev" => page_cycle(state, Odometer::Prev),
"help-page-next" => Some(vec![Command::HelpPage(HelpPage::Next)]),
"help-page-prev" => Some(vec![Command::HelpPage(HelpPage::Prev)]),
// ── minibuffer commits ───────────────────────────────────────
"commit-buffer" => commit_buffer(state, CommitKind::of(args.first()?, &state.view.mode)?),
// ── cell operations ──────────────────────────────────────────
"clear-cell" => Some(vec![model(
state,
ModelCommand::ClearCell(record_cell_at_cursor(state)?),
)?]),
// `YankCell::execute`: the value, then the status. The projected
// value, never text re-parsed: improvise-dsxq.
"yank" => Some(vec![
Command::SetYanked(cell_at_cursor(state)?.value.clone()),
Command::SetStatus("Yanked".to_string()),
]),
"paste" => Some(match state.view.yanked.clone() {
Some(value) => vec![model(
state,
ModelCommand::SetCell {
cell: record_cell_at_cursor(state)?,
value,
},
)?],
None => Vec::new(),
}),
_ => None,
}
}
/// The panel a `<panel>` argument names — `parse_panel`'s spellings, which
/// `Panel::name` is required to match (gotcha 12).
fn named_panel(name: &str) -> Option<Panel> {
Panel::ALL.into_iter().find(|p| p.name() == name)
}
/// One numeric argument, as the registry's interactive parsers read it.
fn num(args: &[String], i: usize) -> Option<i32> {
args.get(i)?.parse().ok()
}
// The named buffers. A runtime-named buffer may not be an entry in `buffers`
// at all: "search" is `ViewState::search_query` (improvise-gdl).
pub fn read_buffer(view: &ViewState, name: &str) -> String {
match name {
"search" => view.search_query.clone(),
other => view.buffers.get(other).cloned().unwrap_or_default(),
}
}
fn write_buffer(name: &str, value: String) -> Command {
match name {
"search" => Command::SetSearchQuery(value),
other => Command::SetBuffer {
name: other.to_string(),
value,
},
}
}
/// A prompt this client can *enter* but cannot *commit*; `Some` is the subject
/// to decline by name. A dead room is worse than a locked door: clients.md.
fn stranding(mode: &AppMode) -> Option<&'static str> {
match mode {
AppMode::SavePrompt { .. } => Some("saving to a path"),
AppMode::ExportPrompt { .. } => Some("exporting"),
AppMode::CommandMode { .. } => Some("the command line"),
_ => None,
}
}
/// `EnterMode::execute`: clear the incoming mode's minibuffer, then change.
fn entered(mode: AppMode) -> Vec<Command> {
let mut commands = Vec::new();
if let Some(mb) = mode.minibuffer() {
commands.push(write_buffer(&mb.buffer_key, String::new()));
}
// Plain `Normal`: which member of the pair it settles to is the grid's, and
// `ClientState::fold` answers that — improvise-e5pf, `context/code/clients.md`.
commands.push(Command::ChangeMode(mode));
commands
}
// ── the grid cursor ─────────────────────────────────────────────────────
/// A mirror of `navigation.rs`'s `MoveKind`, spelled again because that crate
/// is not on a browser's dependency list. The *keys* are not spelled twice.
#[derive(Debug, Clone, Copy)]
enum GridMove {
Relative(i32, i32),
/// Jump to the start of an axis: `true` = row, `false` = column.
ToStart(bool),
ToEnd(bool),
/// A screenful, `+1` down and `-1` up.
Page(i32),
}
/// Rows one `page-scroll` step moves: three quarters of the window, at least one.
fn page_rows(window_rows: usize) -> i32 {
(window_rows as i32 * 3 / 4).max(1)
}
fn grid_move(state: &ClientState, kind: GridMove) -> Option<Vec<Command>> {
let GridExtent { rows, cols } = state.grid.extent;
let (window_rows, window_cols) = state.window;
// Two shapes with nothing to say, kept apart from "not ours": neither is
// a key the host could answer better. See `context/code/clients.md`.
if rows == 0 || cols == 0 || window_rows == 0 || window_cols == 0 {
return Some(Vec::new());
}
let cursor = state.view.cursor();
let row_max = (rows - 1) as i32;
let col_max = (cols - 1) as i32;
let (row, col) = (cursor.selected.0 as i32, cursor.selected.1 as i32);
let (nr, nc) = match kind {
GridMove::Relative(dr, dc) => (
(row + dr).clamp(0, row_max) as usize,
(col + dc).clamp(0, col_max) as usize,
),
GridMove::ToStart(true) => (0, cursor.selected.1),
GridMove::ToStart(false) => (cursor.selected.0, 0),
GridMove::ToEnd(true) => (row_max as usize, cursor.selected.1),
GridMove::ToEnd(false) => (cursor.selected.0, col_max as usize),
GridMove::Page(dir) => {
let delta = page_rows(window_rows) * dir;
((row + delta).clamp(0, row_max) as usize, cursor.selected.1)
}
};
Some(scrolled_to(state, nr, nc))
}
/// `SetSelected` plus whichever scroll offsets follow it: a mirror of
/// `navigation.rs::viewport_effects` over the window this client announced.
fn scrolled_to(state: &ClientState, nr: usize, nc: usize) -> Vec<Command> {
let cursor = state.view.cursor();
let (window_rows, window_cols) = state.window;
let mut commands = vec![Command::SetSelected { row: nr, col: nc }];
let mut row_offset = cursor.row_offset;
let mut col_offset = cursor.col_offset;
if nr < row_offset {
row_offset = nr;
}
if nr >= row_offset + window_rows {
row_offset = nr.saturating_sub(window_rows - 1);
}
if nc < col_offset {
col_offset = nc;
}
if nc >= col_offset + window_cols {
col_offset = nc.saturating_sub(window_cols - 1);
}
if row_offset != cursor.row_offset {
commands.push(Command::SetRowOffset(row_offset));
}
if col_offset != cursor.col_offset {
commands.push(Command::SetColOffset(col_offset));
}
commands
}
// ── the panel cursors ───────────────────────────────────────────────────
/// `MovePanelCursor::execute`, deliberately line for line — the three details
/// that differ from its siblings are in `context/code/clients.md`.
fn panel_cursor(state: &ClientState, panel: &str, delta: i32) -> Option<Vec<Command>> {
let view = &state.view;
let panel = named_panel(panel)?;
let (current, max) = match panel {
Panel::Formula => (view.formula_cursor, state.formulas.formulas.len()),
Panel::Category => (
view.cat_panel_cursor,
flatten_cat_tree(&state.categories, &view.expanded_cats).len(),
),
Panel::View => (view.view_panel_cursor, state.views.views.len()),
};
if max == 0 {
return Some(Vec::new());
}
let clamped = current.min(max - 1);
let new = (clamped as i32 + delta).clamp(0, (max - 1) as i32) as usize;
Some(match new == current {
true => Vec::new(),
false => vec![Command::SetPanelCursor { panel, cursor: new }],
})
}
// ── the tile cursor ─────────────────────────────────────────────────────
/// `move-tile-cursor ±1`, clamped against `TileBarCache::tiles`. Unlike the
/// panel cursor it emits even when the index does not move, as its `Cmd` does.
fn tile_cursor(state: &ClientState, delta: i32) -> Option<Vec<Command>> {
let count = state.tiles.tiles.len();
if count == 0 {
return Some(Vec::new());
}
let new = (state.view.tile_cat_idx as i32 + delta).clamp(0, (count - 1) as i32) as usize;
Some(vec![Command::SetTileCatIdx(new)])
}
// ── editing a cell ──────────────────────────────────────────────────────
/// The cell under the cursor in this viewport-indexed cache. `None` when the
/// selection is outside the window — reachable only by a projection race.
fn cell_at_cursor(state: &ClientState) -> Option<&crate::CellDisplay> {
let cursor = state.view.cursor();
let row = cursor.selected.0.checked_sub(cursor.row_offset)?;
let col = cursor.selected.1.checked_sub(cursor.col_offset)?;
state.grid.cell(row, col)
}
/// `EditOrDrill::execute`, with ADR 002 answering the branch, and drill
/// declining out loud rather than upstream. See `context/code/clients.md`.
fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
// The grid, not the mode: a records GRID is the observed fact and the mode
// follows it (improvise-bd9o, `context/code/clients.md`).
if state.grid.records {
return Some(vec![Command::SetStatus(not_yet("editing in records mode"))]);
}
let cell = cell_at_cursor(state)?;
match cell.id.as_ref()? {
// `edit_text`, never `text`: the drawn text has been through
// `format_f64`. `tests/lockstep.rs` found exactly that.
CellId::Record(_) => Some(vec![
Command::SetBuffer {
name: "edit".to_string(),
value: cell.edit_text.clone().unwrap_or_default(),
},
Command::ChangeMode(mode),
]),
CellId::Aggregate(_) => Some(vec![Command::SetStatus(format!(
"an aggregate is edited by drilling in — {}",
not_yet("drill")
))]),
CellId::Derived { target } => Some(vec![Command::SetStatus(format!(
"{target} is computed by a formula — edit the formula, not the cell"
))]),
// Both creating columns are records-only, so the guard above takes
// them; declining rather than `None` keeps that a door, not a hole.
CellId::Create(_) => Some(vec![Command::SetStatus(not_yet("creating a column"))]),
CellId::Coordinate => Some(vec![Command::SetStatus(not_yet("renaming a coordinate"))]),
}
}
/// One direction of the view history. An empty stack is answered out loud:
/// `reduce_view`'s own arm is silent there — `context/code/clients.md`.
fn step_history(stack: &[crate::ViewFrame], step: Command, empty: &str) -> Vec<Command> {
match stack.is_empty() {
true => vec![Command::SetStatus(empty.to_string())],
false => vec![step],
}
}
/// Which way the page odometer turns.
#[derive(Debug, Clone, Copy)]
enum Odometer {
Next,
Prev,
}
/// `PageNext`/`PagePrev::execute`, mirrored: every page axis moves as one
/// odometer, least significant last. See `context/code/clients.md`.
fn page_cycle(state: &ClientState, way: Odometer) -> Option<Vec<Command>> {
let axes: Vec<(&str, &[String], usize)> = state
.grid
.page_coords
.iter()
.filter_map(|axis| {
let (items, at) = axis.cycle()?;
Some((axis.cat.as_str(), items, at))
})
.collect();
if axes.is_empty() {
return None;
}
let mut indices: Vec<usize> = axes.iter().map(|(_, _, at)| *at).collect();
let mut carry = true;
for i in (0..axes.len()).rev() {
if !carry {
break;
}
let len = axes[i].1.len();
match way {
Odometer::Next if indices[i] + 1 >= len => indices[i] = 0,
Odometer::Next => {
indices[i] += 1;
carry = false;
}
Odometer::Prev if indices[i] == 0 => indices[i] = len.saturating_sub(1),
Odometer::Prev => {
indices[i] -= 1;
carry = false;
}
}
}
axes.iter()
.enumerate()
.map(|(i, (cat, items, _))| {
Some(Command::Model {
basis: state.basis.claim([])?,
cmd: ModelCommand::SetPageSelection {
view: state.view.active_view.clone(),
category: (*cat).to_string(),
item: items[indices[i]].clone(),
},
})
})
.collect()
}
/// The user picked an item off a page axis directly. `PageAxis::cycle` is
/// asked, so a Filter axis composes nothing (gotcha 27).
fn click_page_item(state: &ClientState, axis: usize, item: usize) -> Option<Vec<Command>> {
let page = state.grid.page_coords.get(axis)?;
let (items, _) = page.cycle()?;
let picked = items.get(item)?;
Some(vec![Command::Model {
basis: state.basis.claim([])?,
cmd: ModelCommand::SetPageSelection {
view: state.view.active_view.clone(),
category: page.cat.clone(),
item: picked.clone(),
},
}])
}
/// Which way `CommitAndAdvance` moves after writing.
#[derive(Debug, Clone, Copy)]
enum Advance {
Down,
Right,
}
/// `CommitAndAdvance::execute`, for the one case a thin client can compose: a
/// plain grid cell. The three-way fork and the mirror order: clients.md.
fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<Vec<Command>> {
let (window_rows, window_cols) = state.window;
if window_rows == 0 || window_cols == 0 {
return None;
}
let text = read_buffer(&state.view, "edit");
let id = cell_at_cursor(state)?.id.as_ref()?;
// Records mode needs `record_col` (gotcha 1) — except the two CREATING
// columns, which name nothing but themselves (improvise-gcvb).
if let CellId::Create(kind) = id {
return create_column(state, edit_mode, advance, kind, &text);
}
if state.grid.records {
return None;
}
let mut commands = vec![Command::Model {
basis: state.basis.claim([record_at_cursor(state)?])?,
cmd: typed_cell_command(id, &text)?,
}];
commands.push(Command::ChangeMode(AppMode::Normal));
commands.extend(advanced(state, edit_mode, advance)?);
Some(commands)
}
/// `CommitAndAdvance` over a creating column: a schema change over the typed
/// NAME. Why +cat does not advance: clients.md (improvise-gcvb).
fn create_column(
state: &ClientState,
edit_mode: AppMode,
advance: Advance,
kind: &CreateKind,
text: &str,
) -> Option<Vec<Command>> {
// `exit_mode_for`: the tidy-up goes FIRST, where an aborting
// `AddCategory` cannot reach it (gotcha 21).
let mut commands = vec![Command::ChangeMode(AppMode::Normal)];
let name = text.trim();
if !name.is_empty() {
commands.push(model(
state,
match kind {
CreateKind::Measure => ModelCommand::AddItem {
category: MEASURE_CATEGORY.to_string(),
item: name.to_string(),
},
CreateKind::Category => ModelCommand::AddCategory(name.to_string()),
},
)?);
}
let (row, col) = state.view.cursor().selected;
commands.push(Command::SetSelected { row, col });
// An inserted column renumbers every index to its right, so staying put is
// what lands on the new one (improvise-2zq, gotcha 23).
if matches!(kind, CreateKind::Category) {
return Some(commands);
}
commands.extend(advanced(state, edit_mode, advance)?);
Some(commands)
}
/// The advance and the re-entry `CommitAndAdvance` ends in. `None` where the
/// host would emit `AddRecordRow`, which this client cannot name (gotcha 1).
fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<Vec<Command>> {
let GridExtent { rows, cols } = state.grid.extent;
let row_max = rows.saturating_sub(1);
let col_max = cols.saturating_sub(1);
let (row, col) = state.view.cursor().selected;
if matches!(advance, Advance::Right) && state.grid.records && row >= row_max && col >= col_max {
return None;
}
let landed = match advance {
Advance::Down if row < row_max => Some((row + 1, col)),
Advance::Down if col < col_max => Some((0, col + 1)),
// `EnterAdvance` returns `AbortChain` at the bottom-right corner, so
// here that is simply the arm that appends nothing more.
Advance::Down => None,
// `Right` has no abort: it clamps at the rightmost column and the
// re-entry always runs.
Advance::Right => Some((row, (col + 1).min(col_max))),
};
let Some((nr, nc)) = landed else {
return Some(Vec::new());
};
let mut commands = scrolled_to(state, nr, nc);
// `EnterEditAtCursor`: seed from where we landed, then change mode. The
// new cell is a different cell, so a stale cache is correct here.
commands.push(Command::SetBuffer {
name: "edit".to_string(),
value: seed_at(state, nr, nc),
});
commands.push(Command::ChangeMode(edit_mode));
Some(commands)
}
/// The surrogate naming the record under the cursor, or `None` when the
/// cursor is not on a writable cell.
fn record_at_cursor(state: &ClientState) -> Option<crate::RecordHash> {
match cell_at_cursor(state)?.id.as_ref()? {
CellId::Record(cell) => Some(cell.record),
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => {
None
}
}
}
/// What an editor opened on the cursor starts with, or `None` with no cell
/// there to seed from. Read by the fold, never a renderer (improvise-ktlk).
pub(crate) fn edit_seed(state: &ClientState) -> Option<String> {
cell_at_cursor(state)?.edit_text.clone()
}
/// The display text at an absolute grid position, as the cache holds it.
fn seed_at(state: &ClientState, row: usize, col: usize) -> String {
let cursor = state.view.cursor();
let Some(r) = row.checked_sub(cursor.row_offset) else {
return String::new();
};
let Some(c) = col.checked_sub(cursor.col_offset) else {
return String::new();
};
state
.grid
.cell(r, c)
.and_then(|cell| cell.edit_text.clone())
.unwrap_or_default()
}
/// How a typed string becomes a [`CellValue`]: the **one** home, called by the
/// browser's compose path and by the host's own commit (improvise-j7v6).
pub fn typed(text: &str) -> Option<CellValue> {
match text.is_empty() {
true => None,
false => Some(match text.parse::<f64>() {
Ok(n) => CellValue::Number(n),
Err(_) => CellValue::Text(text.to_string()),
}),
}
}
/// The `ModelCommand` for "the user typed `text` into this cell": the one
/// composition, shared by the mouse path and the keyboard path.
pub fn typed_cell_command(id: &CellId, text: &str) -> Option<ModelCommand> {
let CellId::Record(cell) = id else {
return None;
};
let cell: RecordCell = cell.clone();
Some(match typed(text) {
Some(value) => ModelCommand::SetCell { cell, value },
None => ModelCommand::ClearCell(cell),
})
}
// ── the second tranche (improvise-e24v) ─────────────────────────────────
/// A document mutation under the basis it claims. The records claimed are
/// `records_named`'s — the same function the server checks against (§2).
fn model(state: &ClientState, cmd: ModelCommand) -> Option<Command> {
Some(Command::Model {
basis: state.basis.claim(records_named(&cmd))?,
cmd,
})
}
/// `cell.rs`'s `CellKey` argument, as much of it as ADR 002 lets a thin client
/// name. An aggregate is not a cell this client may write — clients.md.
fn record_cell_at_cursor(state: &ClientState) -> Option<RecordCell> {
match cell_at_cursor(state)?.id.as_ref()? {
CellId::Record(cell) => Some(cell.clone()),
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => {
None
}
}
}
/// `CyclePanelFocus::execute`, line for line. `start` is the panel *after* the
/// focused one, or the first when the caller is not in a panel.
fn cycle_panel_focus(view: &ViewState) -> Vec<Command> {
let order = [
(view.formula_panel_open, AppMode::FormulaPanel),
(view.category_panel_open, AppMode::CategoryPanel),
(view.view_panel_open, AppMode::ViewPanel),
];
let start = match view.mode {
AppMode::FormulaPanel => 1,
AppMode::CategoryPanel => 2,
AppMode::ViewPanel => 3,
_ => 0,
};
(0..order.len())
.map(|i| &order[(start + i) % order.len()])
.find(|(open, _)| *open)
.map(|(_, mode)| vec![Command::ChangeMode(mode.clone())])
.unwrap_or_default()
}
/// `CmdContext::cat_tree_entry`, over the cache rather than the `Model`. The
/// flattening is the one shared derivation (improvise-edp).
fn cat_tree_entry(state: &ClientState) -> Option<CatTreeEntry> {
cat_tree_entry_at(state, state.view.cat_panel_cursor)
}
/// The flattened entry at an arbitrary index. The keyboard asks for the
/// cursor's; a click asks for the row it landed on (improvise-7ybz).
fn cat_tree_entry_at(state: &ClientState, index: usize) -> Option<CatTreeEntry> {
flatten_cat_tree(&state.categories, &state.view.expanded_cats)
.into_iter()
.nth(index)
}
fn cat_at_cursor(state: &ClientState) -> Option<String> {
cat_tree_entry(state).map(|e| e.cat_name().to_string())
}
/// The axis the panel's own cache holds for `cat`. An item row names its
/// category, which is why this is a lookup rather than a field on the entry.
fn cat_axis(state: &ClientState, cat: &str) -> Option<Axis> {
state
.categories
.categories
.iter()
.find(|row| row.name == cat)
.map(|row| row.axis)
}
/// `ToggleCatExpand::execute`. Reached by `filter-to-item` on a header row,
/// which is the only binding that leads here.
fn toggle_cat_expand(state: &ClientState) -> Vec<Command> {
toggle_cat_expand_at(state, state.view.cat_panel_cursor)
}
fn toggle_cat_expand_at(state: &ClientState, index: usize) -> Vec<Command> {
match cat_tree_entry_at(state, index) {
Some(entry) => vec![Command::ToggleCatExpand(entry.cat_name().to_string())],
None => Vec::new(),
}
}
/// `FilterToItem::execute`: an item row filters, a header row expands, and
/// nothing at all is the third arm rather than a status.
fn filter_to_item(state: &ClientState) -> Option<Vec<Command>> {
filter_to_item_at(state, state.view.cat_panel_cursor)
}
fn filter_to_item_at(state: &ClientState, index: usize) -> Option<Vec<Command>> {
match cat_tree_entry_at(state, index) {
Some(CatTreeEntry::Item {
cat_name,
item_name,
}) => Some(vec![
model(
state,
ModelCommand::SetAxis {
view: state.view.active_view.clone(),
category: cat_name.clone(),
axis: Axis::Page,
},
)?,
model(
state,
ModelCommand::SetPageSelection {
view: state.view.active_view.clone(),
category: cat_name.clone(),
item: item_name.clone(),
},
)?,
Command::SetStatus(format!("Filter: {cat_name} = {item_name}")),
]),
Some(CatTreeEntry::Category { .. }) => Some(toggle_cat_expand_at(state, index)),
None => Some(Vec::new()),
}
}
/// `DeleteCategoryAtCursor::execute`: a header deletes the category, an item
/// row deletes the item, and an empty tree says so.
fn delete_at_cat_cursor(state: &ClientState) -> Option<Vec<Command>> {
match cat_tree_entry(state) {
Some(CatTreeEntry::Category { name, .. }) => Some(vec![
model(state, ModelCommand::RemoveCategory(name.clone()))?,
Command::SetStatus(format!("Deleted category '{name}'")),
]),
Some(CatTreeEntry::Item {
cat_name,
item_name,
}) => {
let mut commands = vec![
model(
state,
ModelCommand::RemoveItem {
category: cat_name.clone(),
item: item_name.clone(),
},
)?,
Command::SetStatus(format!("Deleted item '{item_name}' from '{cat_name}'")),
];
commands.extend(retreat_from_deleted_item(state));
Some(commands)
}
None => Some(vec![Command::SetStatus(
"No category to delete".to_string(),
)]),
}
}
/// Where the cursor may sit after an item is deleted, in the tree the deletion
/// leaves behind. improvise-uej8, `context/code/clients.md`.
fn retreat_from_deleted_item(state: &ClientState) -> Option<Command> {
let tree = flatten_cat_tree(&state.categories, &state.view.expanded_cats);
let cursor = state.view.cat_panel_cursor;
safe_landing(&tree, cursor)
.filter(|&landing| landing != cursor)
.map(|cursor| Command::SetPanelCursor {
panel: Panel::Category,
cursor,
})
}
/// The nearest row `d` deletes an ITEM from, indexed in the post-deletion
/// tree. `None` means there is none — clients.md names what that costs.
fn safe_landing(tree: &[CatTreeEntry], cursor: usize) -> Option<usize> {
let item_at = |i: usize| {
tree.get(i)
.is_some_and(|e| matches!(e, CatTreeEntry::Item { .. }))
};
let closed_up = item_at(cursor + 1).then_some(cursor);
closed_up
.or_else(|| (0..cursor).rev().find(|&j| item_at(j)))
.or_else(|| {
((cursor + 1)..tree.len())
.find(|&j| item_at(j))
.map(|j| j - 1)
})
}
/// The view the panel cursor names. `ViewListCache` is flat and in the
/// workbook's own order, which is what `ctx.workbook.views.keys()` walks.
fn view_at_cursor(state: &ClientState) -> Option<String> {
state
.views
.views
.get(state.view.view_panel_cursor)
.map(|summary| summary.name.clone())
}
/// `SwitchViewAtCursor::execute`'s whole answer, at an arbitrary index. The
/// keyboard passes its cursor and a click passes the row — clients.md.
fn switch_view_at(state: &ClientState, index: usize) -> Option<Vec<Command>> {
let summary = state.views.views.get(index)?;
Some(vec![
Command::SwitchView(summary.name.clone()),
Command::ChangeMode(AppMode::Normal),
])
}
/// `DeleteViewAtCursor::execute`, including the cursor step back that only
/// happens away from the top of the list.
fn delete_view_at_cursor(state: &ClientState) -> Option<Vec<Command>> {
let Some(name) = view_at_cursor(state) else {
return Some(Vec::new());
};
let mut commands = vec![model(state, ModelCommand::DeleteView(name))?];
if state.view.view_panel_cursor > 0 {
commands.push(Command::SetPanelCursor {
panel: Panel::View,
cursor: state.view.view_panel_cursor - 1,
});
}
Some(commands)
}
/// `TileAxisOp::execute` for both of its names: `None` cycles, `Some` sets.
/// The status is the *new* axis either way, which is why it is computed here.
fn tile_axis(state: &ClientState, axis: Option<Axis>) -> Option<Vec<Command>> {
tile_axis_at(state, state.view.tile_cat_idx, axis)
}
fn tile_axis_at(state: &ClientState, index: usize, axis: Option<Axis>) -> Option<Vec<Command>> {
let Some(tile) = state.tiles.tiles.get(index) else {
return Some(Vec::new());
};
let new_axis = axis.unwrap_or_else(|| tile.axis.next());
let cmd = match axis {
Some(axis) => ModelCommand::SetAxis {
view: state.view.active_view.clone(),
category: tile.category.clone(),
axis,
},
None => ModelCommand::CycleAxis {
view: state.view.active_view.clone(),
category: tile.category.clone(),
},
};
Some(vec![
model(state, cmd)?,
Command::SetStatus(axis_change_status(&tile.category, new_axis)),
])
}
/// `core.rs::parse_axis`'s spellings. Spelled again because that crate is not
/// on a browser's dependency list; the *keys* are not spelled twice.
fn parse_axis(s: &str) -> Option<Axis> {
match s.to_lowercase().as_str() {
"row" => Some(Axis::Row),
"column" | "col" => Some(Axis::Column),
"page" => Some(Axis::Page),
"none" => Some(Axis::None),
_ => None,
}
}
/// A mirror of `commit.rs`'s `CommitKind`, resolved against the mode exactly
/// as `parse_commit_kind` does — only `Item` needs the mode.
enum CommitKind {
Formula,
Category,
Item { category: String },
}
impl CommitKind {
fn of(arg: &str, mode: &AppMode) -> Option<Self> {
match (arg, mode) {
("formula", _) => Some(Self::Formula),
("category", _) => Some(Self::Category),
("item", AppMode::ItemAdd { category, .. }) => Some(Self::Item {
category: category.clone(),
}),
_ => None,
}
}
fn buffer(&self) -> &'static str {
match self {
Self::Formula => "formula",
Self::Category => "category",
Self::Item { .. } => "item",
}
}
fn on_empty(&self) -> AppMode {
match self {
Self::Formula => AppMode::FormulaPanel,
Self::Category | Self::Item { .. } => AppMode::CategoryPanel,
}
}
fn on_commit(&self) -> Option<AppMode> {
match self {
Self::Formula => Some(AppMode::FormulaPanel),
Self::Category | Self::Item { .. } => None,
}
}
fn status(&self, name: &str) -> String {
match self {
Self::Formula => "Formula added".to_string(),
Self::Category => format!("Added category \"{name}\""),
Self::Item { .. } => format!("Added \"{name}\""),
}
}
fn add(&self, name: &str) -> ModelCommand {
match self {
Self::Formula => ModelCommand::AddFormula {
raw: name.to_string(),
target_category: "_Measure".to_string(),
},
Self::Category => ModelCommand::AddCategory(name.to_string()),
Self::Item { category } => ModelCommand::AddItem {
category: category.clone(),
item: name.to_string(),
},
}
}
}
/// `CommitBuffer::execute`: the status is optimistic and goes **first**, so
/// the add's own failure message wins on the host (improvise-6t3).
fn commit_buffer(state: &ClientState, kind: CommitKind) -> Option<Vec<Command>> {
let raw = read_buffer(&state.view, kind.buffer());
let name = raw.trim();
match name.is_empty() {
true => Some(vec![Command::ChangeMode(kind.on_empty())]),
false => {
let mut commands = vec![
Command::SetStatus(kind.status(name)),
model(state, kind.add(name))?,
];
commands.extend(kind.on_commit().map(Command::ChangeMode));
Some(commands)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keymap::Keymap;
use crate::{AxisFrame, CellDisplay, GridUpdate, KeyModifiers, Projection, Snapshot};
fn ev(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
/// A client in `mode` with **empty caches** — no grid, no panels, no
/// tiles, and no announced window.
fn in_mode(mode: AppMode) -> ClientState {
ClientState {
view: ViewState {
mode,
..ViewState::default()
},
..ClientState::default()
}
}
/// The user's ruling: the category panel is open at launch. It is the
/// *shared* default, so both front ends open with it — clients.md.
#[test]
fn a_fresh_session_opens_with_the_category_panel() {
let fresh = ViewState::default();
assert!(
fresh.category_panel_open,
"a client that has pressed nothing can already see its categories"
);
assert!(
!fresh.formula_panel_open && !fresh.view_panel_open,
"and only that one — the ruling names the category panel alone"
);
}
// ── the pointer (improvise-7ybz) ────────────────────────────────
fn with_categories() -> ClientState {
let mut state = ClientState::default();
// Explicitly shut: these tests are about a click *opening* the panel,
// which the launch default would otherwise answer for them.
state.view.category_panel_open = false;
state.fold(&Command::Project(Projection::CategoryTree(
crate::CategoryTreeUpdate::Categories(vec![
crate::CategoryRow {
name: "Region".into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
},
crate::CategoryRow {
name: "Product".into(),
items: vec!["Shirts".into()],
axis: Axis::Column,
},
]),
)));
state
}
/// A click is three intents in one, and the third must be resolved
/// against the row clicked — see `context/test-evidence/clients.md`.
#[test]
fn a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row() {
let state = with_categories();
assert_eq!(
state.view.cat_panel_cursor, 0,
"the cursor starts elsewhere"
);
let commands = click(&state, Click::CategoryRow(1));
assert_eq!(
commands,
vec![
Command::SetPanelOpen {
panel: Panel::Category,
open: true
},
Command::ChangeMode(AppMode::CategoryPanel),
Command::SetPanelCursor {
panel: Panel::Category,
cursor: 1
},
// `Product`, the row clicked — NOT `Region`, which is where
// the cursor still is when this is resolved.
Command::ToggleCatExpand("Product".into()),
]
);
}
/// The focus half emits only what is not already true, so a click inside
/// the focused panel is not a mode change every time.
#[test]
fn a_click_in_an_already_focused_panel_only_moves_and_activates() {
let mut state = with_categories();
state.view.category_panel_open = true;
state.view.mode = AppMode::CategoryPanel;
assert_eq!(
click(&state, Click::CategoryRow(0)),
vec![
Command::SetPanelCursor {
panel: Panel::Category,
cursor: 0
},
Command::ToggleCatExpand("Region".into()),
]
);
}
/// A click on the panel's empty space is not a click on a row, and must
/// not drag the cursor onto one.
#[test]
fn a_click_past_the_last_row_resolves_to_nothing() {
assert!(click(&with_categories(), Click::CategoryRow(9)).is_empty());
assert!(click(&ClientState::default(), Click::Tile(0)).is_empty());
assert!(click(&ClientState::default(), Click::ViewRow(0)).is_empty());
assert!(click(&ClientState::default(), Click::FormulaRow(0)).is_empty());
}
/// Two tiles and a **published basis**, so a missing `Command::Model` is
/// a decision rather than a client that could not compose one.
fn with_tiles() -> ClientState {
let mut state = ClientState::default();
state.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![Projection::Basis(published())],
});
state.tiles.tiles = vec![
crate::TileEntry {
category: "Region".into(),
axis: Axis::Row,
},
crate::TileEntry {
category: "Product".into(),
axis: Axis::Column,
},
];
state
}
/// The user's ruling: a click **selects**, it never mutates. The basis is
/// published here, so nothing stops the cycle but the rule itself.
#[test]
fn a_first_tile_click_selects_without_touching_the_document() {
let state = with_tiles();
let commands = click(&state, Click::Tile(1));
assert_eq!(
commands,
vec![
Command::SetTileCatIdx(1),
Command::ChangeMode(AppMode::TileSelect),
],
"select and enter TileSelect, and stop there"
);
assert!(
!commands.iter().any(|c| matches!(c, Command::Model { .. })),
"a click must not change the document; the axis cycle needs a second one"
);
}
/// `tile_cat_idx` is 0 before anything has selected anything, so the
/// already-selected test needs the **mode** as well as the index.
#[test]
fn the_first_click_on_tile_zero_selects_it_like_any_other() {
let state = with_tiles();
assert_eq!(state.view.tile_cat_idx, 0, "the default index is the trap");
let commands = click(&state, Click::Tile(0));
assert_eq!(
commands,
vec![
Command::SetTileCatIdx(0),
Command::ChangeMode(AppMode::TileSelect),
]
);
assert!(
!commands.iter().any(|c| matches!(c, Command::Model { .. })),
"index alone would read tile 0 as already selected and cycle it"
);
}
/// Entering `TileSelect` by keyboard leaves `tile_cat_idx` at 0, so a click
/// on the leftmost tile cycles on the FIRST gesture. See clients.md.
#[test]
fn t_then_a_click_on_the_leftmost_tile_cycles_it_immediately() {
let mut state = with_tiles();
state.view.mode = AppMode::TileSelect;
assert_eq!(state.view.tile_cat_idx, 0, "what `T` leaves behind");
let commands = click(&state, Click::Tile(0));
assert!(
commands.iter().any(|c| matches!(c, Command::Model { .. })),
"the tile really is selected, and a click does what Enter does there"
);
assert!(
!click(&state, Click::Tile(1))
.iter()
.any(|c| matches!(c, Command::Model { .. })),
"its neighbour answers the identical gesture by selecting — deliberate"
);
}
/// The second click is on the **already-selected** tile, not merely the
/// second click of the session — otherwise the guarantee holds once.
#[test]
fn a_second_click_on_the_selected_tile_cycles_its_axis() {
let mut state = with_tiles();
state.view.mode = AppMode::TileSelect;
state.view.tile_cat_idx = 1;
assert_eq!(
click(&state, Click::Tile(1)),
vec![
Command::Model {
basis: state.basis.claim([]).unwrap(),
cmd: ModelCommand::CycleAxis {
view: state.view.active_view.clone(),
category: "Product".into(),
},
},
// `Column.next()` is `Page` — the *new* axis, as `Enter` says.
Command::SetStatus("Product → Page".into()),
]
);
}
/// Clicking A then B selects B. Cycling B would make "a click does not
/// mutate" true only of the first click in a session.
#[test]
fn clicking_a_different_tile_selects_it_rather_than_cycling_it() {
let mut state = with_tiles();
state.view.mode = AppMode::TileSelect;
state.view.tile_cat_idx = 0;
let commands = click(&state, Click::Tile(1));
assert_eq!(
commands,
vec![Command::SetTileCatIdx(1)],
"already in TileSelect, so only the index moves"
);
assert!(!commands.iter().any(|c| matches!(c, Command::Model { .. })));
}
/// improvise-uej8: evidence in `context/test-evidence/clients.md`.
#[test]
fn deleting_a_categorys_last_item_leaves_the_cursor_inside_that_category() {
let mut state = with_categories();
state.fold(&Command::Project(Projection::Basis(published())));
state.view.expanded_cats.insert("Region".into());
state.view.mode = AppMode::CategoryPanel;
state.view.cat_panel_cursor = 2;
assert!(
matches!(
cat_tree_entry_at(&state, 3),
Some(CatTreeEntry::Category { .. })
),
"row 3 is the NEXT category's header — the row d would land on"
);
let commands = delete_at_cat_cursor(&state).expect("an item row deletes");
assert!(
commands.contains(&Command::SetPanelCursor {
panel: Panel::Category,
cursor: 1,
}),
"the cursor stayed over a category header, so one more d removes a \
whole category: {commands:?}"
);
}
/// The other direction, which a cursor that always steps back would break:
/// holding `d` must keep clearing the category out, not walk backwards.
#[test]
fn deleting_an_item_with_a_sibling_below_it_leaves_the_cursor_alone() {
let mut state = with_categories();
state.fold(&Command::Project(Projection::Basis(published())));
state.view.expanded_cats.insert("Region".into());
state.view.cat_panel_cursor = 1;
let commands = delete_at_cat_cursor(&state).expect("an item row deletes");
assert!(
!commands
.iter()
.any(|c| matches!(c, Command::SetPanelCursor { .. })),
"South takes row 1; the cursor has nowhere to go: {commands:?}"
);
}
/// improvise-uej8, the singleton arm: evidence in `context/test-evidence/clients.md`.
#[test]
fn deleting_a_categorys_only_item_does_not_arm_the_next_d_with_remove_category() {
let mut state = with_categories();
state.fold(&Command::Project(Projection::Basis(published())));
state.view.expanded_cats.insert("Region".into());
state.view.expanded_cats.insert("Product".into());
state.view.cat_panel_cursor = 4;
assert_eq!(
cat_tree_entry_at(&state, 4),
Some(CatTreeEntry::Item {
cat_name: "Product".into(),
item_name: "Shirts".into(),
}),
"row 4 is Product's ONLY item — no sibling above it and none below"
);
assert!(
cat_tree_entry_at(&state, 5).is_none()
&& matches!(
cat_tree_entry_at(&state, 3),
Some(CatTreeEntry::Category { .. })
),
"and it is fenced by its own header above and the end below"
);
let commands = delete_at_cat_cursor(&state).expect("an item row deletes");
let landed = commands
.iter()
.find_map(|c| match c {
Command::SetPanelCursor { cursor, .. } => Some(*cursor),
_ => None,
})
.unwrap_or(state.view.cat_panel_cursor);
let mut after = with_categories();
after.fold(&Command::Project(Projection::Basis(published())));
after.categories.categories[1].items.clear();
after.view.expanded_cats.insert("Region".into());
after.view.expanded_cats.insert("Product".into());
after.view.cat_panel_cursor = landed;
assert!(
!matches!(
cat_tree_entry_at(&after, landed),
Some(CatTreeEntry::Category { .. })
),
"the cursor landed on a category header, so holding d deletes the \
category next: row {landed} is {:?}",
cat_tree_entry_at(&after, landed)
);
assert!(
!delete_at_cat_cursor(&after)
.unwrap_or_default()
.iter()
.any(|c| matches!(
c,
Command::Model {
cmd: ModelCommand::RemoveCategory(_),
..
}
)),
"the second d composed a RemoveCategory"
);
}
/// improvise-25bf: evidence in `context/test-evidence/clients.md`.
#[test]
fn cycling_an_axis_from_the_category_panel_says_so_as_the_tile_bar_does() {
let mut state = with_categories();
state.fold(&Command::Project(Projection::Basis(published())));
state.view.mode = AppMode::CategoryPanel;
state.view.cat_panel_cursor = 0;
let KeyOutcome::Local(commands) = resolve(&state, ev(KeyCode::Char(' '))) else {
panic!("Space is advertised in the CATEGORIES hint; it must answer");
};
assert!(
matches!(commands.first(), Some(Command::Model { .. })),
"the status must accompany the mutation, not replace it"
);
assert_eq!(
commands.last(),
Some(&Command::SetStatus("Region → Col".to_string())),
"the tile route says it and this one did not — one operation, two \
derivations, only one of which tells the user"
);
}
/// improvise-koh6: evidence in `context/test-evidence/clients.md`.
#[test]
fn n_in_normal_opens_the_category_prompt_rather_than_falling_through() {
let state = in_mode(AppMode::Normal);
assert!(state.view.search_query.is_empty(), "no search is running");
assert_eq!(
resolve(&state, ev(KeyCode::Char('N'))),
KeyOutcome::Local(vec![
Command::SetPanelOpen {
panel: Panel::Category,
open: true,
},
Command::ChangeMode(AppMode::category_add()),
]),
"the help overlay advertises N, and an Upstream here is a black \
hole that leaves the keyboard in a MUTATING mode"
);
}
/// The rule `enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream`
/// protects: whether the host is needed is a property of the NAME.
#[test]
fn search_or_category_add_is_answered_here_whatever_the_search_query_holds() {
for query in ["", "North"] {
let mut state = in_mode(AppMode::Normal);
state.view.search_query = query.to_string();
let outcome = resolve(&state, ev(KeyCode::Char('N')));
assert!(
matches!(outcome, KeyOutcome::Local(_)),
"query {query:?} sent N upstream: {outcome:?}"
);
}
}
/// `None` from a `click_*` means "this gesture composed nothing"; `None`
/// from `step` means "not this client's command". See clients.md.
#[test]
fn enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream() {
let state = in_mode(AppMode::ViewPanel);
assert!(state.views.views.is_empty(), "no views to switch to");
assert_eq!(
resolve(&state, ev(KeyCode::Enter)),
KeyOutcome::Ignored,
"an empty panel is a state, not a command the host must answer — \
Upstream here would make the host-needed list state-dependent"
);
}
/// improvise-hkr8: a click is all of its intents or none of them. With no
/// published basis the activation cannot be composed, so nothing is.
#[test]
fn a_click_that_cannot_compose_its_activation_composes_nothing() {
let mut state = with_categories();
state.view.expanded_cats.insert("Region".into());
assert!(
matches!(
cat_tree_entry_at(&state, 1),
Some(CatTreeEntry::Item { .. })
),
"row 1 is an item row, whose activation is a document change"
);
assert!(
state.basis.claim([]).is_none(),
"and this client has been published no basis"
);
assert!(
click(&state, Click::CategoryRow(1)).is_empty(),
"the focus and the cursor move must not survive the activation's failure"
);
}
/// The same click, once the basis has arrived: all four intents.
#[test]
fn the_same_click_composes_everything_once_a_basis_is_published() {
let mut state = with_categories();
state.view.expanded_cats.insert("Region".into());
state.fold(&Command::Project(Projection::Basis(published())));
let basis = state.basis.claim([]).unwrap();
assert_eq!(
click(&state, Click::CategoryRow(1)),
vec![
Command::SetPanelOpen {
panel: Panel::Category,
open: true
},
Command::ChangeMode(AppMode::CategoryPanel),
Command::SetPanelCursor {
panel: Panel::Category,
cursor: 1
},
Command::Model {
basis: basis.clone(),
cmd: ModelCommand::SetAxis {
view: state.view.active_view.clone(),
category: "Region".into(),
axis: Axis::Page
},
},
Command::Model {
basis,
cmd: ModelCommand::SetPageSelection {
view: state.view.active_view.clone(),
category: "Region".into(),
item: "North".into()
},
},
Command::SetStatus("Filter: Region = North".into()),
]
);
}
fn with_panels() -> ClientState {
let mut state = ClientState::default();
state.fold(&Command::Project(Projection::ViewList(vec![
crate::ViewSummary {
name: "Default".into(),
axes: vec![],
},
crate::ViewSummary {
name: "Plan".into(),
axes: vec![],
},
])));
state.fold(&Command::Project(Projection::Formulas(vec![
"Profit = Revenue - Cost".into(),
"Margin = Profit / Revenue".into(),
])));
state
}
/// improvise-iioi: the same three intents the category panel gets, with
/// `switch-view-at-cursor` as the activation.
#[test]
fn a_view_row_click_focuses_moves_and_switches_to_the_row_clicked() {
let state = with_panels();
assert_eq!(
click(&state, Click::ViewRow(1)),
vec![
Command::SetPanelOpen {
panel: Panel::View,
open: true
},
Command::ChangeMode(AppMode::ViewPanel),
Command::SetPanelCursor {
panel: Panel::View,
cursor: 1
},
// `Plan`, the row clicked — not `Default`, where the cursor is.
Command::SwitchView("Plan".into()),
Command::ChangeMode(AppMode::Normal),
]
);
}
/// The formula panel binds no `Enter`, so the rule yields navigation and
/// nothing else. A mode change here would be an invented activation.
#[test]
fn a_formula_row_click_navigates_and_activates_nothing() {
let state = with_panels();
let commands = click(&state, Click::FormulaRow(1));
assert_eq!(
commands,
vec![
Command::SetPanelOpen {
panel: Panel::Formula,
open: true
},
Command::ChangeMode(AppMode::FormulaPanel),
Command::SetPanelCursor {
panel: Panel::Formula,
cursor: 1
},
]
);
}
/// The asymmetry is a fact about the keymap, so it is asserted against
/// the keymap — not against a reading of it. See `context/code/clients.md`.
#[test]
fn the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does() {
let keymaps = Keymaps::shared();
assert!(
keymaps
.lookup(&ModeKey::FormulaPanel, KeyCode::Enter, KeyModifiers::NONE)
.is_none(),
"if FormulaPanel gains an Enter binding, click_formula_row must \
activate too — the click mirrors Enter, and this is the only \
thing tying the two together"
);
assert!(
keymaps
.lookup(&ModeKey::ViewPanel, KeyCode::Enter, KeyModifiers::NONE)
.is_some(),
"and the view panel's click activates because its Enter does"
);
}
/// The pointer and the keyboard are one cursor: a click resolves to the
/// same `SetSelected` a movement key would, offsets reconciled.
#[test]
fn a_cell_click_is_absolute_even_though_the_page_paints_a_window() {
let mut state = ClientState::default();
state.fold(&Command::Project(Projection::Grid(GridUpdate::Extent(
crate::GridExtent { rows: 40, cols: 9 },
))));
state.window = (10, 4);
state.view.cursor_mut().row_offset = 12;
state.view.cursor_mut().col_offset = 3;
assert_eq!(
click(&state, Click::Cell { row: 2, col: 1 }),
vec![Command::SetSelected { row: 14, col: 4 }],
"viewport (2,1) under offsets (12,3) is absolute (14,4)"
);
assert!(
click(&state, Click::Cell { row: 39, col: 0 }).is_empty(),
"a click below the grid names no cell"
);
}
// ── text entry ──────────────────────────────────────────────────
#[test]
fn typing_a_character_in_any_text_entry_mode_resolves_locally() {
for mode in AppMode::text_entry() {
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
let mut state = in_mode(mode.clone());
state.view.buffers.insert(buffer.clone(), "Reg".into());
assert_eq!(
resolve(&state, ev(KeyCode::Char('i'))),
KeyOutcome::Local(vec![Command::SetBuffer {
name: buffer,
value: "Regi".into(),
}]),
"{} should type locally",
crate::help::mode_label(&mode)
);
}
}
/// The old classifier sent a modified character upstream in a text-entry
/// mode; reading the same table removes the question. Evidence file.
#[test]
fn a_modified_character_now_types_because_the_fallback_is_the_shared_one() {
let mut state = in_mode(AppMode::editing());
state.view.buffers.insert("edit".into(), "R".into());
assert_eq!(
resolve(
&state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)
),
KeyOutcome::Local(vec![Command::SetBuffer {
name: "edit".into(),
value: "Rx".into(),
}])
);
}
#[test]
fn backspace_pops_locally_everywhere_except_command_mode() {
for mode in AppMode::text_entry() {
let buffer = mode.minibuffer().unwrap().buffer_key.clone();
let mut state = in_mode(mode.clone());
state.view.buffers.insert(buffer.clone(), "Regi".into());
let outcome = resolve(&state, ev(KeyCode::Backspace));
if matches!(mode, AppMode::CommandMode { .. }) {
assert_eq!(
outcome,
KeyOutcome::Upstream(ev(KeyCode::Backspace)),
"CommandMode rebinds Backspace to a command that leaves the mode"
);
} else {
assert_eq!(
outcome,
KeyOutcome::Local(vec![Command::SetBuffer {
name: buffer,
value: "Reg".into(),
}]),
"{} should pop locally",
crate::help::mode_label(&mode)
);
}
}
}
/// `Esc` is a `Binding::Sequence` — clear the buffer, then leave — and
/// this client resolves both halves. Half would be worse than none.
#[test]
fn escape_clears_the_buffer_and_leaves_the_mode() {
let mut state = in_mode(AppMode::editing());
state
.view
.buffers
.insert("edit".into(), "half typed".into());
assert_eq!(
resolve(&state, ev(KeyCode::Esc)),
KeyOutcome::Local(vec![
Command::SetBuffer {
name: "edit".into(),
value: String::new(),
},
Command::ChangeMode(AppMode::Normal),
])
);
}
/// Search typing resolves here only because the keymap is shared, and
/// "search" is not a `buffers` entry. See `context/test-evidence/clients.md`.
#[test]
fn search_typing_writes_the_query_and_not_a_buffer() {
let mut state = in_mode(AppMode::Normal);
state.view.search_mode = true;
state.view.search_query = "Nor".into();
state.grid.extent = GridExtent { rows: 9, cols: 9 };
state.window = (9, 9);
assert_eq!(
resolve(&state, ev(KeyCode::Char('t'))),
KeyOutcome::Local(vec![Command::SetSearchQuery("Nort".into())]),
"j/t in search mode types; it must not move the cursor"
);
assert_eq!(
resolve(&state, ev(KeyCode::Esc)),
KeyOutcome::Upstream(ev(KeyCode::Esc)),
"exit-search-mode owns the query and is the host's"
);
}
// ── 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 {
furnished_on(mode, record_cell())
}
/// `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 = centred_on(cell);
let frame = |name: &str| crate::ViewFrame {
view_name: name.into(),
mode: AppMode::Normal,
};
state.view.view_back_stack = vec![frame("Second")];
state.view.view_forward_stack = vec![frame("Third")];
state.view.yanked = Some(CellValue::Number(5.0));
// A records MODE over a non-records GRID is a state no host can
// produce, so the fixture projects both (improvise-e5pf).
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
state.view.mode = mode;
state.grid.page_coords = vec![crate::PageAxis {
cat: "Quarter".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.
crate::WidgetCache::apply(
&mut state.categories,
&crate::CategoryTreeUpdate::Categories(
["Region", "Product", "Quarter"]
.into_iter()
.map(|name| crate::CategoryRow {
name: name.into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
})
.collect(),
),
);
state.formulas.formulas = [
"Profit = Revenue - Cost",
"Tax = Revenue * 0.1",
"Net = Profit",
]
.into_iter()
.map(str::to_string)
.collect();
state.views.views = ["Default", "Second", "Third"]
.into_iter()
.map(|name| crate::ViewSummary {
name: name.into(),
axes: Vec::new(),
})
.collect();
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;
state
.view
.buffers
.insert("formula".into(), "Profit = Revenue".into());
state
.view
.buffers
.insert("category".into(), "Product".into());
state.view.buffers.insert("item".into(), "Shirts".into());
state.view.buffers.insert("edit".into(), "42".into());
state
}
/// What a user sees an advertised key do. A status alone is `Declines`:
/// honest, but not what the caption promised (improvise-3t5x).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Heard {
Acts,
Declines,
Silent,
}
/// 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(_)));
match (sent || quiet(&state.view) != before, said) {
(true, _) => Heard::Acts,
(false, true) => Heard::Declines,
(false, false) => Heard::Silent,
}
}
/// Every `(mode, search, command, args)` either table advertises: the
/// status-bar hint and the help pages (improvise-wk18).
fn advertised_everywhere() -> Vec<(AppMode, bool, &'static str, Option<Vec<String>>)> {
let mut rows = Vec::new();
for mode in AppMode::all() {
for (command, args, _) in crate::help::advertised(&mode) {
rows.push((mode.clone(), false, command, args));
}
}
for (key, command, args, _) in crate::help::help_bindings() {
let (mode, search) = match key {
ModeKey::SearchMode => (AppMode::Normal, true),
key => match AppMode::all()
.into_iter()
.find(|m| ModeKey::from_app_mode(m, false).as_ref() == Some(&key))
{
Some(mode) => (mode, false),
None => panic!("a help row names {key:?}, which no mode reaches"),
},
};
rows.push((mode, search, command, args));
}
rows.sort_by_key(|(m, s, c, a)| (crate::mode_label(m), *s, *c, a.clone()));
rows.dedup();
rows
}
/// Every key sequence that runs `command` in `keymap`: one key, or a
/// prefix and the key after it.
fn sequences_running(
keymap: &Keymap,
command: &str,
args: Option<&[String]>,
) -> Vec<Vec<KeyEvent>> {
let one = keymap
.keys_running(command, args)
.into_iter()
.map(|(code, mods)| vec![KeyEvent::new(code, mods)]);
let two = keymap
.prefixed_keys_running(command, args)
.into_iter()
.map(|pair| pair.map(|(code, mods)| KeyEvent::new(code, mods)).to_vec());
one.chain(two).collect()
}
/// What pressing `sequence` in `state` does, arming any prefix first.
fn press(mut state: ClientState, sequence: &[KeyEvent]) -> Heard {
let (last, prefix) = sequence.split_last().expect("a sequence has a key");
for key in prefix {
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
commands.iter().for_each(|c| state.fold(c));
}
}
let outcome = resolve(&state, *last);
heard(&mut state, outcome)
}
/// improvise-rmvx, wk18, 3t5x. Every advertised key does what it says, or
/// is named below saying why not. See `context/test-evidence/clients.md`.
#[test]
fn every_advertised_command_answers_in_its_own_mode() {
let aggregate = CellId::Aggregate(
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
);
let derived = CellId::Derived {
target: "Profit".into(),
};
// (mode, cell, command, heard) -> the keys that heard it.
let mut short: std::collections::BTreeMap<(String, &str, Heard), Vec<String>> =
Default::default();
for (mode, search, command, args) in advertised_everywhere() {
let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode");
let keymap = Keymaps::shared().get(&key).expect("a keymap");
let at = |cell: &CellId| {
let mut state = furnished_on(mode.clone(), cell.clone());
state.view.search_mode = search;
state
};
let label = crate::mode_label(&mode).to_string() + if search { " SEARCH" } else { "" };
for sequence in sequences_running(keymap, command, args.as_deref()) {
let keys: String = sequence
.iter()
.map(|k| crate::keymap::key_label(&k.code, k.modifiers))
.collect();
let on_record = press(at(&record_cell()), &sequence);
let mut report = vec![(label.clone(), on_record)];
// Only a grid mode stands on another kind of cell, and a cell
// matters only where it changes the answer.
if mode == AppMode::Normal {
for (on, cell) in [
(" on an aggregate", &aggregate),
(" on a derived cell", &derived),
] {
let got = press(at(cell), &sequence);
if got != on_record {
report.push((label.clone() + on, got));
}
}
}
for (where_, got) in report.into_iter().filter(|(_, got)| *got != Heard::Acts) {
short
.entry((where_, command, got))
.or_default()
.push(keys.clone());
}
}
}
let short: Vec<String> = short
.into_iter()
.map(|((at, command, got), mut keys)| {
keys.sort();
keys.dedup();
format!("{at}: {} {command} {got:?}", keys.join("/"))
})
.collect();
// Closing one has to come here and say so; adding one fails. A caption
// is a promise, and each line here is a promise a browser does not keep.
let expected: &[&str] = &[
// Reached only from a mode `declined` refuses at the door, so a
// browser is never standing in front of these.
"COMMAND: Enter execute-command Silent",
"EXPORT: Enter commit-export Silent",
"SAVE AS: Enter commit-save Silent",
"NORMAL SEARCH: Enter/Esc exit-search-mode Silent",
// Commands the browser has no arm for: upstream, so a black hole
// (improvise-uln5). Each is also in the host-only list below.
"NORMAL: Enter enter-advance Silent",
"NORMAL: H hide-selected-row-item Silent",
"NORMAL: / search Silent",
"NORMAL: n search-navigate Silent",
"NORMAL: z toggle-group-under-cursor Silent",
// Honestly unavailable, and saying so in `declined`'s words.
"FORMULAS: Del/d delete-formula-at-cursor Declines",
"NORMAL: > drill-into-cell Declines",
"NORMAL: : enter-mode Declines",
"NORMAL: P toggle-prune-empty Declines",
"NORMAL: R toggle-records-mode Declines",
"NORMAL: ZZ wq Declines",
"RECORDS: o add-record-row Declines",
"RECORDS: a/i edit-or-drill Declines",
"RECORDS: : enter-mode Declines",
"RECORDS: P toggle-prune-empty Declines",
"RECORDS: R toggle-records-mode Declines",
// `i` on an aggregate IS drill, which the browser lacks (s8h7); on a
// derived cell the browser names the formula instead (ir7l).
"NORMAL on an aggregate: a/i edit-or-drill Declines",
"NORMAL on a derived cell: a/i edit-or-drill Declines",
// No record under the cursor to write. The TUI writes onto the
// aggregate's partial key, which is its own bug (improvise-l4mp).
"NORMAL on an aggregate: p paste Silent",
"NORMAL on an aggregate: x clear-cell Silent",
"NORMAL on a derived cell: p paste Silent",
"NORMAL on a derived cell: x clear-cell Silent",
// A records cell commit needs `record_col` (gotcha 1); only the two
// creating columns resolve, and this fixture is on a record cell.
"RECORDS INSERT: Tab commit-and-advance-right Silent",
"RECORDS INSERT: Enter commit-cell-edit Silent",
];
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
expected.sort();
let mut short = short;
short.sort();
assert_eq!(
short,
expected,
"advertised keys that do not do what they say, in full:\n{}",
short.join("\n")
);
}
/// 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 bound = Vec::new();
bound_commands(keymap, &mut bound);
bound.sort();
bound.dedup();
// Far from every edge of a grid far larger than any step, so a landing
// shows magnitude as well as sign (improvise-5kdr); a jump's is its name.
const START: (usize, usize) = (10, 10);
const WINDOW: (usize, usize) = (8, 8);
let d = |a: &String| a.parse::<isize>().expect("a delta");
let at = |base: usize, by: isize| base.checked_add_signed(by).expect("inside the grid");
let page = page_rows(WINDOW.0) as isize;
let destination = |name: &str, args: &[String]| match name {
"move-selection" => Some((at(START.0, d(&args[0])), at(START.1, d(&args[1])))),
"scroll-rows" => Some((at(START.0, d(&args[0])), START.1)),
"page-scroll" => Some((at(START.0, page * d(&args[0])), START.1)),
"jump-first-row" => Some((0, START.1)),
"jump-last-row" => Some((20, START.1)),
"jump-first-col" => Some((START.0, 0)),
"jump-last-col" => Some((START.0, 20)),
_ => None,
};
let mut unstated = Vec::new();
let mut unpressed = Vec::new();
let mut pressed = std::collections::BTreeSet::new();
for (name, args) in &bound {
let to = destination(name, args);
let sequences = sequences_running(keymap, name, Some(args.as_slice()));
if to.is_some() && sequences.is_empty() {
unpressed.push(format!("{name} {args:?}"));
}
for sequence in sequences {
let mut state = furnished(AppMode::Normal);
state.grid.extent = GridExtent { rows: 21, cols: 21 };
state.window = WINDOW;
*state.view.cursor_mut() = crate::ViewCursor {
selected: START,
row_offset: 5,
col_offset: 5,
};
let view = state.view.active_view.clone();
for key in &sequence {
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
commands.iter().for_each(|c| state.fold(c));
}
}
let landed = state.view.cursor().selected;
if to.is_some() {
pressed.insert(name.as_str());
}
match to {
Some(to) => {
assert_eq!(
landed, to,
"{sequence:?} runs `{name} {args:?}` from {START:?}"
)
}
None if state.view.active_view == view && landed != START => {
unstated.push(format!("{name} {args:?} -> {landed:?}"))
}
None => {}
}
}
}
assert!(
unpressed.is_empty(),
"stated and never pressed: {unpressed:?}"
);
assert!(
unstated.is_empty(),
"these move the cursor and nothing states where to: {unstated:?}"
);
let every = [
"jump-first-col",
"jump-first-row",
"jump-last-col",
"jump-last-row",
"move-selection",
"page-scroll",
"scroll-rows",
];
assert_eq!(
pressed.into_iter().collect::<Vec<_>>(),
every,
"each kind is pressed"
);
}
/// improvise-rmvx. `step` checks `declined` first, so a name answered
/// there makes an arm in `composed` UNREACHABLE. Evidence file.
#[test]
fn nothing_declined_is_also_composed() {
let mut shadowed = Vec::new();
for mode in AppMode::all() {
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
continue;
};
let Some(keymap) = Keymaps::shared().get(&mode_key) else {
continue;
};
let state = furnished(mode.clone());
// Derived from `declined` itself rather than from a list beside
// it: a seventh decline is covered without coming here.
let mut vocabulary = Vec::new();
bound_commands(keymap, &mut vocabulary);
for (name, args) in vocabulary {
if declined(&name, &args).is_none() {
continue;
}
if composed(&state, ev(KeyCode::Char('x')), &name, &args).is_some() {
shadowed.push(format!("{}: {name} {args:?}", crate::mode_label(&mode)));
}
}
}
shadowed.sort();
shadowed.dedup();
assert!(
shadowed.is_empty(),
"these are answered by BOTH `declined` and `composed`, and `step` \
reaches `declined` first — so the browser keeps saying \"not \
available yet\" while the working code sits unreachable:\n{}",
shadowed.join("\n")
);
}
/// improvise-lltt is deferred on a PREMISE, not a judgement: no shipped
/// client emits `SwitchView`. See `context/test-evidence/clients.md`.
#[test]
fn the_keys_that_compose_a_switch_view_are_these() {
let mut emitters = Vec::new();
for mode in AppMode::all() {
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
continue;
};
let Some(keymap) = Keymaps::shared().get(&mode_key) else {
continue;
};
let state = furnished(mode.clone());
let mut vocabulary = Vec::new();
bound_commands(keymap, &mut vocabulary);
for (name, args) in vocabulary {
let Some(commands) = composed(&state, ev(KeyCode::Char('x')), &name, &args) else {
continue;
};
if commands.iter().any(|c| matches!(c, Command::SwitchView(_))) {
emitters.push(format!("{}: {name} {args:?}", crate::mode_label(&mode)));
}
}
}
emitters.sort();
emitters.dedup();
// NOT an exemption list. improvise-lltt was deferred on the premise
// that this list is EMPTY, and it is not: these two are live.
let known = [
"VIEWS: create-and-switch-view []",
"VIEWS: switch-view-at-cursor []",
];
assert_eq!(
emitters, known,
"improvise-lltt is LIVE through every name here. A client that \
switches view and then transposes moves the HOST's view, \
silently. A third name means a third live path; an empty list \
means lltt can go back to being deferred, and this test with it."
);
}
/// Every `(name, args)` a keymap, its parents and its prefixes bind —
/// the whole vocabulary a keypress in that mode can reach.
fn bound_commands(keymap: &Keymap, out: &mut Vec<(String, Vec<String>)>) {
for binding in keymap.bindings().values() {
match binding {
Binding::Cmd { name, args } => out.push((name.to_string(), args.clone())),
Binding::Sequence(steps) => out.extend(
steps
.iter()
.map(|(name, args)| (name.to_string(), args.clone())),
),
// Prefixes are flat (gotcha 13), so this terminates.
Binding::Prefix(sub) => bound_commands(sub, out),
}
}
if let Some(parent) = keymap.parent() {
bound_commands(parent, out);
}
}
/// The command **names** this client cannot execute, one by one, so that
/// closing one of these gaps has to come here and say so. Evidence file.
#[test]
fn the_command_names_that_still_need_the_host_are_these() {
let mut state = in_mode(AppMode::Normal);
state.grid.extent = GridExtent { rows: 4, cols: 4 };
state.window = (4, 4);
for (key, name) in [
// The three prefixes left with improvise-hs0u: arming is a
// `Command`, and the second key resolves in the sub-keymap.
(ev(KeyCode::Char('H')), "hide-selected-row-item"),
(ev(KeyCode::Char('z')), "toggle-group-under-cursor"),
// `>`, `R`, `P`, Ctrl-W and Ctrl-E are NOT here any more: each
// answers with a status of its own (improvise-gd28, improvise-rmvx).
// `<` left too, and further: since improvise-cfly it composes a
// real `Command::ViewBack` rather than a refusal.
(ev(KeyCode::Char('/')), "search"),
(ev(KeyCode::Char('n')), "search-navigate"),
// `N` left this list with improvise-koh6: both its arms are
// answered here, one of them out loud. See clients.md.
(ev(KeyCode::Enter), "enter-advance"),
// Ctrl-S left this list with improvise-jz6w: it is a `Request`,
// not a `Command`, and the host has answered it since 368h.
(
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL),
"force-quit",
),
] {
assert_eq!(
resolve(&state, key),
KeyOutcome::Upstream(key),
"{name} ({:?}) was resolved locally; if that is deliberate, \
this list is where it stops being a gap",
key.code
);
}
}
/// improvise-jz6w: the outcome a resolver had no way to name. See
/// `context/test-evidence/clients.md`.
#[test]
fn ctrl_s_asks_the_host_to_save_and_composes_nothing() {
let state = in_mode(AppMode::Normal);
let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
assert_eq!(
resolve(&state, key),
KeyOutcome::Request(Request::Save),
"Ctrl-S must resolve to the save REQUEST"
);
assert_eq!(
Request::Save.frame(),
crate::ClientFrame::Save,
"and the one mapping must name the frame the host already answers"
);
assert_eq!(
composed(&state, key, "save", &[]),
None,
"`save` must compose NO command: a Commands batch is all-or-\
nothing, which is why Save is a frame (gotcha 45)"
);
}
/// A request is one frame, so a sequence has nowhere to put one. The arm
/// that refuses is unreachable, and this is what keeps it so.
#[test]
fn no_binding_puts_a_request_inside_a_sequence() {
fn steps_of(keymap: &Keymap, out: &mut Vec<String>) {
for binding in keymap.bindings().values() {
match binding {
Binding::Sequence(steps) => {
out.extend(steps.iter().map(|(name, _)| name.to_string()))
}
// Prefixes are flat (gotcha 13), so this terminates.
Binding::Prefix(sub) => steps_of(sub, out),
Binding::Cmd { .. } => {}
}
}
if let Some(parent) = keymap.parent() {
steps_of(parent, out);
}
}
let mut names = Vec::new();
for (_, keymap) in Keymaps::shared().iter() {
steps_of(keymap, &mut names);
}
assert!(
!names.is_empty(),
"the walk found no sequence steps at all, so it proves nothing"
);
for name in &names {
assert_eq!(
requested(name),
None,
"`{name}` is bound inside a sequence, and a request cannot \
travel in one: see clients.md"
);
}
}
/// improvise-cfly. The two directions of the view history disagree in the
/// `Cmd`, and the client mirrors the disagreement — evidence file.
#[test]
fn the_view_history_keys_answer_in_both_directions() {
let mut state = in_mode(AppMode::Normal);
// Empty first. `stepped` is a silent no-op on an empty stack where the
// `Cmd` speaks, so sending the bare command would lose the sentence.
let KeyOutcome::Local(cmds) = resolve(&state, ev(KeyCode::Char('<'))) else {
panic!("`<` must answer locally");
};
assert_eq!(
cmds,
vec![Command::SetStatus("No previous view".to_string())],
"an empty stack is answered out loud, in the Cmd's own words"
);
state.view.view_back_stack.push(crate::ViewFrame {
view_name: "Default".to_string(),
mode: AppMode::Normal,
});
let KeyOutcome::Local(cmds) = resolve(&state, ev(KeyCode::Char('<'))) else {
panic!("`<` must answer locally");
};
assert_eq!(
cmds,
vec![Command::ViewBack],
"with history, `<` steps back and says nothing — and emits NO \
drill commit, which is the decline this replaced"
);
}
/// The forward sibling, and the key that reaches it in both front ends
/// (improvise-cfly, improvise-htl6). See the evidence file.
#[test]
fn the_forward_step_mirrors_its_cmd_and_one_key_reaches_it() {
let mut state = in_mode(AppMode::Normal);
assert_eq!(
composed(&state, ev(KeyCode::Char('<')), "view-forward", &[]),
Some(vec![Command::SetStatus("No forward view".to_string())]),
"an empty forward stack is answered out loud"
);
state.view.view_forward_stack.push(crate::ViewFrame {
view_name: "Second".to_string(),
mode: AppMode::Normal,
});
assert_eq!(
composed(&state, ev(KeyCode::Char('<')), "view-forward", &[]),
Some(vec![Command::ViewForward]),
"and a populated one steps forward"
);
let mut bound = Vec::new();
for (_, keymap) in Keymaps::shared().iter() {
bound_commands(keymap, &mut bound);
}
// improvise-htl6 inverted this: it asserted view-forward was bound by
// NOTHING, which is where the gap was meant to stop being one.
assert!(
bound.iter().any(|(name, _)| name == "view-forward"),
"view-forward has lost its key again and the arm above is \
unreachable: the TUI is back to :view-forward and the browser to \
nothing at all"
);
assert!(
bound.iter().any(|(name, _)| name == "view-back"),
"and the control: view-back is bound too, so the check above is \
not vacuous over a table that binds everything or nothing"
);
assert_eq!(
resolve(&state, ev(KeyCode::Char(','))),
KeyOutcome::Local(vec![Command::ViewForward]),
"and the key is `,` — the unshifted twin of `<`, so both halves of \
the history sit on one physical key"
);
}
/// `i` on an aggregate IS drill in the TUI, so both doors name one missing
/// feature; only `i` says why. See `context/test-evidence/clients.md`.
#[test]
fn an_aggregate_edit_names_the_same_missing_drill_as_the_drill_key() {
let aggregate = CellId::Aggregate(
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
);
let state = client_on(aggregate);
let said = |key| match resolve(&state, ev(key)) {
KeyOutcome::Local(commands) => match &commands[..] {
[Command::SetStatus(said)] => said.clone(),
other => panic!("expected one status, got {other:?}"),
},
other => panic!("{key:?} is advertised; it must answer, got {other:?}"),
};
let (drill, edit) = (said(KeyCode::Char('>')), said(KeyCode::Char('i')));
assert!(
drill.contains("drill") && drill.contains("browser"),
"the status must name drill and say it is the browser that lacks it: {drill}"
);
assert!(
!drill.contains("aggregate"),
"`>` is refused whatever the cell is, so it must not blame the cell: {drill}"
);
assert!(
edit.contains(&drill),
"`i` reached the same missing feature and must name it the same way: \
{edit:?} does not contain {drill:?}"
);
assert!(
edit.contains("aggregate"),
"and `i` must still say why it drilled rather than edited: {edit}"
);
}
/// The refusal above is only half the property: the bug guarded is
/// *entering a room with no exit*. See `context/test-evidence/clients.md`.
#[test]
fn a_prompt_this_client_cannot_commit_is_never_entered() {
let state = in_mode(AppMode::Normal);
for (key, mode, subject) in [
(
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
"save-prompt",
"saving",
),
(
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
"export-prompt",
"exporting",
),
(ev(KeyCode::Char(':')), "command-mode", "command line"),
] {
let KeyOutcome::Local(said) = resolve(&state, key) else {
panic!("{mode} must be refused *out loud*, not in silence");
};
let [Command::SetStatus(msg)] = &said[..] else {
panic!("{mode}: expected one status, got {said:?}");
};
assert!(
msg.contains(subject),
"{mode}: the refusal must name its own subject, got {msg}"
);
assert!(
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
"{mode} must not be entered by a client that cannot commit it"
);
}
// Negative arm, half one: a mode that is not refused is still entered.
// Why `?` and not `:` — `context/test-evidence/clients.md`.
assert_eq!(
resolve(&state, ev(KeyCode::Char('?'))),
KeyOutcome::Local(vec![Command::ChangeMode(AppMode::Help)]),
"a mode this client can finish must still be entered, or the \
guard above is just \"refuse every mode\""
);
// Half two: the predicate is about *finishing*, so something has to
// press Enter and observe.
let mut client = client_on(record_cell());
client.view.mode = AppMode::editing();
client.view.buffers.insert("edit".into(), "42".into());
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
panic!("Enter in Editing must resolve here — that is the arm");
};
assert!(
commands.iter().any(|c| matches!(c, Command::Model { .. })),
"and it must actually write something, not merely change mode: \
{commands:?}"
);
}
/// The evidence behind listing `CommandMode` in `strands_the_user`, and
/// the test improvise-e24v should break. See the evidence file.
#[test]
fn the_command_line_cannot_be_committed_by_this_client() {
let mut state = in_mode(AppMode::command_mode());
state.view.buffers.insert("command".into(), "q".into());
assert_eq!(
resolve(&state, ev(KeyCode::Enter)),
KeyOutcome::Upstream(ev(KeyCode::Enter)),
"`:q` — and every other ex-command — resolves to nothing here"
);
}
/// The keys an independent browser smoke test found inert. Each asserts
/// the *shape* composed: "resolves locally" would pass on a wrong command.
#[test]
fn the_keys_the_smoke_test_found_inert_now_resolve() {
let state = in_mode(AppMode::Normal);
assert_eq!(
resolve(&state, ev(KeyCode::Char('?'))),
KeyOutcome::Local(vec![Command::ChangeMode(AppMode::Help)]),
"? opens help"
);
assert_eq!(
resolve(&state, ev(KeyCode::Char('T'))),
KeyOutcome::Local(vec![
Command::SetTileCatIdx(0),
Command::ChangeMode(AppMode::TileSelect),
]),
"T enters tile select"
);
assert_eq!(
resolve(&state, ev(KeyCode::Char('F'))),
KeyOutcome::Local(vec![
Command::SetPanelOpen {
panel: Panel::Formula,
open: true,
},
Command::ChangeMode(AppMode::FormulaPanel),
]),
"F opens and focuses the formula panel"
);
assert_eq!(
resolve(
&state,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)
),
KeyOutcome::Local(vec![Command::SetPanelOpen {
panel: Panel::Formula,
open: true,
}]),
"Ctrl-f shows the panel without focusing it"
);
}
/// `F` on an already-focused panel closes it: the interactive arm of
/// `TogglePanelAndFocus`, not its script arm.
#[test]
fn a_focused_panel_toggles_shut() {
let mut state = in_mode(AppMode::FormulaPanel);
state.view.formula_panel_open = true;
assert_eq!(
resolve(&state, ev(KeyCode::Char('F'))),
KeyOutcome::Local(vec![
Command::SetPanelOpen {
panel: Panel::Formula,
open: false,
},
Command::ChangeMode(AppMode::Normal),
])
);
}
/// `CyclePanelFocus`'s wrap arms, driven directly: `PanelKeymap` binds Tab
/// to `enter-mode normal` inside a panel, so no key reaches them.
#[test]
fn panel_focus_cycles_from_the_focused_panel_not_the_first() {
// Spelled out rather than inherited: this test is about skipping a
// *closed* panel, so the default's own answer must not decide it.
let open_two = |mode: AppMode| ViewState {
mode,
formula_panel_open: true,
category_panel_open: false,
view_panel_open: true,
..ViewState::default()
};
assert_eq!(
cycle_panel_focus(&open_two(AppMode::Normal)),
vec![Command::ChangeMode(AppMode::FormulaPanel)],
"from outside a panel, the first open one"
);
assert_eq!(
cycle_panel_focus(&open_two(AppMode::FormulaPanel)),
vec![Command::ChangeMode(AppMode::ViewPanel)],
"from a panel, the next OPEN one, skipping the closed category panel"
);
assert_eq!(
cycle_panel_focus(&open_two(AppMode::ViewPanel)),
vec![Command::ChangeMode(AppMode::FormulaPanel)],
"and from the last, it wraps"
);
let nothing_open = ViewState {
category_panel_open: false,
..ViewState::default()
};
assert!(
cycle_panel_focus(&nothing_open).is_empty(),
"with nothing open there is nowhere to go"
);
}
/// `t` is a document mutation, so it carries a basis — and until one is
/// published there is nothing truthful to claim (ADR 001).
#[test]
fn transpose_needs_a_published_basis() {
let state = in_mode(AppMode::Normal);
assert_eq!(
resolve(&state, ev(KeyCode::Char('t'))),
KeyOutcome::Upstream(ev(KeyCode::Char('t'))),
"with no basis this client has nothing it may claim"
);
let client = client_on(record_cell());
assert_eq!(
resolve(&client, ev(KeyCode::Char('t'))),
KeyOutcome::Local(vec![Command::Model {
basis: client.basis.claim([]).unwrap(),
cmd: ModelCommand::TransposeAxes {
view: client.view.active_view.clone(),
},
}])
);
}
/// A mode this client was never taught answers nothing itself: the
/// wizard's whole keymap is one `Any` binding into `HandleWizardKey`.
#[test]
fn an_untaught_mode_resolves_nothing() {
for code in [KeyCode::Char('j'), KeyCode::Down, KeyCode::Enter] {
assert_eq!(
resolve(&in_mode(AppMode::ImportWizard), ev(code)),
KeyOutcome::Upstream(ev(code)),
"the wizard is the host's"
);
}
}
/// A key nothing binds is `Ignored`, not `Upstream`: reading the same
/// table makes "nothing is bound" a fact rather than a deferred guess.
#[test]
fn an_unbound_key_is_ignored_rather_than_sent_into_the_void() {
let state = in_mode(AppMode::Normal);
assert_eq!(resolve(&state, ev(KeyCode::F(9))), KeyOutcome::Ignored);
assert_eq!(
resolve(&state, KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)),
KeyOutcome::Ignored
);
}
/// An empty widget is not an unanswerable key: the client knows perfectly
/// well that there is nowhere to go.
#[test]
fn an_empty_widget_answers_ignored_rather_than_forwarding() {
for (mode, code) in [
(AppMode::Normal, KeyCode::Char('j')),
(AppMode::TileSelect, KeyCode::Char('l')),
(AppMode::FormulaPanel, KeyCode::Char('j')),
] {
assert_eq!(
resolve(&in_mode(mode.clone()), ev(code)),
KeyOutcome::Ignored,
"{} / {code:?}",
crate::help::mode_label(&mode)
);
}
}
/// Which widgets say nothing about a move that goes nowhere is **not a
/// rule of this module** — it is the `Cmd`'s, and the three differ.
#[test]
fn a_move_that_goes_nowhere_follows_the_command_it_mirrors() {
let mut grid = in_mode(AppMode::Normal);
grid.grid.extent = GridExtent { rows: 2, cols: 2 };
grid.window = (10, 10);
assert_eq!(
resolve(&grid, ev(KeyCode::Char('k'))),
KeyOutcome::Local(vec![Command::SetSelected { row: 0, col: 0 }]),
"Move emits even when it clamps, so this must too"
);
assert_eq!(
resolve(&grid, ev(KeyCode::Char('j'))),
KeyOutcome::Local(vec![Command::SetSelected { row: 1, col: 0 }]),
"and the fixture is not simply inert"
);
let mut panel = in_mode(AppMode::FormulaPanel);
panel.formulas.formulas = vec!["Profit = Revenue - Cost".into()];
assert_eq!(
resolve(&panel, ev(KeyCode::Char('k'))),
KeyOutcome::Ignored,
"MovePanelCursor returns no effects when the cursor does not move"
);
let mut tiles = in_mode(AppMode::TileSelect);
tiles.tiles.tiles = vec![crate::TileEntry {
category: "Region".into(),
axis: improvise_core::view::Axis::Row,
}];
assert_eq!(
resolve(&tiles, ev(KeyCode::Char('h'))),
KeyOutcome::Local(vec![Command::SetTileCatIdx(0)]),
"MoveTileCursor emits regardless, like Move"
);
}
// ── the editing path ────────────────────────────────────────────
fn surrogate() -> crate::RecordHash {
serde_json::from_str("\"fe4c8e5e9cbd578e0d388f4b2b5a7879\"").expect("a surrogate is hex")
}
fn record_cell() -> CellId {
CellId::Record(RecordCell {
record: surrogate(),
measure: Some("Revenue".into()),
})
}
/// A one-by-two grid whose first cell carries `id`, with a published basis
/// naming its record: the shape a client is in before the user types.
fn client_on(id: CellId) -> ClientState {
let mut first = CellDisplay::number("1200");
first.id = Some(id);
let mut client = ClientState::default();
client.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North", "South"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
Projection::Grid(GridUpdate::Cells(vec![
((0, 0), first),
((1, 0), CellDisplay::number("900")),
])),
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 2, cols: 1 })),
Projection::Basis(published()),
],
});
client.window = (2, 1);
client
}
fn published() -> crate::Basis {
crate::basis::published(7, 3, [(surrogate(), 4)].into_iter().collect())
}
/// `i` on a `Record` cell seeds the edit buffer and enters `Editing`:
/// `EditOrDrill`'s non-aggregated branch, in the order it emits.
#[test]
fn i_on_a_record_cell_enters_editing_seeded_from_the_cell() {
let client = client_on(record_cell());
assert_eq!(
resolve(&client, ev(KeyCode::Char('i'))),
KeyOutcome::Local(vec![
Command::SetBuffer {
name: "edit".into(),
value: "1200".into(),
},
Command::ChangeMode(AppMode::editing()),
])
);
}
/// `a` is bound to the same command with the same argument, and behaves
/// identically without a second arm here: the point of sharing the table.
#[test]
fn a_and_i_are_one_binding_with_two_keys() {
let client = client_on(record_cell());
assert_eq!(
resolve(&client, ev(KeyCode::Char('a'))),
resolve(&client, ev(KeyCode::Char('i')))
);
}
/// `i` on an aggregate says so; upstream would be a keypress the user
/// sees do nothing (`doctrine(an_upstream_key_is_a_black_hole)`).
#[test]
fn i_on_an_aggregate_declines_out_loud() {
let client = client_on(CellId::Aggregate(
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
));
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Char('i'))) else {
panic!("an aggregate must not be a silent no-op");
};
assert!(
matches!(&commands[..], [Command::SetStatus(msg)] if msg.contains("drill")),
"expected a status naming drill, got {commands:?}"
);
}
/// `i` on a formula-owned cell names the formula, so the user learns where
/// the value comes from rather than typing into a shadowed cell (ir7l).
#[test]
fn i_on_a_derived_cell_names_the_formula() {
let client = client_on(CellId::Derived {
target: "Profit".into(),
});
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Char('i'))) else {
panic!("a derived cell must not be a silent no-op");
};
assert!(
matches!(&commands[..], [Command::SetStatus(msg)] if msg.contains("Profit")
&& msg.contains("formula")),
"expected a status naming the formula target, got {commands:?}"
);
}
/// Two page axes, so the odometer's carry is visible: `Quarter` is least
/// significant and `Year` only moves when it wraps.
fn paged() -> ClientState {
let mut state = in_mode(AppMode::Normal);
crate::WidgetCache::apply(&mut state.basis, &published());
state.grid.page_coords = vec![
crate::PageAxis {
cat: "Year".into(),
selected: "2024".into(),
items: Some(vec!["2024".into(), "2025".into()]),
},
crate::PageAxis {
cat: "Quarter".into(),
selected: "Q4".into(),
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]),
},
];
state
}
fn selections(outcome: KeyOutcome) -> Vec<(String, String)> {
let KeyOutcome::Local(commands) = outcome else {
panic!("paging must resolve here, got {outcome:?}");
};
commands
.into_iter()
.map(|c| match c {
Command::Model {
cmd: ModelCommand::SetPageSelection { category, item, .. },
..
} => (category, item),
other => panic!("expected a page selection, got {other:?}"),
})
.collect()
}
/// improvise-p1ym: `]` had no item list to cycle, so it reached the host
/// and did nothing. `context/test-evidence/clients.md`.
#[test]
fn the_page_keys_turn_the_odometer_as_the_command_does() {
let state = paged();
assert_eq!(
selections(resolve(&state, ev(KeyCode::Char(']')))),
vec![
("Year".to_string(), "2025".to_string()),
("Quarter".to_string(), "Q1".to_string()),
],
"Q4 wraps to Q1 and carries into Year"
);
assert_eq!(
selections(resolve(&state, ev(KeyCode::Char('[')))),
vec![
("Year".to_string(), "2024".to_string()),
("Quarter".to_string(), "Q3".to_string()),
],
"no borrow, so Year stands still — and is still restated"
);
}
/// A Filter axis constrains without being navigable (gotcha 27), so it
/// carries no items and the odometer cannot reach it.
#[test]
fn a_filter_axis_is_projected_but_not_cycled() {
let mut state = paged();
state.grid.page_coords.push(crate::PageAxis {
cat: "Region".into(),
selected: "East".into(),
items: None,
});
let moved = selections(resolve(&state, ev(KeyCode::Char(']'))));
assert!(
!moved.iter().any(|(cat, _)| cat == "Region"),
"a Filter axis must not be paged, got {moved:?}"
);
assert_eq!(moved.len(), 2, "the two Page axes still move: {moved:?}");
}
/// improvise-rw6l: picking straight off the bar, which is what a menu
/// does. It names ONE axis, so the odometer's carry must not appear.
#[test]
fn picking_a_page_item_moves_that_axis_and_only_that_axis() {
let state = paged();
let commands = click(&state, Click::PageItem { axis: 1, item: 0 });
assert_eq!(
commands,
vec![Command::Model {
// The claim `]` makes for the same document: this command
// names no record, so the record tier is omitted (gotcha 43).
basis: state.basis.claim([]).expect("the basis is published"),
cmd: ModelCommand::SetPageSelection {
view: state.view.active_view.clone(),
category: "Quarter".into(),
item: "Q1".into(),
},
}],
"Q4 to Q1 directly: Year is untouched, where `]` would have carried into it"
);
}
/// A Filter axis is on the bar and offers nothing to pick (gotcha 27).
/// Offering a menu that cannot act is worse than offering none.
#[test]
fn a_filter_axis_offers_no_item_to_pick() {
let mut state = paged();
state.grid.page_coords.push(crate::PageAxis {
cat: "Region".into(),
selected: "East".into(),
items: None,
});
assert!(
click(&state, Click::PageItem { axis: 2, item: 0 }).is_empty(),
"a Filter axis has no item list, so there is nothing a click could mean"
);
assert!(
click(&state, Click::PageItem { axis: 9, item: 0 }).is_empty(),
"and an axis that is not on the bar is not one either"
);
assert!(
click(&state, Click::PageItem { axis: 0, item: 9 }).is_empty(),
"nor an item past the end of the list the bar was painted from"
);
}
/// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]`
/// and every part shows up here — including the odd-looking trailing clear.
#[test]
fn enter_commits_the_cell_advances_and_re_enters_editing() {
let mut client = client_on(record_cell());
client.view.mode = AppMode::editing();
client.view.buffers.insert("edit".into(), "42".into());
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
panic!("Enter in Editing must resolve here");
};
// The write, composed through the same function click-to-edit uses.
assert_eq!(
commands[0],
Command::Model {
basis: published(),
cmd: ModelCommand::SetCell {
cell: RecordCell {
record: surrogate(),
measure: Some("Revenue".into()),
},
value: CellValue::Number(42.0),
},
}
);
assert_eq!(commands[1], Command::ChangeMode(AppMode::Normal));
assert_eq!(commands[2], Command::SetSelected { row: 1, col: 0 });
assert!(
commands.contains(&Command::ChangeMode(AppMode::editing())),
"the advance re-enters editing on the cell it landed on: {commands:?}"
);
assert_eq!(
commands.last(),
Some(&Command::SetBuffer {
name: "edit".into(),
value: String::new(),
}),
"the keymap welds `clear-buffer` to every commit: {commands:?}"
);
}
/// An empty buffer is `ClearCell`, not `SetCell` of an empty string: the
/// decision `typed()` makes, reached by keyboard and by mouse alike.
#[test]
fn committing_an_empty_buffer_clears_the_cell() {
let mut client = client_on(record_cell());
client.view.mode = AppMode::editing();
client.view.buffers.insert("edit".into(), String::new());
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
panic!("Enter in Editing must resolve here");
};
assert!(matches!(
&commands[0],
Command::Model {
cmd: ModelCommand::ClearCell(_),
..
}
));
}
/// improvise-ktlk: what an editor shows and what Enter commits are one
/// fact. See `context/test-evidence/clients.md`.
#[test]
fn an_editor_entered_without_its_buffer_commits_and_extends_the_cell() {
let mut client = client_on(record_cell());
let mut cell = CellDisplay::number("1,200.50");
cell.edit_text = Some("1200.5".into());
cell.id = Some(record_cell());
client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells(
vec![((0, 0), cell)],
))));
client.fold(&Command::ChangeMode(AppMode::editing()));
assert_eq!(
read_buffer(&client.view, "edit"),
"1200.5",
"the buffer is what the overlay paints and what a commit reads"
);
let committed = |client: &ClientState| {
let KeyOutcome::Local(commands) = resolve(client, ev(KeyCode::Enter)) else {
panic!("Enter in Editing must resolve here");
};
match &commands[0] {
Command::Model {
cmd: ModelCommand::SetCell { value, .. },
..
} => Some(value.clone()),
_ => None,
}
};
assert_eq!(
committed(&client),
Some(CellValue::Number(1200.5)),
"Enter on an editor showing the cell must not clear it"
);
let KeyOutcome::Local(typed) = resolve(&client, ev(KeyCode::Char('5'))) else {
panic!("a character in Editing is append-char");
};
for command in &typed {
client.fold(command);
}
assert_eq!(
committed(&client),
Some(CellValue::Number(1200.55)),
"a typed character extends what the editor showed, not replaces it"
);
}
/// At the bottom-right corner `EnterAdvance` returns `AbortChain`, so the
/// commit leaves the user in normal mode. See the evidence file.
#[test]
fn a_commit_in_the_last_cell_exits_editing_instead_of_advancing() {
// A one-by-one grid, so the only cell *is* the corner.
let mut cell = CellDisplay::number("1200");
cell.id = Some(record_cell());
let mut client = ClientState::default();
client.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 1, cols: 1 })),
Projection::Basis(published()),
],
});
client.window = (1, 1);
client.view.mode = AppMode::editing();
client.view.buffers.insert("edit".into(), "7".into());
let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else {
panic!("Enter in Editing must resolve here");
};
assert!(
matches!(&commands[0], Command::Model { .. }),
"the write still happens: {commands:?}"
);
assert!(
!commands.contains(&Command::ChangeMode(AppMode::editing())),
"the corner must not re-enter editing: {commands:?}"
);
assert!(
commands.contains(&Command::ChangeMode(AppMode::Normal)),
"it drops to normal instead: {commands:?}"
);
}
/// `settled_mode` mirrors `settle_normal_mode`, which settles BOTH members
/// of the pair. See `context/test-evidence/clients.md`.
#[test]
fn the_normal_pair_is_settled_in_both_directions() {
for (records, held, expected) in [
(true, AppMode::Normal, AppMode::RecordsNormal),
(true, AppMode::RecordsNormal, AppMode::RecordsNormal),
(false, AppMode::RecordsNormal, AppMode::Normal),
(false, AppMode::Normal, AppMode::Normal),
] {
assert_eq!(
crate::settled_mode(&held, records),
expected,
"records={records}, holding {held:?}"
);
}
// Everything else passes through, or this is a records-mode filter and
// not a settling — `Editing` is the one that must NOT move.
for other in [
AppMode::Help,
AppMode::editing(),
AppMode::records_editing(),
] {
assert_eq!(crate::settled_mode(&other, true), other);
assert_eq!(crate::settled_mode(&other, false), other);
}
}
/// Records mode is the fork this client does not have (gotcha 1), so its
/// commit goes upstream rather than writing the wrong cell.
#[test]
fn a_records_mode_commit_is_the_hosts() {
let mut client = client_on(record_cell());
crate::WidgetCache::apply(&mut client.grid, &GridUpdate::Records(true));
client.view.mode = AppMode::records_editing();
assert_eq!(
resolve(&client, ev(KeyCode::Enter)),
KeyOutcome::Upstream(ev(KeyCode::Enter))
);
}
/// The state a client is in after the prefix key: the arming folded, so
/// the second key is resolved against what the browser really holds.
fn arm(state: &mut ClientState, prefix: KeyEvent) {
let KeyOutcome::Local(commands) = resolve(state, prefix) else {
panic!("{prefix:?} is a prefix; arming it must resolve locally");
};
for command in &commands {
state.fold(command);
}
assert_eq!(
state.view.transient_prefix,
Some((prefix.code, prefix.modifiers)),
"arming must leave the prefix where `resolve` looks for it"
);
}
/// improvise-hs0u: evidence in `context/test-evidence/clients.md`.
#[test]
fn gg_reaches_the_first_row_the_way_shift_g_reaches_the_last() {
let mut state = client_on(record_cell());
state.view.cursor_mut().selected = (1, 0);
state.view.cursor_mut().row_offset = 1;
arm(&mut state, ev(KeyCode::Char('g')));
let KeyOutcome::Local(commands) = resolve(&state, ev(KeyCode::Char('g'))) else {
panic!("`gg` is bound to jump-first-row and the arm is implemented");
};
assert_eq!(
commands.first(),
Some(&Command::SetTransientPrefix(None)),
"the prefix is consumed FIRST, whatever the second key did"
);
assert!(
commands.contains(&Command::SetSelected { row: 0, col: 0 }),
"`gg` must move to the first row: {commands:?}"
);
}
/// improvise-lut7: evidence in `context/test-evidence/clients.md`.
#[test]
fn yy_yanks_the_cell_under_the_cursor_and_p_pastes_what_it_yanked() {
let mut state = client_on(record_cell());
state.grid.cells[0][0].value = Some(CellValue::Number(1200.0));
arm(&mut state, ev(KeyCode::Char('y')));
let KeyOutcome::Local(yank) = resolve(&state, ev(KeyCode::Char('y'))) else {
panic!("`yy` is bound to yank");
};
assert!(
yank.contains(&Command::SetYanked(Some(CellValue::Number(1200.0)))),
"the cell under the cursor reads 1200: {yank:?}"
);
for command in &yank {
state.fold(command);
}
assert_eq!(
state.view.transient_prefix, None,
"and the y prefix is spent"
);
let KeyOutcome::Local(paste) = resolve(&state, ev(KeyCode::Char('p'))) else {
panic!(
"`p` composes a SetCell once something has been yanked; \
before improvise-lut7 nothing could ever yank"
);
};
assert!(
paste.iter().any(|c| matches!(
c,
Command::Model {
cmd: ModelCommand::SetCell {
value: CellValue::Number(n),
..
},
..
} if *n == 1200.0
)),
"`p` must write what `yy` took: {paste:?}"
);
}
/// A prefix is consumed whether or not the second key matched, mirroring
/// `App::dispatch_key`. Evidence in `context/test-evidence/clients.md`.
#[test]
fn a_second_key_that_matches_nothing_still_spends_the_prefix() {
let mut state = client_on(record_cell());
arm(&mut state, ev(KeyCode::Char('g')));
assert_eq!(
resolve(&state, ev(KeyCode::Char('q'))),
KeyOutcome::Local(vec![Command::SetTransientPrefix(None)]),
"`gq` binds nothing, and leaving the prefix armed strands every \
key after it in the g map"
);
}
/// A name behind a prefix that this client cannot compose must still
/// spend the prefix, and say so. Evidence in `context/test-evidence/clients.md`.
#[test]
fn zz_declines_out_loud_rather_than_stranding_the_prefix() {
let mut state = client_on(record_cell());
arm(&mut state, ev(KeyCode::Char('Z')));
let KeyOutcome::Local(commands) = resolve(&state, ev(KeyCode::Char('Z'))) else {
panic!("an Upstream here leaves `transient_prefix` armed forever");
};
assert_eq!(commands.first(), Some(&Command::SetTransientPrefix(None)));
assert!(
matches!(commands.last(), Some(Command::SetStatus(said)) if said.contains("not available")),
"silence is worse (improvise-rmvx): {commands:?}"
);
}
/// The structural half: a name reachable only behind a prefix was
/// unreachable full stop, so nothing checked it. Evidence file.
#[test]
fn every_name_bound_behind_a_prefix_is_answered_by_this_client() {
let mut names = Vec::new();
for (_, keymap) in Keymaps::shared().iter() {
let mut map = Some(keymap.clone());
while let Some(current) = map {
for binding in current.bindings().values() {
// Prefixes are flat (gotcha 13), so the inner map has none.
if let Binding::Prefix(sub) = binding {
for inner in sub.bindings().values() {
match inner {
Binding::Cmd { name, args } => {
names.push((name.to_string(), args.clone()))
}
Binding::Sequence(steps) => names
.extend(steps.iter().map(|(n, a)| (n.to_string(), a.clone()))),
Binding::Prefix(_) => {
panic!("prefix_keymaps_are_flat is broken")
}
}
}
}
}
map = current.parent().cloned();
}
}
names.sort();
names.dedup();
assert!(
!names.is_empty(),
"the walk found no prefix-bound names at all, so it proves nothing"
);
let state = client_on(record_cell());
for (name, args) in &names {
assert_eq!(
requested(name),
None,
"`{name}` is bound behind a prefix and is a REQUEST, which a \
chord discards: see the sibling test below"
);
let answered = declined(name, args).is_some()
|| composed(&state, ev(KeyCode::Char('x')), name, args).is_some();
assert!(
answered,
"`{name}` is reachable only behind a prefix, and this client \
answers it with nothing — which strands the prefix armed"
);
}
}
/// Why the guard above refuses a request rather than counting it answered
/// (ChatGPT Codex, PR #161). See `context/test-evidence/clients.md`.
#[test]
fn a_request_bound_behind_a_prefix_would_be_silently_discarded() {
let state = client_on(record_cell());
assert!(
requested("save").is_some(),
"the request class must be non-empty, or the guard's refusal \
clause can never fire and asserts nothing"
);
assert_eq!(
chord(
&state,
ev(KeyCode::Char('s')),
Some(&Binding::Cmd {
name: "save",
args: Vec::new(),
}),
),
KeyOutcome::Local(vec![Command::SetTransientPrefix(None)]),
"the save request is GONE: a chord must carry its disarm, and \
`KeyOutcome::Request` has nowhere to put one"
);
}
}