Pad/internal/test/e2e/real_file_large_ime_test.go
Greg Pomerantz dd9493bdbd IME: fix random mid-word capitalization (stable snippet window + commit in-flight handling)
Gboard capitalizes the first character of a fresh input session and
derives its word context from the text it holds locally; it never
queries the app for context while typing. The editor was resetting that
session on essentially every event, so Gboard re-anchored to a fresh
session mid-word and caps went random:

- The pushed snippet WAS the render window. Any viewport change (keyboard
  show/hide animation, tap re-centering, scroll) changed the snippet, and
  gioui turns every snippet change into imm.restartInput — a full IME
  session reset. The snippet is now a hysteresis window around the caret
  (32 KB, re-anchor only when the caret is within 4 KB of an edge),
  decoupled from the render window: typing, taps within range, keyboard
  animation and flings never re-push it.
- gioui's EditEvent callback applies the commit to its own window state
  directly, so the op queue lags it by one commit; any frame event in
  the gap regressed the state and sent a restartInput with pre-commit
  text per keystroke. The drained commit is now applied to the pushed
  model immediately (Renderer.ApplyIMECommitToModel), and a short hold
  keeps stale pre-commit frames out of FlushIME until the logic's
  post-commit frame arrives.
- The layout feedback loop re-emitted on exact float equality of the
  derived last-line Y, spinning a re-emit -> shape -> re-emit loop
  (float-sum noise) that re-drew the editor and re-pushed IME state;
  gate it with a 0.5 dp epsilon.
- The render window bottom mapped through the WrapIndex whose
  in-viewport counts land while this very window is being shaped: the
  bottom oscillated frame to frame, resizing the window (and the old
  snippet) every frame. Use a fixed line span from the stable top
  instead (each logical line yields >= 1 visual line, so the viewport is
  always covered).

Result: zero snippet re-pushes during typing or flings (one re-anchor at
a far tap/file switch); emulator typing tests show all-lowercase
mid-word commits ('thaaaaaaaae', 'vapoaaaaaaaar') and the stress suite
(7 scenarios) passes clean with contiguous insertions only.
2026-09-13 17:00:49 -04:00

213 lines
6.6 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
Snip string
}
v, err := h.Inspect(func(st *editor.State) any {
e := &st.Editor
return struct {
Start int
Text string
Snip string
}{e.IMEWindowStartByte, e.IMEWindowText, e.IMESnippetText}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
win = v.(struct {
Start int
Text string
Snip string
})
if want := model[win.Start : win.Start+len(win.Text)]; win.Text != want {
t.Fatalf("IME window text desync at start=%d", win.Start)
}
// The IME snippet is a hysteresis window around the caret (see
// State.computeIMESnippetWindow); it must always be a fresh slice of
// the model.
if i := strings.Index(model, win.Snip); i < 0 {
t.Fatalf("IME snippet not a slice of the model: %q", win.Snip[:min(40, len(win.Snip))])
}
// 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
}