package browser import ( "fmt" "testing" "time" ) // --- Helper: create a browser state with named entries for search testing --- func makeSearchState(t *testing.T, entries []Entry) *BrowserState { t.Helper() s := NewBrowserState() s.TotalEntries = len(entries) s.VisibleCount = 20 s.CurrentPath = "/test" s.EntryHeight = 48.0 // Distribute entries across pages for pageIdx := 0; pageIdx <= (len(entries)-1)/PageSize; pageIdx++ { start := pageIdx * PageSize end := start + PageSize if end > len(entries) { end = len(entries) } s.Pages[pageIdx] = NewPage(pageIdx, entries[start:end]) } return s } // --- Test: Search - Empty Query --- func TestSearch_EmptyQuery(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "alpha.txt", 100, time.Time{}, false), NewEntry("/b", "beta.txt", 200, time.Time{}, false), NewEntry("/c", "gamma.txt", 300, time.Time{}, false), }) // First, set a non-empty query and results to simulate prior search s.Query = "alpha" s.SearchResults = []int{0} // Clear the search HandleSearch(s, "") if s.Query != "" { t.Errorf("expected Query to be empty, got %q", s.Query) } if s.SearchResults != nil { t.Errorf("expected SearchResults to be nil after empty query, got %v", s.SearchResults) } // Scroll should not change when clearing search if s.GetScrollIndex() != 0 { t.Errorf("expected ScrollIndex=0, got %d", s.GetScrollIndex()) } } // --- Test: Search - Case Insensitive --- func TestSearch_CaseInsensitive(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "Apple.txt", 100, time.Time{}, false), NewEntry("/b", "banana.txt", 200, time.Time{}, false), NewEntry("/c", "CHERRY.txt", 300, time.Time{}, false), NewEntry("/d", "date.txt", 400, time.Time{}, false), }) HandleSearch(s, "apple") if len(s.SearchResults) != 1 { t.Errorf("expected 1 result for 'apple', got %d: %v", len(s.SearchResults), s.SearchResults) } if len(s.SearchResults) > 0 && s.SearchResults[0] != 0 { t.Errorf("expected first result at index 0 (Apple.txt), got %d", s.SearchResults[0]) } // Uppercase query should also match lowercase entries HandleSearch(s, "BANANA") if len(s.SearchResults) != 1 { t.Errorf("expected 1 result for 'BANANA', got %d: %v", len(s.SearchResults), s.SearchResults) } if len(s.SearchResults) > 0 && s.SearchResults[0] != 1 { t.Errorf("expected first result at index 1 (banana.txt), got %d", s.SearchResults[0]) } // Mixed case HandleSearch(s, "ChErRy") if len(s.SearchResults) != 1 { t.Errorf("expected 1 result for 'ChErRy', got %d: %v", len(s.SearchResults), s.SearchResults) } } // --- Test: Search - Partial Match --- func TestSearch_PartialMatch(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "abc.dat", 100, time.Time{}, false), NewEntry("/b", "xabc.dat", 200, time.Time{}, false), NewEntry("/c", "abcdef.dat", 300, time.Time{}, false), NewEntry("/d", "xyz.dat", 400, time.Time{}, false), NewEntry("/e", "bca.dat", 500, time.Time{}, false), }) HandleSearch(s, "abc") // "abc" should match: abc.dat (0), xabc.dat (1), abcdef.dat (2) // Should NOT match: xyz.dat (3), bca.dat (4) if len(s.SearchResults) != 3 { t.Errorf("expected 3 results for 'abc', got %d: %v", len(s.SearchResults), s.SearchResults) } expectedIndices := map[int]bool{0: true, 1: true, 2: true} for _, idx := range s.SearchResults { if !expectedIndices[idx] { t.Errorf("unexpected result at index %d", idx) } } // Test single character partial match HandleSearch(s, "x") // "x" should match: xabc.dat (1), xyz.dat (3) // Should NOT match: abc.dat (0), abcdef.dat (2), bca.dat (4) expectedIndices = map[int]bool{1: true, 3: true} if len(s.SearchResults) != len(expectedIndices) { t.Errorf("expected %d results for 'x', got %d: %v", len(expectedIndices), len(s.SearchResults), s.SearchResults) } } // --- Test: Search - No Match --- func TestSearch_NoMatch(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "alpha.txt", 100, time.Time{}, false), NewEntry("/b", "beta.txt", 200, time.Time{}, false), NewEntry("/c", "gamma.txt", 300, time.Time{}, false), }) HandleSearch(s, "zzz") if len(s.SearchResults) != 0 { t.Errorf("expected 0 results for 'zzz', got %d: %v", len(s.SearchResults), s.SearchResults) } // Scroll should not change when there are no results if s.GetScrollIndex() != 0 { t.Errorf("expected ScrollIndex=0 when no match, got %d", s.GetScrollIndex()) } } // --- Test: Search - Jump To First --- func TestSearch_JumpToFirst(t *testing.T) { // Create enough entries to make results scrollable entries := make([]Entry, 100) for i := 0; i < 100; i++ { name := fmt.Sprintf("file_%d", i) entries[i] = NewEntry(fmt.Sprintf("/%d", i), name, 100, time.Time{}, false) } s := makeSearchState(t, entries) // Start with scroll at position 0 s.ScrollOffset = 0 // Search for "file_3" — should find 12 results (3, 30-39) HandleSearch(s, "file_3") if len(s.SearchResults) == 0 { t.Fatalf("expected results for 'file_3', got 0") } // The first result should be index 3 (file_3). // With 12 results, it fits in 20 visible. ScrollIndex should be 0. if s.GetScrollIndex() != 0 { t.Errorf("expected ScrollIndex=0 (clamped), got %d", s.GetScrollIndex()) } } // --- Test: Search - Large Directory --- func TestSearch_LargeDirectory(t *testing.T) { // Create 10k entries for performance testing const count = 10000 entries := make([]Entry, count) for i := 0; i < count; i++ { entries[i] = NewEntry( fmt.Sprintf("/test/file_%05d.txt", i), fmt.Sprintf("file_%05d.txt", i), int64(i*100), time.Time{}, false, ) } // Sprinkle some entries with "special" in the name for i := 0; i < 50; i++ { idx := i * 200 // spread across the dataset entries[idx] = NewEntry( fmt.Sprintf("/test/special_%03d.txt", i), fmt.Sprintf("special_%03d.txt", i), int64(i*100), time.Time{}, false, ) } s := makeSearchState(t, entries) start := time.Now() HandleSearch(s, "special") elapsed := time.Since(start) // Should find all 50 "special" entries if len(s.SearchResults) != 50 { t.Errorf("expected 50 results for 'special', got %d", len(s.SearchResults)) } // Time budget: 100ms for searching 10k entries if elapsed > 100*time.Millisecond { t.Errorf("search took %v, expected < 100ms", elapsed) } } // --- Test: Search - Across Page Boundaries --- func TestSearch_AcrossPageBoundaries(t *testing.T) { // Create entries spanning multiple pages entries := make([]Entry, 300) for i := 0; i < 300; i++ { name := fmt.Sprintf("file_%03d.txt", i) // Every 50th file gets "match" in the name if i%50 == 0 { name = fmt.Sprintf("match_%03d.txt", i) } entries[i] = NewEntry( fmt.Sprintf("/test/%s", name), name, int64(i*100), time.Time{}, false, ) } s := makeSearchState(t, entries) HandleSearch(s, "match") // Should find entries at indices 0, 50, 100, 150, 200, 250 // These span pages 0, 0, 1, 1, 2, 2 expectedCount := 6 if len(s.SearchResults) != expectedCount { t.Errorf("expected %d results spanning pages, got %d: %v", expectedCount, len(s.SearchResults), s.SearchResults) } expectedIndices := []int{0, 50, 100, 150, 200, 250} for i, expected := range expectedIndices { if i >= len(s.SearchResults) || s.SearchResults[i] != expected { t.Errorf("result[%d] = %d, want %d", i, s.SearchResults[i], expected) } } } // --- Test: Search - Special Characters --- func TestSearch_SpecialCharacters(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "file.name.dat", 100, time.Time{}, false), NewEntry("/b", "file-name.dat", 200, time.Time{}, false), NewEntry("/c", "file_name.dat", 300, time.Time{}, false), NewEntry("/d", "file(name).dat", 400, time.Time{}, false), }) // Dot should match all entries (all have .dat extension) HandleSearch(s, ".") if len(s.SearchResults) != 4 { t.Errorf("expected 4 results for '.', got %d: %v", len(s.SearchResults), s.SearchResults) } // Hyphen should match "file-name.dat" only HandleSearch(s, "-") if len(s.SearchResults) != 1 { t.Errorf("expected 1 result for '-', got %d: %v", len(s.SearchResults), s.SearchResults) } if len(s.SearchResults) > 0 && s.SearchResults[0] != 1 { t.Errorf("expected result at index 1, got %d", s.SearchResults[0]) } // Underscore should match "file_name.dat" only HandleSearch(s, "_") if len(s.SearchResults) != 1 { t.Errorf("expected 1 result for '_', got %d: %v", len(s.SearchResults), s.SearchResults) } // Parentheses should match "file(name).dat" only HandleSearch(s, "(") if len(s.SearchResults) != 1 { t.Errorf("expected 1 result for '(', got %d: %v", len(s.SearchResults), s.SearchResults) } } // --- Test: Search - Clears Previous Results --- func TestSearch_ClearsPreviousResults(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "alpha.txt", 100, time.Time{}, false), NewEntry("/b", "beta.txt", 200, time.Time{}, false), NewEntry("/c", "gamma.txt", 300, time.Time{}, false), }) // First search HandleSearch(s, "alpha") if len(s.SearchResults) != 1 { t.Fatalf("expected 1 result for 'alpha', got %d", len(s.SearchResults)) } // Second search should replace results, not append HandleSearch(s, "beta") if len(s.SearchResults) != 1 { t.Errorf("expected 1 result after new search, got %d: %v", len(s.SearchResults), s.SearchResults) } if s.SearchResults[0] != 1 { t.Errorf("expected result at index 1, got %d", s.SearchResults[0]) } if s.Query != "beta" { t.Errorf("expected Query='beta', got %q", s.Query) } } // --- Test: Search - Only Loaded Pages --- func TestSearch_OnlyLoadedPages(t *testing.T) { // Create state with only some pages loaded s := NewBrowserState() s.TotalEntries = 300 s.VisibleCount = 20 s.CurrentPath = "/test" // Only load page 0 with entries containing "searchme" entries0 := make([]Entry, PageSize) for i := 0; i < PageSize; i++ { name := fmt.Sprintf("file_%03d.txt", i) if i < 5 { name = fmt.Sprintf("searchme_%03d.txt", i) } entries0[i] = NewEntry(fmt.Sprintf("/test/%s", name), name, int64(i*100), time.Time{}, false) } s.Pages[0] = NewPage(0, entries0) // Page 1 and 2 are NOT loaded — search should only find results in loaded pages HandleSearch(s, "searchme") // Should only find the 5 entries in page 0 (loaded pages only) if len(s.SearchResults) != 5 { t.Errorf("expected 5 results (only from loaded pages), got %d: %v", len(s.SearchResults), s.SearchResults) } } // --- Test: Search - Directory Entries --- func TestSearch_DirectoryEntries(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "mydir", 0, time.Time{}, true), NewEntry("/b", "myfile.txt", 100, time.Time{}, false), NewEntry("/c", "mydir2", 0, time.Time{}, true), NewEntry("/d", "other.txt", 200, time.Time{}, false), }) // Search should match both files and directories HandleSearch(s, "mydir") if len(s.SearchResults) != 2 { t.Errorf("expected 2 results for 'mydir', got %d: %v", len(s.SearchResults), s.SearchResults) } } // --- Test: Search - Numeric Names --- func TestSearch_NumericNames(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "123.txt", 100, time.Time{}, false), NewEntry("/b", "456.txt", 200, time.Time{}, false), NewEntry("/c", "1234.txt", 300, time.Time{}, false), NewEntry("/d", "789.txt", 400, time.Time{}, false), }) HandleSearch(s, "123") // Should match "123.txt" (0) and "1234.txt" (2) if len(s.SearchResults) != 2 { t.Errorf("expected 2 results for '123', got %d: %v", len(s.SearchResults), s.SearchResults) } } // --- Test: Search - Empty Directory --- func TestSearch_EmptyDirectory(t *testing.T) { s := NewBrowserState() s.TotalEntries = 0 s.VisibleCount = 20 HandleSearch(s, "anything") if len(s.SearchResults) != 0 { t.Errorf("expected 0 results for empty directory, got %d", len(s.SearchResults)) } if s.GetScrollIndex() != 0 { t.Errorf("expected ScrollIndex=0, got %d", s.GetScrollIndex()) } } // --- Test: Search - Whitespace Handling --- func TestSearch_WhitespaceHandling(t *testing.T) { s := makeSearchState(t, []Entry{ NewEntry("/a", "hello world.txt", 100, time.Time{}, false), NewEntry("/b", "helloworld.txt", 200, time.Time{}, false), NewEntry("/c", "hello.txt", 300, time.Time{}, false), }) // Search with spaces HandleSearch(s, "hello world") if len(s.SearchResults) != 1 { t.Errorf("expected 1 result for 'hello world', got %d: %v", len(s.SearchResults), s.SearchResults) } // Search single word HandleSearch(s, "hello") if len(s.SearchResults) != 3 { t.Errorf("expected 3 results for 'hello', got %d: %v", len(s.SearchResults), s.SearchResults) } }