Pad/internal/test/e2e/real_file_large_ime_test.go
Greg Pomerantz a83cc1a72f IME: guard distant commits; arm the resync on the first anomaly
The boundary trace (PADIME at EditorStateChanged) showed why a
desynchronized IME never heals itself: gioui's EditorReplace advances
its stored selection state when the commit arrives, so the
post-commit selection push is always deduplicated away and
updateSelection is never sent after a commit. A desynced IME is
therefore healed only by a snippet restart — and the resync was
armed only after THREE consecutive anomalies, while the phone
incident's first anomaly (a 5-rune autocorrect 19,000 runes from
the caret, width > 2) sailed through the old <=2-rune guard and
clobbered distant text before any resync could fire.

Now:
- the guard also snaps commits that are far from the caret AND
  outside the live selection (a legitimate commit is always local:
  at the caret, inside the selection, or a nearby correction);
  a distant commit lands at the caret instead of clobbering text;
- the resync is armed on the FIRST anomalous commit (snapped or
  distant), not after a streak; the streak counter is gone;
- imeSelectionRuneRange() lets the guard allow selection
  replacements anywhere.

TestRealFile_IMEForceResync rewritten for the new semantics and
extended with the distant-replacement case (must land at the
caret and arm the resync).
2026-09-13 22:31:28 -04:00

308 lines
9.7 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
}
// TestRealFile_IMEForceResync pins the desync recovery: the first
// anomalous IME commit (a drift-snapped small commit, a distant commit —
// the phone autocorrect that landed 19,000 runes from the caret — or an
// empty fix-up) arms a forced re-syncing snippet re-push on the very next
// frame (a desynced IME is healed only by a restart; a selection push is
// deduplicated away after commits). A normal commit never arms it.
func TestRealFile_IMEForceResync(t *testing.T) {
model := largeFileContent(4000)
h, _ := realFileHarness(t, "resync.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)
}
const caretByte = 10000
caretRune := utf8.RuneCountInString(model[:caretByte])
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = caretByte
}); err != nil {
t.Fatalf("WithState: %v", err)
}
commit := func(start, end int, text string) {
h.SendInput([]ui.InputEvent{{
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{StartRune: start, EndRune: end, Text: text},
}})
// The logic applies commits asynchronously; wait for the resulting
// frame before asserting on the derived state.
prev := h.FrameCount()
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
t.Fatalf("wait for commit frame: %v", err)
}
}
forceInLatestFrame := func() bool {
frames := h.GetFrames()
if len(frames) == 0 {
t.Fatal("no frames")
}
for _, el := range frames[len(frames)-1] {
if tf, ok := el.(ui.TextField); ok && tf.ID() == "editor_text" {
return tf.IMEForceResync
}
}
t.Fatal("no editor_text element in latest frame")
return false
}
// A normal commit at the caret arms nothing.
commit(caretRune, caretRune, "q")
if forceInLatestFrame() {
t.Fatal("resync armed by a normal commit")
}
// A small drifted commit (Gboard fix-up at a stale position): the frame
// emitted by that commit carries the resync flag.
commit(caretRune+5, caretRune+5, "")
if !forceInLatestFrame() {
t.Fatal("resync not armed after a drift-snapped commit")
}
// One-shot: a later frame carries no flag.
prev := h.FrameCount()
h.SendConfig(780, 400)
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
t.Fatalf("wait for frame: %v", err)
}
if forceInLatestFrame() {
t.Fatal("resync flag not one-shot")
}
// A distant replacement commit (width > 2: the autocorrect class that
// the old guard let through) is snapped to the caret and arms the
// resync.
commit(caretRune+20000, caretRune+20005, "silly")
if !forceInLatestFrame() {
t.Fatal("resync not armed after a distant commit")
}
// The distant commit must have landed at the caret, not at 20000:
// the caret advances by exactly the inserted text length.
var cur int
if err := h.WithState(func(st *editor.State) {
cur = st.Editor.CursorPosition
}); err != nil {
t.Fatalf("WithState: %v", err)
}
// "q" (1) + "silly" (5) inserted at the original caret.
if want := caretByte + 6; cur != want {
t.Fatalf("cursor = %d, want %d (distant commit must land at the caret)", cur, want)
}
}