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