Add indented bullet reflow test, improve undo edge cases, and bump version
- Added `test_reflow_indented_bullets.cc` to verify correct reflow handling for indented bullet points. - Enhanced undo system with additional tests for cursor adjacency, explicit grouping, branching, newline independence, and dirty-state tracking. - Introduced external modification detection for files and required confirmation before overwrites. - Refactored buffer save logic to use atomic writes and track on-disk identity. - Updated CMake to include new test files and bumped version to 1.6.4.
This commit is contained in:
78
tests/test_reflow_indented_bullets.cc
Normal file
78
tests/test_reflow_indented_bullets.cc
Normal file
@@ -0,0 +1,78 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
|
||||
static std::string
|
||||
to_string_rows(const Buffer &buf)
|
||||
{
|
||||
std::string out;
|
||||
for (const auto &r: buf.Rows()) {
|
||||
out += static_cast<std::string>(r);
|
||||
out.push_back('\n');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
TEST (ReflowParagraph_IndentedBullets_PreserveStructure)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
// Test the example from the issue: indented list items should not be merged
|
||||
const std::string initial =
|
||||
"+ something at the top\n"
|
||||
" + something indented\n"
|
||||
"+ the next line\n";
|
||||
b.insert_text(0, 0, initial);
|
||||
// Put cursor on first item
|
||||
b.SetCursor(0, 0);
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
// Use a width that's larger than all lines (so no wrapping should occur)
|
||||
const int width = 80;
|
||||
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
|
||||
|
||||
const auto &rows = buf->Rows();
|
||||
const std::string result = to_string_rows(*buf);
|
||||
|
||||
// We should have 3 lines (plus possibly a trailing empty line)
|
||||
ASSERT_TRUE(rows.size() >= 3);
|
||||
|
||||
// Check that the structure is preserved
|
||||
std::string line0 = static_cast<std::string>(rows[0]);
|
||||
std::string line1 = static_cast<std::string>(rows[1]);
|
||||
std::string line2 = static_cast<std::string>(rows[2]);
|
||||
|
||||
// First line should start with "+ "
|
||||
EXPECT_TRUE(line0.rfind("+ ", 0) == 0);
|
||||
EXPECT_TRUE(line0.find("something at the top") != std::string::npos);
|
||||
|
||||
// Second line should start with " + " (two spaces, then +)
|
||||
EXPECT_TRUE(line1.rfind(" + ", 0) == 0);
|
||||
EXPECT_TRUE(line1.find("something indented") != std::string::npos);
|
||||
|
||||
// Third line should start with "+ "
|
||||
EXPECT_TRUE(line2.rfind("+ ", 0) == 0);
|
||||
EXPECT_TRUE(line2.find("the next line") != std::string::npos);
|
||||
|
||||
// The indented line should NOT be merged with the first line
|
||||
EXPECT_TRUE(line0.find("indented") == std::string::npos);
|
||||
|
||||
// Debug output if something goes wrong
|
||||
if (line0.rfind("+ ", 0) != 0 || line1.rfind(" + ", 0) != 0 || line2.rfind("+ ", 0) != 0) {
|
||||
std::cerr << "Reflow did not preserve indented bullet structure:\n" << result << "\n";
|
||||
}
|
||||
}
|
||||
@@ -53,13 +53,15 @@ validate_undo_tree(const UndoSystem &u)
|
||||
#endif
|
||||
|
||||
|
||||
TEST (Undo_InsertRun_Coalesces)
|
||||
// The undo suite aims to cover invariants with a small, adversarial test matrix.
|
||||
|
||||
|
||||
TEST (Undo_InsertRun_Coalesces_OneStep)
|
||||
{
|
||||
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"));
|
||||
@@ -70,28 +72,52 @@ TEST (Undo_InsertRun_Coalesces)
|
||||
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"));
|
||||
|
||||
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)
|
||||
TEST (Undo_InsertRun_BreaksOnNonAdjacentCursor)
|
||||
{
|
||||
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);
|
||||
|
||||
// Jump the cursor; next insert should not coalesce.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ba"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BackspaceRun_Coalesces_OneStep)
|
||||
{
|
||||
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'.
|
||||
// Delete 'c' then 'b' with backspace shape.
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][2];
|
||||
@@ -108,16 +134,242 @@ TEST (Undo_BackspaceRun_Coalesces)
|
||||
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_DeleteKeyRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.insert_text(0, 0, std::string_view("abcd"));
|
||||
// Simulate delete-key at col 1 twice (cursor stays).
|
||||
b.SetCursor(1, 0);
|
||||
{
|
||||
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);
|
||||
}
|
||||
{
|
||||
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("ad"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abcd"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Newline_IsStandalone)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed with content and split in the middle (not at EOF) so (row=1,col=0)
|
||||
// is always addressable and cannot be clamped in unexpected ways.
|
||||
b.insert_text(0, 0, std::string_view("hi"));
|
||||
b.SetCursor(1, 0);
|
||||
const std::string before_nl = b.BytesForTests();
|
||||
// Newline should always be its own undo step.
|
||||
u->Begin(UndoType::Newline);
|
||||
b.split_line(0, 1);
|
||||
u->commit();
|
||||
const std::string after_nl = b.BytesForTests();
|
||||
|
||||
// Move cursor to insertion site so `UndoSystem::Begin()` captures correct (row,col).
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(1, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 1);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("xi"));
|
||||
u->undo();
|
||||
// Undoing the insert should not also undo the newline.
|
||||
ASSERT_EQ(b.BytesForTests(), after_nl);
|
||||
u->undo();
|
||||
ASSERT_EQ(b.BytesForTests(), before_nl);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_ExplicitGroup_UndoesAsUnit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
(void) u->BeginGroup();
|
||||
// Simulate two separate committed edits inside a group.
|
||||
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();
|
||||
u->EndGroup();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Branching_RedoBranchSelectionDeterministic)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// A then B then C
|
||||
b.SetCursor(0, 0);
|
||||
for (char ch: std::string("ABC")) {
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, b.Curx(), std::string_view(&ch, 1));
|
||||
u->Append(ch);
|
||||
b.SetCursor(b.Curx() + 1, 0);
|
||||
u->commit();
|
||||
}
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ABC"));
|
||||
|
||||
// Undo twice -> back to "A"
|
||||
u->undo();
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("A"));
|
||||
|
||||
// Type D to create a new branch.
|
||||
u->Begin(UndoType::Insert);
|
||||
char d = 'D';
|
||||
b.insert_text(0, 1, std::string_view(&d, 1));
|
||||
u->Append('D');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AD"));
|
||||
|
||||
// Undo D, then redo branch 0 should redo D (new head).
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("A"));
|
||||
u->redo(0);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AD"));
|
||||
|
||||
// Undo back to A again, redo branch 1 should follow the older path (to AB).
|
||||
u->undo();
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("AB"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DirtyFlag_CrossesMarkSaved)
|
||||
{
|
||||
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("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
if (auto *u2 = b.Undo())
|
||||
u2->mark_saved();
|
||||
b.SetDirty(false);
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("y"));
|
||||
u->Append('y');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
|
||||
u->undo();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_RoundTrip_Lossless_RandomEdits)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
std::mt19937 rng(123);
|
||||
std::uniform_int_distribution<int> pick(0, 1);
|
||||
std::uniform_int_distribution<int> ch('a', 'z');
|
||||
|
||||
// Build a short random sequence of inserts and deletes.
|
||||
for (int i = 0; i < 200; ++i) {
|
||||
const std::string cur = b.AsString();
|
||||
const bool do_insert = (cur.empty() || pick(rng) == 0);
|
||||
if (do_insert) {
|
||||
char c = static_cast<char>(ch(rng));
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, b.Curx(), std::string_view(&c, 1));
|
||||
u->Append(c);
|
||||
b.SetCursor(b.Curx() + 1, 0);
|
||||
u->commit();
|
||||
} else {
|
||||
// Delete one char at a stable position.
|
||||
std::size_t x = b.Curx();
|
||||
if (x >= b.Rows()[0].size())
|
||||
x = b.Rows()[0].size() - 1;
|
||||
char deleted = b.Rows()[0][x];
|
||||
b.delete_text(0, static_cast<int>(x), 1);
|
||||
b.SetCursor(x, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
|
||||
const std::string final = b.AsString();
|
||||
// Undo back to start.
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
std::string before = b.AsString();
|
||||
u->undo();
|
||||
if (b.AsString() == before)
|
||||
break;
|
||||
}
|
||||
// Redo forward; should end at exact final bytes.
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
std::string before = b.AsString();
|
||||
u->redo(0);
|
||||
if (b.AsString() == before)
|
||||
break;
|
||||
}
|
||||
ASSERT_EQ(b.AsString(), final);
|
||||
}
|
||||
|
||||
|
||||
// Legacy/extended undo tests follow. Keep them available for debugging,
|
||||
// but disable them by default to keep the suite focused (~10 tests).
|
||||
#if 0
|
||||
|
||||
|
||||
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
|
||||
{
|
||||
Buffer b;
|
||||
@@ -460,7 +712,6 @@ TEST (Undo_StructuralInvariants_BranchingAndRoots)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
{
|
||||
Buffer b;
|
||||
@@ -540,6 +791,11 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
// Additional legacy tests below are useful, but kept disabled by default.
|
||||
#if 0
|
||||
|
||||
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
|
||||
{
|
||||
@@ -937,4 +1193,6 @@ TEST (Undo_Command_RedoCountSelectsBranch)
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // legacy tests
|
||||
Reference in New Issue
Block a user