Add undo system with coalescing logic and comprehensive tests.
- Implemented robust undo system supporting coalescing of text operations (insert, backspace, delete). - Added `UndoSystem` integration into the editor/commands pipeline. - Wrote extensive unit tests for various undo/redo scenarios, including multiline operations, cursor preservation, and history management. - Refactored to ensure consistent cursor behavior during undo/redo actions. - Updated CMake to include new tests.
This commit is contained in:
343
tests/test_undo.cc
Normal file
343
tests/test_undo.cc
Normal file
@@ -0,0 +1,343 @@
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
|
||||
|
||||
TEST (Undo_InsertRun_Coalesces)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Simulate two separate "typed" insert commands without committing in between.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("h"));
|
||||
u->Append('h');
|
||||
b.SetCursor(1, 0);
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("i"));
|
||||
u->Append('i');
|
||||
b.SetCursor(2, 0);
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BackspaceRun_Coalesces)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed content.
|
||||
b.insert_text(0, 0, std::string_view("abc"));
|
||||
b.SetCursor(3, 0);
|
||||
u->mark_saved();
|
||||
|
||||
// Simulate two backspaces: delete 'c' then 'b'.
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][2];
|
||||
b.delete_text(0, 2, 1);
|
||||
b.SetCursor(2, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
b.delete_text(0, 1, 1);
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// One undo should restore both characters.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abc"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Linear_RedoDiscardedAfterNewEdit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// New edit after undo should discard redo.
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("c"));
|
||||
u->Append('c');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DirtyFlag_MarkSavedAndUndoRedo)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
u->mark_saved();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
u->undo();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
u->redo();
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Newline_UndoRedo_SplitJoin)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed a single line and split it.
|
||||
b.insert_text(0, 0, std::string_view("hello"));
|
||||
b.SetCursor(2, 0); // split after "he"
|
||||
u->Begin(UndoType::Newline);
|
||||
b.split_line(0, 2);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("he"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("llo"));
|
||||
|
||||
// Undo should join the lines back.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hello"));
|
||||
|
||||
// Redo should split again at the same point.
|
||||
u->redo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("he"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("llo"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DeleteKeyRun_Coalesces)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed content: delete-key semantics keep cursor at the same column.
|
||||
b.insert_text(0, 0, std::string_view("abcd"));
|
||||
b.SetCursor(1, 0); // on 'b'
|
||||
|
||||
// Delete 'b'
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
u->Begin(UndoType::Delete);
|
||||
b.delete_text(0, 1, 1);
|
||||
u->Append(deleted);
|
||||
b.SetCursor(1, 0);
|
||||
}
|
||||
// Delete next char (was 'c', now at same col=1)
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
u->Begin(UndoType::Delete);
|
||||
b.delete_text(0, 1, 1);
|
||||
u->Append(deleted);
|
||||
b.SetCursor(1, 0);
|
||||
}
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
|
||||
|
||||
// One undo should restore both deleted characters.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abcd"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_UndoPastFirstEdit_RedoFromPreFirstEdit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Commit two separate insert edits.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// Undo twice: we should reach the pre-first-edit state.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
|
||||
// Redo twice should restore both edits.
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_NewEditFromPreFirstEdit_DiscardsOldHistory)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Build up two edits.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// Undo past first edit so current becomes null.
|
||||
u->undo();
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
|
||||
// Commit a new edit from the pre-first-edit state.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
|
||||
|
||||
// Old history should be gone: redo should not resurrect "ab".
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_MultiLineDelete_ConsumesNewline_UndoRestores)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Create two lines. PieceTable treats '\n' between logical lines.
|
||||
b.insert_text(0, 0, std::string_view("ab\ncd"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("cd"));
|
||||
|
||||
// Delete spanning the newline: delete "b\n" starting at (0,1).
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
b.delete_text(0, 1, 2);
|
||||
u->Append(std::string_view("b\n"));
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("acd"));
|
||||
|
||||
// Undo should restore exact original text/line structure.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("cd"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DeleteIndent_UndoRestoresCursorAtText)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed 3-line content with indentation on the middle line.
|
||||
b.insert_text(0, 0,
|
||||
std::string_view("I did a thing\n and then I edited a thing\nbut there were gaps"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
|
||||
// Cursor at start of the line (before spaces), then C-d C-d deletes two spaces.
|
||||
b.SetCursor(0, 1);
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[1][0];
|
||||
ASSERT_EQ(deleted, ' ');
|
||||
u->Begin(UndoType::Delete);
|
||||
b.delete_text(1, 0, 1);
|
||||
u->Append(deleted);
|
||||
b.SetCursor(0, 1); // delete-key keeps col the same
|
||||
}
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("and then I edited a thing"));
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 1);
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 0);
|
||||
|
||||
// Undo should restore indentation, and keep cursor on the text (at 'a'), not at EOL.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string(" and then I edited a thing"));
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 1);
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||
}
|
||||
Reference in New Issue
Block a user