After the selection-replacement autocorrect on the phone, Gboard's local text was out of sync with ours and it re-sent the same empty fix-up commit in an endless loop (~one per 150 ms, each drift-snapped to the caret and applied as a no-op). The file was never damaged, but the IME never converged because it kept 'fixing' text that did not exist in its own model. The app cannot see the IME's model; the only recovery the IME contract offers is a restartInput, which makes it re-fetch the real text and selection around the caret. Arm that recovery automatically: three consecutive anomalous commits (drift-snapped, or empty text) set IMEForceResync, and the next frame ships the snippet trimmed by one rune, which changes the pushed text and forces the restart. The streak resets on any normal commit, so isolated anomalies never trigger it, and the resync is one-shot. TestRealFile_IMEForceResync pins the arm/reset/consume cycle.
304 lines
9.8 KiB
Go
304 lines
9.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
|
|
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 self-heal: after several
|
|
// consecutive anomalous IME commits (drift-snapped or empty — the pattern
|
|
// Gboard produces when its local text has desynchronized from the app and it
|
|
// keeps re-sending the same fix), the next frame arms a forced snippet
|
|
// re-push (a restartInput that makes the IME re-fetch the real text). A
|
|
// normal commit resets the streak, so isolated anomalies never resync.
|
|
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)
|
|
}
|
|
|
|
// A caret deep in the file, in absolute file runes.
|
|
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 goroutine 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 reports whether the latest delivered frame's editor
|
|
// element carries the forced-resync flag (the state flag is consumed by
|
|
// the very frame that follows the arming commit, so the frame is the
|
|
// observable artifact).
|
|
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
|
|
}
|
|
|
|
// Two anomalous commits (an empty fix-up and a drifted insertion):
|
|
// below the threshold, no resync.
|
|
commit(caretRune+5, caretRune+5, "")
|
|
commit(caretRune+6, caretRune+6, "z")
|
|
if forceInLatestFrame() {
|
|
t.Fatalf("resync armed after only 2 anomalous commits")
|
|
}
|
|
|
|
// A normal commit resets the streak: two more anomalies must not arm.
|
|
commit(caretRune+10, caretRune+10, "q")
|
|
commit(caretRune+11, caretRune+11, "")
|
|
commit(caretRune+12, caretRune+12, "")
|
|
if forceInLatestFrame() {
|
|
t.Fatalf("resync armed after a reset streak")
|
|
}
|
|
|
|
// One more anomaly makes three in a row: the frame emitted by that
|
|
// commit carries the flag on its editor element (the state flag is
|
|
// consumed by that same frame).
|
|
commit(caretRune+13, caretRune+13, "")
|
|
if !forceInLatestFrame() {
|
|
t.Fatalf("resync not armed after 3 consecutive anomalous commits")
|
|
}
|
|
|
|
// A later frame (no new anomalies) carries no flag: the resync fires
|
|
// exactly once.
|
|
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.Fatalf("resync flag not one-shot")
|
|
}
|
|
}
|