From 09febad0970ec72ea462f4e18548add028126201 Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Wed, 18 Sep 2024 13:01:05 +0200 Subject: [PATCH 1/3] Replace Debouncer with the new DebounceTask v5 (#2678) --- ...crosoft.FluentUI.AspNetCore.Components.xml | 126 +++++++++ src/Core/Components/Base/FluentInputBase.cs | 2 +- .../Base/FluentInputBaseHandlers.cs | 4 +- .../List/FluentAutocomplete.razor.cs | 6 +- .../TreeView/FluentTreeView.razor.cs | 6 +- src/Core/Utilities/Debounce.cs | 13 + src/Core/Utilities/Debouncer.cs | 64 ----- .../InternalDebounce/DebounceAction.cs | 75 ++++++ .../InternalDebounce/DebounceTask.cs | 99 +++++++ .../DispatcherTimerExtensions.cs | 108 ++++++++ tests/Core/Utilities/DebounceActionTests.cs | 241 +++++++++++++++++ tests/Core/Utilities/DebounceTaskTests.cs | 244 ++++++++++++++++++ tests/Core/Utilities/DebouncerTests.cs | 87 ------- 13 files changed, 916 insertions(+), 159 deletions(-) create mode 100644 src/Core/Utilities/Debounce.cs delete mode 100644 src/Core/Utilities/Debouncer.cs create mode 100644 src/Core/Utilities/InternalDebounce/DebounceAction.cs create mode 100644 src/Core/Utilities/InternalDebounce/DebounceTask.cs create mode 100644 src/Core/Utilities/InternalDebounce/DispatcherTimerExtensions.cs create mode 100644 tests/Core/Utilities/DebounceActionTests.cs create mode 100644 tests/Core/Utilities/DebounceTaskTests.cs delete mode 100644 tests/Core/Utilities/DebouncerTests.cs diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index 3b3afcc4e0..e58459dabc 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -15319,6 +15319,12 @@ + + + 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. + + Initializes a new instance of the class. @@ -15374,6 +15380,126 @@ StyleBuilder + + + 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. + + + + + Gets a value indicating whether the DebounceTask dispatcher is busy. + + + + + Gets the current task. + + + + + Delays the invocation of an action until a predetermined interval has elapsed since the last call. + + + + + + + + Delays the invocation of an action until a predetermined interval has elapsed since the last call. + + + + + + + + Releases all resources used by the DebounceTask dispatcher. + + + + + 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. + + + + + Gets a value indicating whether the DebounceTask dispatcher is busy. + + + + + Gets the current task. + + + + + Delays the invocation of an action until a predetermined interval has elapsed since the last call. + + + + + + + + Delays the invocation of an action until a predetermined interval has elapsed since the last call. + + + + + + + + Releases all resources used by the DebounceTask dispatcher. + + + + + Extension methods for . + + + Inspired from Microsoft.Toolkit.Uwp.UI.DispatcherQueueTimerExtensions + + + + + Delays the invocation of an action until a predetermined interval has elapsed since the last call. + + + + + + + + + Timer elapsed event handler. + + + + + + + Timer debounce item. + + + + + Gets the task completion source. + + + + + Gets or sets the action to execute. + + + + + Updates the action to execute. + + + + Flags for a member that is JSON (de)serialized. diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index bf872b7198..9622a11e5f 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -467,7 +467,7 @@ void IDisposable.Dispose() EditContext.OnValidationStateChanged -= _validationStateChangedHandler; } - _debouncer.Dispose(); + _debounce.Dispose(); Dispose(disposing: true); } diff --git a/src/Core/Components/Base/FluentInputBaseHandlers.cs b/src/Core/Components/Base/FluentInputBaseHandlers.cs index b051cb303c..71a26eb396 100644 --- a/src/Core/Components/Base/FluentInputBaseHandlers.cs +++ b/src/Core/Components/Base/FluentInputBaseHandlers.cs @@ -6,7 +6,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentInputBase { - private readonly Debouncer _debouncer = new(); + private readonly Debounce _debounce = new(); /// /// Change the content of this input field when the user write text (based on 'OnInput' HTML event). @@ -64,7 +64,7 @@ protected virtual async Task InputHandlerAsync(ChangeEventArgs e) // TODO: To up } if (ImmediateDelay > 0) { - await _debouncer.DebounceAsync(ImmediateDelay, async () => await ChangeHandlerAsync(e)); + await _debounce.RunAsync(ImmediateDelay, async () => await ChangeHandlerAsync(e)); } else { diff --git a/src/Core/Components/List/FluentAutocomplete.razor.cs b/src/Core/Components/List/FluentAutocomplete.razor.cs index e5155e0788..6f76f7053f 100644 --- a/src/Core/Components/List/FluentAutocomplete.razor.cs +++ b/src/Core/Components/List/FluentAutocomplete.razor.cs @@ -18,7 +18,7 @@ public partial class FluentAutocomplete : ListComponentBase wh public new FluentTextField? Element { get; set; } = default!; private Virtualize? VirtualizationContainer { get; set; } - private readonly Debouncer _debouncer = new(); + private readonly Debounce _debounce = new(); /// /// Initializes a new instance of the class. @@ -295,13 +295,15 @@ protected override async Task InputHandlerAsync(ChangeEventArgs e) if (ImmediateDelay > 0) { - await _debouncer.DebounceAsync(ImmediateDelay, () => InvokeAsync(() => OnOptionsSearch.InvokeAsync(args))); + await _debounce.RunAsync(ImmediateDelay, () => InvokeAsync(() => OnOptionsSearch.InvokeAsync(args))); } else { await OnOptionsSearch.InvokeAsync(args); } + Console.WriteLine($"args.Items: {args.Items?.Count()}"); + Items = args.Items?.Take(MaximumOptionsSearch); SelectableItem = Items != null diff --git a/src/Core/Components/TreeView/FluentTreeView.razor.cs b/src/Core/Components/TreeView/FluentTreeView.razor.cs index 16abfc8d61..e6004b5f74 100644 --- a/src/Core/Components/TreeView/FluentTreeView.razor.cs +++ b/src/Core/Components/TreeView/FluentTreeView.razor.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentTreeView : FluentComponentBase, IDisposable { private readonly Dictionary _allItems = []; - private readonly Debouncer _currentSelectedChangedDebouncer = new(); + private readonly Debounce _currentSelectedChangedDebounce = new(); private bool _disposed; public static string LoadingMessage = "Loading..."; @@ -154,7 +154,7 @@ internal async Task HandleCurrentSelectedChangeAsync(TreeChangeEventArgs args) } var previouslySelected = CurrentSelected; - await _currentSelectedChangedDebouncer.DebounceAsync(50, () => InvokeAsync(async () => + await _currentSelectedChangedDebounce.RunAsync(50, () => InvokeAsync(async () => { CurrentSelected = treeItem?.Selected == true ? treeItem : null; if (CurrentSelected != previouslySelected && CurrentSelectedChanged.HasDelegate) @@ -190,7 +190,7 @@ protected virtual void Dispose(bool disposing) if (disposing) { - _currentSelectedChangedDebouncer?.Dispose(); + _currentSelectedChangedDebounce?.Dispose(); _allItems.Clear(); } diff --git a/src/Core/Utilities/Debounce.cs b/src/Core/Utilities/Debounce.cs new file mode 100644 index 0000000000..6cac0f44b1 --- /dev/null +++ b/src/Core/Utilities/Debounce.cs @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components.Utilities; + +/// +/// 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. +/// +public sealed class Debounce : InternalDebounce.DebounceTask +{ +} diff --git a/src/Core/Utilities/Debouncer.cs b/src/Core/Utilities/Debouncer.cs deleted file mode 100644 index 27179a2803..0000000000 --- a/src/Core/Utilities/Debouncer.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Timer = System.Timers.Timer; - -namespace Microsoft.FluentUI.AspNetCore.Components.Utilities; - -internal sealed class Debouncer : IDisposable -{ - private readonly object _syncRoot = new(); - private bool _disposed; - private Timer? _timer; - private TaskCompletionSource? _taskCompletionSource; - - public bool Busy => _timer is not null && !_disposed; - - public Task DebounceAsync(double milliseconds, Func action) - { - ArgumentNullException.ThrowIfNull(action); - - lock (_syncRoot) - { - _taskCompletionSource?.TrySetResult(false); - _taskCompletionSource = null; - - _timer?.Dispose(); - _timer = null; - - Timer newTimer = _timer = new Timer(milliseconds); - TaskCompletionSource newTaskCompletionSource = _taskCompletionSource = new TaskCompletionSource(); - - newTimer.Elapsed += async (_, _) => - { - newTimer.Stop(); - try - { - if (!_disposed) - { - await action(); - } - } - finally - { - lock (_syncRoot) - { - if (_timer == newTimer) - { - _timer.Dispose(); - _timer = null; - newTaskCompletionSource?.SetResult(!_disposed); - } - } - } - }; - - newTimer.Start(); - return newTaskCompletionSource.Task; - } - } - - public void Dispose() - { - _disposed = true; - _timer?.Dispose(); - } -} - diff --git a/src/Core/Utilities/InternalDebounce/DebounceAction.cs b/src/Core/Utilities/InternalDebounce/DebounceAction.cs new file mode 100644 index 0000000000..c91a8dc320 --- /dev/null +++ b/src/Core/Utilities/InternalDebounce/DebounceAction.cs @@ -0,0 +1,75 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce; + +/// +/// 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. +/// +[Obsolete("Use Debounce, which inherits from DebounceTask.")] +internal class DebounceAction : IDisposable +{ + private bool _disposed; + private readonly System.Timers.Timer _timer = new(); + private TaskCompletionSource? _taskCompletionSource; + + /// + /// Gets a value indicating whether the DebounceTask dispatcher is busy. + /// + public bool Busy => _taskCompletionSource?.Task.Status == TaskStatus.Running && !_disposed; + + /// + /// Gets the current task. + /// + public Task CurrentTask => _taskCompletionSource?.Task ?? Task.CompletedTask; + + /// + /// Delays the invocation of an action until a predetermined interval has elapsed since the last call. + /// + /// + /// + /// + public void Run(int milliseconds, Func 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); + } + } + + /// + /// Delays the invocation of an action until a predetermined interval has elapsed since the last call. + /// + /// + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Required to return the current Task.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MA0042:Do not use blocking calls in an async method", Justification = "Special case using CurrentTask")] + public Task RunAsync(int milliseconds, Func action) + { + Run(milliseconds, action); + return CurrentTask; + } + + /// + /// Releases all resources used by the DebounceTask dispatcher. + /// + public void Dispose() + { + _taskCompletionSource = null; + _timer.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } +} diff --git a/src/Core/Utilities/InternalDebounce/DebounceTask.cs b/src/Core/Utilities/InternalDebounce/DebounceTask.cs new file mode 100644 index 0000000000..1aecb0c4f8 --- /dev/null +++ b/src/Core/Utilities/InternalDebounce/DebounceTask.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce; + +/// +/// 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. +/// +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; + + /// + /// Gets a value indicating whether the DebounceTask dispatcher is busy. + /// + public bool Busy => _task?.Status == TaskStatus.Running && !_disposed; + + /// + /// Gets the current task. + /// + public Task CurrentTask => _task ?? Task.CompletedTask; + + /// + /// Delays the invocation of an action until a predetermined interval has elapsed since the last call. + /// + /// + /// + /// + public void Run(int milliseconds, Func 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 + } + } + + /// + /// Delays the invocation of an action until a predetermined interval has elapsed since the last call. + /// + /// + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Required to return the current Task.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MA0042:Do not use blocking calls in an async method", Justification = "Special case using CurrentTask")] + public Task RunAsync(int milliseconds, Func action) + { + Run(milliseconds, action); + return CurrentTask; + } + + /// + /// Releases all resources used by the DebounceTask dispatcher. + /// + public void Dispose() + { + _disposed = true; + _cts?.Cancel(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Core/Utilities/InternalDebounce/DispatcherTimerExtensions.cs b/src/Core/Utilities/InternalDebounce/DispatcherTimerExtensions.cs new file mode 100644 index 0000000000..be1799fcd5 --- /dev/null +++ b/src/Core/Utilities/InternalDebounce/DispatcherTimerExtensions.cs @@ -0,0 +1,108 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce; + +/// +/// Extension methods for . +/// +/// +/// Inspired from Microsoft.Toolkit.Uwp.UI.DispatcherQueueTimerExtensions +/// +internal static class DispatcherTimerExtensions +{ + private static readonly ConcurrentDictionary _debounceInstances = new(); + + /// + /// Delays the invocation of an action until a predetermined interval has elapsed since the last call. + /// + /// + /// + /// + /// + public static TaskCompletionSource Debounce(this System.Timers.Timer timer, Func 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.Status.SetCanceled(); + v.Status = new TaskCompletionSource(); + return 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; + } + + /// + /// Timer elapsed event handler. + /// + /// + /// + 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(); + } + } + } + + /// + /// Timer debounce item. + /// + private class TimerDebounceItem + { + /// + /// Gets the task completion source. + /// + public required TaskCompletionSource Status { get; set; } + + /// + /// Gets or sets the action to execute. + /// + public required Func Action { get; set; } + + /// + /// Updates the action to execute. + /// + /// + /// + public TimerDebounceItem UpdateAction(Func action) + { + Action = action; + return this; + } + } +} diff --git a/tests/Core/Utilities/DebounceActionTests.cs b/tests/Core/Utilities/DebounceActionTests.cs new file mode 100644 index 0000000000..394ab63125 --- /dev/null +++ b/tests/Core/Utilities/DebounceActionTests.cs @@ -0,0 +1,241 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Diagnostics; +using Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities; + +#pragma warning disable CS0618 // Type or member is obsolete + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Tests purpose")] +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; + var actionNextCount = 0; + var actionNextCalled = string.Empty; + + // Act: simulate two async calls + _ = Task.Run(async () => + { + await debounce.RunAsync(50, async () => + { + actionCalled = "Step1"; + actionCalledCount++; + await Task.CompletedTask; + }); + + actionNextCalled = "Next1"; + actionNextCount++; + }); + + await Task.Delay(5); // To ensure the second call is made after the first one + + _ = Task.Run(async () => + { + await debounce.RunAsync(40, async () => + { + actionCalled = "Step2"; + actionCalledCount++; + await Task.CompletedTask; + }); + + actionNextCalled = "Next2"; + actionNextCount++; + }); + + await Task.Delay(100); // Wait for the debounce to complete + + // Assert + Assert.Equal("Step2", actionCalled); + Assert.Equal(1, actionCalledCount); + + Assert.Equal("Next2", actionNextCalled); + Assert.Equal(1, actionNextCount); + } + + [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(() => + { + 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 diff --git a/tests/Core/Utilities/DebounceTaskTests.cs b/tests/Core/Utilities/DebounceTaskTests.cs new file mode 100644 index 0000000000..51e1c08683 --- /dev/null +++ b/tests/Core/Utilities/DebounceTaskTests.cs @@ -0,0 +1,244 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Diagnostics; +using Microsoft.FluentUI.AspNetCore.Components.Utilities.InternalDebounce; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Tests purpose")] +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; + var actionNextCount = 0; + var actionNextCalled = string.Empty; + + // Act: simulate two async calls + var t1 = Task.Run(async () => + { + try + { + await debounce.RunAsync(50, async () => + { + actionCalled = "Step1"; + actionCalledCount++; + await Task.CompletedTask; + }); + + actionNextCalled = "Next1"; + actionNextCount++; + } + catch (OperationCanceledException) + { + // Task cancelled + } + }); + + await Task.Delay(15); // Wait for the first task to start + + var t2 = Task.Run(async () => + { + await debounce.RunAsync(40, async () => + { + actionCalled = "Step2"; + actionCalledCount++; + await Task.CompletedTask; + }); + + actionNextCalled = "Next2"; + actionNextCount++; + }); + + await Task.WhenAll(t1, t2); + + // Assert + Assert.Equal("Step2", actionCalled); + Assert.Equal(1, actionCalledCount); + + Assert.Equal("Next2", actionNextCalled); + Assert.Equal(1, actionNextCount); + } + + [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(() => + { + 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); + } +} diff --git a/tests/Core/Utilities/DebouncerTests.cs b/tests/Core/Utilities/DebouncerTests.cs deleted file mode 100644 index 1d82ccf59f..0000000000 --- a/tests/Core/Utilities/DebouncerTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.FluentUI.AspNetCore.Components.Utilities; -using Xunit; - -namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Utilities; - -public class DebouncerTests -{ - [Fact] - public async Task DebounceAsync_ShouldCallAction_WhenTimerElapsed() - { - // Arrange - var debouncer = new Debouncer(); - var actionCalled = false; - - // Act - await debouncer.DebounceAsync(100, async () => - { - actionCalled = true; - await Task.Delay(50); - }); - - // Assert - Assert.True(actionCalled); - } - - //[Fact] - //public async Task DebounceAsync_ShouldNotCallAction_WhenCalledMultipleTimesWithinTimerInterval() - //{ - // // Arrange - // var debouncer = new Debouncer(); - // var actionCalledCount = 0; - - // // Act - // await debouncer.DebounceAsync(100, async () => - // { - // actionCalledCount++; - // await Task.Delay(50); - // }); - // await debouncer.DebounceAsync(100, async () => - // { - // actionCalledCount++; - // await Task.Delay(50); - // }); - - // // Assert - // Assert.Equal(1, actionCalledCount); - //} - - [Fact] - public async Task DebounceAsync_ShouldNotCallAction_WhenDisposed() - { - // Arrange - var debouncer = new Debouncer(); - var actionCalled = false; - - // Act - debouncer.Dispose(); - await debouncer.DebounceAsync(100, async () => - { - actionCalled = true; - await Task.Delay(50); - }); - - // Assert - Assert.False(actionCalled); - } - - [Fact] - public async Task DebounceAsync_Busy() - { - // Arrange - var debouncer = new Debouncer(); - var busy = false; - - // Act - debouncer.Dispose(); - await debouncer.DebounceAsync(100, async () => - { - busy = debouncer.Busy; - Assert.True(busy); - await Task.Delay(50); - }); - - // Assert - Assert.False(busy); - } -} From 5294a4ddb59c282b94df00a26eac1a2e612e98ac Mon Sep 17 00:00:00 2001 From: Gary Chan Date: Wed, 18 Sep 2024 04:08:11 -0700 Subject: [PATCH 2/3] [Docs] Blazor Hybrid code snippet reformat (#2673) Co-authored-by: Vincent Baaij --- examples/Demo/Shared/Pages/BlazorHybrid.razor | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/Demo/Shared/Pages/BlazorHybrid.razor b/examples/Demo/Shared/Pages/BlazorHybrid.razor index 92793b741b..9c72eadd4b 100644 --- a/examples/Demo/Shared/Pages/BlazorHybrid.razor +++ b/examples/Demo/Shared/Pages/BlazorHybrid.razor @@ -18,9 +18,11 @@ included in the general library script and needs to be included with a script tag before the _framework/blazor.webview.js script tag.

- - <script app-name="{NAME OF YOUR APP}" src="./_content/Microsoft.FluentUI.AspNetCore.Components/js/initializersLoader.webview.js"></script> - <script src="_framework/blazor.webview.js"></script> + + @( +@" +" + )

From 35f3e0496c6f0fe2a9b746448f7b525a361ffe8e Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Wed, 18 Sep 2024 13:33:37 +0200 Subject: [PATCH 3/3] Fix Unit Test (#2679) --- tests/Core/Utilities/DebounceActionTests.cs | 31 +++++++++++++-------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/Core/Utilities/DebounceActionTests.cs b/tests/Core/Utilities/DebounceActionTests.cs index 394ab63125..ca75ec3d10 100644 --- a/tests/Core/Utilities/DebounceActionTests.cs +++ b/tests/Core/Utilities/DebounceActionTests.cs @@ -86,22 +86,29 @@ public async Task Debounce_MultipleCalls_Async() var actionNextCalled = string.Empty; // Act: simulate two async calls - _ = Task.Run(async () => + var t1 = Task.Run(async () => { - await debounce.RunAsync(50, async () => + try { - actionCalled = "Step1"; - actionCalledCount++; - await Task.CompletedTask; - }); - - actionNextCalled = "Next1"; - actionNextCount++; + await debounce.RunAsync(50, async () => + { + actionCalled = "Step1"; + actionCalledCount++; + await Task.CompletedTask; + }); + + actionNextCalled = "Next1"; + actionNextCount++; + } + catch (OperationCanceledException) + { + // Task cancelled + } }); - await Task.Delay(5); // To ensure the second call is made after the first one + await Task.Delay(15); // Wait for the first task to start - _ = Task.Run(async () => + var t2 = Task.Run(async () => { await debounce.RunAsync(40, async () => { @@ -114,7 +121,7 @@ await debounce.RunAsync(40, async () => actionNextCount++; }); - await Task.Delay(100); // Wait for the debounce to complete + await Task.WhenAll(t1, t2); // Assert Assert.Equal("Step2", actionCalled);