Pad/internal/editor/chunked_buffer_test.go
Greg Pomerantz 3c8017f871 editor: split oversized chunks on insert (bound per-edit copy cost)
The chunked buffer was the right structure for this workload, but the
implementation let the edited chunk grow without bound (the type comment
even admitted 'not rebalanced'): sustained typing at one spot made that
chunk grow monotonically, so each subsequent insert copied the whole
grown chunk -- quadratic total copy work for sustained typing.

Insert now splits any chunk that grows past 2x the target size in half,
and the empty-buffer path chunks a large first paste directly instead of
creating one oversized chunk. The per-edit copy cost is now bounded by
O(chunkSize) by construction. The split cut is an arbitrary byte offset
(like SetContent boundaries): chunk edges may fall inside multi-byte
sequences, which is fine because readers reassemble whole line-aligned
windows from chunk bytes.

Shrunken/empty chunks from deletes are left in place: chunk count never
grows with deletes, all readers walk actual lengths, and removal would be
pure churn with no reader-side benefit.

No behavior change visible to readers (Content/FullContent/LineIndex all
walk actual chunk lengths); covered by three new tests: sustained-typing
chunk-size invariant + content, large paste into empty buffer, and
content integrity across newly created split boundaries. Full suite green
under -race.
2026-08-16 22:36:15 -04:00

282 lines
9.4 KiB
Go

