package editor import ( "bytes" "fmt" "strings" "testing" "pad/internal/io/pool/types" ) // 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") } } // int32SlicesEqual reports whether two []int32 are element-wise equal. func int32SlicesEqual(a, b []int32) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // lineStartOffsets computes the ground-truth line-start byte offsets for // content: position 0 plus the byte after each '\n'. func lineStartOffsets(content []byte) []int32 { off := make([]int32, 0, 16) off = append(off, 0) for i, b := range content { if b == '\n' { off = append(off, int32(i+1)) } } return off } // assertLineIndexMatchesOracle verifies the buffer's incrementally-maintained // LineIndex is exactly the one a full recomputation from the content would // produce. This is the regression guard for newline handling in // UpdateLineIndexAfterInsert / UpdateLineIndexAfterDelete. func assertLineIndexMatchesOracle(t *testing.T, cb *ChunkedBuffer, label string) { t.Helper() full, err := cb.FullContent() if err != nil { t.Fatalf("%s: FullContent: %v", label, err) } want := lineStartOffsets([]byte(full)) if cb.LineIndex == nil { t.Fatalf("%s: LineIndex is nil", label) } if !int32SlicesEqual(cb.LineIndex.Offsets, want) { t.Fatalf("%s: line index drift\n got %v\n want %v", label, cb.LineIndex.Offsets, want) } if cb.LineIndex.Size != int64(len(full)) { t.Fatalf("%s: LineIndex.Size=%d, want %d", label, cb.LineIndex.Size, len(full)) } } // TestLineIndex_InsertNewlines drives inserts that add newlines through the // incremental update and checks the oracle invariant after each step. func TestLineIndex_InsertNewlines(t *testing.T) { base := "aaa\nbbb\nccc" cb := newTestBuffer(t, []byte(base)) cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base))) steps := []struct { pos int text string }{ {7, "\n"}, // empty line after "bbb" {0, "\n"}, // leading empty line {len(base) + 2, "x\ny\n"}, // append two lines at EOF (after edits) {4, "QQ\n"}, // insert at start of "bbb" line, with newline {1000000, "zz"}, // insert far past EOF (clamps in Insert) } for i, s := range steps { // Insert clamps pos to EOF; use the same clamped pos for both the // buffer edit and the index update, as production code does (the // cursor is always within the buffer). p := s.pos if p > int(cb.FileLen()) { p = int(cb.FileLen()) } cb.Insert(p, s.text) cb.UpdateLineIndexAfterInsert(p, s.text) assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("insert step %d (pos=%d text=%q)", i, p, s.text)) } } // TestLineIndex_DeleteNewlines drives deletions that remove newlines / whole // lines / from line starts through the incremental update and checks the // oracle invariant after each step. func TestLineIndex_DeleteNewlines(t *testing.T) { base := "aaa\nbbb\nccc\nddd" cb := newTestBuffer(t, []byte(base)) cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base))) // (start, end) ranges chosen to exercise: mid-line, across a newline, // a whole line, from a line start, and the file start. steps := []struct { start, end int }{ {3, 4}, // delete the '\n' after aaa -> merge aaa+bbb {4, 7}, // delete "bbb" (after merge: content is aaabbb\nddd...) {0, 3}, // delete from file start {3, 8}, // delete "bb\n" region } for i, s := range steps { full, _ := cb.FullContent() if s.start > len(full) || s.end > len(full) { t.Fatalf("step %d: range [%d,%d) beyond content len %d", i, s.start, s.end, len(full)) } cb.Delete(s.start, s.end-s.start) cb.UpdateLineIndexAfterDelete(s.start, s.end) assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("delete step %d [%d,%d)", i, s.start, s.end)) } } // TestLineIndex_MixedEditSequence interleaves inserts and deletes (including // IME-style replace = delete+insert at the same point) and checks the oracle // after each operation. func TestLineIndex_MixedEditSequence(t *testing.T) { base := "hello\nworld\nfoo\nbar\nbaz" cb := newTestBuffer(t, []byte(base)) cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base))) ops := []struct { desc string isIns bool pos int text string // for insert start int // for delete end int // for delete }{ {desc: "insert newline mid", isIns: true, pos: 3, text: "\n"}, {desc: "delete across nl", start: 6, end: 10}, {desc: "replace with multi-line", isIns: false, pos: 2, text: "X\nY\nZ", start: 2, end: 4}, {desc: "delete whole line", start: 0, end: 4}, {desc: "insert at eof", isIns: true, pos: 999, text: "end\n"}, } for i, op := range ops { full, _ := cb.FullContent() switch { case op.isIns: p := op.pos if p > len(full) { p = len(full) } cb.Insert(p, op.text) cb.UpdateLineIndexAfterInsert(p, op.text) case op.text != "": // replace: delete [start,end) then insert at start, mirroring // HandleReplaceRange ordering. s, e := op.start, op.end if s > len(full) { s = len(full) } if e > len(full) { e = len(full) } if e > s { cb.Delete(s, e-s) cb.UpdateLineIndexAfterDelete(s, e) } cb.Insert(s, op.text) cb.UpdateLineIndexAfterInsert(s, op.text) default: s, e := op.start, op.end if s > len(full) { s = len(full) } if e > len(full) { e = len(full) } if e > s { cb.Delete(s, e-s) cb.UpdateLineIndexAfterDelete(s, e) } } assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("op %d (%s)", i, op.desc)) } }