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 }