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) } // TestHandleResult_NavigationFailureRecoveryReloads reproduces the empty-list // bug: navigating into an unreadable directory fails, the manager reverts to // the previous path and re-reads it; that re-read finds the directory // UNCHANGED, and the no-change fast path must not skip restoring the rows the // failed navigation's reset wiped — or the browser sits on an empty list // forever (header correct, list dead). func TestHandleResult_NavigationFailureRecoveryReloads(t *testing.T) { state := NewBrowserState() fs := mock.NewFileSystem() wp := pool.NewWorkerPool(1) // Started (unlike the direct-result tests above): the recovery path // dispatches REAL follow-up tasks (BuildIndex, LoadPages), and three of // them would overflow the unstarted pool's high-priority channel. wp.Start() defer wp.Stop() bm, err := NewBrowserManager(state, wp, fs) if err != nil { t.Fatalf("NewBrowserManager failed: %v", err) } // A FIXED mtime: the two index results must carry byte-identical content // (same directorySignature) so the recovery re-read genuinely hits the // no-change fast path that the bug lived in. fixed := time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC) entries := func() []types.DirEntry { return []types.DirEntry{ testDirEntry{name: "file1.txt", size: 8, mod: fixed}, testDirEntry{name: "subdir", isDir: true, mod: fixed}, } } indexResult := func(dirPath string) pool.Result { return pool.Result{TaskType: pool.TypeBuildIndex, Success: true, DirPath: dirPath, Data: entries()} } // Land in /test/docs with a populated view. state.CurrentPath = "/test/docs" bm.indexInFlightPath = "/test/docs" bm.indexInFlightIsNav = true if !bm.HandleResult(indexResult("/test/docs")) { t.Fatal("initial index did not change the view") } if state.TotalEntries != 3 { // 2 entries + ".." t.Fatalf("TotalEntries = %d, want 3", state.TotalEntries) } // The user taps into an unreadable directory (as NavigateTo would set up). state.History = append(state.History, state.CurrentPath) navigateToDirectory(state, "/missing") state.Loading = true bm.indexInFlightPath = "/missing" bm.indexInFlightIsNav = true // That read fails: the manager must revert to /test/docs. failed := pool.Result{TaskType: pool.TypeBuildIndex, Success: false, Error: ErrDirNotFound, DirPath: "/missing"} bm.HandleResult(failed) if state.CurrentPath != "/test/docs" { t.Fatalf("CurrentPath = %q, want reverted /test/docs", state.CurrentPath) } if state.TotalEntries != 0 { t.Fatalf("TotalEntries = %d after failed navigation, want 0 (rows reset)", state.TotalEntries) } // The recovery re-read of /test/docs lands with the directory unchanged. // This is where the bug lived: the no-change fast path returned early and // the view stayed empty. if !bm.HandleResult(indexResult("/test/docs")) { t.Fatal("recovery re-read reported no view change; the list would stay empty") } if state.TotalEntries != 3 { t.Fatalf("TotalEntries = %d after recovery re-read, want 3", state.TotalEntries) } if page, ok := state.Pages[0]; !ok || page == nil || !page.Loaded { t.Fatal("page 0 not restored after recovery re-read") } }