package editor
import (
"bytes"
"strings"
"testing"
)
// newTestBuffer builds a fully-loaded (in-range) buffer from content using the
// on-open path (SetContent), so all chunks are resident.
func newTestBuffer(t *testing.T, content []byte) *ChunkedBuffer {
t.Helper()
cb := NewChunkedBuffer("/test.txt", DefaultChunkSize, nil, "")
cb.SetContent(content)
return cb
}
// TestSetContent_MultipleChunks verifies the whole-file load splits content
// into ordered chunks and Content reconstructs it exactly.
func TestSetContent_MultipleChunks(t *testing.T) {
content := make([]byte, 200*1024)
for i := range content {
content[i] = byte(i % 251)
}
cb := newTestBuffer(t, content)
// 200KB / 64KB = 3 full chunks (64KB each) + 1 partial (8KB) = 4 chunks.
if got := len(cb.chunks); got != 4 {
t.Fatalf("expected 4 chunks, got %d", got)
}
if cb.FileLen() != int64(len(content)) {
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(content))
}
full, err := cb.FullContent()
if err != nil {
t.Fatalf("FullContent error: %v", err)
}
if full != string(content) {
t.Fatalf("FullContent mismatch after SetContent")
}
// Spot-check a range that spans the chunk-0/chunk-1 boundary.
seg := cb.Content(64*1024-10, 64*1024+10)
if seg != string(content[64*1024-10:64*1024+10]) {
t.Fatalf("Content across chunk boundary mismatch: got %q", seg)
}
}
// TestInsertShiftsLaterChunks is the core drift-fix test: an insert in chunk 0
// must shift every later byte, and Content must still return the correct byte
// at the shifted offset (the old fixed-slot i*chunkSize model failed here).
func TestInsertShiftsLaterChunks(t *testing.T) {
content := make([]byte, 200*1024)
for i := range content {
content[i] = byte(i % 251)
}
cb := newTestBuffer(t, content)
const insLen = 3
cb.Insert(0, strings.Repeat("X", insLen))
// A byte originally at position 100000 (chunk 1) is now at 100003.
want := content[100000]
if got := cb.Content(100000+insLen, 100001+insLen); got != string(want) {
t.Fatalf("byte at shifted offset 100003 = %q, want %q", got, string(want))
}
// The inserted prefix is present.
if got := cb.Content(0, insLen); got != strings.Repeat("X", insLen) {
t.Fatalf("prefix = %q, want %q", got, strings.Repeat("X", insLen))
}
// Full content is the insert + original.
full, _ := cb.FullContent()
if full != strings.Repeat("X", insLen)+string(content) {
t.Fatalf("FullContent after insert mismatch (len=%d, want %d)", len(full), insLen+len(content))
}
}
// TestDeleteShiftsLaterChunks verifies a deletion in an early chunk shifts
// later bytes left and Content/FullContent stay exact.
func TestDeleteShiftsLaterChunks(t *testing.T) {
content := make([]byte, 200*1024)
for i := range content {
content[i] = byte(i % 251)
}
cb := newTestBuffer(t, content)
const delLen = 100
cb.Delete(0, delLen)
// A byte originally at 100000 is now at 99900.
want := content[100000]
if got := cb.Content(100000-delLen, 100001-delLen); got != string(want) {
t.Fatalf("byte at shifted offset = %q, want %q", got, string(want))
}
full, _ := cb.FullContent()
if full != string(content[delLen:]) {
t.Fatalf("FullContent after delete mismatch (len=%d, want %d)", len(full), len(content)-delLen)
}
}
// TestDeleteSpanningChunks verifies a deletion that spans multiple chunk
// boundaries is exact.
func TestDeleteSpanningChunks(t *testing.T) {
content := make([]byte, 200*1024)
for i := range content {
content[i] = byte(i % 251)
}
cb := newTestBuffer(t, content)
// Delete 64KB+50 bytes starting at 30KB (spans chunks 0,1,2).
const start = 30 * 1024
const n = 64*1024 + 50
cb.Delete(start, n)
expected := make([]byte, 0, len(content)-n)
expected = append(expected, content[:start]...)
expected = append(expected, content[start+n:]...)
full, _ := cb.FullContent()
if !bytes.Equal([]byte(full), expected) {
t.Fatalf("FullContent after spanning delete mismatch (len=%d, want %d)", len(full), len(expected))
}
if cb.FileLen() != int64(len(expected)) {
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(expected))
}
}
// TestRuneIndexToByteAfterEdit verifies the IME rune->byte bridge stays correct
// after a length-changing edit (ASCII + multibyte).
func TestRuneIndexToByteAfterEdit(t *testing.T) {
// 2 chunks of ASCII so the edit crosses into chunk arithmetic.
var b bytes.Buffer
for i := 0; i < 100*1024; i++ {
b.WriteByte(byte('a' + i%26))
}
cb := newTestBuffer(t, b.Bytes())
// Insert a multibyte rune ("é" = 2 bytes) at rune 0.
cb.Insert(0, "é")
// Rune 0 is now 'é' (bytes 0..1). Rune 1 is the original first 'a' at byte 2.
if got := cb.RuneIndexToByte(0); got != 0 {
t.Fatalf("RuneIndexToByte(0)=%d, want 0", got)
}
if got := cb.RuneIndexToByte(1); got != 2 {
t.Fatalf("RuneIndexToByte(1)=%d, want 2", got)
}
// New rune R (R>0) is the original rune R-1 (originally at byte R-1), now
// at byte (R-1)+2 = R+1 (é is 1 rune, 2 bytes). Check one deep in chunk 1
// (past the 64KB boundary) to confirm the shift survives chunk boundaries.
deep := 70000
if got := cb.RuneIndexToByte(deep); got != deep+1 {
t.Fatalf("RuneIndexToByte(%d)=%d, want %d", deep, got, deep+1)
}
}
// TestContent_BoundsAndEmpty guards edge cases in the rewritten Content.
func TestContent_BoundsAndEmpty(t *testing.T) {
cb := newTestBuffer(t, []byte("hello world"))
if got := cb.Content(0, 5); got != "hello" {
t.Fatalf("Content(0,5)=%q, want hello", got)
}
if got := cb.Content(6, 11); got != "world" {
t.Fatalf("Content(6,11)=%q, want world", got)
}
// Out-of-range end clamps to fileLen.
if got := cb.Content(0, 1000); got != "hello world" {
t.Fatalf("Content(0,1000)=%q, want full", got)
}
// start >= end is empty.
if got := cb.Content(5, 5); got != "" {
t.Fatalf("Content(5,5)=%q, want empty", got)
}
// Empty buffer.
empty := newTestBuffer(t, nil)
if got := empty.Content(0, 10); got != "" {
t.Fatalf("empty Content=%q, want empty", got)
}
if got, _ := empty.FullContent(); got != "" {
t.Fatalf("empty FullContent=%q, want empty", got)
}
}
// TestAppendAtEnd inserts at the very end of the buffer (pos == fileLen), which
// maps to the last chunk's end.
func TestAppendAtEnd(t *testing.T) {
cb := newTestBuffer(t, []byte("abcdef"))
cb.Insert(6, "XYZ")
full, _ := cb.FullContent()
if full != "abcdefXYZ" {
t.Fatalf("append at end = %q, want abcdefXYZ", full)
}
if cb.FileLen() != 9 {
t.Fatalf("FileLen=%d, want 9", cb.FileLen())
}
}
// assertMaxChunkSize verifies the chunking invariant: no chunk may exceed
// twice the target size (Insert splits oversized chunks in half).
func assertMaxChunkSize(t *testing.T, cb *ChunkedBuffer) {
t.Helper()
for i, c := range cb.chunks {
if len(c) > 2*cb.chunkSize {
t.Fatalf("chunk %d is %d bytes, want <= %d (2 x %d)", i, len(c), 2*cb.chunkSize, cb.chunkSize)
}
}
}
// TestInsert_SustainedTyping_SplitsOversizedChunks simulates sustained typing
// at one spot (many small inserts into the same chunk). Without the split, the
// target chunk grows without bound and every edit copies the whole grown
// chunk (quadratic total). With the split, chunks stay O(chunkSize).
func TestInsert_SustainedTyping_SplitsOversizedChunks(t *testing.T) {
const chunkSize = 64
cb := NewChunkedBuffer("/test.txt", chunkSize, nil, "")
seed := strings.Repeat("seed line\n", chunkSize/8) // 64 bytes = 1 chunk
cb.SetContent([]byte(seed))
// Type 2000 characters, each inserted immediately after the previous one,
// starting at position 10 (inside chunk 0).
var typed strings.Builder
pos := 10
for i := 0; i < 2000; i++ {
r := rune('a' + i%26)
cb.Insert(pos, string(r))
pos++
typed.WriteRune(r)
if i%200 == 0 {
assertMaxChunkSize(t, cb)
}
}
assertMaxChunkSize(t, cb)
want := seed[:10] + typed.String() + seed[10:]
full, _ := cb.FullContent()
if full != want {
t.Fatalf("FullContent mismatch after sustained inserts (len %d vs %d)", len(full), len(want))
}
if cb.FileLen() != int64(len(want)) {
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(want))
}
}
// TestInsert_LargePaste_EmptyBuffer verifies that a large first paste into an
// empty buffer is chunked (not one oversized chunk) and reconstructs exactly.
func TestInsert_LargePaste_EmptyBuffer(t *testing.T) {
cb := NewChunkedBuffer("/test.txt", 64*1024, nil, "")
paste := strings.Repeat("0123456789", 100*1024) // 1 MB
cb.Insert(0, paste)
assertMaxChunkSize(t, cb)
if got := len(cb.chunks); got != 16 {
t.Fatalf("expected 16 chunks for 1MB/64KB, got %d", got)
}
full, _ := cb.FullContent()
if full != paste {
t.Fatalf("FullContent mismatch after large paste")
}
}
// TestInsert_SplitContentIntegrity checks that Content stays exact across the
// NEW chunk boundary created by a split, including spans that cross it.
func TestInsert_SplitContentIntegrity(t *testing.T) {
const chunkSize = 64
cb := NewChunkedBuffer("/test.txt", chunkSize, nil, "")
cb.SetContent([]byte(strings.Repeat("x", 64)))
// 200 inserts of 2 chars at pos 0: the first chunk grows to 464+ bytes and
// must be split several times.
for i := 0; i < 200; i++ {
cb.Insert(0, "AB")
}
want := strings.Repeat("AB", 200) + strings.Repeat("x", 64)
full, _ := cb.FullContent()
if full != want {
t.Fatalf("FullContent mismatch: got %d bytes, want %d", len(full), len(want))
}
// Span across several chunk boundaries near the front.
seg := cb.Content(0, 200)
if seg != want[:200] {
t.Fatalf("Content(0,200) mismatch across split boundary")
}
seg = cb.Content(50, 300)
if seg != want[50:300] {
t.Fatalf("Content(50,300) mismatch across split boundary")
}
}