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) } }