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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user