Pad/internal/test/e2e/real_file_large_ime_test.go
Greg Pomerantz f54ca2f81a IME: map commits against the whole buffer; drop the renderer-side model
The renderer kept a mirror of the pushed IME snippet (the 'IME model')
to translate commit positions, but it transiently desynced from the
buffer on fling/tap sequences (observed as a few-byte mapping drift on
both the x86_64 emulator and the ARM phone), corrupting text. The model
string also sat on the main goroutine next to the JNI render path,
where the app observed states that were impossible for Go memory
(string contents changing between reads microseconds apart), pointing
at corruption in the native bridge layer.

Restructure along the lines of the Android InputConnection contract
and Gio's own reference editor (widget/editor.go):

- Commits carry absolute file runes (the pushed snippet's coordinate
  space) straight to the logic goroutine, which maps them to bytes
  against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan).
  Scrolling moves the window, not the buffer, so the mapping is exact
  mid-fling by construction — no mirror to desync.
- Drift guard in HandleIMECommit: a small commit (range <= 2 runes)
  is always anchored at the caret the IME was last told about; if the
  IME reports it ending elsewhere, its snippet text is stale (a
  dropped restartInput, as Gboard does during flings) and its
  position is in the stale text's coordinates — snap the commit to
  the cursor, the only position it cannot drift from.
- FlushIME simplifies to: push the snippet when the frame's
  (context+window) text differs from the last push (gioui dedupes
  against its own cache), force the selection re-push in the same
  frame. After a commit the frame text equals what the IME already
  holds locally, so the restart is naturally suppressed; a fling
  re-anchors the IME once per text change.
