Add swap journaling and group undo/redo with extensive tests.

- Introduced SwapManager for sidecar journaling of buffer mutations, with a safe recovery mechanism.
- Added group undo/redo functionality, allowing atomic grouping of related edits.
- Implemented `SwapRecorder` and integrated it as a callback interface for mutations.
- Added unit tests for swap journaling (save/load/replay) and undo grouping.
- Refactored undo to support group tracking and ID management.
- Updated CMake to include the new tests and swap journaling logic.
This commit is contained in:
2026-02-11 20:47:18 -08:00
parent 15b350bfaa
commit 895e4ccb1e
27 changed files with 2419 additions and 290 deletions

142
tests/test_buffer_rows.cc Normal file
View File

@@ -0,0 +1,142 @@
#include "Test.h"
#include "Buffer.h"
#include <algorithm>
#include <limits>
#include <string>
#include <vector>
static std::vector<std::string>
split_lines_preserve_trailing_empty(const std::string &s)
{
std::vector<std::string> out;
std::size_t start = 0;
for (std::size_t i = 0; i <= s.size(); i++) {
if (i == s.size() || s[i] == '\n') {
out.push_back(s.substr(start, i - start));
start = i + 1;
}
}
if (out.empty())
out.push_back(std::string());
return out;
}
static std::vector<std::size_t>
line_starts_for(const std::string &s)
{
std::vector<std::size_t> starts;
starts.push_back(0);
for (std::size_t i = 0; i < s.size(); i++) {
if (s[i] == '\n')
starts.push_back(i + 1);
}
return starts;
}
static std::size_t
ref_linecol_to_offset(const std::string &s, std::size_t row, std::size_t col)
{
auto starts = line_starts_for(s);
if (starts.empty())
return 0;
if (row >= starts.size())
return s.size();
std::size_t start = starts[row];
std::size_t end = (row + 1 < starts.size()) ? starts[row + 1] : s.size();
if (end > start && s[end - 1] == '\n')
end -= 1; // clamp before trailing newline
return start + std::min(col, end - start);
}
static void
check_buffer_matches_model(const Buffer &b, const std::string &model)
{
auto expected_lines = split_lines_preserve_trailing_empty(model);
const auto &rows = b.Rows();
ASSERT_EQ(rows.size(), expected_lines.size());
ASSERT_EQ(b.Nrows(), rows.size());
auto starts = line_starts_for(model);
ASSERT_EQ(starts.size(), expected_lines.size());
std::string via_views;
for (std::size_t i = 0; i < rows.size(); i++) {
ASSERT_EQ(std::string(rows[i]), expected_lines[i]);
ASSERT_EQ(b.GetLineString(i), expected_lines[i]);
std::size_t exp_start = starts[i];
std::size_t exp_end = (i + 1 < starts.size()) ? starts[i + 1] : model.size();
auto r = b.GetLineRange(i);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
auto v = b.GetLineView(i);
ASSERT_EQ(std::string(v), model.substr(exp_start, exp_end - exp_start));
via_views.append(v.data(), v.size());
}
ASSERT_EQ(via_views, model);
}
TEST (Buffer_RowsCache_MultiLineEdits_StayConsistent)
{
Buffer b;
std::string model;
check_buffer_matches_model(b, model);
// Insert text and newlines in a few different ways.
b.insert_text(0, 0, std::string("abc"));
model.insert(0, "abc");
check_buffer_matches_model(b, model);
b.split_line(0, 1); // a\nbc
model.insert(ref_linecol_to_offset(model, 0, 1), "\n");
check_buffer_matches_model(b, model);
b.insert_text(1, 2, std::string("X")); // a\nbcX
model.insert(ref_linecol_to_offset(model, 1, 2), "X");
check_buffer_matches_model(b, model);
b.join_lines(0); // abcX
{
std::size_t off = ref_linecol_to_offset(model, 0, std::numeric_limits<std::size_t>::max());
if (off < model.size() && model[off] == '\n')
model.erase(off, 1);
}
check_buffer_matches_model(b, model);
// Insert a multi-line segment in one shot.
b.insert_text(0, 2, std::string("\n123\nxyz"));
model.insert(ref_linecol_to_offset(model, 0, 2), "\n123\nxyz");
check_buffer_matches_model(b, model);
// Delete spanning across a newline.
b.delete_text(0, 1, 5);
{
std::size_t start = ref_linecol_to_offset(model, 0, 1);
std::size_t actual = std::min<std::size_t>(5, model.size() - start);
model.erase(start, actual);
}
check_buffer_matches_model(b, model);
// Insert/delete whole rows.
b.insert_row(1, std::string_view("ROW"));
model.insert(ref_linecol_to_offset(model, 1, 0), "ROW\n");
check_buffer_matches_model(b, model);
b.delete_row(1);
{
auto starts = line_starts_for(model);
if (1 < (int) starts.size()) {
std::size_t start = starts[1];
std::size_t end = (2 < starts.size()) ? starts[2] : model.size();
model.erase(start, end - start);
}
}
check_buffer_matches_model(b, model);
}

View File

