Pad/internal/browser/manager_test.go
Greg Pomerantz 16740b0de0 Browser: keep the file list live (re-read on change, re-sort, no idle frames)
The browser list was cached: files added/removed/renamed, or whose mtime
changed, did not show up (and did not re-sort) until the user navigated
away and back in. In particular, opening a file and returning showed a
stale list — a new file created elsewhere only appeared after navigating
up and back down.

The browser now re-reads the current directory while it is on screen:

- A short (~1 s) timer re-reads the visible directory so external changes
  (including inbound syncs) appear live, re-sorted in the current sort
  mode. A landing re-index repopulates the visible+prefetch pages
  synchronously (no flicker), preserves the scroll offset, and re-filters
  an active search without a scroll jump.
- Returning to the browser from the editor triggers one immediate re-read
  so the list is current the moment you land back.
- Bounded to one read per directory: a refresh while a read of the same
  directory is in flight coalesces, and a stale result for a directory the
  user has since left is dropped rather than clobbering the current view.
- A failed background refresh keeps the last good view (the next tick
  retries); a failed navigation still recovers to the previous directory.
- Change detection: a refresh that finds the directory unchanged rebuilds
  nothing and emits no frame, preserving the event-driven emission
  contract (TestNoFramesWhileIdle_Browser) while the browser idles.

Verified on-device: external add while sitting in the browser appears and
re-sorts to the top within ~1 s; an external add+remove made while in the
editor is reflected immediately on return; an external delete drops the
row within ~1 s.
2026-09-02 18:23:16 -04:00

477 lines
14 KiB
Go

