diff --git a/src/CuteUtils.Tests/CuteUtils.Tests.csproj b/src/CuteUtils.Tests/CuteUtils.Tests.csproj index 95dadc8..e625376 100644 --- a/src/CuteUtils.Tests/CuteUtils.Tests.csproj +++ b/src/CuteUtils.Tests/CuteUtils.Tests.csproj @@ -10,10 +10,13 @@ - - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/CuteUtils.Tests/Misc/SafeRetryTests.cs b/src/CuteUtils.Tests/Misc/SafeRetryTests.cs new file mode 100644 index 0000000..dcc597c --- /dev/null +++ b/src/CuteUtils.Tests/Misc/SafeRetryTests.cs @@ -0,0 +1,222 @@ +using CuteUtils.Misc; + +namespace CuteUtils.Tests.Misc; + +[TestClass] +public class SafeRetryTests +{ + [TestMethod] + public async Task RetryAsyncT_SucceedsFirstTry() + { + int callCount = 0; + int result = await SafeRetry.RetryAsync(async () => + { + callCount++; + await Task.Delay(10); + return 42; + }); + Assert.AreEqual(1, callCount); + Assert.AreEqual(42, result); + } + + [TestMethod] + public async Task RetryAsyncT_RetriesAndSucceeds() + { + int callCount = 0; + int result = await SafeRetry.RetryAsync(async () => + { + callCount++; + if (callCount < 3) + { + throw new InvalidOperationException(); + } + + await Task.Delay(10); + return 99; + }, maxRetries: 5, delay: TimeSpan.FromMilliseconds(1)); + Assert.AreEqual(3, callCount); + Assert.AreEqual(99, result); + } + + [TestMethod] + public async Task RetryAsyncT_ThrowsAfterMaxRetries() + { + int callCount = 0; + _ = await Assert.ThrowsExceptionAsync(async () => + { + _ = await SafeRetry.RetryAsync(() => + { + callCount++; + throw new InvalidOperationException("fail"); + }, maxRetries: 2, delay: TimeSpan.FromMilliseconds(1)); + }); + Assert.AreEqual(2, callCount); + } + + [TestMethod] + public async Task RetryAsync_SucceedsFirstTry() + { + int callCount = 0; + await SafeRetry.RetryAsync(async () => + { + callCount++; + await Task.Delay(10); + }); + Assert.AreEqual(1, callCount); + } + + [TestMethod] + public async Task RetryAsync_RetriesAndSucceeds() + { + int callCount = 0; + await SafeRetry.RetryAsync(async () => + { + callCount++; + if (callCount < 2) + { + throw new Exception(); + } + + await Task.Delay(10); + }, maxRetries: 3, delay: TimeSpan.FromMilliseconds(1)); + Assert.AreEqual(2, callCount); + } + + [TestMethod] + public async Task RetryAsync_ThrowsAfterMaxRetries() + { + int callCount = 0; + _ = await Assert.ThrowsExceptionAsync(async () => + { + await SafeRetry.RetryAsync(() => + { + callCount++; + throw new Exception(); + }, maxRetries: 2, delay: TimeSpan.FromMilliseconds(1)); + }); + Assert.AreEqual(2, callCount); + } + + [TestMethod] + public void RetryT_SucceedsFirstTry() + { + int callCount = 0; + int result = SafeRetry.Retry(() => + { + callCount++; + return 7; + }); + Assert.AreEqual(1, callCount); + Assert.AreEqual(7, result); + } + + [TestMethod] + public void RetryT_RetriesAndSucceeds() + { + int callCount = 0; + int result = SafeRetry.Retry(() => + { + callCount++; + if (callCount < 4) + { + throw new Exception(); + } + + return 123; + }, maxRetries: 5, delay: TimeSpan.FromMilliseconds(1)); + Assert.AreEqual(4, callCount); + Assert.AreEqual(123, result); + } + + [TestMethod] + public void RetryT_ThrowsAfterMaxRetries() + { + int callCount = 0; + _ = Assert.ThrowsException(() => + { + _ = SafeRetry.Retry(() => + { + callCount++; + throw new Exception(); + }, maxRetries: 3, delay: TimeSpan.FromMilliseconds(1)); + }); + Assert.AreEqual(3, callCount); + } + + [TestMethod] + public void Retry_SucceedsFirstTry() + { + int callCount = 0; + SafeRetry.Retry(() => + { + callCount++; + }); + Assert.AreEqual(1, callCount); + } + + [TestMethod] + public void Retry_RetriesAndSucceeds() + { + int callCount = 0; + SafeRetry.Retry(() => + { + callCount++; + if (callCount < 2) + { + throw new Exception(); + } + }, maxRetries: 3, delay: TimeSpan.FromMilliseconds(1)); + Assert.AreEqual(2, callCount); + } + + [TestMethod] + public void Retry_ThrowsAfterMaxRetries() + { + int callCount = 0; + _ = Assert.ThrowsException(() => + { + SafeRetry.Retry(() => + { + callCount++; + throw new Exception(); + }, maxRetries: 2, delay: TimeSpan.FromMilliseconds(1)); + }); + Assert.AreEqual(2, callCount); + } + + [TestMethod] + public async Task RetryAsyncT_ThrowsArgumentNullException() + { + _ = await Assert.ThrowsExceptionAsync(async () => + { + _ = await SafeRetry.RetryAsync(null!); + }); + } + + [TestMethod] + public async Task RetryAsync_ThrowsArgumentNullException() + { + _ = await Assert.ThrowsExceptionAsync(async () => + { + await SafeRetry.RetryAsync(null!); + }); + } + + [TestMethod] + public void RetryT_ThrowsArgumentNullException() + { + _ = Assert.ThrowsException(() => + { + _ = SafeRetry.Retry(null!); + }); + } + + [TestMethod] + public void Retry_ThrowsArgumentNullException() + { + _ = Assert.ThrowsException(() => + { + SafeRetry.Retry(null!); + }); + } +} \ No newline at end of file diff --git a/src/CuteUtils.Tests/Misc/WaitTests.cs b/src/CuteUtils.Tests/Misc/WaitTests.cs new file mode 100644 index 0000000..efd14fe --- /dev/null +++ b/src/CuteUtils.Tests/Misc/WaitTests.cs @@ -0,0 +1,137 @@ +using CuteUtils.Misc; + +namespace CuteUtils.Tests.Misc; + +[TestClass] +public class WaitTests +{ + [TestMethod] + public async Task UntilAsync_ConditionMet_CompletesSuccessfully() + { + bool flag = false; + _ = Task.Run(() => + { + Thread.Sleep(200); + flag = true; + }); + await Wait.UntilAsync(() => flag, TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(2)); + Assert.IsTrue(flag); + } + + [TestMethod] + [ExpectedException(typeof(TimeoutException))] + public async Task UntilAsync_ConditionNotMet_ThrowsTimeoutException() + { + await Wait.UntilAsync(() => false, TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(200)); + } + + [TestMethod] + public void Until_ConditionMet_CompletesSuccessfully() + { + bool flag = false; + _ = Task.Run(() => + { + Thread.Sleep(200); + flag = true; + }); + Wait.Until(() => flag, TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(2)); + Assert.IsTrue(flag); + } + + [TestMethod] + [ExpectedException(typeof(TimeoutException))] + public void Until_ConditionNotMet_ThrowsTimeoutException() + { + Wait.Until(() => false, TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(200)); + } + + [TestMethod] + public async Task WaitForEventAsync_EventRaised_CompletesSuccessfully() + { + void subscribe(Action handler) + { + _ = Task.Run(() => + { + Thread.Sleep(200); + handler(); + }); + } + await Wait.WaitForEventAsync(subscribe, TimeSpan.FromSeconds(2)); + } + + [TestMethod] + [ExpectedException(typeof(TimeoutException))] + public async Task WaitForEventAsync_EventNotRaised_ThrowsTimeoutException() + { + void subscribe(Action handler) { } + await Wait.WaitForEventAsync(subscribe, TimeSpan.FromMilliseconds(200)); + } + + [TestMethod] + public async Task WaitForEventAsyncT_EventRaised_CompletesWithValue() + { + void subscribe(Action handler) + { + _ = Task.Run(() => + { + Thread.Sleep(200); + handler(42); + }); + } + int result = await Wait.WaitForEventAsync((Action>)subscribe, TimeSpan.FromSeconds(2)); + Assert.AreEqual(42, result); + } + + [TestMethod] + [ExpectedException(typeof(TimeoutException))] + public async Task WaitForEventAsyncT_EventNotRaised_ThrowsTimeoutException() + { + void subscribe(Action handler) { } + _ = await Wait.WaitForEventAsync((Action>)subscribe, TimeSpan.FromMilliseconds(200)); + } + + [TestMethod] + public void WaitForEvent_EventRaised_CompletesSuccessfully() + { + void subscribe(Action handler) + { + _ = Task.Run(() => + { + Thread.Sleep(200); + handler(); + }); + } + Wait.WaitForEvent(subscribe, TimeSpan.FromSeconds(2)); + } + + [TestMethod] + [ExpectedException(typeof(TimeoutException))] + public void WaitForEvent_EventNotRaised_ThrowsTimeoutException() + { + void subscribe(Action handler) { } + Wait.WaitForEvent(subscribe, TimeSpan.FromMilliseconds(200)); + } + + [TestMethod] + public void WaitForEventT_EventRaised_CompletesWithValue() + { + void subscribe(Action handler) + { + _ = Task.Run(() => + { + Thread.Sleep(200); + handler("hello"); + }); + } + string result = Wait.WaitForEvent((Action>)subscribe, TimeSpan.FromSeconds(2)); + Assert.AreEqual("hello", result); + } + + [TestMethod] + [ExpectedException(typeof(TimeoutException))] + public void WaitForEventT_EventNotRaised_ThrowsTimeoutException() + { + void subscribe(Action handler) { } + _ = Wait.WaitForEvent((Action>)subscribe, TimeSpan.FromMilliseconds(200)); + } +} \ No newline at end of file diff --git a/src/CuteUtils/CuteUtils.csproj b/src/CuteUtils/CuteUtils.csproj index fe76f91..a6dbae5 100644 --- a/src/CuteUtils/CuteUtils.csproj +++ b/src/CuteUtils/CuteUtils.csproj @@ -29,7 +29,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -44,7 +44,7 @@ --> $([System.IO.Path]::GetTempPath()) - 1.0.0.0 + 1.1.0.0 A "cute" utility library for C# https://github.com/Stone-Red-Code/CuteUtils Utility, Helper diff --git a/src/CuteUtils/Misc/SafeRetry.cs b/src/CuteUtils/Misc/SafeRetry.cs new file mode 100644 index 0000000..d99fd3a --- /dev/null +++ b/src/CuteUtils/Misc/SafeRetry.cs @@ -0,0 +1,130 @@ +namespace CuteUtils.Misc; + +/// +/// Provides methods for safely retrying actions and functions with optional delay and retry count. +/// +public static class SafeRetry +{ + /// + /// Retries an asynchronous function returning a value, up to a specified number of times, with an optional delay between attempts. + /// + /// The type of the result returned by the function. + /// The asynchronous function to execute. + /// The maximum number of retry attempts. Default is 3. + /// The delay between retry attempts. Default is 1 second. + /// The result of the function if successful. + /// Thrown if is null. + /// Thrown if all retry attempts fail. + public static async Task RetryAsync(Func> action, int maxRetries = 3, TimeSpan? delay = null) + { + ArgumentNullException.ThrowIfNull(action); + + delay ??= TimeSpan.FromSeconds(1); + Exception? lastException = null; + for (int i = 0; i < maxRetries; i++) + { + try + { + return await action(); + } + catch (Exception ex) + { + lastException = ex; + await Task.Delay(delay.Value); + } + } + throw new InvalidOperationException($"Failed after {maxRetries} attempts.", lastException); + } + + /// + /// Retries an asynchronous action up to a specified number of times, with an optional delay between attempts. + /// + /// The asynchronous action to execute. + /// The maximum number of retry attempts. Default is 3. + /// The delay between retry attempts. Default is 1 second. + /// A task representing the asynchronous operation. + /// Thrown if is null. + /// Thrown if all retry attempts fail. + public static async Task RetryAsync(Func action, int maxRetries = 3, TimeSpan? delay = null) + { + ArgumentNullException.ThrowIfNull(action); + + delay ??= TimeSpan.FromSeconds(1); + Exception? lastException = null; + for (int i = 0; i < maxRetries; i++) + { + try + { + await action(); + return; + } + catch (Exception ex) + { + lastException = ex; + await Task.Delay(delay.Value); + } + } + throw new InvalidOperationException($"Failed after {maxRetries} attempts.", lastException); + } + + /// + /// Retries a synchronous function returning a value, up to a specified number of times, with an optional delay between attempts. + /// + /// The type of the result returned by the function. + /// The function to execute. + /// The maximum number of retry attempts. Default is 3. + /// The delay between retry attempts. Default is 1 second. + /// The result of the function if successful. + /// Thrown if is null. + /// Thrown if all retry attempts fail. + public static T Retry(Func action, int maxRetries = 3, TimeSpan? delay = null) + { + ArgumentNullException.ThrowIfNull(action); + + delay ??= TimeSpan.FromSeconds(1); + Exception? lastException = null; + for (int i = 0; i < maxRetries; i++) + { + try + { + return action(); + } + catch (Exception ex) + { + lastException = ex; + System.Threading.Thread.Sleep(delay.Value); + } + } + throw new InvalidOperationException($"Failed after {maxRetries} attempts.", lastException); + } + + /// + /// Retries a synchronous action up to a specified number of times, with an optional delay between attempts. + /// + /// The action to execute. + /// The maximum number of retry attempts. Default is 3. + /// The delay between retry attempts. Default is 1 second. + /// Thrown if is null. + /// Thrown if all retry attempts fail. + public static void Retry(Action action, int maxRetries = 3, TimeSpan? delay = null) + { + ArgumentNullException.ThrowIfNull(action); + + delay ??= TimeSpan.FromSeconds(1); + Exception? lastException = null; + for (int i = 0; i < maxRetries; i++) + { + try + { + action(); + return; + } + catch (Exception ex) + { + lastException = ex; + System.Threading.Thread.Sleep(delay.Value); + } + } + throw new InvalidOperationException($"Failed after {maxRetries} attempts.", lastException); + } +} \ No newline at end of file diff --git a/src/CuteUtils/StringExtentions.cs b/src/CuteUtils/Misc/StringExtentions.cs similarity index 99% rename from src/CuteUtils/StringExtentions.cs rename to src/CuteUtils/Misc/StringExtentions.cs index ca82ea2..1c9d0dc 100644 --- a/src/CuteUtils/StringExtentions.cs +++ b/src/CuteUtils/Misc/StringExtentions.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Text; -namespace CuteUtils; +namespace CuteUtils.Misc; /// /// Extensions diff --git a/src/CuteUtils/Misc/WaitUntil.cs b/src/CuteUtils/Misc/WaitUntil.cs new file mode 100644 index 0000000..470fc06 --- /dev/null +++ b/src/CuteUtils/Misc/WaitUntil.cs @@ -0,0 +1,181 @@ +namespace CuteUtils.Misc; + +/// +/// Provides utility methods for waiting on conditions or events with optional timeouts and intervals. +/// +public static class Wait +{ + /// + /// Asynchronously waits until the specified condition is met or the timeout is reached. + /// + /// A function that returns true when the wait should end. + /// The interval to check the condition. Defaults to 100ms. + /// The maximum time to wait. Defaults to infinite. + /// Thrown if the condition is not met within the timeout. + public static async Task UntilAsync(Func condition, TimeSpan? interval = null, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.MaxValue; + interval ??= TimeSpan.FromMilliseconds(100); + DateTime endTime = DateTime.UtcNow.Add(timeout.Value); + while (!condition() && DateTime.UtcNow < endTime) + { + await Task.Delay(interval.Value); + } + if (!condition()) + { + throw new TimeoutException("The condition was not met within the specified timeout."); + } + } + + /// + /// Synchronously waits until the specified condition is met or the timeout is reached. + /// + /// A function that returns true when the wait should end. + /// The interval to check the condition. Defaults to 100ms. + /// The maximum time to wait. Defaults to infinite. + /// Thrown if the condition is not met within the timeout. + public static void Until(Func condition, TimeSpan? interval = null, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.MaxValue; + interval ??= TimeSpan.FromMilliseconds(100); + DateTime endTime = DateTime.UtcNow.Add(timeout.Value); + while (!condition() && DateTime.UtcNow < endTime) + { + System.Threading.Thread.Sleep(interval.Value); + } + if (!condition()) + { + throw new TimeoutException("The condition was not met within the specified timeout."); + } + } + + /// + /// Asynchronously waits for an event to be raised or the timeout to be reached. + /// + /// An action that subscribes a handler to the event. + /// The maximum time to wait. Defaults to infinite. + /// Thrown if the event is not raised within the timeout. + public static async Task WaitForEventAsync(Action subscribe, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.MaxValue; + TaskCompletionSource tcs = new TaskCompletionSource(); + void Handler() + { + _ = tcs.TrySetResult(); + } + + subscribe(Handler); + + using CancellationTokenSource cts = new CancellationTokenSource(timeout.Value); + using (cts.Token.Register(() => tcs.TrySetCanceled(cts.Token))) + { + try + { + await tcs.Task.ConfigureAwait(false); + } + catch (TaskCanceledException) + { + throw new TimeoutException("The event was not raised within the specified timeout."); + } + } + } + + /// + /// Asynchronously waits for an event to be raised or the timeout to be reached. + /// + /// An action that subscribes a handler to the event. + /// The maximum time to wait. Defaults to infinite. + /// The type of the event argument. + /// A task that completes with the event argument when the event is raised. + /// Thrown if the event is not raised within the timeout. + public static async Task WaitForEventAsync(Action> subscribe, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.MaxValue; + + TaskCompletionSource tcs = new TaskCompletionSource(); + + void Handler(T arg) + { + _ = tcs.TrySetResult(arg); + } + + subscribe(Handler); + + using CancellationTokenSource cts = new CancellationTokenSource(timeout.Value); + using (cts.Token.Register(() => tcs.TrySetCanceled(cts.Token))) + { + try + { + return await tcs.Task; + } + catch (TaskCanceledException) + { + throw new TimeoutException("The event was not raised within the specified timeout."); + } + } + } + + /// + /// Synchronously waits for an event to be raised or the timeout to be reached. + /// + /// An action that subscribes a handler to the event. + /// The maximum time to wait. Defaults to infinite. + /// Thrown if the event is not raised within the timeout. + public static void WaitForEvent(Action subscribe, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.MaxValue; + TaskCompletionSource tcs = new TaskCompletionSource(); + void Handler() + { + _ = tcs.TrySetResult(); + } + subscribe(Handler); + using CancellationTokenSource cts = new CancellationTokenSource(timeout.Value); + using (cts.Token.Register(() => tcs.TrySetCanceled(cts.Token))) + { + try + { + tcs.Task.Wait(); + } + catch (AggregateException ex) when (ex.InnerException is TaskCanceledException) + { + throw new TimeoutException("The event was not raised within the specified timeout.", ex); + } + } + } + + /// + /// Synchronously waits for an event to be raised or the timeout to be reached. + /// + /// An action that subscribes a handler to the event. + /// The maximum time to wait. Defaults to infinite. + /// The type of the event argument. + /// The event argument when the event is raised. + /// Thrown if the event is not raised within the timeout. + public static T WaitForEvent(Action> subscribe, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.MaxValue; + TaskCompletionSource tcs = new TaskCompletionSource(); + void Handler(T arg) + { + _ = tcs.TrySetResult(arg); + } + subscribe(Handler); + using CancellationTokenSource cts = new CancellationTokenSource(timeout.Value); + using (cts.Token.Register(() => tcs.TrySetCanceled(cts.Token))) + { + try + { + return tcs.Task.GetAwaiter().GetResult(); + } + catch (AggregateException ex) when (ex.InnerException is TaskCanceledException) + { + throw new TimeoutException("The event was not raised within the specified timeout.", ex); + } + catch (TaskCanceledException ex) + { + throw new TimeoutException("The event was not raised within the specified timeout.", ex); + } + } + } +} \ No newline at end of file