Pad/internal/test/e2e/find_test.go
Greg Pomerantz 78d240eedb Add in-file search (find bar)
- pool: SearchTask + FindSubstring (case-insensitive substring scan with
  query generation for stale-result dropping)
- editor: FindState on EditorState; find bar handlers (ToggleFind/FindNext/
  FindPrev/FindClose); worker-pool scan of the in-memory content; edit-aware
  offset remapping (shift after, drop overlapping); next/prev with
  wrap-around, selection + scroll to match; main-owned find_bar input
  forwarded via FindQueryChan (browser search_bar precedent)
- ui: find-bar layout below the margin-free top bar; search icon on the top
  bar right; new search/close/chevron_up/chevron_down icons
- logic: findQueryChan, TypeSearch result handling, findReset on open
- frame: FindQuery field (main syncs the widget text like Query)
- tests: pool scan tests, find-state unit tests (remap, nav, gen gate,
  focus), e2e find bar + navigation + edit-survival on real files
- docs: spec/architecture/development_plan updated (search implemented,
  channel/task tables)
2026-08-20 00:02:54 -04:00

217 lines
7.1 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)
}
// 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
}