package e2e_test import ( "fmt" "strings" "testing" "time" "gioui.org/io/key" "pad/internal/editor" "pad/internal/test/e2e" "pad/internal/ui" ) // uniformContent returns a file of n lines, each exactly 21 bytes: // "Lxxx" + 16 filler chars (derived from the line number, so every line is // unique) + "\n". Line i therefore starts at byte i*21. func uniformContent(n int) string { var b strings.Builder for i := 0; i < n; i++ { b.WriteString(fmt.Sprintf("L%03d", i)) b.WriteString(strings.Repeat(string(rune('a'+i%26)), 16)) b.WriteString("\n") } return b.String() } // The chunked-buffer window bookkeeping must stay in lockstep with the // visible window text: // - Editor.IMEWindowStartByte is the absolute byte where the window starts // (it offsets the IME's window-relative EditEvent.Range into the buffer); // - the TextField's SelectionStart/End are window-relative. // // A classic shadowing regression broke both (a `:=` inside the // `cb != nil` block of frameOf shadowed the outer start/end, so both were // always 0 for chunked files): at non-zero scroll the selection highlight // was mapped against byte 0 (it "jumped" to different text as the user // scrolled) and IME commits landed near the top of the file instead of at // the caret — silent data corruption. These tests scroll to a non-zero // window start and pin both mappings. // lastEditorTextField finds the editor_text TextField in the latest captured // frame and reports whether it was found. func lastEditorTextField(t *testing.T, h *e2e.Harness) (ui.TextField, bool) { t.Helper() frames := h.GetFrames() if len(frames) == 0 { t.Fatal("no frames captured") } for _, elem := range frames[len(frames)-1] { if tf, ok := elem.(ui.TextField); ok && tf.ID() == "editor_text" { return tf, true } } return ui.TextField{}, false } func TestRealFile_WindowSelectionAtScroll(t *testing.T) { const ( lines = 60 lineLen = 21 scrollLn = 10 // window starts at line 10 (byte 210) ) // 60 lines * 16.8 dp = 1008 dp of content; a 400 dp window gives a // ~304 dp editor region (~18 lines), so the file is scrollable and the // window at line 10 is fully inside the file. h, _ := realFileHarness(t, "win.txt", uniformContent(lines)) defer h.Cleanup() h.SendConfig(780, 400) if _, err := h.WaitForFrame(5 * time.Second); err != nil { t.Fatalf("wait for frame: %v", err) } // Select 6 filler chars on line 12 (well inside the window that starts // at line 10). Filler starts at in-line offset 4 (after "L012"). selStart := 12*lineLen + 9 // byte 261 selEnd := 12*lineLen + 15 // byte 267 // Scroll 5 dp INTO line 10: the app's line pitch is a float32 (16.8000007…) // that differs in the last bits from this file's float64 16.8, so scrolling // to the exact line boundary is fragile; an interior offset is robust. 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) } // Config re-triggers a frame (state changes alone do not emit frames). 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) The IME window start must be the window's absolute byte start. v, err := h.Inspect(func(st *editor.State) any { return st.Editor.IMEWindowStartByte }) if err != nil { t.Fatalf("Inspect: %v", err) } if got, want := v.(int), scrollLn*lineLen; got != want { t.Fatalf("IMEWindowStartByte = %d, want %d (window starts at line %d)", got, want, scrollLn) } // 2) The rendered window-relative selection must be offset by that same // start (the pre-fix code mapped it against byte 0). tf, ok := lastEditorTextField(t, h) if !ok { t.Fatal("no editor_text TextField in latest frame") } wantS, wantE := selStart-scrollLn*lineLen, selEnd-scrollLn*lineLen if tf.SelectionStart != wantS || tf.SelectionEnd != wantE { t.Fatalf("window selection = [%d,%d), want [%d,%d)", tf.SelectionStart, tf.SelectionEnd, wantS, wantE) } // The window text must actually begin at the window start. if want, got := "L010", tf.Value[:4]; got != want { t.Fatalf("window text starts with %q, want %q", got, want) } } func TestRealFile_IMECommitAtScroll(t *testing.T) { const ( lines = 60 lineLen = 21 scrollLn = 10 editLn = 12 ) h, path := realFileHarness(t, "win2.txt", uniformContent(lines)) defer h.Cleanup() h.SendConfig(780, 400) if _, err := h.WaitForFrame(5 * time.Second); err != nil { t.Fatalf("wait for frame: %v", err) } // The selection (absolute) and the matching window-relative EditEvent // range: the IME reports offsets relative to the pushed snippet, which // is the visible window. selStart := editLn*lineLen + 5 selEnd := editLn*lineLen + 11 // Scroll 5 dp into line 10 (see TestRealFile_WindowSelectionAtScroll for // why the offset avoids the exact line boundary). 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) } // Typing replaces the selection: "ZZ" over the 6 selected filler chars // (in-line offsets [5,11)) on line 12. The pre-fix code (windowStart=0) // applied this at byte 47 — line 2 — corrupting the file. h.SendInput([]ui.InputEvent{{ Handler: editor.HandleKeyDown, Data: key.EditEvent{ Range: key.Range{Start: selStart - scrollLn*lineLen, End: selEnd - scrollLn*lineLen}, Text: "ZZ", }, }}) time.Sleep(100 * time.Millisecond) got, err := h.FullContent() if err != nil { t.Fatalf("FullContent: %v", err) } linesOut := strings.Split(got, "\n") // Expectation derived from the original line: in-line [5,11) replaced. filler := strings.Repeat(string(rune('a'+editLn%26)), 16) origLn := fmt.Sprintf("L%03d", editLn) + filler wantLn12 := origLn[:5] + "ZZ" + origLn[11:] if linesOut[editLn] != wantLn12 { t.Fatalf("line %d = %q, want %q (IME commit must land in the scrolled window, not at byte 0)", editLn, linesOut[editLn], wantLn12) } // Line 2 must be untouched (the pre-fix corruption target). if want := fmt.Sprintf("L%03d", 2) + strings.Repeat("c", 16); linesOut[2] != want { t.Fatalf("line 2 = %q, want %q (must not be clobbered by a scrolled commit)", linesOut[2], want) } // Persistence check: the on-disk file matches. if err := h.Flush(); err != nil { t.Fatalf("flush: %v", err) } if disk := readDisk(t, path); disk != got { t.Fatalf("disk = %q, want in-memory content", disk) } }