Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
275 lines
7.0 KiB
Go
275 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 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))
|
||
}
|
||
}
|