@@ -0,0 +1,91 @@
#include "Test.h"
#include "TestHarness.h"
using ktet::TestHarness;
TEST (CommandSemantics_KillToEOL_KillChain_And_Yank)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, std::string("abc\ndef"));
b.SetCursor(1, 0); // a|bc
ed.KillRingClear();
ed.SetKillChain(false);
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
ASSERT_EQ(h.Text(), std::string("a\ndef"));
ASSERT_EQ(ed.KillRingHead(), std::string("bc"));
// At EOL, KillToEOL kills the newline (join).
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
ASSERT_EQ(h.Text(), std::string("adef"));
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
// Yank pastes the kill ring head and breaks the kill chain.
ASSERT_TRUE(h.Exec(CommandId::Yank));
ASSERT_EQ(h.Text(), std::string("abc\ndef"));
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
ASSERT_EQ(ed.KillChain(), false);
}
TEST (CommandSemantics_ToggleMark_JumpToMark)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, std::string("hello"));
b.SetCursor(2, 0);
ASSERT_EQ(b.MarkSet(), false);
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
ASSERT_EQ(b.MarkSet(), true);
ASSERT_EQ(b.MarkCurx(), (std::size_t) 2);
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
b.SetCursor(4, 0);
ASSERT_TRUE(h.Exec(CommandId::JumpToMark));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
ASSERT_EQ(b.Cury(), (std::size_t) 0);
// Jump-to-mark swaps: mark becomes previous cursor.
ASSERT_EQ(b.MarkSet(), true);
ASSERT_EQ(b.MarkCurx(), (std::size_t) 4);
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
}
TEST (CommandSemantics_CopyRegion_And_KillRegion)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, std::string("hello world"));
b.SetCursor(0, 0);
ed.KillRingClear();
ed.SetKillChain(false);
// Copy "hello" (region [0,5)).
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
b.SetCursor(5, 0);
ASSERT_TRUE(h.Exec(CommandId::CopyRegion));
ASSERT_EQ(ed.KillRingHead(), std::string("hello"));
ASSERT_EQ(b.MarkSet(), false);
ASSERT_EQ(h.Text(), std::string("hello world"));
// Kill "world" (region [6,11)).
ed.SetKillChain(false);
b.SetCursor(6, 0);
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
b.SetCursor(11, 0);
ASSERT_TRUE(h.Exec(CommandId::KillRegion));
ASSERT_EQ(ed.KillRingHead(), std::string("world"));
ASSERT_EQ(b.MarkSet(), false);
ASSERT_EQ(h.Text(), std::string("hello "));
}

View File

@@ -0,0 +1,170 @@
#include "Test.h"
#include "Command.h"
#include "Editor.h"
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), (std::streamsize) bytes.size());
}
static std::string
read_file_bytes(const std::string &path)
{
std::ifstream in(path, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
static std::string
buffer_bytes_via_views(const Buffer &b)
{
const auto &rows = b.Rows();
std::string out;
for (std::size_t i = 0; i < rows.size(); i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
TEST (DailyWorkflow_OpenEditSave_Transcript)
{
ktet::InstallDefaultCommandsOnce();
const std::string path = "./.kte_ut_daily_open_edit_save.txt";
std::remove(path.c_str());
write_file_bytes(path, "one\n");
const std::string npath = std::filesystem::canonical(path).string();
Editor ed;
ed.SetDimensions(24, 80);
// Seed an empty buffer so OpenFile can reuse it.
{
Buffer scratch;
ed.AddBuffer(std::move(scratch));
}
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(ed.CurrentBuffer()->Filename(), npath);
// Append two new lines via commands (no UI).
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "two"));
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "three"));
ASSERT_TRUE(Execute(ed, CommandId::Save));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(read_file_bytes(npath), buffer_bytes_via_views(*ed.CurrentBuffer()));
std::remove(path.c_str());
std::remove(npath.c_str());
}
TEST (DailyWorkflow_MultiBufferSwitchClose_Transcript)
{
ktet::InstallDefaultCommandsOnce();
const std::string p1 = "./.kte_ut_daily_buf_1.txt";
const std::string p2 = "./.kte_ut_daily_buf_2.txt";
std::remove(p1.c_str());
std::remove(p2.c_str());
write_file_bytes(p1, "aaa\n");
write_file_bytes(p2, "bbb\n");
const std::string np1 = std::filesystem::canonical(p1).string();
const std::string np2 = std::filesystem::canonical(p2).string();
Editor ed;
ed.SetDimensions(24, 80);
{
Buffer scratch;
ed.AddBuffer(std::move(scratch));
}
std::string err;
ASSERT_TRUE(ed.OpenFile(p1, err));
ASSERT_TRUE(ed.OpenFile(p2, err));
ASSERT_EQ(ed.BufferCount(), (std::size_t) 2);
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
// Switch back and forth.
ASSERT_TRUE(Execute(ed, CommandId::BufferPrev));
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
ASSERT_TRUE(Execute(ed, CommandId::BufferNext));
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
// Close current buffer (p2); ensure we land on p1.
ASSERT_TRUE(Execute(ed, CommandId::BufferClose));
ASSERT_EQ(ed.BufferCount(), (std::size_t) 1);
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
std::remove(p1.c_str());
std::remove(p2.c_str());
std::remove(np1.c_str());
std::remove(np2.c_str());
}
TEST (DailyWorkflow_CrashRecovery_SwapReplay_Transcript)
{
ktet::InstallDefaultCommandsOnce();
const std::string path = "./.kte_ut_daily_swap_recover.txt";
std::remove(path.c_str());
write_file_bytes(path, "base\nline2\n");
Editor ed;
ed.SetDimensions(24, 80);
{
Buffer scratch;
ed.AddBuffer(std::move(scratch));
}
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
// Make unsaved edits through command execution.
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
ASSERT_TRUE(Execute(ed, CommandId::MoveDown));
ASSERT_TRUE(Execute(ed, CommandId::MoveHome));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "ZZ"));
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "TAIL"));
// Ensure journal is durable and capture expected bytes.
ed.Swap()->Flush(buf);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(*buf);
const std::string expected = buffer_bytes_via_views(*buf);
// "Crash": reopen from disk (original file content) into a fresh Buffer and replay.
Buffer recovered;
ASSERT_TRUE(recovered.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(recovered, swap_path, err));
ASSERT_EQ(buffer_bytes_via_views(recovered), expected);
// Cleanup.
ed.Swap()->Detach(buf);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}

