* Add new Debounce class

* Add Unit Tests

* Fix Unit Test

* Add Debounce Timer

* Refactore names

* Fix Task MultipleCalls

* Refactoring to create a common class Debounce
This commit is contained in:
Denis Voituron 2024-09-16 21:10:40 +02:00 коммит произвёл GitHub
Родитель 3c21b67522
Коммит f68fb1e54a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
6 изменённых файлов: 728 добавлений и 0 удалений

Просмотреть файл

@ -0,0 +1,13 @@
// ------------------------------------------------------------------------
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------
namespace Microsoft.FluentUI.AspNetCore.Components.Utilities;
/// <summary>
/// The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
/// This ensures that the action is only invoked once after the calls have stopped for the specified duration.
/// </summary>
public sealed class Debounce : InternalDebounce.DebounceTask
{
}

Просмотреть файл

@ -0,0 +1,61 @@
// ------------------------------------------------------------------------
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------
namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
/// <summary>
/// The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
/// This ensures that the action is only invoked once after the calls have stopped for the specified duration.
/// </summary>
[Obsolete("Use Debounce, which inherits from DebounceTask.")]
internal class DebounceAction : IDisposable
{
private bool _disposed;
private readonly System.Timers.Timer _timer = new();
private TaskCompletionSource? _taskCompletionSource;
/// <summary>
/// Gets a value indicating whether the DebounceTask dispatcher is busy.
/// </summary>
public bool Busy => _taskCompletionSource?.Task.Status == TaskStatus.Running && !_disposed;
/// <summary>
/// Gets the current task.
/// </summary>
public Task CurrentTask => _taskCompletionSource?.Task ?? Task.CompletedTask;
/// <summary>
/// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
/// </summary>
/// <param name="milliseconds"></param>
/// <param name="action"></param>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public void Run(int milliseconds, Func<Task> action)
{
// Check arguments
if (milliseconds <= 0)
{
throw new ArgumentOutOfRangeException(nameof(milliseconds), milliseconds, "The milliseconds must be greater than to zero.");
}
ArgumentNullException.ThrowIfNull(action);
// DebounceTask
if (!_disposed)
{
_taskCompletionSource = _timer.Debounce(action, milliseconds);
}
}
/// <summary>
/// Releases all resources used by the DebounceTask dispatcher.
/// </summary>
public void Dispose()
{
_taskCompletionSource = null;
_timer.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}

Просмотреть файл

@ -0,0 +1,84 @@
// ------------------------------------------------------------------------
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------
namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
/// <summary>
/// The DebounceTask dispatcher delays the invocation of an action until a predetermined interval has elapsed since the last call.
/// This ensures that the action is only invoked once after the calls have stopped for the specified duration.
/// </summary>
public class DebounceTask : IDisposable
{
#if NET9_0_OR_GREATER
private readonly System.Threading.Lock _syncRoot = new();
#else
private readonly object _syncRoot = new();
#endif
private bool _disposed;
private Task? _task;
private CancellationTokenSource? _cts;
/// <summary>
/// Gets a value indicating whether the DebounceTask dispatcher is busy.
/// </summary>
public bool Busy => _task?.Status == TaskStatus.Running && !_disposed;
/// <summary>
/// Gets the current task.
/// </summary>
public Task CurrentTask => _task ?? Task.CompletedTask;
/// <summary>
/// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
/// </summary>
/// <param name="milliseconds"></param>
/// <param name="action"></param>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public void Run(int milliseconds, Func<Task> action)
{
// Check arguments
if (milliseconds <= 0)
{
throw new ArgumentOutOfRangeException(nameof(milliseconds), milliseconds, "The milliseconds must be greater than to zero.");
}
ArgumentNullException.ThrowIfNull(action);
// Cancel the previous task if it's still running
_cts?.Cancel();
// Create a new cancellation token source
_cts = new CancellationTokenSource();
try
{
// Wait for the specified time
_task = Task.Delay(TimeSpan.FromMilliseconds(milliseconds), _cts.Token)
.ContinueWith(t =>
{
if (!_disposed && !_cts.IsCancellationRequested)
{
lock (_syncRoot)
{
_ = action.Invoke();
}
}
}, _cts.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current);
}
catch (TaskCanceledException)
{
// Task was canceled
}
}
/// <summary>
/// Releases all resources used by the DebounceTask dispatcher.
/// </summary>
public void Dispose()
{
_disposed = true;
_cts?.Cancel();
}
}

Просмотреть файл

@ -0,0 +1,102 @@
// ------------------------------------------------------------------------
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------
using System.Collections.Concurrent;
namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
/// <summary>
/// Extension methods for <see cref="System.Timers.Timer"/>.
/// </summary>
/// <remarks>
/// Inspired from Microsoft.Toolkit.Uwp.UI.DispatcherQueueTimerExtensions
/// </remarks>
internal static class DispatcherTimerExtensions
{
private static readonly ConcurrentDictionary<System.Timers.Timer, TimerDebounceItem> _debounceInstances = new();
/// <summary>
/// Delays the invocation of an action until a predetermined interval has elapsed since the last call.
/// </summary>
/// <param name="timer"></param>
/// <param name="action"></param>
/// <param name="interval"></param>
/// <returns></returns>
public static TaskCompletionSource Debounce(this System.Timers.Timer timer, Func<Task> action, double interval)
{
// Check and stop any existing timer
timer.Stop();
// Reset timer parameters
timer.Elapsed -= Timer_Elapsed;
timer.Interval = interval;
// If we're not in immediate mode, then we'll execute when the current timer expires.
timer.Elapsed += Timer_Elapsed;
// Store/Update function
TimerDebounceItem updateValueFactory(System.Timers.Timer k, TimerDebounceItem v) => v.UpdateAction(action);
var item = _debounceInstances.AddOrUpdate(
key: timer,
addValue: new TimerDebounceItem()
{
Status = new TaskCompletionSource(),
Action = action,
},
updateValueFactory: updateValueFactory);
// Start the timer to keep track of the last call here.
timer.Start();
return item.Status;
}
/// <summary>
/// Timer elapsed event handler.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private static void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
// This event is only registered/run if we weren't in immediate mode above
if (sender is System.Timers.Timer timer)
{
timer.Elapsed -= Timer_Elapsed;
timer.Stop();
if (_debounceInstances.TryRemove(timer, out var item))
{
_ = (item?.Action.Invoke());
item?.Status.SetResult();
}
}
}
/// <summary>
/// Timer debounce item.
/// </summary>
private class TimerDebounceItem
{
/// <summary>
/// Gets the task completion source.
/// </summary>
public required TaskCompletionSource Status { get; init; }
/// <summary>
/// Gets or sets the action to execute.
/// </summary>
public required Func<Task> Action { get; set; }
/// <summary>
/// Updates the action to execute.
/// </summary>
/// <param name="action"></param>
/// <returns></returns>
public TimerDebounceItem UpdateAction(Func<Task> action)
{
Action = action;
return this;
}
}
}

