Pad/internal/editor/chunked_buffer_test.go
Greg Pomerantz 615957196e feat: virtual scrolling with chunked buffer for large files
- Add ChunkedBuffer for 64KB chunked file access with dirty-chunk
  eviction protection
- Add LineIndex for precise byte-offset-to-line-number mapping
- Refactor IO task system with context cancellation, typed priorities,
  and new task types (ReadChunk, BuildLineIndex, StatFile)
- Add ReadFileAt to FileSystem interface (mock + real implementations)
- Integrate virtual scrolling into editor layout
- Add comprehensive tests for chunked buffer eviction, dirty-chunk
  safety, and full edit lifecycle
2026-06-05 09:11:32 -04:00

177 lines
5.5 KiB
Go

package editor
import (
"testing"
"time"
"pad/internal/io/pool/mock"
)
// TestEvictDirtyChunk_LosesEdits verifies that editing a chunk and then
// evicting it does NOT lose the modification. Dirty chunks are protected
// from eviction even when far from the cursor.
func TestEvictDirtyChunk_LosesEdits(t *testing.T) {
mockFS := mock.NewFileSystem()
filename := "/test.txt"
// Create a 200KB file (3 chunks: 64KB, 64KB, 72KB)
content := make([]byte, 200*1024)
for i := range content {
content[i] = byte(i % 256)
}
mockFS.AddFile(filename, content, time.Now())
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "")
cb.SetFileSize(200 * 1024)
cb.LoadChunk(0)
// 1. Insert 100 bytes at position 0, modifying chunk 0
insertText := "MODIFIED"
cb.Insert(0, insertText)
// Verify the chunk grew
if len(cb.chunks[0]) != DefaultChunkSize+len(insertText) {
t.Fatalf("expected chunk 0 to grow to %d, got %d", DefaultChunkSize+len(insertText), len(cb.chunks[0]))
}
// 2. Simulate scrolling to chunk 2, which would normally trigger eviction of chunk 0
cb.EvictFarChunks(2*DefaultChunkSize, 1)
// Chunk 0 should NOT be evicted because it's dirty
if _, ok := cb.chunks[0]; !ok {
t.Fatal("expected dirty chunk 0 to survive eviction")
}
// 3. Reconstruct the full file — the edit at position 0 must survive
full, err := cb.FullContent()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The first len(insertText) bytes must be the inserted text
if full[:len(insertText)] != insertText {
t.Errorf("expected first %d bytes to be %q, got %q", len(insertText), insertText, full[:len(insertText)])
}
}
// TestEvictDirtyChunk_DeleteLosesEdits verifies that a deletion in a chunk
// survives eviction. Dirty chunks are never evicted.
func TestEvictDirtyChunk_DeleteLosesEdits(t *testing.T) {
mockFS := mock.NewFileSystem()
filename := "/test.txt"
// Create a 128KB file (2 chunks of 64KB)
content := make([]byte, 128*1024)
for i := range content {
content[i] = byte(i % 256)
}
mockFS.AddFile(filename, content, time.Now())
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "")
cb.SetFileSize(128 * 1024)
cb.LoadChunk(0)
cb.LoadChunk(1)
// 1. Delete 100 bytes at position 127000 (in chunk 1)
cb.Delete(127000, 100)
// Verify chunk 1 shrunk
if len(cb.chunks[1]) != DefaultChunkSize-100 {
t.Fatalf("expected chunk 1 to shrink to %d, got %d", DefaultChunkSize-100, len(cb.chunks[1]))
}
// 2. Evict — chunk 1 should NOT be evicted because it's dirty
cb.EvictFarChunks(0, 1)
if _, ok := cb.chunks[1]; !ok {
t.Fatal("expected dirty chunk 1 to survive eviction")
}
// 3. Reconstruct — the deletion must survive
full, err := cb.FullContent()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedLen := 128*1024 - 100
if len(full) != expectedLen {
t.Errorf("expected file length %d after deletion, got %d", expectedLen, len(full))
}
}
// TestEvictCleanChunk_AllowsEviction verifies that chunks WITHOUT edits
// CAN be evicted (i.e., dirty-chunk tracking doesn't prevent normal eviction).
func TestEvictCleanChunk_AllowsEviction(t *testing.T) {
mockFS := mock.NewFileSystem()
filename := "/test.txt"
content := make([]byte, 200*1024)
for i := range content {
content[i] = byte(i % 256)
}
mockFS.AddFile(filename, content, time.Now())
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "")
cb.SetFileSize(200 * 1024)
cb.LoadChunk(0)
cb.LoadChunk(1)
cb.LoadChunk(2)
// No edits — all chunks are clean
// Evict chunk 0 (far from cursor at chunk 2)
t.Logf("DEBUG: chunks before eviction: %v", cb.chunks)
cb.EvictFarChunks(2*DefaultChunkSize, 1)
t.Logf("DEBUG: chunks after eviction: %v", cb.chunks)
// Chunk 0 should be evicted (it's clean)
if _, evicted := cb.chunks[0]; evicted {
t.Fatalf("expected clean chunk 0 to be evicted, but it still exists (chunks=%v)", cb.chunks)
}
// But chunks 1 and 2 should remain
if _, ok := cb.chunks[1]; !ok {
t.Fatal("expected chunk 1 to remain")
}
if _, ok := cb.chunks[2]; !ok {
t.Fatal("expected chunk 2 to remain")
}
}
// TestFullContent_AfterEvictDirtyChunk_ReReadsFromDisk verifies that when a dirty
// chunk is somehow evicted (e.g., by a bug), FullContent re-reads the OLD disk
// content — confirming the data loss scenario. This test documents the bug before
// it is fixed.
func TestFullContent_AfterEvictDirtyChunk_ReReadsFromDisk(t *testing.T) {
mockFS := mock.NewFileSystem()
filename := "/test.txt"
// Create a small 100-byte file (1 chunk)
content := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
mockFS.AddFile(filename, content, time.Now())
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "")
cb.SetFileSize(100)
cb.LoadChunk(0)
// Insert at position 0 — modifies chunk 0 in memory
cb.Insert(0, "X")
// The in-memory chunk 0 should now be 101 bytes starting with "X"
t.Logf("DEBUG: chunk 0 len=%d, first byte=%q, dirtyChunks=%v", len(cb.chunks[0]), cb.chunks[0][0], cb.dirtyChunks)
if cb.chunks[0][0] != 'X' {
t.Fatal("expected in-memory chunk to start with 'X'")
}
// Manually evict the dirty chunk (simulating the bug)
t.Logf("DEBUG: before delete, chunks=%v", cb.chunks)
delete(cb.chunks, 0)
t.Logf("DEBUG: after delete, chunks=%v", cb.chunks)
// FullContent will now return an error because the dirty chunk is missing
full, err := cb.FullContent()
if err == nil {
t.Errorf("expected error due to missing dirty chunk, got nil, full content: %q", full)
} else {
t.Logf("DEBUG: caught expected error: %v", err)
}
}