84
tests/test_kkeymap.cc Normal file
View File

@@ -0,0 +1,84 @@
#include "Test.h"
#include "KKeymap.h"
#include <ncurses.h>
TEST (KKeymap_KPrefix_CanonicalChords)
{
CommandId id{};
// From docs/ke.md (K-commands)
ASSERT_TRUE(KLookupKCommand('s', false, id));
ASSERT_EQ(id, CommandId::Save);
ASSERT_TRUE(KLookupKCommand('s', true, id)); // C-k C-s
ASSERT_EQ(id, CommandId::Save);
ASSERT_TRUE(KLookupKCommand('d', false, id));
ASSERT_EQ(id, CommandId::KillToEOL);
ASSERT_TRUE(KLookupKCommand('d', true, id)); // C-k C-d
ASSERT_EQ(id, CommandId::KillLine);
ASSERT_TRUE(KLookupKCommand(' ', false, id)); // C-k SPACE
ASSERT_EQ(id, CommandId::ToggleMark);
ASSERT_TRUE(KLookupKCommand('j', false, id));
ASSERT_EQ(id, CommandId::JumpToMark);
ASSERT_TRUE(KLookupKCommand('f', false, id));
ASSERT_EQ(id, CommandId::FlushKillRing);
ASSERT_TRUE(KLookupKCommand('y', false, id));
ASSERT_EQ(id, CommandId::Yank);
// Unknown should not map
ASSERT_EQ(KLookupKCommand('Z', false, id), false);
}
TEST (KKeymap_CtrlChords_CanonicalChords)
{
CommandId id{};
// From docs/ke.md (other keybindings)
ASSERT_TRUE(KLookupCtrlCommand('n', id));
ASSERT_EQ(id, CommandId::MoveDown);
ASSERT_TRUE(KLookupCtrlCommand('p', id));
ASSERT_EQ(id, CommandId::MoveUp);
ASSERT_TRUE(KLookupCtrlCommand('f', id));
ASSERT_EQ(id, CommandId::MoveRight);
ASSERT_TRUE(KLookupCtrlCommand('b', id));
ASSERT_EQ(id, CommandId::MoveLeft);
ASSERT_TRUE(KLookupCtrlCommand('w', id));
ASSERT_EQ(id, CommandId::KillRegion);
ASSERT_TRUE(KLookupCtrlCommand('y', id));
ASSERT_EQ(id, CommandId::Yank);
ASSERT_EQ(KLookupCtrlCommand('z', id), false);
}
TEST (KKeymap_EscChords_CanonicalChords)
{
CommandId id{};
// From docs/ke.md (ESC bindings)
ASSERT_TRUE(KLookupEscCommand('b', id));
ASSERT_EQ(id, CommandId::WordPrev);
ASSERT_TRUE(KLookupEscCommand('f', id));
ASSERT_EQ(id, CommandId::WordNext);
ASSERT_TRUE(KLookupEscCommand('d', id));
ASSERT_EQ(id, CommandId::DeleteWordNext);
ASSERT_TRUE(KLookupEscCommand('q', id));
ASSERT_EQ(id, CommandId::ReflowParagraph);
ASSERT_TRUE(KLookupEscCommand('w', id));
ASSERT_EQ(id, CommandId::CopyRegion);
// ESC BACKSPACE
ASSERT_TRUE(KLookupEscCommand(KEY_BACKSPACE, id));
ASSERT_EQ(id, CommandId::DeleteWordPrev);
ASSERT_EQ(KLookupEscCommand('z', id), false);
}

View File

