Pad/internal/test/e2e/real_file_edit_test.go
Greg Pomerantz ec11abf8f1 Add text selection, real-file e2e tests, and Android arrow-key support
Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.

Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.

Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
  1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
     involving newlines left it permanently inconsistent. Replaced with
     newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
  2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
     corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
     straddling a chunk boundary). Now rune-granular.

Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).

Full suite green under -race; on-device verified.
2026-08-17 00:32:53 -04:00

427 lines
13 KiB
Go

package e2e_test
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gioui.org/io/key"
"pad/internal/editor"
"pad/internal/io/pool/real"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// realFileHarness writes content to <tempdir>/name, builds a harness whose
// browser root and editor both use the real filesystem, opens /name and waits
// for the load + line index. Returns the harness and the on-disk path.
func realFileHarness(t *testing.T, name, content string) (*e2e.Harness, string) {
t.Helper()
dir := t.TempDir()
diskPath := filepath.Join(dir, name)
if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Run()
if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil {
t.Fatalf("GoToBrowser: %v", err)
}
time.Sleep(100 * time.Millisecond)
if err := h.WithState(func(st *editor.State) { editor.OpenFile("/" + name) }); err != nil {
t.Fatalf("OpenFile: %v", err)
}
// "Loaded" means both async results are fully applied: the ReadFile
// content (len(FullContent) == FileLen) AND the BuildLineIndex result
// (LineIndex.Size == FileLen). Waiting on FileLen>0 && LineIndex!=nil
// alone is a race: stat sets FileLen, and the two worker results can
// interleave with test edits.
loaded := false
for i := 0; i < 100; i++ {
v, err := h.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
return false
}
full, err := cb.FullContent()
return err == nil && int64(len(full)) == cb.FileLen() &&
cb.LineIndex.Size == cb.FileLen()
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(bool) {
loaded = true
break
}
time.Sleep(50 * time.Millisecond)
}
if !loaded {
t.Fatal("timed out waiting for real file to load")
}
return h, diskPath
}
// readDisk reads the on-disk content of a test file.
func readDisk(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading disk: %v", err)
}
return string(data)
}
// TestRealFile_BasicInsert verifies an insert on a small real file lands in
// the buffer, flushes, and matches on disk.
func TestRealFile_BasicInsert(t *testing.T) {
h, path := realFileHarness(t, "small.txt", "hello world")
defer h.Cleanup()
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = 5
editor.HandleInsert(" there")
}); err != nil {
t.Fatalf("insert: %v", err)
}
got, _ := h.FullContent()
if got != "hello there world" {
t.Fatalf("buffer = %q, want %q", got, "hello there world")
}
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != "hello there world" {
t.Fatalf("disk = %q, want %q", disk, "hello there world")
}
}
// TestRealFile_BackspaceAndDelete verifies deletion edits on a real file,
// including across the line-index update path.
func TestRealFile_BackspaceAndDelete(t *testing.T) {
h, path := realFileHarness(t, "small.txt", "0123456789")
defer h.Cleanup()
// Backspace at 5 deletes '4' (index 4); cursor moves to 4 (before '5').
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = 5
editor.HandleBackspace()
}); err != nil {
t.Fatalf("backspace: %v", err)
}
got, _ := h.FullContent()
if got != "012356789" {
t.Fatalf("buffer = %q, want %q", got, "012356789")
}
// Delete at cursor 4 removes '5' (the character now at index 4).
if err := h.WithState(func(st *editor.State) {
editor.HandleDelete()
}); err != nil {
t.Fatalf("delete: %v", err)
}
got, _ = h.FullContent()
if got != "01236789" {
t.Fatalf("buffer = %q, want %q", got, "01236789")
}
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != "01236789" {
t.Fatalf("disk = %q, want %q", disk, "01236789")
}
}
// TestRealFile_IMEReplaceRange simulates an IME commit replacing a range
// (swipe-to-delete + replacement semantics) on a real file.
func TestRealFile_IMEReplaceRange(t *testing.T) {
h, path := realFileHarness(t, "small.txt", "hello world")
defer h.Cleanup()
// IME replaces [0,5) "hello" with "goodbye".
if err := h.WithState(func(st *editor.State) {
editor.HandleReplaceRange(0, 5, "goodbye")
}); err != nil {
t.Fatalf("replace: %v", err)
}
got, _ := h.FullContent()
if got != "goodbye world" {
t.Fatalf("buffer = %q, want %q", got, "goodbye world")
}
if pos, _ := h.CursorPosition(); pos != 7 {
t.Fatalf("cursor = %d, want 7", pos)
}
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != "goodbye world" {
t.Fatalf("disk = %q, want %q", disk, "goodbye world")
}
}
// TestRealFile_IMEInsertAtCaret simulates a plain IME commit (empty range at
// the caret) through the real HandleKeyDown event path.
func TestRealFile_IMEInsertAtCaret(t *testing.T) {
h, path := realFileHarness(t, "small.txt", "abc")
defer h.Cleanup()
if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 3 }); err != nil {
t.Fatalf("set cursor: %v", err)
}
// Go through the full event handler, as main.go delivers it.
h.SendInput([]ui.InputEvent{
{Handler: editor.HandleKeyDown, Data: key.EditEvent{Range: key.Range{Start: 3, End: 3}, Text: "def"}},
})
time.Sleep(50 * time.Millisecond)
got, _ := h.FullContent()
if got != "abcdef" {
t.Fatalf("buffer = %q, want %q", got, "abcdef")
}
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != "abcdef" {
t.Fatalf("disk = %q, want %q", disk, "abcdef")
}
}
// TestRealFile_AutosavePersists verifies the 1s-debounced autosave writes the
// edit to disk without an explicit Flush.
func TestRealFile_AutosavePersists(t *testing.T) {
h, path := realFileHarness(t, "small.txt", "before")
defer h.Cleanup()
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = 6
editor.HandleInsert("-after")
}); err != nil {
t.Fatalf("insert: %v", err)
}
// Wait for the autosave debounce (1 s) plus margin.
deadline := time.Now().Add(5 * time.Second)
for {
if disk := readDisk(t, path); disk == "before-after" {
break
}
if time.Now().After(deadline) {
t.Fatalf("disk = %q, want %q (autosave did not fire)", readDisk(t, path), "before-after")
}
time.Sleep(100 * time.Millisecond)
}
}
// TestRealFile_ShiftSelectionInsert verifies shift+arrow selection through the
// real key event path and that typing replaces the selection; also checks the
// selection reaches the rendered TextField element.
func TestRealFile_ShiftSelectionInsert(t *testing.T) {
h, path := realFileHarness(t, "small.txt", "hello world")
defer h.Cleanup()
if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 0 }); err != nil {
t.Fatalf("set cursor: %v", err)
}
// Select "hello" (5 chars) with shift+right.
for i := 0; i < 5; i++ {
h.SendInput([]ui.InputEvent{
{Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameRightArrow, Shift: true}},
})
}
time.Sleep(50 * time.Millisecond)
// Selection must be visible in state...
v, err := h.Inspect(func(st *editor.State) any {
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
})
if err != nil {
t.Fatalf("inspect: %v", err)
}
sel := v.([2]int)
s, e := sel[0], sel[1]
if s != 0 || e != 5 {
t.Fatalf("selection = [%d,%d), want [0,5)", s, e)
}
// ...and in the rendered TextField element (window-relative; at scroll 0
// window == file, so same values). Only the latest frame matters: earlier
// frames predate the selection.
time.Sleep(100 * time.Millisecond) // let the post-input frame be captured
frames := h.GetFrames()
last := frames[len(frames)-1]
var found bool
for _, elem := range last {
if tf, ok := elem.(ui.TextField); ok && tf.ID() == "editor_text" {
found = true
if tf.SelectionStart != 0 || tf.SelectionEnd != 5 {
t.Fatalf("TextField selection = [%d,%d), want [0,5)", tf.SelectionStart, tf.SelectionEnd)
}
}
}
if !found {
t.Fatal("no editor_text TextField in latest frame")
}
// Typing replaces the selection.
h.SendInput([]ui.InputEvent{
{Handler: editor.HandleKeyDown, Data: key.EditEvent{Range: key.Range{Start: 5, End: 5}, Text: "goodbye"}},
})
time.Sleep(50 * time.Millisecond)
got, _ := h.FullContent()
if got != "goodbye world" {
t.Fatalf("buffer = %q, want %q", got, "goodbye world")
}
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != "goodbye world" {
t.Fatalf("disk = %q, want %q", disk, "goodbye world")
}
}
// TestRealFile_ShiftSelectionBackspace verifies backspace deletes a
// shift-selected range through the real key event path.
func TestRealFile_ShiftSelectionBackspace(t *testing.T) {
h, path := realFileHarness(t, "small.txt", "0123456789")
defer h.Cleanup()
if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 2 }); err != nil {
t.Fatalf("set cursor: %v", err)
}
// Shift+right x3 selects [2,5) = "234".
for i := 0; i < 3; i++ {
h.SendInput([]ui.InputEvent{
{Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameRightArrow, Shift: true}},
})
}
h.SendInput([]ui.InputEvent{
{Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameDeleteBackward, Shift: false}},
})
time.Sleep(50 * time.Millisecond)
got, _ := h.FullContent()
if got != "0156789" {
t.Fatalf("buffer = %q, want %q", got, "0156789")
}
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != "0156789" {
t.Fatalf("disk = %q, want %q", disk, "0156789")
}
}
// TestRealFile_MultilineEdit verifies insert and backspace across line
// boundaries keep the line index consistent (line count + offsets).
func TestRealFile_MultilineEdit(t *testing.T) {
h, path := realFileHarness(t, "multi.txt", "aaa\nbbb\nccc")
defer h.Cleanup()
// Newline at end of line 1 -> 4 lines.
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = 7 // after "bbb"
editor.HandleInsert("\n")
}); err != nil {
t.Fatalf("insert: %v", err)
}
checkLineIndex(t, h, "aaa\nbbb\n\nccc", 4)
// Backspace #1 removes the inserted '\n' (cursor is right after it).
if err := h.WithState(func(st *editor.State) {
editor.HandleBackspace()
}); err != nil {
t.Fatalf("backspace 1: %v", err)
}
checkLineIndex(t, h, "aaa\nbbb\nccc", 3)
// Backspace #2 removes the trailing 'b' of line 2.
if err := h.WithState(func(st *editor.State) {
editor.HandleBackspace()
}); err != nil {
t.Fatalf("backspace 2: %v", err)
}
checkLineIndex(t, h, "aaa\nbb\nccc", 3)
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != "aaa\nbb\nccc" {
t.Fatalf("disk = %q, want %q", disk, "aaa\nbb\nccc")
}
}
// checkLineIndex verifies buffer content and that the incrementally-maintained
// line index matches the expected line count and per-line offsets.
// lo/hi/seg bound a failure-report window around offset at.
func lo(at, n int) int {
if at < n {
return 0
}
return at - n
}
func hi(at, n int) int { return at + n }
func seg(s string, at, n int) string {
start := lo(at, n)
end := at + n
if end > len(s) {
end = len(s)
}
if start > end {
start = end
}
return s[start:end]
}
func checkLineIndex(t *testing.T, h *e2e.Harness, wantContent string, wantLines int) {
t.Helper()
got, err := h.FullContent()
if err != nil {
t.Fatalf("FullContent: %v", err)
}
if got != wantContent {
// Report compactly: large files make %q messages megabytes long.
at := 0
for at < len(got) && at < len(wantContent) && got[at] == wantContent[at] {
at++
}
t.Fatalf("buffer mismatch at byte %d: got %d bytes, want %d\ngot [%d:%d]=%q\nwant [%d:%d]=%q",
at, len(got), len(wantContent), lo(at, 40), hi(at, 40), seg(got, at, 40), lo(at, 40), hi(at, 40), seg(wantContent, at, 40))
}
diag, err := h.Inspect(func(st *editor.State) any {
li := st.Editor.ChunkedBuffer.LineIndex
if li == nil {
return "nil index"
}
if li.LineCount() != wantLines {
return fmt.Sprintf("linecount=%d want=%d size=%d head=%v", li.LineCount(), wantLines, li.Size, li.Offsets[:min(10, len(li.Offsets))])
}
head := ""
if len(li.Offsets) > 10 {
head = fmt.Sprintf(" head=%v", li.Offsets[:10])
}
// Offsets must match a fresh computation from the content.
offset := 0
for line := 0; line < wantLines; line++ {
if li.ByteOffset(line) != offset {
return fmt.Sprintf("mismatch at line %d: index=%d want=%d size=%d%s",
line, li.ByteOffset(line), offset, li.Size, head)
}
idx := strings.IndexByte(got[offset:], '\n')
if idx < 0 {
break
}
offset += idx + 1
}
return "ok"
})
if err != nil {
t.Fatalf("inspect: %v", err)
}
if diag.(string) != "ok" {
t.Fatalf("line index inconsistent: %s (content=%d bytes, want %d lines)", diag, len(wantContent), wantLines)
}
}