The find bar (32..72) used to be drawn over the top of the text area, covering the first visible line. EditorLayout now lowers the text area top by FindBarHeight (new shared constant) while the find bar is visible; it snaps back on close. e2e: assert the editor region top moves to 32+FindBarHeight while open.
223 lines
7.5 KiB
Go
223 lines
7.5 KiB
Go
package e2e_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pad/internal/editor"
|
|
"pad/internal/test/e2e"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// findContent is the file the in-file search e2e tests open. "needle"
|
|
// occurs 3 times, at byte offsets 0, 25, and 50.
|
|
const findContent = "needle at start\n" +
|
|
"some filler line here\n" +
|
|
"needle in the middle\n" +
|
|
"another filler line\n" +
|
|
"needle at the end"
|
|
|
|
// frameHasFindBar reports whether the latest frame carries the find bar
|
|
// (the "find_bar" GioEditor inside the find-bar container).
|
|
func frameHasFindBar(h *e2e.Harness) bool {
|
|
for _, el := range e2e.GetLastFrame(h) {
|
|
c, ok := el.(ui.Container)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, ch := range c.Children {
|
|
if ge, ok := ch.(ui.GioEditor); ok && ge.ID() == "find_bar" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// waitForFindMatches polls the logic state until the find scan has produced
|
|
// n matches (the scan runs on the worker pool and the result lands on the
|
|
// logic goroutine asynchronously).
|
|
func waitForFindMatches(t *testing.T, h *e2e.Harness, n int) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
v, err := h.Inspect(func(st *editor.State) any {
|
|
return len(st.Editor.Find.Matches)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Inspect: %v", err)
|
|
}
|
|
if v.(int) == n {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for %d find matches", n)
|
|
}
|
|
|
|
func TestRealFile_FindBarAndNavigation(t *testing.T) {
|
|
h, _ := realFileHarness(t, "find.txt", findContent)
|
|
defer h.Cleanup()
|
|
|
|
h.SendConfig(780, 1688)
|
|
if _, err := h.WaitForFrame(5 * time.Second); err != nil {
|
|
t.Fatalf("wait for frame: %v", err)
|
|
}
|
|
if ok, _ := h.FileLoaded(); !ok {
|
|
t.Fatal("file not loaded")
|
|
}
|
|
|
|
// Open the find bar via the top-bar icon handler.
|
|
h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: ui.Point{}}})
|
|
if _, err := h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second); err != nil {
|
|
t.Fatalf("wait for find-bar frame: %v", err)
|
|
}
|
|
if !frameHasFindBar(h) {
|
|
t.Fatal("find bar not in frame after ToggleFind")
|
|
}
|
|
focus, _ := h.Inspect(func(st *editor.State) any { return st.FocusedElementID })
|
|
if focus.(string) != "find_bar" {
|
|
t.Fatalf("focus %q, want find_bar", focus)
|
|
}
|
|
// The text area must shrink from the top while the find bar is open so
|
|
// the first line is not covered: region top = top bar (32) + FindBarHeight.
|
|
reg, _ := h.Inspect(func(st *editor.State) any { return st.EditorRegion })
|
|
if got := reg.(ui.Region); got.Y != ui.Dp(32)+editor.FindBarHeight {
|
|
t.Fatalf("editor region top %d, want %d (find bar must not cover text)", int(got.Y), int(ui.Dp(32)+editor.FindBarHeight))
|
|
}
|
|
|
|
// Type the query through the same channel main uses.
|
|
h.Logic().FindQueryChan() <- "NEEDLE" // case-insensitive
|
|
waitForFindMatches(t, h, 3)
|
|
|
|
// The first discovery already selects the first match (Cur=0).
|
|
sel, _ := h.Inspect(func(st *editor.State) any {
|
|
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
|
|
})
|
|
if sel.([2]int) != [2]int{0, 6} {
|
|
t.Fatalf("selection after first discovery %v, want [0,6)", sel)
|
|
}
|
|
if got := findContent[:6]; got != "needle" {
|
|
t.Fatalf("test content drift: %q", got)
|
|
}
|
|
|
|
// Next -> the second match (byte 38); the selection text must be a
|
|
// match, whatever the offset.
|
|
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
|
|
sel, _ = h.Inspect(func(st *editor.State) any {
|
|
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
|
|
})
|
|
if want := strings.Index(findContent[6:], "needle") + 6; sel.([2]int) != [2]int{want, want + 6} {
|
|
t.Fatalf("selection %v, want [%d,%d)", sel, want, want+6)
|
|
}
|
|
|
|
// Next -> the third; next again wraps to the FIRST.
|
|
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
|
|
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
|
|
sel, _ = h.Inspect(func(st *editor.State) any {
|
|
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
|
|
})
|
|
if sel.([2]int) != [2]int{0, 6} {
|
|
t.Fatalf("selection %v, want [0,6) (wrapped to first)", sel)
|
|
}
|
|
|
|
// Prev from the first wraps to the LAST.
|
|
h.SendInput([]ui.InputEvent{{Handler: editor.FindPrev, Data: ui.Point{}}})
|
|
nMatches, _ := h.Inspect(func(st *editor.State) any { return len(st.Editor.Find.Matches) })
|
|
last := nMatches.(int) - 1
|
|
cur, _ := h.Inspect(func(st *editor.State) any { return st.Editor.Find.Cur })
|
|
if cur.(int) != last {
|
|
t.Fatalf("cur %d, want last (wrapped)", cur)
|
|
}
|
|
|
|
// A query with no hits reports zero matches.
|
|
h.Logic().FindQueryChan() <- "no-such-text"
|
|
waitForFindMatches(t, h, 0)
|
|
|
|
// Close the find bar: it leaves the frame and focus returns to the
|
|
// editor.
|
|
h.SendInput([]ui.InputEvent{{Handler: editor.FindClose, Data: ui.Point{}}})
|
|
if _, err := h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second); err != nil {
|
|
t.Fatalf("wait for close frame: %v", err)
|
|
}
|
|
if frameHasFindBar(h) {
|
|
t.Fatal("find bar still in frame after close")
|
|
}
|
|
focus, _ = h.Inspect(func(st *editor.State) any { return st.FocusedElementID })
|
|
if focus.(string) != "editor_text" {
|
|
t.Fatalf("focus %q, want editor_text after close", focus)
|
|
}
|
|
}
|
|
|
|
func TestRealFile_FindMatchesSurviveEdits(t *testing.T) {
|
|
h, _ := realFileHarness(t, "find_edit.txt", findContent)
|
|
defer h.Cleanup()
|
|
|
|
h.SendConfig(780, 1688)
|
|
if ok, _ := h.FileLoaded(); !ok {
|
|
t.Fatal("file not loaded")
|
|
}
|
|
|
|
h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: ui.Point{}}})
|
|
h.Logic().FindQueryChan() <- "needle"
|
|
waitForFindMatches(t, h, 3)
|
|
|
|
// Insert 5 bytes at the start: ALL matches shift by 5, none is dropped
|
|
// (the edit is outside every match), and no re-scan happens. Clear the
|
|
// first-discovery selection first: HandleInsert REPLACES an active
|
|
// selection, which would eat match 0.
|
|
if err := h.WithState(func(st *editor.State) {
|
|
editor.ClearSelection()
|
|
st.Editor.CursorPosition = 0
|
|
}); err != nil {
|
|
t.Fatalf("WithState: %v", err)
|
|
}
|
|
h.SendInput([]ui.InputEvent{{Handler: func(data any) { editor.HandleInsert(data.(string)) }, Data: "12345"}})
|
|
m, _ := h.Inspect(func(st *editor.State) any {
|
|
return st.Editor.Find.Matches
|
|
})
|
|
got := m.([][2]int)
|
|
want := [][2]int{}
|
|
for _, mm := range initialNeedleMatches(t) {
|
|
want = append(want, [2]int{mm[0] + 5, mm[1] + 5})
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("matches %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("match[%d] = %v, want %v (shifted by the insert)", i, got[i], want[i])
|
|
}
|
|
}
|
|
|
|
// An edit INSIDE the first (now shifted) match drops only that match.
|
|
// Match 0 is now at [5,11); put the caret in its middle and insert.
|
|
if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 7 }); err != nil {
|
|
t.Fatalf("WithState: %v", err)
|
|
}
|
|
h.SendInput([]ui.InputEvent{{Handler: func(data any) { editor.HandleInsert(data.(string)) }, Data: "X"}})
|
|
waitForFindMatches(t, h, 2)
|
|
m, _ = h.Inspect(func(st *editor.State) any { return st.Editor.Find.Matches })
|
|
got = m.([][2]int)
|
|
// The surviving matches (at 43 and 84 after the +5 shift) sit after the
|
|
// edit at byte 7, so they shift by 1 more.
|
|
if got[0] != [2]int{44, 50} || got[1] != [2]int{85, 91} {
|
|
t.Fatalf("surviving matches %v, want [44,50) and [85,91)", got)
|
|
}
|
|
}
|
|
|
|
// initialNeedleMatches computes the expected byte ranges of "needle" in
|
|
// findContent (the pre-edit ground truth).
|
|
func initialNeedleMatches(t *testing.T) [][2]int {
|
|
t.Helper()
|
|
var out [][2]int
|
|
for i := 0; i+len("needle") <= len(findContent); i++ {
|
|
if strings.EqualFold(findContent[i:i+len("needle")], "needle") {
|
|
out = append(out, [2]int{i, i + len("needle")})
|
|
i += len("needle")
|
|
}
|
|
}
|
|
return out
|
|
}
|