Skip to content
Prev Previous commit
Next Next commit
test: fix and update api_debouncer tests
  • Loading branch information
shift committed Aug 22, 2025
commit 92a4380339d254bce4c4eec7b2f084c271ab601d
30 changes: 17 additions & 13 deletions packages/tui/internal/util/api_debouncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type APIDebouncer struct {
delay time.Duration
mu sync.Mutex
lastQuery string
cache map[string]interface{}
cache map[string]any
cacheTTL time.Duration
cacheTime map[string]time.Time
}
Expand All @@ -20,23 +20,24 @@ type APIDebouncer struct {
func NewAPIDebouncer(delay time.Duration, cacheTTL time.Duration) *APIDebouncer {
return &APIDebouncer{
delay: delay,
cache: make(map[string]interface{}),
cache: make(map[string]any),
cacheTTL: cacheTTL,
cacheTime: make(map[string]time.Time),
}
}

// Debounce executes the function after delay, with caching and deduplication
func (d *APIDebouncer) Debounce(query string, fn func() interface{}) <-chan interface{} {
func (d *APIDebouncer) Debounce(query string, fn func() any) <-chan any {
d.mu.Lock()
defer d.mu.Unlock()

result := make(chan interface{}, 1)
// Create result channel with buffer to prevent blocking
result := make(chan any, 1)

// Check cache first (with TTL)
if cached, exists := d.cache[query]; exists {
if cacheTime, timeExists := d.cacheTime[query]; timeExists {
if time.Since(cacheTime) < d.cacheTTL {
d.mu.Unlock()
result <- cached
return result
}
Expand All @@ -46,46 +47,49 @@ func (d *APIDebouncer) Debounce(query string, fn func() interface{}) <-chan inte
}
}

// Request deduplication: if same query as last request, skip
// Request deduplication: if same query as last request and timer is active
if d.lastQuery == query && d.timer != nil {
// Same query, just wait for existing timer
d.mu.Unlock()
// Wait for existing operation
go func() {
time.Sleep(d.delay + 50*time.Millisecond) // Wait a bit longer than the timer
time.Sleep(d.delay + 50*time.Millisecond)
d.mu.Lock()
if cached, exists := d.cache[query]; exists {
result <- cached
} else {
result <- nil // Cache miss, request may have failed
result <- fn() // Execute if cache miss
}
d.mu.Unlock()
}()
return result
}

// Cancel existing timer
// Cancel existing timer if any
if d.timer != nil {
d.timer.Stop()
}

d.lastQuery = query

// Start new timer
// Execute after delay
d.timer = time.AfterFunc(d.delay, func() {
data := fn()
d.mu.Lock()
d.cache[query] = data
d.cacheTime[query] = time.Now()
d.timer = nil // Clear timer reference
d.mu.Unlock()
result <- data
})

d.mu.Unlock()
return result
}

// ClearCache clears the cache (useful for invalidation)
func (d *APIDebouncer) ClearCache() {
d.mu.Lock()
defer d.mu.Unlock()
d.cache = make(map[string]interface{})
d.cache = make(map[string]any)
d.cacheTime = make(map[string]time.Time)
}
}
134 changes: 134 additions & 0 deletions packages/tui/internal/util/api_debouncer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package util

import (
"sync"
"testing"
"time"
)

func TestAPIDebouncer(t *testing.T) {
t.Run("basic debouncing", func(t *testing.T) {
debouncer := NewAPIDebouncer(50*time.Millisecond, 200*time.Millisecond)
callCount := 0
var mu sync.Mutex

// Make multiple rapid calls
for range 5 {
resultCh := debouncer.Debounce("test", func() any {
mu.Lock()
callCount++
mu.Unlock()
return "result"
})

// Ensure we get a result
result := <-resultCh
if result != "result" {
t.Errorf("Expected 'result', got %v", result)
}
}

// Wait for debounce period
time.Sleep(100 * time.Millisecond)

mu.Lock()
if callCount != 1 {
t.Errorf("Expected 1 call, got %d", callCount)
}
mu.Unlock()
})

t.Run("cache functionality", func(t *testing.T) {
debouncer := NewAPIDebouncer(50*time.Millisecond, 200*time.Millisecond)
callCount := 0
var mu sync.Mutex

// First call
resultCh := debouncer.Debounce("test", func() any {
mu.Lock()
callCount++
mu.Unlock()
return "result1"
})

result1 := <-resultCh
time.Sleep(100 * time.Millisecond) // Wait for result to be cached

// Second call with same query should use cache
resultCh = debouncer.Debounce("test", func() any {
mu.Lock()
callCount++
mu.Unlock()
return "result2"
})

result2 := <-resultCh

if result1 != result2 {
t.Errorf("Cache not working, got different results: %v != %v", result1, result2)
}

mu.Lock()
if callCount != 1 {
t.Errorf("Expected 1 call due to caching, got %d", callCount)
}
mu.Unlock()

// Wait for cache to expire
time.Sleep(250 * time.Millisecond)

// Call after cache expiry
resultCh = debouncer.Debounce("test", func() any {
mu.Lock()
callCount++
mu.Unlock()
return "result3"
})

<-resultCh
time.Sleep(100 * time.Millisecond) // Wait for operation to complete

mu.Lock()
if callCount != 2 {
t.Errorf("Expected 2 calls after cache expiry, got %d", callCount)
}
mu.Unlock()
})

t.Run("clear cache", func(t *testing.T) {
debouncer := NewAPIDebouncer(50*time.Millisecond, 200*time.Millisecond)
callCount := 0
var mu sync.Mutex

// First call
resultCh := debouncer.Debounce("test", func() any {
mu.Lock()
callCount++
mu.Unlock()
return "result1"
})

<-resultCh
time.Sleep(100 * time.Millisecond) // Wait for result to be cached

// Clear cache
debouncer.ClearCache()

// Second call should not use cache
resultCh = debouncer.Debounce("test", func() any {
mu.Lock()
callCount++
mu.Unlock()
return "result2"
})

<-resultCh
time.Sleep(100 * time.Millisecond) // Wait for operation to complete

mu.Lock()
if callCount != 2 {
t.Errorf("Expected 2 calls after cache clear, got %d", callCount)
}
mu.Unlock()
})
}