Merge pull request #126 from fiddlerwoaroof/fix/command-layer
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix(command): two P1s in the key path, and both were about silence
This commit is contained in:
@@ -2933,3 +2933,88 @@ it through `display_value_of`, and `grid_of` projects the committed value to
|
||||
everyone, **including that session's own grid**. The producer never reads
|
||||
`drill_state`, and teaching it to would put one client's uncommitted typing on
|
||||
another's screen.
|
||||
|
||||
## A bound key that builds nothing (improvise-55l5)
|
||||
|
||||
`KeymapSet::dispatch` returns `Option<Vec<Box<dyn Effect>>>`, and until this
|
||||
ticket that option carried **two** facts under one shape: *no binding matched*,
|
||||
and *a binding matched but its command would not construct*. `run` spelled the
|
||||
second `registry.interactive(name, args, ctx).ok()?`, and `App::dispatch_key`
|
||||
answers `None` with `Dirty::NOTHING` — so a bound key could be a black hole.
|
||||
|
||||
The interesting half is which errors that actually hid, because a fix that
|
||||
surfaces errors nobody can act on is noise.
|
||||
|
||||
**The class that reaches a user is `no cell at cursor`.** Six registrations in
|
||||
`cmd/registry.rs` read `ctx.cell_key()` and refuse without one: `clear-cell`,
|
||||
`yank-cell`, `paste-cell`, `drill-into-cell`, `commit-cell-edit` and
|
||||
`commit-and-advance-right`. It is *state-dependent*, so nothing checkable at
|
||||
registration time can see it, and the two commit entries are the worst case —
|
||||
the user is in `Editing`, has typed a value, presses Enter, and the batch is
|
||||
discarded with the minibuffer still up (which is where `draw_bottom_bar` paints
|
||||
instead of the status line, gotcha 21).
|
||||
|
||||
**Everything else the constructors can return is a keymap-authoring error**:
|
||||
`Unknown command`, `require_args`, `Unknown mode` from `parse_mode_name`, and
|
||||
`commit-buffer item requires item-add mode` — which is the repro the ticket
|
||||
itself proposes. `every_bound_command_name_is_registered` already refuses all of
|
||||
these at test time, checking *both* halves of the old expression precisely
|
||||
because improvise-s13 made a typo'd mode name compile into a dead key
|
||||
(gotcha 15). They are therefore unreachable from a shipped keymap, and they
|
||||
surface now only because the mechanism is one rule rather than a list of
|
||||
blessed errors — which is the right direction: a list would be a second
|
||||
description of which failures matter, free to disagree with the first.
|
||||
|
||||
So the rule is: **`None` means no binding matched, and nothing else.** A
|
||||
construction failure becomes `Some(vec![set_status(format!("{name}: {e}"))])`.
|
||||
The command name is in the message because the two classes need different
|
||||
readers — a user reads "no cell at cursor", a maintainer reads which binding
|
||||
produced it — and one string serves both without a second channel.
|
||||
|
||||
A `Sequence` whose later step fails reports and runs **none** of the batch.
|
||||
That is `try_fold`'s shape and it is not a new rule: gotcha 21's abort is about
|
||||
effects that *ran*, this is about effects that were never built, and there is
|
||||
nothing to roll back.
|
||||
|
||||
What this does **not** do, and should not: report an unbound key. `an_unbound_key_stays_silent`
|
||||
is the control, and the injected rival that makes `lookup`'s `None` a status
|
||||
fails it. Every unmapped character in every mode would otherwise write one.
|
||||
|
||||
## `add-record-row` names the row it lands on (improvise-o9uq)
|
||||
|
||||
`o` in records mode is `[add-record-row, enter-edit-at-cursor records-editing]`,
|
||||
and `AddRecordRow::execute` returned `SetCell` plus a status and **never placed
|
||||
the cursor**. The result was silent corruption of an unrelated record: appending
|
||||
a row changes `view_frame`'s hash, the frame observation inside `apply_effects`
|
||||
invalidates the cursor to the origin *mid-batch* (gotcha 24), and
|
||||
`EnterEditAtCursor` then pre-fills the editor with row 0's category coordinate.
|
||||
Enter commits through `rename_record_coordinate`, which by gotcha 1 moves every
|
||||
measure cell of that row.
|
||||
|
||||
Two things are worth carrying past this fix.
|
||||
|
||||
**The order is the whole repair, and the acceptance criteria did not say so.**
|
||||
"Append `viewport_effects` after its `SetCell`" is only correct because the
|
||||
observation runs inside the loop; the same two effects in the other order are
|
||||
the bug verbatim, and the two are indistinguishable by reading. The injected
|
||||
rival for the regression test is therefore the *swap*, not the omission.
|
||||
|
||||
**`CommitAndAdvance`'s Tab path already did this**, and that is the tell to
|
||||
generalise: one of two call sites placed the cursor and the other did not, so
|
||||
the placement was a property of the *caller* where it is a property of the
|
||||
command. It is now the command's, and the Tab site's own later
|
||||
`viewport_effects` still wins because it is emitted after.
|
||||
|
||||
**Where it lands** is `row_count()` — an append is last — unless the record
|
||||
already exists, in which case it is that record's index, found by scanning
|
||||
`record_row(r).coords`. Predicting `row_count()` unconditionally would put the
|
||||
cursor one past the end in that arm. That arm also stops claiming "Added new
|
||||
record row" for a row it did not add; it is not made a *refusal*, because the
|
||||
Tab path depends on seeding an empty-coordinate row and a refusal there would
|
||||
break bulk entry.
|
||||
|
||||
**The deeper defect is untouched and filed.** `view_frame` hashes `entries.len()`
|
||||
and every entry, so *any* append reframes and throws the cursor to the origin
|
||||
although nothing was renumbered. Placing the cursor masks that at this call
|
||||
site. Distinguishing "rows appended" from "rows renumbered" is a larger question
|
||||
and does not belong in a P1 data-loss fix.
|
||||
|
||||
@@ -4217,6 +4217,111 @@ The neighbouring test `a_refused_switch_corrects_the_asker_and_nobody_else`
|
||||
compared `active_view` alone and stayed green through all of this: the check
|
||||
that answers a neighbouring question, in the place the doctrine warns about.
|
||||
|
||||
## `command/keymap.rs` — a bound key that builds nothing (improvise-55l5)
|
||||
|
||||
### a_bound_key_whose_command_cannot_be_built_says_so
|
||||
|
||||
`dispatch` resolved a bound name with `registry.interactive(name, args, ctx).ok()?`,
|
||||
so a command that would not *construct* collapsed into the same `None` an
|
||||
**unbound** key returns — and `App::dispatch_key` answers `None` with
|
||||
`Dirty::NOTHING` and no status. The two are different facts and the option
|
||||
could carry only one of them.
|
||||
|
||||
Which errors were invisible, and what each would have caught:
|
||||
|
||||
- **`no cell at cursor`** — six registrations read `ctx.cell_key()`:
|
||||
`clear-cell`, `yank-cell`, `paste-cell`, `drill-into-cell`,
|
||||
`commit-cell-edit` and `commit-and-advance-right`. This is the class that
|
||||
reaches a user: it is *state-dependent*, so no compile-time or registration
|
||||
check can see it. `x` on a grid whose row axis carries an empty category
|
||||
did nothing and said nothing.
|
||||
- **`Unknown command`, `require_args`, `Unknown mode`,
|
||||
`commit-buffer item requires item-add mode`** — keymap-authoring errors.
|
||||
`every_bound_command_name_is_registered` already refuses these at test time
|
||||
(gotcha 15 checks *both* halves for exactly this reason), so they are
|
||||
unreachable from a shipped keymap. They surface now because the mechanism is
|
||||
one rule rather than a list, not because they were the complaint.
|
||||
|
||||
The repro is the first class. `app_with_no_cells` puts an item-less category on
|
||||
`Axis::Row`, and the setup asserts `layout.cell_key(0, 0) == None` — so the
|
||||
fixture states the precondition rather than hoping for it. The test then asserts
|
||||
`x` **is bound** before pressing it, because a test that pressed an unbound key
|
||||
would pass against the bug.
|
||||
|
||||
Injected rivals, one per test:
|
||||
|
||||
- **`Some(built.unwrap_or_default())`** — return `Some` but swallow the error,
|
||||
the smallest thing that looks like a fix. `a_bound_key_…_says_so` fails;
|
||||
`an_unbound_key_stays_silent` passes, as it should.
|
||||
- **Report a failed *lookup* as well** (`None` from `Keymaps::lookup` becomes a
|
||||
status). `an_unbound_key_stays_silent` fails; the other passes. That is the
|
||||
neighbouring question this pair separates: *did a binding match* is not *did
|
||||
the command build*, and a fix that answers both with a message is noise on
|
||||
every typo and every unmapped key.
|
||||
|
||||
A `Sequence` whose later step fails now reports and runs **none** of it, which
|
||||
is the existing all-or-nothing shape (`try_fold`), not a new rule — gotcha 21's
|
||||
abort is about effects that ran, this is about effects that were never built.
|
||||
|
||||
### an_unbound_key_stays_silent
|
||||
|
||||
The control. `None` still means, and only means, *no binding matched*. It presses
|
||||
a key the Normal keymap does not bind and asserts the status stays empty, and it
|
||||
asserts that key is unbound first — without that assertion the test would pass
|
||||
against a keymap in which it *was* bound to something silent.
|
||||
|
||||
## `command/cmd/grid.rs` — `o` edits the row it added (improvise-o9uq)
|
||||
|
||||
### o_in_records_mode_edits_the_row_it_added_and_leaves_the_others_alone
|
||||
|
||||
`o` is `[add-record-row, enter-edit-at-cursor records-editing]`, and
|
||||
`AddRecordRow::execute` returned only `SetCell` + a status: it never placed the
|
||||
cursor. Appending a record changes `view_frame`'s hash, so the frame observation
|
||||
inside `apply_effects` (gotcha 24) invalidated the cursor to the origin
|
||||
*mid-batch*, and `EnterEditAtCursor` then opened an editor pre-filled with **row
|
||||
0's** `Region` coordinate. Typing appended to it and Enter went through
|
||||
`commit_plain_records_edit` → `rename_record_coordinate`, moving every measure
|
||||
cell of an unrelated record (gotcha 1: a category-column edit moves the whole
|
||||
row, which is why the basis unit is the record — gotcha 40).
|
||||
|
||||
Measured before the fix, from `Region{East,West}` with `Revenue` 10 and 20, at
|
||||
`sel=(1,0)`, keys `o N o r t h Enter`: `East`'s `Revenue` cell was **gone** and
|
||||
the rows read `["0","1","2"]` — the East record had become `EastNorth`.
|
||||
|
||||
The fix appends `viewport_effects(target_row, 0, ..)` **after** the `SetCell`,
|
||||
which is the whole content of the repair: the observation is inside the loop, so
|
||||
a placement emitted after the reframe survives and one emitted before does not.
|
||||
|
||||
Injected rival: **move the `viewport_effects` ahead of the `SetCell`**. Both
|
||||
tests fail — the reframe then overwrites the placement, which is the exact bug.
|
||||
That rival is the one worth keeping, because the naive reading of the acceptance
|
||||
criteria ("append `viewport_effects`") does not say *where*, and the wrong order
|
||||
is indistinguishable from the right one by inspection.
|
||||
|
||||
`target_row` is `row_count()` — the appended row is last — unless the record
|
||||
already exists, in which case it is that record's own index, found by scanning
|
||||
`record_row(r).coords`. Predicting `row_count()` unconditionally would put the
|
||||
cursor out of range in that arm.
|
||||
|
||||
### o_in_records_mode_opens_the_editor_on_an_empty_coordinate
|
||||
|
||||
The same defect one step earlier, and the cheaper witness: after `o` alone, the
|
||||
`edit` buffer must be empty. Pre-filled with another row's coordinate is the
|
||||
state from which any subsequent keystroke corrupts. It is a separate test
|
||||
because it fails without needing the commit path to run, so a regression in
|
||||
placement is diagnosed without reading through `rename_record_coordinate`.
|
||||
|
||||
Two things this fix does **not** close, deliberately:
|
||||
|
||||
- **`o` twice with no page-axis category still adds one row.** Both presses
|
||||
build `CellKey::new(vec![])` — the same record — and the second now says so
|
||||
rather than claiming "Added new record row". Refusing outright was rejected:
|
||||
`CommitAndAdvance`'s Tab-at-bottom-right path calls the same command and
|
||||
relies on seeding an empty-coordinate row.
|
||||
- **1b proper.** `view_frame` hashes `entries.len()` and every entry, so *any*
|
||||
append reframes and invalidates the cursor although no existing row was
|
||||
renumbered. Placing the cursor masks it at this call site; distinguishing
|
||||
"appended" from "renumbered" is a separate question and stays filed.
|
||||
---
|
||||
|
||||
## The format a projected cell is drawn with (improvise-e0ex)
|
||||
|
||||
@@ -513,9 +513,33 @@ impl Cmd for AddRecordRow {
|
||||
.filter(|(_, v)| !v.is_empty())
|
||||
.collect();
|
||||
let key = crate::model::cell::CellKey::new(coords);
|
||||
vec![
|
||||
Box::new(effect::SetCell(key, CellValue::Number(0.0))),
|
||||
effect::set_status("Added new record row"),
|
||||
]
|
||||
// The row this lands on is named, not inherited — improvise-o9uq, and
|
||||
// the ORDER is the fix: `context/code/command.md`.
|
||||
let existing = (0..ctx.layout.row_count()).find(|&r| {
|
||||
ctx.layout
|
||||
.record_row(r)
|
||||
.is_some_and(|rec| rec.coords == key)
|
||||
});
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
match existing {
|
||||
None => effects.push(Box::new(effect::SetCell(key, CellValue::Number(0.0)))),
|
||||
Some(_) => effects.push(effect::set_status(
|
||||
"That record row is already here — a row is its coordinates, so \
|
||||
fill this one in first",
|
||||
)),
|
||||
}
|
||||
let target_row = existing.unwrap_or_else(|| ctx.layout.row_count());
|
||||
effects.extend(super::navigation::viewport_effects(
|
||||
target_row,
|
||||
0,
|
||||
ctx.row_offset,
|
||||
ctx.col_offset,
|
||||
ctx.visible_rows,
|
||||
ctx.visible_cols,
|
||||
));
|
||||
if existing.is_none() {
|
||||
effects.push(effect::set_status("Added new record row"));
|
||||
}
|
||||
effects
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,11 @@ fn run(
|
||||
ctx: &CmdContext,
|
||||
name: &str,
|
||||
args: &[String],
|
||||
) -> Option<Vec<Box<dyn Effect>>> {
|
||||
let cmd = registry.interactive(name, args, ctx).ok()?;
|
||||
Some(cmd.execute(ctx))
|
||||
) -> Result<Vec<Box<dyn Effect>>, String> {
|
||||
let cmd = registry
|
||||
.interactive(name, args, ctx)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
Ok(cmd.execute(ctx))
|
||||
}
|
||||
|
||||
/// Turn a binding into effects. A `Sequence` **flattens** into one batch, and
|
||||
@@ -47,17 +49,19 @@ fn effects_of(
|
||||
key: KeyCode,
|
||||
mods: KeyModifiers,
|
||||
) -> Option<Vec<Box<dyn Effect>>> {
|
||||
match binding {
|
||||
// `None` means NO BINDING MATCHED, and nothing else: a bound key whose
|
||||
// command will not build reports it — improvise-55l5, `context/code/command.md`.
|
||||
let built = match binding {
|
||||
Binding::Cmd { name, args } => run(registry, ctx, name, args),
|
||||
Binding::Prefix(_) => Some(vec![Box::new(SetTransientPrefix(key, mods))]),
|
||||
Binding::Sequence(steps) => {
|
||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||
for (name, args) in steps {
|
||||
effects.extend(run(registry, ctx, name, args)?);
|
||||
}
|
||||
Some(effects)
|
||||
}
|
||||
}
|
||||
Binding::Prefix(_) => Ok(vec![
|
||||
Box::new(SetTransientPrefix(key, mods)) as Box<dyn Effect>
|
||||
]),
|
||||
Binding::Sequence(steps) => steps.iter().try_fold(Vec::new(), |mut acc, (name, args)| {
|
||||
acc.extend(run(registry, ctx, name, args)?);
|
||||
Ok(acc)
|
||||
}),
|
||||
};
|
||||
Some(built.unwrap_or_else(|e| vec![crate::ui::effect::set_status(e)]))
|
||||
}
|
||||
|
||||
/// The shared keymap table plus the registry that gives its names meaning.
|
||||
@@ -1187,3 +1191,73 @@ mod tests_support {
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod construction_failure_tests {
|
||||
use super::*;
|
||||
use crate::ui::app::App;
|
||||
use crate::workbook::Workbook;
|
||||
use improvise_protocol::key::KeyEvent;
|
||||
|
||||
fn app_with_no_cells() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.active_view_mut()
|
||||
.set_axis("Region", crate::view::Axis::Row);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert_eq!(
|
||||
app.layout.cell_key(0, 0),
|
||||
None,
|
||||
"setup: an empty category on Row means no cell under the cursor"
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
/// improvise-55l5: evidence in `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_bound_key_whose_command_cannot_be_built_says_so() {
|
||||
let mut app = app_with_no_cells();
|
||||
assert!(
|
||||
KeymapSet::default_keymaps()
|
||||
.maps()
|
||||
.lookup(&ModeKey::Normal, KeyCode::Char('x'), KeyModifiers::NONE)
|
||||
.is_some(),
|
||||
"setup: x must be BOUND, or this tests an unbound key"
|
||||
);
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("no cell at cursor"),
|
||||
"a bound key resolved to nothing and said nothing: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-55l5: evidence in `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_unbound_key_stays_silent() {
|
||||
let mut app = app_with_no_cells();
|
||||
assert!(
|
||||
KeymapSet::default_keymaps()
|
||||
.maps()
|
||||
.lookup(
|
||||
&ModeKey::Normal,
|
||||
KeyCode::Char('\u{00e9}'),
|
||||
KeyModifiers::NONE
|
||||
)
|
||||
.is_none(),
|
||||
"setup: this key must be UNBOUND, or the test proves nothing"
|
||||
);
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('\u{00e9}'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
app.view_state.status_msg, "",
|
||||
"an unbound key is not a failure and owes the user no message"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3523,6 +3523,88 @@ mod tests {
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
fn two_region_records() -> App {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "East").unwrap();
|
||||
wb.add_item("Region", "West").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
for (region, n) in [("East", 10.0), ("West", 20.0)] {
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), region.into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]),
|
||||
CellValue::Number(n),
|
||||
);
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(app.layout.is_records_mode(), "setup: records mode");
|
||||
assert_eq!(app.layout.row_count(), 2, "setup: East and West");
|
||||
app
|
||||
}
|
||||
|
||||
fn region_revenue(app: &App, region: &str) -> Option<String> {
|
||||
use crate::model::cell::CellKey;
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&CellKey::new(vec![
|
||||
("Region".into(), region.into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]))
|
||||
.map(|v| v.to_string())
|
||||
}
|
||||
|
||||
/// improvise-o9uq: evidence in `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn o_in_records_mode_edits_the_row_it_added_and_leaves_the_others_alone() {
|
||||
let mut app = two_region_records();
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
for c in "North".chars() {
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
}
|
||||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
region_revenue(&app, "East").as_deref(),
|
||||
Some("10"),
|
||||
"adding a row renamed a record the user never touched: {:?}",
|
||||
(0..app.layout.row_count())
|
||||
.map(|r| app.layout.row_label(r))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
region_revenue(&app, "West").as_deref(),
|
||||
Some("20"),
|
||||
"the row the cursor was on when `o` was pressed also moved"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-o9uq: evidence in `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn o_in_records_mode_opens_the_editor_on_an_empty_coordinate() {
|
||||
let mut app = two_region_records();
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
app.view_state.buffers.get("edit").map(String::as_str),
|
||||
Some(""),
|
||||
"the editor opened pre-filled with another row's coordinate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Behavioural coverage for the import wizard's key handling (improvise-2wm),
|
||||
|
||||
Reference in New Issue
Block a user