Pad/internal/browser/manager_test.go
Greg Pomerantz 4c8a13b779 Reload the previous directory's list after a failed navigation
Navigating into an unreadable directory failed cleanly in the header
(handleError reverts CurrentPath to the previous path) but left the
list empty forever: the recovery re-read of the previous directory
found it unchanged, so handleBuildIndexSuccess took the no-change
fast path — which assumes the view already shows that content — while
the failed navigation's navigateToDirectory reset had just wiped
TotalEntries/Pages. No frame was emitted and the browser sat on a
correct header over a dead list (seen on device tapping ".." into
/storage/emulated, which is media_rw:media_rw 0750; only
/storage/emulated/0 is app-exposed).

The fast path now requires a live view (TotalEntries != 0); a
torn-down view always takes the full rebuild, restoring the rows.
Regression test TestHandleResult_NavigationFailureRecoveryReloads
(red without the fix, green with it); verified on emulator and phone.
2026-09-03 12:53:23 -04:00

554 lines
17 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)
}
// 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")
}
}