Files
kte/tests/test_audit_regressions.cc
T
Claude f853fa60b1 Highlighting: invalidate from the edited row, not the whole buffer
Every edit bumped the buffer version (through SetDirty) and called
InvalidateFrom(0), so all cached spans and line states were discarded
and stateful highlighters re-lexed from row 0 to the viewport on each
keystroke.

Buffer mutators now report the row they changed (edited_at_), which
bumps the version and tells the engine to drop only rows from there
down; a row's highlighting depends only on rows above it. A version
change that arrives without that notice still clears everything, so a
missed path cannot show stale spans. The engine keeps states as a
contiguous vector and caches spans for every row it lexes. SetDirty no
longer touches the version: the edits themselves do, including undo
and redo back to the saved state (D9 still holds; its test now checks
the highlighting itself).

Typing near the end of a 100k-line C++ file: 46.3 ms -> 0.14 ms of
highlighting work per keystroke.

Audit finding P3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00

549 lines
15 KiB
C++

/*
* test_audit_regressions.cc - Regression tests for defects recorded in
* docs/audits/claude-findings-20260923.md. Each test names its finding ID.
*/
#include "Test.h"
#include "tests/TestHarness.h"
#include "ErrorRecovery.h"
#include "PieceTable.h"
#include "UndoTree.h"
#include "syntax/HighlighterEngine.h"
#include "syntax/HighlighterRegistry.h"
#include "syntax/LanguageHighlighter.h"
#include <chrono>
#include <random>
#include <vector>
#include <clocale>
#include <memory>
#include <string>
#include <thread>
using ktet::TestHarness;
// M1: assignment must not keep the destination's old line index.
TEST(Audit_M1_PieceTable_MoveAssign_ResetsLineIndex)
{
const std::string a_text = "line1\nline2\nline3\n";
PieceTable a;
a.Append(a_text.data(), a_text.size());
ASSERT_EQ(a.LineCount(), (std::size_t) 4); // builds a's index
PieceTable b;
b.Append("x", 1);
a = std::move(b);
ASSERT_EQ(a.Size(), (std::size_t) 1);
ASSERT_EQ(a.LineCount(), (std::size_t) 1);
auto [start, end] = a.GetLineRange(0);
ASSERT_EQ(start, (std::size_t) 0);
ASSERT_EQ(end, (std::size_t) 1);
}
TEST(Audit_M1_PieceTable_CopyAssign_ResetsLineIndex)
{
const std::string a_text = "one\ntwo\nthree\n";
PieceTable a;
a.Append(a_text.data(), a_text.size());
ASSERT_EQ(a.LineCount(), (std::size_t) 4);
PieceTable b;
b.Append("x", 1);
a = b;
ASSERT_EQ(a.LineCount(), (std::size_t) 1);
ASSERT_EQ(a.GetLine(0), std::string("x"));
}
// B12: appends that merge into the last piece must invalidate the line index.
TEST(Audit_B12_PieceTable_MergedAppend_InvalidatesLineIndex)
{
PieceTable t;
t.Append("ab", 2);
ASSERT_EQ(t.LineCount(), (std::size_t) 1);
t.Append("\n", 1);
ASSERT_EQ(t.LineCount(), (std::size_t) 2);
}
// M4: destroying a very long undo history must not recurse per node.
TEST(Audit_M4_UndoTree_DeepHistory_FreesWithoutRecursion)
{
constexpr int kDepth = 1000000;
auto *tree = new UndoTree();
UndoNode *prev = nullptr;
for (int i = 0; i < kDepth; ++i) {
auto *n = new UndoNode();
n->parent = prev;
if (prev)
prev->child = n;
else
tree->root = n;
prev = n;
}
tree->current = prev;
delete tree; // stack overflow before the fix
ASSERT_TRUE(true);
}
// D1: undoing a smart newline must restore the original text in one step.
TEST(Audit_D1_SmartNewline_UndoRestoresText)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, " foobar\nnext");
b.SetCursor(7, 0);
ASSERT_TRUE(h.Exec(CommandId::SmartNewline));
ASSERT_EQ(h.Line(0), std::string(" foo"));
ASSERT_EQ(h.Line(1), std::string(" bar"));
ASSERT_EQ(b.Curx(), (std::size_t) 4);
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Text(), std::string(" foobar\nnext"));
ASSERT_TRUE(h.Redo());
ASSERT_EQ(h.Text(), std::string(" foo\n bar\nnext"));
}
// B11: with a prompt open, SmartNewline accepts the prompt and edits nothing.
TEST(Audit_B11_SmartNewline_WithPrompt_DoesNotIndentBuffer)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, " foo");
b.SetCursor(7, 0);
ASSERT_TRUE(h.Exec(CommandId::FindStart));
ASSERT_TRUE(ed.PromptActive());
ASSERT_TRUE(h.Exec(CommandId::SmartNewline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_EQ(h.Text(), std::string(" foo"));
}
// D5: replace-all is a single undo step.
TEST(Audit_D5_ReplaceAll_SingleUndoStep)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "a1 a2\na3");
b.SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
ASSERT_TRUE(h.Exec(CommandId::InsertText, "a"));
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
ASSERT_TRUE(h.Exec(CommandId::InsertText, "bb"));
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_EQ(h.Text(), std::string("bb1 bb2\nbb3"));
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Text(), std::string("a1 a2\na3"));
}
// D2: scrolling that drags the cursor onto a shorter line clamps its column,
// so a following Backspace edits that line rather than joining lines.
TEST(Audit_D2_ScrollDown_ClampsCursorColumn)
{
TestHarness h;
Buffer &b = h.Buf();
std::string text(50, 'x');
for (int i = 0; i < 60; ++i)
text += "\nab";
b.insert_text(0, 0, text);
b.SetCursor(40, 0);
b.SetOffsets(0, 0);
ASSERT_TRUE(h.Exec(CommandId::ScrollDown, std::string(), 5));
ASSERT_EQ(b.Cury(), (std::size_t) 5);
ASSERT_EQ(b.Curx(), (std::size_t) 2);
const std::size_t rows_before = b.Nrows();
ASSERT_TRUE(h.Exec(CommandId::Backspace));
ASSERT_EQ(b.Nrows(), rows_before);
ASSERT_EQ(h.Line(5), std::string("a"));
}
// M5: jumping to a mark that edits have left past the end clamps the cursor.
TEST(Audit_M5_JumpToMark_ClampsToBuffer)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "one\ntwo");
b.SetMark(5, 40); // stale: no such row or column
b.SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::JumpToMark));
ASSERT_EQ(b.Cury(), (std::size_t) 1);
ASSERT_EQ(b.Curx(), (std::size_t) 3);
}
// D6: reflow with the cursor on a blank separator line is a no-op.
TEST(Audit_D6_Reflow_OnBlankLine_KeepsParagraphsSeparate)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "aaa\n\nbbb");
b.SetCursor(0, 1);
ASSERT_TRUE(h.Exec(CommandId::ReflowParagraph));
ASSERT_EQ(h.Text(), std::string("aaa\n\nbbb"));
}
static bool
row_has_comment(Buffer &b, int row)
{
const auto lh = b.Highlighter()->GetLine(b, row, b.Version());
for (const auto &sp: lh.spans)
if (sp.kind == kte::TokenKind::Comment)
return true;
return false;
}
// D9: undo back to the saved state must not leave stale highlighting (the
// rest of the file coloured as a comment after "/*" is undone).
TEST(Audit_D9_UndoToSaved_RefreshesHighlighting)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "int a;\nint b;\n");
b.EnsureHighlighter();
b.Highlighter()->SetHighlighter(kte::HighlighterRegistry::CreateFor("cpp"));
if (auto *u = b.Undo())
u->mark_saved();
b.SetDirty(false);
ASSERT_TRUE(!row_has_comment(b, 1));
b.SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::InsertText, "/*"));
ASSERT_TRUE(row_has_comment(b, 1));
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Line(0), std::string("int a;"));
ASSERT_TRUE(!b.Dirty());
ASSERT_TRUE(!row_has_comment(b, 1));
}
// P3: an edit keeps cached highlighting for the rows above it; only rows
// from the edit down are recomputed.
TEST(Audit_P3_EditKeepsHighlightCacheAbove)
{
struct Counting : kte::StatefulHighlighter {
int *calls;
explicit Counting(int *c) : calls(c) {}
void HighlightLine(const Buffer &, int, std::vector<kte::HighlightSpan> &) const override {}
LineState HighlightLineStateful(const Buffer &, int, const LineState &prev,
std::vector<kte::HighlightSpan> &) const override
{
++*calls;
return prev;
}
};
TestHarness h;
Buffer &b = h.Buf();
std::string text;
for (int i = 0; i < 1000; ++i)
text += "line\n";
b.insert_text(0, 0, text);
b.EnsureHighlighter();
int calls = 0;
b.Highlighter()->SetHighlighter(std::make_unique<Counting>(&calls));
(void) b.Highlighter()->GetLine(b, 999, b.Version());
ASSERT_EQ(calls, 1000);
calls = 0;
b.insert_text(990, 0, "x");
(void) b.Highlighter()->GetLine(b, 999, b.Version());
ASSERT_EQ(calls, 10); // rows 990..999, not the whole file
}
// D8: failures farther apart than the window must not accumulate.
TEST(Audit_D8_CircuitBreaker_WindowExpires)
{
kte::CircuitBreaker::Config cfg;
cfg.failure_threshold = 2;
cfg.window = std::chrono::seconds(1);
kte::CircuitBreaker cb(cfg);
cb.RecordFailure();
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
cb.RecordFailure();
ASSERT_TRUE(cb.GetState() == kte::CircuitBreaker::State::Closed);
ASSERT_EQ(cb.GetFailureCount(), (std::size_t) 1);
}
// M3: an editor that shares another's buffers must attach them through the
// owner's SwapManager, so destroying it leaves no dangling recorders.
TEST(Audit_M3_SharedBuffers_UseOwnersSwapManager)
{
TestHarness h;
Editor &primary = h.EditorRef();
auto secondary = std::make_unique<Editor>();
secondary->SetSharedBuffers(&primary.Buffers(), primary.Swap());
ASSERT_TRUE(secondary->Swap() == primary.Swap());
secondary->AddBuffer(Buffer());
const std::size_t idx = primary.Buffers().size() - 1;
secondary.reset();
// Edits record through the buffer's swap recorder; before the fix it
// pointed into the destroyed secondary SwapManager.
Buffer &b = primary.Buffers()[idx];
b.insert_text(0, 0, "abc");
ASSERT_EQ(b.GetLineString(0), std::string("abc"));
}
// D3: killing a region that ends on a last line without a trailing newline
// removes it entirely (no stray newline), and undo restores the original.
TEST(Audit_D3_KillRegion_ToUnterminatedLastLine)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "foo\nbar");
b.SetMark(3, 0);
b.SetCursor(3, 1);
ASSERT_TRUE(h.Exec(CommandId::KillRegion));
ASSERT_EQ(h.Text(), std::string("foo"));
ASSERT_EQ(b.Nrows(), (std::size_t) 1);
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Text(), std::string("foo\nbar"));
}
// D3: killing the unterminated last line removes it and the newline before
// it; undo restores it exactly.
TEST(Audit_D3_KillLine_UnterminatedLastLine)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "a\nb");
b.SetCursor(0, 1);
ASSERT_TRUE(h.Exec(CommandId::KillLine));
ASSERT_EQ(h.Text(), std::string("a"));
ASSERT_EQ(h.EditorRef().KillRingHead(), std::string("b"));
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Text(), std::string("a\nb"));
}
// D4: the empty row after a trailing newline is not a line; killing it is a
// no-op, and undo must not add a line.
TEST(Audit_D4_KillLine_OnRowAfterTrailingNewline)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "a\n");
b.SetCursor(0, 1);
ASSERT_TRUE(h.Exec(CommandId::KillLine));
ASSERT_EQ(h.Text(), std::string("a\n"));
(void) h.Undo();
ASSERT_EQ(h.Text(), std::string("a\n"));
}
static void
regex_replace_all(TestHarness &h, const std::string &find, const std::string &with)
{
ASSERT_TRUE(h.Exec(CommandId::RegexpReplace));
ASSERT_TRUE(h.Exec(CommandId::InsertText, find));
ASSERT_TRUE(h.Exec(CommandId::Newline));
if (!with.empty())
ASSERT_TRUE(h.Exec(CommandId::InsertText, with));
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!h.EditorRef().PromptActive());
}
// D4: a zero-width regex does not match the position after the final newline.
TEST(Audit_D4_RegexReplace_ZeroWidth_NoExtraLine)
{
TestHarness h;
h.Buf().insert_text(0, 0, "a\nb\n");
regex_replace_all(h, "^", "# ");
ASSERT_EQ(h.Text(), std::string("# a\n# b\n"));
}
// D3: regex replace on an unterminated last line does not add a newline.
TEST(Audit_D3_RegexReplace_KeepsMissingTrailingNewline)
{
TestHarness h;
h.Buf().insert_text(0, 0, "a\nfoo");
regex_replace_all(h, "foo", "bar");
ASSERT_EQ(h.Text(), std::string("a\nbar"));
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Text(), std::string("a\nfoo"));
}
// D3: reflowing the last paragraph of a file without a trailing newline does
// not add one.
TEST(Audit_D3_Reflow_KeepsMissingTrailingNewline)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "one\ntwo");
b.SetCursor(0, 1);
ASSERT_TRUE(h.Exec(CommandId::ReflowParagraph));
ASSERT_EQ(h.Text(), std::string("one two"));
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Text(), std::string("one\ntwo"));
}
// D5: the visual-line newline is undoable as one step.
TEST(Audit_D5_VisualLineNewline_Undo)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "ab\ncd\nef");
b.SetCursor(1, 0);
b.VisualLineStart();
b.VisualLineSetActiveY(1);
b.SetCursor(1, 1);
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_EQ(h.Text(), std::string("a\nb\nc\nd\nef"));
b.VisualLineClear();
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Text(), std::string("ab\ncd\nef"));
}
// B5: backspace and delete remove a whole UTF-8 character; undo restores it.
TEST(Audit_B5_Backspace_DeletesWholeUtf8Char)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "caf\xC3\xA9!");
b.SetCursor(5, 0); // after the two-byte e-acute
ASSERT_TRUE(h.Exec(CommandId::Backspace));
ASSERT_EQ(h.Text(), std::string("caf!"));
ASSERT_EQ(b.Curx(), (std::size_t) 3);
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Text(), std::string("caf\xC3\xA9!"));
}
TEST(Audit_B5_DeleteChar_DeletesWholeUtf8Char)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "a\xE4\xB8\xAD" "b"); // a, U+4E2D (3 bytes), b
b.SetCursor(1, 0);
ASSERT_TRUE(h.Exec(CommandId::DeleteChar));
ASSERT_EQ(h.Text(), std::string("ab"));
}
TEST(Audit_B5_Motion_StepsOverUtf8Chars)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "x\xF0\x9F\x98\x80y"); // x, U+1F600 (4 bytes), y
b.SetCursor(1, 0);
ASSERT_TRUE(h.Exec(CommandId::MoveRight));
ASSERT_EQ(b.Curx(), (std::size_t) 5);
ASSERT_TRUE(h.Exec(CommandId::MoveLeft));
ASSERT_EQ(b.Curx(), (std::size_t) 1);
}
// B6: horizontal scrolling counts display cells, not bytes, so the cursor
// at the end of a long CJK line stays on screen.
TEST(Audit_B6_HorizontalScroll_CountsCells)
{
const char *prev = std::setlocale(LC_CTYPE, nullptr);
const std::string saved = prev ? prev : "C";
if (!std::setlocale(LC_CTYPE, "C.UTF-8") && !std::setlocale(LC_CTYPE, "C.utf8"))
return; // no UTF-8 locale available
TestHarness h;
Buffer &b = h.Buf();
std::string line;
for (int i = 0; i < 100; ++i)
line += "\xE4\xB8\xAD"; // U+4E2D, two cells wide
b.insert_text(0, 0, line);
b.SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::MoveEnd));
const std::size_t coloffs = b.Coloffs();
std::setlocale(LC_CTYPE, saved.c_str());
// 200 cells wide on an 80-column screen: the cursor cell is 200.
ASSERT_EQ(coloffs, (std::size_t) (200 - 80 + 1));
}
// P2: the line index is updated in place by Insert/Delete. Query it after
// every edit (so it is never dirty) and compare with a plain string model.
TEST(Audit_P2_IncrementalLineIndex_MatchesModel)
{
std::mt19937 rng(20260923u);
PieceTable t;
std::string model;
const char alphabet[] = "ab\nc\n\nd";
for (int step = 0; step < 3000; ++step) {
const bool do_insert = model.empty() || (rng() % 3 != 0);
if (do_insert) {
const std::size_t off = model.empty() ? 0 : rng() % (model.size() + 1);
std::string text;
const std::size_t n = 1 + rng() % 6;
for (std::size_t i = 0; i < n; ++i)
text.push_back(alphabet[rng() % (sizeof(alphabet) - 1)]);
t.Insert(off, text.data(), text.size());
model.insert(off, text);
} else {
const std::size_t off = rng() % model.size();
const std::size_t n = 1 + rng() % 5;
t.Delete(off, n);
model.erase(off, n);
}
// Reference line starts.
std::vector<std::size_t> starts{0};
for (std::size_t i = 0; i < model.size(); ++i)
if (model[i] == '\n')
starts.push_back(i + 1);
ASSERT_EQ(t.LineCount(), starts.size());
for (std::size_t l = 0; l < starts.size(); ++l) {
const auto [s, e] = t.GetLineRange(l);
ASSERT_EQ(s, starts[l]);
ASSERT_EQ(e, (l + 1 < starts.size()) ? starts[l + 1] : model.size());
}
}
}