package pool import ( "context" "fmt" "sync" "sync/atomic" "testing" "time" "pad/internal/io/pool/mock" "pad/internal/io/pool/types" ) // 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 // Gate task: occupies the single worker until we release it, so that the // low- and high-priority test tasks both sit in their queues first. gateStarted := make(chan struct{}) release := make(chan struct{}) gateTask := &stubTask{ id: "gate", taskType: TypeSaveState, priority: HighPriority, execute: func() (any, error) { close(gateStarted) <-release return nil, nil }, } pool.Dispatch(gateTask) // Wait until the worker is inside the gate task. select { case <-gateStarted: case <-time.After(2 * time.Second): t.Fatal("Timed out waiting for gate task to start") } // Queue a low-priority task, then a high-priority one. lowTask := &stubTask{ id: "low", taskType: TypeSaveState, priority: LowPriority, execute: func() (any, error) { lowExecuted.Store(true) return nil, nil }, } highTask := &stubTask{ id: "high", taskType: TypeReadFile, priority: HighPriority, execute: func() (any, error) { highExecuted.Store(true) return "fast", nil }, } pool.Dispatch(lowTask) pool.Dispatch(highTask) // Release the worker; it must pick the high-priority task first. close(release) // Gate result first. select { case result := <-pool.resultChan: if result.TaskID != "gate" { t.Fatalf("First result TaskID = %q, want %q", result.TaskID, "gate") } case <-time.After(2 * time.Second): t.Fatal("Timed out waiting for gate result") } // High priority result before low priority. select { case result := <-pool.resultChan: if result.TaskID != "high" { t.Errorf("Second result TaskID = %q, want %q", result.TaskID, "high") } if !highExecuted.Load() { t.Error("High priority task should have executed before low") } case <-time.After(2 * time.Second): t.Fatal("Timed out waiting for high priority result") } // Low priority result last. select { case result := <-pool.resultChan: if result.TaskID != "low" { t.Errorf("Third result TaskID = %q, want %q", result.TaskID, "low") } if !lowExecuted.Load() { t.Error("Low priority task should have executed last") } 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.([]types.DirEntry) if !ok { t.Fatalf("Result data is not []types.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()) } }