Pad/internal/test/e2e/ime_key_edit_test.go
Greg Pomerantz a9183a0f73 IME: accept the one stale-caret commit after a key-event edit; fix the IME hold
Gboard sends Enter as a key event, not a commit, so the IME's model
lags the buffer by exactly one edit and its next commit is anchored at
the pre-edit caret. The drift guard snapped such commits to the new
caret, duplicating the just-typed word (2026-09-19 phone incident:
'a<enter>a' became 'A' + 'AaAa').

- state.go: track one pending non-IME content edit (imeStaleEditByte,
  armed by noteNonIMEEdit on Enter/backspace/delete/cut/paste, consumed
  by the next IME commit or key edit, cleared by a tap). The guard
  accepts a small (<= 2 rune) TEXT commit ending at or before the edit
  position as-is (IME STALE-OK): everything before the edit is
  byte-identical in both models. Empty-text commits and commits ending
  past the edit still snap to the caret.
- render.go + state.go + main.go: arm the pre-commit flush hold with the
  drained frame's own EditSeq. The hold was armed with the last drawn
  frame's seq while the frame's TextField.EditSeq was never set (always
  0), so every frame matched and the hold released only via its
  8-frame timeout - silently swallowing the resync pushes (and all IME
  sync) that heal the desync. EditorLayout now ships EditSeq with the
  frame's TextField; FlushIME releases on the next higher seq.
- e2e (ime_key_edit_test.go): reproduce the incident (same-batch and
  next-batch variants) and pin the guard edges (deletions and
  past-the-edit commits still snap).
- ime.md: document the key-event desync invariant and the hold.
2026-09-19 21:52:35 -04:00

174 lines
6.2 KiB
Go

