Skip to content

Commit fb3c897

Browse files
committed
Feat: Add comprehensive tests for ThrottledDebouncer behavior
1 parent 92e2995 commit fb3c897

File tree

2 files changed

+308
-12
lines changed

2 files changed

+308
-12
lines changed

src/CodeOfChaos.Extensions/Debouncers/Throttled/ThrottledDebouncerBase.cs

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public abstract class ThrottledDebouncerBase<T> : IAsyncDisposable {
1919
private bool _isDisposed;
2020
private T? _latestValue;
2121
private DateTime _lastExecuteTime = DateTime.MinValue;
22+
private DateTime _firstCallTime = DateTime.MinValue;
23+
2224

2325
// -----------------------------------------------------------------------------------------------------------------
2426
// Methods
@@ -32,6 +34,9 @@ protected async Task DebouncerLogicAsync(T? value = default, CancellationToken c
3234
await _semaphore.WaitAsync(ct);
3335
try {
3436
_latestValue = value;
37+
if (_firstCallTime == DateTime.MinValue) {
38+
_firstCallTime = DateTime.UtcNow;
39+
}
3540

3641
if (_cts is not null) {
3742
await _cts.CancelAsync();
@@ -44,21 +49,23 @@ protected async Task DebouncerLogicAsync(T? value = default, CancellationToken c
4449

4550
_debounceTask = Task.Run(async () => {
4651
try {
47-
DateTime now = DateTime.UtcNow;
48-
double timeSinceLastExecute = (now - _lastExecuteTime).TotalMilliseconds;
52+
DateTime taskStartTime = DateTime.UtcNow;
4953

50-
if (_lastExecuteTime != DateTime.MinValue && timeSinceLastExecute >= ThrottleMs) {
51-
_lastExecuteTime = now;
54+
DateTime referenceTime = _lastExecuteTime != DateTime.MinValue ? _lastExecuteTime : _firstCallTime;
55+
double timeSinceReference = (taskStartTime - referenceTime).TotalMilliseconds;
56+
57+
// Execute immediately due to throttling
58+
if (timeSinceReference >= ThrottleMs) {
59+
_lastExecuteTime = taskStartTime;
5260
await InvokeCallbackAsync(_latestValue!, ct);
5361
return;
5462
}
5563

5664
await Task.Delay(DebounceMs, debounceToken);
5765

5866
if (debounceToken.IsCancellationRequested) return;
59-
_lastExecuteTime = now;
67+
_lastExecuteTime = taskStartTime;
6068
await InvokeCallbackAsync(_latestValue!, ct);
61-
6269
}
6370
catch (OperationCanceledException) {
6471
// Ignore cancellation
@@ -69,25 +76,25 @@ protected async Task DebouncerLogicAsync(T? value = default, CancellationToken c
6976
_semaphore.Release();
7077
}
7178
}
72-
79+
7380
public async ValueTask DisposeAsync() {
7481
if (_isDisposed) return;
7582

7683
_isDisposed = true;
7784

7885
await _semaphore.WaitAsync();
7986
try {
80-
if (_cts is not null) {
81-
await _cts.CancelAsync();
82-
_cts.Dispose();
83-
}
84-
8587
if (_debounceTask is not null) {
8688
try { await _debounceTask; }
8789
catch {
8890
// Ignore
8991
}
9092
}
93+
94+
if (_cts is not null) {
95+
await _cts.CancelAsync();
96+
_cts.Dispose();
97+
}
9198
}
9299
finally {
93100
_semaphore.Dispose();
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
using CodeOfChaos.Extensions.Debouncers;
5+
6+
namespace Tests.CodeOfChaos.Extensions.Debouncers.Throttled;
7+
// ---------------------------------------------------------------------------------------------------------------------
8+
// Code
9+
// ---------------------------------------------------------------------------------------------------------------------
10+
// ReSharper disable ConvertToLocalFunction
11+
public class ThrottledDebouncerGenericTests {
12+
13+
[Test]
14+
public async Task ThrottledDebouncer_ShouldPassValueToCallback() {
15+
// Arrange
16+
string? receivedValue = null;
17+
Action<string> callback = value => {
18+
receivedValue = value;
19+
};
20+
21+
// Act
22+
await using ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 100, throttleMs: 500);
23+
await debouncer.InvokeDebouncedAsync("test");
24+
await Task.Delay(150);
25+
26+
// Assert
27+
await Assert.That(receivedValue).IsEqualTo("test");
28+
}
29+
30+
[Test]
31+
public async Task ThrottledDebouncer_MultipleValues_ShouldUseLastValue() {
32+
// Arrange
33+
string? receivedValue = null;
34+
Func<string, Task> callback = value => {
35+
receivedValue = value;
36+
return Task.CompletedTask;
37+
};
38+
39+
// Act
40+
await using ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 100, throttleMs: 500);
41+
await debouncer.InvokeDebouncedAsync("first");
42+
await debouncer.InvokeDebouncedAsync("second");
43+
await debouncer.InvokeDebouncedAsync("third");
44+
await Task.Delay(150);
45+
46+
// Assert
47+
await Assert.That(receivedValue).IsEqualTo("third");
48+
}
49+
50+
[Test]
51+
public async Task ThrottledDebouncer_ThrottleBehavior_ShouldExecuteImmediatelyAfterThrottleTime() {
52+
// Arrange
53+
var receivedValues = new List<(string Value, DateTime Time)>();
54+
Func<string, Task> callback = value => {
55+
receivedValues.Add((value, DateTime.UtcNow));
56+
return Task.CompletedTask;
57+
};
58+
59+
// Act
60+
await using ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 100, throttleMs: 200);
61+
62+
await debouncer.InvokeDebouncedAsync("first");
63+
await Task.Delay(150); // First execution happens after debounce
64+
65+
await debouncer.InvokeDebouncedAsync("second");
66+
await debouncer.InvokeDebouncedAsync("third");
67+
await debouncer.InvokeDebouncedAsync("fourth");
68+
await Task.Delay(100); // This should trigger throttle behavior (immediate execution)
69+
70+
await Task.Delay(50); // Give time for execution
71+
72+
// Assert
73+
await Assert.That(receivedValues).HasCount().EqualTo(2);
74+
await Assert.That(receivedValues[0].Value).IsEqualTo("first");
75+
await Assert.That(receivedValues[1].Value).IsEqualTo("fourth");
76+
77+
// Second execution should happen much faster due to throttling
78+
double timeBetweenExecutions = (receivedValues[1].Time - receivedValues[0].Time).TotalMilliseconds;
79+
await Assert.That(timeBetweenExecutions).IsLessThan(200); // Should be immediate due to throttle
80+
}
81+
82+
[Test]
83+
public async Task ThrottledDebouncer_ContinuousRequests_ShouldRespectThrottleInterval() {
84+
// Arrange
85+
var @lock = new Lock();
86+
var receivedValues = new List<string>();
87+
int executionCount = 0;
88+
Action<string> callback = value => {
89+
lock (@lock) {
90+
receivedValues.Add(value);
91+
}
92+
Interlocked.Increment(ref executionCount);
93+
};
94+
95+
// Act
96+
ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 50, throttleMs: 150);
97+
98+
var max = DateTime.UtcNow.AddMilliseconds(400);
99+
int counter = 0;
100+
while (DateTime.UtcNow < max) {
101+
await debouncer.InvokeDebouncedAsync($"value{counter++}");
102+
await Task.Delay(25);
103+
}
104+
105+
await Task.Delay(1000); // Wait for final execution
106+
// await debouncer.FlushAsync();
107+
108+
109+
// Assert
110+
await Assert.That(executionCount).IsGreaterThan(1);
111+
await Assert.That(receivedValues).HasCount().EqualTo(executionCount);
112+
}
113+
114+
[Test]
115+
public async Task ThrottledDebouncer_ContinuousRequests_ShouldRespectThrottleInterval_v2() {
116+
// Arrange
117+
var @lock = new Lock();
118+
var receivedValues = new List<string>();
119+
int executionCount = 0;
120+
Action<string> callback = value => {
121+
lock (@lock) {
122+
receivedValues.Add(value);
123+
}
124+
Interlocked.Increment(ref executionCount);
125+
};
126+
127+
// Act
128+
ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 50, throttleMs: 150);
129+
130+
// First burst - should execute after debounce (50ms)
131+
await Task.WhenAll(
132+
debouncer.InvokeDebouncedAsync("burst1-1"),
133+
debouncer.InvokeDebouncedAsync("burst1-2"),
134+
debouncer.InvokeDebouncedAsync("burst1-3")
135+
);
136+
137+
await Task.Delay(75); // Wait for first execution (longer than debounce)
138+
139+
// Second burst after throttle period - should execute immediately
140+
await Task.Delay(100); // Total ~175ms from start, exceeds throttle (150ms)
141+
await Task.WhenAll(
142+
debouncer.InvokeDebouncedAsync("burst2-1"),
143+
debouncer.InvokeDebouncedAsync("burst2-2")
144+
);
145+
146+
await Task.Delay(25); // Short wait for immediate execution
147+
148+
// Third burst - should be debounced normally
149+
await Task.WhenAll(
150+
debouncer.InvokeDebouncedAsync("burst3-1"),
151+
debouncer.InvokeDebouncedAsync("burst3-2")
152+
);
153+
154+
await Task.Delay(75); // Wait for final debounced execution
155+
156+
// Assert
157+
await Assert.That(executionCount).IsGreaterThanOrEqualTo(2);
158+
await Assert.That(executionCount).IsLessThanOrEqualTo(3);
159+
await Assert.That(receivedValues).HasCount().EqualTo(executionCount);
160+
161+
}
162+
163+
[Test]
164+
public async Task ThrottledDebouncer_ConcurrentValues_ShouldBeThreadSafe() {
165+
// Arrange
166+
var receivedValues = new List<string>();
167+
Func<string, Task> callback = value => {
168+
lock (receivedValues) {
169+
receivedValues.Add(value);
170+
}
171+
return Task.CompletedTask;
172+
};
173+
174+
// Act
175+
ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 100, throttleMs: 300);
176+
IEnumerable<Task> tasks = Enumerable.Range(0, 10)
177+
.Select(i => debouncer.InvokeDebouncedAsync($"value{i}"));
178+
179+
await Task.WhenAll(tasks);
180+
await Task.Delay(150);
181+
182+
// Assert - May have 1 or 2 executions depending on timing (debounce + possible throttle)
183+
await Assert.That(receivedValues.Count).IsGreaterThanOrEqualTo(1);
184+
await Assert.That(receivedValues.Count).IsLessThanOrEqualTo(2);
185+
}
186+
187+
[Test]
188+
public async Task ThrottledDebouncer_AfterDispose_ShouldThrowObjectDisposedException() {
189+
// Arrange
190+
Func<string, Task> callback = _ => Task.CompletedTask;
191+
ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 100, throttleMs: 300);
192+
193+
// Act
194+
await debouncer.DisposeAsync();
195+
196+
// Assert
197+
await Assert.ThrowsAsync<ObjectDisposedException>(async () =>
198+
await debouncer.InvokeDebouncedAsync("test")
199+
);
200+
}
201+
202+
[Test]
203+
public async Task ThrottledDebouncer_MultipleDispose_ShouldBeIdempotent() {
204+
// Arrange
205+
Func<string, Task> callback = _ => Task.CompletedTask;
206+
ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 100, throttleMs: 300);
207+
208+
// Act & Assert
209+
await debouncer.DisposeAsync();
210+
await debouncer.DisposeAsync(); // Should not throw
211+
}
212+
213+
[Test]
214+
public async Task ThrottledDebouncer_DefaultValues_ShouldUseDefaultDebounceAndThrottleTimes() {
215+
// Arrange
216+
var executionTimes = new List<DateTime>();
217+
Func<string, Task> callback = _ => {
218+
executionTimes.Add(DateTime.UtcNow);
219+
return Task.CompletedTask;
220+
};
221+
222+
// Act
223+
await using ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback); // Using defaults
224+
225+
DateTime startTime = DateTime.UtcNow;
226+
await debouncer.InvokeDebouncedAsync("test");
227+
await Task.Delay(150); // Should execute after default debounce (100ms)
228+
229+
// Assert
230+
await Assert.That(executionTimes).HasCount().EqualTo(1);
231+
double executionDelay = (executionTimes[0] - startTime).TotalMilliseconds;
232+
await Assert.That(executionDelay).IsGreaterThanOrEqualTo(95); // Account for timing variations
233+
}
234+
235+
[Test]
236+
public async Task ThrottledDebouncer_ZeroDebounce_ShouldExecuteImmediately() {
237+
// Arrange
238+
string? receivedValue = null;
239+
var executionTime = DateTime.MinValue;
240+
Func<string, Task> callback = value => {
241+
receivedValue = value;
242+
executionTime = DateTime.UtcNow;
243+
return Task.CompletedTask;
244+
};
245+
246+
// Act
247+
await using ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 0, throttleMs: 100);
248+
249+
DateTime startTime = DateTime.UtcNow;
250+
await debouncer.InvokeDebouncedAsync("immediate");
251+
await Task.Delay(50); // Give time for execution
252+
253+
// Assert
254+
await Assert.That(receivedValue).IsEqualTo("immediate");
255+
double executionDelay = (executionTime - startTime).TotalMilliseconds;
256+
await Assert.That(executionDelay).IsLessThan(50); // Should execute very quickly
257+
}
258+
259+
[Test]
260+
public async Task ThrottledDebouncer_LongRunningCallback_ShouldNotBlockSubsequentCalls() {
261+
// Arrange
262+
int executionCount = 0;
263+
bool isFirstCallRunning = false;
264+
Func<string, Task> callback = async value => {
265+
if (value == "slow") {
266+
isFirstCallRunning = true;
267+
await Task.Delay(200); // Simulate long-running operation
268+
isFirstCallRunning = false;
269+
}
270+
Interlocked.Increment(ref executionCount);
271+
};
272+
273+
// Act
274+
await using ThrottledDebouncer<string> debouncer = ThrottledDebouncer<string>.FromDelegate(callback, debounceMs: 50, throttleMs: 100);
275+
276+
await debouncer.InvokeDebouncedAsync("slow");
277+
await Task.Delay(75); // Wait for first call to start
278+
279+
await Assert.That(isFirstCallRunning).IsTrue(); // First call should be running
280+
281+
await debouncer.InvokeDebouncedAsync("fast");
282+
await Task.Delay(100); // Wait for second call
283+
284+
await Task.Delay(200); // Wait for both calls to complete
285+
286+
// Assert
287+
await Assert.That(executionCount).IsEqualTo(2);
288+
}
289+
}

0 commit comments

Comments
 (0)