package browser
import (
"os"
"testing"
"time"
"pad/internal/io/pool"
"pad/internal/io/pool/mock"
"pad/internal/io/pool/types"
)
// testDirEntry / testFileInfo are minimal types.DirEntry implementations so a
// browser BuildIndex result can be constructed directly (no worker-pool round
// trip, which avoids the follow-up LoadPages tasks that a real landing result
// triggers via OnScroll).
type testDirEntry struct {
name string
isDir bool
size int64
mod time.Time
}
func (e testDirEntry) Name() string { return e.name }
func (e testDirEntry) IsDir() bool { return e.isDir }
func (e testDirEntry) Info() (os.FileInfo, error) { return testFileInfo{e}, nil }
type testFileInfo struct{ e testDirEntry }
func (f testFileInfo) Name() string { return f.e.name }
func (f testFileInfo) Size() int64 { return f.e.size }
func (f testFileInfo) Mode() os.FileMode { return 0o644 }
func (f testFileInfo) ModTime() time.Time { return f.e.mod }
func (f testFileInfo) IsDir() bool { return f.e.isDir }
func (f testFileInfo) Sys() any { return nil }
// TestNewBrowserManager verifies that a BrowserManager can be constructed
// with valid parameters and has correct initial state.
func TestNewBrowserManager(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
if bm == nil {
t.Fatal("NewBrowserManager returned nil")
}
// Verify initial state
if bm.state != state {
t.Errorf("BrowserManager.state = %p, want %p", bm.state, state)
}
}
// TestNavigateTo verifies that NavigateTo dispatches a BuildIndexTask
// and resets browser state appropriately.
func TestNavigateTo(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
// Create some mock entries in the filesystem
now := time.Now()
fs.AddDir("/test/docs", now)
fs.AddFile("/test/docs/file1.txt", []byte("content1"), now)
fs.AddFile("/test/docs/file2.txt", []byte("content2"), now)
fs.AddDir("/test/docs/subdir", now)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Navigate to a directory
bm.NavigateTo("/test/docs")
// Verify state was reset
if state.CurrentPath != "/test/docs" {
t.Errorf("CurrentPath = %q, want %q", state.CurrentPath, "/test/docs")
}
if state.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %f, want 0", state.ScrollOffset)
}
if state.SelectedIndex != -1 {
t.Errorf("SelectedIndex = %d, want -1", state.SelectedIndex)
}
// Verify a task was dispatched (should have pending work)
// The manager should have queued a BuildIndexTask
if !state.Loading {
t.Error("Expected Loading to be true after NavigateTo")
}
}
// TestNavigateTo_NonExistentDirectory verifies behavior when navigating
// to a directory that doesn't exist in the filesystem.
func TestNavigateTo_NonExistentDirectory(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Navigate to non-existent directory
bm.NavigateTo("/non/existent/path")
// Should still set the path (error handling happens async)
if state.CurrentPath != "/non/existent/path" {
t.Errorf("CurrentPath = %q, want %q", state.CurrentPath, "/non/existent/path")
}
}
// TestHandleResult_BuildIndexSuccess verifies that a successful BuildIndex
// result populates the SortIndex and triggers initial page loading.
func TestHandleResult_BuildIndexSuccess(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Simulate a successful BuildIndex result
result := pool.Result{
TaskType: pool.TypeBuildIndex,
Success: true,
Data: []types.DirEntry{},
}
bm.HandleResult(result)
// After successful BuildIndex, Loading should be false
if state.Loading {
t.Error("Expected Loading to be false after successful BuildIndex result")
}
}
// TestHandleResult_BuildIndexRefreshFailure verifies that a failed BACKGROUND
// REFRESH keeps the current (last good) view rather than teleporting the user
// or resetting the list over a transient read error.
func TestHandleResult_BuildIndexRefreshFailure(t *testing.T) {
state := NewBrowserState()
state.CurrentPath = "/dir"
state.TotalEntries = 5
state.Pages[0] = NewPage(0, []Entry{{Name: "a.txt"}})
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Model an in-flight background refresh of the current directory (as
// BrowserManager.Refresh would), then feed it a failed result.
bm.indexInFlightPath = "/dir"
bm.indexInFlightIsNav = false
result := pool.Result{
TaskType: pool.TypeBuildIndex,
Success: false,
Error: ErrDirNotFound,
DirPath: "/dir",
}
bm.HandleResult(result)
// The view must be preserved: same directory, same pages, no reset.
if state.CurrentPath != "/dir" {
t.Errorf("CurrentPath = %q after refresh failure, want /dir (view must be kept)", state.CurrentPath)
}
if len(state.Pages) != 1 {
t.Errorf("Pages = %d after refresh failure, want 1 (view must be kept)", len(state.Pages))
}
if bm.indexInFlightPath != "" {
t.Errorf("indexInFlightPath = %q after refresh failure, want cleared", bm.indexInFlightPath)
}
}
// waitBrowserResult is a test helper: read one result from the pool's channel
// with a deadline, fatal on timeout.
func waitBrowserResult(t *testing.T, wp *pool.WorkerPool) pool.Result {
t.Helper()
select {
case res := <-wp.ResultChan():
return res
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for browser result")
return pool.Result{}
}
}
// TestRefresh_PicksUpExternalChanges verifies the periodic-refresh contract:
// the initial load reports a change; an unchanged refresh reports NO change
// (so no frame is emitted while idle) and preserves the scroll offset; and a
// refresh after an external add picks up the new file and re-sorts.
func TestRefresh_PicksUpExternalChanges(t *testing.T) {
t.Parallel()
fs := mock.NewFileSystem()
fs.AddDir("/Notes", time.Now()) // only used by the OnScroll LoadPages, harmless
wp := pool.NewWorkerPool(2)
wp.Start()
defer wp.Stop()
state := NewBrowserState()
state.CurrentPath = "/Notes"
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
mod := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
buildIndex := func(dirPath string, files ...string) pool.Result {
entries := make([]types.DirEntry, 0, len(files))
for _, f := range files {
entries = append(entries, testDirEntry{name: f, size: 1, mod: mod})
}
return pool.Result{TaskType: pool.TypeBuildIndex, Success: true, Data: entries, DirPath: dirPath}
}
// Initial load: reports a change.
if !bm.HandleResult(buildIndex("/Notes", "a.txt")) {
t.Fatal("initial load should report a change")
}
if state.TotalEntries != 2 { // ".." + a.txt
t.Fatalf("TotalEntries = %d after load, want 2", state.TotalEntries)
}
// Unchanged refresh: reports NO change and preserves the scroll offset.
state.ScrollOffset = 123
if bm.HandleResult(buildIndex("/Notes", "a.txt")) {
t.Fatal("unchanged refresh should report no change")
}
if state.ScrollOffset != 123 {
t.Errorf("ScrollOffset = %v after no-op refresh, want 123 (preserved)", state.ScrollOffset)
}
// An external add: the next refresh picks it up and re-sorts.
if !bm.HandleResult(buildIndex("/Notes", "a.txt", "b.txt")) {
t.Fatal("refresh after external add should report a change")
}
if state.TotalEntries != 3 { // ".." + a.txt + b.txt
t.Fatalf("TotalEntries = %d after add, want 3", state.TotalEntries)
}
}
// TestRefresh_CoalescesInFlight verifies that a refresh requested while a read
// of the same directory is in flight does not dispatch a second read.
func TestRefresh_CoalescesInFlight(t *testing.T) {
t.Parallel()
fs := mock.NewFileSystem()
fs.AddDir("/Notes", time.Now())
fs.SetDelay(300 * time.Millisecond) // slow the read so we can observe it
wp := pool.NewWorkerPool(2)
wp.Start()
defer wp.Stop()
state := NewBrowserState()
state.CurrentPath = "/Notes"
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
bm.Load("/Notes") // dispatches one read; it is in flight for ~300ms
if bm.indexInFlightPath != "/Notes" {
t.Fatalf("expected an in-flight read of /Notes, got %q", bm.indexInFlightPath)
}
// A refresh while that read is in flight must coalesce (no second read).
bm.Refresh("/Notes")
if bm.indexInFlightPath != "/Notes" {
t.Fatalf("refresh should not replace the in-flight path, got %q", bm.indexInFlightPath)
}
// Only ONE result should arrive for the coalesced reads.
res := waitBrowserResult(t, wp)
bm.HandleResult(res)
if bm.indexInFlightPath != "" {
t.Fatalf("in-flight marker should be cleared after the result, got %q", bm.indexInFlightPath)
}
}
// TestHandleResult_BuildIndexNavFailureRestoresPrevious verifies that a failed
// NAVIGATION restores the previous directory (the existing recovery behavior)
// instead of leaving the user on a directory that could not be read.
func TestHandleResult_BuildIndexNavFailureRestoresPrevious(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Model a navigation from "/" to a directory that then fails to read.
state.History = append(state.History, "/")
state.CurrentPath = "/missing"
bm.indexInFlightPath = "/missing"
bm.indexInFlightIsNav = true
result := pool.Result{
TaskType: pool.TypeBuildIndex,
Success: false,
Error: ErrDirNotFound,
DirPath: "/missing",
}
bm.HandleResult(result)
// The user is sent back to the previous directory.
if state.CurrentPath != "/" {
t.Errorf("CurrentPath = %q after nav failure, want / (previous dir restored)", state.CurrentPath)
}
}
// TestHandleResult_LoadPagesSuccess verifies that a successful LoadPages
// result populates the Pages map.
func TestHandleResult_LoadPagesSuccess(t *testing.T) {
state := NewBrowserState()
state.TotalEntries = 500
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Simulate a successful LoadPages result
pageIndices := []int{0, 1}
result := pool.Result{
TaskType: pool.TypeLoadPages,
Success: true,
Data: pageIndices,
}
bm.HandleResult(result)
// Pages should be populated (or at least not cause a panic)
// The exact behavior depends on implementation
}
// TestOnScroll_Prefetch verifies that scrolling triggers prefetch for
// pages beyond the visible region.
func TestOnScroll_Prefetch(t *testing.T) {
state := NewBrowserState()
state.TotalEntries = 1000
state.EntryHeight = 48.0
state.VisibleCount = 10
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Set initial scroll position
state.ScrollOffset = 0
// Simulate scrolling down
state.ScrollOffset = 5000.0 // ~100 entries down
bm.OnScroll()
// Should trigger prefetch (exact behavior depends on implementation)
// At minimum, should not panic
}
// TestOnScroll_Eviction verifies that scrolling triggers eviction of
// pages far from the visible region.
func TestOnScroll_Eviction(t *testing.T) {
state := NewBrowserState()
state.TotalEntries = 1000
state.EntryHeight = 48.0
state.VisibleCount = 10
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Pre-populate some pages
state.Pages[0] = NewPage(0, make([]Entry, 100))
state.Pages[5] = NewPage(5, make([]Entry, 100))
state.Pages[10] = NewPage(10, make([]Entry, 100))
// Scroll far down
state.ScrollOffset = 50000.0 // ~1000 entries down
bm.OnScroll()
// Pages far from visible region should be evicted
// (exact behavior depends on implementation)
}
// TestBrowserManager_StateOwnership verifies that the BrowserManager
// does not directly modify state outside of designated methods.
func TestBrowserManager_StateOwnership(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Verify that the manager has a reference to the state
// but does not own it (state is owned by the editor)
if bm.state != state {
t.Error("BrowserManager should have reference to BrowserState")
}
}
// TestNavigateTo_ChangesDirectory verifies that navigating to a new
// directory clears the previous directory's state.
func TestNavigateTo_ChangesDirectory(t *testing.T) {
state := NewBrowserState()
state.CurrentPath = "/old/path"
state.TotalEntries = 500
state.Pages[0] = NewPage(0, make([]Entry, 100))
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Navigate to new directory
bm.NavigateTo("/new/path")
if state.CurrentPath != "/new/path" {
t.Errorf("CurrentPath = %q, want %q", state.CurrentPath, "/new/path")
}
// Pages should be cleared for new directory
if len(state.Pages) != 0 {
t.Errorf("Expected Pages to be cleared, got %d pages", len(state.Pages))
}
}
// TestHandleResult_UnknownType verifies that unknown result types
// are handled gracefully without panicking.
func TestHandleResult_UnknownType(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Simulate an unknown result type
result := pool.Result{
TaskType: pool.TypeReadFile, // Not a browser task
Success: true,
Data: nil,
}
// Should not panic
bm.HandleResult(result)
}