Pad/internal/test/e2e/find_test.go
Greg Pomerantz d726ea7244 Find bar X clears the query instead of closing the bar
The X was redundant with the top-bar search icon (both closed the bar).
Now the X empties the query and results but leaves the bar open; closing
is the search icon's toggle.

- editor: new findClear (empties query/matches, bumps Gen so an in-flight
  scan of the old query is dropped, disarms the settle) and FindClear
  handler; the X icon now runs FindClear.
- main: mirrors the logic-side clear into the main-owned widget input
  (frame.FindQuery=="" while the widget still has text -> SetText("")).
- tests: unit findClear (stays open, supersedes in-flight scan); e2e X
  clears but keeps the bar open and focus, close now via ToggleFind.
- doc: spec updated (clear button vs icon toggle).
2026-08-20 13:00:21 -04:00

284 lines
10 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"
// editorField returns the editor's TextField from the latest frame (the
// frame element whose value is the file's visible content), if present.
func editorField(h *e2e.Harness) (ui.TextField, bool) {
for _, el := range e2e.GetLastFrame(h) {
if tf, ok := el.(ui.TextField); ok && strings.Contains(tf.Value, "needle") {
return tf, true
}
}
return ui.TextField{}, false
}
// waitForEditorField polls the captured frames (frame capture lags the
// logic-owner state) until the editor TextField satisfies want, and returns
// it.
func waitForEditorField(t *testing.T, h *e2e.Harness, want func(ui.TextField) bool) ui.TextField {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if tf, ok := editorField(h); ok && want(tf) {
return tf
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for an editor frame satisfying the predicate")
return ui.TextField{}
}
// 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))
}
// The editor field reports UNFOCUSED while the find bar is open (the
// renderer hides its caret for unfocused fields; main hands key focus to
// the search input).
waitForEditorField(t, h, func(tf ui.TextField) bool { return !tf.Focused })
// 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)
}
// The in-text highlight data rides the frame: all three matches are
// windowed into the visible content, the first flagged as current.
tf := waitForEditorField(t, h, func(tf ui.TextField) bool {
return len(tf.MatchRanges) == 3 && tf.CurrentMatch == 0
})
for i, m := range tf.MatchRanges {
if !strings.EqualFold(tf.Value[m[0]:m[1]], "needle") {
t.Fatalf("match %d highlights %q, want a needle", i, tf.Value[m[0]:m[1]])
}
}
// 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)
}
// The current-match highlight follows the navigation.
waitForEditorField(t, h, func(tf ui.TextField) bool {
return len(tf.MatchRanges) == 3 && tf.CurrentMatch == 1
})
// 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)
}
// The X button clears the query but LEAVES the bar open (closing is the
// top-bar icon's toggle).
h.SendInput([]ui.InputEvent{{Handler: editor.FindClear, Data: ui.Point{}}})
if !frameHasFindBar(h) {
t.Fatal("find bar closed by the clear button; it should stay open")
}
clear, _ := h.Inspect(func(st *editor.State) any {
return [3]any{st.Editor.Find.Query, len(st.Editor.Find.Matches), st.FocusedElementID}
})
if clear.([3]any) != [3]any{"", 0, "find_bar"} {
t.Fatalf("after clear: query/matches/focus = %v, want \"\"/0/find_bar", clear)
}
// A query with no hits reports zero matches.
h.Logic().FindQueryChan() <- "no-such-text"
waitForFindMatches(t, h, 0)
// Close the find bar via the top-bar icon toggle: it leaves the frame
// and focus returns to the editor.
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 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)
}
// And the editor field reports focused again (caret returns).
waitForEditorField(t, h, func(tf ui.TextField) bool { return tf.Focused })
}
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
}