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).
308 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|