Undo/dirty correctness from the stress-testing review
- Redoing an insert and undoing a delete put the cursor at col + size on the start row even when the text spans lines, i.e. past the end of the line. Later edits and their undo records then went to the wrong place (a join recorded at column 2 of 'a' re-split the text wrongly) and delete-word-prev read past the line (heap-buffer-overflow under ASan). The cursor now goes after the text's last newline, and undo and redo clamp it to the buffer. - A recovered buffer stays modified in every undo state (mark_base_unsaved): one edit and its undo made it look clean, and closing it then discarded the recovered text and its journal. - Save-and-quit records the saved undo state. - Visual-line selections are clamped to the buffer when read; the visual newline counts the cursor's own line only if it was selected. A stale selection put the cursor rows past the end, where typed text could not be undone. - The yank size cap accounts for visual-line yanks (once per line). - After an exception escapes a command, open undo groups are closed so later edits do not join them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
@@ -1026,3 +1026,87 @@ TEST(Audit_Yank_HugeRepeatRefused)
|
||||
ASSERT_EQ(h.Buf().Nrows(), (std::size_t) 1);
|
||||
ASSERT_EQ(h.Line(0), std::string());
|
||||
}
|
||||
|
||||
|
||||
// Undoing a multi-line kill puts the cursor after the restored text (it was
|
||||
// placed at col + size on the start row, past the end of the line, so later
|
||||
// edits and their undo went to the wrong place).
|
||||
TEST(Audit_Undo_MultiLineRestore_CursorAfterText)
|
||||
{
|
||||
TestHarness h;
|
||||
Buffer &b = h.Buf();
|
||||
b.insert_text(0, 0, "a\nb\n");
|
||||
b.SetCursor(0, 0);
|
||||
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||
ASSERT_TRUE(h.Exec(CommandId::MoveDown));
|
||||
ASSERT_TRUE(h.Exec(CommandId::KillRegion));
|
||||
ASSERT_EQ(h.Text(), std::string("b\n"));
|
||||
ASSERT_TRUE(h.Undo());
|
||||
ASSERT_EQ(h.Text(), std::string("a\nb\n"));
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 1);
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 0);
|
||||
ASSERT_TRUE(h.Exec(CommandId::KillLine));
|
||||
ASSERT_TRUE(h.Undo());
|
||||
ASSERT_EQ(h.Text(), std::string("a\nb\n"));
|
||||
}
|
||||
|
||||
|
||||
// Same shape that overflowed the heap in delete-word-prev: after the undo
|
||||
// the cursor is within its line.
|
||||
TEST(Audit_Undo_ThenDeleteWordPrev_NoOverflow)
|
||||
{
|
||||
TestHarness h;
|
||||
Buffer &b = h.Buf();
|
||||
b.insert_text(0, 0, std::string(24, 'a') + "\n" + std::string(30, 'b'));
|
||||
b.SetCursor(0, 0);
|
||||
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||
ASSERT_TRUE(h.Exec(CommandId::MoveDown));
|
||||
ASSERT_TRUE(h.Exec(CommandId::MoveEnd));
|
||||
ASSERT_TRUE(h.Exec(CommandId::KillRegion));
|
||||
ASSERT_TRUE(h.Undo());
|
||||
ASSERT_TRUE(b.Curx() <= h.Line(b.Cury()).size());
|
||||
ASSERT_TRUE(h.Exec(CommandId::DeleteWordPrev));
|
||||
}
|
||||
|
||||
|
||||
// Save-and-quit records the saved state, so undo afterwards marks the buffer
|
||||
// dirty again (it differs from the file).
|
||||
TEST(Audit_SaveAndQuit_MarksSaved)
|
||||
{
|
||||
TempDir d("saq_marksaved");
|
||||
std::ofstream(d.path / "a.txt") << "aaa\n";
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
h.Buf().insert_text(0, 0, "dirty other");
|
||||
h.Buf().SetDirty(true);
|
||||
std::string err;
|
||||
ASSERT_TRUE(ed.OpenFile((d.path / "a.txt").string(), err));
|
||||
ASSERT_TRUE(h.Exec(CommandId::InsertText, "X"));
|
||||
ASSERT_TRUE(h.Exec(CommandId::SaveAndQuit));
|
||||
ASSERT_TRUE(!ed.QuitRequested()); // other buffer still dirty
|
||||
ASSERT_EQ(slurp(d.path / "a.txt"), std::string("Xaaa\n"));
|
||||
ASSERT_TRUE(!ed.CurrentBuffer()->Dirty());
|
||||
ASSERT_TRUE(h.Undo());
|
||||
ASSERT_TRUE(ed.CurrentBuffer()->Dirty());
|
||||
}
|
||||
|
||||
|
||||
// Visual-line newline with a stale selection never puts the cursor past the
|
||||
// buffer, so later typing is undoable.
|
||||
TEST(Audit_VisualLine_StaysInsideBuffer)
|
||||
{
|
||||
TestHarness h;
|
||||
Buffer &b = h.Buf();
|
||||
b.insert_text(0, 0, "one\ntwo\nthree\n");
|
||||
b.SetCursor(0, 0);
|
||||
const std::string orig = h.Text();
|
||||
ASSERT_TRUE(h.Exec(CommandId::VisualLineModeToggle));
|
||||
ASSERT_TRUE(h.Exec(CommandId::MarkAllAndJumpEnd));
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
ASSERT_TRUE(h.Exec(CommandId::VisualLineModeToggle));
|
||||
ASSERT_TRUE(b.Cury() < b.Nrows());
|
||||
ASSERT_TRUE(h.Exec(CommandId::InsertText, "Z"));
|
||||
for (int i = 0; i < 10; ++i)
|
||||
(void) h.Undo();
|
||||
ASSERT_EQ(h.Text(), orig);
|
||||
}
|
||||
|
||||
@@ -679,3 +679,29 @@ TEST(SwapRecoveryPrompt_TouchedFile_StillRecoverable)
|
||||
answer(ed, "y");
|
||||
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), expected);
|
||||
}
|
||||
|
||||
|
||||
// A recovered buffer stays modified even after an edit and its undo: its
|
||||
// content differs from the file on disk in every undo state.
|
||||
TEST(SwapRecoveryPrompt_Recovered_StaysDirtyAfterUndo)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
XdgSandbox sb("recovered_dirty");
|
||||
const std::string file = (sb.root / "work" / "rd.txt").string();
|
||||
write_file_bytes(file, "base\n");
|
||||
std::string expected;
|
||||
(void) make_journal(file, [](Buffer &b) {
|
||||
b.insert_text(0, 0, std::string("IMPORTANT "));
|
||||
}, expected);
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
ed.AddBuffer(Buffer());
|
||||
ed.RequestOpenFile(file);
|
||||
(void) ed.ProcessPendingOpens();
|
||||
answer(ed, "y");
|
||||
ASSERT_TRUE(ed.CurrentBuffer()->Dirty());
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "a"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_TRUE(ed.CurrentBuffer()->Dirty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user