package e2e_test
import (
"testing"
"time"
"gioui.org/io/key"
"pad/internal/editor"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// incidentContent is the file from the 2026-09-19 phone incident: "test"
// plus five newlines (9 bytes). The tap target is byte 6 (the start of
// line 3), exactly where the user tapped.
const incidentContent = "test\n\n\n\n\n"
func setCursor(t *testing.T, h *e2e.Harness, pos int) {
t.Helper()
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = pos
}); err != nil {
t.Fatalf("set cursor: %v", err)
}
}
// contentAfter sends the input batch (nil = none) and waits until the buffer
// content reaches want; the logic goroutine applies the events
// asynchronously, so a read straight after SendInput is a race.
func contentAfter(t *testing.T, h *e2e.Harness, events []ui.InputEvent, want string) string {
t.Helper()
if events != nil {
h.SendInput(events)
}
for i := 0; i < 300; i++ {
c, err := h.FullContent()
if err != nil {
t.Fatalf("FullContent: %v", err)
}
if c == want {
return c
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("buffer did not reach %q", want)
return ""
}
// TestIMECommitAfterEnterKeySameBatch is the exact phone incident: tap at
// byte 6, type "a" (Gboard autocaps and commits [6,6)→"A"), press Enter
// (Gboard sends it as a KEY EVENT, not a commit — the logic inserts "\n"
// and the caret moves to 8, but Gboard's local model still lacks it), then
// type "a" (Gboard, one edit behind, commits the word append [6,7)→"Aa"
// against the pre-Enter caret). The key event and the append commit arrive
// in the same input batch, so no frame has drawn between them.
//
// The append must be applied at its reported range — everything before the
// pending key edit is byte-identical in both models — instead of being
// drift-snapped to the new caret and duplicated (the incident produced
// "test\n\nA\nAaAa\n\n\n").
func TestIMECommitAfterEnterKeySameBatch(t *testing.T) {
h, _ := realFileHarness(t, "test.txt", incidentContent)
defer h.Cleanup()
setCursor(t, h, 6)
// "a" typed with autocap: one clean IME commit.
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{StartRune: 6, EndRune: 6, Text: "A"},
}}, "test\n\nA\n\n\n")
// Enter (key event) and the stale-model word append in ONE batch: the
// logic processes the "\n" first, so when the append is guarded the
// caret is already at 8 while the commit ends at 7. "A" must be replaced
// by "Aa" in place and the Enter newline survive after it.
contentAfter(t, h, []ui.InputEvent{
{Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameReturn}},
{Handler: editor.HandleIMECommit, Data: editor.IMECommit{StartRune: 6, EndRune: 7, Text: "Aa"}},
}, "test\n\nAa\n\n\n\n")
if pos, _ := h.CursorPosition(); pos != 8 {
t.Errorf("cursor: got %d want 8 (end of the replaced word)", pos)
}
}
// TestIMECommitAfterEnterKeyNextBatch is the same incident with the append
// commit arriving in a LATER input batch than the Enter: a frame was
// emitted for the Enter, but the resyncing snippet push only lands on the
// next drawn frame, and the IME's commit can beat it. The pending key edit
// must still be honored.
func TestIMECommitAfterEnterKeyNextBatch(t *testing.T) {
h, _ := realFileHarness(t, "test.txt", incidentContent)
defer h.Cleanup()
setCursor(t, h, 6)
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{StartRune: 6, EndRune: 6, Text: "A"},
}}, "test\n\nA\n\n\n")
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: ui.KeyEvent{Name: key.NameReturn},
}}, "test\n\nA\n\n\n\n")
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{StartRune: 6, EndRune: 7, Text: "Aa"},
}}, "test\n\nAa\n\n\n\n")
}
// TestIMEEmptyCommitStillSnappedWithPendingEdit: a pending key edit does
// NOT relax the guard for range deletions (empty text). Deleting at a range
// the IME sees but the app no longer expects there is how documents get
// clobbered; such commits must still snap to the caret (a no-op there).
func TestIMEEmptyCommitStillSnappedWithPendingEdit(t *testing.T) {
h, _ := realFileHarness(t, "test.txt", incidentContent)
defer h.Cleanup()
setCursor(t, h, 6)
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{StartRune: 6, EndRune: 6, Text: "A"},
}}, "test\n\nA\n\n\n")
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: ui.KeyEvent{Name: key.NameReturn},
}}, "test\n\nA\n\n\n\n")
// Gboard's stale model still shows "A" at [6,7) and asks to delete it.
// The commit must snap to the caret (a no-op there), so the "A"
// survives. The content is unchanged by a correct snap, so poll for
// quiescence (a new frame after the input) before asserting.
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{StartRune: 6, EndRune: 7, Text: ""},
}}, "test\n\nA\n\n\n\n")
before := h.FrameCount()
for h.FrameCount() == before {
time.Sleep(10 * time.Millisecond)
}
if c, _ := h.FullContent(); c != "test\n\nA\n\n\n\n" {
t.Fatalf("empty commit must snap to the caret (no-op), got %q", c)
}
}
// TestIMECommitPastPendingEditStillSnapped: a commit that ends PAST the
// pending key edit addresses text the IME's model does not have (it is
// shifted by the edit), so it is still anomalous and snaps to the caret.
func TestIMECommitPastPendingEditStillSnapped(t *testing.T) {
h, _ := realFileHarness(t, "test.txt", incidentContent)
defer h.Cleanup()
setCursor(t, h, 6)
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{StartRune: 6, EndRune: 6, Text: "A"},
}}, "test\n\nA\n\n\n")
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: ui.KeyEvent{Name: key.NameReturn},
}}, "test\n\nA\n\n\n\n")
// Ends at 9, past the pending edit at 7: the range crosses the edit, so
// the file content there is NOT what the IME sees. Snap to the caret
// (8): "x" lands at 8, not 9.
contentAfter(t, h, []ui.InputEvent{{
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{StartRune: 9, EndRune: 9, Text: "x"},
}}, "test\n\nA\nx\n\n\n")
}