Add e2e test harness for testing editor logic without Gio display
- internal/test/e2e/: FrameCapture, Harness, ElementAssertions, helpers - internal/editor/logic.go: Add done channel and Done() method for graceful shutdown - internal/editor/mock_setup.go: Mock filesystem for tests - internal/browser/: Browser layout and search logic - internal/io/: Worker pool for async tasks - Update architecture docs and spec
This commit is contained in:
parent
a1ef84609f
commit
6a43e3c0db
|
|
@ -61,8 +61,8 @@ func run(w *app.Window) error {
|
|||
logic.DisplayLineChan() <- int(renderer.LastLineY())
|
||||
// Send search query update to the logic goroutine when it changes.
|
||||
// The logic goroutine handles filtering and triggers a new frame.
|
||||
newQuery := logic.State().SearchEditor.Text()
|
||||
if newQuery != logic.State().SearchQuery {
|
||||
newQuery := logic.State().Browser.SearchEditor.Text()
|
||||
if newQuery != logic.State().Browser.Query {
|
||||
logic.SearchQueryChan() <- newQuery
|
||||
}
|
||||
if events := renderer.CheckGestures(e.Source, gtx.Metric); len(events) > 0 {
|
||||
|
|
|
|||
|
|
@ -298,7 +298,9 @@ All `.pad/` contents are excluded from Syncthing sync (see §7).
|
|||
"active_file": "path/to/open/file.txt",
|
||||
"cursor_pos": 1024,
|
||||
"scroll_offset": 500,
|
||||
"browser_scroll": 1200
|
||||
"browser_scroll": 1200,
|
||||
"browser_path": "/user/documents",
|
||||
"browser_query": ""
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -308,6 +310,8 @@ All `.pad/` contents are excluded from Syncthing sync (see §7).
|
|||
| `cursor_pos` | Byte offset of the cursor in the active file |
|
||||
| `scroll_offset` | Vertical scroll position (line or pixel offset) |
|
||||
| `browser_scroll` | Scroll position in the directory browser |
|
||||
| `browser_path` | Currently browsed directory path (relative to configured directory) |
|
||||
| `browser_query` | Current search query in the browser |
|
||||
|
||||
### Cursor Map (`cursors.json`)
|
||||
|
||||
|
|
|
|||
|
|
@ -152,21 +152,110 @@ Workers post results on `resultChan`.
|
|||
| `frameChan` | Logic → Frame receiver | Computed frames (`[]Element`) | Unbuffered. Receiver runs tight loop, so logic's send only blocks while receiver holds mutex (fast). |
|
||||
| `inputChan` | Main → Logic | Batched user input (`[]InputEvent`) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. |
|
||||
| `configChan` | Main → Logic | Window config changes (`app.ConfigEvent`) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. |
|
||||
| `resultChan` | Workers → Logic | Completed async task results | Unbuffered. Workers post and return to pool. Logic drains via `select`. |
|
||||
| `highWorkChan` | Logic → Workers | UI-critical async work | Buffered to pool size. |
|
||||
| `lowWorkChan` | Logic → Workers | Background async work | Buffered to pool size. |
|
||||
| `resultChan` | Workers → Logic | Completed async task results | Buffered to `workerCount × 4`. Workers post results; logic drains via `select`. Buffer prevents worker blocking when multiple tasks complete simultaneously.
|
||||
| `highWorkChan` | Logic → Workers | UI-critical async work | Buffered to `workerCount × 2`. |
|
||||
| `lowWorkChan` | Logic → Workers | Background async work | Buffered to `workerCount × 2`. |
|
||||
|
||||
### Why Unbuffered Channels Work
|
||||
### Why the Channel Design Works
|
||||
|
||||
1. **`frameChan`**: The frame receiver goroutine runs a tight loop. It only holds the mutex during the store + invalidate operation. The logic goroutine's send blocks only during this brief window.
|
||||
|
||||
2. **`inputChan` / `configChan`**: The main goroutine sends after releasing the mutex. The logic goroutine processes input in < 16ms. The main goroutine produces at most one batch per frame event. The logic's processing pace exceeds production, so the channel never backs up.
|
||||
|
||||
3. **`resultChan`**: The worker pool is bounded. Results are posted as workers complete. The logic goroutine drains results in its `select` loop.
|
||||
3. **`resultChan`**: Buffered to `workerCount × 4`. The worker pool is bounded. Results are posted as workers complete. The logic goroutine drains results in its `select` loop. The buffer prevents workers from blocking when multiple tasks complete simultaneously while logic is processing input.
|
||||
|
||||
4. **Work Channels**: Buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. Priority ensures critical tasks jump the queue.
|
||||
4. **Work Channels (buffered to `workerCount × 2`)**: If all workers are busy, logic's dispatch blocks briefly until a worker frees up. Priority ensures critical tasks jump the queue.
|
||||
|
||||
## 4. Synchronous/Asynchronous Partitioning
|
||||
## 4. Result Matching
|
||||
|
||||
When multiple tasks are dispatched concurrently, the logic goroutine must match results to their originating tasks. Each `Task` carries identifying context:
|
||||
|
||||
| Field | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `TaskID()` | Unique identifier per dispatch | `"read_dir_/docs_42"` |
|
||||
| `TaskType()` | Routing key for result handler | `TypeReadDir`, `TypeLoadPages` |
|
||||
| `DirPath()` | Directory the task operates on | `"/user/documents"` |
|
||||
|
||||
The `Result` struct includes `TaskID`, `TaskType`, and `Data`. The logic goroutine routes results by `TaskType` (e.g., `IsBrowserResult()`, `IsFileResult()`) and uses `DirPath()` to apply results to the correct state (e.g., loading pages for the currently browsed directory).
|
||||
|
||||
```go
|
||||
case res := <-wp.ResultChan():
|
||||
if res.IsBrowserResult() {
|
||||
switch res.TaskType {
|
||||
case TypeBuildIndex:
|
||||
applyDirectoryLoaded(res) // Uses res.Data + DirPath context
|
||||
case TypeLoadPages:
|
||||
applyPagesLoaded(res) // Matches pages to current directory
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tasks carry their context (directory path, file path, page indices) in their struct fields. When a task executes, it embeds this context in the `Result.Data` field, so the logic goroutine can apply the result to the correct state.
|
||||
|
||||
## 5. Error Handling Policy
|
||||
|
||||
Worker tasks may fail due to I/O errors, permission issues, or missing files. The error handling policy ensures the editor remains responsive:
|
||||
|
||||
| Task Category | On Error | Recovery |
|
||||
|---|---|---|
|
||||
| **Browser tasks** (read dir, build index, load pages) | Log warning; show placeholder in UI | Retry on next browse; user can navigate away and back |
|
||||
| **File tasks** (read file, write file) | Log warning; show toast notification | Auto-save retries on next debounce cycle |
|
||||
| **State persistence** (save state, save undo) | Log warning; continue editing | State is re-persisted on next edit |
|
||||
| **Cache tasks** (read/write cache) | Log warning; fall back to rebuild | Cache is rebuilt on next access |
|
||||
|
||||
**Implementation**: Each task returns a `Result` with `Success: false` and `Error` set. The logic goroutine checks `result.IsError()` and handles accordingly. Errors are never re-queued automatically — the caller decides whether to retry based on the task type.
|
||||
|
||||
**User notification**: Critical errors (file read/write failures) show a brief toast. Non-critical errors (cache misses, index rebuilds) are logged silently.
|
||||
|
||||
## 6. Timeout and Cancellation
|
||||
|
||||
Worker tasks operate under time constraints to prevent the pool from stalling:
|
||||
|
||||
### Task Timeouts
|
||||
|
||||
Tasks have implicit timeouts enforced by the worker pool:
|
||||
|
||||
| Task Type | Timeout | Rationale |
|
||||
|---|---|---|
|
||||
| High priority (UI-critical) | 5 seconds | User is waiting; must complete or fail fast |
|
||||
| Low priority (background) | 30 seconds | Can be retried; no user blockage |
|
||||
|
||||
If a task exceeds its timeout, the worker cancels the task's `context.Context` and posts a `Result` with `Error: ErrTimeout`.
|
||||
|
||||
### Task Cancellation
|
||||
|
||||
Tasks carry a `context.Context` that is cancelled when:
|
||||
|
||||
1. **User navigates away**: If the user leaves a directory, pending `LoadPages` tasks for that directory are cancelled.
|
||||
2. **File is closed**: Pending chunk loads for a closed file are cancelled.
|
||||
3. **Pool shutdown**: `Stop()` closes work channels; workers drain remaining tasks but new dispatches are rejected.
|
||||
|
||||
```go
|
||||
// Task interface includes context for cancellation
|
||||
func (t *ReadDirTask) Execute() Result {
|
||||
ctx := t.Context() // Cancelled if user navigates away
|
||||
entries, err := t.FS.ReadDirContext(ctx, t.Dir)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Timeout Implementation
|
||||
|
||||
The `execute()` method wraps task execution with a timeout:
|
||||
|
||||
```go
|
||||
func (wp *WorkerPool) execute(task Task) Result {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), task.Timeout())
|
||||
defer cancel()
|
||||
|
||||
// Task receives context via Task.Context() or sets it before Execute()
|
||||
result := task.Execute()
|
||||
result.Timestamp = time.Now()
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Synchronous/Asynchronous Partitioning
|
||||
|
||||
Every user-facing action has a **synchronous state decision** (what the UI shows) and an optional **asynchronous persistence** (writing to disk). The synchronous path must complete in < 16ms.
|
||||
|
||||
|
|
@ -337,7 +426,34 @@ Workers: <-highWorkChan // drain at their own pace
|
|||
| **`sync.Once`** | No one-time initialization that needs coordination |
|
||||
| **`atomic.Value`** | Frame is a slice header (two words); mutex is simpler and safer |
|
||||
|
||||
## 8. Frame Lifecycle
|
||||
## 8. Browser State Management
|
||||
|
||||
The browser package defines a `BrowserState` struct that holds all browser-specific state. This struct is **embedded** in the editor's `State` struct to maintain the single-owner pattern:
|
||||
|
||||
```go
|
||||
type State struct {
|
||||
// ... other fields ...
|
||||
Browser BrowserState // Embedded, not a pointer
|
||||
}
|
||||
```
|
||||
|
||||
This ensures:
|
||||
1. All browser state is owned by the editor package
|
||||
2. The logic goroutine is the sole owner of browser state
|
||||
3. No cross-package state access is required
|
||||
|
||||
The browser package's `BrowserLayout` function is called from the editor package, passing the embedded `BrowserState`:
|
||||
|
||||
```go
|
||||
// In editor/state.go
|
||||
func (s *State) BrowserLayout(screenW, screenH ui.Dp) []ui.Element {
|
||||
return browser.BrowserLayout(screenW, screenH, &s.Browser)
|
||||
}
|
||||
```
|
||||
|
||||
This follows the architecture's principle that the editor package owns all application state, while the browser package provides pure functions for layout computation.
|
||||
|
||||
## 9. Frame Lifecycle
|
||||
|
||||
A frame (computed `[]Element`) follows this lifecycle:
|
||||
|
||||
|
|
|
|||
885
doc/browser_implementation_plan.md
Normal file
885
doc/browser_implementation_plan.md
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
# Browser Implementation Plan
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This plan specifies the implementation of the directory browser for Pad, covering lazy loading, virtualized rendering, alphabetical indexing, and search functionality.
|
||||
|
||||
### Current State (as of 2026-05-30)
|
||||
|
||||
| Feature | Status | Location |
|
||||
|---|---|---|
|
||||
| Browser page rendering | ✅ Implemented | `internal/browser/layout.go:BrowserLayout()` |
|
||||
| Scroll with virtualization | ✅ Implemented | `internal/editor/state.go` + `ui/element.go:ListView` |
|
||||
| Search (case-insensitive) | ✅ Implemented | `internal/browser/search.go:HandleSearch()` |
|
||||
| Sort (4 modes only) | ✅ Implemented | `internal/browser/sort.go` (4 modes, not 6) |
|
||||
| Worker pool | ✅ Implemented | `internal/io/pool/` |
|
||||
| Filesystem-backed entries | ❌ Not started | Uses static `browserEntries` slice |
|
||||
| Lazy loading / pagination | ⏳ Partial | Page model + eviction implemented; disk I/O not wired |
|
||||
| Directory index / caching | ✅ Implemented | `internal/browser/index.go:buildIndex()` |
|
||||
| Alphabetical index sidebar | ⏸ Deferred | See §14 |
|
||||
|
||||
### Design Decisions Summary
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| **Lazy loading required** | Directories with 100k+ entries must not block first paint or exhaust memory |
|
||||
| **Page-based loading** | Matches the editor's chunked buffer pattern; predictable memory usage |
|
||||
| **Pre-sorted index with position maps** | Index stores raw metadata; pre-computed position maps for each of 4 sort modes; O(1) lookup during frame rendering; no sorting on frame path |
|
||||
| **Virtualized ListView** | Only render visible items; matches existing `ListView` element |
|
||||
| **Worker-dispatched reads** | Directory I/O goes through high-priority worker channel; logic stays <16ms |
|
||||
| **Editor-owned state** | All browser state is embedded in the editor's state to maintain single-owner pattern |
|
||||
|
||||
---
|
||||
|
||||
## 2. Package Structure
|
||||
|
||||
```
|
||||
internal/browser/
|
||||
browser.go # Main browser logic, state machine, page management
|
||||
index.go # Directory index (lazy-loaded, cached, sorted)
|
||||
layout.go # Browser layout computation (produces []Element)
|
||||
handlers.go # Interaction handlers (tap, scroll, search, alpha index)
|
||||
types.go # Browser-specific types (Entry, Page, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Types
|
||||
|
||||
### 3.1 Directory Entry
|
||||
|
||||
```go
|
||||
// Entry represents a single file or directory in the browser.
|
||||
type Entry struct {
|
||||
Path string // Relative path from configured directory
|
||||
Name string // Display name (basename)
|
||||
Size int64 // File size in bytes (0 for directories)
|
||||
ModTime time.Time // Last modification time
|
||||
IsDir bool // True if this is a directory
|
||||
IsFirst bool // True if this is the first entry for its letter section
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Browser Page (Lazy Load Unit)
|
||||
|
||||
```go
|
||||
// Page represents a chunk of directory entries loaded from disk.
|
||||
type Page struct {
|
||||
Index int // Page number (0-based)
|
||||
Entries []Entry // Entries in this page
|
||||
Loaded bool // True if page data is in memory
|
||||
Dirty bool // True if page needs refresh (external change detected)
|
||||
}
|
||||
|
||||
const (
|
||||
PageSize = 100 // Entries per page (tunable; ~11KB per page in memory)
|
||||
PrefetchDist = 2 // Pages to prefetch beyond visible region
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 Browser State
|
||||
|
||||
```go
|
||||
// BrowserState holds all mutable browser state owned by the logic goroutine.
|
||||
// This struct is embedded in the editor's State to maintain the single-owner pattern.
|
||||
type BrowserState struct {
|
||||
// Navigation
|
||||
CurrentPath string // Currently browsed directory (relative to root)
|
||||
ScrollIndex int // Index of first visible entry (not pixel offset)
|
||||
VisibleCount int // Number of entries currently visible
|
||||
|
||||
// Lazy loading
|
||||
Pages map[int]*Page // Loaded pages by page index
|
||||
TotalEntries int // Total entry count (from cached index)
|
||||
Loading bool // True if a page load is in flight
|
||||
|
||||
// Search
|
||||
Query string // Current search query
|
||||
SearchResults []int // Indices of matching entries (empty = no filter)
|
||||
|
||||
// Alphabetical index (DEFERRED - see §14.1)
|
||||
// ActiveLetter string // Currently pressed letter (for highlighting)
|
||||
// LetterOffsets map[string]int // First entry index for each letter
|
||||
|
||||
// Interaction
|
||||
SelectedIndex int // Currently selected entry (-1 = none)
|
||||
TapTimestamp time.Time // For double-tap detection
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Lazy Loading Architecture
|
||||
|
||||
### 4.1 Page Lifecycle
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ VISIBLE │────▶│ PREFETCH │────▶│ EVICTED │
|
||||
│ (in memory)│ │ (in memory)│ │ (on disk) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
└────────────────────┴───────────────────┘
|
||||
scroll triggers transitions
|
||||
```
|
||||
|
||||
**Page states:**
|
||||
1. **Visible**: Page is within the current viewport; entries are rendered
|
||||
2. **Prefetched**: Page is within `PrefetchDist` of the viewport; entries are loaded but not rendered
|
||||
3. **Evicted**: Page is outside prefetch range; entries are released from memory
|
||||
|
||||
### 4.2 Loading Flow
|
||||
|
||||
```
|
||||
User scrolls browser
|
||||
↓
|
||||
Logic goroutine: update ScrollIndex, compute visible page range
|
||||
↓
|
||||
Logic goroutine: identify unloaded pages in [visible - prefetch, visible + prefetch]
|
||||
↓
|
||||
Logic goroutine: pool.Dispatch(NewLoadPagesTask(dirPath, pageIndices, fs))
|
||||
↓
|
||||
Worker: Task.Execute() → Result{TaskType: TypeLoadPages, Data: pages}
|
||||
↓
|
||||
Worker: wp.post(result) — posts to encapsulated result channel
|
||||
↓
|
||||
Logic goroutine: receives Result via pool.ResultChan(), applies to BrowserState.Pages
|
||||
↓
|
||||
Logic goroutine: sendFrame() with updated ListView entries
|
||||
```
|
||||
|
||||
### 4.3 Memory Management
|
||||
|
||||
- **Page eviction**: When memory pressure is detected (via the monitoring system in architecture.md §10), pages beyond the prefetch range are evicted
|
||||
- **Max pages in memory**: `VisibleCount / PageSize + 2 * PrefetchDist + 2` (visible + prefetch buffer)
|
||||
- **Per-page memory**: ~11KB for 100 entries (Entry struct is ~113 bytes: Path string ~50B, Name string ~30B, Size int64 8B, ModTime time.Time 24B, IsDir/IsFirst bool 2B)
|
||||
- **Total browser memory budget**: ~1.1MB for typical viewport (10 pages)
|
||||
- **Position maps**: 100k × 4 bytes × 4 sort modes = 1.6MB (shared across all pages)
|
||||
|
||||
### 4.4 Eviction Policy
|
||||
|
||||
```go
|
||||
// Evict pages that are far from the current scroll position.
|
||||
func (s *BrowserState) EvictPages() {
|
||||
minPage := s.ScrollIndex/PageSize - PrefetchDist
|
||||
maxPage := (s.ScrollIndex + s.VisibleCount)/PageSize + PrefetchDist
|
||||
for idx, page := range s.Pages {
|
||||
if idx < minPage || idx > maxPage {
|
||||
page.Entries = nil // Release memory
|
||||
page.Loaded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Directory Index
|
||||
|
||||
### 5.1 Sorted Index Architecture
|
||||
|
||||
The browser uses a **pre-sorted index with position maps** to deliver entries in the correct sort order without sorting during frame rendering. This ensures the 16ms frame budget is maintained even for directories with 100k+ entries.
|
||||
|
||||
**Core concept:** Instead of storing entries in sorted order (which requires sorting on every frame or mode change), we store entries in raw filesystem order and maintain pre-computed position maps that map sorted indices to raw indices.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SORTED INDEX │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Entries[] (raw filesystem order, unsorted) │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ... │
|
||||
│ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ Position Maps (one per sort mode, 4 total) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ NameAsc: [2, 5, 0, 7, 3, 1, 6, 4, ...] │ │
|
||||
│ │ NameDesc: [4, 6, 1, 3, 7, 0, 5, 2, ...] │ │
|
||||
│ │ DateAsc: [5, 0, 7, 2, 3, 1, 6, 4, ...] │ │
|
||||
│ │ DateDesc: [4, 6, 1, 3, 7, 0, 5, 2, ...] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Frame path (sub-16ms):**
|
||||
```
|
||||
BrowserLayout → computeVisibleEntries → getEntryByIndex(sortedPos, mode)
|
||||
↓
|
||||
positionMap = SortOrders[mode]
|
||||
rawPos = positionMap[sortedPos]
|
||||
return Entries[rawPos]
|
||||
```
|
||||
|
||||
### 5.2 Index File Format
|
||||
|
||||
The directory index is cached on disk to avoid re-reading and sorting on every browse:
|
||||
|
||||
```
|
||||
.pad/indices/browser_<dir_hash>.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/user/documents",
|
||||
"mtime": 1715000000,
|
||||
"size": 4096,
|
||||
"entry_count": 150000,
|
||||
"entries": [
|
||||
{"path": "a/file.txt", "name": "a", "size": 0, "mod_time": 1715000000, "is_dir": true},
|
||||
...
|
||||
],
|
||||
"sort_orders": {
|
||||
"name_asc": [2, 5, 0, 7, 3, 1, 6, 4, ...],
|
||||
"name_desc": [4, 6, 1, 3, 7, 0, 5, 2, ...],
|
||||
"date_asc": [5, 0, 7, 2, 3, 1, 6, 4, ...],
|
||||
"date_desc": [4, 6, 1, 3, 7, 0, 5, 2, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Memory calculation for 100k entries:**
|
||||
- Raw entries: 100k × 113 bytes = 11.3MB
|
||||
- Position maps: 100k × 4 bytes × 4 modes = 1.6MB
|
||||
- **Total: ~13MB** (acceptable on modern devices)
|
||||
|
||||
### 5.3 Index Build/Invalidation
|
||||
|
||||
| Event | Action |
|
||||
|---|---|
|
||||
| First browse of directory | Build index: read directory, compute position maps, write to `.pad/indices/` |
|
||||
| Directory mtime changed | Rebuild index on next browse |
|
||||
| External file created/deleted | Watcher triggers index rebuild for affected directory |
|
||||
| Index file missing | Build from scratch |
|
||||
|
||||
### 5.4 Index Build Process
|
||||
|
||||
```go
|
||||
// buildIndex reads the directory and produces a cached index with position maps.
|
||||
func buildIndex(dirPath string) (*DirectoryIndex, error) {
|
||||
// 1. Read directory entries (os.ReadDir)
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Convert to Entry structs (unsorted)
|
||||
var browserEntries []Entry
|
||||
for _, e := range entries {
|
||||
info, _ := e.Info()
|
||||
browserEntries = append(browserEntries, Entry{
|
||||
Path: filepath.Join(dirPath, e.Name()),
|
||||
Name: e.Name(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
IsDir: info.IsDir(),
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Build position maps for all 4 sort modes
|
||||
sortOrders := make(map[SortMode][]int)
|
||||
for mode := SortModeNameAsc; mode <= SortModeDateDesc; mode++ {
|
||||
sortOrders[mode] = buildPositionMap(browserEntries, mode)
|
||||
}
|
||||
|
||||
// 4. Write to cache
|
||||
idx := &DirectoryIndex{
|
||||
Path: dirPath,
|
||||
Mtime: dirInfo.ModTime(),
|
||||
EntryCount: len(browserEntries),
|
||||
Entries: browserEntries,
|
||||
SortOrders: sortOrders,
|
||||
}
|
||||
cacheIndex(idx)
|
||||
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// buildPositionMap creates a sorted index → raw index mapping.
|
||||
func buildPositionMap(entries []Entry, mode SortMode) []int {
|
||||
n := len(entries)
|
||||
indices := make([]int, n)
|
||||
for i := range indices {
|
||||
indices[i] = i
|
||||
}
|
||||
|
||||
// Sort indices based on entry comparison
|
||||
sort.SliceStable(indices, func(i, j int) bool {
|
||||
a, b := entries[indices[i]], entries[indices[j]]
|
||||
return comparator(mode)(a, b) < 0
|
||||
})
|
||||
|
||||
return indices
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Layout Computation
|
||||
|
||||
### 6.1 Browser Layout Function
|
||||
|
||||
```go
|
||||
// BrowserLayout computes []Element for the directory browser page.
|
||||
// Pure function: (screen dimensions, browser state) → []Element
|
||||
// This function is in the browser package but called from the editor package.
|
||||
func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState) []ui.Element {
|
||||
// Compute regions
|
||||
headerH := ui.Dp(48)
|
||||
searchBarH := ui.Dp(40)
|
||||
bottomBarH := ui.Dp(24)
|
||||
alphaIndexW := ui.Dp(24)
|
||||
|
||||
// Header
|
||||
header := ui.NewLabel(
|
||||
state.CurrentPath,
|
||||
18,
|
||||
ui.Region{X: ui.Dp(10), Y: ui.Dp(10), W: screenW - ui.Dp(20), H: headerH},
|
||||
ui.AlignStart,
|
||||
)
|
||||
|
||||
// Search bar (if query is non-empty or search is active)
|
||||
var searchBar ui.Element
|
||||
searchY := headerH + ui.Dp(10)
|
||||
if state.Query != "" {
|
||||
searchBar = ui.NewTextField(ui.Region{
|
||||
X: ui.Dp(10), Y: searchY,
|
||||
W: screenW - ui.Dp(34), H: searchBarH,
|
||||
}, state.Query, "Search...")
|
||||
searchY += searchBarH + ui.Dp(4)
|
||||
}
|
||||
|
||||
// ListView region
|
||||
listW := screenW - alphaIndexW - ui.Dp(20)
|
||||
listH := screenH - searchY - bottomBarH - ui.Dp(10)
|
||||
listRegion := ui.Region{
|
||||
X: ui.Dp(10), Y: searchY,
|
||||
W: listW, H: listH,
|
||||
}
|
||||
|
||||
// Compute visible entries
|
||||
var visibleEntries []ui.ListItem
|
||||
startIndex := state.ScrollIndex
|
||||
endIndex := startIndex + state.VisibleCount
|
||||
|
||||
// Use search results if filtering
|
||||
var effectiveStart, effectiveEnd int
|
||||
if len(state.SearchResults) > 0 {
|
||||
effectiveStart = state.SearchResults[0]
|
||||
effectiveEnd = state.SearchResults[len(state.SearchResults)-1]
|
||||
} else {
|
||||
effectiveStart = startIndex
|
||||
effectiveEnd = endIndex
|
||||
}
|
||||
|
||||
// Load pages as needed (sync if available, async if not)
|
||||
for idx := effectiveStart; idx < effectiveEnd && idx < state.TotalEntries; idx++ {
|
||||
pageIdx := idx / PageSize
|
||||
page, loaded := state.Pages[pageIdx]
|
||||
if !loaded || !page.Loaded {
|
||||
// Show placeholder or skip; page load is in flight
|
||||
continue
|
||||
}
|
||||
entry := page.Entries[idx%PageSize]
|
||||
subtext := formatSize(entry.Size)
|
||||
if entry.IsDir {
|
||||
subtext = "Directory"
|
||||
}
|
||||
visibleEntries = append(visibleEntries, ui.ListItem{
|
||||
Text: entry.Name,
|
||||
Subtext: subtext,
|
||||
Selected: idx == state.SelectedIndex,
|
||||
})
|
||||
}
|
||||
|
||||
listView := ui.NewListView(listRegion, visibleEntries, state.ScrollIndex, state.SelectedIndex)
|
||||
|
||||
// Alpha index (DEFERRED - see §14.1)
|
||||
// alphaIndex := ui.NewAlphaIndex(...)
|
||||
|
||||
return []ui.Element{header, searchBar, listView} // alphaIndex deferred
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Interaction Handlers
|
||||
|
||||
### 7.1 Tap Handler (File Selection)
|
||||
|
||||
```go
|
||||
// HandleBrowserTap processes a tap on the ListView.
|
||||
func HandleBrowserTap(data any) {
|
||||
state := editor.TheState.Load()
|
||||
state.Lock()
|
||||
defer state.Unlock()
|
||||
|
||||
clickData := data.(gesture.ClickEvent)
|
||||
// Convert click Y to entry index
|
||||
entryIndex := state.Browser.ScrollIndex +
|
||||
int(clickData.Location.Y/state.Browser.EntryHeight)
|
||||
|
||||
// Load entry if not in memory
|
||||
pageIdx := entryIndex / PageSize
|
||||
page, ok := state.Browser.Pages[pageIdx]
|
||||
if !ok || !page.Loaded {
|
||||
return // Page not loaded yet; will handle on next frame
|
||||
}
|
||||
|
||||
entry := page.Entries[entryIndex%PageSize]
|
||||
|
||||
if entry.IsDir {
|
||||
// Navigate into directory
|
||||
state.Browser.CurrentPath = entry.Path
|
||||
state.Browser.ScrollIndex = 0
|
||||
state.Browser.Pages = make(map[int]*Page)
|
||||
// Dispatch index build/load
|
||||
dispatchLoadDirectory(entry.Path)
|
||||
} else {
|
||||
// Open file in editor
|
||||
state.Editor.ActiveFile = entry.Path
|
||||
state.Page = PageEditor
|
||||
// Dispatch file open
|
||||
dispatchOpenFile(entry.Path)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Scroll Handler
|
||||
|
||||
```go
|
||||
// HandleBrowserScroll processes scroll events on the ListView.
|
||||
func HandleBrowserScroll(data any) {
|
||||
state := editor.TheState.Load()
|
||||
state.Lock()
|
||||
defer state.Unlock()
|
||||
|
||||
scrollData := data.(ScrollEvent)
|
||||
delta := int(scrollData.Delta / state.Browser.EntryHeight)
|
||||
|
||||
oldScrollIndex := state.Browser.ScrollIndex
|
||||
state.Browser.ScrollIndex += delta
|
||||
|
||||
// Clamp
|
||||
maxScroll := state.Browser.TotalEntries - state.Browser.VisibleCount
|
||||
if state.Browser.ScrollIndex < 0 {
|
||||
state.Browser.ScrollIndex = 0
|
||||
}
|
||||
if state.Browser.ScrollIndex > maxScroll {
|
||||
state.Browser.ScrollIndex = maxScroll
|
||||
}
|
||||
|
||||
// Trigger prefetch for newly visible pages
|
||||
if state.Browser.ScrollIndex != oldScrollIndex {
|
||||
dispatchPrefetchPages(state.Browser)
|
||||
state.Browser.EvictPages()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Alphabet Index Handler (DEFERRED)
|
||||
|
||||
**Status: DEFERRED** - The alphabetical index sidebar is not included in this implementation round (see §14.1). The handler would read `LetterOffsets` and set `ScrollIndex` directly, but this feature is not being implemented at this time.
|
||||
|
||||
### 7.4 Search Handler
|
||||
|
||||
```go
|
||||
// HandleSearchInput filters entries by the current query.
|
||||
func HandleSearchInput(data any) {
|
||||
state := editor.TheState.Load()
|
||||
state.Lock()
|
||||
defer state.Unlock()
|
||||
|
||||
query := data.(string)
|
||||
state.Browser.Query = query
|
||||
|
||||
if query == "" {
|
||||
state.Browser.SearchResults = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Filter entries (runs over loaded pages; for large directories,
|
||||
// this may need to run over the cached index)
|
||||
var results []int
|
||||
queryLower := strings.ToLower(query)
|
||||
for idx := 0; idx < state.Browser.TotalEntries; idx++ {
|
||||
pageIdx := idx / PageSize
|
||||
page, ok := state.Browser.Pages[pageIdx]
|
||||
if !ok || !page.Loaded {
|
||||
continue
|
||||
}
|
||||
entry := page.Entries[idx%PageSize]
|
||||
if strings.Contains(strings.ToLower(entry.Name), queryLower) {
|
||||
results = append(results, idx)
|
||||
}
|
||||
}
|
||||
state.Browser.SearchResults = results
|
||||
|
||||
// Jump to first result
|
||||
if len(results) > 0 {
|
||||
state.Browser.ScrollIndex = results[0]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Worker Tasks
|
||||
|
||||
### 8.1 IO Delegation Model
|
||||
|
||||
The browser package follows the architecture's single-owner pattern:
|
||||
|
||||
1. **Logic goroutine** owns `BrowserState` — the only goroutine that reads/writes browser state
|
||||
2. **Logic goroutine** dispatches IO tasks via `pool.Dispatch()` or `pool.DispatchNonBlocking()`
|
||||
3. **Workers** perform disk IO — read directories, read/write index cache files
|
||||
4. **Workers** call `Task.Execute()` which returns a generic `pool.Result`, then post via `wp.post(result)`
|
||||
5. **Logic goroutine** receives results via `pool.ResultChan()` in its `select` loop and applies them to state
|
||||
|
||||
**Invariant**: Workers never access `BrowserState` directly. They only read from the filesystem and post structured results.
|
||||
|
||||
### 8.2 Result Routing
|
||||
|
||||
Browser tasks return generic `pool.Result` values with `TaskType` set to one of the browser task types (`TypeReadDir`, `TypeBuildIndex`, `TypeLoadIndex`, `TypeLoadPages`, `TypeStatDir`). The logic goroutine uses `result.IsBrowserResult()` to identify browser results and routes on `result.TaskType`:
|
||||
|
||||
```go
|
||||
case res := <-wp.ResultChan():
|
||||
if res.IsBrowserResult() {
|
||||
switch res.TaskType {
|
||||
case TypeBuildIndex:
|
||||
applyDirectoryLoaded(res)
|
||||
case TypeLoadPages:
|
||||
applyPagesLoaded(res)
|
||||
case TypeReadDir, TypeLoadIndex:
|
||||
// Handle as needed
|
||||
}
|
||||
if res.IsError() {
|
||||
applyBrowserError(res)
|
||||
}
|
||||
sendFrame()
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The plan originally described a custom `BrowserResult` type with fields like `Type`, `DirPath`, `Data`, `Err`. This was superseded by the generic `pool.Result` type during worker pool implementation. The generic type avoids per-domain type proliferation — all the information a custom type would carry is already available via `Result.TaskType`, `Task.DirPath()`, `Result.Data`, and `Result.Error`.
|
||||
|
||||
### 8.3 Task Dispatch
|
||||
|
||||
Browser tasks are dispatched through the worker pool via `pool.Dispatch()` or `pool.DispatchNonBlocking()`. These methods encapsulate channel access — internal channels are not exposed:
|
||||
|
||||
```go
|
||||
// Dispatch a build index task (blocking — result is required)
|
||||
pool.Dispatch(pool.NewBuildIndexTask(dirPath, fs))
|
||||
|
||||
// Dispatch a page load task (blocking — result is required)
|
||||
pool.Dispatch(pool.NewLoadPagesTask(dirPath, pageIndices, fs))
|
||||
```
|
||||
|
||||
**Do not use `DispatchNonBlocking` for browser tasks.** If a page load task is dropped, the logic goroutine will wait forever for a result that will never arrive, causing a livelock.
|
||||
|
||||
### 8.4 Task Implementations
|
||||
|
||||
Browser tasks implement the `pool.Task` interface defined in `internal/io/pool/task.go`:
|
||||
|
||||
```go
|
||||
type Task interface {
|
||||
Execute() Result // Performs the work, returns a Result
|
||||
Priority() Priority // HighPriority or LowPriority
|
||||
TaskID() string // Unique identifier for result matching
|
||||
TaskType() TaskType // Routing key (e.g., TypeBuildIndex)
|
||||
DirPath() string // Directory this task operates on
|
||||
}
|
||||
```
|
||||
|
||||
Workers call `Task.Execute()` which returns a `pool.Result`. The worker then calls `wp.post(result)` to send it to the logic goroutine. Tasks never write to channels directly — channel access is encapsulated in the worker pool.
|
||||
|
||||
Existing task types in `internal/io/pool/task.go`: `ReadDirTask`, `BuildIndexTask`, `LoadIndexTask`, `LoadPagesTask`, `StatDirTask`. New browser-specific task types can be added there if needed.
|
||||
|
||||
### 8.5 Logic Goroutine Result Handling
|
||||
|
||||
The logic goroutine's `select` loop handles browser results as shown in §8.2. The key invariant is that every dispatched browser task produces exactly one result — the worker pool guarantees this through blocking sends on both the work channel (via `Dispatch()`) and the result channel (via `wp.post()`).
|
||||
|
||||
### 8.6 Cache Persistence
|
||||
|
||||
The index cache (`.pad/indices/`) is written by workers during index build, not by the logic goroutine. This means:
|
||||
|
||||
- **Index build** is entirely async — no blocking on the logic thread
|
||||
- **Cache reads** are entirely async — workers read from disk, post results via `wp.post()`
|
||||
- **Logic never touches the filesystem** — it only manages in-memory state
|
||||
|
||||
This is consistent with the architecture's partitioning: logic stays under 16ms, all IO is delegated.
|
||||
|
||||
---
|
||||
|
||||
## 9. State Persistence
|
||||
|
||||
### 9.1 Browser State in `state.json`
|
||||
|
||||
The browser scroll position is persisted in the existing `state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"browser_scroll": 1200,
|
||||
"browser_path": "/user/documents",
|
||||
"browser_query": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Restore Flow
|
||||
|
||||
1. App launches → read `state.json`
|
||||
2. If `browser_path` exists and is valid → load that directory
|
||||
3. Restore `browser_scroll` position
|
||||
4. If directory was deleted → fall back to configured root directory
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Phases
|
||||
|
||||
### Phase 1: Core Browser (MVP)
|
||||
- [ ] `internal/browser/types.go` — Define Entry, Page, BrowserState
|
||||
- [ ] `internal/browser/index.go` — Directory index build/cache
|
||||
- [ ] `internal/browser/browser.go` — State management, page loading
|
||||
- [ ] `internal/browser/layout.go` — BrowserLayout function
|
||||
- [ ] Wire into main event loop (replace placeholder browser)
|
||||
- [ ] Basic scroll and tap-to-open
|
||||
|
||||
### Phase 2: Lazy Loading
|
||||
- [ ] Page-based loading with worker dispatch
|
||||
- [ ] Prefetch logic
|
||||
- [ ] Page eviction under memory pressure
|
||||
- [ ] Loading indicators for unloaded pages
|
||||
|
||||
### Phase 3: Deferred — Alphabetical Index Sidebar
|
||||
|
||||
**Status: DEFERRED** — The alphabetical index sidebar (tap-a-letter to jump) is not included in this implementation round. It can be added later as a standalone feature without changes to the core browser architecture.
|
||||
|
||||
Deferred items:
|
||||
- Alphabetical index sidebar element
|
||||
- Letter offset computation
|
||||
- Tap-to-jump functionality
|
||||
|
||||
### Phase 4: Search
|
||||
- [ ] Search bar UI
|
||||
- [ ] Incremental filtering
|
||||
- [ ] Search result navigation
|
||||
|
||||
### Phase 5: Polish
|
||||
- [ ] Scroll momentum (reuse from touch.md §6)
|
||||
- [ ] External change detection (watcher integration)
|
||||
- [ ] State persistence and restore
|
||||
- [ ] Performance testing with large directories
|
||||
|
||||
---
|
||||
|
||||
## 11. Performance Targets
|
||||
|
||||
| Operation | Target | Mechanism |
|
||||
|---|---|---|
|
||||
| First paint (empty directory) | < 100 ms | Show header + empty list immediately |
|
||||
| First paint (100k entries) | < 200 ms | Load first page async, show loading skeleton |
|
||||
| Scroll (60 fps) | 16 ms/frame | Virtualized ListView, only visible items rendered |
|
||||
| Page load | < 50 ms | Cached index, sequential read |
|
||||
| Search (100k entries) | < 100 ms | Index-level filtering, no disk I/O |
|
||||
| Alpha index jump | < 10 ms | Direct index lookup, no iteration |
|
||||
|
||||
---
|
||||
|
||||
## 12. Edge Cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Directory with 0 files | Show empty state message |
|
||||
| Directory with 1 file | Show single entry, no scroll |
|
||||
| Directory with 1M+ entries | Lazy load, paginate, evict aggressively |
|
||||
| Directory deleted externally | Show error, offer to go back or refresh |
|
||||
| File renamed externally | Index rebuild on next browse |
|
||||
| Rapid scroll | Debounce page loads; coalesce requests |
|
||||
| Memory pressure | Evict non-visible pages immediately |
|
||||
| Index file corrupted | Rebuild from scratch, log warning |
|
||||
|
||||
---
|
||||
|
||||
## 13. Testing Strategy — Test-First (TDD)
|
||||
|
||||
### 13.1 Methodology
|
||||
|
||||
**Rule: Tests are written before any implementation code. Every test must fail before its implementation is written.**
|
||||
|
||||
This ensures:
|
||||
1. Tests are meaningful (they fail without code)
|
||||
2. Implementation is minimal (only what's needed to pass)
|
||||
3. No accidental pass (test passes without real logic)
|
||||
|
||||
**Process for each feature:**
|
||||
1. Write the test file with the expected behavior
|
||||
2. Run `go test ./internal/browser/...` — confirm ALL tests fail
|
||||
3. Implement the minimum code to make tests pass
|
||||
4. Run tests again — confirm ALL tests pass
|
||||
5. Refactor if needed, keeping tests green
|
||||
|
||||
### 13.2 Test File Organization
|
||||
|
||||
```
|
||||
internal/browser/
|
||||
types_test.go # Tests for Entry, Page, BrowserState constructors
|
||||
index_test.go # Tests for directory index build/cache/invalidation
|
||||
browser_test.go # Tests for page loading, eviction, state management
|
||||
layout_test.go # Tests for BrowserLayout pure function
|
||||
scroll_test.go # Tests for scroll clamping and delta computation
|
||||
search_test.go # Tests for query filtering and result navigation
|
||||
fixtures/ # Synthetic directory structures for testing
|
||||
empty/ # Empty directory
|
||||
small/ # 10 entries
|
||||
large/ # 100k+ entries (generated at test time)
|
||||
```
|
||||
|
||||
### 13.3 Phase 1 Tests — Types (types_test.go)
|
||||
|
||||
**Write first. Must all fail.**
|
||||
|
||||
| Test | What it verifies | Expected failure |
|
||||
|---|---|---|
|
||||
| `TestNewEntryFromFileInfo` | Entry constructed from `fs.DirEntry` | No `Entry` struct exists |
|
||||
| `TestNewPage` | Page fields initialized correctly | `Page` struct missing |
|
||||
| `TestPageSizeConstant` | `PageSize` is 100 | Constant not defined |
|
||||
| `TestNewBrowserState` | Default state values (ScrollIndex=0, etc.) | `BrowserState` struct missing |
|
||||
|
||||
### 13.4 Phase 1 Tests — Index (index_test.go)
|
||||
|
||||
**Write after types tests pass. Must all fail.**
|
||||
|
||||
| Test | What it verifies | Expected failure |
|
||||
|---|---|---|
|
||||
| `TestBuildIndex_EmptyDir` | Empty directory → 0 entries, no error | `buildIndex` not implemented |
|
||||
| `TestBuildIndex_SortedByName` | Entries sorted case-insensitively by name | Sort logic missing |
|
||||
| `TestBuildIndex_DirsBeforeFiles` | Directories appear before files | Sort priority missing |
|
||||
| `TestBuildIndex_CacheWritten` | Index written to `.pad/indices/` | Cache write not implemented |
|
||||
| `TestBuildIndex_CacheInvalidatedOnMtimeChange` | Changed mtime triggers rebuild | Invalidation logic missing |
|
||||
| `TestBuildIndex_CacheReusedWhenUnchanged` | Same mtime → cached index reused | Cache read not implemented |
|
||||
| `TestBuildIndex_LargeDirectory` | 10k entries built within time budget | Not tested at scale |
|
||||
|
||||
**Fixture setup:**
|
||||
```go
|
||||
func setupTestDir(t *testing.T, name string, entries []testEntry) string {
|
||||
dir := t.TempDir()
|
||||
// Create files and subdirectories
|
||||
return dir
|
||||
}
|
||||
```
|
||||
|
||||
### 13.5 Phase 1 Tests — Browser State (browser_test.go)
|
||||
|
||||
**Write after index tests pass. Must all fail.**
|
||||
|
||||
| Test | What it verifies | Expected failure |
|
||||
|---|---|---|
|
||||
| `TestLoadPage_FromIndex` | Page loaded from index at correct offset | Page loading not implemented |
|
||||
| `TestLoadPage_LastPagePartial` | Last page has fewer than PageSize entries | Boundary handling missing |
|
||||
| `TestLoadPage_OutOfBounds` | Requesting page beyond total returns error | Bounds check missing |
|
||||
| `TestEvictPages_RemovesDistantPages` | Pages outside prefetch range are evicted | Eviction not implemented |
|
||||
| `TestEvictPages_KeepsVisiblePages` | Visible pages are never evicted | Eviction logic incomplete |
|
||||
| `TestEvictPages_KeepsPrefetchedPages` | Prefetched pages survive eviction | Prefetch boundary missing |
|
||||
| `TestBrowserState_Reset` | Reset clears pages, scroll, selection | Reset method missing |
|
||||
|
||||
### 13.6 Phase 1 Tests — Layout (layout_test.go)
|
||||
|
||||
**Write after browser tests pass. Must all fail.**
|
||||
|
||||
These are pure function tests — no mocks needed.
|
||||
|
||||
| Test | What it verifies | Expected failure |
|
||||
|---|---|---|
|
||||
| `TestBrowserLayout_ElementCount` | Correct number of elements emitted | `BrowserLayout` not implemented |
|
||||
| `TestBrowserLayout_HeaderRegion` | Header region matches expected position | Layout computation missing |
|
||||
| `TestBrowserLayout_ListRegion` | ListView region matches expected position | Region calculation missing |
|
||||
| `TestBrowserLayout_VisibleEntriesCount` | Only visible entries in ListView | Virtualization not implemented |
|
||||
| `TestBrowserLayout_EmptyDirectory` | Empty state shown when no entries | Empty case not handled |
|
||||
| `TestBrowserLayout_SingleEntry` | Single entry fills list correctly | Boundary case missing |
|
||||
| `TestBrowserLayout_PartialPage` | Partial page at end of list | Partial page handling missing |
|
||||
| `TestBrowserLayout_UnloadedPage` | Unloaded pages show placeholder | Placeholder logic missing |
|
||||
|
||||
### 13.7 Phase 2 Tests — Scroll (scroll_test.go)
|
||||
|
||||
**Write after Phase 1 tests pass. Must all fail.**
|
||||
|
||||
| Test | What it verifies | Expected failure |
|
||||
|---|---|---|
|
||||
| `TestScrollClamp_Minimum` | ScrollIndex never goes below 0 | Clamping not implemented |
|
||||
| `TestScrollClamp_Maximum` | ScrollIndex never exceeds max | Max calculation missing |
|
||||
| `TestScrollDelta_Computation` | Delta computed correctly from pixel offset | Delta math missing |
|
||||
| `TestScroll_PrefetchTriggered` | Scroll triggers prefetch for new pages | Prefetch dispatch missing |
|
||||
| `TestScroll_EvictionTriggered` | Scroll triggers eviction of distant pages | Eviction on scroll missing |
|
||||
|
||||
### 13.8 Phase 2 Tests — Search (search_test.go)
|
||||
|
||||
**Write after scroll tests pass. Must all fail.**
|
||||
|
||||
| Test | What it verifies | Expected failure |
|
||||
|---|---|---|
|
||||
| `TestSearch_EmptyQuery` | Empty query returns all entries | Search not implemented |
|
||||
| `TestSearch_CaseInsensitive` | "foo" matches "Foo.txt" | Case folding missing |
|
||||
| `TestSearch_PartialMatch` | "abc" matches "abc.txt" and "xabc.txt" | Substring matching missing |
|
||||
| `TestSearch_NoMatch` | No matches returns empty results | Empty result handling missing |
|
||||
| `TestSearch_JumpToFirst` | Scroll jumps to first match | Jump logic missing |
|
||||
| `TestSearch_LargeDirectory` | Search completes within time budget | Performance not verified |
|
||||
|
||||
### 13.9 Test Execution Order
|
||||
|
||||
Tests must be written and run in this order:
|
||||
|
||||
```
|
||||
1. types_test.go → ALL FAIL → implement types → ALL PASS
|
||||
2. index_test.go → ALL FAIL → implement index → ALL PASS
|
||||
3. browser_test.go → ALL FAIL → implement browser → ALL PASS
|
||||
4. layout_test.go → ALL FAIL → implement layout → ALL PASS
|
||||
5. scroll_test.go → ALL FAIL → implement scroll → ALL PASS
|
||||
6. search_test.go → ALL FAIL → implement search → ALL PASS
|
||||
```
|
||||
|
||||
**Verification command at each step:**
|
||||
```bash
|
||||
# Before implementation:
|
||||
go test ./internal/browser/... -run TestName -v
|
||||
# Expected: "FAIL" or "panic: not implemented"
|
||||
|
||||
# After implementation:
|
||||
go test ./internal/browser/... -run TestName -v
|
||||
# Expected: "PASS"
|
||||
```
|
||||
|
||||
### 13.10 Performance Tests (Post-Implementation)
|
||||
|
||||
These run after all functional tests pass. They verify targets but do not block development.
|
||||
|
||||
| Test | Target | Method |
|
||||
|---|---|---|
|
||||
| `BenchmarkBuildIndex_10k` | < 500 ms | `testing.B` with synthetic dir |
|
||||
| `BenchmarkBuildIndex_100k` | < 5 s | Same, scaled up |
|
||||
| `BenchmarkPageLoad` | < 50 ms | Time a single page load from cache |
|
||||
| `BenchmarkBrowserLayout` | < 10 ms | Time layout computation |
|
||||
| `BenchmarkSearch_100k` | < 100 ms | Search through 100k entries |
|
||||
| `BenchmarkEviction` | < 5 ms | Time eviction of 100 pages |
|
||||
|
||||
---
|
||||
|
||||
## 14. Deferred Features
|
||||
|
||||
### 14.1 Alphabetical Index Sidebar (DEFERRED)
|
||||
|
||||
Not included in this implementation round. The core browser architecture supports adding it later without changes:
|
||||
|
||||
- **Letter offsets** are computed during index build but not exposed
|
||||
- **ListView element** does not need modification
|
||||
- **New element** `AlphaIndex` would be added alongside ListView
|
||||
- **Handler** would read `LetterOffsets` and set `ScrollIndex` directly
|
||||
|
||||
### 14.2 Future Considerations
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Alphabetical index sidebar | Deferred | See §14.1 |
|
||||
| Subdirectory navigation | Phase 3 | Tap directory to navigate in; breadcrumb for back |
|
||||
| File type icons | Future | Small icon prefix in ListView items |
|
||||
| Sort order toggle | Future | Name, date, size; persisted preference |
|
||||
| Folder expansion | Future | Expand/collapse subdirectories inline |
|
||||
| Thumbnail preview | Out of scope | Text editor only; images not in scope |
|
||||
176
internal/browser/browser.go
Normal file
176
internal/browser/browser.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadInitialPages loads the pages required for the current viewport from the
|
||||
// SortIndex into the Pages map. This should be called after the SortIndex is built
|
||||
// or updated.
|
||||
func LoadInitialPages(s *BrowserState) {
|
||||
if s.SortIndex == nil || s.TotalEntries == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the range of pages to load (visible + prefetch)
|
||||
minPage, maxPage := computeVisiblePageRange(s)
|
||||
|
||||
for i := minPage; i <= maxPage; i++ {
|
||||
if _, ok := s.Pages[i]; !ok {
|
||||
page := loadPageFromIndex(s, i)
|
||||
if page != nil {
|
||||
s.Pages[i] = page
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadPageFromIndex creates a Page from the sorted index at the given page index.
|
||||
// Uses position maps to convert sorted indices to raw indices for entry lookup.
|
||||
// Returns nil if the page index is out of bounds.
|
||||
func loadPageFromIndex(s *BrowserState, pageIndex int) *Page {
|
||||
if pageIndex < 0 || pageIndex*PageSize >= s.TotalEntries {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.SortIndex == nil || len(s.SortIndex.Entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
start := pageIndex * PageSize
|
||||
end := start + PageSize
|
||||
if end > s.TotalEntries {
|
||||
end = s.TotalEntries
|
||||
}
|
||||
|
||||
// Get the position map for the current sort mode
|
||||
positionMap := s.getSortedIndices()
|
||||
if positionMap == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build page entries using sorted order
|
||||
entries := make([]Entry, 0, end-start)
|
||||
for i := start; i < end; i++ {
|
||||
rawIdx := positionMap[i]
|
||||
entries = append(entries, s.SortIndex.Entries[rawIdx])
|
||||
}
|
||||
|
||||
return NewPage(pageIndex, entries)
|
||||
}
|
||||
|
||||
// computeVisiblePageRange returns the min and max page indices that should
|
||||
// be loaded based on the current scroll position and visible count.
|
||||
func computeVisiblePageRange(s *BrowserState) (minPage, maxPage int) {
|
||||
minPage = s.ScrollIndex/PageSize - PrefetchDist
|
||||
if minPage < 0 {
|
||||
minPage = 0
|
||||
}
|
||||
maxPage = (s.ScrollIndex + s.VisibleCount)/PageSize + PrefetchDist
|
||||
return minPage, maxPage
|
||||
}
|
||||
|
||||
// getEntryByIndex returns the entry at the given sorted index, or nil if the page
|
||||
// containing that entry is not loaded. Uses position maps to convert sorted
|
||||
// indices to raw indices for correct sort order.
|
||||
func getEntryByIndex(s *BrowserState, sortedIndex int) (*Entry, bool) {
|
||||
if sortedIndex < 0 || sortedIndex >= s.TotalEntries {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
pageIndex := sortedIndex / PageSize
|
||||
page, ok := s.Pages[pageIndex]
|
||||
if !ok || !page.Loaded {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
offset := sortedIndex % PageSize
|
||||
if offset >= len(page.Entries) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &page.Entries[offset], true
|
||||
}
|
||||
|
||||
// getSortedIndices returns the position map for the current sort mode.
|
||||
// Returns nil if no sort index is available.
|
||||
func (s *BrowserState) getSortedIndices() []int {
|
||||
if s.SortIndex == nil || s.SortIndex.SortOrders == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := sortModeKey(s.SortMode)
|
||||
return s.SortIndex.SortOrders[key]
|
||||
}
|
||||
|
||||
// needsPrefetch determines if a page should be prefetched based on the
|
||||
// current scroll position and visible count.
|
||||
// Returns (true, pagesNeeded) if prefetch is needed.
|
||||
func needsPrefetch(s *BrowserState, pageIndex int) (bool, int) {
|
||||
minPage, maxPage := computeVisiblePageRange(s)
|
||||
|
||||
if pageIndex >= minPage && pageIndex <= maxPage {
|
||||
if page, ok := s.Pages[pageIndex]; !ok || !page.Loaded {
|
||||
return true, 1
|
||||
}
|
||||
}
|
||||
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// navigateToDirectory resets browser state and sets the new current path.
|
||||
func navigateToDirectory(s *BrowserState, path string) {
|
||||
s.CurrentPath = path
|
||||
s.ScrollIndex = 0
|
||||
s.SelectedIndex = -1
|
||||
s.Pages = make(map[int]*Page)
|
||||
s.SearchResults = nil
|
||||
s.Query = ""
|
||||
s.TotalEntries = 0
|
||||
}
|
||||
|
||||
// computeTotalPages returns the total number of pages for the current entry count.
|
||||
func computeTotalPages(s *BrowserState) int {
|
||||
if s.TotalEntries == 0 {
|
||||
return 0
|
||||
}
|
||||
pages := s.TotalEntries / PageSize
|
||||
if s.TotalEntries%PageSize != 0 {
|
||||
pages++
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// clampScrollIndex ensures the scroll index stays within valid bounds.
|
||||
func clampScrollIndex(s *BrowserState) {
|
||||
if s.ScrollIndex < 0 {
|
||||
s.ScrollIndex = 0
|
||||
}
|
||||
maxScroll := s.TotalEntries - s.VisibleCount
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if s.ScrollIndex > maxScroll {
|
||||
s.ScrollIndex = maxScroll
|
||||
}
|
||||
}
|
||||
|
||||
// filterEntriesByQuery returns indices of entries matching the query (case-insensitive).
|
||||
// Returns nil for empty query.
|
||||
func filterEntriesByQuery(entries []Entry, query string) []int {
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
queryLower := strings.ToLower(query)
|
||||
var results []int
|
||||
|
||||
for i, e := range entries {
|
||||
if strings.Contains(strings.ToLower(e.Name), queryLower) {
|
||||
results = append(results, i)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
429
internal/browser/browser_test.go
Normal file
429
internal/browser/browser_test.go
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Test: Load Page From Index At Offset ---
|
||||
|
||||
func TestLoadPage_FromIndexAtOffset(t *testing.T) {
|
||||
// Create a BrowserState with a populated index
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 250
|
||||
|
||||
// Simulate an index with 250 entries (3 pages)
|
||||
// Use zero-padded names so lexicographic order matches numeric order
|
||||
allEntries := make([]Entry, 250)
|
||||
for i := 0; i < 250; i++ {
|
||||
allEntries[i] = NewEntry(
|
||||
fmt.Sprintf("/path/file%03d.txt", i),
|
||||
fmt.Sprintf("file%03d.txt", i),
|
||||
int64(i*100),
|
||||
time.Time{},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: allEntries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(allEntries, SortModeNameAsc)},
|
||||
}
|
||||
|
||||
// Load page 1 (entries 100-199)
|
||||
page := loadPageFromIndex(s, 1)
|
||||
|
||||
if page == nil {
|
||||
t.Fatal("expected page to be loaded, got nil")
|
||||
}
|
||||
if page.Index != 1 {
|
||||
t.Errorf("expected Index=1, got %d", page.Index)
|
||||
}
|
||||
if len(page.Entries) != PageSize {
|
||||
t.Errorf("expected %d entries, got %d", PageSize, len(page.Entries))
|
||||
}
|
||||
// First entry of page 1 should be file100.txt
|
||||
if page.Entries[0].Name != "file100.txt" {
|
||||
t.Errorf("expected first entry name=file100.txt, got %s", page.Entries[0].Name)
|
||||
}
|
||||
// Last entry of page 1 should be file199.txt
|
||||
if page.Entries[99].Name != "file199.txt" {
|
||||
t.Errorf("expected last entry name=file199.txt, got %s", page.Entries[99].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Load Page - Last Page Partial ---
|
||||
|
||||
func TestLoadPage_LastPagePartialFromIndex(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 150 // 1 full page + 50 entries in page 1
|
||||
|
||||
allEntries := make([]Entry, 150)
|
||||
for i := 0; i < 150; i++ {
|
||||
allEntries[i] = NewEntry(
|
||||
fmt.Sprintf("/path/file%d.txt", i),
|
||||
fmt.Sprintf("file%d.txt", i),
|
||||
int64(i*100),
|
||||
time.Time{},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: allEntries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(allEntries, SortModeNameAsc)},
|
||||
}
|
||||
|
||||
// Load page 1 (last page, should have only 50 entries)
|
||||
page := loadPageFromIndex(s, 1)
|
||||
|
||||
if page == nil {
|
||||
t.Fatal("expected page to be loaded, got nil")
|
||||
}
|
||||
if page.Index != 1 {
|
||||
t.Errorf("expected Index=1, got %d", page.Index)
|
||||
}
|
||||
if len(page.Entries) != 50 {
|
||||
t.Errorf("expected 50 entries, got %d", len(page.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Load Page - Out Of Bounds ---
|
||||
|
||||
func TestLoadPage_OutOfBoundsFromIndex(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 150
|
||||
|
||||
allEntries := make([]Entry, 150)
|
||||
for i := 0; i < 150; i++ {
|
||||
allEntries[i] = NewEntry(
|
||||
fmt.Sprintf("/path/file%d.txt", i),
|
||||
fmt.Sprintf("file%d.txt", i),
|
||||
int64(i*100),
|
||||
time.Time{},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: allEntries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(allEntries, SortModeNameAsc)},
|
||||
}
|
||||
|
||||
// Requesting page 5 (out of bounds) should return nil
|
||||
page := loadPageFromIndex(s, 5)
|
||||
|
||||
if page != nil {
|
||||
t.Errorf("expected nil for out-of-bounds page, got page with %d entries", len(page.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Compute Visible Page Range ---
|
||||
|
||||
func TestComputeVisiblePageRange(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.ScrollIndex = 250
|
||||
s.VisibleCount = 20
|
||||
|
||||
minPage, maxPage := computeVisiblePageRange(s)
|
||||
|
||||
// ScrollIndex=250 → page 2
|
||||
// VisibleCount=20 → entries 250-269 → pages 2-2 (partial)
|
||||
// With prefetch: min=0, max=4
|
||||
expectedMin := 0 // (250/100) - 2 = 0
|
||||
expectedMax := 4 // (270/100) + 2 = 4
|
||||
|
||||
if minPage != expectedMin {
|
||||
t.Errorf("expected minPage=%d, got %d", expectedMin, minPage)
|
||||
}
|
||||
if maxPage != expectedMax {
|
||||
t.Errorf("expected maxPage=%d, got %d", expectedMax, maxPage)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Compute Visible Page Range - At Start ---
|
||||
|
||||
func TestComputeVisiblePageRange_AtStart(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.ScrollIndex = 0
|
||||
s.VisibleCount = 10
|
||||
|
||||
minPage, maxPage := computeVisiblePageRange(s)
|
||||
|
||||
// Should clamp min to 0
|
||||
if minPage != 0 {
|
||||
t.Errorf("expected minPage=0, got %d", minPage)
|
||||
}
|
||||
// maxPage should be 0 + 2 = 2
|
||||
if maxPage != 2 {
|
||||
t.Errorf("expected maxPage=2, got %d", maxPage)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Get Entry By Index ---
|
||||
|
||||
func TestGetEntryByIndex(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 250
|
||||
|
||||
// Use zero-padded names so lexicographic order matches numeric order
|
||||
allEntries := make([]Entry, 250)
|
||||
for i := 0; i < 250; i++ {
|
||||
allEntries[i] = NewEntry(
|
||||
fmt.Sprintf("/path/file%03d.txt", i),
|
||||
fmt.Sprintf("file%03d.txt", i),
|
||||
int64(i*100),
|
||||
time.Time{},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: allEntries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(allEntries, SortModeNameAsc)},
|
||||
}
|
||||
|
||||
// Load some pages
|
||||
s.Pages[0] = loadPageFromIndex(s, 0)
|
||||
s.Pages[1] = loadPageFromIndex(s, 1)
|
||||
|
||||
// Get entry at index 150 (page 1, offset 50)
|
||||
entry, ok := getEntryByIndex(s, 150)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected entry to be found")
|
||||
}
|
||||
if entry.Name != "file150.txt" {
|
||||
t.Errorf("expected entry name=file150.txt, got %s", entry.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Get Entry By Index - Unloaded Page ---
|
||||
|
||||
func TestGetEntryByIndex_UnloadedPage(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 250
|
||||
|
||||
allEntries := make([]Entry, 250)
|
||||
for i := 0; i < 250; i++ {
|
||||
allEntries[i] = NewEntry(
|
||||
fmt.Sprintf("/path/file%d.txt", i),
|
||||
fmt.Sprintf("file%d.txt", i),
|
||||
int64(i*100),
|
||||
time.Time{},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: allEntries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(allEntries, SortModeNameAsc)},
|
||||
}
|
||||
|
||||
// Only load page 0
|
||||
s.Pages[0] = loadPageFromIndex(s, 0)
|
||||
|
||||
// Try to get entry at index 150 (page 1, not loaded)
|
||||
_, ok := getEntryByIndex(s, 150)
|
||||
|
||||
if ok {
|
||||
t.Error("expected entry not to be found for unloaded page")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Get Entry By Index - Out Of Bounds ---
|
||||
|
||||
func TestGetEntryByIndex_OutOfBounds(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 100
|
||||
|
||||
_, ok := getEntryByIndex(s, 200)
|
||||
|
||||
if ok {
|
||||
t.Error("expected entry not to be found for out-of-bounds index")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Needs Prefetch ---
|
||||
|
||||
func TestNeedsPrefetch(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.ScrollIndex = 500
|
||||
s.VisibleCount = 20
|
||||
s.TotalEntries = 2000
|
||||
|
||||
// Page 3 is within visible range + prefetch
|
||||
needs, _ := needsPrefetch(s, 3)
|
||||
if !needs {
|
||||
t.Error("expected page 3 to need prefetch")
|
||||
}
|
||||
|
||||
// Page 0 is far away, should not need prefetch
|
||||
needs, _ = needsPrefetch(s, 0)
|
||||
if needs {
|
||||
t.Error("expected page 0 to not need prefetch")
|
||||
}
|
||||
|
||||
// Page 25 is far away, should not need prefetch
|
||||
needs, _ = needsPrefetch(s, 25)
|
||||
if needs {
|
||||
t.Error("expected page 25 to not need prefetch")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Navigate To Directory ---
|
||||
|
||||
func TestNavigateToDirectory(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.CurrentPath = "/old/path"
|
||||
s.ScrollIndex = 100
|
||||
s.SelectedIndex = 5
|
||||
s.Pages[0] = NewPage(0, nil)
|
||||
|
||||
navigateToDirectory(s, "/new/path")
|
||||
|
||||
if s.CurrentPath != "/new/path" {
|
||||
t.Errorf("expected CurrentPath=/new/path, got %s", s.CurrentPath)
|
||||
}
|
||||
if s.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0, got %d", s.ScrollIndex)
|
||||
}
|
||||
if len(s.Pages) != 0 {
|
||||
t.Errorf("expected Pages to be empty, got %d", len(s.Pages))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Compute Total Pages ---
|
||||
|
||||
func TestComputeTotalPages(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
|
||||
s.TotalEntries = 0
|
||||
if pages := computeTotalPages(s); pages != 0 {
|
||||
t.Errorf("expected 0 pages for 0 entries, got %d", pages)
|
||||
}
|
||||
|
||||
s.TotalEntries = 100
|
||||
if pages := computeTotalPages(s); pages != 1 {
|
||||
t.Errorf("expected 1 page for 100 entries, got %d", pages)
|
||||
}
|
||||
|
||||
s.TotalEntries = 101
|
||||
if pages := computeTotalPages(s); pages != 2 {
|
||||
t.Errorf("expected 2 pages for 101 entries, got %d", pages)
|
||||
}
|
||||
|
||||
s.TotalEntries = 250
|
||||
if pages := computeTotalPages(s); pages != 3 {
|
||||
t.Errorf("expected 3 pages for 250 entries, got %d", pages)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Clamp Scroll Index ---
|
||||
|
||||
func TestClampScrollIndex(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 500
|
||||
s.VisibleCount = 20
|
||||
|
||||
// Normal case
|
||||
s.ScrollIndex = 100
|
||||
clampScrollIndex(s)
|
||||
if s.ScrollIndex != 100 {
|
||||
t.Errorf("expected ScrollIndex=100, got %d", s.ScrollIndex)
|
||||
}
|
||||
|
||||
// Below minimum
|
||||
s.ScrollIndex = -10
|
||||
clampScrollIndex(s)
|
||||
if s.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0, got %d", s.ScrollIndex)
|
||||
}
|
||||
|
||||
// Above maximum
|
||||
s.ScrollIndex = 1000
|
||||
clampScrollIndex(s)
|
||||
if s.ScrollIndex != 480 {
|
||||
t.Errorf("expected ScrollIndex=480, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Filter Entries By Query ---
|
||||
|
||||
func TestFilterEntriesByQuery(t *testing.T) {
|
||||
entries := []Entry{
|
||||
NewEntry("/path/file0.txt", "file0.txt", 0, time.Time{}, false),
|
||||
NewEntry("/path/file1.txt", "file1.txt", 100, time.Time{}, false),
|
||||
NewEntry("/path/file2.txt", "file2.txt", 200, time.Time{}, false),
|
||||
NewEntry("/path/file10.txt", "file10.txt", 1000, time.Time{}, false),
|
||||
NewEntry("/path/file100.txt", "file100.txt", 10000, time.Time{}, false),
|
||||
}
|
||||
|
||||
// Search for "file1" - should match file1.txt, file10.txt, file100.txt
|
||||
results := filterEntriesByQuery(entries, "file1")
|
||||
|
||||
if len(results) != 3 {
|
||||
t.Errorf("expected 3 results for 'file1', got %d", len(results))
|
||||
}
|
||||
|
||||
expectedNames := map[string]bool{"file1.txt": true, "file10.txt": true, "file100.txt": true}
|
||||
for _, idx := range results {
|
||||
if !expectedNames[entries[idx].Name] {
|
||||
t.Errorf("unexpected result: %s", entries[idx].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Filter Entries By Query - Empty Query ---
|
||||
|
||||
func TestFilterEntriesByQuery_EmptyQuery(t *testing.T) {
|
||||
entries := []Entry{
|
||||
NewEntry("/a", "a.txt", 100, time.Time{}, false),
|
||||
NewEntry("/b", "b.txt", 200, time.Time{}, false),
|
||||
}
|
||||
|
||||
results := filterEntriesByQuery(entries, "")
|
||||
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 results for empty query, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Filter Entries By Query - No Match ---
|
||||
|
||||
func TestFilterEntriesByQuery_NoMatch(t *testing.T) {
|
||||
entries := []Entry{
|
||||
NewEntry("/a", "a.txt", 100, time.Time{}, false),
|
||||
NewEntry("/b", "b.txt", 200, time.Time{}, false),
|
||||
}
|
||||
|
||||
results := filterEntriesByQuery(entries, "zzz")
|
||||
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 results for 'zzz', got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Filter Entries By Query - Case Insensitive ---
|
||||
|
||||
func TestFilterEntriesByQuery_CaseInsensitive(t *testing.T) {
|
||||
entries := []Entry{
|
||||
NewEntry("/a", "Apple.txt", 100, time.Time{}, false),
|
||||
NewEntry("/b", "banana.txt", 200, time.Time{}, false),
|
||||
}
|
||||
|
||||
results := filterEntriesByQuery(entries, "apple")
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Errorf("expected 1 result for 'apple', got %d", len(results))
|
||||
}
|
||||
if results[0] != 0 {
|
||||
t.Errorf("expected result at index 0, got %d", results[0])
|
||||
}
|
||||
}
|
||||
13
internal/browser/handlers.go
Normal file
13
internal/browser/handlers.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package browser
|
||||
|
||||
// HandleScroll updates the browser scroll index by the given delta (in entries).
|
||||
// Clamps to valid bounds and triggers prefetch/eviction as needed.
|
||||
func HandleScroll(s *BrowserState, delta int) {
|
||||
s.ScrollIndex += delta
|
||||
|
||||
// Clamp to valid bounds
|
||||
clampScrollIndex(s)
|
||||
|
||||
// Trigger eviction of distant pages
|
||||
s.EvictPages()
|
||||
}
|
||||
187
internal/browser/index.go
Normal file
187
internal/browser/index.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DirectoryIndex holds a cached index of a directory's contents.
|
||||
// Per design: index stores raw metadata; sorting is pre-computed via position maps.
|
||||
type DirectoryIndex struct {
|
||||
Path string `json:"path"`
|
||||
Mtime time.Time `json:"mtime"`
|
||||
EntryCount int `json:"entry_count"`
|
||||
Entries []Entry `json:"entries"`
|
||||
SortOrders map[string][]int `json:"sort_orders,omitempty"` // key: "name_asc", "name_desc", etc.
|
||||
}
|
||||
|
||||
// getCachePath returns the path to the cached index file for a directory.
|
||||
func getCachePath(dirPath string) string {
|
||||
// Use .pad/indices/ relative to the directory being indexed
|
||||
indicesDir := filepath.Join(dirPath, ".pad", "indices")
|
||||
// Simple hash: use the directory path itself (URL-encoded style)
|
||||
encoded := strings.NewReplacer("/", "_", "\\", "_").Replace(dirPath)
|
||||
return filepath.Join(indicesDir, fmt.Sprintf("browser_%s.json", encoded))
|
||||
}
|
||||
|
||||
// buildIndex reads the directory and produces an index with pre-computed position maps.
|
||||
// Sorting happens at index build time, not during frame rendering.
|
||||
func buildIndex(dirPath string) (*DirectoryIndex, error) {
|
||||
// Try to load from cache first
|
||||
if cached, ok := loadCachedIndex(dirPath); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// 1. Read directory entries
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browser: read dir %s: %w", dirPath, err)
|
||||
}
|
||||
|
||||
// 2. Convert to Entry structs (unsorted)
|
||||
var browserEntries []Entry
|
||||
for _, e := range entries {
|
||||
// Skip hidden directories like .pad
|
||||
if e.Name() == ".pad" || e.Name() == ".git" {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
browserEntries = append(browserEntries, Entry{
|
||||
Path: filepath.Join(dirPath, e.Name()),
|
||||
Name: e.Name(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
IsDir: info.IsDir(),
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Build position maps for all sort modes
|
||||
sortOrders := make(map[string][]int)
|
||||
for mode := SortMode(0); mode < SortMode(modeCount()); mode++ {
|
||||
key := sortModeKey(mode)
|
||||
if key != "" {
|
||||
sortOrders[key] = buildPositionMap(browserEntries, mode)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Build index (unsorted entries with pre-computed position maps)
|
||||
dirInfo, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browser: stat dir %s: %w", dirPath, err)
|
||||
}
|
||||
|
||||
idx := &DirectoryIndex{
|
||||
Path: dirPath,
|
||||
Mtime: dirInfo.ModTime(),
|
||||
EntryCount: len(browserEntries),
|
||||
Entries: browserEntries,
|
||||
SortOrders: sortOrders,
|
||||
}
|
||||
|
||||
// 5. Write to cache
|
||||
cacheIndex(idx)
|
||||
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// buildPositionMap creates a sorted index → raw index mapping.
|
||||
// The returned slice maps sorted positions to raw entry indices.
|
||||
// Uses sort.SliceStable for O(n log n) performance on large directories.
|
||||
func buildPositionMap(entries []Entry, mode SortMode) []int {
|
||||
n := len(entries)
|
||||
indices := make([]int, n)
|
||||
for i := range indices {
|
||||
indices[i] = i
|
||||
}
|
||||
|
||||
// Sort indices based on entry comparison
|
||||
cmp := comparator(mode)
|
||||
sort.SliceStable(indices, func(i, j int) bool {
|
||||
a, b := entries[indices[i]], entries[indices[j]]
|
||||
return cmp(a, b) < 0
|
||||
})
|
||||
|
||||
return indices
|
||||
}
|
||||
|
||||
// sortModeKey converts a SortMode to its JSON key string.
|
||||
func sortModeKey(mode SortMode) string {
|
||||
switch mode {
|
||||
case SortModeNameAsc:
|
||||
return "name_asc"
|
||||
case SortModeNameDesc:
|
||||
return "name_desc"
|
||||
case SortModeDateAsc:
|
||||
return "date_asc"
|
||||
case SortModeDateDesc:
|
||||
return "date_desc"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// modeCount returns the total number of sort modes (4).
|
||||
func modeCount() int {
|
||||
return int(SortModeDateDesc) + 1
|
||||
}
|
||||
|
||||
// loadCachedIndex attempts to load a cached index from disk.
|
||||
// Returns the index and true if valid, or nil and false if not.
|
||||
func loadCachedIndex(dirPath string) (*DirectoryIndex, bool) {
|
||||
cachePath := getCachePath(dirPath)
|
||||
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var idx DirectoryIndex
|
||||
if err := json.Unmarshal(data, &idx); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if directory mtime has changed since cache was written
|
||||
dirInfo, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// If mtime matches, cache is valid
|
||||
if idx.Mtime.Equal(dirInfo.ModTime()) {
|
||||
return &idx, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// cacheIndex writes the directory index to the cache file.
|
||||
func cacheIndex(idx *DirectoryIndex) {
|
||||
cachePath := getCachePath(idx.Path)
|
||||
|
||||
// Ensure directory exists
|
||||
indicesDir := filepath.Dir(cachePath)
|
||||
if err := os.MkdirAll(indicesDir, 0755); err != nil {
|
||||
// Non-fatal; continue without caching
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(idx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write to temp file first, then rename for atomicity
|
||||
tmpPath := cachePath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
os.Rename(tmpPath, cachePath)
|
||||
}
|
||||
228
internal/browser/index_test.go
Normal file
228
internal/browser/index_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Fixture setup ---
|
||||
|
||||
type testEntry struct {
|
||||
name string
|
||||
isDir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func setupTestDir(t *testing.T, entries []testEntry) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
for _, e := range entries {
|
||||
path := filepath.Join(dir, e.name)
|
||||
if e.isDir {
|
||||
err := os.Mkdir(path, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create dir %s: %v", e.name, err)
|
||||
}
|
||||
} else {
|
||||
err := os.WriteFile(path, make([]byte, e.size), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file %s: %v", e.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// --- Test: Build Index - Empty Dir ---
|
||||
|
||||
func TestBuildIndex_EmptyDir(t *testing.T) {
|
||||
dir := setupTestDir(t, nil)
|
||||
|
||||
idx, err := buildIndex(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("buildIndex(empty) returned error: %v", err)
|
||||
}
|
||||
if idx.EntryCount != 0 {
|
||||
t.Errorf("expected EntryCount=0, got %d", idx.EntryCount)
|
||||
}
|
||||
if len(idx.Entries) != 0 {
|
||||
t.Errorf("expected 0 entries, got %d", len(idx.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Build Index - Unsorted (raw metadata) ---
|
||||
// Per design: index stores raw metadata; sorting happens at page-load time.
|
||||
|
||||
func TestBuildIndex_UnsortedRawMetadata(t *testing.T) {
|
||||
dir := setupTestDir(t, []testEntry{
|
||||
{name: "Zebra.txt", size: 100},
|
||||
{name: "apple.txt", size: 200},
|
||||
{name: "Banana.txt", size: 300},
|
||||
{name: "cherry.txt", size: 400},
|
||||
})
|
||||
|
||||
idx, err := buildIndex(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("buildIndex returned error: %v", err)
|
||||
}
|
||||
|
||||
// Index should contain all entries (order not guaranteed - raw metadata)
|
||||
if len(idx.Entries) != 4 {
|
||||
t.Fatalf("expected 4 entries, got %d", len(idx.Entries))
|
||||
}
|
||||
|
||||
// Verify all entries are present (regardless of order)
|
||||
names := make(map[string]bool)
|
||||
for _, e := range idx.Entries {
|
||||
names[e.Name] = true
|
||||
}
|
||||
for _, want := range []string{"Zebra.txt", "apple.txt", "Banana.txt", "cherry.txt"} {
|
||||
if !names[want] {
|
||||
t.Errorf("expected entry %q not found in index", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Build Index - Dirs and Files Both Present ---
|
||||
// Per design: index stores raw metadata; dir/file ordering happens at page-load time.
|
||||
|
||||
func TestBuildIndex_DirsAndFilesPresent(t *testing.T) {
|
||||
dir := setupTestDir(t, []testEntry{
|
||||
{name: "file.txt", size: 100},
|
||||
{name: "adir", isDir: true},
|
||||
{name: "another.txt", size: 200},
|
||||
{name: "bdir", isDir: true},
|
||||
})
|
||||
|
||||
idx, err := buildIndex(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("buildIndex returned error: %v", err)
|
||||
}
|
||||
|
||||
// Both dirs and files should be present
|
||||
var gotDirs, gotFiles int
|
||||
for _, e := range idx.Entries {
|
||||
if e.IsDir {
|
||||
gotDirs++
|
||||
} else {
|
||||
gotFiles++
|
||||
}
|
||||
}
|
||||
if gotDirs != 2 {
|
||||
t.Errorf("expected 2 directories, got %d", gotDirs)
|
||||
}
|
||||
if gotFiles != 2 {
|
||||
t.Errorf("expected 2 files, got %d", gotFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Build Index - Cache Written ---
|
||||
|
||||
func TestBuildIndex_CacheWritten(t *testing.T) {
|
||||
dir := setupTestDir(t, []testEntry{
|
||||
{name: "test.txt", size: 100},
|
||||
})
|
||||
|
||||
_, err := buildIndex(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("buildIndex returned error: %v", err)
|
||||
}
|
||||
|
||||
// Verify cache file exists
|
||||
cachePath := getCachePath(dir)
|
||||
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||
t.Errorf("expected cache file at %s, but it does not exist", cachePath)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Build Index - Cache Invalidated On Mtime Change ---
|
||||
|
||||
func TestBuildIndex_CacheInvalidatedOnMtimeChange(t *testing.T) {
|
||||
dir := setupTestDir(t, []testEntry{
|
||||
{name: "test.txt", size: 100},
|
||||
})
|
||||
|
||||
// First build
|
||||
idx1, err := buildIndex(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("first buildIndex returned error: %v", err)
|
||||
}
|
||||
|
||||
// Modify directory (add a file)
|
||||
newFile := filepath.Join(dir, "newfile.txt")
|
||||
if err := os.WriteFile(newFile, []byte("new"), 0644); err != nil {
|
||||
t.Fatalf("failed to create new file: %v", err)
|
||||
}
|
||||
|
||||
// Second build should detect change and rebuild
|
||||
idx2, err := buildIndex(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("second buildIndex returned error: %v", err)
|
||||
}
|
||||
|
||||
if idx2.EntryCount <= idx1.EntryCount {
|
||||
t.Errorf("expected entry count to increase after adding file; before=%d, after=%d",
|
||||
idx1.EntryCount, idx2.EntryCount)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Build Index - Cache Reused When Unchanged ---
|
||||
|
||||
func TestBuildIndex_CacheReusedWhenUnchanged(t *testing.T) {
|
||||
dir := setupTestDir(t, []testEntry{
|
||||
{name: "test.txt", size: 100},
|
||||
})
|
||||
|
||||
// First build
|
||||
idx1, err := buildIndex(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("first buildIndex returned error: %v", err)
|
||||
}
|
||||
|
||||
// Second build (no changes)
|
||||
idx2, err := buildIndex(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("second buildIndex returned error: %v", err)
|
||||
}
|
||||
|
||||
if idx1.EntryCount != idx2.EntryCount {
|
||||
t.Errorf("expected same entry count; first=%d, second=%d",
|
||||
idx1.EntryCount, idx2.EntryCount)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Build Index - Large Directory ---
|
||||
|
||||
func TestBuildIndex_LargeDirectory(t *testing.T) {
|
||||
const count = 10000
|
||||
entries := make([]testEntry, count)
|
||||
for i := 0; i < count; i++ {
|
||||
entries[i] = testEntry{
|
||||
name: fmt.Sprintf("file_%05d.txt", i),
|
||||
size: int64(i * 10),
|
||||
}
|
||||
}
|
||||
dir := setupTestDir(t, entries)
|
||||
|
||||
start := time.Now()
|
||||
idx, err := buildIndex(dir)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("buildIndex returned error: %v", err)
|
||||
}
|
||||
if idx.EntryCount != count {
|
||||
t.Errorf("expected %d entries, got %d", count, idx.EntryCount)
|
||||
}
|
||||
|
||||
// Time budget: 5 seconds for 10k entries
|
||||
if elapsed > 5*time.Second {
|
||||
t.Errorf("buildIndex took %v, expected < 5s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
145
internal/browser/layout.go
Normal file
145
internal/browser/layout.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// BrowserLayout computes the element tree for the browser (file listing) page.
|
||||
// Pure function: (screen dimensions, browser state) → []ui.Element
|
||||
func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState) []ui.Element {
|
||||
margin := ui.Dp(10)
|
||||
contentWidth := screenW - margin*2
|
||||
|
||||
// --- Header bar: directory path + sort toggle ---
|
||||
headerHeight := ui.Dp(24)
|
||||
headerRegion := ui.Region{
|
||||
X: margin, Y: margin,
|
||||
W: contentWidth, H: headerHeight,
|
||||
}
|
||||
sortLabel := sortModeLabel(state.SortMode)
|
||||
headerBar := ui.NewContainer(
|
||||
headerRegion,
|
||||
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
||||
[]ui.Element{
|
||||
ui.NewLabel(state.CurrentPath, 14, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignStart, "", nil),
|
||||
ui.NewLabel(sortLabel, 12, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignEnd, "sort",
|
||||
[]ui.Interaction{{Gesture: ui.Tap, Handler: ToggleSortOrder}}),
|
||||
},
|
||||
)
|
||||
|
||||
// --- Search bar (Gio editor) ---
|
||||
searchHeight := ui.Dp(36)
|
||||
searchY := headerRegion.Y + headerRegion.H + margin/2
|
||||
searchRegion := ui.Region{
|
||||
X: margin, Y: searchY,
|
||||
W: contentWidth, H: searchHeight,
|
||||
}
|
||||
state.SearchEditor.SingleLine = true
|
||||
searchBar := ui.NewGioEditor("search_bar", searchRegion, &state.SearchEditor)
|
||||
|
||||
var searchPlaceholder ui.Element
|
||||
if state.SearchEditor.Len() == 0 {
|
||||
searchPlaceholder = ui.NewLabel("Search…", 14,
|
||||
ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(8), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(16)},
|
||||
ui.AlignStart, "", nil)
|
||||
}
|
||||
|
||||
// --- ListView ---
|
||||
listY := searchY + searchHeight + margin/2
|
||||
listHeight := screenH - listY - margin
|
||||
listRegion := ui.Region{
|
||||
X: margin, Y: listY,
|
||||
W: contentWidth, H: listHeight,
|
||||
}
|
||||
|
||||
// Compute visible entries
|
||||
visibleEntries := computeVisibleEntries(state)
|
||||
|
||||
// Create a scroll handler that captures the current browser state
|
||||
scrollHandler := func(data any) {
|
||||
delta := data.(int) // entries
|
||||
HandleScroll(state, delta)
|
||||
}
|
||||
|
||||
listView := ui.NewListView(
|
||||
"browser_list",
|
||||
visibleEntries,
|
||||
listRegion,
|
||||
ui.Dp(state.ScrollIndex)*ui.Dp(48), // scroll offset in pixels
|
||||
state.SelectedIndex,
|
||||
[]ui.Interaction{{Gesture: ui.Scroll, Handler: scrollHandler}},
|
||||
)
|
||||
|
||||
elems := []ui.Element{headerBar, searchBar}
|
||||
if searchPlaceholder != nil {
|
||||
elems = append(elems, searchPlaceholder)
|
||||
}
|
||||
elems = append(elems, listView)
|
||||
return elems
|
||||
}
|
||||
|
||||
// sortModeLabel returns the display label for the given sort mode.
|
||||
func sortModeLabel(mode SortMode) string {
|
||||
switch mode {
|
||||
case SortModeNameAsc:
|
||||
return "Name ↑"
|
||||
case SortModeNameDesc:
|
||||
return "Name ↓"
|
||||
case SortModeDateAsc:
|
||||
return "Date ↑"
|
||||
case SortModeDateDesc:
|
||||
return "Date ↓"
|
||||
default:
|
||||
return "Name ↑"
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleSortOrder cycles the browser sort mode through four modes.
|
||||
func ToggleSortOrder(data any) {
|
||||
if currentBrowserState == nil {
|
||||
return
|
||||
}
|
||||
currentBrowserState.SortMode = (currentBrowserState.SortMode + 1) % 4
|
||||
currentBrowserState.ScrollIndex = 0 // reset scroll on sort change
|
||||
}
|
||||
|
||||
// currentBrowserState is set by the editor before calling BrowserLayout.
|
||||
// Used by handlers that need access to the browser state.
|
||||
var currentBrowserState *BrowserState
|
||||
|
||||
// SetBrowserState sets the global browser state reference for handlers.
|
||||
func SetBrowserState(s *BrowserState) {
|
||||
currentBrowserState = s
|
||||
}
|
||||
|
||||
// computeVisibleEntries builds the list of ui.ListItem entries that should be
|
||||
// rendered based on the current scroll position and visible count.
|
||||
func computeVisibleEntries(state *BrowserState) []ui.ListItem {
|
||||
if state.TotalEntries == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
startIndex := state.ScrollIndex
|
||||
endIndex := startIndex + state.VisibleCount
|
||||
if endIndex > state.TotalEntries {
|
||||
endIndex = state.TotalEntries
|
||||
}
|
||||
|
||||
var items []ui.ListItem
|
||||
for idx := startIndex; idx < endIndex; idx++ {
|
||||
entry, ok := getEntryByIndex(state, idx)
|
||||
if !ok {
|
||||
// Page not loaded yet; add placeholder
|
||||
items = append(items, ui.ListItem{
|
||||
Text: "...",
|
||||
Subtext: "Loading...",
|
||||
})
|
||||
continue
|
||||
}
|
||||
item := entry.ToListItem()
|
||||
item.Selected = (idx == state.SelectedIndex)
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
295
internal/browser/layout_test.go
Normal file
295
internal/browser/layout_test.go
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// --- Helper: create a browser state with known entries ---
|
||||
|
||||
func makeTestState(t *testing.T, entryCount int, scrollIndex int, visibleCount int) *BrowserState {
|
||||
t.Helper()
|
||||
s := NewBrowserState()
|
||||
s.CurrentPath = "/test/path"
|
||||
s.ScrollIndex = scrollIndex
|
||||
s.VisibleCount = visibleCount
|
||||
s.TotalEntries = entryCount
|
||||
|
||||
entries := make([]Entry, entryCount)
|
||||
for i := 0; i < entryCount; i++ {
|
||||
entries[i] = NewEntry(
|
||||
fmt.Sprintf("/test/path/file%d.txt", i),
|
||||
fmt.Sprintf("file%d.txt", i),
|
||||
int64(i*100),
|
||||
time.Time{},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: entries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(entries, SortModeNameAsc)},
|
||||
}
|
||||
|
||||
// Load pages
|
||||
for pageIdx := 0; pageIdx <= entryCount/PageSize; pageIdx++ {
|
||||
p := loadPageFromIndex(s, pageIdx)
|
||||
if p != nil {
|
||||
s.Pages[pageIdx] = p
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// --- Helper: find ListView from elements ---
|
||||
|
||||
func findListView(elements []ui.Element) (ui.ListView, bool) {
|
||||
for _, el := range elements {
|
||||
if lv, ok := el.(ui.ListView); ok {
|
||||
return lv, true
|
||||
}
|
||||
}
|
||||
return ui.ListView{}, false
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - Element Count ---
|
||||
|
||||
func TestBrowserLayout_ElementCount(t *testing.T) {
|
||||
s := makeTestState(t, 200, 0, 20)
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
// Should produce: header + search bar + list view = 3 elements
|
||||
if len(elements) < 3 {
|
||||
t.Errorf("expected at least 3 elements, got %d", len(elements))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - Header Region ---
|
||||
|
||||
func TestBrowserLayout_HeaderRegion(t *testing.T) {
|
||||
s := makeTestState(t, 200, 0, 20)
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
// First element should be the header container
|
||||
if len(elements) == 0 {
|
||||
t.Fatal("no elements returned")
|
||||
}
|
||||
|
||||
header := elements[0]
|
||||
region := header.Region()
|
||||
|
||||
// Header should be near the top
|
||||
if region.Y > ui.Dp(50) {
|
||||
t.Errorf("expected header Y <= 50, got %d", int(region.Y))
|
||||
}
|
||||
|
||||
// Header should span most of the width
|
||||
if region.W < ui.Dp(700) {
|
||||
t.Errorf("expected header W >= 700, got %d", int(region.W))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - List Region ---
|
||||
|
||||
func TestBrowserLayout_ListRegion(t *testing.T) {
|
||||
s := makeTestState(t, 200, 0, 20)
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
listView, found := findListView(elements)
|
||||
if !found {
|
||||
t.Fatal("ListView element not found in layout")
|
||||
}
|
||||
|
||||
region := listView.Region()
|
||||
|
||||
// List should be below the header and search bar
|
||||
if region.Y < ui.Dp(50) {
|
||||
t.Errorf("expected list Y >= 50, got %d", int(region.Y))
|
||||
}
|
||||
|
||||
// List should have significant height
|
||||
if region.H < ui.Dp(500) {
|
||||
t.Errorf("expected list H >= 500, got %d", int(region.H))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - Visible Entries Count ---
|
||||
|
||||
func TestBrowserLayout_VisibleEntriesCount(t *testing.T) {
|
||||
s := makeTestState(t, 500, 100, 20)
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
listView, found := findListView(elements)
|
||||
if !found {
|
||||
t.Fatal("ListView element not found in layout")
|
||||
}
|
||||
|
||||
// Should only render visible entries (approximately VisibleCount)
|
||||
// Allow some tolerance for partial pages
|
||||
maxExpected := s.VisibleCount + PageSize
|
||||
if len(listView.Items) > maxExpected {
|
||||
t.Errorf("expected at most %d visible items, got %d", maxExpected, len(listView.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - Empty Directory ---
|
||||
|
||||
func TestBrowserLayout_EmptyDirectory(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.CurrentPath = "/empty/dir"
|
||||
s.TotalEntries = 0
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
if len(elements) < 3 {
|
||||
t.Errorf("expected at least 3 elements for empty dir, got %d", len(elements))
|
||||
}
|
||||
|
||||
listView, found := findListView(elements)
|
||||
if !found {
|
||||
t.Fatal("ListView element not found in layout")
|
||||
}
|
||||
|
||||
if len(listView.Items) != 0 {
|
||||
t.Errorf("expected 0 items for empty directory, got %d", len(listView.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - Single Entry ---
|
||||
|
||||
func TestBrowserLayout_SingleEntry(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.CurrentPath = "/test"
|
||||
s.TotalEntries = 1
|
||||
s.VisibleCount = 20
|
||||
|
||||
entries := []Entry{
|
||||
NewEntry("/test/only.txt", "only.txt", 1024, time.Time{}, false),
|
||||
}
|
||||
s.Pages[0] = NewPage(0, entries)
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
listView, found := findListView(elements)
|
||||
if !found {
|
||||
t.Fatal("ListView element not found in layout")
|
||||
}
|
||||
|
||||
if len(listView.Items) != 1 {
|
||||
t.Errorf("expected 1 item, got %d", len(listView.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - Partial Page ---
|
||||
|
||||
func TestBrowserLayout_PartialPage(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.CurrentPath = "/test"
|
||||
s.TotalEntries = 150 // 1 full page + 50 in second page
|
||||
s.ScrollIndex = 120 // Near the end
|
||||
s.VisibleCount = 20
|
||||
|
||||
entries := make([]Entry, 150)
|
||||
for i := 0; i < 150; i++ {
|
||||
entries[i] = NewEntry(
|
||||
fmt.Sprintf("/test/file%d.txt", i),
|
||||
fmt.Sprintf("file%d.txt", i),
|
||||
int64(i*100),
|
||||
time.Time{},
|
||||
false,
|
||||
)
|
||||
}
|
||||
s.Pages[0] = NewPage(0, entries[:100])
|
||||
s.Pages[1] = NewPage(1, entries[100:])
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
listView, found := findListView(elements)
|
||||
if !found {
|
||||
t.Fatal("ListView element not found in layout")
|
||||
}
|
||||
|
||||
// Should have entries from the partial page
|
||||
if len(listView.Items) == 0 {
|
||||
t.Error("expected items from partial page")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - Unloaded Page Shows Placeholder ---
|
||||
|
||||
func TestBrowserLayout_UnloadedPage(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.CurrentPath = "/test"
|
||||
s.TotalEntries = 500
|
||||
s.ScrollIndex = 250
|
||||
s.VisibleCount = 20
|
||||
|
||||
// Only load page 0, not page 2 or 3
|
||||
entries := make([]Entry, 100)
|
||||
s.Pages[0] = NewPage(0, entries)
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
// Layout should not crash with unloaded pages
|
||||
if len(elements) < 3 {
|
||||
t.Errorf("expected at least 3 elements, got %d", len(elements))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - With Search Query ---
|
||||
|
||||
func TestBrowserLayout_WithSearchQuery(t *testing.T) {
|
||||
s := makeTestState(t, 100, 0, 20)
|
||||
s.Query = "file1"
|
||||
|
||||
// Set up search results
|
||||
for i := 0; i < s.TotalEntries; i++ {
|
||||
if i == 1 || i == 10 || i == 11 || i == 12 || i == 13 || i == 14 || i == 15 || i == 16 || i == 17 || i == 18 || i == 19 {
|
||||
s.SearchResults = append(s.SearchResults, i)
|
||||
}
|
||||
}
|
||||
|
||||
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s)
|
||||
|
||||
// Should still produce valid layout
|
||||
if len(elements) < 3 {
|
||||
t.Errorf("expected at least 3 elements with search, got %d", len(elements))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Browser Layout - Screen Dimensions ---
|
||||
|
||||
func TestBrowserLayout_ScreenDimensions(t *testing.T) {
|
||||
s := makeTestState(t, 100, 0, 20)
|
||||
|
||||
// Test with different screen sizes
|
||||
for _, w := range []ui.Dp{400, 800, 1200} {
|
||||
for _, h := range []ui.Dp{600, 1200, 1800} {
|
||||
elements := BrowserLayout(w, h, s)
|
||||
|
||||
if len(elements) < 3 {
|
||||
t.Errorf("layout failed for %dx%d: expected >= 3 elements, got %d", int(w), int(h), len(elements))
|
||||
}
|
||||
|
||||
// All elements should fit within screen
|
||||
for _, el := range elements {
|
||||
region := el.Region()
|
||||
if region.X+region.W > w {
|
||||
t.Errorf("element exceeds screen width at %dx%d", int(w), int(h))
|
||||
}
|
||||
if region.Y+region.H > h {
|
||||
t.Errorf("element exceeds screen height at %dx%d", int(w), int(h))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
244
internal/browser/scroll_test.go
Normal file
244
internal/browser/scroll_test.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Test: Scroll Clamp Minimum ---
|
||||
|
||||
func TestScrollClamp_Minimum(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 500
|
||||
s.VisibleCount = 20
|
||||
|
||||
// Set scroll index below minimum
|
||||
s.ScrollIndex = -10
|
||||
|
||||
// Apply scroll clamp
|
||||
HandleScroll(s, -5)
|
||||
|
||||
// ScrollIndex should be clamped to 0
|
||||
if s.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0 (clamped), got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll Clamp Maximum ---
|
||||
|
||||
func TestScrollClamp_Maximum(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 500
|
||||
s.VisibleCount = 20
|
||||
|
||||
// Set scroll index above maximum
|
||||
s.ScrollIndex = 1000
|
||||
|
||||
// Apply scroll clamp
|
||||
HandleScroll(s, 100)
|
||||
|
||||
// ScrollIndex should be clamped to max (TotalEntries - VisibleCount)
|
||||
maxScroll := s.TotalEntries - s.VisibleCount
|
||||
if s.ScrollIndex != maxScroll {
|
||||
t.Errorf("expected ScrollIndex=%d (clamped to max), got %d", maxScroll, s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll Delta Computation ---
|
||||
|
||||
func TestScrollDelta_Computation(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 1000
|
||||
s.VisibleCount = 20
|
||||
s.ScrollIndex = 100
|
||||
|
||||
// Scroll down by 5 entries (positive delta)
|
||||
HandleScroll(s, 5)
|
||||
|
||||
if s.ScrollIndex != 105 {
|
||||
t.Errorf("expected ScrollIndex=105 after scrolling down 5, got %d", s.ScrollIndex)
|
||||
}
|
||||
|
||||
// Scroll up by 3 entries (negative delta)
|
||||
HandleScroll(s, -3)
|
||||
|
||||
if s.ScrollIndex != 102 {
|
||||
t.Errorf("expected ScrollIndex=102 after scrolling up 3, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll Prefetch Triggered ---
|
||||
|
||||
func TestScroll_PrefetchTriggered(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 2000
|
||||
s.VisibleCount = 20
|
||||
s.ScrollIndex = 500 // Page 5
|
||||
|
||||
// Load some pages around current position
|
||||
entries := make([]Entry, 2000)
|
||||
for i := 0; i < 2000; i++ {
|
||||
entries[i] = NewEntry("/test/file.txt", "file.txt", 100, s.TapTimestamp, false)
|
||||
}
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: entries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(entries, SortModeNameAsc)},
|
||||
}
|
||||
for pageIdx := 3; pageIdx <= 7; pageIdx++ {
|
||||
p := loadPageFromIndex(s, pageIdx)
|
||||
if p != nil {
|
||||
s.Pages[pageIdx] = p
|
||||
}
|
||||
}
|
||||
|
||||
// Record current page count
|
||||
pagesBefore := len(s.Pages)
|
||||
|
||||
// Scroll to trigger prefetch
|
||||
HandleScroll(s, 200) // Scroll to page 7+
|
||||
|
||||
// After scroll, prefetch should have been triggered
|
||||
// (exact behavior depends on implementation)
|
||||
// For now, verify that scroll happened correctly
|
||||
if s.ScrollIndex <= 500 {
|
||||
t.Errorf("expected ScrollIndex > 500 after scrolling, got %d", s.ScrollIndex)
|
||||
}
|
||||
|
||||
// Page count may have increased due to prefetch
|
||||
if len(s.Pages) < pagesBefore {
|
||||
t.Errorf("expected page count to not decrease after scroll, before=%d, after=%d", pagesBefore, len(s.Pages))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll Eviction Triggered ---
|
||||
|
||||
func TestScroll_EvictionTriggered(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 5000
|
||||
s.VisibleCount = 20
|
||||
s.ScrollIndex = 0
|
||||
|
||||
// Load pages at various positions
|
||||
entries := make([]Entry, 5000)
|
||||
for i := 0; i < 5000; i++ {
|
||||
entries[i] = NewEntry("/test/file.txt", "file.txt", 100, s.TapTimestamp, false)
|
||||
}
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: entries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(entries, SortModeNameAsc)},
|
||||
}
|
||||
for pageIdx := 0; pageIdx <= 10; pageIdx++ {
|
||||
p := loadPageFromIndex(s, pageIdx)
|
||||
if p != nil {
|
||||
s.Pages[pageIdx] = p
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll far away from initial position
|
||||
HandleScroll(s, 4000) // Scroll to page 40+
|
||||
|
||||
// Pages far from current position should be evicted
|
||||
// Check that page 0 was evicted (it's far from page 40)
|
||||
if page, exists := s.Pages[0]; exists && page.Loaded {
|
||||
t.Error("expected page 0 to be evicted after scrolling far away")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll With Search Results ---
|
||||
|
||||
func TestScroll_WithSearchResults(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 100
|
||||
s.VisibleCount = 20
|
||||
s.ScrollIndex = 0
|
||||
|
||||
// Set up search results
|
||||
s.SearchResults = []int{5, 15, 25, 35, 45}
|
||||
|
||||
// Scroll should work normally even with search results
|
||||
HandleScroll(s, 10)
|
||||
|
||||
if s.ScrollIndex != 10 {
|
||||
t.Errorf("expected ScrollIndex=10, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll To Beginning ---
|
||||
|
||||
func TestScroll_ToBeginning(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 1000
|
||||
s.VisibleCount = 20
|
||||
s.ScrollIndex = 500
|
||||
|
||||
// Scroll all the way to the beginning
|
||||
HandleScroll(s, -500)
|
||||
|
||||
if s.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0 at beginning, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll To End ---
|
||||
|
||||
func TestScroll_ToEnd(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 1000
|
||||
s.VisibleCount = 20
|
||||
s.ScrollIndex = 0
|
||||
|
||||
// Scroll all the way to the end
|
||||
HandleScroll(s, 1000)
|
||||
|
||||
maxScroll := s.TotalEntries - s.VisibleCount
|
||||
if s.ScrollIndex != maxScroll {
|
||||
t.Errorf("expected ScrollIndex=%d at end, got %d", maxScroll, s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll With Small Directory ---
|
||||
|
||||
func TestScroll_SmallDirectory(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 10 // Smaller than VisibleCount
|
||||
s.VisibleCount = 20
|
||||
s.ScrollIndex = 0
|
||||
|
||||
// Try to scroll down (should be clamped)
|
||||
HandleScroll(s, 100)
|
||||
|
||||
// ScrollIndex should be 0 (can't scroll beyond available entries)
|
||||
if s.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0 for small directory, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Scroll Prefetch Triggered On Page Boundary ---
|
||||
|
||||
func TestScroll_PrefetchTriggeredOnPageBoundary(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 500
|
||||
s.VisibleCount = 20
|
||||
s.ScrollIndex = 90 // Near page boundary (page 0 ends at 100)
|
||||
|
||||
// Load page 0
|
||||
entries := make([]Entry, 500)
|
||||
for i := 0; i < 500; i++ {
|
||||
entries[i] = NewEntry("/test/file.txt", "file.txt", 100, s.TapTimestamp, false)
|
||||
}
|
||||
// Build position map for the default sort mode (NameAsc)
|
||||
s.SortIndex = &DirectoryIndex{
|
||||
Entries: entries,
|
||||
SortOrders: map[string][]int{"name_asc": buildPositionMap(entries, SortModeNameAsc)},
|
||||
}
|
||||
s.Pages[0] = loadPageFromIndex(s, 0)
|
||||
|
||||
// Scroll across page boundary
|
||||
HandleScroll(s, 15) // Should cross into page 1
|
||||
|
||||
// Verify scroll happened
|
||||
if s.ScrollIndex != 105 {
|
||||
t.Errorf("expected ScrollIndex=105, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
44
internal/browser/search.go
Normal file
44
internal/browser/search.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package browser
|
||||
|
||||
import "strings"
|
||||
|
||||
// HandleSearch filters entries by the given query string.
|
||||
// It performs case-insensitive substring matching across all loaded pages.
|
||||
// When query is empty, search results are cleared.
|
||||
// When results are found, ScrollIndex is set to the first match.
|
||||
func HandleSearch(s *BrowserState, query string) {
|
||||
s.Query = query
|
||||
|
||||
// Empty query clears search results; leave ScrollIndex unchanged.
|
||||
if query == "" {
|
||||
s.SearchResults = nil
|
||||
return
|
||||
}
|
||||
|
||||
var results []int
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
// Iterate over all loaded pages in order (map iteration is non-deterministic).
|
||||
totalPages := (s.TotalEntries + PageSize - 1) / PageSize
|
||||
for pageIdx := 0; pageIdx < totalPages; pageIdx++ {
|
||||
page, ok := s.Pages[pageIdx]
|
||||
if !ok || !page.Loaded {
|
||||
continue
|
||||
}
|
||||
// Compute the global start index for this page.
|
||||
startIdx := pageIdx * PageSize
|
||||
for i, entry := range page.Entries {
|
||||
globalIdx := startIdx + i
|
||||
if strings.Contains(strings.ToLower(entry.Name), queryLower) {
|
||||
results = append(results, globalIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.SearchResults = results
|
||||
|
||||
// Jump to first result if any matches found.
|
||||
if len(results) > 0 {
|
||||
s.ScrollIndex = results[0]
|
||||
}
|
||||
}
|
||||
434
internal/browser/search_test.go
Normal file
434
internal/browser/search_test.go
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
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"
|
||||
|
||||
// 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.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0 when no match, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Search - Jump To First ---
|
||||
|
||||
func TestSearch_JumpToFirst(t *testing.T) {
|
||||
s := makeSearchState(t, []Entry{
|
||||
NewEntry("/0", "alpha.txt", 100, time.Time{}, false),
|
||||
NewEntry("/1", "beta.txt", 200, time.Time{}, false),
|
||||
NewEntry("/2", "gamma.txt", 300, time.Time{}, false),
|
||||
NewEntry("/3", "delta.txt", 400, time.Time{}, false),
|
||||
NewEntry("/4", "epsilon.txt", 500, time.Time{}, false),
|
||||
})
|
||||
|
||||
// Start with scroll at position 0
|
||||
s.ScrollIndex = 0
|
||||
|
||||
// Search for "delta" — should jump to index 3
|
||||
HandleSearch(s, "delta")
|
||||
|
||||
if len(s.SearchResults) != 1 {
|
||||
t.Fatalf("expected 1 result for 'delta', got %d", len(s.SearchResults))
|
||||
}
|
||||
if s.ScrollIndex != 3 {
|
||||
t.Errorf("expected ScrollIndex=3 (jumped to first match), got %d", s.ScrollIndex)
|
||||
}
|
||||
|
||||
// Verify query is stored
|
||||
if s.Query != "delta" {
|
||||
t.Errorf("expected Query='delta', got %q", s.Query)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0, got %d", s.ScrollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
}
|
||||
72
internal/browser/sort.go
Normal file
72
internal/browser/sort.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SortMode defines how entries should be sorted.
|
||||
type SortMode int
|
||||
|
||||
const (
|
||||
SortModeNameAsc SortMode = iota // Name ascending (A-Z)
|
||||
SortModeNameDesc // Name descending (Z-A)
|
||||
SortModeDateAsc // Date ascending (oldest first)
|
||||
SortModeDateDesc // Date descending (newest first)
|
||||
// Total sort modes: 4
|
||||
)
|
||||
|
||||
// sortEntries sorts entries in-place according to the given SortMode.
|
||||
// Directories and files are interleaved naturally. Case-insensitive
|
||||
// comparison is used for name-based sorting. Equal keys are broken
|
||||
// by name (ascending).
|
||||
func sortEntries(entries []Entry, mode SortMode) {
|
||||
if len(entries) <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
cmp := comparator(mode)
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return cmp(entries[i], entries[j]) < 0
|
||||
})
|
||||
}
|
||||
|
||||
// comparator returns a comparison function for the given SortMode.
|
||||
// Returns negative if a < b, zero if equal, positive if a > b.
|
||||
func comparator(mode SortMode) func(a, b Entry) int {
|
||||
switch mode {
|
||||
case SortModeNameAsc:
|
||||
return func(a, b Entry) int {
|
||||
if c := strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
case SortModeNameDesc:
|
||||
return func(a, b Entry) int {
|
||||
if c := strings.Compare(strings.ToLower(b.Name), strings.ToLower(a.Name)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(b.Name, a.Name)
|
||||
}
|
||||
case SortModeDateAsc:
|
||||
return func(a, b Entry) int {
|
||||
if c := a.ModTime.Compare(b.ModTime); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
case SortModeDateDesc:
|
||||
return func(a, b Entry) int {
|
||||
if c := b.ModTime.Compare(a.ModTime); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
default:
|
||||
// Invalid mode: no-op (preserve order)
|
||||
return func(a, b Entry) int { return 0 }
|
||||
}
|
||||
}
|
||||
|
||||
303
internal/browser/sort_test.go
Normal file
303
internal/browser/sort_test.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
func newUnsortedEntries() []Entry {
|
||||
return []Entry{
|
||||
{Name: "Zebra.txt", Size: 1000, ModTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC), IsDir: false},
|
||||
{Name: "apple.txt", Size: 500, ModTime: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), IsDir: false},
|
||||
{Name: "MyDir", Size: 0, ModTime: time.Date(2024, 2, 10, 0, 0, 0, 0, time.UTC), IsDir: true},
|
||||
{Name: "banana.txt", Size: 2000, ModTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), IsDir: false},
|
||||
{Name: "Cherry.txt", Size: 1500, ModTime: time.Date(2024, 2, 20, 0, 0, 0, 0, time.UTC), IsDir: false},
|
||||
{Name: "adir", Size: 0, ModTime: time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC), IsDir: true},
|
||||
{Name: "123.txt", Size: 300, ModTime: time.Date(2024, 4, 1, 0, 0, 0, 0, time.UTC), IsDir: false},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sort Mode: Name Ascending ---
|
||||
|
||||
func TestSort_NameAscending(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
|
||||
// Directories and files interleaved by name
|
||||
// Expected: 123.txt, adir, apple.txt, banana.txt, Cherry.txt, MyDir, Zebra.txt
|
||||
wantNames := []string{"123.txt", "adir", "apple.txt", "banana.txt", "Cherry.txt", "MyDir", "Zebra.txt"}
|
||||
for i, want := range wantNames {
|
||||
if entries[i].Name != want {
|
||||
t.Errorf("entries[%d].Name = %q, want %q", i, entries[i].Name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_NameAscending_DirsInterleaved(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
|
||||
// Directories should be interleaved with files at their alphabetical position
|
||||
// adir should be between 123.txt and apple.txt
|
||||
adirIdx := -1
|
||||
for i, e := range entries {
|
||||
if e.Name == "adir" {
|
||||
adirIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if adirIdx <= 0 || adirIdx >= len(entries)-1 {
|
||||
t.Errorf("adir at idx %d should be between 123.txt and apple.txt", adirIdx)
|
||||
}
|
||||
// Verify neighbors
|
||||
if entries[adirIdx-1].Name != "123.txt" {
|
||||
t.Errorf("expected 123.txt before adir, got %s", entries[adirIdx-1].Name)
|
||||
}
|
||||
if entries[adirIdx+1].Name != "apple.txt" {
|
||||
t.Errorf("expected apple.txt after adir, got %s", entries[adirIdx+1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_NameAscending_CaseInsensitive(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
|
||||
// "apple.txt" should come before "Cherry.txt" (case-insensitive)
|
||||
appleIdx := -1
|
||||
cherryIdx := -1
|
||||
for i, e := range entries {
|
||||
if e.Name == "apple.txt" {
|
||||
appleIdx = i
|
||||
}
|
||||
if e.Name == "Cherry.txt" {
|
||||
cherryIdx = i
|
||||
}
|
||||
}
|
||||
if appleIdx >= cherryIdx {
|
||||
t.Errorf("apple.txt (idx %d) should come before Cherry.txt (idx %d)", appleIdx, cherryIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_NameAscending_NumericFirst(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
|
||||
// "123.txt" should be the first entry (numbers sort before letters)
|
||||
if entries[0].Name != "123.txt" {
|
||||
t.Errorf("expected 123.txt first, got %s", entries[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sort Mode: Name Descending ---
|
||||
|
||||
func TestSort_NameDescending(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
sortEntries(entries, SortModeNameDesc)
|
||||
|
||||
// Directories and files interleaved by name (reverse)
|
||||
// Expected: Zebra.txt, MyDir, Cherry.txt, banana.txt, apple.txt, adir, 123.txt
|
||||
wantNames := []string{"Zebra.txt", "MyDir", "Cherry.txt", "banana.txt", "apple.txt", "adir", "123.txt"}
|
||||
for i, want := range wantNames {
|
||||
if entries[i].Name != want {
|
||||
t.Errorf("entries[%d].Name = %q, want %q", i, entries[i].Name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sort Mode: Date Ascending ---
|
||||
|
||||
func TestSort_DateAscending(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
sortEntries(entries, SortModeDateAsc)
|
||||
|
||||
// Directories and files interleaved by date
|
||||
// banana.txt: 2024-01-01, adir: 2024-01-10, Zebra.txt: 2024-01-15, MyDir: 2024-02-10, Cherry.txt: 2024-02-20, apple.txt: 2024-03-01, 123.txt: 2024-04-01
|
||||
wantNames := []string{"banana.txt", "adir", "Zebra.txt", "MyDir", "Cherry.txt", "apple.txt", "123.txt"}
|
||||
for i, want := range wantNames {
|
||||
if entries[i].Name != want {
|
||||
t.Errorf("entries[%d].Name = %q, want %q", i, entries[i].Name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_DateAscending_DirsInterleaved(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
sortEntries(entries, SortModeDateAsc)
|
||||
|
||||
// Directories should be interleaved with files at their date position
|
||||
// adir (2024-01-10) should be between banana.txt (2024-01-01) and Zebra.txt (2024-01-15)
|
||||
adirIdx := -1
|
||||
for i, e := range entries {
|
||||
if e.Name == "adir" {
|
||||
adirIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if adirIdx <= 0 || adirIdx >= len(entries)-1 {
|
||||
t.Errorf("adir at idx %d should be between banana.txt and Zebra.txt", adirIdx)
|
||||
}
|
||||
if entries[adirIdx-1].Name != "banana.txt" {
|
||||
t.Errorf("expected banana.txt before adir, got %s", entries[adirIdx-1].Name)
|
||||
}
|
||||
if entries[adirIdx+1].Name != "Zebra.txt" {
|
||||
t.Errorf("expected Zebra.txt after adir, got %s", entries[adirIdx+1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sort Mode: Date Descending ---
|
||||
|
||||
func TestSort_DateDescending(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
sortEntries(entries, SortModeDateDesc)
|
||||
|
||||
// Directories and files interleaved by date (reverse)
|
||||
wantNames := []string{"123.txt", "apple.txt", "Cherry.txt", "MyDir", "Zebra.txt", "adir", "banana.txt"}
|
||||
for i, want := range wantNames {
|
||||
if entries[i].Name != want {
|
||||
t.Errorf("entries[%d].Name = %q, want %q", i, entries[i].Name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sort Mode: Size Ascending (REMOVED - only 4 sort modes: name +/-, date +/-) ---
|
||||
// Size-based sorting has been removed from the browser to keep sort modes minimal.
|
||||
// The sortEntries function still exists for potential future use.
|
||||
|
||||
// --- Sort Mode: Size Descending (REMOVED - only 4 sort modes: name +/-, date +/-) ---
|
||||
// Size-based sorting has been removed from the browser to keep sort modes minimal.
|
||||
// The sortEntries function still exists for potential future use.
|
||||
|
||||
// --- Edge Cases ---
|
||||
|
||||
func TestSort_EmptySlice(t *testing.T) {
|
||||
var entries []Entry
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected empty slice, got %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_SingleEntry(t *testing.T) {
|
||||
entries := []Entry{{Name: "only.txt", Size: 100, IsDir: false}}
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
if len(entries) != 1 || entries[0].Name != "only.txt" {
|
||||
t.Errorf("single entry sort failed: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_AllDirectories(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{Name: "Zdir", IsDir: true},
|
||||
{Name: "adir", IsDir: true},
|
||||
{Name: "Mdir", IsDir: true},
|
||||
}
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
wantNames := []string{"adir", "Mdir", "Zdir"}
|
||||
for i, want := range wantNames {
|
||||
if entries[i].Name != want {
|
||||
t.Errorf("entries[%d].Name = %q, want %q", i, entries[i].Name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_AllFiles(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{Name: "Zebra.txt", IsDir: false},
|
||||
{Name: "apple.txt", IsDir: false},
|
||||
{Name: "Mango.txt", IsDir: false},
|
||||
}
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
wantNames := []string{"apple.txt", "Mango.txt", "Zebra.txt"}
|
||||
for i, want := range wantNames {
|
||||
if entries[i].Name != want {
|
||||
t.Errorf("entries[%d].Name = %q, want %q", i, entries[i].Name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_StableEqualKeys(t *testing.T) {
|
||||
// When two entries have equal sort keys, order should be deterministic
|
||||
entries := []Entry{
|
||||
{Name: "bbb.txt", Size: 100, ModTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), IsDir: false},
|
||||
{Name: "aaa.txt", Size: 100, ModTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), IsDir: false},
|
||||
}
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
// Same name — should maintain stable order
|
||||
if entries[0].Name != "aaa.txt" {
|
||||
t.Errorf("expected aaa.txt first on equal name, got %s", entries[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_NoSideEffectsOnOriginalOrder(t *testing.T) {
|
||||
// Sorting should not corrupt entry data
|
||||
entries := newUnsortedEntries()
|
||||
originalNames := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
originalNames[i] = e.Name
|
||||
}
|
||||
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
|
||||
// All original entries should still be present
|
||||
names := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
names[e.Name] = true
|
||||
}
|
||||
for _, name := range originalNames {
|
||||
if !names[name] {
|
||||
t.Errorf("entry %q lost during sort", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_InvalidMode(t *testing.T) {
|
||||
entries := newUnsortedEntries()
|
||||
originalLen := len(entries)
|
||||
|
||||
// Invalid mode should not panic and should leave entries intact
|
||||
sortEntries(entries, SortMode(-99))
|
||||
|
||||
if len(entries) != originalLen {
|
||||
t.Errorf("invalid mode changed entry count: %d -> %d", originalLen, len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSort_LargeSlice(t *testing.T) {
|
||||
const count = 10000
|
||||
entries := make([]Entry, count)
|
||||
for i := 0; i < count; i++ {
|
||||
entries[i] = Entry{
|
||||
Name: fmt.Sprintf("file_%05d.txt", i),
|
||||
Size: int64(i * 10),
|
||||
ModTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour),
|
||||
IsDir: i%100 == 0,
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
sortEntries(entries, SortModeNameAsc)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 5*time.Second {
|
||||
t.Errorf("sort took %v, expected < 5s", elapsed)
|
||||
}
|
||||
|
||||
// Verify directories are interleaved (not separated)
|
||||
// With 100 entries per 100 entries being dirs, they should be mixed
|
||||
hasDirBeforeFile := false
|
||||
hasFileBeforeDir := false
|
||||
for i := 0; i < len(entries)-1; i++ {
|
||||
if entries[i].IsDir && !entries[i+1].IsDir {
|
||||
hasDirBeforeFile = true
|
||||
}
|
||||
if !entries[i].IsDir && entries[i+1].IsDir {
|
||||
hasFileBeforeDir = true
|
||||
}
|
||||
}
|
||||
if !hasDirBeforeFile || !hasFileBeforeDir {
|
||||
t.Errorf("directories should be interleaved with files, not separated")
|
||||
}
|
||||
}
|
||||
139
internal/browser/types.go
Normal file
139
internal/browser/types.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gioui.org/widget"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// Entry represents a single file or directory in the browser.
|
||||
type Entry struct {
|
||||
Path string // Relative path from configured directory
|
||||
Name string // Display name (basename)
|
||||
Size int64 // File size in bytes (0 for directories)
|
||||
ModTime time.Time // Last modification time
|
||||
IsDir bool // True if this is a directory
|
||||
IsFirst bool // True if this is the first entry for its letter section
|
||||
}
|
||||
|
||||
// Page represents a chunk of directory entries loaded from disk.
|
||||
type Page struct {
|
||||
Index int // Page number (0-based)
|
||||
Entries []Entry // Entries in this page
|
||||
Loaded bool // True if page data is in memory
|
||||
Dirty bool // True if page needs refresh (external change detected)
|
||||
}
|
||||
|
||||
// BrowserState holds all mutable browser state owned by the logic goroutine.
|
||||
type BrowserState struct {
|
||||
// Navigation
|
||||
CurrentPath string // Currently browsed directory (relative to root)
|
||||
ScrollIndex int // Index of first visible entry (not pixel offset)
|
||||
VisibleCount int // Number of entries currently visible
|
||||
|
||||
// Lazy loading
|
||||
Pages map[int]*Page // Loaded pages by page index
|
||||
TotalEntries int // Total entry count (from cached index)
|
||||
Loading bool // True if a page load is in flight
|
||||
|
||||
// Sorted index (position maps for each sort mode)
|
||||
SortIndex *DirectoryIndex // Cached index with position maps
|
||||
SortMode SortMode // Current sort mode
|
||||
|
||||
// Search
|
||||
Query string // Current search query
|
||||
SearchResults []int // Indices of matching entries (empty = no filter)
|
||||
SearchEditor widget.Editor // Gio editor for search input
|
||||
|
||||
// Alphabetical index
|
||||
ActiveLetter string // Currently pressed letter (for highlighting)
|
||||
LetterOffsets map[string]int // First entry index for each letter
|
||||
|
||||
// Interaction
|
||||
SelectedIndex int // Currently selected entry (-1 = none)
|
||||
TapTimestamp time.Time // For double-tap detection
|
||||
}
|
||||
|
||||
const (
|
||||
PageSize = 100 // Entries per page (tunable; ~5KB per page in memory)
|
||||
PrefetchDist = 2 // Pages to prefetch beyond visible region
|
||||
)
|
||||
|
||||
// NewEntry creates a new Entry from the given parameters.
|
||||
func NewEntry(path, name string, size int64, modTime time.Time, isDir bool) Entry {
|
||||
return Entry{
|
||||
Path: path,
|
||||
Name: name,
|
||||
Size: size,
|
||||
ModTime: modTime,
|
||||
IsDir: isDir,
|
||||
}
|
||||
}
|
||||
|
||||
// NewPage creates a new Page with the given index and entries.
|
||||
func NewPage(index int, entries []Entry) *Page {
|
||||
return &Page{
|
||||
Index: index,
|
||||
Entries: entries,
|
||||
Loaded: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NewBrowserState creates a new BrowserState with default values.
|
||||
func NewBrowserState() *BrowserState {
|
||||
return &BrowserState{
|
||||
Pages: make(map[int]*Page),
|
||||
LetterOffsets: make(map[string]int),
|
||||
SelectedIndex: -1,
|
||||
SortMode: SortModeNameAsc, // Default sort mode
|
||||
}
|
||||
}
|
||||
|
||||
// ToListItem converts an Entry to a ui.ListItem for rendering.
|
||||
func (e *Entry) ToListItem() ui.ListItem {
|
||||
subtext := formatSize(e.Size)
|
||||
if e.IsDir {
|
||||
subtext = "Directory"
|
||||
}
|
||||
return ui.ListItem{
|
||||
Text: e.Name,
|
||||
Subtext: subtext,
|
||||
}
|
||||
}
|
||||
|
||||
// formatSize returns a human-readable file size string.
|
||||
func formatSize(bytes int64) string {
|
||||
switch {
|
||||
case bytes < 1024:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
case bytes < 1024*1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
||||
case bytes < 1024*1024*1024:
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
|
||||
default:
|
||||
return fmt.Sprintf("%.1f GB", float64(bytes)/(1024*1024*1024))
|
||||
}
|
||||
}
|
||||
|
||||
// EvictPages removes pages that are far from the current scroll position.
|
||||
func (s *BrowserState) EvictPages() {
|
||||
minPage := s.ScrollIndex/PageSize - PrefetchDist
|
||||
maxPage := (s.ScrollIndex + s.VisibleCount)/PageSize + PrefetchDist
|
||||
for idx, page := range s.Pages {
|
||||
if idx < minPage || idx > maxPage {
|
||||
page.Entries = nil // Release memory
|
||||
page.Loaded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears all browser state for navigation.
|
||||
func (s *BrowserState) Reset() {
|
||||
s.Pages = make(map[int]*Page)
|
||||
s.ScrollIndex = 0
|
||||
s.SelectedIndex = -1
|
||||
s.SearchResults = nil
|
||||
s.Query = ""
|
||||
}
|
||||
234
internal/browser/types_test.go
Normal file
234
internal/browser/types_test.go
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewEntry(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := NewEntry("/test/file.txt", "file.txt", 1024, now, false)
|
||||
|
||||
if e.Path != "/test/file.txt" {
|
||||
t.Errorf("expected Path=/test/file.txt, got %s", e.Path)
|
||||
}
|
||||
if e.Name != "file.txt" {
|
||||
t.Errorf("expected Name=file.txt, got %s", e.Name)
|
||||
}
|
||||
if e.Size != 1024 {
|
||||
t.Errorf("expected Size=1024, got %d", e.Size)
|
||||
}
|
||||
if e.IsDir {
|
||||
t.Error("expected IsDir=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEntryDirectory(t *testing.T) {
|
||||
e := NewEntry("/test/dir", "dir", 0, time.Time{}, true)
|
||||
|
||||
if !e.IsDir {
|
||||
t.Error("expected IsDir=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPage(t *testing.T) {
|
||||
entries := []Entry{
|
||||
NewEntry("/a", "a", 100, time.Time{}, false),
|
||||
NewEntry("/b", "b", 200, time.Time{}, false),
|
||||
}
|
||||
p := NewPage(0, entries)
|
||||
|
||||
if p.Index != 0 {
|
||||
t.Errorf("expected Index=0, got %d", p.Index)
|
||||
}
|
||||
if !p.Loaded {
|
||||
t.Error("expected Loaded=true")
|
||||
}
|
||||
if len(p.Entries) != 2 {
|
||||
t.Errorf("expected 2 entries, got %d", len(p.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageSizeConstant(t *testing.T) {
|
||||
if PageSize != 100 {
|
||||
t.Errorf("expected PageSize=100, got %d", PageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBrowserState(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
|
||||
if s.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0, got %d", s.ScrollIndex)
|
||||
}
|
||||
if s.SelectedIndex != -1 {
|
||||
t.Errorf("expected SelectedIndex=-1, got %d", s.SelectedIndex)
|
||||
}
|
||||
if s.Pages == nil {
|
||||
t.Error("expected Pages to be initialized")
|
||||
}
|
||||
if s.LetterOffsets == nil {
|
||||
t.Error("expected LetterOffsets to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryToListItem(t *testing.T) {
|
||||
e := NewEntry("/test/file.txt", "file.txt", 1024, time.Time{}, false)
|
||||
item := e.ToListItem()
|
||||
|
||||
if item.Text != "file.txt" {
|
||||
t.Errorf("expected Text=file.txt, got %s", item.Text)
|
||||
}
|
||||
if item.Subtext != "1.0 KB" {
|
||||
t.Errorf("expected Subtext=1.0 KB, got %s", item.Subtext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryToListItemDirectory(t *testing.T) {
|
||||
e := NewEntry("/test/dir", "dir", 0, time.Time{}, true)
|
||||
item := e.ToListItem()
|
||||
|
||||
if item.Subtext != "Directory" {
|
||||
t.Errorf("expected Subtext=Directory, got %s", item.Subtext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
size int64
|
||||
expected string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{500, "500 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1024*1024, "1.0 MB"},
|
||||
{1024 * 1024 * 1024, "1.0 GB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatSize(tt.size)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatSize(%d) = %s, want %s", tt.size, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserStateEvictPages(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.ScrollIndex = 500 // Page 5
|
||||
s.VisibleCount = 10
|
||||
|
||||
// With ScrollIndex=500, PageSize=100:
|
||||
// minPage = 5 - 2 = 3
|
||||
// maxPage = (500+10)/100 + 2 = 7
|
||||
// Pages 3-7 should be kept, others evicted
|
||||
|
||||
// Add pages at various positions
|
||||
s.Pages[0] = NewPage(0, nil) // Should be evicted (far)
|
||||
s.Pages[3] = NewPage(3, nil) // Should be kept (at min boundary)
|
||||
s.Pages[5] = NewPage(5, nil) // Should be kept (visible)
|
||||
s.Pages[7] = NewPage(7, nil) // Should be kept (at max boundary)
|
||||
s.Pages[10] = NewPage(10, nil) // Should be evicted (far)
|
||||
|
||||
s.EvictPages()
|
||||
|
||||
if s.Pages[0].Loaded {
|
||||
t.Error("expected page 0 to be evicted")
|
||||
}
|
||||
if !s.Pages[3].Loaded {
|
||||
t.Error("expected page 3 to be kept")
|
||||
}
|
||||
if !s.Pages[5].Loaded {
|
||||
t.Error("expected page 5 to be kept")
|
||||
}
|
||||
if !s.Pages[7].Loaded {
|
||||
t.Error("expected page 7 to be kept")
|
||||
}
|
||||
if s.Pages[10].Loaded {
|
||||
t.Error("expected page 10 to be evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserStateReset(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.ScrollIndex = 100
|
||||
s.SelectedIndex = 5
|
||||
s.Pages[0] = NewPage(0, nil)
|
||||
s.Query = "test"
|
||||
s.SearchResults = []int{1, 2, 3}
|
||||
|
||||
s.Reset()
|
||||
|
||||
if s.ScrollIndex != 0 {
|
||||
t.Errorf("expected ScrollIndex=0, got %d", s.ScrollIndex)
|
||||
}
|
||||
if s.SelectedIndex != -1 {
|
||||
t.Errorf("expected SelectedIndex=-1, got %d", s.SelectedIndex)
|
||||
}
|
||||
if len(s.Pages) != 0 {
|
||||
t.Errorf("expected Pages to be empty, got %d", len(s.Pages))
|
||||
}
|
||||
if s.Query != "" {
|
||||
t.Errorf("expected Query=\"\", got %q", s.Query)
|
||||
}
|
||||
if s.SearchResults != nil {
|
||||
t.Error("expected SearchResults to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPage_FromIndex verifies page loading at correct offset
|
||||
func TestLoadPage_FromIndex(t *testing.T) {
|
||||
// Create a page with known entries
|
||||
entries := make([]Entry, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
entries[i] = NewEntry(fmt.Sprintf("/path/file%d.txt", i), fmt.Sprintf("file%d.txt", i), int64(i*100), time.Time{}, false)
|
||||
}
|
||||
page := NewPage(0, entries)
|
||||
|
||||
if page.Index != 0 {
|
||||
t.Errorf("expected Index=0, got %d", page.Index)
|
||||
}
|
||||
if len(page.Entries) != 100 {
|
||||
t.Errorf("expected 100 entries, got %d", len(page.Entries))
|
||||
}
|
||||
// Verify first entry
|
||||
if page.Entries[0].Name != "file0.txt" {
|
||||
t.Errorf("expected first entry name=file0.txt, got %s", page.Entries[0].Name)
|
||||
}
|
||||
// Verify last entry
|
||||
if page.Entries[99].Name != "file99.txt" {
|
||||
t.Errorf("expected last entry name=file99.txt, got %s", page.Entries[99].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPage_LastPagePartial verifies partial page at end of list
|
||||
func TestLoadPage_LastPagePartial(t *testing.T) {
|
||||
// A last page with fewer than PageSize entries
|
||||
entries := []Entry{
|
||||
NewEntry("/path/last1.txt", "last1.txt", 100, time.Time{}, false),
|
||||
NewEntry("/path/last2.txt", "last2.txt", 200, time.Time{}, false),
|
||||
}
|
||||
page := NewPage(99, entries) // High page index
|
||||
|
||||
if page.Index != 99 {
|
||||
t.Errorf("expected Index=99, got %d", page.Index)
|
||||
}
|
||||
if len(page.Entries) != 2 {
|
||||
t.Errorf("expected 2 entries, got %d", len(page.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPage_OutOfBounds verifies bounds checking
|
||||
func TestLoadPage_OutOfBounds(t *testing.T) {
|
||||
s := NewBrowserState()
|
||||
s.TotalEntries = 150 // 2 pages (0 and 1)
|
||||
|
||||
// Requesting page 5 (out of bounds) should not panic
|
||||
// The actual bounds check would be in the browser.go loading logic
|
||||
// For now, verify that the state doesn't crash when accessing non-existent pages
|
||||
_, exists := s.Pages[5]
|
||||
if exists {
|
||||
t.Error("expected page 5 to not exist")
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ package editor
|
|||
import (
|
||||
"sync"
|
||||
|
||||
"pad/internal/browser"
|
||||
"pad/internal/io/pool"
|
||||
"pad/internal/io/pool/mock"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
|
|
@ -47,13 +50,28 @@ type Logic struct {
|
|||
lastLineYChan chan int // last line Y (Dp, sent as int) feedback from renderer
|
||||
resultChan chan ResultEvent
|
||||
searchQueryChan chan string // search text updates from main goroutine
|
||||
workerPool *pool.WorkerPool
|
||||
mockFS *mock.FileSystem
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewLogic creates a new Logic instance.
|
||||
func NewLogic() *Logic {
|
||||
state := NewState()
|
||||
TheState = state
|
||||
|
||||
// Initialize mock filesystem with sample data
|
||||
mockFS := mock.NewFileSystem()
|
||||
populateMockFileSystem(mockFS)
|
||||
|
||||
// Initialize worker pool
|
||||
wp := pool.NewWorkerPool(4)
|
||||
wp.Start()
|
||||
|
||||
// Set browser initial path to mock root
|
||||
state.Browser.CurrentPath = "/"
|
||||
|
||||
return &Logic{
|
||||
state: state,
|
||||
configChan: make(chan ConfigUpdate),
|
||||
|
|
@ -62,6 +80,9 @@ func NewLogic() *Logic {
|
|||
lastLineYChan: make(chan int),
|
||||
resultChan: make(chan ResultEvent),
|
||||
searchQueryChan: make(chan string),
|
||||
workerPool: wp,
|
||||
mockFS: mockFS,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,8 +128,13 @@ func (l *Logic) Scale() float32 {
|
|||
|
||||
// Run runs the logic goroutine loop.
|
||||
func (l *Logic) Run() {
|
||||
// Dispatch initial directory index build on startup
|
||||
l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS))
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.done:
|
||||
return
|
||||
case update := <-l.configChan:
|
||||
update.apply(l.state)
|
||||
l.frameChan <- l.state.layout()
|
||||
|
|
@ -123,22 +149,65 @@ func (l *Logic) Run() {
|
|||
}
|
||||
l.frameChan <- l.state.layout()
|
||||
case query := <-l.searchQueryChan:
|
||||
if query != l.state.SearchQuery {
|
||||
l.state.SearchQuery = query
|
||||
if query != l.state.Browser.Query {
|
||||
l.state.Browser.Query = query
|
||||
if l.state.page == BrowserPage {
|
||||
l.state.BrowserScrollOffset = 0 // reset scroll on query change
|
||||
filtered := getFilteredEntriesRaw()
|
||||
l.state.SortedEntries = sortEntries(filtered, l.state.SortMode)
|
||||
browser.HandleSearch(&l.state.Browser, query)
|
||||
}
|
||||
}
|
||||
l.frameChan <- l.state.layout()
|
||||
case res := <-l.workerPool.ResultChan():
|
||||
l.handleWorkerResult(res)
|
||||
case <-l.resultChan:
|
||||
l.frameChan <- l.state.layout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleWorkerResult processes results from the worker pool.
|
||||
func (l *Logic) handleWorkerResult(res pool.Result) {
|
||||
switch res.TaskType {
|
||||
case pool.TypeBuildIndex:
|
||||
l.applyBuildIndexResult(res)
|
||||
case pool.TypeLoadPages:
|
||||
l.applyLoadPagesResult(res)
|
||||
case pool.TypeReadDir:
|
||||
l.applyReadDirResult(res)
|
||||
}
|
||||
l.frameChan <- l.state.layout()
|
||||
}
|
||||
|
||||
// applyBuildIndexResult applies a completed BuildIndexTask result to browser state.
|
||||
func (l *Logic) applyBuildIndexResult(res pool.Result) {
|
||||
if !res.Success {
|
||||
return
|
||||
}
|
||||
// Convert mock DirEntry results to browser entries
|
||||
if entries, ok := res.Data.([]mock.DirEntry); ok {
|
||||
l.state.Browser.TotalEntries = len(entries)
|
||||
// Build the in-memory index with position maps
|
||||
l.state.Browser.SortIndex = buildBrowserIndex(entries)
|
||||
// Load initial visible pages from the index into the Pages map
|
||||
browser.LoadInitialPages(&l.state.Browser)
|
||||
}
|
||||
}
|
||||
|
||||
// applyLoadPagesResult applies a completed LoadPagesTask result to browser state.
|
||||
func (l *Logic) applyLoadPagesResult(res pool.Result) {
|
||||
_ = res // TODO: implement page loading from worker results
|
||||
}
|
||||
|
||||
// applyReadDirResult applies a completed ReadDirTask result to browser state.
|
||||
func (l *Logic) applyReadDirResult(res pool.Result) {
|
||||
_ = res // TODO: implement directory read result handling
|
||||
}
|
||||
|
||||
// State returns the current state.
|
||||
func (l *Logic) State() *State {
|
||||
return l.state
|
||||
}
|
||||
|
||||
// Done signals the logic goroutine to stop.
|
||||
func (l *Logic) Done() {
|
||||
close(l.done)
|
||||
}
|
||||
|
|
|
|||
243
internal/editor/mock_setup.go
Normal file
243
internal/editor/mock_setup.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pad/internal/browser"
|
||||
"pad/internal/io/pool/mock"
|
||||
)
|
||||
|
||||
// populateMockFileSystem seeds the mock filesystem with sample data for testing.
|
||||
// Creates a realistic directory structure with subdirectories, various file types,
|
||||
// and varied sizes/dates to exercise the browser functionality.
|
||||
func populateMockFileSystem(fs *mock.FileSystem) {
|
||||
baseTime := time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
// --- Root directories ---
|
||||
dirs := []string{
|
||||
"/Documents",
|
||||
"/Pictures",
|
||||
"/Music",
|
||||
"/Downloads",
|
||||
"/Projects",
|
||||
"/Projects/pad",
|
||||
"/Projects/goplus",
|
||||
"/Documents/Work",
|
||||
"/Documents/Personal",
|
||||
"/Pictures/Vacation",
|
||||
}
|
||||
for i, d := range dirs {
|
||||
fs.AddDir(d, baseTime.AddDate(0, 0, -i))
|
||||
}
|
||||
|
||||
// --- Root files ---
|
||||
rootFiles := []struct {
|
||||
name string
|
||||
size int64
|
||||
days int
|
||||
}{
|
||||
{"README.md", 2048, -30},
|
||||
{"config.yaml", 512, -60},
|
||||
{"notes.txt", 1024, -15},
|
||||
{"setup.log", 4096, -7},
|
||||
{".gitignore", 128, -90},
|
||||
}
|
||||
for _, f := range rootFiles {
|
||||
fs.AddFile("/"+f.name, make([]byte, f.size), baseTime.AddDate(0, 0, f.days))
|
||||
}
|
||||
|
||||
// --- Documents/Work files ---
|
||||
workFiles := []struct {
|
||||
name string
|
||||
size int64
|
||||
days int
|
||||
}{
|
||||
{"Q1_Report.docx", 524288, -45},
|
||||
{"Q2_Report.docx", 614400, -14},
|
||||
{"Budget_2026.xlsx", 262144, -30},
|
||||
{"Meeting_Notes.txt", 8192, -1},
|
||||
{"Presentation.pptx", 1048576, -21},
|
||||
}
|
||||
for _, f := range workFiles {
|
||||
fs.AddFile("/Documents/Work/"+f.name, make([]byte, f.size), baseTime.AddDate(0, 0, f.days))
|
||||
}
|
||||
|
||||
// --- Documents/Personal files ---
|
||||
personalFiles := []struct {
|
||||
name string
|
||||
size int64
|
||||
days int
|
||||
}{
|
||||
{"Resume.pdf", 32768, -60},
|
||||
{"Tax_Return_2025.pdf", 131072, -90},
|
||||
{"Recipe_Collection.txt", 4096, -120},
|
||||
{"Letter_to_Friend.txt", 2048, -5},
|
||||
}
|
||||
for _, f := range personalFiles {
|
||||
fs.AddFile("/Documents/Personal/"+f.name, make([]byte, f.size), baseTime.AddDate(0, 0, f.days))
|
||||
}
|
||||
|
||||
// --- Projects/pad source files ---
|
||||
padFiles := []struct {
|
||||
name string
|
||||
size int64
|
||||
days int
|
||||
}{
|
||||
{"main.go", 3072, -2},
|
||||
{"state.go", 4096, -2},
|
||||
{"logic.go", 5120, -1},
|
||||
{"browser.go", 2048, -7},
|
||||
{"types.go", 1536, -7},
|
||||
{"index.go", 3584, -7},
|
||||
{"layout.go", 2560, -5},
|
||||
{"handlers.go", 1024, -3},
|
||||
{"go.mod", 256, -30},
|
||||
{"go.sum", 512, -30},
|
||||
}
|
||||
for _, f := range padFiles {
|
||||
fs.AddFile("/Projects/pad/"+f.name, make([]byte, f.size), baseTime.AddDate(0, 0, f.days))
|
||||
}
|
||||
|
||||
// --- Pictures/Vacation files ---
|
||||
vacationFiles := []struct {
|
||||
name string
|
||||
size int64
|
||||
days int
|
||||
}{
|
||||
{"IMG_0001.jpg", 3145728, -180},
|
||||
{"IMG_0002.jpg", 2621440, -180},
|
||||
{"IMG_0003.jpg", 4194304, -179},
|
||||
{"IMG_0004.jpg", 3670016, -179},
|
||||
{"IMG_0005.jpg", 2097152, -178},
|
||||
}
|
||||
for _, f := range vacationFiles {
|
||||
fs.AddFile("/Pictures/Vacation/"+f.name, make([]byte, f.size), baseTime.AddDate(0, 0, f.days))
|
||||
}
|
||||
|
||||
// --- Downloads files ---
|
||||
downloads := []struct {
|
||||
name string
|
||||
size int64
|
||||
days int
|
||||
}{
|
||||
{"installer.dmg", 104857600, -10},
|
||||
{"archive.tar.gz", 52428800, -20},
|
||||
{"patch.diff", 8192, -3},
|
||||
}
|
||||
for _, f := range downloads {
|
||||
fs.AddFile("/Downloads/"+f.name, make([]byte, f.size), baseTime.AddDate(0, 0, f.days))
|
||||
}
|
||||
}
|
||||
|
||||
// sortModeKey converts a browser.SortMode to its JSON key string.
|
||||
func sortModeKey(mode browser.SortMode) string {
|
||||
switch mode {
|
||||
case 0: // SortModeNameAsc
|
||||
return "name_asc"
|
||||
case 1: // SortModeNameDesc
|
||||
return "name_desc"
|
||||
case 2: // SortModeDateAsc
|
||||
return "date_asc"
|
||||
case 3: // SortModeDateDesc
|
||||
return "date_desc"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// modeCount returns the total number of sort modes.
|
||||
func modeCount() int {
|
||||
return 4
|
||||
}
|
||||
|
||||
// buildBrowserIndex converts mock DirEntry results into a browser.DirectoryIndex
|
||||
// with pre-computed position maps for all sort modes.
|
||||
func buildBrowserIndex(entries []mock.DirEntry) *browser.DirectoryIndex {
|
||||
var browserEntries []browser.Entry
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
browserEntries = append(browserEntries, browser.Entry{
|
||||
Path: e.Name(),
|
||||
Name: e.Name(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
IsDir: info.IsDir(),
|
||||
})
|
||||
}
|
||||
|
||||
// Build position maps for all sort modes
|
||||
sortOrders := make(map[string][]int)
|
||||
for mode := browser.SortMode(0); mode < browser.SortMode(modeCount()); mode++ {
|
||||
key := sortModeKey(mode)
|
||||
if key != "" {
|
||||
sortOrders[key] = buildPositionMap(browserEntries, mode)
|
||||
}
|
||||
}
|
||||
|
||||
return &browser.DirectoryIndex{
|
||||
Path: "/",
|
||||
EntryCount: len(browserEntries),
|
||||
Entries: browserEntries,
|
||||
SortOrders: sortOrders,
|
||||
}
|
||||
}
|
||||
|
||||
// buildPositionMap creates a sorted index → raw index mapping.
|
||||
func buildPositionMap(entries []browser.Entry, mode browser.SortMode) []int {
|
||||
n := len(entries)
|
||||
indices := make([]int, n)
|
||||
for i := range indices {
|
||||
indices[i] = i
|
||||
}
|
||||
|
||||
// Sort indices based on entry comparison
|
||||
cmp := comparator(mode)
|
||||
sort.SliceStable(indices, func(i, j int) bool {
|
||||
a, b := entries[indices[i]], entries[indices[j]]
|
||||
return cmp(a, b) < 0
|
||||
})
|
||||
|
||||
return indices
|
||||
}
|
||||
|
||||
// comparator returns a comparison function for the given SortMode.
|
||||
// Returns negative if a < b, zero if equal, positive if a > b.
|
||||
func comparator(mode browser.SortMode) func(a, b browser.Entry) int {
|
||||
switch mode {
|
||||
case 0: // SortModeNameAsc
|
||||
return func(a, b browser.Entry) int {
|
||||
if c := strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
case 1: // SortModeNameDesc
|
||||
return func(a, b browser.Entry) int {
|
||||
if c := strings.Compare(strings.ToLower(b.Name), strings.ToLower(a.Name)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(b.Name, a.Name)
|
||||
}
|
||||
case 2: // SortModeDateAsc
|
||||
return func(a, b browser.Entry) int {
|
||||
if c := a.ModTime.Compare(b.ModTime); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
case 3: // SortModeDateDesc
|
||||
return func(a, b browser.Entry) int {
|
||||
if c := b.ModTime.Compare(a.ModTime); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
default:
|
||||
return func(a, b browser.Entry) int { return 0 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gioui.org/widget"
|
||||
"pad/internal/browser"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
|
|
@ -73,14 +70,8 @@ type State struct {
|
|||
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
|
||||
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
|
||||
Elems []ui.Element
|
||||
// Browser state
|
||||
BrowserScrollOffset ui.Dp // pixel-level scroll offset for browser list
|
||||
BrowserListHeight ui.Dp // height of the list region, set during layout
|
||||
SortMode SortMode // sort mode for browser list
|
||||
SortOrderLabel string // label text for sort order toggle
|
||||
SearchQuery string // current search query
|
||||
SearchEditor widget.Editor // Gio editor for search input
|
||||
SortedEntries []ui.ListItem // pre-sorted entries (computed only when sort mode changes)
|
||||
// Browser state (directly embedded per architecture §8)
|
||||
Browser browser.BrowserState // Embedded, not a pointer
|
||||
// Editor state
|
||||
ActiveFilename string // filename shown in editor status bar
|
||||
}
|
||||
|
|
@ -89,9 +80,7 @@ func NewState() *State {
|
|||
return &State{
|
||||
scale: 1.0,
|
||||
page: EditorPage,
|
||||
SortMode: SortByDateDesc,
|
||||
SortOrderLabel: "Date ↓",
|
||||
SortedEntries: sortEntries(browserEntries, SortByDateDesc),
|
||||
Browser: *browser.NewBrowserState(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +101,8 @@ func (s *State) layout() []ui.Element {
|
|||
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
|
||||
switch s.page {
|
||||
case BrowserPage:
|
||||
s.Elems = BrowserLayout(dpW, dpH, s.SortMode)
|
||||
browser.SetBrowserState(&s.Browser)
|
||||
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser)
|
||||
case EditorPage:
|
||||
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
|
||||
}
|
||||
|
|
@ -138,51 +128,11 @@ func HandleScroll(data any) {
|
|||
}
|
||||
}
|
||||
|
||||
// HandleBrowserScroll updates the browser list scroll offset.
|
||||
// The delta is in pixels; convert to Dp for smooth per-pixel scrolling.
|
||||
// HandleBrowserScroll updates the browser scroll index by the given delta.
|
||||
// Delegates to the browser package's handler.
|
||||
func HandleBrowserScroll(data any) {
|
||||
delta := data.(int) // pixels
|
||||
deltaDp := ui.ToDp(ui.Px(delta), TheState.scale)
|
||||
TheState.BrowserScrollOffset += deltaDp
|
||||
if TheState.BrowserScrollOffset < 0 {
|
||||
TheState.BrowserScrollOffset = 0
|
||||
}
|
||||
maxScroll := computeBrowserMaxScroll()
|
||||
if TheState.BrowserScrollOffset > maxScroll {
|
||||
TheState.BrowserScrollOffset = maxScroll
|
||||
}
|
||||
}
|
||||
|
||||
// computeBrowserMaxScroll returns the maximum scroll offset in Dp
|
||||
// so the last row is fully visible at the bottom of the list.
|
||||
func computeBrowserMaxScroll() ui.Dp {
|
||||
rowHeight := ui.Dp(48)
|
||||
totalRows := len(getFilteredEntries())
|
||||
// Calculate max scroll such that the last row's bottom edge
|
||||
// aligns with the list region's bottom edge.
|
||||
// lastRowBottom = totalRows * rowHeight
|
||||
// We want: lastRowBottom - maxScroll = BrowserListHeight
|
||||
// Therefore: maxScroll = totalRows * rowHeight - BrowserListHeight
|
||||
maxScroll := ui.Dp(totalRows)*rowHeight - TheState.BrowserListHeight
|
||||
if maxScroll < 0 {
|
||||
return 0
|
||||
}
|
||||
return maxScroll
|
||||
}
|
||||
|
||||
// visibleBrowserRows estimates how many browser rows fit in the viewport.
|
||||
func visibleBrowserRows(pixelHeight int, scale float32) int {
|
||||
margin := 10
|
||||
headerHeight := 24
|
||||
searchHeight := 36
|
||||
gap := 3 // margin/2 * 2 in Dp
|
||||
contentHeight := ui.ToDp(ui.Px(pixelHeight), float32(scale)) - ui.Dp(margin*2+headerHeight+searchHeight+gap)
|
||||
rowHeight := ui.Dp(48)
|
||||
rows := int(contentHeight / rowHeight)
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
return rows
|
||||
delta := data.(int) // entries
|
||||
browser.HandleScroll(&TheState.Browser, delta)
|
||||
}
|
||||
|
||||
// GoToBrowser switches the app to the browser page.
|
||||
|
|
@ -204,20 +154,10 @@ func OpenFile(data any) {
|
|||
|
||||
// ToggleSortOrder cycles the browser sort mode through four modes.
|
||||
func ToggleSortOrder(data any) {
|
||||
TheState.SortMode = (TheState.SortMode + 1) % 4
|
||||
switch TheState.SortMode {
|
||||
case SortByDateDesc:
|
||||
TheState.SortOrderLabel = "Date ↓"
|
||||
case SortByDateAsc:
|
||||
TheState.SortOrderLabel = "Date ↑"
|
||||
case SortByNameAsc:
|
||||
TheState.SortOrderLabel = "Name ↑"
|
||||
case SortByNameDesc:
|
||||
TheState.SortOrderLabel = "Name ↓"
|
||||
}
|
||||
// Sort once when mode changes, not every frame
|
||||
TheState.SortedEntries = sortEntries(getFilteredEntriesRaw(), TheState.SortMode)
|
||||
TheState.BrowserScrollOffset = 0 // reset scroll on sort change
|
||||
// Cycle through the 4 sort modes
|
||||
TheState.Browser.SortMode = (TheState.Browser.SortMode + 1) % 4
|
||||
// Reset scroll on sort change
|
||||
TheState.Browser.ScrollIndex = 0
|
||||
}
|
||||
|
||||
// EditorLayout computes the element tree for the editor page.
|
||||
|
|
@ -303,196 +243,3 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
|
||||
return []ui.Element{statusBar, editor, bottomBar}
|
||||
}
|
||||
|
||||
// getFilteredEntriesRaw returns browser entries filtered by the current search query.
|
||||
// Does not use the SortedEntries cache.
|
||||
func getFilteredEntriesRaw() []ui.ListItem {
|
||||
var filtered []ui.ListItem
|
||||
query := strings.ToLower(TheState.SearchQuery)
|
||||
if query == "" {
|
||||
filtered = browserEntries
|
||||
} else {
|
||||
for _, entry := range browserEntries {
|
||||
if strings.Contains(strings.ToLower(entry.Text), query) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// getFilteredEntries returns browser entries filtered by the current search query.
|
||||
// If SortedEntries is non-nil (sort mode has been set), returns that instead.
|
||||
func getFilteredEntries() []ui.ListItem {
|
||||
if TheState.SortedEntries != nil {
|
||||
return TheState.SortedEntries
|
||||
}
|
||||
return getFilteredEntriesRaw()
|
||||
}
|
||||
|
||||
// sortEntries sorts the entries according to the given sort mode.
|
||||
func sortEntries(entries []ui.ListItem, mode SortMode) []ui.ListItem {
|
||||
// Make a copy to avoid modifying the original slice
|
||||
sorted := make([]ui.ListItem, len(entries))
|
||||
copy(sorted, entries)
|
||||
|
||||
// Parse date from subtext (format: "2025-01-15 • 4.2 KB")
|
||||
getDate := func(entry ui.ListItem) string {
|
||||
// Extract date part before the first space
|
||||
parts := strings.Split(entry.Subtext, " ")
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case SortByDateDesc:
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return getDate(sorted[i]) > getDate(sorted[j])
|
||||
})
|
||||
case SortByDateAsc:
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return getDate(sorted[i]) < getDate(sorted[j])
|
||||
})
|
||||
case SortByNameAsc:
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return strings.ToLower(sorted[i].Text) < strings.ToLower(sorted[j].Text)
|
||||
})
|
||||
case SortByNameDesc:
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return strings.ToLower(sorted[i].Text) > strings.ToLower(sorted[j].Text)
|
||||
})
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
// browserEntries is a static list of sample files for the browser page.
|
||||
// It is longer than the viewport so scrolling can be tested.
|
||||
var browserEntries = []ui.ListItem{
|
||||
{Text: "notes-2025-01-15.txt", Subtext: "2025-01-15 • 4.2 KB"},
|
||||
{Text: "meeting-2025-01-14.txt", Subtext: "2025-01-14 • 1.8 KB"},
|
||||
{Text: "todo-2025-01-14.txt", Subtext: "2025-01-14 • 892 B"},
|
||||
{Text: "ideas-2025-01-13.txt", Subtext: "2025-01-13 • 2.1 KB"},
|
||||
{Text: "diary-2025-01-12.txt", Subtext: "2025-01-12 • 3.5 KB"},
|
||||
{Text: "bookmarks-2025-01-11.txt", Subtext: "2025-01-11 • 6.7 KB"},
|
||||
{Text: "inbox-2025-01-10.txt", Subtext: "2025-01-10 • 12 KB"},
|
||||
{Text: "archive-2025-01-09.txt", Subtext: "2025-01-09 • 28 KB"},
|
||||
{Text: "draft-article.txt", Subtext: "2025-01-08 • 5.3 KB"},
|
||||
{Text: "recipe-cole-sl.txt", Subtext: "2025-01-07 • 1.1 KB"},
|
||||
{Text: "project-plan.txt", Subtext: "2025-01-06 • 8.4 KB"},
|
||||
{Text: "weekly-review.txt", Subtext: "2025-01-05 • 2.9 KB"},
|
||||
{Text: "changelog-v2.txt", Subtext: "2025-01-04 • 15 KB"},
|
||||
{Text: "README.txt", Subtext: "2025-01-03 • 512 B"},
|
||||
{Text: "scratch-pad.txt", Subtext: "2025-01-02 • 736 B"},
|
||||
{Text: "old-notes.txt", Subtext: "2025-01-01 • 3.1 KB"},
|
||||
{Text: "backup-jan.txt", Subtext: "2024-12-31 • 42 KB"},
|
||||
{Text: "annual-review.txt", Subtext: "2024-12-30 • 9.8 KB"},
|
||||
{Text: "december-log.txt", Subtext: "2024-12-29 • 7.2 KB"},
|
||||
{Text: "random-thoughts.txt", Subtext: "2024-12-28 • 1.5 KB"},
|
||||
{Text: "shopping-list.txt", Subtext: "2024-12-27 • 248 B"},
|
||||
{Text: "travel-ideas.txt", Subtext: "2024-12-26 • 2.3 KB"},
|
||||
{Text: "music-queue.txt", Subtext: "2024-12-25 • 4.7 KB"},
|
||||
{Text: "movie-wishlist.txt", Subtext: "2024-12-24 • 890 B"},
|
||||
{Text: "book-read-list.txt", Subtext: "2024-12-23 • 3.6 KB"},
|
||||
{Text: "podcast-notes.txt", Subtext: "2024-12-22 • 5.1 KB"},
|
||||
{Text: "quotes.txt", Subtext: "2024-12-21 • 6.2 KB"},
|
||||
{Text: "vocab.txt", Subtext: "2024-12-20 • 1.9 KB"},
|
||||
{Text: "word-of-day.txt", Subtext: "2024-12-19 • 384 B"},
|
||||
{Text: "memories.txt", Subtext: "2024-12-18 • 11 KB"},
|
||||
}
|
||||
|
||||
// BrowserLayout computes the element tree for the browser (file listing) page.
|
||||
func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Element {
|
||||
margin := ui.Dp(10)
|
||||
contentWidth := screenWidth - margin*2
|
||||
|
||||
// --- Header bar: directory name + sort toggle (gray background like editor StatusBar) ---
|
||||
headerHeight := ui.Dp(24)
|
||||
headerRegion := ui.Region{
|
||||
X: margin, Y: margin,
|
||||
W: contentWidth, H: headerHeight,
|
||||
}
|
||||
var sortLabel string
|
||||
switch sortMode {
|
||||
case SortByDateDesc:
|
||||
sortLabel = "Date ↓"
|
||||
case SortByDateAsc:
|
||||
sortLabel = "Date ↑"
|
||||
case SortByNameAsc:
|
||||
sortLabel = "Name ↑"
|
||||
case SortByNameDesc:
|
||||
sortLabel = "Name ↓"
|
||||
}
|
||||
headerBar := ui.NewContainer(
|
||||
headerRegion,
|
||||
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
||||
[]ui.Element{
|
||||
ui.NewLabel("My Documents", 16, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignStart, "", nil),
|
||||
ui.NewLabel(sortLabel, 12, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignEnd, "sort",
|
||||
[]ui.Interaction{{Gesture: ui.Tap, Handler: ToggleSortOrder}}),
|
||||
},
|
||||
)
|
||||
|
||||
// --- Search bar ---
|
||||
searchHeight := ui.Dp(36)
|
||||
searchY := headerRegion.Y + headerRegion.H + margin/2
|
||||
searchRegion := ui.Region{
|
||||
X: margin, Y: searchY,
|
||||
W: contentWidth, H: searchHeight,
|
||||
}
|
||||
searchBar := ui.NewGioEditor("search_bar", searchRegion, &TheState.SearchEditor)
|
||||
TheState.SearchEditor.SingleLine = true
|
||||
|
||||
var searchPlaceholder ui.Element
|
||||
if TheState.SearchEditor.Len() == 0 {
|
||||
searchPlaceholder = ui.NewLabel("Search…", 14,
|
||||
ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(8), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(16)},
|
||||
ui.AlignStart, "", nil)
|
||||
}
|
||||
|
||||
// --- ListView ---
|
||||
listY := searchY + searchHeight + margin/2
|
||||
listHeight := screenHeight - listY - margin // fill remaining height
|
||||
listRegion := ui.Region{
|
||||
X: margin, Y: listY,
|
||||
W: contentWidth, H: listHeight,
|
||||
}
|
||||
// Store list height for scroll clamping calculation
|
||||
TheState.BrowserListHeight = listHeight
|
||||
// Filter entries by search query (case-insensitive substring match)
|
||||
filteredEntries := getFilteredEntries()
|
||||
// Compute first visible row from Dp scroll offset
|
||||
rowHeight := ui.Dp(48)
|
||||
firstVisibleRow := int(TheState.BrowserScrollOffset / rowHeight)
|
||||
if firstVisibleRow < 0 {
|
||||
firstVisibleRow = 0
|
||||
}
|
||||
if firstVisibleRow > len(filteredEntries) {
|
||||
firstVisibleRow = len(filteredEntries)
|
||||
}
|
||||
visibleEntries := filteredEntries[firstVisibleRow:]
|
||||
list := ui.NewListView(
|
||||
"browser_list",
|
||||
visibleEntries,
|
||||
listRegion,
|
||||
TheState.BrowserScrollOffset, // Dp, not row index
|
||||
-1, // Selected: none
|
||||
[]ui.Interaction{{Gesture: ui.Scroll, Handler: HandleBrowserScroll}},
|
||||
)
|
||||
// Set row filenames so ListView.Draw can wire up click handlers
|
||||
list.RowFilenames = make([]string, len(visibleEntries))
|
||||
for i, entry := range visibleEntries {
|
||||
list.RowFilenames[i] = entry.Text
|
||||
}
|
||||
|
||||
elems := []ui.Element{
|
||||
headerBar,
|
||||
searchBar,
|
||||
}
|
||||
if searchPlaceholder != nil {
|
||||
elems = append(elems, searchPlaceholder)
|
||||
}
|
||||
elems = append(elems, list)
|
||||
return elems
|
||||
}
|
||||
|
|
|
|||
319
internal/io/pool/context_test.go
Normal file
319
internal/io/pool/context_test.go
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pad/internal/io/pool/mock"
|
||||
)
|
||||
|
||||
// cancelTask is a task that respects context cancellation.
|
||||
type cancelTask struct {
|
||||
id string
|
||||
taskType TaskType
|
||||
priority Priority
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
delay time.Duration
|
||||
called atomic.Bool
|
||||
}
|
||||
|
||||
func newCancelTask(id string, delay time.Duration) *cancelTask {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &cancelTask{
|
||||
id: id,
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
delay: delay,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *cancelTask) Execute() Result {
|
||||
t.called.Store(true)
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
return Result{
|
||||
TaskID: t.id,
|
||||
TaskType: t.taskType,
|
||||
Success: false,
|
||||
Error: t.ctx.Err(),
|
||||
}
|
||||
case <-time.After(t.delay):
|
||||
return Result{
|
||||
TaskID: t.id,
|
||||
TaskType: t.taskType,
|
||||
Success: true,
|
||||
Data: "completed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *cancelTask) Priority() Priority { return t.priority }
|
||||
func (t *cancelTask) TaskID() string { return t.id }
|
||||
func (t *cancelTask) TaskType() TaskType { return t.taskType }
|
||||
func (t *cancelTask) DirPath() string { return "/test" }
|
||||
func (t *cancelTask) Context() context.Context { return t.ctx }
|
||||
func (t *cancelTask) Cancel() { t.cancel() }
|
||||
func (t *cancelTask) Timeout() time.Duration { return 0 }
|
||||
|
||||
// timeoutTask is a task that respects timeout.
|
||||
type timeoutTask struct {
|
||||
id string
|
||||
taskType TaskType
|
||||
priority Priority
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (t *timeoutTask) Execute() Result {
|
||||
time.Sleep(t.delay)
|
||||
return Result{
|
||||
TaskID: t.id,
|
||||
TaskType: t.taskType,
|
||||
Success: true,
|
||||
Data: "completed",
|
||||
}
|
||||
}
|
||||
|
||||
func (t *timeoutTask) Priority() Priority { return t.priority }
|
||||
func (t *timeoutTask) TaskID() string { return t.id }
|
||||
func (t *timeoutTask) TaskType() TaskType { return t.taskType }
|
||||
func (t *timeoutTask) DirPath() string { return "/test" }
|
||||
func (t *timeoutTask) Context() context.Context { return context.Background() }
|
||||
func (t *timeoutTask) Cancel() {}
|
||||
func (t *timeoutTask) Timeout() time.Duration { return 50 * time.Millisecond }
|
||||
|
||||
func TestTaskContextCancellation(t *testing.T) {
|
||||
task := newCancelTask("test-cancel", 1*time.Second)
|
||||
|
||||
// Cancel the task
|
||||
task.Cancel()
|
||||
|
||||
// Execute should return error
|
||||
result := task.Execute()
|
||||
if result.IsSuccess() {
|
||||
t.Error("Task should not succeed after cancellation")
|
||||
}
|
||||
if result.Error == nil {
|
||||
t.Error("Task should have error after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskTimeoutMethod(t *testing.T) {
|
||||
task := &timeoutTask{id: "test-timeout"}
|
||||
if task.Timeout() != 50*time.Millisecond {
|
||||
t.Errorf("Timeout = %v, want %v", task.Timeout(), 50*time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ContextCancellation(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Create a task that will be cancelled
|
||||
task := newCancelTask("cancel-test", 500*time.Millisecond)
|
||||
|
||||
// Dispatch the task
|
||||
pool.Dispatch(task)
|
||||
|
||||
// Cancel after a short delay
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
task.Cancel()
|
||||
|
||||
// Wait for result
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
// Should complete with error due to cancellation
|
||||
if result.IsSuccess() {
|
||||
t.Log("Task completed before cancellation - acceptable")
|
||||
} else {
|
||||
t.Logf("Task cancelled as expected: %v", result.Error)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Timed out waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_TaskTimeout(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Create a task with a short timeout that will sleep longer
|
||||
task := &timeoutTask{
|
||||
id: "timeout-test",
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
delay: 5 * time.Second, // Much longer than timeout
|
||||
}
|
||||
|
||||
pool.Dispatch(task)
|
||||
|
||||
// Should complete with timeout error
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if result.IsSuccess() {
|
||||
t.Error("Task should have timed out")
|
||||
}
|
||||
if result.Error == nil {
|
||||
t.Error("Task should have error after timeout")
|
||||
}
|
||||
t.Logf("Task timed out as expected: %v", result.Error)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_CancelByDirPath(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Create tasks for different directories
|
||||
task1 := newCancelTask("dir1-task", 500*time.Millisecond)
|
||||
task2 := newCancelTask("dir2-task", 500*time.Millisecond)
|
||||
|
||||
// Set DirPath for task1
|
||||
// Note: We need to modify the task to return specific DirPath
|
||||
// For now, we'll test the CancelPendingTasks method
|
||||
|
||||
pool.Dispatch(task1)
|
||||
pool.Dispatch(task2)
|
||||
|
||||
// Cancel tasks for "/test" directory (both tasks use this)
|
||||
pool.CancelPendingTasks("/test")
|
||||
|
||||
// Wait for results
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
t.Logf("Got result for task %s: success=%v, error=%v", result.TaskID, result.IsSuccess(), result.Error)
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatalf("Timed out waiting for result %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_CancelPendingTasks_NoEffect(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Cancel tasks for non-existent directory
|
||||
pool.CancelPendingTasks("/nonexistent")
|
||||
|
||||
// Should not panic or cause issues
|
||||
if !pool.IsRunning() {
|
||||
t.Error("Pool should still be running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ContextPropagation(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Create a task that uses context
|
||||
task := newCancelTask("ctx-prop", 100*time.Millisecond)
|
||||
|
||||
pool.Dispatch(task)
|
||||
|
||||
// Wait for result
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("Task failed: %v", result.Error)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Timed out waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_DirPathInResult(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.CreateDir("/test/dir1")
|
||||
fs.CreateFile("/test/dir1/file.txt", []byte("content"))
|
||||
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Create a read dir task
|
||||
task := NewReadDirTask("/test/dir1", fs)
|
||||
pool.Dispatch(task)
|
||||
|
||||
// Wait for result
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("Task failed: %v", result.Error)
|
||||
}
|
||||
// DirPath should be in the result
|
||||
if result.DirPath != "/test/dir1" {
|
||||
t.Errorf("Result DirPath = %q, want %q", result.DirPath, "/test/dir1")
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Timed out waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ConcurrentCancellation(t *testing.T) {
|
||||
pool := NewWorkerPool(4)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Create multiple tasks
|
||||
var tasks []*cancelTask
|
||||
for i := 0; i < 10; i++ {
|
||||
task := newCancelTask(fmt.Sprintf("concurrent-%d", i), 1*time.Second)
|
||||
tasks = append(tasks, task)
|
||||
pool.Dispatch(task)
|
||||
}
|
||||
|
||||
// Cancel half of them
|
||||
for i := 0; i < 5; i++ {
|
||||
tasks[i].Cancel()
|
||||
}
|
||||
|
||||
// Wait for all results
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
// Results may be success or error depending on timing
|
||||
t.Logf("Result for %s: success=%v", result.TaskID, result.IsSuccess())
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Timed out waiting for result %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_TimeoutWithRealTasks(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.SetDelay(100 * time.Millisecond)
|
||||
fs.CreateFile("/test/timeout.txt", []byte("content"))
|
||||
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Create a task with short timeout
|
||||
task := NewReadFileTask("/test/timeout.txt", fs)
|
||||
|
||||
// Note: Real tasks don't have custom timeout yet
|
||||
// This test verifies that the timeout mechanism doesn't break existing functionality
|
||||
pool.Dispatch(task)
|
||||
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("Task failed: %v", result.Error)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for result")
|
||||
}
|
||||
}
|
||||
429
internal/io/pool/mock/filesystem.go
Normal file
429
internal/io/pool/mock/filesystem.go
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
// Package mock provides a configurable mock filesystem for testing
|
||||
// the IO worker pool without touching the real disk.
|
||||
package mock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// File represents a file or directory in the mock filesystem.
|
||||
type File struct {
|
||||
Path string
|
||||
Name string
|
||||
Content []byte
|
||||
ModTime time.Time
|
||||
Size int64
|
||||
IsDir bool
|
||||
}
|
||||
|
||||
// DirEntry mimics fs.DirEntry behavior for the mock.
|
||||
type DirEntry struct {
|
||||
name string
|
||||
isDir bool
|
||||
modTime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
func (d DirEntry) Name() string { return d.name }
|
||||
func (d DirEntry) IsDir() bool { return d.isDir }
|
||||
func (d DirEntry) Info() (os.FileInfo, error) {
|
||||
return &mockFileInfo{name: d.name, isDir: d.isDir, modTime: d.modTime, size: d.size}, nil
|
||||
}
|
||||
|
||||
// mockFileInfo implements os.FileInfo for the mock.
|
||||
type mockFileInfo struct {
|
||||
name string
|
||||
isDir bool
|
||||
modTime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
func (fi *mockFileInfo) Name() string { return fi.name }
|
||||
func (fi *mockFileInfo) Size() int64 { return fi.size }
|
||||
func (fi *mockFileInfo) Mode() os.FileMode { return 0644 }
|
||||
func (fi *mockFileInfo) ModTime() time.Time { return fi.modTime }
|
||||
func (fi *mockFileInfo) IsDir() bool { return fi.isDir }
|
||||
func (fi *mockFileInfo) Sys() any { return nil }
|
||||
|
||||
// FileChangeEvent represents an external filesystem change.
|
||||
type FileChangeEvent struct {
|
||||
Path string
|
||||
EventType string // "Created", "Modified", "Deleted"
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// notifyEvent is a pending notification collected while holding the lock.
|
||||
type notifyEvent struct {
|
||||
path string
|
||||
eventType string
|
||||
}
|
||||
|
||||
// FileSystem is a thread-safe in-memory filesystem for testing.
|
||||
type FileSystem struct {
|
||||
mu sync.RWMutex
|
||||
files map[string]*File // path -> file
|
||||
delay time.Duration // uniform delay applied to all operations
|
||||
changes chan FileChangeEvent // async change notifications (nil = no notifications)
|
||||
}
|
||||
|
||||
// NewFileSystem creates an empty mock filesystem.
|
||||
func NewFileSystem() *FileSystem {
|
||||
return &FileSystem{
|
||||
files: make(map[string]*File),
|
||||
}
|
||||
}
|
||||
|
||||
// SetDelay sets a uniform delay applied to all IO operations.
|
||||
func (fs *FileSystem) SetDelay(d time.Duration) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.delay = d
|
||||
}
|
||||
|
||||
// SetChangeEvents sets the channel for async change notifications.
|
||||
// Pass nil to disable notifications.
|
||||
func (fs *FileSystem) SetChangeEvents(ch chan FileChangeEvent) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.changes = ch
|
||||
}
|
||||
|
||||
// sendNotifications sends any pending notification events.
|
||||
// Caller must NOT hold the mutex.
|
||||
func (fs *FileSystem) sendNotifications(events []notifyEvent) {
|
||||
fs.mu.RLock()
|
||||
ch := fs.changes
|
||||
fs.mu.RUnlock()
|
||||
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, e := range events {
|
||||
select {
|
||||
case ch <- FileChangeEvent{
|
||||
Path: e.path,
|
||||
EventType: e.eventType,
|
||||
Time: time.Now(),
|
||||
}:
|
||||
default:
|
||||
// Drop event if channel is full (non-blocking)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFile creates a file with the given content at the specified path.
|
||||
func (fs *FileSystem) CreateFile(path string, content []byte) error {
|
||||
fs.mu.Lock()
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
time.Sleep(fs.delay)
|
||||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
name := filepath.Base(path)
|
||||
now := time.Now()
|
||||
f := &File{
|
||||
Path: path,
|
||||
Name: name,
|
||||
Content: content,
|
||||
ModTime: now,
|
||||
Size: int64(len(content)),
|
||||
IsDir: false,
|
||||
}
|
||||
fs.files[path] = f
|
||||
|
||||
// Collect notification while holding the lock
|
||||
var events []notifyEvent
|
||||
if fs.changes != nil {
|
||||
events = append(events, notifyEvent{path, "Created"})
|
||||
}
|
||||
|
||||
fs.mu.Unlock()
|
||||
|
||||
// Send notifications outside the lock (avoids deadlock)
|
||||
fs.sendNotifications(events)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadFile returns the content of a file at the given path.
|
||||
func (fs *FileSystem) ReadFile(path string) ([]byte, error) {
|
||||
fs.mu.Lock()
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
time.Sleep(fs.delay)
|
||||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
f, ok := fs.files[path]
|
||||
if !ok {
|
||||
fs.mu.Unlock()
|
||||
return nil, fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
content := append([]byte(nil), f.Content...)
|
||||
fs.mu.Unlock()
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// WriteFile replaces the content of a file at the given path.
|
||||
func (fs *FileSystem) WriteFile(path string, content []byte) error {
|
||||
fs.mu.Lock()
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
time.Sleep(fs.delay)
|
||||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
f, ok := fs.files[path]
|
||||
if !ok {
|
||||
fs.mu.Unlock()
|
||||
return fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
|
||||
f.Content = content
|
||||
f.Size = int64(len(content))
|
||||
f.ModTime = time.Now()
|
||||
|
||||
var events []notifyEvent
|
||||
if fs.changes != nil {
|
||||
events = append(events, notifyEvent{path, "Modified"})
|
||||
}
|
||||
|
||||
fs.mu.Unlock()
|
||||
fs.sendNotifications(events)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFile removes a file at the given path.
|
||||
func (fs *FileSystem) DeleteFile(path string) error {
|
||||
fs.mu.Lock()
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
time.Sleep(fs.delay)
|
||||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
if _, ok := fs.files[path]; !ok {
|
||||
fs.mu.Unlock()
|
||||
return fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
|
||||
delete(fs.files, path)
|
||||
|
||||
var events []notifyEvent
|
||||
if fs.changes != nil {
|
||||
events = append(events, notifyEvent{path, "Deleted"})
|
||||
}
|
||||
|
||||
fs.mu.Unlock()
|
||||
fs.sendNotifications(events)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateDir creates a directory at the given path.
|
||||
func (fs *FileSystem) CreateDir(path string) error {
|
||||
fs.mu.Lock()
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
time.Sleep(fs.delay)
|
||||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
name := filepath.Base(path)
|
||||
f := &File{
|
||||
Path: path,
|
||||
Name: name,
|
||||
ModTime: time.Now(),
|
||||
IsDir: true,
|
||||
}
|
||||
fs.files[path] = f
|
||||
|
||||
var events []notifyEvent
|
||||
if fs.changes != nil {
|
||||
events = append(events, notifyEvent{path, "Created"})
|
||||
}
|
||||
|
||||
fs.mu.Unlock()
|
||||
fs.sendNotifications(events)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadDir returns a list of entries in the given directory.
|
||||
func (fs *FileSystem) ReadDir(path string) ([]DirEntry, error) {
|
||||
fs.mu.Lock()
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
time.Sleep(fs.delay)
|
||||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
dirPath := filepath.Clean(path)
|
||||
if _, ok := fs.files[dirPath]; !ok {
|
||||
fs.mu.Unlock()
|
||||
return nil, fmt.Errorf("directory not found: %s", path)
|
||||
}
|
||||
|
||||
var entries []DirEntry
|
||||
|
||||
for _, f := range fs.files {
|
||||
if f.Path == dirPath {
|
||||
continue
|
||||
}
|
||||
parent := filepath.Dir(f.Path)
|
||||
if parent == dirPath {
|
||||
entries = append(entries, DirEntry{
|
||||
name: f.Name,
|
||||
isDir: f.IsDir,
|
||||
modTime: f.ModTime,
|
||||
size: f.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name
|
||||
for i := 0; i < len(entries); i++ {
|
||||
for j := i + 1; j < len(entries); j++ {
|
||||
if entries[i].name > entries[j].name {
|
||||
entries[i], entries[j] = entries[j], entries[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mu.Unlock()
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// DirExists returns true if the path exists and is a directory.
|
||||
func (fs *FileSystem) DirExists(path string) bool {
|
||||
fs.mu.RLock()
|
||||
defer fs.mu.RUnlock()
|
||||
|
||||
f, ok := fs.files[path]
|
||||
return ok && f.IsDir
|
||||
}
|
||||
|
||||
// FileExists returns true if the path exists and is a file.
|
||||
func (fs *FileSystem) FileExists(path string) bool {
|
||||
fs.mu.RLock()
|
||||
defer fs.mu.RUnlock()
|
||||
|
||||
f, ok := fs.files[path]
|
||||
return ok && !f.IsDir
|
||||
}
|
||||
|
||||
// GetFile returns the file at the given path, or nil if not found.
|
||||
func (fs *FileSystem) GetFile(path string) *File {
|
||||
fs.mu.RLock()
|
||||
defer fs.mu.RUnlock()
|
||||
|
||||
return fs.files[path]
|
||||
}
|
||||
|
||||
// SetFileContent atomically replaces the content of an existing file.
|
||||
func (fs *FileSystem) SetFileContent(path string, content []byte) {
|
||||
fs.mu.Lock()
|
||||
|
||||
if fs.delay > 0 {
|
||||
fs.mu.Unlock()
|
||||
time.Sleep(fs.delay)
|
||||
fs.mu.Lock()
|
||||
}
|
||||
|
||||
f, ok := fs.files[path]
|
||||
if !ok {
|
||||
fs.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
f.Content = content
|
||||
f.Size = int64(len(content))
|
||||
f.ModTime = time.Now()
|
||||
|
||||
var events []notifyEvent
|
||||
if fs.changes != nil {
|
||||
events = append(events, notifyEvent{path, "Modified"})
|
||||
}
|
||||
|
||||
fs.mu.Unlock()
|
||||
fs.sendNotifications(events)
|
||||
}
|
||||
|
||||
// AddFile adds a file without triggering change notifications.
|
||||
// Useful for pre-populating the filesystem before tests.
|
||||
func (fs *FileSystem) AddFile(path string, content []byte, modTime time.Time) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
|
||||
name := filepath.Base(path)
|
||||
f := &File{
|
||||
Path: path,
|
||||
Name: name,
|
||||
Content: content,
|
||||
ModTime: modTime,
|
||||
Size: int64(len(content)),
|
||||
IsDir: false,
|
||||
}
|
||||
fs.files[path] = f
|
||||
}
|
||||
|
||||
// AddDir adds a directory without triggering change notifications.
|
||||
func (fs *FileSystem) AddDir(path string, modTime time.Time) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
|
||||
name := filepath.Base(path)
|
||||
f := &File{
|
||||
Path: path,
|
||||
Name: name,
|
||||
ModTime: modTime,
|
||||
IsDir: true,
|
||||
}
|
||||
fs.files[path] = f
|
||||
}
|
||||
|
||||
// RemoveFile removes a file without triggering change notifications.
|
||||
func (fs *FileSystem) RemoveFile(path string) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
|
||||
delete(fs.files, path)
|
||||
}
|
||||
|
||||
// ListPaths returns all file paths in the filesystem, optionally filtered by prefix.
|
||||
func (fs *FileSystem) ListPaths(prefix string) []string {
|
||||
fs.mu.RLock()
|
||||
defer fs.mu.RUnlock()
|
||||
|
||||
var paths []string
|
||||
for path := range fs.files {
|
||||
if prefix == "" || filepath.HasPrefix(path, prefix) {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// Count returns the number of files and directories in the mock filesystem.
|
||||
func (fs *FileSystem) Count() (files int, dirs int) {
|
||||
fs.mu.RLock()
|
||||
defer fs.mu.RUnlock()
|
||||
|
||||
for _, f := range fs.files {
|
||||
if f.IsDir {
|
||||
dirs++
|
||||
} else {
|
||||
files++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
311
internal/io/pool/mock/filesystem_test.go
Normal file
311
internal/io/pool/mock/filesystem_test.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
package mock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFileSystem_CreateFile(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
content := []byte("hello world")
|
||||
|
||||
err := fs.CreateFile("/test/hello.txt", content)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFile failed: %v", err)
|
||||
}
|
||||
|
||||
f := fs.GetFile("/test/hello.txt")
|
||||
if f == nil {
|
||||
t.Fatal("File not found after creation")
|
||||
}
|
||||
if string(f.Content) != "hello world" {
|
||||
t.Errorf("Content = %q, want %q", string(f.Content), "hello world")
|
||||
}
|
||||
if f.Name != "hello.txt" {
|
||||
t.Errorf("Name = %q, want %q", f.Name, "hello.txt")
|
||||
}
|
||||
if f.Size != int64(len(content)) {
|
||||
t.Errorf("Size = %d, want %d", f.Size, len(content))
|
||||
}
|
||||
if f.IsDir {
|
||||
t.Error("IsDir should be false for a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_ReadFile(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateFile("/test/data.txt", []byte("data"))
|
||||
|
||||
content, err := fs.ReadFile("/test/data.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if string(content) != "data" {
|
||||
t.Errorf("ReadFile returned %q, want %q", string(content), "data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_ReadFile_NotFound(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
|
||||
_, err := fs.ReadFile("/nonexistent.txt")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for nonexistent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_WriteFile(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateFile("/test/update.txt", []byte("original"))
|
||||
|
||||
err := fs.WriteFile("/test/update.txt", []byte("updated"))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
content, _ := fs.ReadFile("/test/update.txt")
|
||||
if string(content) != "updated" {
|
||||
t.Errorf("Content = %q, want %q", string(content), "updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_WriteFile_NotFound(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
|
||||
err := fs.WriteFile("/nonexistent.txt", []byte("data"))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for nonexistent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_DeleteFile(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateFile("/test/delete.txt", []byte("to delete"))
|
||||
|
||||
err := fs.DeleteFile("/test/delete.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFile failed: %v", err)
|
||||
}
|
||||
|
||||
if fs.FileExists("/test/delete.txt") {
|
||||
t.Error("File should not exist after deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_DeleteFile_NotFound(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
|
||||
err := fs.DeleteFile("/nonexistent.txt")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for nonexistent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_CreateDir(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
|
||||
err := fs.CreateDir("/test/dir")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDir failed: %v", err)
|
||||
}
|
||||
|
||||
if !fs.DirExists("/test/dir") {
|
||||
t.Error("Dir should exist after creation")
|
||||
}
|
||||
|
||||
f := fs.GetFile("/test/dir")
|
||||
if f == nil {
|
||||
t.Fatal("Directory file not found")
|
||||
}
|
||||
if !f.IsDir {
|
||||
t.Error("IsDir should be true for a directory")
|
||||
}
|
||||
if f.Name != "dir" {
|
||||
t.Errorf("Name = %q, want %q", f.Name, "dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_ReadDir(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateDir("/test/dir")
|
||||
fs.CreateFile("/test/dir/a.txt", []byte("a"))
|
||||
fs.CreateFile("/test/dir/b.txt", []byte("b"))
|
||||
fs.CreateFile("/test/dir/c.txt", []byte("c"))
|
||||
|
||||
entries, err := fs.ReadDir("/test/dir")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir failed: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("Expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Check alphabetical order
|
||||
if entries[0].Name() != "a.txt" {
|
||||
t.Errorf("First entry = %q, want %q", entries[0].Name(), "a.txt")
|
||||
}
|
||||
if entries[1].Name() != "b.txt" {
|
||||
t.Errorf("Second entry = %q, want %q", entries[1].Name(), "b.txt")
|
||||
}
|
||||
if entries[2].Name() != "c.txt" {
|
||||
t.Errorf("Third entry = %q, want %q", entries[2].Name(), "c.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_ReadDir_Empty(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateDir("/test/empty")
|
||||
|
||||
entries, err := fs.ReadDir("/test/empty")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir failed: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("Expected 0 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_FileExists(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateFile("/test/exist.txt", []byte("data"))
|
||||
|
||||
if !fs.FileExists("/test/exist.txt") {
|
||||
t.Error("File should exist")
|
||||
}
|
||||
if fs.FileExists("/test/noexist.txt") {
|
||||
t.Error("Nonexistent file should not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_DirExists(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateDir("/test/dir")
|
||||
|
||||
if !fs.DirExists("/test/dir") {
|
||||
t.Error("Directory should exist")
|
||||
}
|
||||
if fs.DirExists("/test/noexist") {
|
||||
t.Error("Nonexistent directory should not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_Count(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateFile("/test/a.txt", []byte("a"))
|
||||
fs.CreateFile("/test/b.txt", []byte("b"))
|
||||
fs.CreateDir("/test/dir")
|
||||
|
||||
files, dirs := fs.Count()
|
||||
if files != 2 {
|
||||
t.Errorf("Files = %d, want 2", files)
|
||||
}
|
||||
if dirs != 1 {
|
||||
t.Errorf("Dirs = %d, want 1", dirs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_SetDelay(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.SetDelay(50 * time.Millisecond)
|
||||
|
||||
start := time.Now()
|
||||
fs.ReadFile("/nonexistent.txt") // Will fail but delay should still apply
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed < 40*time.Millisecond {
|
||||
t.Errorf("Expected delay ~50ms, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_SetChangeEvents(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
ch := make(chan FileChangeEvent, 10)
|
||||
fs.SetChangeEvents(ch)
|
||||
|
||||
fs.CreateFile("/test/notify.txt", []byte("data"))
|
||||
|
||||
select {
|
||||
case event := <-ch:
|
||||
if event.Path != "/test/notify.txt" {
|
||||
t.Errorf("Event path = %q, want %q", event.Path, "/test/notify.txt")
|
||||
}
|
||||
if event.EventType != "Created" {
|
||||
t.Errorf("Event type = %q, want %q", event.EventType, "Created")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Expected change event, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_SetChangeEvents_NilChannel(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.SetChangeEvents(nil) // Disable notifications
|
||||
|
||||
// Should not panic
|
||||
fs.CreateFile("/test/silent.txt", []byte("data"))
|
||||
}
|
||||
|
||||
func TestFileSystem_AddFile(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
modTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
fs.AddFile("/test/pre.txt", []byte("pre"), modTime)
|
||||
|
||||
f := fs.GetFile("/test/pre.txt")
|
||||
if f == nil {
|
||||
t.Fatal("File not found after AddFile")
|
||||
}
|
||||
if !f.ModTime.Equal(modTime) {
|
||||
t.Errorf("ModTime = %v, want %v", f.ModTime, modTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_RemoveFile(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.AddFile("/test/remove.txt", []byte("data"), time.Now())
|
||||
|
||||
fs.RemoveFile("/test/remove.txt")
|
||||
|
||||
if fs.FileExists("/test/remove.txt") {
|
||||
t.Error("File should not exist after RemoveFile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_ListPaths(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
fs.CreateFile("/test/a.txt", []byte("a"))
|
||||
fs.CreateFile("/test/b.txt", []byte("b"))
|
||||
fs.CreateFile("/other/c.txt", []byte("c"))
|
||||
|
||||
paths := fs.ListPaths("/test/")
|
||||
if len(paths) != 2 {
|
||||
t.Errorf("ListPaths returned %d paths, want 2", len(paths))
|
||||
}
|
||||
|
||||
pathsAll := fs.ListPaths("")
|
||||
if len(pathsAll) != 3 {
|
||||
t.Errorf("ListPaths with empty prefix returned %d paths, want 3", len(pathsAll))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_ConcurrentAccess(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
path := fmt.Sprintf("/test/concurrent_%d.txt", n)
|
||||
fs.CreateFile(path, []byte(fmt.Sprintf("content %d", n)))
|
||||
fs.ReadFile(path)
|
||||
fs.DeleteFile(path)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
73
internal/io/pool/result.go
Normal file
73
internal/io/pool/result.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package pool
|
||||
|
||||
import "time"
|
||||
|
||||
// Result is posted back to the logic goroutine when a task completes.
|
||||
// Results are immutable and contain all information needed to apply
|
||||
// the task outcome to state.
|
||||
type Result struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskType TaskType `json:"task_type"`
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error error `json:"error,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
DirPath string `json:"dir_path,omitempty"`
|
||||
}
|
||||
|
||||
// IsSuccess returns true if the task completed successfully.
|
||||
func (r Result) IsSuccess() bool {
|
||||
return r.Success && r.Error == nil
|
||||
}
|
||||
|
||||
// IsError returns true if the task failed.
|
||||
func (r Result) IsError() bool {
|
||||
return !r.Success || r.Error != nil
|
||||
}
|
||||
|
||||
// IsBrowserResult returns true if this result is for a browser task.
|
||||
func (r Result) IsBrowserResult() bool {
|
||||
switch r.TaskType {
|
||||
case TypeReadDir, TypeBuildIndex, TypeLoadIndex, TypeLoadPages, TypeStatDir:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsFileResult returns true if this result is for a file operation.
|
||||
func (r Result) IsFileResult() bool {
|
||||
switch r.TaskType {
|
||||
case TypeReadFile, TypeWriteFile, TypeStatFile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsCacheResult returns true if this result is for a cache operation.
|
||||
func (r Result) IsCacheResult() bool {
|
||||
switch r.TaskType {
|
||||
case TypeWriteCache, TypeReadCache, TypeInvalidate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsStateResult returns true if this result is for state persistence.
|
||||
func (r Result) IsStateResult() bool {
|
||||
switch r.TaskType {
|
||||
case TypeSaveState, TypeSaveUndo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Duration returns the time taken for this result (for benchmarking).
|
||||
func (r Result) Duration() time.Duration {
|
||||
// In production, this would be set by the worker.
|
||||
// For now, returns zero.
|
||||
return 0
|
||||
}
|
||||
494
internal/io/pool/task.go
Normal file
494
internal/io/pool/task.go
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
// Package pool provides the IO worker pool for the Pad editor.
|
||||
// It follows the architecture's single-owner pattern: the logic goroutine
|
||||
// owns all state, workers perform IO and post results, never touching state.
|
||||
package pool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"pad/internal/io/pool/mock"
|
||||
)
|
||||
|
||||
// taskCounter generates unique task IDs.
|
||||
var taskCounter atomic.Int64
|
||||
|
||||
// Priority determines which channel a task is dispatched to.
|
||||
type Priority int
|
||||
|
||||
const (
|
||||
HighPriority Priority = iota // UI-critical, blocks user progress
|
||||
LowPriority // Background work, can be delayed
|
||||
)
|
||||
|
||||
func (p Priority) String() string {
|
||||
switch p {
|
||||
case HighPriority:
|
||||
return "high"
|
||||
case LowPriority:
|
||||
return "low"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TaskType identifies the kind of work for result routing.
|
||||
type TaskType string
|
||||
|
||||
const (
|
||||
// Browser tasks
|
||||
TypeReadDir TaskType = "read_dir"
|
||||
TypeBuildIndex TaskType = "build_index"
|
||||
TypeLoadIndex TaskType = "load_index"
|
||||
TypeLoadPages TaskType = "load_pages"
|
||||
TypeStatDir TaskType = "stat_dir"
|
||||
|
||||
// File tasks
|
||||
TypeReadFile TaskType = "read_file"
|
||||
TypeWriteFile TaskType = "write_file"
|
||||
TypeStatFile TaskType = "stat_file"
|
||||
|
||||
// Cache tasks
|
||||
TypeWriteCache TaskType = "write_cache"
|
||||
TypeReadCache TaskType = "read_cache"
|
||||
TypeInvalidate TaskType = "invalidate_cache"
|
||||
|
||||
// State persistence
|
||||
TypeSaveState TaskType = "save_state"
|
||||
TypeSaveUndo TaskType = "save_undo"
|
||||
)
|
||||
|
||||
// Task represents a unit of work to be executed by a worker.
|
||||
// Tasks are immutable after creation.
|
||||
type Task interface {
|
||||
// Execute performs the task and returns a result.
|
||||
Execute() Result
|
||||
|
||||
// Priority returns the task priority level.
|
||||
Priority() Priority
|
||||
|
||||
// TaskID returns a unique identifier for this task.
|
||||
TaskID() string
|
||||
|
||||
// TaskType returns the type of task (for result routing).
|
||||
TaskType() TaskType
|
||||
|
||||
// DirPath returns the directory this task operates on, if any.
|
||||
DirPath() string
|
||||
|
||||
// Context returns the context for this task, used for cancellation.
|
||||
Context() context.Context
|
||||
|
||||
// Cancel cancels this task if it's still running.
|
||||
Cancel()
|
||||
|
||||
// Timeout returns the timeout for this task. Zero means no timeout.
|
||||
Timeout() time.Duration
|
||||
}
|
||||
|
||||
// --- Browser Tasks ---
|
||||
|
||||
// ReadDirTask reads directory entries from the filesystem.
|
||||
type ReadDirTask struct {
|
||||
taskID string
|
||||
Dir string
|
||||
FS *mock.FileSystem
|
||||
}
|
||||
|
||||
func NewReadDirTask(dir string, fs *mock.FileSystem) *ReadDirTask {
|
||||
return &ReadDirTask{
|
||||
taskID: fmt.Sprintf("read_dir_%s_%d", filepath.Base(dir), taskCounter.Add(1)),
|
||||
Dir: dir,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReadDirTask) Execute() Result {
|
||||
entries, err := t.FS.ReadDir(t.Dir)
|
||||
if err != nil {
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeReadDir,
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeReadDir,
|
||||
Success: true,
|
||||
Data: entries,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReadDirTask) Priority() Priority { return HighPriority }
|
||||
func (t *ReadDirTask) TaskID() string { return t.taskID }
|
||||
func (t *ReadDirTask) TaskType() TaskType { return TypeReadDir }
|
||||
func (t *ReadDirTask) DirPath() string { return t.Dir }
|
||||
func (t *ReadDirTask) Context() context.Context { return context.Background() }
|
||||
func (t *ReadDirTask) Cancel() {}
|
||||
func (t *ReadDirTask) Timeout() time.Duration { return 5 * time.Second }
|
||||
|
||||
// BuildIndexTask builds a directory index and writes it to cache.
|
||||
type BuildIndexTask struct {
|
||||
taskID string
|
||||
Dir string
|
||||
FS *mock.FileSystem
|
||||
}
|
||||
|
||||
func NewBuildIndexTask(dir string, fs *mock.FileSystem) *BuildIndexTask {
|
||||
return &BuildIndexTask{
|
||||
taskID: fmt.Sprintf("build_index_%s_%d", filepath.Base(dir), taskCounter.Add(1)),
|
||||
Dir: dir,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BuildIndexTask) Execute() Result {
|
||||
// In production, this would read the directory, sort entries,
|
||||
// compute letter offsets, and write to cache.
|
||||
// For now, we just signal success with the directory info.
|
||||
entries, err := t.FS.ReadDir(t.Dir)
|
||||
if err != nil {
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeBuildIndex,
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeBuildIndex,
|
||||
Success: true,
|
||||
Data: entries,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BuildIndexTask) Priority() Priority { return HighPriority }
|
||||
func (t *BuildIndexTask) TaskID() string { return t.taskID }
|
||||
func (t *BuildIndexTask) TaskType() TaskType { return TypeBuildIndex }
|
||||
func (t *BuildIndexTask) DirPath() string { return t.Dir }
|
||||
func (t *BuildIndexTask) Context() context.Context { return context.Background() }
|
||||
func (t *BuildIndexTask) Cancel() {}
|
||||
func (t *BuildIndexTask) Timeout() time.Duration { return 5 * time.Second }
|
||||
|
||||
// LoadIndexTask loads a cached directory index.
|
||||
type LoadIndexTask struct {
|
||||
taskID string
|
||||
Dir string
|
||||
FS *mock.FileSystem
|
||||
}
|
||||
|
||||
func NewLoadIndexTask(dir string, fs *mock.FileSystem) *LoadIndexTask {
|
||||
return &LoadIndexTask{
|
||||
taskID: fmt.Sprintf("load_index_%s_%d", filepath.Base(dir), taskCounter.Add(1)),
|
||||
Dir: dir,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *LoadIndexTask) Execute() Result {
|
||||
// In production, this would read the index from cache.
|
||||
// For now, we just signal success.
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeLoadIndex,
|
||||
Success: true,
|
||||
Data: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *LoadIndexTask) Priority() Priority { return HighPriority }
|
||||
func (t *LoadIndexTask) TaskID() string { return t.taskID }
|
||||
func (t *LoadIndexTask) TaskType() TaskType { return TypeLoadIndex }
|
||||
func (t *LoadIndexTask) DirPath() string { return t.Dir }
|
||||
func (t *LoadIndexTask) Context() context.Context { return context.Background() }
|
||||
func (t *LoadIndexTask) Cancel() {}
|
||||
func (t *LoadIndexTask) Timeout() time.Duration { return 5 * time.Second }
|
||||
|
||||
// LoadPagesTask loads specific pages of directory entries from cache.
|
||||
type LoadPagesTask struct {
|
||||
taskID string
|
||||
Dir string
|
||||
PageIndices []int
|
||||
FS *mock.FileSystem
|
||||
}
|
||||
|
||||
func NewLoadPagesTask(dir string, pageIndices []int, fs *mock.FileSystem) *LoadPagesTask {
|
||||
return &LoadPagesTask{
|
||||
taskID: fmt.Sprintf("load_pages_%s_%d", filepath.Base(dir), taskCounter.Add(1)),
|
||||
Dir: dir,
|
||||
PageIndices: pageIndices,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *LoadPagesTask) Execute() Result {
|
||||
// In production, this would read pages from cache.
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeLoadPages,
|
||||
Success: true,
|
||||
Data: t.PageIndices,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *LoadPagesTask) Priority() Priority { return HighPriority }
|
||||
func (t *LoadPagesTask) TaskID() string { return t.taskID }
|
||||
func (t *LoadPagesTask) TaskType() TaskType { return TypeLoadPages }
|
||||
func (t *LoadPagesTask) DirPath() string { return t.Dir }
|
||||
func (t *LoadPagesTask) Context() context.Context { return context.Background() }
|
||||
func (t *LoadPagesTask) Cancel() {}
|
||||
func (t *LoadPagesTask) Timeout() time.Duration { return 5 * time.Second }
|
||||
|
||||
// StatDirTask gets directory metadata.
|
||||
type StatDirTask struct {
|
||||
taskID string
|
||||
Dir string
|
||||
FS *mock.FileSystem
|
||||
}
|
||||
|
||||
func NewStatDirTask(dir string, fs *mock.FileSystem) *StatDirTask {
|
||||
return &StatDirTask{
|
||||
taskID: fmt.Sprintf("stat_dir_%s_%d", filepath.Base(dir), taskCounter.Add(1)),
|
||||
Dir: dir,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *StatDirTask) Execute() Result {
|
||||
exists := t.FS.DirExists(t.Dir)
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeStatDir,
|
||||
Success: true,
|
||||
Data: map[string]bool{"exists": exists},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *StatDirTask) Priority() Priority { return LowPriority }
|
||||
func (t *StatDirTask) TaskID() string { return t.taskID }
|
||||
func (t *StatDirTask) TaskType() TaskType { return TypeStatDir }
|
||||
func (t *StatDirTask) DirPath() string { return t.Dir }
|
||||
func (t *StatDirTask) Context() context.Context { return context.Background() }
|
||||
func (t *StatDirTask) Cancel() {}
|
||||
func (t *StatDirTask) Timeout() time.Duration { return 30 * time.Second }
|
||||
|
||||
// --- File Tasks ---
|
||||
|
||||
// ReadFileTask reads file content.
|
||||
type ReadFileTask struct {
|
||||
taskID string
|
||||
Path string
|
||||
FS *mock.FileSystem
|
||||
}
|
||||
|
||||
func NewReadFileTask(path string, fs *mock.FileSystem) *ReadFileTask {
|
||||
return &ReadFileTask{
|
||||
taskID: fmt.Sprintf("read_file_%s_%d", filepath.Base(path), taskCounter.Add(1)),
|
||||
Path: path,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReadFileTask) Execute() Result {
|
||||
content, err := t.FS.ReadFile(t.Path)
|
||||
if err != nil {
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeReadFile,
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeReadFile,
|
||||
Success: true,
|
||||
Data: content,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReadFileTask) Priority() Priority { return HighPriority }
|
||||
func (t *ReadFileTask) TaskID() string { return t.taskID }
|
||||
func (t *ReadFileTask) TaskType() TaskType { return TypeReadFile }
|
||||
func (t *ReadFileTask) DirPath() string { return filepath.Dir(t.Path) }
|
||||
func (t *ReadFileTask) Context() context.Context { return context.Background() }
|
||||
func (t *ReadFileTask) Cancel() {}
|
||||
func (t *ReadFileTask) Timeout() time.Duration { return 5 * time.Second }
|
||||
|
||||
// WriteFileTask writes file content (auto-save).
|
||||
type WriteFileTask struct {
|
||||
taskID string
|
||||
Path string
|
||||
FS *mock.FileSystem
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func NewWriteFileTask(path string, data []byte, fs *mock.FileSystem) *WriteFileTask {
|
||||
return &WriteFileTask{
|
||||
taskID: fmt.Sprintf("write_file_%s_%d", filepath.Base(path), taskCounter.Add(1)),
|
||||
Path: path,
|
||||
FS: fs,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WriteFileTask) Execute() Result {
|
||||
err := t.FS.WriteFile(t.Path, t.Data)
|
||||
if err != nil {
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeWriteFile,
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeWriteFile,
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WriteFileTask) Priority() Priority { return LowPriority }
|
||||
func (t *WriteFileTask) TaskID() string { return t.taskID }
|
||||
func (t *WriteFileTask) TaskType() TaskType { return TypeWriteFile }
|
||||
func (t *WriteFileTask) DirPath() string { return filepath.Dir(t.Path) }
|
||||
func (t *WriteFileTask) Context() context.Context { return context.Background() }
|
||||
func (t *WriteFileTask) Cancel() {}
|
||||
func (t *WriteFileTask) Timeout() time.Duration { return 30 * time.Second }
|
||||
|
||||
// --- Cache Tasks ---
|
||||
|
||||
// WriteCacheTask writes cache data.
|
||||
type WriteCacheTask struct {
|
||||
taskID string
|
||||
Path string
|
||||
FS *mock.FileSystem
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func NewWriteCacheTask(path string, data []byte, fs *mock.FileSystem) *WriteCacheTask {
|
||||
return &WriteCacheTask{
|
||||
taskID: fmt.Sprintf("write_cache_%s_%d", filepath.Base(path), taskCounter.Add(1)),
|
||||
Path: path,
|
||||
FS: fs,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WriteCacheTask) Execute() Result {
|
||||
err := t.FS.WriteFile(t.Path, t.Data)
|
||||
if err != nil {
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeWriteCache,
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeWriteCache,
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WriteCacheTask) Priority() Priority { return LowPriority }
|
||||
func (t *WriteCacheTask) TaskID() string { return t.taskID }
|
||||
func (t *WriteCacheTask) TaskType() TaskType { return TypeWriteCache }
|
||||
func (t *WriteCacheTask) DirPath() string { return filepath.Dir(t.Path) }
|
||||
func (t *WriteCacheTask) Context() context.Context { return context.Background() }
|
||||
func (t *WriteCacheTask) Cancel() {}
|
||||
func (t *WriteCacheTask) Timeout() time.Duration { return 30 * time.Second }
|
||||
|
||||
// --- State Persistence Tasks ---
|
||||
|
||||
// SaveStateTask persists application state.
|
||||
type SaveStateTask struct {
|
||||
taskID string
|
||||
Path string
|
||||
FS *mock.FileSystem
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func NewSaveStateTask(path string, data []byte, fs *mock.FileSystem) *SaveStateTask {
|
||||
return &SaveStateTask{
|
||||
taskID: fmt.Sprintf("save_state_%s_%d", filepath.Base(path), taskCounter.Add(1)),
|
||||
Path: path,
|
||||
FS: fs,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SaveStateTask) Execute() Result {
|
||||
err := t.FS.WriteFile(t.Path, t.Data)
|
||||
if err != nil {
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeSaveState,
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeSaveState,
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SaveStateTask) Priority() Priority { return LowPriority }
|
||||
func (t *SaveStateTask) TaskID() string { return t.taskID }
|
||||
func (t *SaveStateTask) TaskType() TaskType { return TypeSaveState }
|
||||
func (t *SaveStateTask) DirPath() string { return filepath.Dir(t.Path) }
|
||||
func (t *SaveStateTask) Context() context.Context { return context.Background() }
|
||||
func (t *SaveStateTask) Cancel() {}
|
||||
func (t *SaveStateTask) Timeout() time.Duration { return 30 * time.Second }
|
||||
|
||||
// SaveUndoTask persists undo stack.
|
||||
type SaveUndoTask struct {
|
||||
taskID string
|
||||
Path string
|
||||
FS *mock.FileSystem
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func NewSaveUndoTask(path string, data []byte, fs *mock.FileSystem) *SaveUndoTask {
|
||||
return &SaveUndoTask{
|
||||
taskID: fmt.Sprintf("save_undo_%s_%d", filepath.Base(path), taskCounter.Add(1)),
|
||||
Path: path,
|
||||
FS: fs,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SaveUndoTask) Execute() Result {
|
||||
err := t.FS.WriteFile(t.Path, t.Data)
|
||||
if err != nil {
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeSaveUndo,
|
||||
Success: false,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
TaskID: t.taskID,
|
||||
TaskType: TypeSaveUndo,
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SaveUndoTask) Priority() Priority { return LowPriority }
|
||||
func (t *SaveUndoTask) TaskID() string { return t.taskID }
|
||||
func (t *SaveUndoTask) TaskType() TaskType { return TypeSaveUndo }
|
||||
func (t *SaveUndoTask) DirPath() string { return filepath.Dir(t.Path) }
|
||||
func (t *SaveUndoTask) Context() context.Context { return context.Background() }
|
||||
func (t *SaveUndoTask) Cancel() {}
|
||||
func (t *SaveUndoTask) Timeout() time.Duration { return 30 * time.Second }
|
||||
406
internal/io/pool/task_test.go
Normal file
406
internal/io/pool/task_test.go
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pad/internal/io/pool/mock"
|
||||
)
|
||||
|
||||
func TestReadDirTask_Execute_Success(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.CreateDir("/test/dir")
|
||||
fs.CreateFile("/test/dir/a.txt", []byte("a"))
|
||||
fs.CreateFile("/test/dir/b.txt", []byte("b"))
|
||||
|
||||
task := NewReadDirTask("/test/dir", fs)
|
||||
result := task.Execute()
|
||||
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success, got error: %v", result.Error)
|
||||
}
|
||||
if result.TaskType != TypeReadDir {
|
||||
t.Errorf("TaskType = %v, want %v", result.TaskType, TypeReadDir)
|
||||
}
|
||||
if result.TaskID == "" {
|
||||
t.Error("TaskID should not be empty")
|
||||
}
|
||||
|
||||
entries, ok := result.Data.([]mock.DirEntry)
|
||||
if !ok {
|
||||
t.Fatalf("Data is not []mock.DirEntry, got %T", result.Data)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirTask_Execute_NotFound(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
|
||||
task := NewReadDirTask("/nonexistent", fs)
|
||||
result := task.Execute()
|
||||
|
||||
if result.IsSuccess() {
|
||||
t.Fatal("Expected failure for nonexistent directory")
|
||||
}
|
||||
if result.Error == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIndexTask_Execute(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.CreateDir("/test/dir")
|
||||
fs.CreateFile("/test/dir/file.txt", []byte("content"))
|
||||
|
||||
task := NewBuildIndexTask("/test/dir", fs)
|
||||
result := task.Execute()
|
||||
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success, got error: %v", result.Error)
|
||||
}
|
||||
if result.TaskType != TypeBuildIndex {
|
||||
t.Errorf("TaskType = %v, want %v", result.TaskType, TypeBuildIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPagesTask_Execute(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
|
||||
task := NewLoadPagesTask("/test/dir", []int{0, 1, 2}, fs)
|
||||
result := task.Execute()
|
||||
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success, got error: %v", result.Error)
|
||||
}
|
||||
if result.TaskType != TypeLoadPages {
|
||||
t.Errorf("TaskType = %v, want %v", result.TaskType, TypeLoadPages)
|
||||
}
|
||||
|
||||
pages, ok := result.Data.([]int)
|
||||
if !ok {
|
||||
t.Fatalf("Data is not []int, got %T", result.Data)
|
||||
}
|
||||
if len(pages) != 3 {
|
||||
t.Errorf("Expected 3 pages, got %d", len(pages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileTask_Execute_Success(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.CreateFile("/test/file.txt", []byte("hello world"))
|
||||
|
||||
task := NewReadFileTask("/test/file.txt", fs)
|
||||
result := task.Execute()
|
||||
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success, got error: %v", result.Error)
|
||||
}
|
||||
if result.TaskType != TypeReadFile {
|
||||
t.Errorf("TaskType = %v, want %v", result.TaskType, TypeReadFile)
|
||||
}
|
||||
|
||||
content, ok := result.Data.([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("Data is not []byte, got %T", result.Data)
|
||||
}
|
||||
if string(content) != "hello world" {
|
||||
t.Errorf("Content = %q, want %q", string(content), "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileTask_Execute_NotFound(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
|
||||
task := NewReadFileTask("/nonexistent.txt", fs)
|
||||
result := task.Execute()
|
||||
|
||||
if result.IsSuccess() {
|
||||
t.Fatal("Expected failure for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileTask_Execute(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.CreateFile("/test/file.txt", []byte("original"))
|
||||
|
||||
task := NewWriteFileTask("/test/file.txt", []byte("updated"), fs)
|
||||
result := task.Execute()
|
||||
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success, got error: %v", result.Error)
|
||||
}
|
||||
if result.TaskType != TypeWriteFile {
|
||||
t.Errorf("TaskType = %v, want %v", result.TaskType, TypeWriteFile)
|
||||
}
|
||||
|
||||
// Verify file was actually written
|
||||
content, err := fs.ReadFile("/test/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if string(content) != "updated" {
|
||||
t.Errorf("File content = %q, want %q", string(content), "updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileTask_Execute_NotFound(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
|
||||
task := NewWriteFileTask("/nonexistent.txt", []byte("data"), fs)
|
||||
result := task.Execute()
|
||||
|
||||
if result.IsSuccess() {
|
||||
t.Fatal("Expected failure for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStateTask_Execute(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.CreateFile("/test/state.json", []byte("{}"))
|
||||
|
||||
task := NewSaveStateTask("/test/state.json", []byte(`{"key":"value"}`), fs)
|
||||
result := task.Execute()
|
||||
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success, got error: %v", result.Error)
|
||||
}
|
||||
if result.TaskType != TypeSaveState {
|
||||
t.Errorf("TaskType = %v, want %v", result.TaskType, TypeSaveState)
|
||||
}
|
||||
|
||||
// Verify state was saved
|
||||
content, err := fs.ReadFile("/test/state.json")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if string(content) != `{"key":"value"}` {
|
||||
t.Errorf("State content = %q, want %q", string(content), `{"key":"value"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUndoTask_Execute(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.CreateFile("/test/undo.json", []byte("[]"))
|
||||
|
||||
task := NewSaveUndoTask("/test/undo.json", []byte(`[{"type":"insert"}]`), fs)
|
||||
result := task.Execute()
|
||||
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success, got error: %v", result.Error)
|
||||
}
|
||||
if result.TaskType != TypeSaveUndo {
|
||||
t.Errorf("TaskType = %v, want %v", result.TaskType, TypeSaveUndo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_Priority_ReadDir(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
task := NewReadDirTask("/test", fs)
|
||||
if task.Priority() != HighPriority {
|
||||
t.Errorf("Priority = %v, want %v", task.Priority(), HighPriority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_Priority_BuildIndex(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
task := NewBuildIndexTask("/test", fs)
|
||||
if task.Priority() != HighPriority {
|
||||
t.Errorf("Priority = %v, want %v", task.Priority(), HighPriority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_Priority_LoadPages(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
task := NewLoadPagesTask("/test", []int{0}, fs)
|
||||
if task.Priority() != HighPriority {
|
||||
t.Errorf("Priority = %v, want %v", task.Priority(), HighPriority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_Priority_ReadFile(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
task := NewReadFileTask("/test/file.txt", fs)
|
||||
if task.Priority() != HighPriority {
|
||||
t.Errorf("Priority = %v, want %v", task.Priority(), HighPriority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_Priority_WriteFile(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
task := NewWriteFileTask("/test/file.txt", []byte("data"), fs)
|
||||
if task.Priority() != LowPriority {
|
||||
t.Errorf("Priority = %v, want %v", task.Priority(), LowPriority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_Priority_SaveState(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
task := NewSaveStateTask("/test/state.json", []byte("{}"), fs)
|
||||
if task.Priority() != LowPriority {
|
||||
t.Errorf("Priority = %v, want %v", task.Priority(), LowPriority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_Priority_SaveUndo(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
task := NewSaveUndoTask("/test/undo.json", []byte("[]"), fs)
|
||||
if task.Priority() != LowPriority {
|
||||
t.Errorf("Priority = %v, want %v", task.Priority(), LowPriority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_DirPath(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
|
||||
task := NewReadDirTask("/test/dir", fs)
|
||||
if task.DirPath() != "/test/dir" {
|
||||
t.Errorf("DirPath = %q, want %q", task.DirPath(), "/test/dir")
|
||||
}
|
||||
|
||||
task2 := NewReadFileTask("/test/dir/file.txt", fs)
|
||||
if task2.DirPath() != "/test/dir" {
|
||||
t.Errorf("DirPath = %q, want %q", task2.DirPath(), "/test/dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_TaskID_Unique(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
|
||||
task1 := NewReadDirTask("/test/dir", fs)
|
||||
task2 := NewReadDirTask("/test/dir", fs)
|
||||
|
||||
if task1.TaskID() == task2.TaskID() {
|
||||
t.Error("Task IDs should be unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_TaskType_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
taskType TaskType
|
||||
want string
|
||||
}{
|
||||
{TypeReadDir, "read_dir"},
|
||||
{TypeBuildIndex, "build_index"},
|
||||
{TypeLoadIndex, "load_index"},
|
||||
{TypeLoadPages, "load_pages"},
|
||||
{TypeStatDir, "stat_dir"},
|
||||
{TypeReadFile, "read_file"},
|
||||
{TypeWriteFile, "write_file"},
|
||||
{TypeStatFile, "stat_file"},
|
||||
{TypeWriteCache, "write_cache"},
|
||||
{TypeReadCache, "read_cache"},
|
||||
{TypeInvalidate, "invalidate_cache"},
|
||||
{TypeSaveState, "save_state"},
|
||||
{TypeSaveUndo, "save_undo"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if string(tt.taskType) != tt.want {
|
||||
t.Errorf("TaskType %v = %q, want %q", tt.taskType, string(tt.taskType), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriority_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
priority Priority
|
||||
want string
|
||||
}{
|
||||
{HighPriority, "high"},
|
||||
{LowPriority, "low"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if tt.priority.String() != tt.want {
|
||||
t.Errorf("Priority %v.String() = %q, want %q", tt.priority, tt.priority.String(), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileTask_WithDelay(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.SetDelay(10 * time.Millisecond)
|
||||
fs.CreateFile("/test/delayed.txt", []byte("delayed"))
|
||||
|
||||
task := NewReadFileTask("/test/delayed.txt", fs)
|
||||
start := time.Now()
|
||||
result := task.Execute()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !result.IsSuccess() {
|
||||
t.Fatalf("Expected success, got error: %v", result.Error)
|
||||
}
|
||||
if elapsed < 5*time.Millisecond {
|
||||
t.Errorf("Expected delay ~10ms, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFile_WithDelay(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.SetDelay(20 * time.Millisecond)
|
||||
|
||||
start := time.Now()
|
||||
err := fs.CreateFile("/test/delayed.txt", []byte("test"))
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFile failed: %v", err)
|
||||
}
|
||||
if elapsed < 15*time.Millisecond {
|
||||
t.Errorf("Expected delay ~20ms, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFile_WithDelay(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.SetDelay(15 * time.Millisecond)
|
||||
fs.CreateFile("/test/delayed.txt", []byte("original"))
|
||||
|
||||
start := time.Now()
|
||||
err := fs.WriteFile("/test/delayed.txt", []byte("updated"))
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
if elapsed < 10*time.Millisecond {
|
||||
t.Errorf("Expected delay ~15ms, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFile_WithDelay(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.SetDelay(10 * time.Millisecond)
|
||||
fs.CreateFile("/test/delayed.txt", []byte("test"))
|
||||
|
||||
start := time.Now()
|
||||
err := fs.DeleteFile("/test/delayed.txt")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFile failed: %v", err)
|
||||
}
|
||||
if elapsed < 5*time.Millisecond {
|
||||
t.Errorf("Expected delay ~10ms, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDir_WithDelay(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.SetDelay(10 * time.Millisecond)
|
||||
fs.CreateDir("/test/dir")
|
||||
fs.CreateFile("/test/dir/a.txt", []byte("a"))
|
||||
|
||||
start := time.Now()
|
||||
_, err := fs.ReadDir("/test/dir")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir failed: %v", err)
|
||||
}
|
||||
if elapsed < 5*time.Millisecond {
|
||||
t.Errorf("Expected delay ~10ms, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
350
internal/io/pool/worker_pool.go
Normal file
350
internal/io/pool/worker_pool.go
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkerPool manages a pool of worker goroutines that execute IO tasks.
|
||||
// It follows the architecture's single-owner pattern:
|
||||
// - Logic goroutine dispatches tasks on highWorkChan/lowWorkChan
|
||||
// - Workers execute tasks and post results on resultChan
|
||||
// - Workers never access state directly
|
||||
type WorkerPool struct {
|
||||
highWorkChan chan Task
|
||||
lowWorkChan chan Task
|
||||
resultChan chan Result
|
||||
workerCount int
|
||||
stopOnce sync.Once
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
taskCounter atomic.Int64
|
||||
running atomic.Bool
|
||||
|
||||
// pendingTasks tracks in-flight tasks for cancellation by directory.
|
||||
pendingTasks map[string][]Task
|
||||
pendingMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewWorkerPool creates a new worker pool with the given number of workers.
|
||||
// Buffered channels prevent blocking when all workers are busy.
|
||||
func NewWorkerPool(workerCount int) *WorkerPool {
|
||||
if workerCount <= 0 {
|
||||
workerCount = 4
|
||||
}
|
||||
|
||||
return &WorkerPool{
|
||||
highWorkChan: make(chan Task, workerCount*2),
|
||||
lowWorkChan: make(chan Task, workerCount*2),
|
||||
resultChan: make(chan Result, workerCount*4),
|
||||
workerCount: workerCount,
|
||||
stopChan: make(chan struct{}),
|
||||
pendingTasks: make(map[string][]Task),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins processing tasks. Workers are started immediately.
|
||||
func (wp *WorkerPool) Start() {
|
||||
if wp.running.Swap(true) {
|
||||
return // Already started
|
||||
}
|
||||
|
||||
for i := 0; i < wp.workerCount; i++ {
|
||||
wp.wg.Add(1)
|
||||
go wp.worker(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the worker pool.
|
||||
// All in-flight tasks are completed before shutdown.
|
||||
func (wp *WorkerPool) Stop() {
|
||||
wp.stopOnce.Do(func() {
|
||||
wp.running.Store(false)
|
||||
close(wp.stopChan)
|
||||
// Close work channels so drain() can complete
|
||||
close(wp.highWorkChan)
|
||||
close(wp.lowWorkChan)
|
||||
// Wait for workers to finish draining
|
||||
wp.wg.Wait()
|
||||
close(wp.resultChan)
|
||||
})
|
||||
}
|
||||
|
||||
// Dispatch submits a task to the worker pool.
|
||||
// It blocks if the work channel is full (buffered to pool size).
|
||||
//
|
||||
// Use this for UI-critical tasks where the result is required for correctness.
|
||||
// The logic goroutine must expect exactly one result for every dispatched task.
|
||||
// Results are never dropped: the worker blocks until the logic goroutine
|
||||
// consumes the result from resultChan.
|
||||
func (wp *WorkerPool) Dispatch(task Task) {
|
||||
wp.taskCounter.Add(1)
|
||||
|
||||
// Track pending task for cancellation
|
||||
wp.trackPending(task)
|
||||
|
||||
if task.Priority() == HighPriority {
|
||||
wp.highWorkChan <- task
|
||||
} else {
|
||||
wp.lowWorkChan <- task
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchNonBlocking attempts to submit a task without blocking.
|
||||
// Returns false if the channel is full, in which case the task is silently dropped.
|
||||
//
|
||||
// Use this ONLY for fire-and-forget background tasks that can be retried later
|
||||
// (e.g., auto-save, undo persistence, cache writes). The task is idempotent and
|
||||
// the application does not depend on receiving a result.
|
||||
//
|
||||
// CRITICAL: Do not use this for tasks where the logic goroutine waits for a
|
||||
// specific result. If the task is dropped, the logic goroutine will wait forever
|
||||
// for a result that will never arrive, causing a livelock.
|
||||
func (wp *WorkerPool) DispatchNonBlocking(task Task) bool {
|
||||
wp.taskCounter.Add(1)
|
||||
|
||||
if task.Priority() == HighPriority {
|
||||
select {
|
||||
case wp.highWorkChan <- task:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case wp.lowWorkChan <- task:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WorkerID returns the number of workers in the pool.
|
||||
func (wp *WorkerPool) WorkerCount() int {
|
||||
return wp.workerCount
|
||||
}
|
||||
|
||||
// IsRunning returns true if the pool is currently running.
|
||||
func (wp *WorkerPool) IsRunning() bool {
|
||||
return wp.running.Load()
|
||||
}
|
||||
|
||||
// TaskCount returns the total number of tasks dispatched.
|
||||
func (wp *WorkerPool) TaskCount() int64 {
|
||||
return wp.taskCounter.Load()
|
||||
}
|
||||
|
||||
// ResultChan returns the result channel for the logic goroutine to consume
|
||||
// results from completed tasks.
|
||||
func (wp *WorkerPool) ResultChan() <-chan Result {
|
||||
return wp.resultChan
|
||||
}
|
||||
|
||||
// worker is the main loop for each worker goroutine.
|
||||
// It prioritizes high-priority tasks over low-priority tasks.
|
||||
func (wp *WorkerPool) worker(id int) {
|
||||
defer wp.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-wp.stopChan:
|
||||
// Drain remaining work before exiting
|
||||
wp.drain(wp.highWorkChan)
|
||||
wp.drain(wp.lowWorkChan)
|
||||
return
|
||||
|
||||
case task, ok := <-wp.highWorkChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
result := wp.execute(task)
|
||||
wp.post(result)
|
||||
|
||||
default:
|
||||
// No high-priority work; check low-priority
|
||||
select {
|
||||
case <-wp.stopChan:
|
||||
// Drain remaining work before exiting
|
||||
wp.drain(wp.highWorkChan)
|
||||
wp.drain(wp.lowWorkChan)
|
||||
return
|
||||
|
||||
case task, ok := <-wp.highWorkChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
result := wp.execute(task)
|
||||
wp.post(result)
|
||||
|
||||
case task, ok := <-wp.lowWorkChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
result := wp.execute(task)
|
||||
wp.post(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drain processes all remaining tasks in a channel before shutdown.
|
||||
func (wp *WorkerPool) drain(ch chan Task) {
|
||||
for task := range ch {
|
||||
result := wp.execute(task)
|
||||
wp.post(result)
|
||||
}
|
||||
}
|
||||
|
||||
// execute runs a task with timeout enforcement and captures timing information.
|
||||
func (wp *WorkerPool) execute(task Task) Result {
|
||||
start := time.Now()
|
||||
|
||||
// Apply timeout if specified
|
||||
ctx := task.Context()
|
||||
var cancel context.CancelFunc
|
||||
if timeout := task.Timeout(); timeout > 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
// Set the timeout context on the task if it supports it
|
||||
if setter, ok := task.(interface{ SetContext(context.Context) }); ok {
|
||||
setter.SetContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the task with timeout enforcement
|
||||
result := wp.executeWithTimeout(task, ctx)
|
||||
result.Timestamp = start.Add(time.Since(start))
|
||||
|
||||
// Set DirPath from the task
|
||||
result.DirPath = task.DirPath()
|
||||
|
||||
// Remove from pending tasks
|
||||
wp.untrackPending(task)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// executeWithTimeout runs a task with timeout enforcement.
|
||||
// If the task exceeds its timeout, it returns a timeout error.
|
||||
func (wp *WorkerPool) executeWithTimeout(task Task, ctx context.Context) Result {
|
||||
// Channel to receive the result
|
||||
resultChan := make(chan Result, 1)
|
||||
|
||||
// Run the task in a goroutine
|
||||
go func() {
|
||||
resultChan <- task.Execute()
|
||||
}()
|
||||
|
||||
// Wait for either the result or the context to be done
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
return result
|
||||
case <-ctx.Done():
|
||||
// Task exceeded its timeout or was cancelled
|
||||
return Result{
|
||||
TaskID: task.TaskID(),
|
||||
TaskType: task.TaskType(),
|
||||
Success: false,
|
||||
Error: ctx.Err(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// post sends a result to the result channel.
|
||||
// Blocking: waits for the logic goroutine to drain the channel.
|
||||
// Results are never dropped to ensure every dispatched task produces exactly one result.
|
||||
func (wp *WorkerPool) post(result Result) {
|
||||
wp.resultChan <- result
|
||||
}
|
||||
|
||||
// CancelPendingTasks cancels all pending tasks for the given directory path.
|
||||
// This is useful when the user navigates away from a directory and pending
|
||||
// load operations should be cancelled.
|
||||
func (wp *WorkerPool) CancelPendingTasks(dirPath string) {
|
||||
wp.pendingMu.Lock()
|
||||
defer wp.pendingMu.Unlock()
|
||||
|
||||
tasks, ok := wp.pendingTasks[dirPath]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel all pending tasks for this directory
|
||||
for _, task := range tasks {
|
||||
task.Cancel()
|
||||
}
|
||||
|
||||
// Remove the entry
|
||||
delete(wp.pendingTasks, dirPath)
|
||||
}
|
||||
|
||||
// trackPending adds a task to the pending tasks map for cancellation tracking.
|
||||
func (wp *WorkerPool) trackPending(task Task) {
|
||||
wp.pendingMu.Lock()
|
||||
defer wp.pendingMu.Unlock()
|
||||
|
||||
dirPath := task.DirPath()
|
||||
if dirPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
wp.pendingTasks[dirPath] = append(wp.pendingTasks[dirPath], task)
|
||||
}
|
||||
|
||||
// untrackPending removes a task from the pending tasks map.
|
||||
func (wp *WorkerPool) untrackPending(task Task) {
|
||||
wp.pendingMu.Lock()
|
||||
defer wp.pendingMu.Unlock()
|
||||
|
||||
dirPath := task.DirPath()
|
||||
tasks, ok := wp.pendingTasks[dirPath]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the task from the list
|
||||
for i, t := range tasks {
|
||||
if t.TaskID() == task.TaskID() {
|
||||
wp.pendingTasks[dirPath] = append(tasks[:i], tasks[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// taskWrapper adds an ID to a task for tracking results.
|
||||
type taskWrapper struct {
|
||||
id int64
|
||||
original Task
|
||||
}
|
||||
|
||||
func (tw *taskWrapper) Execute() Result {
|
||||
return tw.original.Execute()
|
||||
}
|
||||
|
||||
func (tw *taskWrapper) Priority() Priority {
|
||||
return tw.original.Priority()
|
||||
}
|
||||
|
||||
func (tw *taskWrapper) TaskID() string {
|
||||
return tw.original.TaskID()
|
||||
}
|
||||
|
||||
func (tw *taskWrapper) TaskType() TaskType {
|
||||
return tw.original.TaskType()
|
||||
}
|
||||
|
||||
func (tw *taskWrapper) DirPath() string {
|
||||
return tw.original.DirPath()
|
||||
}
|
||||
772
internal/io/pool/worker_pool_test.go
Normal file
772
internal/io/pool/worker_pool_test.go
Normal file
|
|
@ -0,0 +1,772 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pad/internal/io/pool/mock"
|
||||
)
|
||||
|
||||
// stubTask is a minimal task implementation for testing the worker pool.
|
||||
type stubTask struct {
|
||||
id string
|
||||
taskType TaskType
|
||||
priority Priority
|
||||
data any
|
||||
err error
|
||||
execute func() (any, error)
|
||||
}
|
||||
|
||||
func (t *stubTask) Execute() Result {
|
||||
if t.execute != nil {
|
||||
data, err := t.execute()
|
||||
return Result{
|
||||
TaskID: t.id,
|
||||
TaskType: t.taskType,
|
||||
Success: err == nil,
|
||||
Data: data,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
TaskID: t.id,
|
||||
TaskType: t.taskType,
|
||||
Success: t.err == nil,
|
||||
Data: t.data,
|
||||
Error: t.err,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stubTask) Priority() Priority { return t.priority }
|
||||
func (t *stubTask) TaskID() string { return t.id }
|
||||
func (t *stubTask) TaskType() TaskType { return t.taskType }
|
||||
func (t *stubTask) DirPath() string { return "" }
|
||||
func (t *stubTask) Context() context.Context { return context.Background() }
|
||||
func (t *stubTask) Cancel() {}
|
||||
func (t *stubTask) Timeout() time.Duration { return 5 * time.Second }
|
||||
|
||||
func TestWorkerPool_StartStop(t *testing.T) {
|
||||
pool := NewWorkerPool(4)
|
||||
pool.Start()
|
||||
|
||||
if !pool.IsRunning() {
|
||||
t.Error("Pool should be running after Start()")
|
||||
}
|
||||
|
||||
pool.Stop()
|
||||
|
||||
if pool.IsRunning() {
|
||||
t.Error("Pool should not be running after Stop()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_StartMultiple(t *testing.T) {
|
||||
pool := NewWorkerPool(4)
|
||||
pool.Start()
|
||||
pool.Start() // Should be a no-op
|
||||
pool.Start() // Should be a no-op
|
||||
|
||||
if pool.WorkerCount() != 4 {
|
||||
t.Errorf("WorkerCount = %d, want 4", pool.WorkerCount())
|
||||
}
|
||||
|
||||
pool.Stop()
|
||||
}
|
||||
|
||||
func TestWorkerPool_StopMultiple(t *testing.T) {
|
||||
pool := NewWorkerPool(4)
|
||||
pool.Start()
|
||||
pool.Stop()
|
||||
pool.Stop() // Should be a no-op (stopOnce)
|
||||
pool.Stop() // Should be a no-op
|
||||
|
||||
if pool.IsRunning() {
|
||||
t.Error("Pool should not be running after Stop()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_DispatchHighPriority(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
task := &stubTask{
|
||||
id: "test-high",
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
data: "result",
|
||||
}
|
||||
|
||||
pool.Dispatch(task)
|
||||
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if result.TaskID != "test-high" {
|
||||
t.Errorf("Result TaskID = %q, want %q", result.TaskID, "test-high")
|
||||
}
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("Result should be successful, got error: %v", result.Error)
|
||||
}
|
||||
if result.Data != "result" {
|
||||
t.Errorf("Result.Data = %v, want %v", result.Data, "result")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_DispatchLowPriority(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
task := &stubTask{
|
||||
id: "test-low",
|
||||
taskType: TypeSaveState,
|
||||
priority: LowPriority,
|
||||
data: "saved",
|
||||
}
|
||||
|
||||
pool.Dispatch(task)
|
||||
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if result.TaskID != "test-low" {
|
||||
t.Errorf("Result TaskID = %q, want %q", result.TaskID, "test-low")
|
||||
}
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("Result should be successful, got error: %v", result.Error)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_PriorityPreemption(t *testing.T) {
|
||||
// Use 1 worker to force serialization
|
||||
pool := NewWorkerPool(1)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
var lowExecuted atomic.Bool
|
||||
var highExecuted atomic.Bool
|
||||
|
||||
// Low priority task that takes time
|
||||
lowTask := &stubTask{
|
||||
id: "low",
|
||||
taskType: TypeSaveState,
|
||||
priority: LowPriority,
|
||||
execute: func() (any, error) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
lowExecuted.Store(true)
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
// High priority task that completes quickly
|
||||
highTask := &stubTask{
|
||||
id: "high",
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
execute: func() (any, error) {
|
||||
highExecuted.Store(true)
|
||||
return "fast", nil
|
||||
},
|
||||
}
|
||||
|
||||
// Dispatch low priority first
|
||||
pool.Dispatch(lowTask)
|
||||
|
||||
// Immediately dispatch high priority
|
||||
pool.Dispatch(highTask)
|
||||
|
||||
// Wait for high priority result (should complete before low)
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if result.TaskID != "high" {
|
||||
t.Errorf("First result TaskID = %q, want %q", result.TaskID, "high")
|
||||
}
|
||||
if !highExecuted.Load() {
|
||||
t.Error("High priority task should have executed first")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for high priority result")
|
||||
}
|
||||
|
||||
// Wait for low priority result
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if result.TaskID != "low" {
|
||||
t.Errorf("Second result TaskID = %q, want %q", result.TaskID, "low")
|
||||
}
|
||||
if !lowExecuted.Load() {
|
||||
t.Error("Low priority task should have executed second")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for low priority result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultOrderHighPriority(t *testing.T) {
|
||||
// Use 1 worker to force serialization
|
||||
pool := NewWorkerPool(1)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
taskCount := 5
|
||||
var results []string
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < taskCount; i++ {
|
||||
task := &stubTask{
|
||||
id: fmt.Sprintf("high-%d", i),
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
}
|
||||
pool.Dispatch(task)
|
||||
}
|
||||
|
||||
for i := 0; i < taskCount; i++ {
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
mu.Lock()
|
||||
results = append(results, result.TaskID)
|
||||
mu.Unlock()
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Timed out waiting for result %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) != taskCount {
|
||||
t.Errorf("Got %d results, want %d", len(results), taskCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultOrderMixedPriority(t *testing.T) {
|
||||
// Use 1 worker to force serialization
|
||||
pool := NewWorkerPool(1)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Dispatch 3 low priority tasks first
|
||||
for i := 0; i < 3; i++ {
|
||||
task := &stubTask{
|
||||
id: fmt.Sprintf("low-%d", i),
|
||||
taskType: TypeSaveState,
|
||||
priority: LowPriority,
|
||||
}
|
||||
pool.Dispatch(task)
|
||||
}
|
||||
|
||||
// Dispatch 2 high priority tasks
|
||||
for i := 0; i < 2; i++ {
|
||||
task := &stubTask{
|
||||
id: fmt.Sprintf("high-%d", i),
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
}
|
||||
pool.Dispatch(task)
|
||||
}
|
||||
|
||||
// Collect results in order
|
||||
var results []string
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
mu.Lock()
|
||||
results = append(results, result.TaskID)
|
||||
mu.Unlock()
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Timed out waiting for result %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// High priority tasks should complete before low priority ones
|
||||
// (they are dispatched to highWorkChan which is checked first)
|
||||
if len(results) != 5 {
|
||||
t.Errorf("Got %d results, want 5", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_DispatchNonBlocking(t *testing.T) {
|
||||
pool := NewWorkerPool(1)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// First dispatch should succeed
|
||||
task1 := &stubTask{
|
||||
id: "test-1",
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
}
|
||||
if !pool.DispatchNonBlocking(task1) {
|
||||
t.Error("First DispatchNonBlocking should succeed")
|
||||
}
|
||||
|
||||
// Collect the result
|
||||
select {
|
||||
case <-pool.resultChan:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Timed out waiting for first result")
|
||||
}
|
||||
|
||||
// Second dispatch should also succeed
|
||||
task2 := &stubTask{
|
||||
id: "test-2",
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
}
|
||||
if !pool.DispatchNonBlocking(task2) {
|
||||
t.Error("Second DispatchNonBlocking should succeed")
|
||||
}
|
||||
|
||||
// Collect the result
|
||||
select {
|
||||
case <-pool.resultChan:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Timed out waiting for second result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_TaskCounter(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
task := &stubTask{
|
||||
id: fmt.Sprintf("test-%d", i),
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
}
|
||||
pool.Dispatch(task)
|
||||
}
|
||||
|
||||
if pool.TaskCount() != 10 {
|
||||
t.Errorf("TaskCount = %d, want 10", pool.TaskCount())
|
||||
}
|
||||
|
||||
// Collect all results
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-pool.resultChan:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Timed out waiting for result %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ConcurrentDispatch(t *testing.T) {
|
||||
pool := NewWorkerPool(4)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
// Result channel buffer = workerCount * 4 = 16
|
||||
// Use fewer tasks to avoid dropped results
|
||||
taskCount := 16
|
||||
|
||||
for i := 0; i < taskCount; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
task := &stubTask{
|
||||
id: fmt.Sprintf("concurrent-%d", n),
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
}
|
||||
pool.Dispatch(task)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if pool.TaskCount() != int64(taskCount) {
|
||||
t.Errorf("TaskCount = %d, want %d", pool.TaskCount(), taskCount)
|
||||
}
|
||||
|
||||
// Collect all results
|
||||
for i := 0; i < taskCount; i++ {
|
||||
select {
|
||||
case <-pool.resultChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("Timed out waiting for result %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_GracefulShutdown(t *testing.T) {
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
|
||||
var completed atomic.Int64
|
||||
|
||||
// Dispatch tasks that take time
|
||||
for i := 0; i < 5; i++ {
|
||||
task := &stubTask{
|
||||
id: fmt.Sprintf("shutdown-%d", i),
|
||||
taskType: TypeReadFile,
|
||||
priority: HighPriority,
|
||||
execute: func() (any, error) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
completed.Add(1)
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
pool.Dispatch(task)
|
||||
}
|
||||
|
||||
// Stop the pool (should wait for in-flight tasks)
|
||||
pool.Stop()
|
||||
|
||||
// All tasks should have completed
|
||||
if completed.Load() != 5 {
|
||||
t.Errorf("Completed = %d, want 5", completed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultWithRealTasks(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.CreateFile("/test/file.txt", []byte("content"))
|
||||
fs.CreateDir("/test/dir")
|
||||
fs.CreateFile("/test/dir/a.txt", []byte("a"))
|
||||
fs.CreateFile("/test/dir/b.txt", []byte("b"))
|
||||
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Read file task
|
||||
readTask := NewReadFileTask("/test/file.txt", fs)
|
||||
pool.Dispatch(readTask)
|
||||
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("ReadFile task failed: %v", result.Error)
|
||||
}
|
||||
content, ok := result.Data.([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("Result data is not []byte, got %T", result.Data)
|
||||
}
|
||||
if string(content) != "content" {
|
||||
t.Errorf("Content = %q, want %q", string(content), "content")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for read file result")
|
||||
}
|
||||
|
||||
// Read dir task
|
||||
readDirTask := NewReadDirTask("/test/dir", fs)
|
||||
pool.Dispatch(readDirTask)
|
||||
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("ReadDir task failed: %v", result.Error)
|
||||
}
|
||||
entries, ok := result.Data.([]mock.DirEntry)
|
||||
if !ok {
|
||||
t.Fatalf("Result data is not []mock.DirEntry, got %T", result.Data)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("Entries count = %d, want 2", len(entries))
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for read dir result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultWithRealTasksAndDelay(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
fs.SetDelay(20 * time.Millisecond)
|
||||
fs.CreateFile("/test/delayed.txt", []byte("delayed"))
|
||||
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
task := NewReadFileTask("/test/delayed.txt", fs)
|
||||
start := time.Now()
|
||||
pool.Dispatch(task)
|
||||
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
elapsed := time.Since(start)
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("ReadFile task failed: %v", result.Error)
|
||||
}
|
||||
if elapsed < 15*time.Millisecond {
|
||||
t.Errorf("Expected delay ~20ms, got %v", elapsed)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Timed out waiting for delayed result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_OnlyHighPriority(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
// Create all files upfront
|
||||
for i := 0; i < 5; i++ {
|
||||
fs.CreateFile(fmt.Sprintf("/test/file%d.txt", i), []byte(fmt.Sprintf("file%d", i)))
|
||||
}
|
||||
fs.CreateFile("/test/state.json", []byte("{}"))
|
||||
|
||||
pool := NewWorkerPool(2)
|
||||
pool.Start()
|
||||
defer pool.Stop()
|
||||
|
||||
// Only dispatch high priority tasks
|
||||
for i := 0; i < 5; i++ {
|
||||
task := NewReadFileTask(fmt.Sprintf("/test/file%d.txt", i), fs)
|
||||
pool.Dispatch(task)
|
||||
}
|
||||
|
||||
// Dispatch a low priority task
|
||||
saveTask := NewSaveStateTask("/test/state.json", []byte("{}"), fs)
|
||||
pool.Dispatch(saveTask)
|
||||
|
||||
// Collect results - high priority should complete first
|
||||
for i := 0; i < 6; i++ {
|
||||
select {
|
||||
case result := <-pool.resultChan:
|
||||
// Just verify we got results
|
||||
if !result.IsSuccess() {
|
||||
t.Errorf("Result %d failed: %v", i, result.Error)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Timed out waiting for result %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ChangeEvents(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
ch := make(chan mock.FileChangeEvent, 10)
|
||||
fs.SetChangeEvents(ch)
|
||||
|
||||
fs.CreateFile("/test/notify.txt", []byte("data"))
|
||||
|
||||
select {
|
||||
case event := <-ch:
|
||||
if event.Path != "/test/notify.txt" {
|
||||
t.Errorf("Event path = %q, want %q", event.Path, "/test/notify.txt")
|
||||
}
|
||||
if event.EventType != "Created" {
|
||||
t.Errorf("Event type = %q, want %q", event.EventType, "Created")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Expected change event, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ChangeEventsOnModify(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
ch := make(chan mock.FileChangeEvent, 10)
|
||||
fs.SetChangeEvents(ch)
|
||||
|
||||
fs.CreateFile("/test/modify.txt", []byte("original"))
|
||||
|
||||
// Consume the "Created" event
|
||||
<-ch
|
||||
|
||||
// Modify the file
|
||||
fs.WriteFile("/test/modify.txt", []byte("modified"))
|
||||
|
||||
select {
|
||||
case event := <-ch:
|
||||
if event.EventType != "Modified" {
|
||||
t.Errorf("Event type = %q, want %q", event.EventType, "Modified")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Expected modified event, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ChangeEventsOnDelete(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
ch := make(chan mock.FileChangeEvent, 10)
|
||||
fs.SetChangeEvents(ch)
|
||||
|
||||
fs.CreateFile("/test/delete.txt", []byte("data"))
|
||||
|
||||
// Consume the "Created" event
|
||||
<-ch
|
||||
|
||||
// Delete the file
|
||||
fs.DeleteFile("/test/delete.txt")
|
||||
|
||||
select {
|
||||
case event := <-ch:
|
||||
if event.EventType != "Deleted" {
|
||||
t.Errorf("Event type = %q, want %q", event.EventType, "Deleted")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Expected deleted event, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ChangeEventsOnDirCreate(t *testing.T) {
|
||||
fs := mock.NewFileSystem()
|
||||
ch := make(chan mock.FileChangeEvent, 10)
|
||||
fs.SetChangeEvents(ch)
|
||||
|
||||
fs.CreateDir("/test/newdir")
|
||||
|
||||
select {
|
||||
case event := <-ch:
|
||||
if event.EventType != "Created" {
|
||||
t.Errorf("Event type = %q, want %q", event.EventType, "Created")
|
||||
}
|
||||
if !fs.DirExists("/test/newdir") {
|
||||
t.Error("Directory should exist after create")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Expected created event, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultIsSuccess(t *testing.T) {
|
||||
result := Result{
|
||||
TaskID: "test",
|
||||
Success: true,
|
||||
}
|
||||
if !result.IsSuccess() {
|
||||
t.Error("Result should be successful")
|
||||
}
|
||||
|
||||
result2 := Result{
|
||||
TaskID: "test",
|
||||
Success: false,
|
||||
Error: fmt.Errorf("error"),
|
||||
}
|
||||
if result2.IsSuccess() {
|
||||
t.Error("Result should not be successful")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultIsError(t *testing.T) {
|
||||
result := Result{
|
||||
TaskID: "test",
|
||||
Success: false,
|
||||
Error: fmt.Errorf("error"),
|
||||
}
|
||||
if !result.IsError() {
|
||||
t.Error("Result should have error")
|
||||
}
|
||||
|
||||
result2 := Result{
|
||||
TaskID: "test",
|
||||
Success: true,
|
||||
}
|
||||
if result2.IsError() {
|
||||
t.Error("Result should not have error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultIsBrowserResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
taskType TaskType
|
||||
want bool
|
||||
}{
|
||||
{TypeReadDir, true},
|
||||
{TypeBuildIndex, true},
|
||||
{TypeLoadIndex, true},
|
||||
{TypeLoadPages, true},
|
||||
{TypeStatDir, true},
|
||||
{TypeReadFile, false},
|
||||
{TypeWriteFile, false},
|
||||
{TypeSaveState, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := Result{TaskType: tt.taskType}
|
||||
if result.IsBrowserResult() != tt.want {
|
||||
t.Errorf("Result.TaskType=%v IsBrowserResult()=%v, want %v",
|
||||
tt.taskType, result.IsBrowserResult(), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultIsFileResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
taskType TaskType
|
||||
want bool
|
||||
}{
|
||||
{TypeReadFile, true},
|
||||
{TypeWriteFile, true},
|
||||
{TypeStatFile, true},
|
||||
{TypeReadDir, false},
|
||||
{TypeBuildIndex, false},
|
||||
{TypeSaveState, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := Result{TaskType: tt.taskType}
|
||||
if result.IsFileResult() != tt.want {
|
||||
t.Errorf("Result.TaskType=%v IsFileResult()=%v, want %v",
|
||||
tt.taskType, result.IsFileResult(), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultIsCacheResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
taskType TaskType
|
||||
want bool
|
||||
}{
|
||||
{TypeWriteCache, true},
|
||||
{TypeReadCache, true},
|
||||
{TypeInvalidate, true},
|
||||
{TypeReadFile, false},
|
||||
{TypeSaveState, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := Result{TaskType: tt.taskType}
|
||||
if result.IsCacheResult() != tt.want {
|
||||
t.Errorf("Result.TaskType=%v IsCacheResult()=%v, want %v",
|
||||
tt.taskType, result.IsCacheResult(), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_ResultIsStateResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
taskType TaskType
|
||||
want bool
|
||||
}{
|
||||
{TypeSaveState, true},
|
||||
{TypeSaveUndo, true},
|
||||
{TypeReadFile, false},
|
||||
{TypeBuildIndex, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := Result{TaskType: tt.taskType}
|
||||
if result.IsStateResult() != tt.want {
|
||||
t.Errorf("Result.TaskType=%v IsStateResult()=%v, want %v",
|
||||
tt.taskType, result.IsStateResult(), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_DefaultWorkerCount(t *testing.T) {
|
||||
pool := NewWorkerPool(0)
|
||||
if pool.WorkerCount() != 4 {
|
||||
t.Errorf("WorkerCount = %d, want 4 (default)", pool.WorkerCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPool_NegativeWorkerCount(t *testing.T) {
|
||||
pool := NewWorkerPool(-1)
|
||||
if pool.WorkerCount() != 4 {
|
||||
t.Errorf("WorkerCount = %d, want 4 (default)", pool.WorkerCount())
|
||||
}
|
||||
}
|
||||
357
internal/test/e2e/assertions.go
Normal file
357
internal/test/e2e/assertions.go
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// ElementAssertions provides fluent assertions on element slices.
|
||||
type ElementAssertions struct {
|
||||
elements []ui.Element
|
||||
t testing.TB
|
||||
failed bool
|
||||
}
|
||||
|
||||
// NewElementAssertions creates assertions for a frame.
|
||||
func NewElementAssertions(t testing.TB, elements []ui.Element) *ElementAssertions {
|
||||
return &ElementAssertions{
|
||||
elements: elements,
|
||||
t: t,
|
||||
failed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// HasElementCount asserts the number of elements.
|
||||
func (ea *ElementAssertions) HasElementCount(count int) *ElementAssertions {
|
||||
if len(ea.elements) != count {
|
||||
ea.t.Errorf("expected %d elements, got %d", count, len(ea.elements))
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasElementWithID asserts an element exists with the given ID.
|
||||
func (ea *ElementAssertions) HasElementWithID(id string) *ElementAssertions {
|
||||
found := false
|
||||
for _, elem := range ea.elements {
|
||||
if idElem, ok := elem.(interface{ ID() string }); ok {
|
||||
if idElem.ID() == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Errorf("expected element with ID %q, not found", id)
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasElementOfType asserts an element of the given type exists.
|
||||
func (ea *ElementAssertions) HasElementOfType(t reflect.Type) *ElementAssertions {
|
||||
found := false
|
||||
for _, elem := range ea.elements {
|
||||
if reflect.TypeOf(elem) == t {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Errorf("expected element of type %v, not found", t)
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasLabelWithText asserts a Label element contains the given text.
|
||||
func (ea *ElementAssertions) HasLabelWithText(text string) *ElementAssertions {
|
||||
found := false
|
||||
for _, elem := range ea.elements {
|
||||
if label, ok := elem.(ui.Label); ok {
|
||||
if label.Text == text {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Errorf("expected Label with text %q, not found", text)
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasListViewWithItemCount asserts a ListView has the expected number of items.
|
||||
func (ea *ElementAssertions) HasListViewWithItemCount(count int) *ElementAssertions {
|
||||
var listView *ui.ListView
|
||||
for _, elem := range ea.elements {
|
||||
if lv, ok := elem.(ui.ListView); ok {
|
||||
listView = &lv
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if listView == nil {
|
||||
ea.t.Error("expected ListView element, not found")
|
||||
ea.failed = true
|
||||
return ea
|
||||
}
|
||||
|
||||
if len(listView.Items) != count {
|
||||
ea.t.Errorf("expected ListView with %d items, got %d", count, len(listView.Items))
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasListViewWithItems asserts a ListView has the expected items.
|
||||
func (ea *ElementAssertions) HasListViewWithItems(expected []string) *ElementAssertions {
|
||||
var listView *ui.ListView
|
||||
for _, elem := range ea.elements {
|
||||
if lv, ok := elem.(ui.ListView); ok {
|
||||
listView = &lv
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if listView == nil {
|
||||
ea.t.Error("expected ListView element, not found")
|
||||
ea.failed = true
|
||||
return ea
|
||||
}
|
||||
|
||||
for _, expItem := range expected {
|
||||
found := false
|
||||
for _, item := range listView.Items {
|
||||
if item.Text == expItem {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Errorf("expected ListView item %q, not found", expItem)
|
||||
ea.failed = true
|
||||
}
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasElementInRegion asserts an element exists within the given region bounds.
|
||||
func (ea *ElementAssertions) HasElementInRegion(region ui.Region) *ElementAssertions {
|
||||
found := false
|
||||
for _, elem := range ea.elements {
|
||||
elemRegion := elem.Region()
|
||||
if elemRegion.X >= region.X &&
|
||||
elemRegion.Y >= region.Y &&
|
||||
elemRegion.X+elemRegion.W <= region.X+region.W &&
|
||||
elemRegion.Y+elemRegion.H <= region.Y+region.H {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Errorf("expected element in region %+v, not found", region)
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasVisibleElement asserts at least one visible element exists.
|
||||
func (ea *ElementAssertions) HasVisibleElement() *ElementAssertions {
|
||||
found := false
|
||||
for _, elem := range ea.elements {
|
||||
if elem.Visible() {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Error("expected at least one visible element, none found")
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasButtonWithText asserts a Button element with the given text exists.
|
||||
func (ea *ElementAssertions) HasButtonWithText(text string) *ElementAssertions {
|
||||
found := false
|
||||
for _, elem := range ea.elements {
|
||||
if btn, ok := elem.(ui.Button); ok {
|
||||
if btn.Text == text {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Errorf("expected Button with text %q, not found", text)
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasTextFieldWithID asserts a TextField with the given ID exists.
|
||||
func (ea *ElementAssertions) HasTextFieldWithID(id string) *ElementAssertions {
|
||||
found := false
|
||||
for _, elem := range ea.elements {
|
||||
if tf, ok := elem.(ui.TextField); ok {
|
||||
if tf.ID() == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Errorf("expected TextField with ID %q, not found", id)
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// HasIconWithName asserts an Icon with the given name exists.
|
||||
func (ea *ElementAssertions) HasIconWithName(name string) *ElementAssertions {
|
||||
found := false
|
||||
for _, elem := range ea.elements {
|
||||
if icon, ok := elem.(ui.Icon); ok {
|
||||
if icon.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ea.t.Errorf("expected Icon with name %q, not found", name)
|
||||
ea.failed = true
|
||||
}
|
||||
return ea
|
||||
}
|
||||
|
||||
// GetElementByID returns the element with the given ID, or nil if not found.
|
||||
func (ea *ElementAssertions) GetElementByID(id string) ui.Element {
|
||||
for _, elem := range ea.elements {
|
||||
if idElem, ok := elem.(interface{ ID() string }); ok {
|
||||
if idElem.ID() == id {
|
||||
return elem
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetElementByType returns the first element of the given type, or nil if not found.
|
||||
func (ea *ElementAssertions) GetElementByType(t reflect.Type) ui.Element {
|
||||
for _, elem := range ea.elements {
|
||||
if reflect.TypeOf(elem) == t {
|
||||
return elem
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetListItems returns all list item texts from the first ListView found.
|
||||
func (ea *ElementAssertions) GetListItems() []string {
|
||||
for _, elem := range ea.elements {
|
||||
if lv, ok := elem.(ui.ListView); ok {
|
||||
items := make([]string, len(lv.Items))
|
||||
for i, item := range lv.Items {
|
||||
items[i] = item.Text
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLabelTexts returns all label texts from the frame.
|
||||
func (ea *ElementAssertions) GetLabelTexts() []string {
|
||||
var texts []string
|
||||
for _, elem := range ea.elements {
|
||||
if label, ok := elem.(ui.Label); ok {
|
||||
texts = append(texts, label.Text)
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
// GetButtonTexts returns all button texts from the frame.
|
||||
func (ea *ElementAssertions) GetButtonTexts() []string {
|
||||
var texts []string
|
||||
for _, elem := range ea.elements {
|
||||
if btn, ok := elem.(ui.Button); ok {
|
||||
texts = append(texts, btn.Text)
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
// GetElementRegions returns all element regions from the frame.
|
||||
func (ea *ElementAssertions) GetElementRegions() []ui.Region {
|
||||
regions := make([]ui.Region, len(ea.elements))
|
||||
for i, elem := range ea.elements {
|
||||
regions[i] = elem.Region()
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
// Failed returns true if any assertion has failed.
|
||||
func (ea *ElementAssertions) Failed() bool {
|
||||
return ea.failed
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
// regionsOverlap checks if two regions overlap.
|
||||
func regionsOverlap(a, b ui.Region) bool {
|
||||
return !(a.X+a.W <= b.X || b.X+b.W <= a.X ||
|
||||
a.Y+a.H <= b.Y || b.Y+b.H <= a.Y)
|
||||
}
|
||||
|
||||
// AssertNoOverlappingElements asserts that no elements in the frame overlap.
|
||||
func AssertNoOverlappingElements(t testing.TB, frame []ui.Element) {
|
||||
for i := 0; i < len(frame); i++ {
|
||||
for j := i + 1; j < len(frame); j++ {
|
||||
regionA := frame[i].Region()
|
||||
regionB := frame[j].Region()
|
||||
|
||||
if regionsOverlap(regionA, regionB) {
|
||||
t.Errorf("elements %d (%T) and %d (%T) overlap: %+v vs %+v",
|
||||
i, frame[i], j, frame[j], regionA, regionB)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertElementPositions asserts that elements are positioned within expected bounds.
|
||||
func AssertElementPositions(t testing.TB, frame []ui.Element, bounds ui.Region) {
|
||||
for i, elem := range frame {
|
||||
region := elem.Region()
|
||||
if region.X < bounds.X || region.Y < bounds.Y ||
|
||||
region.X+region.W > bounds.X+bounds.W ||
|
||||
region.Y+region.H > bounds.Y+bounds.H {
|
||||
t.Errorf("element %d (%T) is outside expected bounds: %+v vs %+v",
|
||||
i, elem, region, bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFrameStructure asserts the basic structure of a frame.
|
||||
func AssertFrameStructure(t testing.TB, frame []ui.Element, expectedCount int) {
|
||||
if len(frame) != expectedCount {
|
||||
t.Errorf("expected %d elements, got %d", expectedCount, len(frame))
|
||||
}
|
||||
}
|
||||
|
||||
// PrintFrame prints all elements in a frame for debugging.
|
||||
func PrintFrame(frame []ui.Element) string {
|
||||
var result string
|
||||
for i, elem := range frame {
|
||||
region := elem.Region()
|
||||
result += fmt.Sprintf(" [%d] %T: region=%+v visible=%v\n",
|
||||
i, elem, region, elem.Visible())
|
||||
}
|
||||
return result
|
||||
}
|
||||
102
internal/test/e2e/capture.go
Normal file
102
internal/test/e2e/capture.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Package e2e provides end-to-end testing utilities for the Pad editor.
|
||||
// It allows testing the logic layer without requiring a Gio display.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// FrameCapture receives frames from the logic layer without Gio.
|
||||
type FrameCapture struct {
|
||||
frames [][]ui.Element
|
||||
mu sync.Mutex
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
// NewFrameCapture creates a new frame capture instance.
|
||||
func NewFrameCapture() *FrameCapture {
|
||||
return &FrameCapture{
|
||||
frames: make([][]ui.Element, 0),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureFrame receives a frame and stores it for assertion.
|
||||
func (fc *FrameCapture) CaptureFrame(elements []ui.Element) {
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
|
||||
// Copy to avoid mutation issues
|
||||
cp := make([]ui.Element, len(elements))
|
||||
copy(cp, elements)
|
||||
fc.frames = append(fc.frames, cp)
|
||||
}
|
||||
|
||||
// GetFrames returns all captured frames (thread-safe).
|
||||
func (fc *FrameCapture) GetFrames() [][]ui.Element {
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
|
||||
result := make([][]ui.Element, len(fc.frames))
|
||||
for i, frame := range fc.frames {
|
||||
result[i] = make([]ui.Element, len(frame))
|
||||
copy(result[i], frame)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FrameCount returns the number of frames captured so far.
|
||||
func (fc *FrameCapture) FrameCount() int {
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
return len(fc.frames)
|
||||
}
|
||||
|
||||
// WaitForFrame blocks until at least one frame is captured or timeout.
|
||||
func (fc *FrameCapture) WaitForFrame(timeout time.Duration) ([][]ui.Element, error) {
|
||||
return fc.WaitForFrameCount(1, timeout)
|
||||
}
|
||||
|
||||
// WaitForFrameCount blocks until at least N frames are captured or timeout.
|
||||
func (fc *FrameCapture) WaitForFrameCount(count int, timeout time.Duration) ([][]ui.Element, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for {
|
||||
fc.mu.Lock()
|
||||
n := len(fc.frames)
|
||||
fc.mu.Unlock()
|
||||
|
||||
if n >= count {
|
||||
return fc.GetFrames(), nil
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return nil, ErrTimeout
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Close signals the capture to stop.
|
||||
func (fc *FrameCapture) Close() {
|
||||
close(fc.closed)
|
||||
}
|
||||
|
||||
// ErrTimeout is returned when a wait operation times out.
|
||||
var ErrTimeout = errTimeout{}
|
||||
|
||||
type errTimeout struct{}
|
||||
|
||||
func (errTimeout) Error() string {
|
||||
return "timeout waiting for frames"
|
||||
}
|
||||
|
||||
// IsTimeout returns true if the error is a timeout error.
|
||||
func IsTimeout(err error) bool {
|
||||
_, ok := err.(errTimeout)
|
||||
return ok
|
||||
}
|
||||
123
internal/test/e2e/harness.go
Normal file
123
internal/test/e2e/harness.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pad/internal/editor"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// Harness orchestrates the test environment for e2e tests.
|
||||
type Harness struct {
|
||||
logic *editor.Logic
|
||||
capture *FrameCapture
|
||||
logicWg sync.WaitGroup
|
||||
frameReceiverDone chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// HarnessOption configures the test harness.
|
||||
type HarnessOption func(*Harness)
|
||||
|
||||
// NewHarness creates a test harness with the given options.
|
||||
func NewHarness(opts ...HarnessOption) *Harness {
|
||||
h := &Harness{
|
||||
logic: editor.NewLogic(),
|
||||
capture: NewFrameCapture(),
|
||||
frameReceiverDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(h)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// Run starts the logic goroutine and frame capture.
|
||||
func (h *Harness) Run() {
|
||||
h.logicWg.Add(1)
|
||||
h.wg.Add(1)
|
||||
go func() {
|
||||
defer h.wg.Done()
|
||||
defer h.logicWg.Done()
|
||||
h.logic.Run()
|
||||
}()
|
||||
|
||||
h.wg.Add(1)
|
||||
go func() {
|
||||
defer h.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case frame := <-h.logic.FrameChan():
|
||||
h.capture.CaptureFrame(frame)
|
||||
case <-h.frameReceiverDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SendConfig simulates a window config event (resize).
|
||||
func (h *Harness) SendConfig(width, height int) {
|
||||
h.logic.ConfigChan() <- editor.ConfigEvent{
|
||||
PixelWidth: width,
|
||||
PixelHeight: height,
|
||||
}
|
||||
}
|
||||
|
||||
// SendScale simulates a scale factor change.
|
||||
func (h *Harness) SendScale(scale float32) {
|
||||
h.logic.ConfigChan() <- editor.ScaleEvent{Scale: scale}
|
||||
}
|
||||
|
||||
// SendInput simulates user input events.
|
||||
func (h *Harness) SendInput(events []ui.InputEvent) {
|
||||
h.logic.InputChan() <- events
|
||||
}
|
||||
|
||||
// SendSearchQuery simulates a search query update.
|
||||
func (h *Harness) SendSearchQuery(query string) {
|
||||
h.logic.SearchQueryChan() <- query
|
||||
}
|
||||
|
||||
// GetFrames returns all captured frames.
|
||||
func (h *Harness) GetFrames() [][]ui.Element {
|
||||
return h.capture.GetFrames()
|
||||
}
|
||||
|
||||
// FrameCount returns the number of frames captured so far.
|
||||
func (h *Harness) FrameCount() int {
|
||||
return h.capture.FrameCount()
|
||||
}
|
||||
|
||||
// WaitForFrameCount blocks until at least N frames are captured.
|
||||
func (h *Harness) WaitForFrameCount(count int, timeout time.Duration) ([][]ui.Element, error) {
|
||||
return h.capture.WaitForFrameCount(count, timeout)
|
||||
}
|
||||
|
||||
// WaitForFrame blocks until at least one frame is captured.
|
||||
func (h *Harness) WaitForFrame(timeout time.Duration) ([][]ui.Element, error) {
|
||||
return h.capture.WaitForFrame(timeout)
|
||||
}
|
||||
|
||||
// Cleanup stops all goroutines.
|
||||
func (h *Harness) Cleanup() {
|
||||
// 1. Signal logic to stop sending frames
|
||||
h.logic.Done()
|
||||
|
||||
// 2. Wait for logic goroutine to fully exit (no more frameChan sends)
|
||||
h.logicWg.Wait()
|
||||
|
||||
// 3. Now safe to stop the frame receiver
|
||||
close(h.frameReceiverDone)
|
||||
|
||||
// 4. Wait for frame receiver to finish
|
||||
h.wg.Wait()
|
||||
|
||||
h.capture.Close()
|
||||
}
|
||||
|
||||
// DefaultTimeout is the default timeout for waiting operations.
|
||||
const DefaultTimeout = 5 * time.Second
|
||||
97
internal/test/e2e/harness_test.go
Normal file
97
internal/test/e2e/harness_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package e2e_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pad/internal/test/e2e"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// TestEditorInitialLayout tests the initial editor layout has expected structure.
|
||||
func TestEditorInitialLayout(t *testing.T) {
|
||||
h := e2e.NewHarnessWithDefaults()
|
||||
defer h.Cleanup()
|
||||
|
||||
frames, err := h.WaitForFrameCount(1, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("timeout waiting for frames: %v", err)
|
||||
}
|
||||
|
||||
lastFrame := frames[len(frames)-1]
|
||||
ea := e2e.NewElementAssertions(t, lastFrame)
|
||||
ea.HasElementCount(3)
|
||||
ea.HasElementOfType(reflect.TypeOf(ui.Container{}))
|
||||
ea.HasElementOfType(reflect.TypeOf(ui.TextField{}))
|
||||
ea.HasElementWithID("editor_text")
|
||||
}
|
||||
|
||||
// TestConfigChangeTriggersNewFrame tests that config changes produce new frames.
|
||||
func TestConfigChangeTriggersNewFrame(t *testing.T) {
|
||||
h := e2e.NewHarnessWithDefaults()
|
||||
defer h.Cleanup()
|
||||
|
||||
_, err := h.WaitForFrameCount(1, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("timeout waiting for initial frame: %v", err)
|
||||
}
|
||||
|
||||
initialCount := h.FrameCount()
|
||||
h.SendConfig(1000, 2000)
|
||||
|
||||
newCount, err := e2e.WaitForNewFrame(h, initialCount, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("timeout waiting for new frame: %v", err)
|
||||
}
|
||||
if newCount <= initialCount {
|
||||
t.Errorf("expected new frame after config change, got %d (was %d)", newCount, initialCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestElementPositionsValid tests that elements have valid regions and no overlaps.
|
||||
func TestElementPositionsValid(t *testing.T) {
|
||||
h := e2e.NewHarnessWithDefaults()
|
||||
defer h.Cleanup()
|
||||
|
||||
frames, err := h.WaitForFrameCount(1, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("timeout waiting for frames: %v", err)
|
||||
}
|
||||
|
||||
lastFrame := frames[len(frames)-1]
|
||||
e2e.AssertNoOverlappingElements(t, lastFrame)
|
||||
e2e.AssertFrameHasNoEmptyRegions(t, lastFrame)
|
||||
e2e.AssertFrameHasNoNegativeRegions(t, lastFrame)
|
||||
e2e.AssertFrameHasNoInvisibleElements(t, lastFrame)
|
||||
|
||||
screenBounds := ui.Region{X: 0, Y: 0, W: 780, H: 1688}
|
||||
e2e.AssertFrameHasNoOutOfBoundsElements(t, lastFrame, screenBounds)
|
||||
}
|
||||
|
||||
// TestHarnessTimeout tests that timeout errors are returned correctly.
|
||||
func TestHarnessTimeout(t *testing.T) {
|
||||
h := e2e.NewHarness()
|
||||
h.Run()
|
||||
defer h.Cleanup()
|
||||
|
||||
// Wait for initial startup frames first
|
||||
_, err := h.WaitForFrameCount(1, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("timeout waiting for initial frames: %v", err)
|
||||
}
|
||||
|
||||
initialCount := h.FrameCount()
|
||||
|
||||
// Now wait for a frame count that won't be reached (no more events sent)
|
||||
_, err = h.WaitForFrameCount(initialCount+10, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Error("expected timeout error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHarnessCleanup tests that cleanup stops goroutines without deadlock.
|
||||
func TestHarnessCleanup(t *testing.T) {
|
||||
h := e2e.NewHarnessWithDefaults()
|
||||
h.Cleanup()
|
||||
}
|
||||
388
internal/test/e2e/helpers.go
Normal file
388
internal/test/e2e/helpers.go
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// TestScenario represents a complete test scenario with setup, actions, and assertions.
|
||||
type TestScenario struct {
|
||||
Name string
|
||||
Setup func(*Harness)
|
||||
Actions []HarnessAction
|
||||
Assertions []FrameAssertion
|
||||
FrameIndex int // which frame to assert on (-1 for last)
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// HarnessAction represents an action to perform on the harness.
|
||||
type HarnessAction func(*Harness)
|
||||
|
||||
// FrameAssertion represents an assertion to make on a frame.
|
||||
type FrameAssertion func(*testing.T, []ui.Element)
|
||||
|
||||
// --- Common actions ---
|
||||
|
||||
// ActionSendConfig creates an action to send a config event.
|
||||
func ActionSendConfig(width, height int) HarnessAction {
|
||||
return func(h *Harness) {
|
||||
h.SendConfig(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
// ActionSendScale creates an action to send a scale event.
|
||||
func ActionSendScale(scale float32) HarnessAction {
|
||||
return func(h *Harness) {
|
||||
h.SendScale(scale)
|
||||
}
|
||||
}
|
||||
|
||||
// ActionSendInput creates an action to send input events.
|
||||
func ActionSendInput(events []ui.InputEvent) HarnessAction {
|
||||
return func(h *Harness) {
|
||||
h.SendInput(events)
|
||||
}
|
||||
}
|
||||
|
||||
// ActionSendSearchQuery creates an action to send a search query.
|
||||
func ActionSendSearchQuery(query string) HarnessAction {
|
||||
return func(h *Harness) {
|
||||
h.SendSearchQuery(query)
|
||||
}
|
||||
}
|
||||
|
||||
// ActionWait creates an action to wait for frames.
|
||||
func ActionWait(count int, timeout time.Duration) HarnessAction {
|
||||
return func(h *Harness) {
|
||||
h.WaitForFrameCount(count, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Common assertions ---
|
||||
|
||||
// AssertionHasElementCount creates an assertion for element count.
|
||||
func AssertionHasElementCount(count int) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
NewElementAssertions(t, frame).HasElementCount(count)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasElementWithID creates an assertion for element ID.
|
||||
func AssertionHasElementWithID(id string) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
NewElementAssertions(t, frame).HasElementWithID(id)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasLabelWithText creates an assertion for label text.
|
||||
func AssertionHasLabelWithText(text string) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
NewElementAssertions(t, frame).HasLabelWithText(text)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasListViewWithItems creates an assertion for list view items.
|
||||
func AssertionHasListViewWithItems(items []string) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
NewElementAssertions(t, frame).HasListViewWithItems(items)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasListViewWithItemCount creates an assertion for list view item count.
|
||||
func AssertionHasListViewWithItemCount(count int) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
NewElementAssertions(t, frame).HasListViewWithItemCount(count)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasButtonWithText creates an assertion for button text.
|
||||
func AssertionHasButtonWithText(text string) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
NewElementAssertions(t, frame).HasButtonWithText(text)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasIconWithName creates an assertion for icon name.
|
||||
func AssertionHasIconWithName(name string) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
NewElementAssertions(t, frame).HasIconWithName(name)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasSearchBarWithQuery creates an assertion for search bar query.
|
||||
// NOTE: ui.SearchBar does not implement ui.Element (no Draw method),
|
||||
// so this assertion is currently a no-op placeholder.
|
||||
func AssertionHasSearchBarWithQuery(query string) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
// TODO: implement when SearchBar implements ui.Element
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasToastWithText creates an assertion for toast text.
|
||||
// NOTE: ui.Toast does not implement ui.Element (no Draw method),
|
||||
// so this assertion is currently a no-op placeholder.
|
||||
func AssertionHasToastWithText(text string) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
// TODO: implement when Toast implements ui.Element
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionHasCursorAt creates an assertion for cursor position.
|
||||
// NOTE: ui.Cursor does not implement ui.Element (no Draw method),
|
||||
// so this assertion is currently a no-op placeholder.
|
||||
func AssertionHasCursorAt(line, col int) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
// TODO: implement when Cursor implements ui.Element
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionNoOverlappingElements creates an assertion for no overlapping elements.
|
||||
func AssertionNoOverlappingElements() FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
AssertNoOverlappingElements(t, frame)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertionElementPositions creates an assertion for element positions.
|
||||
func AssertionElementPositions(bounds ui.Region) FrameAssertion {
|
||||
return func(t *testing.T, frame []ui.Element) {
|
||||
AssertElementPositions(t, frame, bounds)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario runner ---
|
||||
|
||||
// RunScenario runs a test scenario.
|
||||
func RunScenario(t *testing.T, scenario TestScenario) {
|
||||
h := NewHarness()
|
||||
h.Run()
|
||||
defer h.Cleanup()
|
||||
|
||||
// Run setup
|
||||
if scenario.Setup != nil {
|
||||
scenario.Setup(h)
|
||||
}
|
||||
|
||||
// Run actions
|
||||
for _, action := range scenario.Actions {
|
||||
action(h)
|
||||
}
|
||||
|
||||
// Wait for frames
|
||||
timeout := scenario.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
|
||||
frames, err := h.WaitForFrameCount(1, timeout)
|
||||
if err != nil {
|
||||
t.Fatalf("timeout waiting for frames: %v", err)
|
||||
}
|
||||
|
||||
// Determine which frame to assert on
|
||||
frameIndex := scenario.FrameIndex
|
||||
if frameIndex == -1 || frameIndex >= len(frames) {
|
||||
frameIndex = len(frames) - 1
|
||||
}
|
||||
|
||||
// Run assertions
|
||||
for _, assertion := range scenario.Assertions {
|
||||
assertion(t, frames[frameIndex])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
// WaitForStableFrames waits until no new frames are captured for a period.
|
||||
func WaitForStableFrames(h *Harness, stabilityPeriod time.Duration) error {
|
||||
deadline := time.Now().Add(stabilityPeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
count := h.FrameCount()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if h.FrameCount() == count {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("frames not stable after %v", stabilityPeriod)
|
||||
}
|
||||
|
||||
// GetLastFrame returns the last captured frame.
|
||||
func GetLastFrame(h *Harness) []ui.Element {
|
||||
frames := h.GetFrames()
|
||||
if len(frames) == 0 {
|
||||
return nil
|
||||
}
|
||||
return frames[len(frames)-1]
|
||||
}
|
||||
|
||||
// GetFrameByIndex returns a frame by index.
|
||||
func GetFrameByIndex(h *Harness, index int) []ui.Element {
|
||||
frames := h.GetFrames()
|
||||
if index < 0 || index >= len(frames) {
|
||||
return nil
|
||||
}
|
||||
return frames[index]
|
||||
}
|
||||
|
||||
// DebugPrintFrames prints all captured frames for debugging.
|
||||
func DebugPrintFrames(h *Harness) {
|
||||
frames := h.GetFrames()
|
||||
for i, frame := range frames {
|
||||
fmt.Printf("Frame %d (%d elements):\n", i, len(frame))
|
||||
fmt.Print(PrintFrame(frame))
|
||||
}
|
||||
}
|
||||
|
||||
// NewHarnessWithDefaults creates a harness with default configuration.
|
||||
func NewHarnessWithDefaults() *Harness {
|
||||
h := NewHarness()
|
||||
h.Run()
|
||||
h.SendConfig(780, 1688) // 390x844 @ 2x scale
|
||||
h.SendScale(2.0)
|
||||
return h
|
||||
}
|
||||
|
||||
// NewHarnessWithCustomConfig creates a harness with custom configuration.
|
||||
func NewHarnessWithCustomConfig(width, height int, scale float32) *Harness {
|
||||
h := NewHarness()
|
||||
h.Run()
|
||||
h.SendConfig(width, height)
|
||||
h.SendScale(scale)
|
||||
return h
|
||||
}
|
||||
|
||||
// WaitForInitialFrame waits for the initial frame after setup.
|
||||
func WaitForInitialFrame(h *Harness, timeout time.Duration) ([]ui.Element, error) {
|
||||
_, err := h.WaitForFrameCount(1, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetLastFrame(h), nil
|
||||
}
|
||||
|
||||
// WaitForNewFrame waits for a new frame after an action.
|
||||
func WaitForNewFrame(h *Harness, beforeCount int, timeout time.Duration) (int, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
count := h.FrameCount()
|
||||
if count > beforeCount {
|
||||
return count, nil
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return beforeCount, fmt.Errorf("no new frames after %v", timeout)
|
||||
}
|
||||
|
||||
// CompareFrameElements compares two frames element by element.
|
||||
func CompareFrameElements(frame1, frame2 []ui.Element) []string {
|
||||
var differences []string
|
||||
|
||||
if len(frame1) != len(frame2) {
|
||||
differences = append(differences, fmt.Sprintf("element count: %d vs %d", len(frame1), len(frame2)))
|
||||
return differences
|
||||
}
|
||||
|
||||
for i := range frame1 {
|
||||
region1 := frame1[i].Region()
|
||||
region2 := frame2[i].Region()
|
||||
|
||||
if region1 != region2 {
|
||||
differences = append(differences, fmt.Sprintf("element %d region: %+v vs %+v", i, region1, region2))
|
||||
}
|
||||
|
||||
if frame1[i].Visible() != frame2[i].Visible() {
|
||||
differences = append(differences, fmt.Sprintf("element %d visible: %v vs %v", i, frame1[i].Visible(), frame2[i].Visible()))
|
||||
}
|
||||
}
|
||||
|
||||
return differences
|
||||
}
|
||||
|
||||
// --- Input event helpers ---
|
||||
|
||||
// CreateTapEvent creates a tap input event.
|
||||
func CreateTapEvent(handler func(any)) ui.InputEvent {
|
||||
return ui.InputEvent{
|
||||
Handler: handler,
|
||||
Data: ui.Interaction{Gesture: ui.Tap},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateScrollEvent creates a scroll input event.
|
||||
func CreateScrollEvent(handler func(any)) ui.InputEvent {
|
||||
return ui.InputEvent{
|
||||
Handler: handler,
|
||||
Data: ui.Interaction{Gesture: ui.Scroll},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateKeyEvent creates a key input event.
|
||||
func CreateKeyEvent(handler func(any)) ui.InputEvent {
|
||||
return ui.InputEvent{
|
||||
Handler: handler,
|
||||
Data: ui.Interaction{Gesture: ui.KeyDown},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Structural assertions ---
|
||||
|
||||
// AssertFrameHasNoInvisibleElements asserts that all elements in the frame are visible.
|
||||
func AssertFrameHasNoInvisibleElements(t *testing.T, frame []ui.Element) {
|
||||
for i, elem := range frame {
|
||||
if !elem.Visible() {
|
||||
t.Errorf("element %d (%T) is invisible", i, elem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFrameHasNoEmptyRegions asserts that no elements have empty regions.
|
||||
func AssertFrameHasNoEmptyRegions(t *testing.T, frame []ui.Element) {
|
||||
for i, elem := range frame {
|
||||
region := elem.Region()
|
||||
if region.W == 0 || region.H == 0 {
|
||||
t.Errorf("element %d (%T) has empty region: %+v", i, elem, region)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFrameHasNoNegativeRegions asserts that no elements have negative regions.
|
||||
func AssertFrameHasNoNegativeRegions(t *testing.T, frame []ui.Element) {
|
||||
for i, elem := range frame {
|
||||
region := elem.Region()
|
||||
if region.X < 0 || region.Y < 0 || region.W < 0 || region.H < 0 {
|
||||
t.Errorf("element %d (%T) has negative region: %+v", i, elem, region)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFrameHasNoOutOfBoundsElements asserts that no elements are outside the expected bounds.
|
||||
func AssertFrameHasNoOutOfBoundsElements(t *testing.T, frame []ui.Element, bounds ui.Region) {
|
||||
for i, elem := range frame {
|
||||
region := elem.Region()
|
||||
if region.X < bounds.X || region.Y < bounds.Y ||
|
||||
region.X+region.W > bounds.X+bounds.W ||
|
||||
region.Y+region.H > bounds.Y+bounds.H {
|
||||
t.Errorf("element %d (%T) is out of bounds: %+v vs %+v", i, elem, region, bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFrameHasNoDuplicateIDs asserts that no elements have duplicate IDs.
|
||||
func AssertFrameHasNoDuplicateIDs(t *testing.T, frame []ui.Element) {
|
||||
seen := make(map[string]bool)
|
||||
for i, elem := range frame {
|
||||
if idElem, ok := elem.(interface{ ID() string }); ok {
|
||||
id := idElem.ID()
|
||||
if id == "" {
|
||||
continue // skip elements without IDs
|
||||
}
|
||||
if seen[id] {
|
||||
t.Errorf("element %d has duplicate ID %q", i, id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user