From 3ded370f75ae99456cdeacf4434cb7069235f98f Mon Sep 17 00:00:00 2001 From: James Croft Date: Sat, 16 May 2026 06:57:28 +0100 Subject: [PATCH 1/2] fix: resolve Timer.Start dueTime bugs and v3.0 hardening Fix Timer.Start(TimeSpan) using .Milliseconds (component only) instead of .TotalMilliseconds, and both overloads not storing the dueTime parameter to the DueTime property. The int overload also used the stale DueTime value in the else branch instead of the parameter. Add Timer tests covering all Start overloads, stop, and dispose. Add test projects for MADE.Foundation and MADE.Runtime. Fix nullable annotations in MADE.Networking and add async safety to AppDiagnostics event handlers. Remove x64/x86 platform configurations from solution. --- MADE.NET.sln | 16 +- src/MADE.Diagnostics/AppDiagnostics.cs | 54 +++-- .../Extensions/UriExtensions.cs | 2 +- .../Http/INetworkRequestManager.cs | 4 +- .../Http/NetworkRequestFactory.cs | 14 +- .../Http/NetworkRequestManager.cs | 33 ++- .../Requests/Json/JsonDeleteNetworkRequest.cs | 10 +- .../Requests/Json/JsonGetNetworkRequest.cs | 10 +- .../Requests/Json/JsonPatchNetworkRequest.cs | 16 +- .../Requests/Json/JsonPostNetworkRequest.cs | 16 +- .../Requests/Json/JsonPutNetworkRequest.cs | 14 +- .../MultipartFormDataPostNetworkRequest.cs | 8 +- .../Http/Requests/NetworkRequest.cs | 2 +- .../Http/Requests/NetworkRequestCallback.cs | 10 +- .../Streams/StreamGetNetworkRequest.cs | 2 +- .../Http/Responses/HttpResponseMessage{T}.cs | 6 +- src/MADE.Networking/MADE.Networking.csproj | 1 - src/MADE.Threading/Timer.cs | 8 +- .../MADE.Foundation.Tests.csproj | 12 + .../Tests/PlatformApiHelperTests.cs | 167 ++++++++++++++ .../PlatformNotSupportedAttributeTests.cs | 35 +++ .../PlatformNotSupportedExceptionTests.cs | 52 +++++ .../MADE.Runtime.Tests.csproj | 12 + .../Tests/WeakReferenceCallbackTests.cs | 85 +++++++ .../Tests/WeakReferenceEventListenerTests.cs | 106 +++++++++ ...eferenceEventListenerWithEventArgsTests.cs | 133 +++++++++++ .../MADE.Threading.Tests/Tests/TimerTests.cs | 211 ++++++++++++++++++ 27 files changed, 957 insertions(+), 82 deletions(-) create mode 100644 tests/MADE.Foundation.Tests/MADE.Foundation.Tests.csproj create mode 100644 tests/MADE.Foundation.Tests/Tests/PlatformApiHelperTests.cs create mode 100644 tests/MADE.Foundation.Tests/Tests/PlatformNotSupportedAttributeTests.cs create mode 100644 tests/MADE.Foundation.Tests/Tests/PlatformNotSupportedExceptionTests.cs create mode 100644 tests/MADE.Runtime.Tests/MADE.Runtime.Tests.csproj create mode 100644 tests/MADE.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs create mode 100644 tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerTests.cs create mode 100644 tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs create mode 100644 tests/MADE.Threading.Tests/Tests/TimerTests.cs diff --git a/MADE.NET.sln b/MADE.NET.sln index cedbba29..67ad9c8e 100644 --- a/MADE.NET.sln +++ b/MADE.NET.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32228.430 @@ -59,6 +59,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 +173,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 +212,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..388fd978 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 { - 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 + { + // 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..ef0fd0bb 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,39 @@ 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)); + if (successCallback is not null) + { + 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 + { + // Swallow exceptions in timer callback to prevent unobserved task exceptions. + } } } diff --git a/src/MADE.Networking/Http/Requests/Json/JsonDeleteNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonDeleteNetworkRequest.cs index caff9ceb..8822a15c 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,7 @@ 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); } /// @@ -84,7 +86,7 @@ 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); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -118,6 +120,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..4a788989 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,7 @@ 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); } /// @@ -84,7 +86,7 @@ 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); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -118,6 +120,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..1e01c9d6 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,7 @@ 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); } /// @@ -115,7 +117,7 @@ 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); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -155,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/JsonPostNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs index 3031bac2..27b6b96d 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,7 @@ 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); } /// @@ -115,7 +117,7 @@ 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); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -153,6 +155,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..6c8596de 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,7 @@ 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); } /// @@ -111,7 +113,7 @@ 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); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -149,6 +151,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..da410f96 --- /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(); + } + } +} + +public class SupportedType +{ +} + +[PlatformNotSupported] +public class UnsupportedType +{ + public void SomeMethod() + { + } +} + +[PlatformNotSupported] +public class UnsupportedTypeWithUnsupportedMethod +{ + [PlatformNotSupported] + public void AlsoUnsupported() + { + } +} + +[PlatformNotSupported] +public class UnsupportedTypeWithUnsupportedProp +{ + [PlatformNotSupported] + public string? AlsoUnsupported { get; set; } +} + +public 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..7995cc14 --- /dev/null +++ b/tests/MADE.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs @@ -0,0 +1,85 @@ +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 + if (!callback.IsAlive) + { + 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..76600dd9 --- /dev/null +++ b/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs @@ -0,0 +1,133 @@ +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 + bool detached = false; + var listener = CreateListenerWithWeakInstance(out _); + listener.OnDetachAction = (_, _) => detached = true; + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + // Act + listener.OnEvent(new object(), "EventData"); + + // Assert - if GC collected the instance, it should have called Detach + // Note: GC behavior is non-deterministic, so we just verify no exception is thrown + } + } + + 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()); + } + } +} From c82a4e7e6b6136f09fa65d60a7db7bb1f73dfa29 Mon Sep 17 00:00:00 2001 From: James Croft Date: Sat, 16 May 2026 09:52:46 +0100 Subject: [PATCH 2/2] fix: address PR review feedback from Copilot and CodeQL Replace generic catch clauses with catch (Exception) in AppDiagnostics and NetworkRequestManager. Remove redundant null check on successCallback in NetworkRequestManager catch block. Guard nullable Data property in Post/Put/Patch requests with fallback to empty string. Add null checks after JsonSerializer.Deserialize in all JSON request types. Use Assume.That for non-deterministic GC tests in WeakReferenceCallbackTests. Remove unused detached variable and add explicit assertion in WeakReferenceEventListenerWithEventArgsTests. Move helper types into PlatformApiHelperTests as private nested classes. Restore solution file BOM and remove leading blank line. Update misleading catch comment in timer callback. --- MADE.NET.sln | 3 +- src/MADE.Diagnostics/AppDiagnostics.cs | 4 +- .../Http/NetworkRequestManager.cs | 10 ++-- .../Requests/Json/JsonDeleteNetworkRequest.cs | 6 +- .../Requests/Json/JsonGetNetworkRequest.cs | 6 +- .../Requests/Json/JsonPatchNetworkRequest.cs | 8 ++- .../Requests/Json/JsonPostNetworkRequest.cs | 8 ++- .../Requests/Json/JsonPutNetworkRequest.cs | 8 ++- .../Tests/PlatformApiHelperTests.cs | 58 +++++++++---------- .../Tests/WeakReferenceCallbackTests.cs | 6 +- ...eferenceEventListenerWithEventArgsTests.cs | 10 +--- 11 files changed, 64 insertions(+), 63 deletions(-) diff --git a/MADE.NET.sln b/MADE.NET.sln index 67ad9c8e..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 diff --git a/src/MADE.Diagnostics/AppDiagnostics.cs b/src/MADE.Diagnostics/AppDiagnostics.cs index 388fd978..23109968 100644 --- a/src/MADE.Diagnostics/AppDiagnostics.cs +++ b/src/MADE.Diagnostics/AppDiagnostics.cs @@ -94,7 +94,7 @@ await this.EventLogger.WriteCritical( this.ExceptionObserved?.Invoke(this, new ExceptionObservedEventArgs(correlationId, args.Exception)); } } - catch + catch (Exception) { // Swallow exceptions in last-resort exception handlers to prevent crashing the process. } @@ -121,7 +121,7 @@ await this.EventLogger.WriteCritical( this.ExceptionObserved?.Invoke(this, new ExceptionObservedEventArgs(correlationId, ex)); } - catch + catch (Exception) { // Swallow exceptions in last-resort exception handlers to prevent crashing the process. } diff --git a/src/MADE.Networking/Http/NetworkRequestManager.cs b/src/MADE.Networking/Http/NetworkRequestManager.cs index ef0fd0bb..10f5ebd0 100644 --- a/src/MADE.Networking/Http/NetworkRequestManager.cs +++ b/src/MADE.Networking/Http/NetworkRequestManager.cs @@ -236,10 +236,7 @@ private static async Task ExecuteRequestsAsync( } catch (Exception ex) { - if (successCallback is not null) - { - successCallback.Invoke(Activator.CreateInstance(successCallback.Type)!); - } + successCallback.Invoke(Activator.CreateInstance(successCallback.Type)!); errorCallback?.Invoke(ex); } @@ -251,9 +248,10 @@ private async void OnProcessTimerTick(object sender, object e) { await this.ProcessCurrentQueueAsync().ConfigureAwait(false); } - catch + catch (Exception) { - // Swallow exceptions in timer callback to prevent unobserved task exceptions. + // 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 8822a15c..846012b9 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonDeleteNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonDeleteNetworkRequest.cs @@ -66,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, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -86,7 +87,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) diff --git a/src/MADE.Networking/Http/Requests/Json/JsonGetNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonGetNetworkRequest.cs index 4a788989..5cbe00d1 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonGetNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonGetNetworkRequest.cs @@ -66,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, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -86,7 +87,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) diff --git a/src/MADE.Networking/Http/Requests/Json/JsonPatchNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonPatchNetworkRequest.cs index 1e01c9d6..d7aa741f 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonPatchNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonPatchNetworkRequest.cs @@ -97,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, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -117,7 +118,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -139,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) diff --git a/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs index 27b6b96d..2c397f40 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonPostNetworkRequest.cs @@ -97,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, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -117,7 +118,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions); + 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 +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) diff --git a/src/MADE.Networking/Http/Requests/Json/JsonPutNetworkRequest.cs b/src/MADE.Networking/Http/Requests/Json/JsonPutNetworkRequest.cs index 6c8596de..ab8f87bd 100644 --- a/src/MADE.Networking/Http/Requests/Json/JsonPutNetworkRequest.cs +++ b/src/MADE.Networking/Http/Requests/Json/JsonPutNetworkRequest.cs @@ -93,7 +93,8 @@ public JsonPutNetworkRequest(HttpClient client, string url, string? jsonData, Di public override async Task ExecuteAsync(CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {typeof(TResponse).Name}."); } /// @@ -113,7 +114,8 @@ public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { string json = await this.GetJsonResponseAsync(cancellationToken).ConfigureAwait(false); - return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions); + return JsonSerializer.Deserialize(json, expectedResponse, DefaultJsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize response to {expectedResponse.Name}."); } private async Task GetJsonResponseAsync(CancellationToken cancellationToken = default) @@ -133,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) diff --git a/tests/MADE.Foundation.Tests/Tests/PlatformApiHelperTests.cs b/tests/MADE.Foundation.Tests/Tests/PlatformApiHelperTests.cs index da410f96..39836e0f 100644 --- a/tests/MADE.Foundation.Tests/Tests/PlatformApiHelperTests.cs +++ b/tests/MADE.Foundation.Tests/Tests/PlatformApiHelperTests.cs @@ -119,49 +119,49 @@ public void ShouldReturnFalseForUnsupportedTypeWithUnsupportedProperty() result.ShouldBeFalse(); } } -} - -public class SupportedType -{ -} -[PlatformNotSupported] -public class UnsupportedType -{ - public void SomeMethod() + private class SupportedType { } -} -[PlatformNotSupported] -public class UnsupportedTypeWithUnsupportedMethod -{ [PlatformNotSupported] - public void AlsoUnsupported() + private class UnsupportedType { + public void SomeMethod() + { + } } -} -[PlatformNotSupported] -public class UnsupportedTypeWithUnsupportedProp -{ [PlatformNotSupported] - public string? AlsoUnsupported { get; set; } -} - -public class MixedSupportType -{ - public string? SupportedProperty { get; set; } + private class UnsupportedTypeWithUnsupportedMethod + { + [PlatformNotSupported] + public void AlsoUnsupported() + { + } + } [PlatformNotSupported] - public string? UnsupportedProperty { get; set; } - - public void SupportedMethod() + private class UnsupportedTypeWithUnsupportedProp { + [PlatformNotSupported] + public string? AlsoUnsupported { get; set; } } - [PlatformNotSupported] - public void UnsupportedMethod() + 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.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs b/tests/MADE.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs index 7995cc14..fc7a9940 100644 --- a/tests/MADE.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs +++ b/tests/MADE.Runtime.Tests/Tests/WeakReferenceCallbackTests.cs @@ -59,10 +59,8 @@ public void ShouldThrowWhenCallbackIsNoLongerAlive() GC.Collect(); // Act & Assert - if (!callback.IsAlive) - { - Should.Throw(() => callback.Invoke("test")); - } + Assume.That(callback.IsAlive, Is.False, "Callback target is still alive; cannot assert dead-target behavior deterministically."); + Should.Throw(() => callback.Invoke("test")); } } diff --git a/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs b/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs index 76600dd9..2c4bf6e9 100644 --- a/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs +++ b/tests/MADE.Runtime.Tests/Tests/WeakReferenceEventListenerWithEventArgsTests.cs @@ -43,19 +43,15 @@ public void ShouldNotThrowWhenOnEventActionIsNull() public void ShouldDetachWhenInstanceIsCollected() { // Arrange - bool detached = false; var listener = CreateListenerWithWeakInstance(out _); - listener.OnDetachAction = (_, _) => detached = true; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); - // Act - listener.OnEvent(new object(), "EventData"); - - // Assert - if GC collected the instance, it should have called Detach - // Note: GC behavior is non-deterministic, so we just verify no exception is thrown + // 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")); } }