@@ -1,49 +1,181 @@
#include "Test.h"
#include "PieceTable.h"
#include <algorithm>
#include <array>
#include <random>
#include <string>
#include <vector>
TEST(PieceTable_Insert_Delete_LineCount) {
PieceTable pt;
// start empty
ASSERT_EQ(pt.Size(), (std::size_t)0);
ASSERT_EQ(pt.LineCount(), (std::size_t)1); // empty buffer has 1 logical line
// Insert some text with newlines
const char *t = "abc\n123\nxyz"; // last line without trailing NL
pt.Insert(0, t, 11);
ASSERT_EQ(pt.Size(), (std::size_t)11);
ASSERT_EQ(pt.LineCount(), (std::size_t)3);
// Check get line
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("123"));
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
// Delete middle line entirely including its trailing NL
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
pt.Delete(r.first, r.second - r.first);
ASSERT_EQ(pt.LineCount(), (std::size_t)2);
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
static std::vector<std::size_t>
LineStartsFor(const std::string &s)
{
std::vector<std::size_t> starts;
starts.push_back(0);
for (std::size_t i = 0; i < s.size(); i++) {
if (s[i] == '\n')
starts.push_back(i + 1);
}
return starts;
}
TEST(PieceTable_LineCol_Conversions) {
PieceTable pt;
std::string s = "hello\nworld\n"; // two lines with trailing NL
pt.Insert(0, s.data(), s.size());
// Byte offsets of starts
auto off0 = pt.LineColToByteOffset(0, 0);
auto off1 = pt.LineColToByteOffset(1, 0);
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
ASSERT_EQ(off0, (std::size_t)0);
ASSERT_EQ(off1, (std::size_t)6); // "hello\n"
ASSERT_EQ(off2, pt.Size());
auto lc0 = pt.ByteOffsetToLineCol(0);
auto lc1 = pt.ByteOffsetToLineCol(6);
ASSERT_EQ(lc0.first, (std::size_t)0);
ASSERT_EQ(lc0.second, (std::size_t)0);
ASSERT_EQ(lc1.first, (std::size_t)1);
ASSERT_EQ(lc1.second, (std::size_t)0);
static std::string
LineContentFor(const std::string &s, std::size_t line_num)
{
auto starts = LineStartsFor(s);
if (starts.empty() || line_num >= starts.size())
return std::string();
std::size_t start = starts[line_num];
std::size_t end = (line_num + 1 < starts.size()) ? starts[line_num + 1] : s.size();
if (end > start && s[end - 1] == '\n')
end -= 1;
return s.substr(start, end - start);
}
TEST (PieceTable_Insert_Delete_LineCount)
{
PieceTable pt;
// start empty
ASSERT_EQ(pt.Size(), (std::size_t) 0);
ASSERT_EQ(pt.LineCount(), (std::size_t) 1); // empty buffer has 1 logical line
// Insert some text with newlines
const char *t = "abc\n123\nxyz"; // last line without trailing NL
pt.Insert(0, t, 11);
ASSERT_EQ(pt.Size(), (std::size_t) 11);
ASSERT_EQ(pt.LineCount(), (std::size_t) 3);
// Check get line
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("123"));
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
// Delete middle line entirely including its trailing NL
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
pt.Delete(r.first, r.second - r.first);
ASSERT_EQ(pt.LineCount(), (std::size_t) 2);
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
}
TEST (PieceTable_LineCol_Conversions)
{
PieceTable pt;
std::string s = "hello\nworld\n"; // two lines with trailing NL
pt.Insert(0, s.data(), s.size());
// Byte offsets of starts
auto off0 = pt.LineColToByteOffset(0, 0);
auto off1 = pt.LineColToByteOffset(1, 0);
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
ASSERT_EQ(off0, (std::size_t) 0);
ASSERT_EQ(off1, (std::size_t) 6); // "hello\n"
ASSERT_EQ(off2, pt.Size());
auto lc0 = pt.ByteOffsetToLineCol(0);
auto lc1 = pt.ByteOffsetToLineCol(6);
ASSERT_EQ(lc0.first, (std::size_t) 0);
ASSERT_EQ(lc0.second, (std::size_t) 0);
ASSERT_EQ(lc1.first, (std::size_t) 1);
ASSERT_EQ(lc1.second, (std::size_t) 0);
}
TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
{
PieceTable pt;
std::string model;
std::mt19937 rng(0xC0FFEEu);
const std::vector<std::string> corpus = {
"a",
"b",
"c",
"xyz",
"123",
"\n",
"!\n",
"foo\nbar",
"end\n",
};
auto check_invariants = [&](const char *where) {
(void) where;
ASSERT_EQ(pt.Size(), model.size());
ASSERT_EQ(pt.GetRange(0, pt.Size()), model);
auto starts = LineStartsFor(model);
ASSERT_EQ(pt.LineCount(), starts.size());
// Spot-check a few line ranges and contents.
std::size_t last = starts.empty() ? (std::size_t) 0 : (starts.size() - 1);
std::size_t mid = (starts.size() > 2) ? (std::size_t) 1 : last;
const std::array<std::size_t, 3> probe_lines = {(std::size_t) 0, last, mid};
for (auto line: probe_lines) {
if (starts.empty())
break;
if (line >= starts.size())
continue;
std::size_t exp_start = starts[line];
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
auto r = pt.GetLineRange(line);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
}
// Round-trips for a few offsets.
const std::vector<std::size_t> probe_offsets = {
0,
model.size() / 2,
model.size(),
};
for (auto off: probe_offsets) {
auto lc = pt.ByteOffsetToLineCol(off);
auto back = pt.LineColToByteOffset(lc.first, lc.second);
ASSERT_EQ(back, off);
}
};
check_invariants("initial");
for (int step = 0; step < 250; step++) {
bool do_insert = model.empty() || ((rng() % 3u) != 0u); // bias toward insert
if (do_insert) {
const std::string &ins = corpus[rng() % corpus.size()];
std::size_t pos = model.empty() ? 0 : (rng() % (model.size() + 1));
pt.Insert(pos, ins.data(), ins.size());
model.insert(pos, ins);
} else {
std::size_t pos = rng() % model.size();
std::size_t max = std::min<std::size_t>(8, model.size() - pos);
std::size_t len = 1 + (rng() % max);
pt.Delete(pos, len);
model.erase(pos, len);
}
// Also validate GetRange on a small random window when non-empty.
if (!model.empty()) {
std::size_t off = rng() % model.size();
std::size_t max = std::min<std::size_t>(16, model.size() - off);
std::size_t len = 1 + (rng() % max);
ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len));
}
check_invariants("step");
}
// Full line-by-line range verification at the end.
auto starts = LineStartsFor(model);
for (std::size_t line = 0; line < starts.size(); line++) {
std::size_t exp_start = starts[line];
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
auto r = pt.GetLineRange(line);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
}
}

