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) }