- Remove the renderer model (adoptFrame/ModelTranslate/
  ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle
  machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the
  window-relative imeRuneToByte.

Also fixed along the way (both found while chasing the corruption):

- real.ReadFileAt: loop over short reads. A single ReadAt on Android
  FUSE can return a short read, silently truncating a chunk and
  shifting every byte offset after it.
- logic: a late lazy-chunk result no longer clobbers a buffer that
  SetContent has already fully loaded.
- e2e: large-file IME test (1.6 MB file, fling + commit).
- app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio
  builds the mipmap/adaptive icon set.

Verified: go vet + staticcheck, go test -race (all packages), and the
emulator scenario loop (open moby excerpt, fling to mid-file, tap,
type 'a', byte-compare the saved file) 75/75 clean.
2026-09-13 11:57:38 -04:00

220 lines
6.8 KiB
Go

package e2e_test
import (
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"gioui.org/io/key"
"pad/internal/editor"
"pad/internal/ui"
)
// largeFileContent generates a deterministic ~1.6 MB text file: 40,000 lines
// of ~40 unique ASCII bytes each, with a multibyte rune ("é", "中") sprinkled
// through every 500th line so rune offsets and byte offsets genuinely differ.
// Generating in-test (t.TempDir, via realFileHarness) keeps the repository
// small while still exercising the >500 KB chunked/IME path the same way a
// real multi-hundred-kb note would.
func largeFileContent(lines int) string {
var b strings.Builder
for i := 0; i < lines; i++ {
b.WriteString(fmt.Sprintf("large file line %05d pad", i))
if i%500 == 0 {
b.WriteString(" é 中")
}
b.WriteString(strings.Repeat(string(rune('a'+i%26)), 12))
b.WriteString("\n")
}
return b.String()
}
// TestRealFile_LargeFile_IMECommits pins IME commit correctness on a large
// (~1.6 MB, well over the 500 KB threshold that the on-device "needs a
// gioui fork" claim referred to) file:
//
// - the IME window invariants (window text, leading context, absolute
// rune offset) hold for a window deep in the file;
// - a replacement commit (a selection, as a swipe/autocorrect would send)
// lands exactly in the scrolled window and nowhere else;
// - an insert commit (empty range at the caret, a plain typed char) in a
// second, deeper window lands exactly at the caret.
//
// The full-content comparison after each commit catches the classic
// corruption mode (the commit mapped through a stale/zero window start and
// clobbering text near the top of the file).
func TestRealFile_LargeFile_IMECommits(t *testing.T) {
const (
lines = 40000
scrollLn = 20000 // scroll deep into the file
editLn = 20008 // comfortably inside the ~18-line viewport window
)
model := largeFileContent(lines)
h, path := realFileHarness(t, "large.txt", model)
defer h.Cleanup()
h.SendConfig(780, 400)
if _, err := h.WaitForFrame(5 * time.Second); err != nil {
t.Fatalf("wait for frame: %v", err)
}
// Scroll deep (5 dp into the line: avoid the float32/float64 line-pitch
// boundary fragility, see TestRealFile_WindowSelectionAtScroll) and put a
// selection on line editLn, 6 bytes into its filler.
selStart := byteOffsetOfLine(t, model, editLn) + 31
selEnd := selStart + 6
if err := h.WithState(func(st *editor.State) {
st.ScrollOffset = ui.Dp(16.8*scrollLn + 5)
st.Editor.SelectionStart = selStart
st.Editor.SelectionEnd = selEnd
st.Editor.CursorPosition = selEnd
}); err != nil {
t.Fatalf("WithState: %v", err)
}
prev := h.FrameCount()
h.SendConfig(780, 400)
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
t.Fatalf("wait for scrolled frame: %v", err)
}
// 1) IME window invariants for a window deep in a large file.
var win struct {
Start int
Text string
CtxStart int
Context string
OffsetRune int
}
v, err := h.Inspect(func(st *editor.State) any {
e := &st.Editor
return struct {
Start int
Text string
CtxStart int
Context string
OffsetRune int
}{e.IMEWindowStartByte, e.IMEWindowText, e.IMEContextStartByte, e.IMEContext, e.IMEOffsetRune}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
win = v.(struct {
Start int
Text string
CtxStart int
Context string
OffsetRune int
})
if want := model[win.Start : win.Start+len(win.Text)]; win.Text != want {
t.Fatalf("IME window text desync at start=%d", win.Start)
}
if win.CtxStart+len(win.Context) != win.Start ||
win.Context != model[win.CtxStart:win.Start] {
t.Fatalf("IME context desync: ctxStart=%d ctx=%q", win.CtxStart, win.Context)
}
if want := utf8.RuneCountInString(model[:win.CtxStart]); win.OffsetRune != want {
t.Fatalf("IME offset rune = %d, want %d", win.OffsetRune, want)
}
// 2) Replacement commit: absolute file runes (the coordinate space the
// real IME uses; the multibyte sprinkles make runes != bytes).
absStart := utf8.RuneCountInString(model[:selStart])
absEnd := utf8.RuneCountInString(model[:selEnd])
wantModel := model[:selStart] + "ZZ" + model[selEnd:]
h.SendInput([]ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: key.EditEvent{
Range: key.Range{Start: absStart, End: absEnd},
Text: "ZZ",
},
}})
time.Sleep(100 * time.Millisecond)
got, err := h.FullContent()
if err != nil {
t.Fatalf("FullContent: %v", err)
}
if got != wantModel {
t.Fatalf("after replacement commit: content differs (first diff at %d)", firstDiff(wantModel, got))
}
// 3) Insert commit in a deeper window: caret-only (empty range), the
// plain "typed a character" case.
deepLn := 30000
caret := byteOffsetOfLine(t, wantModel, deepLn) + 20
if err := h.WithState(func(st *editor.State) {
st.ScrollOffset = ui.Dp(16.8*float64(deepLn) + 5)
st.Editor.SelectionStart = -1
st.Editor.SelectionEnd = -1
st.Editor.CursorPosition = caret
}); err != nil {
t.Fatalf("WithState: %v", err)
}
prev = h.FrameCount()
h.SendConfig(780, 400)
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
t.Fatalf("wait for second scrolled frame: %v", err)
}
absCaret := utf8.RuneCountInString(wantModel[:caret])
wantModel2 := wantModel[:caret] + "Q" + wantModel[caret:]
h.SendInput([]ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: key.EditEvent{
Range: key.Range{Start: absCaret, End: absCaret},
Text: "Q",
},
}})
time.Sleep(100 * time.Millisecond)
got, err = h.FullContent()
if err != nil {
t.Fatalf("FullContent: %v", err)
}
if got != wantModel2 {
t.Fatalf("after insert commit: content differs (first diff at %d)", firstDiff(wantModel2, got))
}
if cp, err := h.Inspect(func(st *editor.State) any { return st.Editor.CursorPosition }); err != nil {
t.Fatalf("Inspect: %v", err)
} else if cp.(int) != caret+1 {
t.Fatalf("cursor = %d, want %d", cp.(int), caret+1)
}
// 4) Persistence: the on-disk file matches the in-memory content.
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != wantModel2 {
t.Fatalf("disk content differs from in-memory (first diff at %d)", firstDiff(wantModel2, disk))
}
}
// byteOffsetOfLine returns the absolute byte offset of the start of line n
// (0-indexed) in model. The generated lines are unique, so a plain index of
// the line prefix is unambiguous.
func byteOffsetOfLine(t *testing.T, model string, n int) int {
t.Helper()
prefix := fmt.Sprintf("large file line %05d", n)
i := strings.Index(model, prefix)
if i < 0 {
t.Fatalf("line %d not found", n)
}
return i
}
// firstDiff returns the byte offset of the first difference between a and b
// (min(len) when they are a common prefix), for useful failure messages.
func firstDiff(a, b string) int {
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
return i
}
}
return n
}