diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 81325ad..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ - -github: [bep] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d64d93..9d11d16 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ on: push: - branches: [ master ] + branches: [ main ] pull_request: workflow_dispatch: name: Test @@ -10,8 +10,8 @@ jobs: test: strategy: matrix: - go-version: [1.18.x] - os: [ubuntu-latest, windows-latest] + go-version: [1.22.x] + os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - name: Install Go @@ -41,10 +41,3 @@ jobs: run: golint ./... - name: Test run: go test -race . - - name: Upload coverage - if: success() && matrix.os == 'ubuntu-latest' - run: | - curl -s https://codecov.io/bash | bash - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - shell: bash \ No newline at end of file diff --git a/README.md b/README.md index b26a64b..a0cce7b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,7 @@ # Go Debounce -[![Tests on Linux, MacOS and Windows](https://github.com/bep/debounce/workflows/Test/badge.svg)](https://github.com/bep/debounce/actions?query=workflow:Test) -[![GoDoc](https://godoc.org/github.com/bep/debounce?status.svg)](https://godoc.org/github.com/bep/debounce) -[![Go Report Card](https://goreportcard.com/badge/github.com/bep/debounce)](https://goreportcard.com/report/github.com/bep/debounce) -[![codecov](https://codecov.io/gh/bep/debounce/branch/master/graph/badge.svg)](https://codecov.io/gh/bep/debounce) -[![Release](https://img.shields.io/github/release/bep/debounce.svg?style=flat-square)](https://github.com/bep/debounce/releases/latest) +[![Tests on Linux, MacOS and Windows](https://github.com/qpoint-io/debounce/workflows/Test/badge.svg)](https://github.com/qpoint-io/debounce/actions?query=workflow:Test) +[![GoDoc](https://godoc.org/github.com/qpoint-io/debounce?status.svg)](https://godoc.org/github.com/qpoint-io/debounce) ## Example @@ -16,7 +13,7 @@ func ExampleNew() { atomic.AddUint64(&counter, 1) } - debounced := debounce.New(100 * time.Millisecond) + debounced := debounce.New(100 * time.Millisecond, 50) for i := 0; i < 3; i++ { for j := 0; j < 10; j++ { diff --git a/debounce.go b/debounce.go index 793d5ed..2e19d8b 100644 --- a/debounce.go +++ b/debounce.go @@ -13,13 +13,18 @@ import ( "time" ) -// New returns a debounced function that takes another functions as its argument. +// New returns a debounced function that takes another function as its argument. // This function will be called when the debounced function stops being called -// for the given duration. +// for the given duration, provided the maximum count hasn't been exceeded. +// Once the maximum count is exceeded, the function is executed one last time +// and the debouncer is reset. // The debounced function can be invoked with different functions, if needed, // the last one will win. -func New(after time.Duration) func(f func()) { - d := &debouncer{after: after} +func New(after time.Duration, countLimit uint64) func(f func()) { + d := &debouncer{ + after: after, + countLimit: countLimit, + } return func(f func()) { d.add(f) @@ -27,17 +32,44 @@ func New(after time.Duration) func(f func()) { } type debouncer struct { - mu sync.Mutex - after time.Duration - timer *time.Timer + mu sync.Mutex + after time.Duration + timer *time.Timer + count uint64 + countLimit uint64 } func (d *debouncer) add(f func()) { d.mu.Lock() defer d.mu.Unlock() + // Increment the count + d.count++ + + // If count exceeds maxCount, execute the function and reset + if d.count > d.countLimit { + if d.timer != nil { + d.timer.Stop() + d.timer = nil + } + + f() + + // Reset the count for the next iteration + d.count = 0 + return + } + if d.timer != nil { d.timer.Stop() } - d.timer = time.AfterFunc(d.after, f) + d.timer = time.AfterFunc(d.after, func() { + d.mu.Lock() + defer d.mu.Unlock() + + f() + + // Reset the count after the function is executed + d.count = 0 + }) } diff --git a/debounce_test.go b/debounce_test.go index 16f864d..d6a9ff6 100644 --- a/debounce_test.go +++ b/debounce_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/bep/debounce" + "github.com/qpoint-io/debounce" ) func TestDebounce(t *testing.T) { @@ -28,7 +28,7 @@ func TestDebounce(t *testing.T) { atomic.AddUint64(&counter2, 2) } - debounced := debounce.New(100 * time.Millisecond) + debounced := debounce.New(100*time.Millisecond, 1000) for i := 0; i < 3; i++ { for j := 0; j < 10; j++ { @@ -64,7 +64,7 @@ func TestDebounceConcurrentAdd(t *testing.T) { var flag uint64 - debounced := debounce.New(100 * time.Millisecond) + debounced := debounce.New(100*time.Millisecond, 1000) for i := 0; i < 10; i++ { wg.Add(1) @@ -95,7 +95,7 @@ func TestDebounceDelayed(t *testing.T) { atomic.AddUint64(&counter1, 1) } - debounced := debounce.New(100 * time.Millisecond) + debounced := debounce.New(100*time.Millisecond, 1000) time.Sleep(110 * time.Millisecond) @@ -117,7 +117,7 @@ func BenchmarkDebounce(b *testing.B) { atomic.AddUint64(&counter, 1) } - debounced := debounce.New(100 * time.Millisecond) + debounced := debounce.New(100*time.Millisecond, 1000) b.ResetTimer() for i := 0; i < b.N; i++ { @@ -137,7 +137,7 @@ func ExampleNew() { atomic.AddUint64(&counter, 1) } - debounced := debounce.New(100 * time.Millisecond) + debounced := debounce.New(100*time.Millisecond, 1000) for i := 0; i < 3; i++ { for j := 0; j < 10; j++ { @@ -152,3 +152,25 @@ func ExampleNew() { fmt.Println("Counter is", c) // Output: Counter is 3 } + +func TestDebouncerCountLimit(t *testing.T) { + after := 1 * time.Hour + countLimit := uint64(3) + + var execCount uint64 + + f := func() { + atomic.AddUint64(&execCount, 1) + } + + debounced := debounce.New(after, countLimit) + + for i := uint64(0); i < countLimit+1; i++ { + debounced(f) + } + + expectedExecCount := uint64(1) + if atomic.LoadUint64(&execCount) != expectedExecCount { + t.Errorf("Expected function to be executed %d time(s), but got %d", expectedExecCount, atomic.LoadUint64(&execCount)) + } +} 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() + }) + } +} diff --git a/go.mod b/go.mod index 3b60dab..e0e67ee 100644 --- a/go.mod +++ b/go.mod @@ -1 +1,3 @@ -module github.com/bep/debounce +module github.com/qpoint-io/debounce + +go 1.22.0