An edited editor always writes, and the stale-seed guards are pinned (improvise-2054, improvise-wv3h)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-22 23:14:50 -07:00
parent 7407abbca6
commit fa8c8b61a2
9 changed files with 217 additions and 30 deletions
@@ -661,6 +661,74 @@ mod tests {
);
}
/// BUG (improvise-2054) — see `context/test-evidence/command.md`.
#[test]
fn an_editor_typed_back_to_its_seed_still_writes() {
use crate::model::cell::{CellKey, CellValue};
use improvise_protocol::key::KeyCode;
let target = CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut app = grid_holding(CellValue::Number(7.0), false);
app.cursor_mut().selected = (1, 1);
press(&mut app, KeyCode::Char('i'));
app.apply_effects(vec![Box::new(effect::SetCell(
target.clone(),
CellValue::Number(5.0),
))]);
press(&mut app, KeyCode::Backspace);
press(&mut app, KeyCode::Char('7'));
assert_eq!(app.view_state.buffers.get("edit").map(String::as_str), Some("7"));
press(&mut app, KeyCode::Enter);
assert_eq!(
app.model_state.workbook.model.get_cell(&target),
Some(&CellValue::Number(7.0)),
"a value the user typed was dropped because it spelled the seed"
);
}
/// BUG (improvise-wv3h) — see `context/test-evidence/command.md`.
#[test]
fn an_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed() {
use crate::model::cell::{CellKey, CellValue};
use improvise_protocol::key::KeyCode;
let empty = CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Cost".into()),
]);
let target = CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut app = grid_holding(CellValue::Number(2.0), false);
app.model_state.workbook.model.clear_cell(&empty);
app.rebuild_layout();
app.cursor_mut().selected = (0, 0);
press(&mut app, KeyCode::Char('i'));
assert_eq!(
app.view_state.opened_editor.as_ref().map(|o| o.seed.as_str()),
Some(""),
"the first editor did not open on the empty cell"
);
press(&mut app, KeyCode::Esc);
app.cursor_mut().selected = (1, 1);
press(&mut app, KeyCode::Char(':'));
for c in "enter-mode editing".chars() {
press(&mut app, KeyCode::Char(c));
}
press(&mut app, KeyCode::Enter);
assert!(app.view_state.mode.is_editing(), "`:enter-mode editing` did not open an editor");
press(&mut app, KeyCode::Enter);
assert_eq!(
app.model_state.workbook.model.get_cell(&target),
None,
"the empty commit was skipped against the earlier editor's seed"
);
}
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
#[test]
fn an_untouched_commit_still_advances_and_reopens_the_editor() {
@@ -395,9 +395,7 @@ impl Cmd for EditOrDrill {
value: ctx.display_value.clone(),
}),
Box::new(effect::SetOpenedEditor(Some(
improvise_protocol::OpenedEditor {
seed: ctx.display_value.clone(),
},
improvise_protocol::OpenedEditor::on(ctx.display_value.clone()),
))),
effect::change_mode(self.edit_mode.clone()),
]
@@ -297,7 +297,7 @@ impl Cmd for AppendChar {
if let KeyCode::Char(c) = ctx.key_code {
let mut val = read_buffer(ctx, &self.buffer);
val.push(c);
vec![write_buffer(&self.buffer, val)]
typed_into(ctx, &self.buffer, val)
} else {
vec![]
}
@@ -316,10 +316,21 @@ impl Cmd for PopChar {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut val = read_buffer(ctx, &self.buffer);
val.pop();
vec![write_buffer(&self.buffer, val)]
typed_into(ctx, &self.buffer, val)
}
}
/// A user's edit of buffer `name`: the write, then the open editor marked
/// touched, so an edit back to the seed still commits (improvise-2054).
fn typed_into(ctx: &CmdContext, name: &str, value: String) -> Vec<Box<dyn Effect>> {
let mut effects = vec![write_buffer(name, value)];
effects.extend(
improvise_protocol::edited(ctx.opened_editor, name)
.map(|opened| Box::new(effect::SetOpenedEditor(Some(opened))) as Box<dyn Effect>),
);
effects
}
/// Handle backspace in command mode — pop char or return to Normal if empty.
#[derive(Debug)]
pub struct CommandModeBackspace;
+1 -1
View File
@@ -271,7 +271,7 @@ impl Effect for EnterEditAtCursor {
app.view_state
.buffers
.insert("edit".to_string(), value.clone());
app.view_state.opened_editor = Some(improvise_protocol::OpenedEditor { seed: value });
app.view_state.opened_editor = Some(improvise_protocol::OpenedEditor::on(value));
app.view_state.mode = self.target_mode.clone();
}
fn kind(&self) -> EffectKind {
+1 -1
View File
@@ -173,7 +173,7 @@ impl ClientState {
.filter(|key| !self.view.buffers.contains_key(key));
if let (Some(key), Some(seed)) = (absent, crate::resolve::edit_seed(self)) {
self.view.buffers.insert(key, seed.clone());
self.view.opened_editor = Some(crate::OpenedEditor { seed });
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub use render::{
WizardProposal, WizardStepKind, header_level_widths,
};
pub use resolve::{
Click, KeyOutcome, OpenedEditor, Request, click, read_buffer, resolve, typed,
Click, KeyOutcome, OpenedEditor, Request, click, edited, read_buffer, resolve, typed,
typed_cell_command, untouched,
};
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
+120 -10
View File
@@ -371,12 +371,12 @@ fn composed(
};
let mut value = read_buffer(&state.view, args.first()?);
value.push(c);
Some(vec![write_buffer(args.first()?, value)])
Some(typed_into(state, args.first()?, value))
}
"pop-char" => {
let mut value = read_buffer(&state.view, args.first()?);
value.pop();
Some(vec![write_buffer(args.first()?, value)])
Some(typed_into(state, args.first()?, value))
}
"clear-buffer" => Some(vec![write_buffer(args.first()?, String::new())]),
@@ -573,6 +573,17 @@ pub fn read_buffer(view: &ViewState, name: &str) -> String {
}
}
/// A user's edit of buffer `name`: the write, then the open editor marked
/// touched. `AppendChar`/`PopChar`, line for line (improvise-2054).
fn typed_into(state: &ClientState, name: &str, value: String) -> Vec<Command> {
let mut commands = vec![write_buffer(name, value)];
commands.extend(
edited(state.view.opened_editor.as_ref(), name)
.map(|opened| Command::SetOpenedEditor(Some(opened))),
);
commands
}
fn write_buffer(name: &str, value: String) -> Command {
match name {
"search" => Command::SetSearchQuery(value),
@@ -772,7 +783,7 @@ fn opened_on(seed: String, mode: AppMode) -> Vec<Command> {
name: "edit".to_string(),
value: seed.clone(),
},
Command::SetOpenedEditor(Some(OpenedEditor { seed })),
Command::SetOpenedEditor(Some(OpenedEditor::on(seed))),
Command::ChangeMode(mode),
]
}
@@ -1005,12 +1016,38 @@ fn seed_at(state: &ClientState, row: usize, col: usize) -> String {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenedEditor {
pub seed: String,
/// Whether the user has edited the buffer since it opened (improvise-2054).
pub touched: bool,
}
impl OpenedEditor {
/// An editor that has just opened on `seed`, untouched.
pub fn on(seed: String) -> Self {
Self {
seed,
touched: false,
}
}
}
/// The buffer every cell editor types into.
const EDIT_BUFFER: &str = "edit";
/// Whether committing `text` is the editor's own seed back, untouched: such a
/// commit writes nothing. The **one** home, beside [`typed`] (improvise-ixxx).
pub fn untouched(opened: Option<&OpenedEditor>, text: &str) -> bool {
opened.is_some_and(|opened| opened.seed == text)
opened.is_some_and(|opened| !opened.touched && opened.seed == text)
}
/// The open editor marked touched by a user edit of `buffer`, or `None` when
/// that edit changes nothing to record (improvise-2054, `protocol.md` §3).
pub fn edited(opened: Option<&OpenedEditor>, buffer: &str) -> Option<OpenedEditor> {
opened
.filter(|opened| buffer == EDIT_BUFFER && !opened.touched)
.map(|opened| OpenedEditor {
touched: true,
..opened.clone()
})
}
/// How a typed string becomes a [`CellValue`]: the **one** home, called by the
@@ -3120,9 +3157,7 @@ mod tests {
name: "edit".into(),
value: "1200".into(),
},
Command::SetOpenedEditor(Some(OpenedEditor {
seed: "1200".into()
})),
Command::SetOpenedEditor(Some(OpenedEditor::on("1200".into()))),
Command::ChangeMode(AppMode::editing()),
])
);
@@ -3392,9 +3427,7 @@ mod tests {
"{seed}: the advance and re-entry did not run: {commands:?}"
);
assert!(
commands.contains(&Command::SetOpenedEditor(Some(OpenedEditor {
seed: "800".into()
}))),
commands.contains(&Command::SetOpenedEditor(Some(OpenedEditor::on("800".into())))),
"{seed}: the reopened editor did not capture its own seed: {commands:?}"
);
}
@@ -3417,6 +3450,83 @@ mod tests {
);
}
/// BUG (improvise-2054) — see `context/test-evidence/clients.md`.
#[test]
fn a_browser_editor_typed_back_to_its_seed_still_writes() {
let mut client = client_on_row_one("7");
press_folded(&mut client, KeyCode::Char('i'));
let mut moved = CellDisplay::number("5");
moved.id = Some(record_cell());
client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells(
vec![((1, 0), moved)],
))));
press_folded(&mut client, KeyCode::Backspace);
press_folded(&mut client, KeyCode::Char('7'));
assert_eq!(read_buffer(&client.view, "edit"), "7");
let commands = press_folded(&mut client, KeyCode::Enter);
assert!(
commands.iter().any(|c| matches!(
c,
Command::Model {
cmd: ModelCommand::SetCell {
value: CellValue::Number(7.0),
..
},
..
}
)),
"a value the user typed was dropped because it spelled the seed: {commands:?}"
);
}
/// BUG (improvise-wv3h) — see `context/test-evidence/clients.md`.
#[test]
fn a_browser_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed() {
let mut client = client_on_row_one("");
press_folded(&mut client, KeyCode::Char('i'));
press_folded(&mut client, KeyCode::Esc);
assert!(!client.view.mode.is_editing(), "Esc did not close the editor");
let mut moved = CellDisplay::number("9");
moved.id = Some(record_cell());
client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells(
vec![((1, 0), moved)],
))));
let entered = composed(&client, ev(KeyCode::Enter), "enter-mode", &["editing".into()])
.expect("enter-mode editing composes here");
for command in &entered {
client.fold(command);
}
assert!(client.view.mode.is_editing(), "enter-mode did not open an editor");
let commands = press_folded(&mut client, KeyCode::Enter);
assert!(
commands.iter().any(|c| matches!(
c,
Command::Model {
cmd: ModelCommand::ClearCell(_),
..
}
)),
"the empty commit was skipped against the earlier editor's seed: {commands:?}"
);
}
/// `untouched` is exact: whitespace is content, and an edited editor is
/// never untouched even when it spells its seed (improvise-wv3h, 2054).
#[test]
fn untouched_is_exact_and_honours_an_edit() {
let fresh = |seed: &str| OpenedEditor::on(seed.into());
assert!(untouched(Some(&fresh("7")), "7"));
for (seed, text) in [("7", "7 "), ("7", " 7"), (" 7", "7"), ("a", "A")] {
assert!(!untouched(Some(&fresh(seed)), text), "{seed:?} vs {text:?}");
}
let edited = OpenedEditor {
touched: true,
..fresh("7")
};
assert!(!untouched(Some(&edited), "7"));
assert!(!untouched(None, ""));
}
/// A typed change is still a write, and `007` typed fresh is still a
/// number: classification is `typed()`'s and out of this ticket's scope.
#[test]
+2 -4
View File
@@ -108,7 +108,7 @@ impl Client {
text: &str,
seed: String,
) -> Result<Option<String>, JsError> {
self.typed_cell_frame(row, col, text, &improvise_protocol::OpenedEditor { seed })
self.typed_cell_frame(row, col, text, &improvise_protocol::OpenedEditor::on(seed))
.map(|f| frame_json(&f))
.transpose()
.map_err(|e| JsError::new(&e.to_string()))
@@ -262,9 +262,7 @@ mod tests {
0,
0,
"42",
&improvise_protocol::OpenedEditor {
seed: "1200".into()
}
&improvise_protocol::OpenedEditor::on("1200".into())
),
None
);
+10 -8
View File
@@ -256,9 +256,7 @@ mod tests {
/// What the page's editor captured opening on `client_showing`'s cell.
fn seed() -> OpenedEditor {
OpenedEditor {
seed: "1200".into(),
}
OpenedEditor::on("1200".into())
}
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
@@ -266,24 +264,28 @@ mod tests {
fn a_mouse_commit_of_the_untouched_seed_composes_nothing() {
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
for seed in ["007", "ERR:boom", "1.23456789"] {
let opened = OpenedEditor { seed: seed.into() };
let opened = OpenedEditor::on(seed.into());
assert_eq!(client.typed_cell_frame(0, 0, seed, &opened), None, "{seed}");
}
assert!(
client
.typed_cell_frame(0, 0, "007", &OpenedEditor { seed: "7".into() })
.typed_cell_frame(0, 0, "007", &OpenedEditor::on("7".into()))
.is_some(),
"a commit that differs from its seed is still a write"
);
assert!(
client
.typed_cell_frame(0, 0, "7 ", &OpenedEditor::on("7".into()))
.is_some(),
"whitespace is content: a trailing space is an edit (improvise-wv3h)"
);
}
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
#[test]
fn a_mouse_commit_is_judged_against_the_seed_it_opened_with() {
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
let opened = OpenedEditor {
seed: "1200".into(),
};
let opened = OpenedEditor::on("1200".into());
let mut moved = CellDisplay::number("5");
moved.id = Some(CellId::Record(record("Revenue")));
client.fold_frame(ServerFrame::Commands(vec![Command::Project(