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