Frame now carries view state (scale/focus/query) to the main goroutine, which reads only the frame-receiver-stored snapshot. Renderer owns Gio widget editors (registered by ID) and the draw scale. Autosave timer sends a token to the logic goroutine instead of touching state; Shutdown waits for the owner to exit before FlushAll. Tests access state only through owner-side Inspect/WithState helpers (harness + in-package). Fixed double-Harness.Run race in two e2e tests, made TestWorkerPool_PriorityPreemption deterministic, and raised the lazy-loading test timeout that was too short under -race. go test -race ./... is now green.
299 lines
7.4 KiB
Go
299 lines
7.4 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)
|
||
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 ---
|
||
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 using ChunkedBuffer's exact design:
|
||
// Each chunk of size chunkSize acts as an independent buffer.
|
||
// We divide the original content into chunks, apply the edits to the correct chunk,
|
||
// and then concatenate them.
|
||
chunks := make([][]byte, 4)
|
||
for i := 0; i < 4; i++ {
|
||
start := i * chunkSize
|
||
end := start + chunkSize
|
||
if end > fileSize {
|
||
end = fileSize
|
||
}
|
||
chunks[i] = append([]byte(nil), initialContent[start:end]...)
|
||
}
|
||
|
||
// Helper to insert into a specific chunk
|
||
insertInChunk := func(chunkIdx, offset int, txt string) {
|
||
chunk := chunks[chunkIdx]
|
||
newChunk := make([]byte, len(chunk)+len(txt))
|
||
copy(newChunk, chunk[:offset])
|
||
copy(newChunk[offset:], txt)
|
||
copy(newChunk[offset+len(txt):], chunk[offset:])
|
||
chunks[chunkIdx] = newChunk
|
||
}
|
||
|
||
// 1. "CHUNK1" (pos 65000 -> chunk 0, offset 65000)
|
||
insertInChunk(0, 65000, "CHUNK1")
|
||
|
||
// 2. "BOUNDARY" (pos 65535 -> chunk 0, offset 65535)
|
||
insertInChunk(0, 65535, "BOUNDARY")
|
||
|
||
// 3. "CHUNK3" (pos 262000 -> chunk 3, offset 262000 - 3*chunkSize = 65392)
|
||
insertInChunk(3, 262000-3*chunkSize, "CHUNK3")
|
||
|
||
// Concatenate chunks to get expected content
|
||
var expectedBuf bytes.Buffer
|
||
for _, chunk := range chunks {
|
||
expectedBuf.Write(chunk)
|
||
}
|
||
expected := expectedBuf.Bytes()
|
||
|
||
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))
|
||
}
|
||
}
|