diff --git a/debounce_time.go b/debounce_time.go new file mode 100644 index 0000000..bd57fef --- /dev/null +++ b/debounce_time.go @@ -0,0 +1,75 @@ +// Copyright © 2024 Jon Friesen . +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package debounce + +import ( + "sync" + "time" +) + +var now = time.Now + +// NewDebounceByDuration returns a debounced function that takes another function as its argument. +// This function will be called at the given interval, but no more than the max duration +// from the first call. +func NewDebounceByDuration(interval, maxDuration time.Duration) func(f func()) { + d := &durationDebouncer{ + interval: interval, + maxDuration: maxDuration, + } + + return func(f func()) { + d.add(f) + } +} + +type durationDebouncer struct { + mu sync.Mutex + interval time.Duration + maxDuration time.Duration + timer *time.Timer + firstCall bool + startTime time.Time +} + +func (d *durationDebouncer) add(f func()) { + d.mu.Lock() + defer d.mu.Unlock() + + now := now() + if !d.firstCall { + d.firstCall = true + d.startTime = now + } + + if d.timer != nil { + d.timer.Stop() + } + + remainingDuration := d.maxDuration - time.Since(d.startTime) + if remainingDuration <= 0 { + d.reset() + f() + return + } + + d.timer = time.AfterFunc(d.interval, func() { + d.mu.Lock() + defer d.mu.Unlock() + + f() + d.reset() + }) +} + +func (d *durationDebouncer) reset() { + d.firstCall = false + if d.timer != nil { + d.timer.Stop() + d.timer = nil + } + d.startTime = time.Time{} +} diff --git a/debounce_time_test.go b/debounce_time_test.go new file mode 100644 index 0000000..816e7bc --- /dev/null +++ b/debounce_time_test.go @@ -0,0 +1,92 @@ +package debounce + +import ( + "sync" + "testing" + "time" +) + +var ( + mockNowFunc func() time.Time + mockNowMutex sync.Mutex +) + +func mockNow() time.Time { + mockNowMutex.Lock() + defer mockNowMutex.Unlock() + return mockNowFunc() +} + +func setMockNow(t time.Time) { + mockNowMutex.Lock() + defer mockNowMutex.Unlock() + mockNowFunc = func() time.Time { + return t + } +} + +func init() { + now = mockNow +} + +func TestTimeDebounce(t *testing.T) { + tests := []struct { + name string + interval time.Duration + maxDuration time.Duration + timeSteps []time.Duration + }{ + { + name: "Single call within interval", + interval: 100 * time.Millisecond, + maxDuration: 500 * time.Millisecond, + timeSteps: []time.Duration{100 * time.Millisecond, 50 * time.Millisecond}, + }, + { + name: "Multiple calls within interval", + interval: 100 * time.Millisecond, + maxDuration: 500 * time.Millisecond, + timeSteps: []time.Duration{50 * time.Millisecond, 50 * time.Millisecond, 200 * time.Millisecond}, + }, + { + name: "Single call at max duration", + interval: 100 * time.Millisecond, + maxDuration: 200 * time.Millisecond, + timeSteps: []time.Duration{100 * time.Millisecond, 100 * time.Millisecond}, + }, + { + name: "Multiple calls at max duration", + interval: 150 * time.Millisecond, + maxDuration: 300 * time.Millisecond, + timeSteps: []time.Duration{50 * time.Millisecond, 50 * time.Millisecond, 200 * time.Millisecond}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + callCount := 0 + mu := sync.Mutex{} + f := func() { + mu.Lock() + defer mu.Unlock() + callCount++ + } + + d := NewDebounceByDuration(tt.interval, tt.maxDuration) + start := time.Now() + setMockNow(start) + + for _, step := range tt.timeSteps { + setMockNow(start.Add(step)) + d(f) + time.Sleep(step) + } + + mu.Lock() + if callCount != 1 { + t.Errorf("expected 1 calls, got %d", callCount) + } + mu.Unlock() + }) + } +}