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.
282 lines
9.4 KiB
Go
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")
|
|
}
|
|
}
|