View File

@@ -0,0 +1,129 @@
#include "Test.h"
#include "tests/TestHarness.h"
using ktet::TestHarness;
// These tests intentionally drive the prompt-based search/replace UI headlessly
// via `Execute(Editor&, CommandId, ...)` to lock down behavior without ncurses.
TEST (SearchFlow_FindStart_Success_LeavesCursorOnMatch_And_ClearsSearchState)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "abc def abc");
b.SetCursor(0, 0);
b.SetOffsets(0, 0);
// Keep a mark set to ensure search doesn't clobber it.
b.SetMark(0, 0);
ASSERT_TRUE(b.MarkSet());
ASSERT_TRUE(h.Exec(CommandId::FindStart));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::Search);
ASSERT_TRUE(ed.SearchActive());
// Typing into the prompt uses InsertText and should jump to the first match.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "def"));
ASSERT_EQ(b.Cury(), (std::size_t) 0);
ASSERT_EQ(b.Curx(), (std::size_t) 4);
// Enter (Newline) accepts the prompt and ends incremental search.
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(!ed.SearchActive());
ASSERT_TRUE(b.MarkSet());
}
TEST (SearchFlow_FindStart_NotFound_RestoresOrigin_And_ClearsSearchState)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "hello world\nsecond line\n");
b.SetCursor(3, 0);
b.SetOffsets(1, 2);
const std::size_t ox = b.Curx();
const std::size_t oy = b.Cury();
const std::size_t orow = b.Rowoffs();
const std::size_t ocol = b.Coloffs();
ASSERT_TRUE(h.Exec(CommandId::FindStart));
ASSERT_TRUE(ed.PromptActive());
ASSERT_TRUE(ed.SearchActive());
// Not-found should restore cursor/viewport to the saved origin while still in prompt.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "zzzz"));
ASSERT_EQ(b.Curx(), ox);
ASSERT_EQ(b.Cury(), oy);
ASSERT_EQ(b.Rowoffs(), orow);
ASSERT_EQ(b.Coloffs(), ocol);
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(!ed.SearchActive());
}
TEST (SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "abc abc\n");
b.SetCursor(0, 0);
const std::string before = h.Text();
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
// Accept empty find -> proceed to ReplaceWith.
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
// Provide replacement and accept -> should cancel due to empty find.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "X"));
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(!ed.SearchActive());
ASSERT_EQ(h.Text(), before);
}
TEST (SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "abc\ndef\n");
b.SetCursor(1, 0);
b.SetOffsets(0, 0);
const std::size_t ox = b.Curx();
const std::size_t oy = b.Cury();
ASSERT_TRUE(h.Exec(CommandId::RegexFindStart));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::RegexSearch);
// Invalid regex should not crash; cursor should remain at origin due to no matches.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "("));
ASSERT_EQ(b.Curx(), ox);
ASSERT_EQ(b.Cury(), oy);
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(!ed.SearchActive());
}

104
tests/test_swap_recorder.cc Normal file
View File

