Pad/internal/editor/e2e_test.go
Greg Pomerantz 58725a5e6c Make state single-owner and stabilize race detector
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.
2026-08-16 01:32:27 -04:00

299 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
}
}