Pad/internal/io/pool/worker_pool_test.go
Greg Pomerantz 06b1444207 gofmt: format all remaining files with the go1.27 toolchain
The tree was formatted with an older gofmt; go1.27's gofmt additionally
wants: EOF exactly one newline (no trailing blank lines), imports sorted
alphabetically within a block, mixed-precedence binary expressions
re-spaced for grouping ((a+b)/c), single-field composite literals
un-aligned, adjacent one-line method signatures aligned, and one-line
bodies containing a compound statement expanded. Applied repo-wide
(31 files under internal/); pure formatting, no semantic changes —
build and the full test suite pass.
2026-08-23 10:03:27 -04:00

803 lines
18 KiB
Go

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