/* * test_piece_table.cc - Tests for the PieceTable data structure * * This file validates the core text storage mechanism used by kte. * PieceTable provides efficient insert/delete operations without copying * the entire buffer, using a list of "pieces" that reference ranges in * original and add buffers. * * Key functionality tested: * - Insert/delete operations maintain correct content * - Line counting and line-based queries work correctly * - Position conversion (byte offset ↔ line/column) is accurate * - Random edits against a reference model (string) produce identical results * * The random edit test is particularly important - it performs hundreds of * random insertions and deletions, comparing PieceTable results against a * simple std::string to ensure correctness under all conditions. */ #include "Test.h" #include "PieceTable.h" #include #include #include #include #include static std::vector LineStartsFor(const std::string &s) { std::vector 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::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 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 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 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(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(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)); } } // Random edits against a std::string model, starting from adopted original // storage, with consolidation forced often: every read path (ranges, views, // lines, offset conversions) must agree with the model after each edit. TEST (PieceTable_ReferenceModel_AllReadPaths) { std::mt19937 rng(0x5EEDu); for (int round = 0; round < 20; ++round) { PieceTable pt(0, 16, 8, 64); // tiny limits: consolidation runs constantly std::string model = "first line\nsecond\n\nthird line here\nlast"; pt.AdoptOriginal(std::string(model)); for (int step = 0; step < 300; ++step) { const std::size_t n = model.size(); if (n == 0 || rng() % 3 != 0) { static const char *frags[] = {"x", "\n", "ab\ncd", "", "tail\n", "\n\n", "é"}; const std::string ins = frags[rng() % 7]; const std::size_t at = n ? rng() % (n + 1) : 0; pt.Insert(at, ins.data(), ins.size()); model.insert(at, ins); } else { const std::size_t at = rng() % n; const std::size_t len = 1 + rng() % std::min(n - at, 7); pt.Delete(at, len); model.erase(at, len); } ASSERT_EQ(pt.Size(), model.size()); ASSERT_EQ(std::string(pt.ContentView()), model); // Line structure. std::vector starts{0}; for (std::size_t i = 0; i < model.size(); ++i) if (model[i] == '\n') starts.push_back(i + 1); ASSERT_EQ(pt.LineCount(), starts.size()); for (std::size_t l = 0; l < starts.size(); ++l) { const std::size_t end = l + 1 < starts.size() ? starts[l + 1] - 1 : model.size(); ASSERT_EQ(pt.GetLine(l), model.substr(starts[l], end - starts[l])); ASSERT_EQ(pt.LineColToByteOffset(l, 1000), end); } // Random ranges and views (edit first, so views take the // no-materialization path where they can). for (int k = 0; k < 4 && !model.empty(); ++k) { const std::size_t off = rng() % model.size(); const std::size_t len = 1 + rng() % 9; ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len)); pt.Insert(0, "", 0); // no-op: must not disturb anything ASSERT_EQ(std::string(pt.View(off, len)), model.substr(off, len)); const auto [row, col] = pt.ByteOffsetToLineCol(off); ASSERT_EQ(starts[row] + col, off); } std::string chunks; pt.ForEachChunk([&](const char *d, std::size_t len) { chunks.append(d, len); }); ASSERT_EQ(chunks, model); } } }