Pad/internal/test/e2e/search_test.go
Greg Pomerantz 06b1444207 gofmt: format all remaining files with the go1.27 toolchain
The tree was formatted with an older gofmt; go1.27's gofmt additionally
wants: EOF exactly one newline (no trailing blank lines), imports sorted
alphabetically within a block, mixed-precedence binary expressions
re-spaced for grouping ((a+b)/c), single-field composite literals
un-aligned, adjacent one-line method signatures aligned, and one-line
bodies containing a compound statement expanded. Applied repo-wide
(31 files under internal/); pure formatting, no semantic changes —
build and the full test suite pass.
2026-08-23 10:03:27 -04:00

317 lines
10 KiB
Go

package e2e_test
import (
"slices"
"strings"
"testing"
"time"
"pad/internal/browser"
"pad/internal/editor"
"pad/internal/test/e2e"
)
// TestSearchFiltersList verifies that typing in the search box filters the
// browser list so that only matching entries appear.
func TestSearchFiltersList(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Navigate to browser page (owner-side)
if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil {
t.Fatalf("GoToBrowser: %v", err)
}
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
// Get initial list items (no filter)
initialItems := getListItems(t, e2e.GetLastFrame(h))
t.Logf("Initial items (%d): %v", len(initialItems), initialItems)
if len(initialItems) == 0 {
t.Skip("no list items; skipping")
}
// Search for "doc" — should match: Documents (directory)
// config.yaml does NOT contain "doc"
query := "doc"
h.SendSearchQuery(query)
time.Sleep(200 * time.Millisecond)
// Wait for new frame
_, err = h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second)
if err != nil {
t.Logf("no new frame after search, checking current frame")
}
searchedItems := getListItems(t, e2e.GetLastFrame(h))
t.Logf("After searching '%s' (%d items): %v", query, len(searchedItems), searchedItems)
// All visible items should contain the query (case-insensitive)
for _, item := range searchedItems {
if item == "..." {
continue // loading placeholder
}
if !strings.Contains(strings.ToLower(item), strings.ToLower(query)) {
t.Errorf("search bug: item %q does not contain '%s' but was shown in search results", item, query)
}
}
// Documents should be in the results
foundDocuments := false
for _, item := range searchedItems {
if item == "Documents" {
foundDocuments = true
break
}
}
if !foundDocuments && len(searchedItems) > 0 {
t.Errorf("search bug: 'Documents' should be in search results for query '%s'", query)
}
}
// TestSearchWithSortModeChange verifies that changing sort mode while
// a search query is active preserves the filter.
func TestSearchWithSortModeChange(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Navigate to browser page (owner-side)
if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil {
t.Fatalf("GoToBrowser: %v", err)
}
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
// Apply a search query
query := "doc"
h.SendSearchQuery(query)
time.Sleep(200 * time.Millisecond)
_, err = h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second)
if err != nil {
t.Logf("no new frame after search, checking current frame")
}
afterSearchItems := getListItems(t, e2e.GetLastFrame(h))
t.Logf("After search '%s' (%d items): %v", query, len(afterSearchItems), afterSearchItems)
// Verify all items contain the query
for _, item := range afterSearchItems {
if item == "..." {
continue
}
if !strings.Contains(strings.ToLower(item), strings.ToLower(query)) {
t.Errorf("search bug: item %q does not contain '%s' but was shown", item, query)
}
}
// Now toggle sort mode WHILE search is active (owner-side)
if err := h.WithState(func(st *editor.State) { editor.ToggleSortOrder(nil) }); err != nil {
t.Fatalf("ToggleSortOrder: %v", err)
}
h.SendConfig(780, 1688)
time.Sleep(300 * time.Millisecond)
afterSortItems := getListItems(t, e2e.GetLastFrame(h))
t.Logf("After sort toggle (%d items): %v", len(afterSortItems), afterSortItems)
// After sort toggle, the items should STILL be filtered by the search query
for _, item := range afterSortItems {
if item == "..." {
continue
}
if !strings.Contains(strings.ToLower(item), strings.ToLower(query)) {
t.Errorf("BUG: after sort toggle with active search, item %q does not contain '%s'. Search filter was lost!", item, query)
}
}
}
// TestSearchDirect verifies HandleSearch computes correct indices.
func TestSearchDirect(t *testing.T) {
state := browser.NewBrowserState()
state.CurrentPath = "/"
state.VisibleCount = 30
// Create entries with known names
entries := []browser.Entry{
browser.NewEntry("/alpha.txt", "alpha.txt", 100, time.Now(), false),
browser.NewEntry("/beta.txt", "beta.txt", 200, time.Now(), false),
browser.NewEntry("/gamma.doc", "gamma.doc", 300, time.Now(), false),
browser.NewEntry("/delta.log", "delta.log", 400, time.Now(), false),
browser.NewEntry("/epsilon.txt", "epsilon.txt", 500, time.Now(), false),
browser.NewEntry("/zeta.doc", "zeta.doc", 600, time.Now(), false),
}
state.TotalEntries = len(entries)
state.SortIndex = &browser.DirectoryIndex{
Path: "/",
EntryCount: len(entries),
Entries: entries,
SortOrders: map[string][]int{
"name_asc": {0, 1, 2, 3, 4, 5},
},
}
state.SortMode = browser.SortModeNameAsc
// Load initial pages
browser.LoadInitialPages(state)
// Search for "txt" — should match: alpha.txt, beta.txt, epsilon.txt
browser.HandleSearch(state, "txt")
expectedMatches := 3 // alpha.txt, beta.txt, epsilon.txt
if len(state.SearchResults) != expectedMatches {
t.Errorf("expected %d search results, got %d: %v",
expectedMatches, len(state.SearchResults), state.SearchResults)
}
// Verify each result index points to a matching entry
for _, idx := range state.SearchResults {
if idx >= len(entries) {
t.Errorf("search result index %d out of bounds (total: %d)", idx, len(entries))
continue
}
entry := entries[idx]
if !strings.Contains(strings.ToLower(entry.Name), "txt") {
t.Errorf("search result index %d points to %q which does not contain 'txt'", idx, entry.Name)
}
}
}
// TestSortModeChangePreservesSearchFilter verifies at the state level that
// changing sort mode with an active search query maintains correct filtering.
func TestSortModeChangePreservesSearchFilter(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Navigate to browser (owner-side)
if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil {
t.Fatalf("GoToBrowser: %v", err)
}
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
// Apply search
query := "doc"
h.SendSearchQuery(query)
time.Sleep(200 * time.Millisecond)
// Record search results before sort change (owner-side snapshot)
_, err = h.Inspect(func(st *editor.State) any {
searchResultsBefore := make([]int, len(st.Browser.SearchResults))
copy(searchResultsBefore, st.Browser.SearchResults)
t.Logf("Search results before sort change: %v", searchResultsBefore)
// Verify all pre-sort results are valid matches
for _, sortedIdx := range st.Browser.SearchResults {
positionMap := st.Browser.GetSortedIndices()
rawIdx := positionMap[sortedIdx]
if rawIdx < len(st.Browser.SortIndex.Entries) {
entry := st.Browser.SortIndex.Entries[rawIdx]
if !strings.Contains(strings.ToLower(entry.Name), strings.ToLower(query)) {
t.Errorf("pre-sort: index %d (raw %d) -> %q does not match query %q", sortedIdx, rawIdx, entry.Name, query)
}
}
}
return nil
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
// Toggle sort mode (owner-side)
if err := h.WithState(func(st *editor.State) { editor.ToggleSortOrder(nil) }); err != nil {
t.Fatalf("ToggleSortOrder: %v", err)
}
h.SendConfig(780, 1688)
time.Sleep(300 * time.Millisecond)
// After sort change, search results should still be valid
// The problem in the test might be that it expects the *indices*
// to be the same, but the indices represent sorted positions.
// When the sort mode changes, the position map changes,
// so the *index* of the entry "Documents" (which matches "doc")
// will change!
// Let's print the entries to see if they are still correct,
// ignoring the index values themselves.
// (Owner-side snapshot: all reads happen on the logic goroutine.)
_, err = h.Inspect(func(st *editor.State) any {
for _, sortedIdx := range st.Browser.SearchResults {
if sortedIdx >= len(st.Browser.SortIndex.Entries) {
t.Errorf("search result index %d is out of bounds (total: %d)",
sortedIdx, st.Browser.TotalEntries)
continue
}
// Map sortedIdx back to rawIdx to check the actual entry
positionMap := st.Browser.GetSortedIndices()
rawIdx := positionMap[sortedIdx]
entry := st.Browser.SortIndex.Entries[rawIdx]
if !strings.Contains(strings.ToLower(entry.Name), strings.ToLower(query)) {
t.Errorf("post-sort: search result sortedIdx %d -> rawIdx %d -> %q does not match query %q",
sortedIdx, rawIdx, entry.Name, query)
}
}
return nil
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
}
// TestSearchClearRestoresFullList verifies that clearing the search query
// restores the full unfiltered list.
func TestSearchClearRestoresFullList(t *testing.T) {
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Navigate to browser page (owner-side)
if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil {
t.Fatalf("GoToBrowser: %v", err)
}
h.SendConfig(780, 1688)
time.Sleep(200 * time.Millisecond)
initialItems := getListItems(t, e2e.GetLastFrame(h))
t.Logf("Initial items (%d): %v", len(initialItems), initialItems)
// Search for something specific
h.SendSearchQuery("doc")
time.Sleep(200 * time.Millisecond)
// Clear search
h.SendSearchQuery("")
time.Sleep(200 * time.Millisecond)
clearedItems := getListItems(t, e2e.GetLastFrame(h))
t.Logf("After clear (%d items): %v", len(clearedItems), clearedItems)
// After clearing, should show all entries again
if !slices.Equal(initialItems, clearedItems) {
t.Errorf("after clearing search, list should match initial list.\n initial: %v\n cleared: %v", initialItems, clearedItems)
}
}