// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Web.Tests; using System.Threading; using MUnique.OpenMU.Web.Shared.Components; /// /// Tests for . /// [TestFixture] public class DebouncerTests { /// /// Tests that the constructor throws when delay is zero. /// [Test] public void Constructor_WithZeroDelay_ThrowsArgumentOutOfRangeException() { Assert.Throws(() => new Debouncer(0)); } /// /// Tests that the constructor throws when delay is negative. /// [Test] public void Constructor_WithNegativeDelay_ThrowsArgumentOutOfRangeException() { Assert.Throws(() => new Debouncer(-1)); } /// /// Tests that a single debounced action executes successfully. /// [Test] public async Task DebounceAsync_SingleCall_ExecutesAction() { using var debouncer = new Debouncer(50); var executed = false; var executionCompleted = new TaskCompletionSource(); await debouncer.DebounceAsync(() => { executed = true; executionCompleted.TrySetResult(true); return Task.CompletedTask; }); await executionCompleted.Task; Assert.That(executed, Is.True); } /// /// Tests that rapid consecutive calls only execute the last action. /// [Test] public async Task DebounceAsync_RapidCalls_OnlyLastExecutes() { using var debouncer = new Debouncer(100); var callCount = 0; var lastValue = string.Empty; var executionCompleted = new TaskCompletionSource(); // Fire 5 rapid calls var tasks = new List(); for (var i = 1; i <= 5; i++) { var captured = i; tasks.Add(debouncer.DebounceAsync(() => { Interlocked.Increment(ref callCount); lastValue = $"call-{captured}"; executionCompleted.TrySetResult(true); return Task.CompletedTask; })); if (i < 5) { await Task.Delay(20); // rapid but not instant } } await Task.WhenAll(tasks); await executionCompleted.Task; Assert.That(callCount, Is.EqualTo(1)); Assert.That(lastValue, Is.EqualTo("call-5")); } /// /// Tests that the cancellation token overload passes the token to the action. /// [Test] public async Task DebounceAsync_WithCancellationToken_PassesTokenToAction() { using var debouncer = new Debouncer(50); CancellationToken receivedToken = default; await debouncer.DebounceAsync(token => { receivedToken = token; return Task.CompletedTask; }); Assert.That(receivedToken.IsCancellationRequested, Is.False); } /// /// Tests that calling DebounceAsync after disposal returns without executing the action. /// [Test] public async Task DebounceAsync_AfterDispose_ReturnsWithoutExecuting() { var debouncer = new Debouncer(50); debouncer.Dispose(); var executed = false; await debouncer.DebounceAsync(() => { executed = true; return Task.CompletedTask; }); Assert.That(executed, Is.False); } /// /// Tests that calling Cancel while an action is pending prevents its execution. /// [Test] public async Task Cancel_WhilePending_PreventsExecution() { using var debouncer = new Debouncer(200); var executed = false; var executionCompleted = new TaskCompletionSource(); var task = debouncer.DebounceAsync(() => { executed = true; executionCompleted.TrySetResult(true); return Task.CompletedTask; }); await Task.Delay(50); debouncer.Cancel(); await task; Assert.That(executed, Is.False); Assert.That(executionCompleted.Task.IsCompleted, Is.False, "Execution should not have completed"); } /// /// Tests that passing a null action throws ArgumentNullException. /// [Test] public void DebounceAsync_WithNullAction_ThrowsArgumentNullException() { // Arrange using var debouncer = new Debouncer(50); // Act & Assert Assert.That(async () => await debouncer.DebounceAsync((Func)null!), Throws.TypeOf()); } /// /// Tests that passing a null cancellable action throws ArgumentNullException. /// [Test] public void DebounceAsync_WithNullCancellableAction_ThrowsArgumentNullException() { // Arrange using var debouncer = new Debouncer(50); // Act & Assert Assert.That(async () => await debouncer.DebounceAsync((Func)null!), Throws.TypeOf()); } /// /// Tests that actions spaced beyond the debounced window each execute independently. /// [Test] public async Task DebounceAsync_SpacedCalls_EachExecutes() { using var debouncer = new Debouncer(50); var callCount = 0; var executionCompleted = new TaskCompletionSource(); // First call await debouncer.DebounceAsync(() => { Interlocked.Increment(ref callCount); executionCompleted.TrySetResult(true); return Task.CompletedTask; }); await executionCompleted.Task; executionCompleted = new TaskCompletionSource(); // Second call - no fixed delay, just let the first complete await debouncer.DebounceAsync(() => { Interlocked.Increment(ref callCount); executionCompleted.TrySetResult(true); return Task.CompletedTask; }); await executionCompleted.Task; Assert.That(callCount, Is.EqualTo(2)); } /// /// Tests that disposing multiple times does not throw an exception. /// [Test] public void Dispose_MultipleTimes_DoesNotThrow() { var debouncer = new Debouncer(50); Assert.DoesNotThrow(() => { debouncer.Dispose(); debouncer.Dispose(); }); } }