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