- 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.
276 lines
7.0 KiB
Go
276 lines
7.0 KiB
Go
package editor
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
|
||
"pad/internal/io/pool/mock"
|
||
)
|
||
|
||
// withState runs fn on the logic goroutine (the sole state owner) and fails
|
||
// the test on timeout. Test-only helper (architecture.md §1): tests must
|
||
// never touch l.state directly while Run() is active.
|
||
func withState(t *testing.T, l *Logic, fn func(*State)) {
|
||
t.Helper()
|
||
_, ok := l.Inspect(func(st *State) any {
|
||
fn(st)
|
||
return nil
|
||
})
|
||
if !ok {
|
||
t.Fatalf("withState: inspect timed out")
|
||
}
|
||
}
|
||
|
||
func TestAutoSaveE2E(t *testing.T) {
|
||
// 1. Setup
|
||
mockFS := mock.NewFileSystem()
|
||
filename := "/test.txt"
|
||
initialContent := "Hello"
|
||
mockFS.AddFile(filename, []byte(initialContent), time.Now())
|
||
|
||
l := NewLogic(mockFS, "/", func(string) {})
|
||
go l.Run()
|
||
defer l.Shutdown()
|
||
|
||
// Drain frameChan to prevent deadlocks
|
||
go func() {
|
||
for range l.FrameChan() {
|
||
}
|
||
}()
|
||
|
||
withState(t, l, func(st *State) {
|
||
TheState = st
|
||
})
|
||
|
||
// 2. Open File (OpenFile also sets Editor.Filename on the owner)
|
||
withState(t, l, func(st *State) {
|
||
TheState = st
|
||
OpenFile(filename)
|
||
})
|
||
|
||
// Wait for the file to be loaded by checking if ChunkedBuffer is populated
|
||
success := false
|
||
for i := 0; i < 20; i++ {
|
||
v, ok := l.Inspect(func(st *State) any {
|
||
cb := st.Editor.ChunkedBuffer
|
||
return cb != nil && cb.FileLen() > 0
|
||
})
|
||
if ok && v.(bool) {
|
||
success = true
|
||
break
|
||
}
|
||
time.Sleep(100 * time.Millisecond)
|
||
}
|
||
if !success {
|
||
t.Fatal("Timed out waiting for file to load")
|
||
}
|
||
|
||
// 3. Edit (owner-side)
|
||
withState(t, l, func(st *State) {
|
||
st.Editor.CursorPosition = len(initialContent)
|
||
HandleInsert(" World")
|
||
})
|
||
|
||
// 4. Trigger auto-save (owner-side)
|
||
withState(t, l, func(st *State) {
|
||
l.markDirty()
|
||
})
|
||
|
||
// 5. Wait for the write to complete
|
||
success = false
|
||
for i := 0; i < 20; i++ {
|
||
content, _ := mockFS.ReadFile(filename)
|
||
if string(content) == "Hello World" {
|
||
success = true
|
||
break
|
||
}
|
||
time.Sleep(200 * time.Millisecond)
|
||
}
|
||
|
||
// 6. Assert
|
||
if !success {
|
||
t.Errorf("Timed out waiting for file to save")
|
||
}
|
||
}
|
||
|
||
// TestLargeFileChunkBoundary verifies that edits near and at chunk boundaries
|
||
// are correctly persisted through the full editor pipeline (open → edit → save →
|
||
// re-open → verify). It creates a 256 KB file (4 × 64 KB chunks) and performs
|
||
// three edits:
|
||
//
|
||
// 1. Near the end of chunk 1 (position 65000)
|
||
// 2. Right at the chunk 0 / chunk 1 boundary (position 65535)
|
||
// 3. Near the end of chunk 3 (position 262000)
|
||
//
|
||
// After saving, the file is re-opened and the full content is compared
|
||
// byte-for-byte against the expected result.
|
||
func TestLargeFileChunkBoundary(t *testing.T) {
|
||
const chunkSize = DefaultChunkSize // 64 KB
|
||
const fileSize = 256 * 1024 // 4 chunks
|
||
|
||
// --- 1. Setup: create a 256 KB file with a predictable pattern ---
|
||
mockFS := mock.NewFileSystem()
|
||
filename := "/large.txt"
|
||
initialContent := make([]byte, fileSize)
|
||
for i := range initialContent {
|
||
initialContent[i] = byte(i % 256)
|
||
}
|
||
mockFS.AddFile(filename, initialContent, time.Now())
|
||
|
||
l := NewLogic(mockFS, "/", func(string) {})
|
||
go l.Run()
|
||
defer l.Shutdown()
|
||
|
||
// Drain frameChan to prevent deadlocks
|
||
go func() {
|
||
for range l.FrameChan() {
|
||
}
|
||
}()
|
||
|
||
withState(t, l, func(st *State) {
|
||
TheState = st
|
||
})
|
||
|
||
// --- 2. Open File ---
|
||
withState(t, l, func(st *State) {
|
||
TheState = st
|
||
OpenFile(filename)
|
||
})
|
||
|
||
success := false
|
||
for i := 0; i < 20; i++ {
|
||
v, ok := l.Inspect(func(st *State) any {
|
||
cb := st.Editor.ChunkedBuffer
|
||
return cb != nil && cb.FileLen() > 0
|
||
})
|
||
if ok && v.(bool) {
|
||
success = true
|
||
break
|
||
}
|
||
time.Sleep(100 * time.Millisecond)
|
||
}
|
||
if !success {
|
||
t.Fatal("Timed out waiting for large file to load")
|
||
}
|
||
|
||
// --- 3. Edit near the end of chunk 1 (position 65000, inside chunk 1) ---
|
||
withState(t, l, func(st *State) {
|
||
st.Editor.CursorPosition = 65000
|
||
HandleInsert("CHUNK1")
|
||
})
|
||
|
||
// --- 4. Edit right at the chunk 0 / chunk 1 boundary (position 65535) ---
|
||
withState(t, l, func(st *State) {
|
||
st.Editor.CursorPosition = 65535
|
||
HandleInsert("BOUNDARY")
|
||
})
|
||
|
||
// --- 5. Edit near the end of chunk 3 (position 262000) ---
|
||
withState(t, l, func(st *State) {
|
||
st.Editor.CursorPosition = 262000
|
||
HandleInsert("CHUNK3")
|
||
})
|
||
|
||
// --- 6. Trigger save (owner-side) ---
|
||
withState(t, l, func(st *State) {
|
||
l.FlushAll()
|
||
})
|
||
|
||
// --- 7. Read back from mockFS and verify byte-for-byte ---
|
||
savedContent, err := mockFS.ReadFile(filename)
|
||
if err != nil {
|
||
t.Fatalf("Failed to read saved file from mockFS: %v", err)
|
||
}
|
||
|
||
// Build the expected content by applying the same three inserts to the
|
||
// original content as a whole, in order. This is the shift-correct ground
|
||
// truth: each insert at an absolute position pushes all later bytes right.
|
||
// (The old fixed-slot model treated each chunk as an independent buffer and
|
||
// did NOT shift later chunks; that was the bug this design replaces.)
|
||
expectedStr := string(initialContent)
|
||
expectedStr = expectedStr[:65000] + "CHUNK1" + expectedStr[65000:] // insert 1 @65000
|
||
expectedStr = expectedStr[:65535] + "BOUNDARY" + expectedStr[65535:] // insert 2 @65535
|
||
expectedStr = expectedStr[:262000] + "CHUNK3" + expectedStr[262000:] // insert 3 @262000
|
||
expected := []byte(expectedStr)
|
||
|
||
if len(savedContent) != len(expected) {
|
||
t.Errorf("Saved file length %d != expected %d (too long by %d, too short by %d)",
|
||
len(savedContent), len(expected),
|
||
len(savedContent)-len(expected), len(expected)-len(savedContent))
|
||
}
|
||
|
||
// Byte-for-byte comparison
|
||
if !bytes.Equal(savedContent, expected) {
|
||
// Find the first differing byte
|
||
minLen := len(savedContent)
|
||
if len(expected) < minLen {
|
||
minLen = len(expected)
|
||
}
|
||
diffPos := -1
|
||
for i := 0; i < minLen; i++ {
|
||
if savedContent[i] != expected[i] {
|
||
diffPos = i
|
||
break
|
||
}
|
||
}
|
||
if diffPos == -1 {
|
||
t.Errorf("Saved content differs in length: got %d bytes, expected %d bytes", len(savedContent), len(expected))
|
||
} else {
|
||
t.Errorf("Saved content differs at byte %d: got %d (%q), expected %d (%q)",
|
||
diffPos, savedContent[diffPos], safeByte(savedContent[diffPos]),
|
||
expected[diffPos], safeByte(expected[diffPos]))
|
||
}
|
||
}
|
||
}
|
||
|
||
// safeByte converts a byte to a printable representation for error messages.
|
||
func safeByte(b byte) string {
|
||
if b >= 32 && b < 127 {
|
||
return string(b)
|
||
}
|
||
return fmt.Sprintf("\\x%02x", b)
|
||
}
|
||
|
||
func TestFlushOnExitE2E(t *testing.T) {
|
||
// 1. Setup
|
||
mockFS := mock.NewFileSystem()
|
||
filename := "/test.txt"
|
||
initialContent := "Hello"
|
||
mockFS.AddFile(filename, []byte(initialContent), time.Now())
|
||
|
||
l := NewLogic(mockFS, "/", func(string) {})
|
||
go l.Run()
|
||
defer l.Shutdown()
|
||
|
||
// Drain frameChan to prevent deadlocks
|
||
go func() {
|
||
for range l.FrameChan() {
|
||
}
|
||
}()
|
||
|
||
withState(t, l, func(st *State) {
|
||
st.Editor.Filename = filename
|
||
st.Editor.Buffer = initialContent
|
||
TheState = st
|
||
})
|
||
|
||
// 2. Edit (owner-side)
|
||
withState(t, l, func(st *State) {
|
||
st.Editor.CursorPosition = len(initialContent)
|
||
HandleInsert(" World")
|
||
})
|
||
|
||
// 3. Trigger Flush (owner-side)
|
||
withState(t, l, func(st *State) {
|
||
l.FlushAll()
|
||
})
|
||
|
||
// 4. Assert
|
||
content, _ := mockFS.ReadFile(filename)
|
||
if string(content) != "Hello World" {
|
||
t.Errorf("Expected 'Hello World', got %q", string(content))
|
||
}
|
||
}
|