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

196 lines
6.0 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")
}
}