Просмотреть файл

@ -0,0 +1,236 @@
// ------------------------------------------------------------------------
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Threading.Tasks;
using Bunit;
using Microsoft.FluentUI.AspNetCore.Components.Utilities;
using Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
using Microsoft.VisualStudio.TestPlatform.Utilities;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities;
#pragma warning disable CS0618 // Type or member is obsolete
public class DebounceActionTests
{
private readonly ITestOutputHelper Output;
public DebounceActionTests(ITestOutputHelper output)
{
Output = output;
}
[Fact]
public async Task Debounce_Default()
{
// Arrange
var debounce = new DebounceAction();
var actionCalled = false;
var watcher = Stopwatch.StartNew();
// Act
debounce.Run(50, async () =>
{
actionCalled = true;
await Task.CompletedTask;
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.True(watcher.ElapsedMilliseconds >= 50);
Assert.True(actionCalled);
}
[Fact]
public async Task Debounce_MultipleCalls()
{
// Arrange
var debounce = new DebounceAction();
var actionCalledCount = 0;
var actionCalled = string.Empty;
// Act
debounce.Run(50, async () =>
{
actionCalled = "Step1";
actionCalledCount++;
await Task.CompletedTask;
});
debounce.Run(40, async () =>
{
actionCalled = "Step2";
actionCalledCount++;
await Task.CompletedTask;
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.Equal("Step2", actionCalled);
Assert.Equal(1, actionCalledCount);
}
[Fact]
public async Task Debounce_MultipleCalls_Async()
{
// Arrange
var debounce = new DebounceAction();
var actionCalledCount = 0;
var actionCalled = string.Empty;
// Act: simulate two async calls
_ = Task.Run(() =>
{
debounce.Run(50, async () =>
{
actionCalled = "Step1";
actionCalledCount++;
await Task.CompletedTask;
});
});
await Task.Delay(5); // To ensure the second call is made after the first one
_ = Task.Run(() =>
{
debounce.Run(40, async () =>
{
actionCalled = "Step2";
actionCalledCount++;
await Task.CompletedTask;
});
});
await Task.Delay(100); // Wait for the debounce to complete
// Assert
Assert.Equal("Step2", actionCalled);
Assert.Equal(1, actionCalledCount);
}
[Fact]
public async Task Debounce_Disposed()
{
// Arrange
var debounce = new DebounceAction();
var actionCalled = false;
// Act
debounce.Dispose();
debounce.Run(50, async () =>
{
actionCalled = true;
await Task.CompletedTask;
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.False(actionCalled);
}
[Fact]
public async Task Debounce_Busy()
{
// Arrange
var debounce = new DebounceAction();
// Act
debounce.Run(50, async () =>
{
await Task.CompletedTask;
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.False(debounce.Busy);
}
[Fact]
public async Task Debounce_Exception()
{
// Arrange
var debounce = new DebounceAction();
// Act
debounce.Run(50, async () =>
{
await Task.CompletedTask;
throw new InvalidProgramException("Error"); // Simulate an exception
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.False(debounce.Busy);
}
[Fact]
public void Debounce_DelayMustBePositive()
{
// Arrange
var debounce = new DebounceAction();
// Act
Assert.Throws<ArgumentOutOfRangeException>(() =>
{
debounce.Run(-10, () => Task.CompletedTask);
});
}
[Fact]
public async Task Debounce_FirstRunAlreadyStarted()
{
// Arrange
var debounce = new DebounceAction();
var actionCalledCount = 0;
// Act
debounce.Run(10, async () =>
{
Output.WriteLine("Step1 - Started");
await Task.Delay(100);
actionCalledCount++;
Output.WriteLine("Step1 - Completed");
});
await Task.Delay(20); // Wait for Step1 to start.
debounce.Run(10, async () =>
{
Output.WriteLine("Step2 - Started");
await Task.CompletedTask;
actionCalledCount++;
Output.WriteLine("Step2 - Completed");
});
// Wait for the debounce to complete
await debounce.CurrentTask;
await Task.Delay(200);
// Assert
Assert.Equal(2, actionCalledCount);
}
}
#pragma warning restore CS0618 // Type or member is obsolete

Просмотреть файл

@ -0,0 +1,232 @@
// ------------------------------------------------------------------------
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Threading.Tasks;
using Bunit;
using Microsoft.FluentUI.AspNetCore.Components.Utilities;
using Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce;
using Microsoft.VisualStudio.TestPlatform.Utilities;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities;
public class DebounceTaskTests
{
private readonly ITestOutputHelper Output;
public DebounceTaskTests(ITestOutputHelper output)
{
Output = output;
}
[Fact]
public async Task Debounce_Default()
{
// Arrange
var debounce = new DebounceTask();
var actionCalled = false;
var watcher = Stopwatch.StartNew();
// Act
debounce.Run(50, async () =>
{
actionCalled = true;
await Task.CompletedTask;
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.True(watcher.ElapsedMilliseconds >= 50);
Assert.True(actionCalled);
}
[Fact]
public async Task Debounce_MultipleCalls()
{
// Arrange
var debounce = new DebounceTask();
var actionCalledCount = 0;
var actionCalled = string.Empty;
// Act
debounce.Run(50, async () =>
{
actionCalled = "Step1";
actionCalledCount++;
await Task.CompletedTask;
});
debounce.Run(40, async () =>
{
actionCalled = "Step2";
actionCalledCount++;
await Task.CompletedTask;
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.Equal("Step2", actionCalled);
Assert.Equal(1, actionCalledCount);
}
[Fact]
public async Task Debounce_MultipleCalls_Async()
{
// Arrange
var debounce = new DebounceTask();
var actionCalledCount = 0;
var actionCalled = string.Empty;
// Act: simulate two async calls
_ = Task.Run(() =>
{
debounce.Run(50, async () =>
{
actionCalled = "Step1";
actionCalledCount++;
await Task.CompletedTask;
});
});
await Task.Delay(5); // To ensure the second call is made after the first one
_ = Task.Run(() =>
{
debounce.Run(40, async () =>
{
actionCalled = "Step2";
actionCalledCount++;
await Task.CompletedTask;
});
});
await Task.Delay(100); // Wait for the debounce to complete
// Assert
Assert.Equal("Step2", actionCalled);
Assert.Equal(1, actionCalledCount);
}
[Fact]
public async Task Debounce_Disposed()
{
// Arrange
var debounce = new DebounceTask();
var actionCalled = false;
// Act
debounce.Dispose();
debounce.Run(50, async () =>
{
actionCalled = true;
await Task.CompletedTask;
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.False(actionCalled);
}
[Fact]
public async Task Debounce_Busy()
{
// Arrange
var debounce = new DebounceTask();
// Act
debounce.Run(50, async () =>
{
await Task.CompletedTask;
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.False(debounce.Busy);
}
[Fact]
public async Task Debounce_Exception()
{
// Arrange
var debounce = new DebounceTask();
// Act
debounce.Run(50, async () =>
{
await Task.CompletedTask;
throw new InvalidProgramException("Error"); // Simulate an exception
});
// Wait for the debounce to complete
await debounce.CurrentTask;
// Assert
Assert.False(debounce.Busy);
}
[Fact]
public void Debounce_DelayMustBePositive()
{
// Arrange
var debounce = new DebounceTask();
// Act
Assert.Throws<ArgumentOutOfRangeException>(() =>
{
debounce.Run(-10, () => Task.CompletedTask);
});
}
[Fact]
public async Task Debounce_FirstRunAlreadyStarted()
{
// Arrange
var debounce = new DebounceTask();
var actionCalledCount = 0;
// Act
debounce.Run(10, async () =>
{
Output.WriteLine("Step1 - Started");
await Task.Delay(100);
actionCalledCount++;
Output.WriteLine("Step1 - Completed");
});
await Task.Delay(20); // Wait for Step1 to start.
debounce.Run(10, async () =>
{
Output.WriteLine("Step2 - Started");
await Task.CompletedTask;
actionCalledCount++;
Output.WriteLine("Step2 - Completed");
});
// Wait for the debounce to complete
await debounce.CurrentTask;
await Task.Delay(200);
// Assert
Assert.Equal(2, actionCalledCount);
}
}