- 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
177 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|