Pad/internal/editor/chunked_buffer_test.go
Greg Pomerantz 3460ef3993 editor: fix viewport-on-open, chunked-buffer drift, add size guard (Phase 3 code)
- Viewport: swallow the opening tap/scroll (justOpenedAt window) and
  re-clamp ScrollOffset to [0,MaxScroll] in EditorLayout so a short file
  never opens past its content (blank viewport).
- Chunked buffer: replace the fixed i*chunkSize slot model (which drifted
  after length-changing edits and could re-read stale disk for shifted
  tail chunks) with an ordered chunk slice + prefix-sum byte offsets.
  In-range files now load fully on open (SetContent), so there is no lazy
  load and no stale-disk re-read. Edits splice only the affected chunk(s).
- Size guard: files > MaxEditableFileSize (50 MB) show a 'too large to
  edit' notice instead of loading; the browser still lists them. Edit
  handlers (KeyDown/ReplaceRange) are no-ops for too-large files.
- Tests: rewrite chunked_buffer_test.go for prefix-sum correctness (insert/
  delete across chunk boundaries, rune->byte after edit); fix the large-file
  e2e expectation to the shift-correct ground truth.
2026-08-16 10:35:10 -04:00

194 lines
6.3 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())
}
}