Pad/internal/editor/search_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

221 lines
6.8 KiB
Go

package editor
import (
"testing"
"pad/internal/io/pool"
)
// findTestState builds a small state: 10 lines of "aa\n" (30 bytes) with a
// find query "a" whose matches were scanned before the test's edits.
func findTestState() {
// NewLogic (used by other tests in this package) leaves the global
// TheLogic pointing at a STOPPED logic; the find code must not dispatch
// through it (findDispatchScan guards on nil).
TheLogic = nil
TheState = NewState()
TheState.Editor.Filename = "t.txt"
content := "aa\naa\naa\naa\naa\naa\naa\naa\naa\naa\n"
TheState.Editor.Buffer = content
TheState.Editor.ChunkedBuffer = nil
e := &TheState.Editor
e.Find = FindState{Visible: true, Query: "a", Cur: -1}
// Every byte except newlines matches "a".
for i := 0; i < len(content); i++ {
if content[i] != '\n' {
e.Find.Matches = append(e.Find.Matches, [2]int{i, i + 1})
}
}
}
func TestFindEdit_ShiftAfter(t *testing.T) {
findTestState()
e := &TheState.Editor
// Insert 4 bytes at offset 20: matches at/after 20 shift by 4; the
// current match (pick one after the edit) keeps its identity.
before := e.Find.Matches[len(e.Find.Matches)-1]
e.Find.Cur = len(e.Find.Matches) - 1
e.findEdit(20, 20, 4)
if got := e.Find.Matches[len(e.Find.Matches)-1]; got != [2]int{before[0] + 4, before[1] + 4} {
t.Fatalf("last match %v, want %v shifted by 4", got, [2]int{before[0] + 4, before[1] + 4})
}
if e.Find.Cur != len(e.Find.Matches)-1 {
t.Fatalf("cur %d", e.Find.Cur)
}
// A match before the edit is untouched.
if e.Find.Matches[0] != [2]int{0, 1} {
t.Fatalf("first match %v", e.Find.Matches[0])
}
}
func TestFindEdit_DropOverlapping(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Cur = 2 // byte 2
// Replace [2,4) with nothing: matches 2 and 3 (bytes 2,3) overlap and
// are dropped; later matches shift left by 2; the current match is
// dropped, so Cur resets to -1.
e.findEdit(2, 4, 0)
for _, m := range e.Find.Matches {
if m[0] == 2 && m[1] == 4 {
t.Fatalf("stale match %v survived an edit inside it", m)
}
}
if e.Find.Cur != -1 {
t.Fatalf("cur %d, want -1 (current match was edited away)", e.Find.Cur)
}
// The match that was at byte 4 is now at byte 2.
if e.Find.Matches[0][0] != 0 || e.Find.Matches[1][0] != 1 || e.Find.Matches[2][0] != 2 {
t.Fatalf("matches after delete: %v", e.Find.Matches[:5])
}
}
func TestFindStep_NextPrevWrap(t *testing.T) {
findTestState()
e := &TheState.Editor
// No current match yet: next from caret 0 -> the match AT 0 (index 0).
e.CursorPosition = 0
findStep(1)
if e.Find.Cur != 0 {
t.Fatalf("cur %d, want 0", e.Find.Cur)
}
if e.SelectionStart != 0 || e.SelectionEnd != 1 {
t.Fatalf("selection [%d,%d), want [0,1)", e.SelectionStart, e.SelectionEnd)
}
// next -> 1, prev -> 0.
findStep(1)
findStep(-1)
if e.Find.Cur != 0 {
t.Fatalf("cur %d, want 0", e.Find.Cur)
}
// prev from 0 wraps to the LAST match.
findStep(-1)
if e.Find.Cur != len(e.Find.Matches)-1 {
t.Fatalf("cur %d, want last (wrapped)", e.Find.Cur)
}
// next from last wraps to the FIRST.
findStep(1)
if e.Find.Cur != 0 {
t.Fatalf("cur %d, want 0 (wrapped)", e.Find.Cur)
}
}
func TestFindStep_NoMatches(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Matches = nil
e.CursorPosition = 5
findStep(1)
if e.Find.Cur != -1 {
t.Fatalf("cur %d, want -1 with no matches", e.Find.Cur)
}
}
func TestApplySearchResult_GenGate(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Gen = 3
// Stale generation: dropped, state untouched.
stale := e.Find.Matches
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 2, Matches: [][2]int{}}})
if len(e.Find.Matches) != len(stale) {
t.Fatalf("stale result was applied: %d matches", len(e.Find.Matches))
}
// Current generation: applied; the surviving previous current match
// keeps its position.
e.Find.Cur = 1
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 3, Matches: stale}})
if e.Find.Cur != 1 {
t.Fatalf("cur %d, want 1 (kept)", e.Find.Cur)
}
}
func TestApplySearchResult_FirstDiscoveryScrolls(t *testing.T) {
findTestState()
e := &TheState.Editor
// No ChunkedBuffer/LineIndex in this unit state: scrollToFindMatch is a
// no-op, so this only pins that first discovery selects match 0.
e.Find.Matches = nil
e.Find.Cur = -1
e.Find.Scanning = true
e.Find.Gen = 1
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{
Gen: 1, Matches: [][2]int{{10, 11}, {20, 21}},
}})
if e.Find.Scanning {
t.Fatal("scanning flag not cleared")
}
if e.Find.Cur != 0 {
t.Fatalf("cur %d, want 0 (first discovery)", e.Find.Cur)
}
}
func TestFindShowClose_FocusTransitions(t *testing.T) {
findTestState()
TheState.FocusedElementID = "editor_text"
TheState.Editor.Find.Visible = false
TheState.Editor.findShow()
if !TheState.Editor.Find.Visible {
t.Fatal("find bar not shown")
}
if TheState.FocusedElementID != "find_bar" {
t.Fatalf("focus %q, want find_bar", TheState.FocusedElementID)
}
TheState.Editor.findClose()
if TheState.Editor.Find.Visible {
t.Fatal("find bar still visible")
}
if TheState.FocusedElementID != "editor_text" {
t.Fatalf("focus %q, want editor_text", TheState.FocusedElementID)
}
// The query survives close/reopen.
q := TheState.Editor.Find.Query
TheState.Editor.findShow()
if TheState.Editor.Find.Query != q {
t.Fatal("query lost across close/reopen")
}
}
func TestFindReset_SupersedesInflight(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Gen = 5
e.findReset()
if e.Find.Visible || len(e.Find.Matches) != 0 {
t.Fatal("findReset did not clear state")
}
if e.Find.Gen != 6 {
t.Fatalf("gen %d, want 6 (in-flight scan must be superseded)", e.Find.Gen)
}
// The in-flight (gen 5) result must now be dropped.
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 5, Matches: [][2]int{{0, 1}}}})
if len(e.Find.Matches) != 0 {
t.Fatal("superseded result was applied")
}
}
func TestFindClear_EmptiesQueryKeepsBarOpen(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Scanning = true // a scan of the old query is in flight
e.Find.SettleByte = 5
oldGen := e.Find.Gen
e.findClear()
if !e.Find.Visible {
t.Fatal("findClear closed the bar; it must stay open")
}
if e.Find.Query != "" || len(e.Find.Matches) != 0 || e.Find.Cur != -1 || e.Find.Scanning || e.Find.SettleByte != -1 {
t.Fatalf("state after clear: %+v", e.Find)
}
if e.Find.Gen != oldGen+1 {
t.Fatalf("gen %d, want %d (in-flight scan must be superseded)", e.Find.Gen, oldGen+1)
}
// The in-flight scan of the old query must now be dropped.
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: oldGen, Matches: [][2]int{{0, 1}}}})
if len(e.Find.Matches) != 0 {
t.Fatal("cleared query's in-flight scan was applied")
}
}