@@ -0,0 +1,104 @@
#include "Test.h"
#include "Buffer.h"
#include "SwapRecorder.h"
#include <string>
#include <vector>
namespace {
struct SwapEvent {
enum class Type {
Insert,
Delete,
};
Type type;
int row;
int col;
std::string bytes;
std::size_t len = 0;
};
class FakeSwapRecorder final : public kte::SwapRecorder {
public:
std::vector<SwapEvent> events;
void OnInsert(int row, int col, std::string_view bytes) override
{
SwapEvent e;
e.type = SwapEvent::Type::Insert;
e.row = row;
e.col = col;
e.bytes = std::string(bytes);
e.len = 0;
events.push_back(std::move(e));
}
void OnDelete(int row, int col, std::size_t len) override
{
SwapEvent e;
e.type = SwapEvent::Type::Delete;
e.row = row;
e.col = col;
e.len = len;
events.push_back(std::move(e));
}
};
} // namespace
TEST (SwapRecorder_InsertABC)
{
Buffer b;
FakeSwapRecorder rec;
b.SetSwapRecorder(&rec);
b.insert_text(0, 0, std::string_view("abc"));
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert);
ASSERT_EQ(rec.events[0].row, 0);
ASSERT_EQ(rec.events[0].col, 0);
ASSERT_EQ(rec.events[0].bytes, std::string("abc"));
}
TEST (SwapRecorder_InsertNewline)
{
Buffer b;
FakeSwapRecorder rec;
b.SetSwapRecorder(&rec);
b.split_line(0, 0);
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert);
ASSERT_EQ(rec.events[0].row, 0);
ASSERT_EQ(rec.events[0].col, 0);
ASSERT_EQ(rec.events[0].bytes, std::string("\n"));
}
TEST (SwapRecorder_DeleteSpanningNewline)
{
Buffer b;
// Prepare content without a recorder (should be no-op)
b.insert_text(0, 0, std::string_view("ab"));
b.split_line(0, 2);
b.insert_text(1, 0, std::string_view("cd"));
FakeSwapRecorder rec;
b.SetSwapRecorder(&rec);
// Delete "b\n c" (3 bytes) starting at row 0, col 1.
b.delete_text(0, 1, 3);
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Delete);
ASSERT_EQ(rec.events[0].row, 0);
ASSERT_EQ(rec.events[0].col, 1);
ASSERT_EQ(rec.events[0].len, (std::size_t) 3);
}

114
tests/test_swap_replay.cc Normal file
View File

