Finish Phase 1 items 1, 2, 4, on top of the range-handling done in 9b78219:
- TextField.Draw now emits, when focused:
* key.InputHintOp{HintText} (item 4: enable text keyboard/autocorrect)
* key.SnippetCmd (the visible window as the snippet, Range {0,len})
(item 2: swipe/autocorrect source)
* key.SelectionCmd (caret, window-relative rune index)
(item 1: IME selection sync)
The snippet is the visible window (not the whole file), so the IME treats
the window as the document and reports EditEvent.Range window-relative.
- HandleReplaceRange now resolves the window-relative range against
IMEWindowText and offsets by IMEWindowStartByte to address the buffer
(string and chunked paths). Falls back to the whole buffer when layout
has not set the window (tests).
- EditorState gains IMEWindowStartByte / IMEWindowText, set during layout.
- Add runeCount helper (utf8 leading-byte scan) in the ui package.
- Fix a data race in three editor e2e/integration tests: they called
OpenFile from the test goroutine while the logic goroutine ran layout;
now wrapped in withState so the state write happens on the owner.
Tests: ime_range_test.go gains windowed-path coverage (string+chunked).
go build, go test, go vet, and go test -race are all green.
305 lines
7.6 KiB
Go
305 lines
7.6 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 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))
|
||
}
|
||
}
|