diff --git a/MADE.NET.sln b/MADE.NET.sln index cedbba29..b6a44409 100644 --- a/MADE.NET.sln +++ b/MADE.NET.sln @@ -1,5 +1,4 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32228.430 MinimumVisualStudioVersion = 10.0.40219.1 @@ -59,6 +58,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MADE.Testing.Tests", "tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MADE.Web.Mvc.Tests", "tests\MADE.Web.Mvc.Tests\MADE.Web.Mvc.Tests.csproj", "{F994F941-474A-4FDD-A9CB-280EB0D78407}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MADE.Foundation.Tests", "tests\MADE.Foundation.Tests\MADE.Foundation.Tests.csproj", "{EE7B8716-5B54-45EC-91AD-4764F4AC863B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MADE.Runtime.Tests", "tests\MADE.Runtime.Tests\MADE.Runtime.Tests.csproj", "{D360BA13-4DAD-4C62-AE4A-737553F34E01}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -169,6 +172,14 @@ Global {F994F941-474A-4FDD-A9CB-280EB0D78407}.Debug|Any CPU.Build.0 = Debug|Any CPU {F994F941-474A-4FDD-A9CB-280EB0D78407}.Release|Any CPU.ActiveCfg = Release|Any CPU {F994F941-474A-4FDD-A9CB-280EB0D78407}.Release|Any CPU.Build.0 = Release|Any CPU + {EE7B8716-5B54-45EC-91AD-4764F4AC863B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE7B8716-5B54-45EC-91AD-4764F4AC863B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE7B8716-5B54-45EC-91AD-4764F4AC863B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE7B8716-5B54-45EC-91AD-4764F4AC863B}.Release|Any CPU.Build.0 = Release|Any CPU + {D360BA13-4DAD-4C62-AE4A-737553F34E01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D360BA13-4DAD-4C62-AE4A-737553F34E01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D360BA13-4DAD-4C62-AE4A-737553F34E01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D360BA13-4DAD-4C62-AE4A-737553F34E01}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -200,6 +211,8 @@ Global {865FBD49-C64B-4B36-AEFC-FD960DDC4CF8} = {69149D0F-BB09-411B-88F0-A1E845058D70} {40B5F4EB-45DD-410A-B0FB-2384C863FC33} = {69149D0F-BB09-411B-88F0-A1E845058D70} {F994F941-474A-4FDD-A9CB-280EB0D78407} = {69149D0F-BB09-411B-88F0-A1E845058D70} + {EE7B8716-5B54-45EC-91AD-4764F4AC863B} = {69149D0F-BB09-411B-88F0-A1E845058D70} + {D360BA13-4DAD-4C62-AE4A-737553F34E01} = {69149D0F-BB09-411B-88F0-A1E845058D70} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3921AD86-E6C0-4436-8880-2D9EDFAD6151} diff --git a/src/MADE.Diagnostics/AppDiagnostics.cs b/src/MADE.Diagnostics/AppDiagnostics.cs index d2d81493..23109968 100644 --- a/src/MADE.Diagnostics/AppDiagnostics.cs +++ b/src/MADE.Diagnostics/AppDiagnostics.cs @@ -78,38 +78,52 @@ public void StopRecordingDiagnostics() private async void OnTaskUnobservedException(object? sender, UnobservedTaskExceptionEventArgs args) { - args.SetObserved(); - - var correlationId = Guid.NewGuid(); + try + { + args.SetObserved(); - await this.EventLogger.WriteCritical( - args.Exception != null - ? $"An unobserved task exception was thrown. Correlation ID: {correlationId}. Error: {args.Exception}." - : $"An unobserved task exception was thrown. Correlation ID: {correlationId}. Error: No exception information was available.").ConfigureAwait(false); + var correlationId = Guid.NewGuid(); - if (args.Exception != null) + await this.EventLogger.WriteCritical( + args.Exception != null + ? $"An unobserved task exception was thrown. Correlation ID: {correlationId}. Error: {args.Exception}." + : $"An unobserved task exception was thrown. Correlation ID: {correlationId}. Error: No exception information was available.").ConfigureAwait(false); + + if (args.Exception != null) + { + this.ExceptionObserved?.Invoke(this, new ExceptionObservedEventArgs(correlationId, args.Exception)); + } + } + catch (Exception) { - this.ExceptionObserved?.Invoke(this, new ExceptionObservedEventArgs(correlationId, args.Exception)); + // Swallow exceptions in last-resort exception handlers to prevent crashing the process. } } private async void OnAppUnhandledException(object sender, UnhandledExceptionEventArgs args) { - if (args.IsTerminating) + try { - await this.EventLogger.WriteCritical( - "The application is terminating due to an unhandled exception being thrown.").ConfigureAwait(false); - } + if (args.IsTerminating) + { + await this.EventLogger.WriteCritical( + "The application is terminating due to an unhandled exception being thrown.").ConfigureAwait(false); + } - if (args.ExceptionObject is not Exception ex) - { - return; - } + if (args.ExceptionObject is not Exception ex) + { + return; + } - var correlationId = Guid.NewGuid(); + var correlationId = Guid.NewGuid(); - await this.EventLogger.WriteCritical($"An unhandled exception was thrown. Correlation ID: {correlationId}. Error: {ex}").ConfigureAwait(false); + await this.EventLogger.WriteCritical($"An unhandled exception was thrown. Correlation ID: {correlationId}. Error: {ex}").ConfigureAwait(false); - this.ExceptionObserved?.Invoke(this, new ExceptionObservedEventArgs(correlationId, ex)); + this.ExceptionObserved?.Invoke(this, new ExceptionObservedEventArgs(correlationId, ex)); + } + catch (Exception) + { + // Swallow exceptions in last-resort exception handlers to prevent crashing the process. + } } } diff --git a/src/MADE.Networking/Extensions/UriExtensions.cs b/src/MADE.Networking/Extensions/UriExtensions.cs index 89be6fd7..fd172e2e 100644 --- a/src/MADE.Networking/Extensions/UriExtensions.cs +++ b/src/MADE.Networking/Extensions/UriExtensions.cs @@ -17,7 +17,7 @@ public static class UriExtensions /// The to extract a query value from. /// The key of the parameter in the query to extract the value for. /// The value for the query parameter. - public static string GetQueryValue(this Uri uri, string queryParam) + public static string? GetQueryValue(this Uri uri, string queryParam) { NameValueCollection queryDictionary = System.Web.HttpUtility.ParseQueryString(uri.Query); return queryDictionary.Get(queryParam); diff --git a/src/MADE.Networking/Http/INetworkRequestManager.cs b/src/MADE.Networking/Http/INetworkRequestManager.cs index cb57f5c4..ef43b2b8 100644 --- a/src/MADE.Networking/Http/INetworkRequestManager.cs +++ b/src/MADE.Networking/Http/INetworkRequestManager.cs @@ -2,6 +2,7 @@ // See the LICENSE file in the project root for more information. using System.Collections.Concurrent; +using System.Threading.Tasks; using MADE.Networking.Http.Requests; namespace MADE.Networking.Http; @@ -100,5 +101,6 @@ void AddOrUpdate( /// /// Processes the current queue of network requests. /// - void ProcessCurrentQueue(); + /// An asynchronous operation. + Task ProcessCurrentQueueAsync(); } diff --git a/src/MADE.Networking/Http/NetworkRequestFactory.cs b/src/MADE.Networking/Http/NetworkRequestFactory.cs index 0aca7ef2..2031e9bb 100644 --- a/src/MADE.Networking/Http/NetworkRequestFactory.cs +++ b/src/MADE.Networking/Http/NetworkRequestFactory.cs @@ -33,43 +33,43 @@ private NetworkRequestFactory(IHttpClientFactory httpClientFactory, string? clie /// public JsonGetNetworkRequest Get(string url, Dictionary? headers = null) { - return new JsonGetNetworkRequest(this.CreateClient(), url, headers!); + return new JsonGetNetworkRequest(this.CreateClient(), url, headers); } /// public JsonPostNetworkRequest Post(string url, string? jsonData = null, Dictionary? headers = null) { - return new JsonPostNetworkRequest(this.CreateClient(), url, jsonData!, headers!); + return new JsonPostNetworkRequest(this.CreateClient(), url, jsonData, headers); } /// public JsonPutNetworkRequest Put(string url, string? jsonData = null, Dictionary? headers = null) { - return new JsonPutNetworkRequest(this.CreateClient(), url, jsonData!, headers!); + return new JsonPutNetworkRequest(this.CreateClient(), url, jsonData, headers); } /// public JsonPatchNetworkRequest Patch(string url, string? jsonData = null, Dictionary? headers = null) { - return new JsonPatchNetworkRequest(this.CreateClient(), url, jsonData!, headers!); + return new JsonPatchNetworkRequest(this.CreateClient(), url, jsonData, headers); } /// public JsonDeleteNetworkRequest Delete(string url, Dictionary? headers = null) { - return new JsonDeleteNetworkRequest(this.CreateClient(), url, headers!); + return new JsonDeleteNetworkRequest(this.CreateClient(), url, headers); } /// public StreamGetNetworkRequest GetStream(string url, Dictionary? headers = null) { - return new StreamGetNetworkRequest(this.CreateClient(), url, headers!); + return new StreamGetNetworkRequest(this.CreateClient(), url, headers); } /// public MultipartFormDataPostNetworkRequest PostMultipart(string url, Dictionary? headers = null) { - return new MultipartFormDataPostNetworkRequest(this.CreateClient(), url, headers!); + return new MultipartFormDataPostNetworkRequest(this.CreateClient(), url, headers); } /// diff --git a/src/MADE.Networking/Http/NetworkRequestManager.cs b/src/MADE.Networking/Http/NetworkRequestManager.cs index c869d245..10f5ebd0 100644 --- a/src/MADE.Networking/Http/NetworkRequestManager.cs +++ b/src/MADE.Networking/Http/NetworkRequestManager.cs @@ -84,7 +84,8 @@ public void Dispose() /// /// Processes the current queue of network requests. /// - public void ProcessCurrentQueue() + /// An asynchronous operation. + public async Task ProcessCurrentQueueAsync() { if (this.CurrentQueue.Count == 0 || this.isProcessingRequests) { @@ -102,13 +103,13 @@ public void ProcessCurrentQueue() { if (this.CurrentQueue.TryRemove( this.CurrentQueue.FirstOrDefault().Key, - out NetworkRequestCallback request)) + out NetworkRequestCallback? request)) { requestTasks.Add(ExecuteRequestsAsync(this.CurrentQueue, request, cts.Token)); } } - Task.WhenAll(requestTasks).GetAwaiter().GetResult(); + await Task.WhenAll(requestTasks).ConfigureAwait(false); } finally { @@ -220,23 +221,37 @@ private static async Task ExecuteRequestsAsync( } NetworkRequest request = requestCallback.Request; - WeakReferenceCallback successCallback = requestCallback.SuccessCallback; - WeakReferenceCallback errorCallback = requestCallback.ErrorCallback; + WeakReferenceCallback? successCallback = requestCallback.SuccessCallback; + WeakReferenceCallback? errorCallback = requestCallback.ErrorCallback; try { + if (successCallback is null) + { + return; + } + object response = await request.ExecuteAsync(successCallback.Type, cancellationToken).ConfigureAwait(false); successCallback.Invoke(response); } catch (Exception ex) { - successCallback.Invoke(Activator.CreateInstance(successCallback.Type)); + successCallback.Invoke(Activator.CreateInstance(successCallback.Type)!); + errorCallback?.Invoke(ex); } } - private void OnProcessTimerTick(object sender, object e) + private async void OnProcessTimerTick(object sender, object e) { - this.ProcessCurrentQueue(); + try + { + await this.ProcessCurrentQueueAsync().ConfigureAwait(false); + } + catch (Exception) + { + // Swallow exceptions in the async void timer callback to prevent them from + // escaping as unhandled exceptions and crashing the process. + } } } diff --git a/src/MADE.Networking/Http/Requests/Json/JsonDeleteNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonDeleteNetworkRequest.cs index caff9ceb..846012b9 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonDeleteNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonDeleteNetworkRequest.cs @@ -15,6 +15,8 @@ namespace MADE.Networking.Http.Requests.Json; /// public sealed class JsonDeleteNetworkRequest : NetworkRequest { + private static readonly JsonSerializerOptions DefaultJsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly HttpClient client; /// @@ -43,7 +45,7 @@ public JsonDeleteNetworkRequest(HttpClient client, string url) /// /// The additional headers. /// - public JsonDeleteNetworkRequest(HttpClient client, string url, Dictionary headers) + public JsonDeleteNetworkRequest(HttpClient client, string url, Dictionary? headers) : base(url, headers) { this.client = client ?? throw new ArgumentNullException(nameof(client)); @@ -64,7 +66,8 @@ public JsonDeleteNetworkRequest(HttpClient client, string url, Dictionary ExecuteAsync(CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -84,7 +87,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -118,6 +122,6 @@ private async Task GetJsonResponseAsync(CancellationToken cancellationTo response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/src/MADE.Networking/Http/Requests/Json/JsonGetNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonGetNetworkRequest.cs index dbcd38d5..5cbe00d1 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonGetNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonGetNetworkRequest.cs @@ -15,6 +15,8 @@ namespace MADE.Networking.Http.Requests.Json; /// public sealed class JsonGetNetworkRequest : NetworkRequest { + private static readonly JsonSerializerOptions DefaultJsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly HttpClient client; /// @@ -43,7 +45,7 @@ public JsonGetNetworkRequest(HttpClient client, string url) /// /// The additional headers. /// - public JsonGetNetworkRequest(HttpClient client, string url, Dictionary headers) + public JsonGetNetworkRequest(HttpClient client, string url, Dictionary? headers) : base(url, headers) { this.client = client ?? throw new ArgumentNullException(nameof(client)); @@ -64,7 +66,8 @@ public JsonGetNetworkRequest(HttpClient client, string url, Dictionary ExecuteAsync(CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -84,7 +87,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -118,6 +122,6 @@ private async Task GetJsonResponseAsync(CancellationToken cancellationTo response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/src/MADE.Networking/Http/Requests/Json/JsonPatchNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonPatchNetworkRequest.cs index 2dcb7370..d7aa741f 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonPatchNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonPatchNetworkRequest.cs @@ -16,6 +16,8 @@ namespace MADE.Networking.Http.Requests.Json; /// public sealed class JsonPatchNetworkRequest : NetworkRequest { + private static readonly JsonSerializerOptions DefaultJsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly HttpClient client; /// @@ -44,7 +46,7 @@ public JsonPatchNetworkRequest(HttpClient client, string url) /// /// The JSON data to post. /// - public JsonPatchNetworkRequest(HttpClient client, string url, string jsonData) + public JsonPatchNetworkRequest(HttpClient client, string url, string? jsonData) : this(client, url, jsonData, null) { } @@ -67,8 +69,8 @@ public JsonPatchNetworkRequest(HttpClient client, string url, string jsonData) public JsonPatchNetworkRequest( HttpClient client, string url, - string jsonData, - Dictionary headers) + string? jsonData, + Dictionary? headers) : base(url, headers) { this.client = client ?? throw new ArgumentNullException(nameof(client)); @@ -78,7 +80,7 @@ public JsonPatchNetworkRequest( /// /// Gets or sets the data. /// - public string Data { get; set; } + public string? Data { get; set; } /// /// Executes the network request. @@ -95,7 +97,8 @@ public JsonPatchNetworkRequest( public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -115,7 +118,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -137,7 +141,7 @@ private async Task GetJsonResponseAsync(CancellationToken cancellationTo { Method = new HttpMethod("PATCH"), RequestUri = uri, - Content = new StringContent(this.Data, Encoding.UTF8, "application/json"), + Content = new StringContent(this.Data ?? string.Empty, Encoding.UTF8, "application/json"), }; if (this.Headers != null) @@ -155,6 +159,6 @@ private async Task GetJsonResponseAsync(CancellationToken cancellationTo response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs index 3031bac2..2c397f40 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs @@ -16,6 +16,8 @@ namespace MADE.Networking.Http.Requests.Json; /// public sealed class JsonPostNetworkRequest : NetworkRequest { + private static readonly JsonSerializerOptions DefaultJsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly HttpClient client; /// @@ -44,7 +46,7 @@ public JsonPostNetworkRequest(HttpClient client, string url) /// /// The JSON data to post. /// - public JsonPostNetworkRequest(HttpClient client, string url, string jsonData) + public JsonPostNetworkRequest(HttpClient client, string url, string? jsonData) : this(client, url, jsonData, null) { } @@ -67,8 +69,8 @@ public JsonPostNetworkRequest(HttpClient client, string url, string jsonData) public JsonPostNetworkRequest( HttpClient client, string url, - string jsonData, - Dictionary headers) + string? jsonData, + Dictionary? headers) : base(url, headers) { this.client = client ?? throw new ArgumentNullException(nameof(client)); @@ -78,7 +80,7 @@ public JsonPostNetworkRequest( /// /// Gets or sets the data. /// - public string Data { get; set; } + public string? Data { get; set; } /// /// Executes the network request. @@ -95,7 +97,8 @@ public JsonPostNetworkRequest( public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -115,7 +118,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -135,7 +139,7 @@ private async Task GetJsonResponseAsync(CancellationToken cancellationTo using var request = new HttpRequestMessage(HttpMethod.Post, uri) { - Content = new StringContent(this.Data, Encoding.UTF8, "application/json"), + Content = new StringContent(this.Data ?? string.Empty, Encoding.UTF8, "application/json"), }; if (this.Headers != null) @@ -153,6 +157,6 @@ private async Task GetJsonResponseAsync(CancellationToken cancellationTo response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/src/MADE.Networking/Http/Requests/Json/JsonPutNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonPutNetworkRequest.cs index 0cc6b36d..ab8f87bd 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonPutNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonPutNetworkRequest.cs @@ -16,6 +16,8 @@ namespace MADE.Networking.Http.Requests.Json; /// public sealed class JsonPutNetworkRequest : NetworkRequest { + private static readonly JsonSerializerOptions DefaultJsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly HttpClient client; /// @@ -44,7 +46,7 @@ public JsonPutNetworkRequest(HttpClient client, string url) /// /// The JSON data to put. /// - public JsonPutNetworkRequest(HttpClient client, string url, string jsonData) + public JsonPutNetworkRequest(HttpClient client, string url, string? jsonData) : this(client, url, jsonData, null) { } @@ -64,7 +66,7 @@ public JsonPutNetworkRequest(HttpClient client, string url, string jsonData) /// /// The additional headers. /// - public JsonPutNetworkRequest(HttpClient client, string url, string jsonData, Dictionary headers) + public JsonPutNetworkRequest(HttpClient client, string url, string? jsonData, Dictionary? headers) : base(url, headers) { this.client = client ?? throw new ArgumentNullException(nameof(client)); @@ -74,7 +76,7 @@ public JsonPutNetworkRequest(HttpClient client, string url, string jsonData, Dic /// /// Gets or sets the data. /// - public string Data { get; set; } + public string? Data { get; set; } /// /// Executes the network request. @@ -91,7 +93,8 @@ public JsonPutNetworkRequest(HttpClient client, string url, string jsonData, Dic public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -111,7 +114,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -131,7 +135,7 @@ private async Task GetJsonResponseAsync(CancellationToken cancellationTo using var request = new HttpRequestMessage(HttpMethod.Put, uri) { - Content = new StringContent(this.Data, Encoding.UTF8, "application/json"), + Content = new StringContent(this.Data ?? string.Empty, Encoding.UTF8, "application/json"), }; if (this.Headers != null) @@ -149,6 +153,6 @@ private async Task GetJsonResponseAsync(CancellationToken cancellationTo response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/src/MADE.Networking/Http/Requests/MultipartFormDataPostNetworkRequest.cs b/src/MADE.Networking/Http/Requests/MultipartFormDataPostNetworkRequest.cs index 01b12ea2..d7fbdea5 100644 --- a/src/MADE.Networking/Http/Requests/MultipartFormDataPostNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/MultipartFormDataPostNetworkRequest.cs @@ -11,6 +11,8 @@ namespace MADE.Networking.Http.Requests; /// public sealed class MultipartFormDataPostNetworkRequest : NetworkRequest { + private static readonly JsonSerializerOptions DefaultJsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly HttpClient client; /// @@ -32,7 +34,7 @@ public MultipartFormDataPostNetworkRequest(HttpClient client, string url) public MultipartFormDataPostNetworkRequest( HttpClient client, string url, - Dictionary headers) + Dictionary? headers) : base(url, headers) { this.client = client ?? throw new ArgumentNullException(nameof(client)); @@ -100,7 +102,7 @@ public MultipartFormDataPostNetworkRequest AddByteArrayContent( public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { string json = await this.PostAndGetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, DefaultJsonOptions); } /// @@ -109,7 +111,7 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.PostAndGetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions); } private async Task PostAndGetJsonResponseAsync(CancellationToken cancellationToken = default) diff --git a/src/MADE.Networking/Http/Requests/NetworkRequest.cs b/src/MADE.Networking/Http/Requests/NetworkRequest.cs index 7cd5def0..fb3f6773 100644 --- a/src/MADE.Networking/Http/Requests/NetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/NetworkRequest.cs @@ -33,7 +33,7 @@ protected NetworkRequest(string url) /// /// Additional headers for the request. /// - protected NetworkRequest(string url, Dictionary headers) + protected NetworkRequest(string url, Dictionary? headers) { this.Identifier = Guid.NewGuid(); this.Url = url; diff --git a/src/MADE.Networking/Http/Requests/NetworkRequestCallback.cs b/src/MADE.Networking/Http/Requests/NetworkRequestCallback.cs index ac6c738e..e6c387b0 100644 --- a/src/MADE.Networking/Http/Requests/NetworkRequestCallback.cs +++ b/src/MADE.Networking/Http/Requests/NetworkRequestCallback.cs @@ -31,7 +31,7 @@ public NetworkRequestCallback(NetworkRequest request) /// /// The success callback. /// - public NetworkRequestCallback(NetworkRequest request, WeakReferenceCallback successCallback) + public NetworkRequestCallback(NetworkRequest request, WeakReferenceCallback? successCallback) : this(request, successCallback, null) { } @@ -50,8 +50,8 @@ public NetworkRequestCallback(NetworkRequest request, WeakReferenceCallback succ /// public NetworkRequestCallback( NetworkRequest request, - WeakReferenceCallback successCallback, - WeakReferenceCallback errorCallback) + WeakReferenceCallback? successCallback, + WeakReferenceCallback? errorCallback) { this.Request = request ?? throw new ArgumentNullException(nameof(request)); this.SuccessCallback = successCallback; @@ -66,10 +66,10 @@ public NetworkRequestCallback( /// /// Gets the success callback. /// - public WeakReferenceCallback SuccessCallback { get; } + public WeakReferenceCallback? SuccessCallback { get; } /// /// Gets the error callback. /// - public WeakReferenceCallback ErrorCallback { get; } + public WeakReferenceCallback? ErrorCallback { get; } } diff --git a/src/MADE.Networking/Http/Requests/Streams/StreamGetNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Streams/StreamGetNetworkRequest.cs index 3fcc644f..ed7670aa 100644 --- a/src/MADE.Networking/Http/Requests/Streams/StreamGetNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Streams/StreamGetNetworkRequest.cs @@ -43,7 +43,7 @@ public StreamGetNetworkRequest(HttpClient client, string url) /// /// The additional headers. /// - public StreamGetNetworkRequest(HttpClient client, string url, Dictionary headers) + public StreamGetNetworkRequest(HttpClient client, string url, Dictionary? headers) : base(url, headers) { this.client = client ?? throw new ArgumentNullException(nameof(client)); diff --git a/src/MADE.Networking/Http/Responses/HttpResponseMessage{T}.cs b/src/MADE.Networking/Http/Responses/HttpResponseMessage{T}.cs index fbdc167d..e545ad5c 100644 --- a/src/MADE.Networking/Http/Responses/HttpResponseMessage{T}.cs +++ b/src/MADE.Networking/Http/Responses/HttpResponseMessage{T}.cs @@ -48,12 +48,12 @@ public HttpResponseMessage(HttpResponseMessage response) /// /// Gets the reason phrase that typically is sent by servers together with the status code. /// - public string ReasonPhrase => this.response.ReasonPhrase; + public string? ReasonPhrase => this.response.ReasonPhrase; /// /// Gets the request message which led to this response message. /// - public HttpRequestMessage RequestMessage => this.response.RequestMessage; + public HttpRequestMessage? RequestMessage => this.response.RequestMessage; /// /// Gets the status code of the HTTP response. @@ -71,7 +71,7 @@ public HttpResponseMessage(HttpResponseMessage response) /// Note, ensure that has been called first, otherwise this value will be default. /// /// - public T DeserializedContent { get; private set; } + public T? DeserializedContent { get; private set; } /// /// Allows conversion of a to the without direct casting. diff --git a/src/MADE.Networking/MADE.Networking.csproj b/src/MADE.Networking/MADE.Networking.csproj index 8f50f425..ab7b9450 100644 --- a/src/MADE.Networking/MADE.Networking.csproj +++ b/src/MADE.Networking/MADE.Networking.csproj @@ -8,7 +8,6 @@ A perfect companion to any application handling networking. MADE Networking Extensions Json Stream HttpClient - $(NoWarn);CS8600;CS8601;CS8603;CS8604;CS8618;CS8622;CS8625;CA1001;CA1869;CA2016 diff --git a/src/MADE.Threading/Timer.cs b/src/MADE.Threading/Timer.cs index f662b561..d5ee2a26 100644 --- a/src/MADE.Threading/Timer.cs +++ b/src/MADE.Threading/Timer.cs @@ -64,12 +64,14 @@ public void Start() /// public void Start(TimeSpan dueTime) { + this.DueTime = dueTime; + if (this.timer == null) { this.timer = new System.Threading.Timer( c => this.InvokeTick(), null, - dueTime.Milliseconds, + (int)Math.Ceiling(dueTime.TotalMilliseconds), (int)Math.Ceiling(this.Interval.TotalMilliseconds)); } else @@ -90,6 +92,8 @@ public void Start(TimeSpan dueTime) /// public void Start(int dueTime) { + this.DueTime = TimeSpan.FromMilliseconds(dueTime); + if (this.timer == null) { this.timer = new System.Threading.Timer( @@ -101,7 +105,7 @@ public void Start(int dueTime) else { this.timer.Change( - (int)Math.Ceiling(this.DueTime.TotalMilliseconds), + dueTime, (int)Math.Ceiling(this.Interval.TotalMilliseconds)); } diff --git a/tests/MADE.Foundation.Tests/MADE.Foundation.Tests.csproj b/tests/MADE.Foundation.Tests/MADE.Foundation.Tests.csproj new file mode 100644 index 00000000..14c75d6f --- /dev/null +++ b/tests/MADE.Foundation.Tests/MADE.Foundation.Tests.csproj @@ -0,0 +1,12 @@ + + + + net8.0;net10.0 + + + + + + + + diff --git a/tests/MADE.Foundation.Tests/Tests/PlatformApiHelperTests.cs b/tests/MADE.Foundation.Tests/Tests/PlatformApiHelperTests.cs new file mode 100644 index 00000000..39836e0f --- /dev/null +++ b/tests/MADE.Foundation.Tests/Tests/PlatformApiHelperTests.cs @@ -0,0 +1,167 @@ +using System.Diagnostics.CodeAnalysis; +using MADE.Foundation.Platform; +using NUnit.Framework; +using Shouldly; + +namespace MADE.Foundation.Tests.Tests; + +[ExcludeFromCodeCoverage] +[TestFixture] +public class PlatformApiHelperTests +{ + public class WhenCheckingTypeSupport + { + [Test] + public void ShouldReturnTrueForTypeWithoutPlatformNotSupportedAttribute() + { + // Arrange & Act + bool result = PlatformApiHelper.IsTypeSupported(typeof(SupportedType)); + + // Assert + result.ShouldBeTrue(); + } + + [Test] + public void ShouldReturnFalseForTypeWithPlatformNotSupportedAttribute() + { + // Arrange & Act + bool result = PlatformApiHelper.IsTypeSupported(typeof(UnsupportedType)); + + // Assert + result.ShouldBeFalse(); + } + + [Test] + public void ShouldCacheResultForSameType() + { + // Arrange & Act + bool first = PlatformApiHelper.IsTypeSupported(typeof(SupportedType)); + bool second = PlatformApiHelper.IsTypeSupported(typeof(SupportedType)); + + // Assert + first.ShouldBeTrue(); + second.ShouldBeTrue(); + } + } + + public class WhenCheckingMethodSupport + { + [Test] + public void ShouldReturnTrueForSupportedTypeRegardlessOfMethod() + { + // Arrange & Act - when the type is supported, all methods are considered supported + bool result = PlatformApiHelper.IsMethodSupported(typeof(MixedSupportType), nameof(MixedSupportType.SupportedMethod)); + + // Assert + result.ShouldBeTrue(); + } + + [Test] + public void ShouldReturnTrueForSupportedTypeEvenWithUnsupportedMethodAttribute() + { + // Arrange & Act - type-level support takes precedence + bool result = PlatformApiHelper.IsMethodSupported(typeof(MixedSupportType), nameof(MixedSupportType.UnsupportedMethod)); + + // Assert - type is supported, so the result is true + result.ShouldBeTrue(); + } + + [Test] + public void ShouldReturnTrueForUnsupportedTypeWithSupportedMethod() + { + // Arrange & Act - type is unsupported, but the individual method does not have the attribute + bool result = PlatformApiHelper.IsMethodSupported(typeof(UnsupportedType), nameof(UnsupportedType.SomeMethod)); + + // Assert - the method itself is supported even though the type is not + result.ShouldBeTrue(); + } + + [Test] + public void ShouldReturnFalseForUnsupportedTypeWithUnsupportedMethod() + { + // Arrange & Act + bool result = PlatformApiHelper.IsMethodSupported(typeof(UnsupportedTypeWithUnsupportedMethod), nameof(UnsupportedTypeWithUnsupportedMethod.AlsoUnsupported)); + + // Assert + result.ShouldBeFalse(); + } + } + + public class WhenCheckingPropertySupport + { + [Test] + public void ShouldReturnTrueForSupportedTypeProperty() + { + // Arrange & Act + bool result = PlatformApiHelper.IsPropertySupported(typeof(MixedSupportType), nameof(MixedSupportType.SupportedProperty)); + + // Assert + result.ShouldBeTrue(); + } + + [Test] + public void ShouldReturnTrueForSupportedTypeEvenWithUnsupportedPropertyAttribute() + { + // Arrange & Act - type-level support takes precedence + bool result = PlatformApiHelper.IsPropertySupported(typeof(MixedSupportType), nameof(MixedSupportType.UnsupportedProperty)); + + // Assert - type is supported, so result is true + result.ShouldBeTrue(); + } + + [Test] + public void ShouldReturnFalseForUnsupportedTypeWithUnsupportedProperty() + { + // Arrange & Act + bool result = PlatformApiHelper.IsPropertySupported(typeof(UnsupportedTypeWithUnsupportedProp), nameof(UnsupportedTypeWithUnsupportedProp.AlsoUnsupported)); + + // Assert + result.ShouldBeFalse(); + } + } + + private class SupportedType + { + } + + [PlatformNotSupported] + private class UnsupportedType + { + public void SomeMethod() + { + } + } + + [PlatformNotSupported] + private class UnsupportedTypeWithUnsupportedMethod + { + [PlatformNotSupported] + public void AlsoUnsupported() + { + } + } + + [PlatformNotSupported] + private class UnsupportedTypeWithUnsupportedProp + { + [PlatformNotSupported] + public string? AlsoUnsupported { get; set; } + } + + private class MixedSupportType + { + public string? SupportedProperty { get; set; } + + [PlatformNotSupported] + public string? UnsupportedProperty { get; set; } + + public void SupportedMethod() + { + } + + [PlatformNotSupported] + public void UnsupportedMethod() + { + } + } +} diff --git a/tests/MADE.Foundation.Tests/Tests/PlatformNotSupportedAttributeTests.cs b/tests/MADE.Foundation.Tests/Tests/PlatformNotSupportedAttributeTests.cs new file mode 100644 index 00000000..4f1de669 --- /dev/null +++ b/tests/MADE.Foundation.Tests/Tests/PlatformNotSupportedAttributeTests.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; +using MADE.Foundation.Platform; +using NUnit.Framework; +using Shouldly; + +namespace MADE.Foundation.Tests.Tests; + +[ExcludeFromCodeCoverage] +[TestFixture] +public class PlatformNotSupportedAttributeTests +{ + [Test] + public void ShouldBeApplicableToAllTargets() + { + // Arrange + var attribute = typeof(PlatformNotSupportedAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + + // Assert + attribute.ValidOn.ShouldBe(AttributeTargets.All); + attribute.Inherited.ShouldBeFalse(); + } + + [Test] + public void ShouldBeConstructable() + { + // Arrange & Act + var attribute = new PlatformNotSupportedAttribute(); + + // Assert + attribute.ShouldNotBeNull(); + } +} diff --git a/tests/MADE.Foundation.Tests/Tests/PlatformNotSupportedExceptionTests.cs b/tests/MADE.Foundation.Tests/Tests/PlatformNotSupportedExceptionTests.cs new file mode 100644 index 00000000..15c79f62 --- /dev/null +++ b/tests/MADE.Foundation.Tests/Tests/PlatformNotSupportedExceptionTests.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using NUnit.Framework; +using Shouldly; + +using PlatformNotSupportedException = MADE.Foundation.Platform.PlatformNotSupportedException; + +namespace MADE.Foundation.Tests.Tests; + +[ExcludeFromCodeCoverage] +[TestFixture] +public class PlatformNotSupportedExceptionTests +{ + [Test] + public void ShouldConstructWithDefaultMessage() + { + // Arrange & Act + var exception = new PlatformNotSupportedException(); + + // Assert + exception.ShouldNotBeNull(); + exception.ShouldBeOfType(); + exception.ShouldBeAssignableTo(); + } + + [Test] + public void ShouldConstructWithMessage() + { + // Arrange + const string message = "This API is not supported on this platform."; + + // Act + var exception = new PlatformNotSupportedException(message); + + // Assert + exception.Message.ShouldBe(message); + } + + [Test] + public void ShouldConstructWithMessageAndInnerException() + { + // Arrange + const string message = "This API is not supported on this platform."; + var inner = new InvalidOperationException("Inner error"); + + // Act + var exception = new PlatformNotSupportedException(message, inner); + + // Assert + exception.Message.ShouldBe(message); + exception.InnerException.ShouldBe(inner); + } +} diff --git a/tests/MADE.Runtime.Tests/MADE.Runtime.Tests.csproj b/tests/MADE.Runtime.Tests/MADE.Runtime.Tests.csproj new file mode 100644 index 00000000..683c6573 --- /dev/null +++ b/tests/MADE.Runtime.Tests/MADE.Runtime.Tests.csproj @@ -0,0 +1,12 @@ + + + + net8.0;net10.0 + + + + + + + + diff --git a/tests/MADE.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs b/tests/MADE.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs new file mode 100644 index 00000000..fc7a9940 --- /dev/null +++ b/tests/MADE.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs @@ -0,0 +1,83 @@ +using System.Diagnostics.CodeAnalysis; +using MADE.Runtime; +using NUnit.Framework; +using Shouldly; + +namespace MADE.Runtime.Tests.Tests; + +[ExcludeFromCodeCoverage] +[TestFixture] +public class WeakReferenceCallbackTests +{ + public class WhenInvokingCallback + { + [Test] + public void ShouldInvokeCallbackWithParameter() + { + // Arrange + string? capturedValue = null; + Action action = s => capturedValue = s; + var callback = new WeakReferenceCallback(action, typeof(string)); + + // Act + callback.Invoke("Hello"); + + // Assert + capturedValue.ShouldBe("Hello"); + } + + [Test] + public void ShouldReportIsAliveWhenTargetExists() + { + // Arrange + var target = new CallbackTarget(); + Action action = target.Handle; + var callback = new WeakReferenceCallback(action, typeof(string)); + + // Act & Assert + callback.IsAlive.ShouldBeTrue(); + } + + [Test] + public void ShouldStoreExpectedType() + { + // Arrange + Action action = _ => { }; + var callback = new WeakReferenceCallback(action, typeof(int)); + + // Act & Assert + callback.Type.ShouldBe(typeof(int)); + } + + [Test] + public void ShouldThrowWhenCallbackIsNoLongerAlive() + { + // Arrange + var callback = CreateCallbackWithWeakTarget(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + // Act & Assert + Assume.That(callback.IsAlive, Is.False, "Callback target is still alive; cannot assert dead-target behavior deterministically."); + Should.Throw(() => callback.Invoke("test")); + } + } + + private static WeakReferenceCallback CreateCallbackWithWeakTarget() + { + var target = new CallbackTarget(); + Action action = target.Handle; + return new WeakReferenceCallback(action, typeof(string)); + } + + private class CallbackTarget + { + public string? LastValue { get; private set; } + + public void Handle(string value) + { + this.LastValue = value; + } + } +} diff --git a/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerTests.cs b/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerTests.cs new file mode 100644 index 00000000..677912d0 --- /dev/null +++ b/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerTests.cs @@ -0,0 +1,106 @@ +using System.Diagnostics.CodeAnalysis; +using MADE.Runtime; +using NUnit.Framework; +using Shouldly; + +namespace MADE.Runtime.Tests.Tests; + +[ExcludeFromCodeCoverage] +[TestFixture] +public class WeakReferenceEventListenerTests +{ + public class WhenEventFires + { + [Test] + public void ShouldInvokeOnEventActionWhenInstanceIsAlive() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + string? capturedSource = null; + listener.OnEventAction = (inst, src) => capturedSource = src; + + // Act + listener.OnEvent("SourceData"); + + // Assert + capturedSource.ShouldBe("SourceData"); + } + + [Test] + public void ShouldNotThrowWhenOnEventActionIsNull() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + // Act & Assert + Should.NotThrow(() => listener.OnEvent("SourceData")); + } + } + + public class WhenDetaching + { + [Test] + public void ShouldInvokeOnDetachAction() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + bool detachCalled = false; + listener.OnDetachAction = (inst, lst) => detachCalled = true; + + // Act + listener.Detach(); + + // Assert + detachCalled.ShouldBeTrue(); + } + + [Test] + public void ShouldClearOnDetachActionAfterDetach() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + int detachCount = 0; + listener.OnDetachAction = (inst, lst) => detachCount++; + + // Act + listener.Detach(); + listener.Detach(); + + // Assert + detachCount.ShouldBe(1); + } + + [Test] + public void ShouldNotThrowWhenOnDetachActionIsNull() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + // Act & Assert + Should.NotThrow(() => listener.Detach()); + } + } + + public class WhenConstructing + { + [Test] + public void ShouldThrowWhenInstanceIsNull() + { + // Act & Assert + Should.Throw( + () => new WeakReferenceEventListener(null!)); + } + } + + private class ListenerInstance + { + } +} diff --git a/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs b/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs new file mode 100644 index 00000000..2c4bf6e9 --- /dev/null +++ b/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs @@ -0,0 +1,129 @@ +using System.Diagnostics.CodeAnalysis; +using MADE.Runtime; +using NUnit.Framework; +using Shouldly; + +namespace MADE.Runtime.Tests.Tests; + +[ExcludeFromCodeCoverage] +[TestFixture] +public class WeakReferenceEventListenerWithEventArgsTests +{ + public class WhenEventFires + { + [Test] + public void ShouldInvokeOnEventActionWhenInstanceIsAlive() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + string? capturedArg = null; + listener.OnEventAction = (inst, src, args) => capturedArg = args; + + // Act + listener.OnEvent(new object(), "EventData"); + + // Assert + capturedArg.ShouldBe("EventData"); + } + + [Test] + public void ShouldNotThrowWhenOnEventActionIsNull() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + // Act & Assert + Should.NotThrow(() => listener.OnEvent(new object(), "EventData")); + } + + [Test] + public void ShouldDetachWhenInstanceIsCollected() + { + // Arrange + var listener = CreateListenerWithWeakInstance(out _); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + // Act & Assert - GC behavior is non-deterministic, so verify the listener + // safely handles event dispatch even when the weak target may no longer be alive. + Should.NotThrow(() => listener.OnEvent(new object(), "EventData")); + } + } + + public class WhenDetaching + { + [Test] + public void ShouldInvokeOnDetachAction() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + bool detachCalled = false; + listener.OnDetachAction = (inst, lst) => detachCalled = true; + + // Act + listener.Detach(); + + // Assert + detachCalled.ShouldBeTrue(); + } + + [Test] + public void ShouldClearOnDetachActionAfterDetach() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + int detachCount = 0; + listener.OnDetachAction = (inst, lst) => detachCount++; + + // Act + listener.Detach(); + listener.Detach(); // Second call should be a no-op + + // Assert + detachCount.ShouldBe(1); + } + + [Test] + public void ShouldNotThrowWhenOnDetachActionIsNull() + { + // Arrange + var instance = new ListenerInstance(); + var listener = new WeakReferenceEventListener(instance); + + // Act & Assert + Should.NotThrow(() => listener.Detach()); + } + } + + public class WhenConstructing + { + [Test] + public void ShouldThrowWhenInstanceIsNull() + { + // Act & Assert + Should.Throw( + () => new WeakReferenceEventListener(null!)); + } + } + + private static WeakReferenceEventListener CreateListenerWithWeakInstance( + out WeakReference weakRef) + { + var instance = new ListenerInstance(); + weakRef = new WeakReference(instance); + return new WeakReferenceEventListener(instance); + } + + private class ListenerInstance + { + } +} diff --git a/tests/MADE.Threading.Tests/Tests/TimerTests.cs b/tests/MADE.Threading.Tests/Tests/TimerTests.cs new file mode 100644 index 00000000..526cc996 --- /dev/null +++ b/tests/MADE.Threading.Tests/Tests/TimerTests.cs @@ -0,0 +1,211 @@ +using System.Diagnostics.CodeAnalysis; +using NUnit.Framework; +using Shouldly; + +namespace MADE.Threading.Tests.Tests; + +[ExcludeFromCodeCoverage] +[TestFixture] +public class TimerTests +{ + public class WhenStarting + { + [Test] + public async Task ShouldSetIsRunningToTrue() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(500) }; + + // Act + timer.Start(); + + // Assert + timer.IsRunning.ShouldBeTrue(); + + await Task.Delay(50); + } + + [Test] + public async Task ShouldTickAtInterval() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(50) }; + int tickCount = 0; + timer.Tick += (_, _) => Interlocked.Increment(ref tickCount); + + // Act + timer.Start(); + await Task.Delay(200); + timer.Stop(); + + // Assert + tickCount.ShouldBeGreaterThan(0); + } + } + + public class WhenStartingWithTimeSpanDueTime + { + [Test] + public async Task ShouldStoreDueTimeProperty() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(500) }; + var dueTime = TimeSpan.FromMilliseconds(100); + + // Act + timer.Start(dueTime); + + // Assert + timer.DueTime.ShouldBe(dueTime); + timer.IsRunning.ShouldBeTrue(); + + await Task.Delay(50); + } + + [Test] + public async Task ShouldDelayFirstTickByDueTime() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(500) }; + int tickCount = 0; + timer.Tick += (_, _) => Interlocked.Increment(ref tickCount); + + // Act + timer.Start(TimeSpan.FromMilliseconds(150)); + await Task.Delay(50); + + // Assert - should not have ticked yet + tickCount.ShouldBe(0); + + // Wait for the due time to pass + await Task.Delay(200); + tickCount.ShouldBeGreaterThan(0); + } + } + + public class WhenStartingWithIntDueTime + { + [Test] + public async Task ShouldStoreDueTimeProperty() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(500) }; + + // Act + timer.Start(100); + + // Assert + timer.DueTime.ShouldBe(TimeSpan.FromMilliseconds(100)); + timer.IsRunning.ShouldBeTrue(); + + await Task.Delay(50); + } + + [Test] + public async Task ShouldDelayFirstTickByDueTime() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(500) }; + int tickCount = 0; + timer.Tick += (_, _) => Interlocked.Increment(ref tickCount); + + // Act + timer.Start(150); + await Task.Delay(50); + + // Assert - should not have ticked yet + tickCount.ShouldBe(0); + + // Wait for the due time to pass + await Task.Delay(200); + tickCount.ShouldBeGreaterThan(0); + } + + [Test] + public async Task ShouldUseDueTimeOnRestart() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(500) }; + int tickCount = 0; + timer.Tick += (_, _) => Interlocked.Increment(ref tickCount); + + // Act - start once to create internal timer, stop, then restart with int dueTime + timer.Start(); + await Task.Delay(50); + timer.Stop(); + + tickCount = 0; + timer.Start(200); + await Task.Delay(50); + + // Assert - should not have ticked yet (dueTime not elapsed) + tickCount.ShouldBe(0); + + await Task.Delay(250); + tickCount.ShouldBeGreaterThan(0); + } + } + + public class WhenStopping + { + [Test] + public async Task ShouldSetIsRunningToFalse() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(500) }; + timer.Start(); + + // Act + timer.Stop(); + + // Assert + timer.IsRunning.ShouldBeFalse(); + + await Task.Delay(50); + } + + [Test] + public async Task ShouldStopTicking() + { + // Arrange + using var timer = new Timer { Interval = TimeSpan.FromMilliseconds(50) }; + int tickCount = 0; + timer.Tick += (_, _) => Interlocked.Increment(ref tickCount); + + timer.Start(); + await Task.Delay(150); + timer.Stop(); + + int ticksAfterStop = tickCount; + await Task.Delay(150); + + // Assert - no additional ticks after stopping + tickCount.ShouldBe(ticksAfterStop); + } + } + + public class WhenDisposing + { + [Test] + public void ShouldNotThrowWhenDisposedWithoutStarting() + { + // Arrange + var timer = new Timer(); + + // Act & Assert + Should.NotThrow(() => timer.Dispose()); + } + + [Test] + public async Task ShouldNotThrowWhenDisposedAfterStarting() + { + // Arrange + var timer = new Timer { Interval = TimeSpan.FromMilliseconds(50) }; + timer.Start(); + await Task.Delay(100); + + // Act & Assert + Should.NotThrow(() => timer.Dispose()); + } + } +}