@@ -0,0 +1,114 @@
#include "Test.h"
#include "Buffer.h"
#include "Swap.h"
#include <cstdio>
#include <fstream>
#include <string>
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), (std::streamsize) bytes.size());
}
static std::string
read_file_bytes(const std::string &path)
{
std::ifstream in(path, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
static std::string
buffer_bytes_via_views(const Buffer &b)
{
const auto &rows = b.Rows();
std::string out;
for (std::size_t i = 0; i < rows.size(); i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
TEST (SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
{
const std::string path = "./.kte_ut_swap_replay_1.txt";
std::remove(path.c_str());
write_file_bytes(path, "base\nline2\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
// Edits (no save): swap should capture these.
b.insert_text(0, 0, std::string("X")); // Xbase\nline2\n
b.delete_text(1, 1, 2); // delete "in" from "line2"
b.split_line(0, 3); // Xba\nse...
b.join_lines(0); // join back
b.insert_text(1, 0, std::string("ZZ")); // insert at start of line2
b.delete_text(0, 0, 1); // delete leading X
sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
const std::string expected = buffer_bytes_via_views(b);
// Close journal before replaying (for determinism)
b.SetSwapRecorder(nullptr);
sm.Detach(&b);
Buffer b2;
ASSERT_TRUE(b2.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapReplay_TruncatedLog_FailsSafely)
{
const std::string path = "./.kte_ut_swap_replay_2.txt";
std::remove(path.c_str());
write_file_bytes(path, "hello\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, std::string("X"));
sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
b.SetSwapRecorder(nullptr);
sm.Detach(&b);
const std::string bytes = read_file_bytes(swap_path);
ASSERT_TRUE(bytes.size() > 70); // header + at least one record
const std::string trunc_path = swap_path + ".trunc";
write_file_bytes(trunc_path, bytes.substr(0, bytes.size() - 1));
Buffer b2;
ASSERT_TRUE(b2.OpenFromFile(path, err));
std::string rerr;
ASSERT_EQ(kte::SwapManager::ReplayFile(b2, trunc_path, rerr), false);
ASSERT_EQ(rerr.empty(), false);
std::remove(path.c_str());
std::remove(swap_path.c_str());
std::remove(trunc_path.c_str());
}

236
tests/test_swap_writer.cc Normal file
View File

@@ -0,0 +1,236 @@
#include "Test.h"
#include "Buffer.h"
#include "Swap.h"
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include <cstdlib>
#include <cstdio>
#include <unistd.h>
#include <sys/stat.h>
namespace {
std::vector<std::uint8_t>
read_all_bytes(const std::string &path)
{
std::ifstream in(path, std::ios::binary);
return std::vector<std::uint8_t>((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
std::uint32_t
read_le32(const std::uint8_t *p)
{
return (std::uint32_t) p[0] | ((std::uint32_t) p[1] << 8) | ((std::uint32_t) p[2] << 16) |
((std::uint32_t) p[3] << 24);
}
std::uint64_t
read_le64(const std::uint8_t *p)
{
std::uint64_t v = 0;
for (int i = 7; i >= 0; --i) {
v = (v << 8) | p[i];
}
return v;
}
std::uint32_t
crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0)
{
static std::uint32_t table[256];
static bool inited = false;
if (!inited) {
for (std::uint32_t i = 0; i < 256; ++i) {
std::uint32_t c = i;
for (int j = 0; j < 8; ++j)
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
table[i] = c;
}
inited = true;
}
std::uint32_t c = ~seed;
for (std::size_t i = 0; i < len; ++i)
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
return ~c;
}
} // namespace
TEST (SwapWriter_Header_Records_And_CRC)
{
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
(std::string("kte_ut_xdg_state_") + std::to_string((int) ::getpid()));
std::filesystem::remove_all(xdg_root);
const char *old_xdg = std::getenv("XDG_STATE_HOME");
setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1);
const std::string path = (xdg_root / "work" / "kte_ut_swap_writer.txt").string();
std::filesystem::create_directories((xdg_root / "work"));
// Clean up from prior runs
std::remove(path.c_str());
// Ensure file exists so buffer is file-backed
{
std::ofstream out(path, std::ios::binary);
out << "";
}
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_TRUE(err.empty());
ASSERT_TRUE(b.IsFileBacked());
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(b);
std::remove(swp.c_str());
// Emit one INS and one DEL
b.insert_text(0, 0, std::string_view("abc"));
b.delete_text(0, 1, 1);
// Ensure all records are written before reading
sm.Flush(&b);
sm.Detach(&b);
b.SetSwapRecorder(nullptr);
ASSERT_TRUE(std::filesystem::exists(swp));
// Verify permissions 0600
struct stat st{};
ASSERT_TRUE(::stat(swp.c_str(), &st) == 0);
ASSERT_EQ((st.st_mode & 0777), 0600);
const std::vector<std::uint8_t> bytes = read_all_bytes(swp);
ASSERT_TRUE(bytes.size() >= 64);
// Header
static const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
for (int i = 0; i < 8; ++i)
ASSERT_EQ(bytes[(std::size_t) i], magic[i]);
ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 1);
// flags currently 0
ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 0);
ASSERT_TRUE(read_le64(bytes.data() + 16) != 0);
// Records
std::vector<std::uint8_t> types;
std::size_t off = 64;
while (off < bytes.size()) {
ASSERT_TRUE(bytes.size() - off >= 8); // at least header+crc
const std::uint8_t type = bytes[off + 0];
const std::uint32_t len = (std::uint32_t) bytes[off + 1] | ((std::uint32_t) bytes[off + 2] << 8) |
((std::uint32_t) bytes[off + 3] << 16);
const std::size_t payload_off = off + 4;
const std::size_t crc_off = payload_off + len;
ASSERT_TRUE(crc_off + 4 <= bytes.size());
const std::uint32_t got_crc = read_le32(bytes.data() + crc_off);
std::uint32_t c = 0;
c = crc32(bytes.data() + off, 4, c);
c = crc32(bytes.data() + payload_off, len, c);
ASSERT_EQ(got_crc, c);
types.push_back(type);
off = crc_off + 4;
}
ASSERT_EQ(types.size(), (std::size_t) 2);
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::INS);
ASSERT_EQ(types[1], (std::uint8_t) kte::SwapRecType::DEL);
std::remove(path.c_str());
std::remove(swp.c_str());
if (old_xdg) {
setenv("XDG_STATE_HOME", old_xdg, 1);
} else {
unsetenv("XDG_STATE_HOME");
}
std::filesystem::remove_all(xdg_root);
}
TEST (SwapWriter_NoStomp_SameBasename)
{
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
(std::string("kte_ut_xdg_state_nostomp_") + std::to_string(
(int) ::getpid()));
std::filesystem::remove_all(xdg_root);
std::filesystem::create_directories(xdg_root);
const char *old_xdg = std::getenv("XDG_STATE_HOME");
setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1);
const std::filesystem::path d1 = xdg_root / "p1";
const std::filesystem::path d2 = xdg_root / "p2";
std::filesystem::create_directories(d1);
std::filesystem::create_directories(d2);
const std::filesystem::path f1 = d1 / "same.txt";
const std::filesystem::path f2 = d2 / "same.txt";
{
std::ofstream out(f1.string(), std::ios::binary);
out << "";
}
{
std::ofstream out(f2.string(), std::ios::binary);
out << "";
}
Buffer b1;
Buffer b2;
std::string err;
ASSERT_TRUE(b1.OpenFromFile(f1.string(), err));
ASSERT_TRUE(err.empty());
ASSERT_TRUE(b2.OpenFromFile(f2.string(), err));
ASSERT_TRUE(err.empty());
const std::string swp1 = kte::SwapManager::ComputeSwapPathForTests(b1);
const std::string swp2 = kte::SwapManager::ComputeSwapPathForTests(b2);
ASSERT_TRUE(swp1 != swp2);
// Actually write to both to ensure one doesn't clobber the other.
kte::SwapManager sm;
sm.Attach(&b1);
sm.Attach(&b2);
b1.SetSwapRecorder(sm.RecorderFor(&b1));
b2.SetSwapRecorder(sm.RecorderFor(&b2));
b1.insert_text(0, 0, std::string_view("one"));
b2.insert_text(0, 0, std::string_view("two"));
sm.Flush();
ASSERT_TRUE(std::filesystem::exists(swp1));
ASSERT_TRUE(std::filesystem::exists(swp2));
ASSERT_TRUE(std::filesystem::file_size(swp1) >= 64);
ASSERT_TRUE(std::filesystem::file_size(swp2) >= 64);
sm.Detach(&b1);
sm.Detach(&b2);
b1.SetSwapRecorder(nullptr);
b2.SetSwapRecorder(nullptr);
std::remove(swp1.c_str());
std::remove(swp2.c_str());
std::remove(f1.string().c_str());
std::remove(f2.string().c_str());
if (old_xdg) {
setenv("XDG_STATE_HOME", old_xdg, 1);
} else {
unsetenv("XDG_STATE_HOME");
}
std::filesystem::remove_all(xdg_root);
}

View File

@@ -65,6 +65,49 @@ TEST (VisualLineMode_BroadcastInsert)
}
TEST (VisualLineMode_BroadcastInsert_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
b.SetCursor(1, 0); // fo|o
ed.AddBuffer(std::move(b));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
// Broadcast insert to all selected lines.
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
ASSERT_TRUE(got == exp);
}
// Undo should restore all affected lines in a single step.
ASSERT_TRUE(Execute(ed, std::string("undo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "foo\nfoo\nfoo\n\n";
ASSERT_TRUE(got == exp);
}
// Redo should re-apply the whole insert.
ASSERT_TRUE(Execute(ed, std::string("redo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
ASSERT_TRUE(got == exp);
}
}
TEST (VisualLineMode_BroadcastBackspace)
{
InstallDefaultCommands();
@@ -92,6 +135,46 @@ TEST (VisualLineMode_BroadcastBackspace)
}
TEST (VisualLineMode_BroadcastBackspace_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "abcd\nabcd\nabcd\n");
b.SetCursor(2, 0); // ab|cd
ed.AddBuffer(std::move(b));
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
ASSERT_TRUE(Execute(ed, std::string("backspace")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "acd\nacd\nacd\n\n";
ASSERT_TRUE(got == exp);
}
// Undo should restore all affected lines.
ASSERT_TRUE(Execute(ed, std::string("undo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "abcd\nabcd\nabcd\n\n";
ASSERT_TRUE(got == exp);
}
// Redo should re-apply.
ASSERT_TRUE(Execute(ed, std::string("redo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "acd\nacd\nacd\n\n";
ASSERT_TRUE(got == exp);
}
}
TEST (VisualLineMode_CancelWithCtrlG)
{
InstallDefaultCommands();
@@ -155,4 +238,95 @@ TEST (Yank_ClearsMarkAndVisualLine)
ASSERT_TRUE(!buf->MarkSet());
ASSERT_TRUE(!buf->VisualLineActive());
}
TEST (VisualLineMode_Yank_BroadcastsToBOL_AndUndo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "aa\nbb\ncc\n");
b.SetCursor(1, 0); // a|a
ed.AddBuffer(std::move(b));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
// Enter visual-line mode and extend selection to 3 lines.
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
ASSERT_TRUE(ed.CurrentBuffer()->VisualLineActive());
ed.KillRingClear();
ed.KillRingPush("X");
// Yank in visual-line mode should paste at BOL on every affected line.
ASSERT_TRUE(Execute(ed, std::string("yank")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
// Note: buffers that end with a trailing '\n' have an extra empty row.
const std::string exp = "Xaa\nXbb\nXcc\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
// Undo should restore all affected lines in a single step.
ASSERT_TRUE(Execute(ed, std::string("undo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "aa\nbb\ncc\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
// Redo should re-apply the whole yank.
ASSERT_TRUE(Execute(ed, std::string("redo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "Xaa\nXbb\nXcc\n\n";
ASSERT_TRUE(got == exp);
}
}
TEST (VisualLineMode_Highlight_IsPerLineCursorSpot)
{
Buffer b;
// Note: buffers that end with a trailing '\n' have an extra empty row.
b.insert_text(0, 0, "abcd\nx\nhi\n");
// Place primary cursor on line 0 at column 3 (abc|d).
b.SetCursor(3, 0);
// Select lines 0..2 in visual-line mode.
b.VisualLineStart();
b.VisualLineSetActiveY(2);
ASSERT_TRUE(b.VisualLineActive());
ASSERT_TRUE(b.VisualLineStartY() == 0);
ASSERT_TRUE(b.VisualLineEndY() == 2);
// Line 0: "abcd" (len=4) => spot is 3
ASSERT_TRUE(b.VisualLineSpotSelected(0, 3));
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 0));
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 2));
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 4));
// Line 1: "x" (len=1) => spot clamps to EOL (1)
ASSERT_TRUE(b.VisualLineSpotSelected(1, 1));
ASSERT_TRUE(!b.VisualLineSpotSelected(1, 0));
// Line 2: "hi" (len=2) => spot clamps to EOL (2)
ASSERT_TRUE(b.VisualLineSpotSelected(2, 2));
ASSERT_TRUE(!b.VisualLineSpotSelected(2, 0));
// Outside the selected line range should never be highlighted.
ASSERT_TRUE(!b.VisualLineSpotSelected(3, 0));
}