diff --git a/tests/CoreTest/Configuration/ObjectTranslatorTests.cs b/tests/CoreTest/Configuration/ObjectTranslatorTests.cs new file mode 100644 index 00000000..fdb9a622 --- /dev/null +++ b/tests/CoreTest/Configuration/ObjectTranslatorTests.cs @@ -0,0 +1,114 @@ +using GeneralUpdate.Core; +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// Unit tests for following AAAT (Arrange-Act-Assert-TearDown). +/// Implements to restore global state +/// after each test, preventing cross-test leakage. +/// +public class ObjectTranslatorTests : IDisposable +{ + private readonly bool _originalTracingEnabled; + + public ObjectTranslatorTests() + { + // Capture original tracing state before any test modifies it + _originalTracingEnabled = GeneralTracer.IsTracingEnabled(); + } + + /// TearDown: restore tracing state to original value for test isolation. + public void Dispose() + { + GeneralTracer.SetTracingEnabled(_originalTracingEnabled); + GC.SuppressFinalize(this); + } + + #region GetPacketHash + + [Fact] + public void GetPacketHash_ValidVersionInfo_ReturnsFormattedHash() + { + // Arrange + GeneralTracer.SetTracingEnabled(true); + var version = new VersionInfo { Hash = "abc123def" }; + + // Act + var result = ObjectTranslator.GetPacketHash(version); + + // Assert + Assert.Equal("[PacketHash]:abc123def ", result); + } + + [Fact] + public void GetPacketHash_NonVersionInfoObject_ReturnsEmpty() + { + // Arrange + GeneralTracer.SetTracingEnabled(true); + var notVersion = "just a string"; + + // Act + var result = ObjectTranslator.GetPacketHash(notVersion); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetPacketHash_TracingDisabled_ReturnsEmptyEvenForVersionInfo() + { + // Arrange + GeneralTracer.SetTracingEnabled(false); + var version = new VersionInfo { Hash = "abc123def" }; + + // Act + var result = ObjectTranslator.GetPacketHash(version); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetPacketHash_NullObject_ReturnsEmpty() + { + // Arrange + GeneralTracer.SetTracingEnabled(true); + + // Act + var result = ObjectTranslator.GetPacketHash(null!); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetPacketHash_VersionInfoWithNullHash_ReturnsEmptyHashSegment() + { + // Arrange + GeneralTracer.SetTracingEnabled(true); + var version = new VersionInfo { Hash = null }; + + // Act + var result = ObjectTranslator.GetPacketHash(version); + + // Assert + Assert.Equal("[PacketHash]: ", result); + } + + [Fact] + public void GetPacketHash_VersionInfoWithEmptyHash_ReturnsEmptyHashSegment() + { + // Arrange + GeneralTracer.SetTracingEnabled(true); + var version = new VersionInfo { Hash = string.Empty }; + + // Act + var result = ObjectTranslator.GetPacketHash(version); + + // Assert + Assert.Equal("[PacketHash]: ", result); + } + + #endregion +} diff --git a/tests/CoreTest/Configuration/ProcessInfoTests.cs b/tests/CoreTest/Configuration/ProcessInfoTests.cs index c2d78698..a10b3bd2 100644 --- a/tests/CoreTest/Configuration/ProcessInfoTests.cs +++ b/tests/CoreTest/Configuration/ProcessInfoTests.cs @@ -144,9 +144,128 @@ public void Ctor_AllParametersValid_AllPropertiesSet() [Fact] public void Ctor_EncodingUTF8_CompressEncodingWebNameIsUtf8() { + // Arrange & Act var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", SingleVersion, "url", "backup", null, null, null, null, null, null, null); + + // Assert Assert.Equal("utf-8", info.CompressEncoding); } + + [Fact] + public void Ctor_EncodingASCII_CompressEncodingWebNameIsAscii() + { + // Arrange & Act + var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.ASCII, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null); + + // Assert + Assert.Equal("us-ascii", info.CompressEncoding); + } + + [Fact] + public void Ctor_EncodingUnicode_CompressEncodingWebNameIsUtf16() + { + // Arrange & Act + var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.Unicode, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null); + + // Assert + Assert.Equal("utf-16", info.CompressEncoding); + } + + [Fact] + public void Ctor_NullableOptionalParams_AllowedAsNull() + { + // Arrange & Act — bowl, scheme, token, driverDirectory, blackList params are all nullable + var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", "backup", + null, null, null, null, null, null, null); + + // Assert + Assert.Null(info.Bowl); + Assert.Null(info.Scheme); + Assert.Null(info.Token); + Assert.Null(info.DriverDirectory); + Assert.Null(info.BlackFileFormats); + Assert.Null(info.BlackFiles); + Assert.Null(info.SkipDirectorys); + } + + [Fact] + public void Ctor_DefaultConstructor_AllPropertiesDefault() + { + // Arrange & Act + var info = new ProcessInfo(); + + // Assert — default constructor should produce empty/null state + Assert.Null(info.AppName); + Assert.Null(info.InstallPath); + Assert.Null(info.CurrentVersion); + Assert.Null(info.LastVersion); + Assert.Equal(0, info.DownloadTimeOut); + } + + [Fact] + public void Ctor_MultipleVersions_AllStored() + { + // Arrange + var versions = new List + { + new() { Version = "1.0.0" }, + new() { Version = "1.1.0" }, + new() { Version = "2.0.0" } + }; + + // Act + var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + versions, "url", "backup", null, null, null, null, null, null, null); + + // Assert + Assert.Equal(3, info.UpdateVersions.Count); + Assert.Equal("1.0.0", info.UpdateVersions[0].Version); + Assert.Equal("1.1.0", info.UpdateVersions[1].Version); + Assert.Equal("2.0.0", info.UpdateVersions[2].Version); + } + + [Fact] + public void Ctor_UpdateLogUrlNull_Allowed() + { + // Arrange & Act + var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null); + + // Assert — UpdateLogUrl is explicitly allowed to be null + Assert.Null(info.UpdateLogUrl); + } + + [Fact] + public void Ctor_AllBlacklistParamsPopulated_PreservedInOrder() + { + // Arrange + var formats = new List { ".log", ".tmp", ".cache" }; + var files = new List { "secret.key", "config.ini" }; + var dirs = new List { "logs", "temp", "backups" }; + + // Act + var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, + formats, files, dirs); + + // Assert + Assert.Equal(3, info.BlackFileFormats!.Count); + Assert.Contains(".log", info.BlackFileFormats); + Assert.Equal(2, info.BlackFiles!.Count); + Assert.Contains("secret.key", info.BlackFiles); + Assert.Equal(3, info.SkipDirectorys!.Count); + Assert.Contains("logs", info.SkipDirectorys); + } + } diff --git a/tests/CoreTest/Configuration/VersionModelTests.cs b/tests/CoreTest/Configuration/VersionModelTests.cs new file mode 100644 index 00000000..576dfe5f --- /dev/null +++ b/tests/CoreTest/Configuration/VersionModelTests.cs @@ -0,0 +1,382 @@ +using System.Text.Json; +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// Unit tests for configuration model/DTO classes following AAAT (Arrange-Act-Assert-TearDown). +/// Covers: VersionInfo, VersionOSS, BaseResponseDTO, VersionRespDTO. +/// +public class VersionModelTests +{ + #region VersionInfo — property defaults and JSON serialization + + [Fact] + public void VersionInfo_DefaultInstance_AllNullablePropertiesAreNull() + { + // Arrange & Act + var vi = new VersionInfo(); + + // Assert + Assert.Equal(0, vi.RecordId); + Assert.Null(vi.Name); + Assert.Null(vi.Hash); + Assert.Null(vi.ReleaseDate); + Assert.Null(vi.Url); + Assert.Null(vi.Version); + Assert.Null(vi.AppType); + Assert.Null(vi.Platform); + Assert.Null(vi.ProductId); + Assert.Null(vi.IsForcibly); + Assert.Null(vi.Format); + Assert.Null(vi.Size); + Assert.Null(vi.AuthScheme); + Assert.Null(vi.AuthToken); + Assert.Null(vi.UpdateLog); + } + + [Fact] + public void VersionInfo_AllProperties_SetCorrectly() + { + // Arrange + var releaseDate = new DateTime(2025, 6, 15, 10, 30, 0, DateTimeKind.Utc); + + // Act + var vi = new VersionInfo + { + RecordId = 42, + Name = "update-package-v2.0.0", + Hash = "sha256:abcdef1234567890", + ReleaseDate = releaseDate, + Url = "https://cdn.example.com/packages/v2.0.0.zip", + Version = "2.0.0", + AppType = 1, + Platform = 2, + ProductId = "prod-001", + IsForcibly = true, + Format = ".zip", + Size = 104857600, + AuthScheme = "Bearer", + AuthToken = "token-xyz", + UpdateLog = "Bug fixes and performance improvements" + }; + + // Assert + Assert.Equal(42, vi.RecordId); + Assert.Equal("update-package-v2.0.0", vi.Name); + Assert.Equal("sha256:abcdef1234567890", vi.Hash); + Assert.Equal(releaseDate, vi.ReleaseDate); + Assert.Equal("https://cdn.example.com/packages/v2.0.0.zip", vi.Url); + Assert.Equal("2.0.0", vi.Version); + Assert.Equal(1, vi.AppType); + Assert.Equal(2, vi.Platform); + Assert.Equal("prod-001", vi.ProductId); + Assert.True(vi.IsForcibly); + Assert.Equal(".zip", vi.Format); + Assert.Equal(104857600, vi.Size); + Assert.Equal("Bearer", vi.AuthScheme); + Assert.Equal("token-xyz", vi.AuthToken); + Assert.Equal("Bug fixes and performance improvements", vi.UpdateLog); + } + + [Fact] + public void VersionInfo_JsonRoundTrip_AllProperties() + { + // Arrange + var original = new VersionInfo + { + RecordId = 7, + Name = "MyPackage", + Hash = "abc123", + ReleaseDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Url = "https://example.com/pkg", + Version = "1.0.0", + AppType = 1, + Platform = 0, + ProductId = "p1", + IsForcibly = false, + Format = ".zip", + Size = 5000000, + AuthScheme = "Bearer", + AuthToken = "tok", + UpdateLog = "v1 release" + }; + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + // Act + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.RecordId, deserialized.RecordId); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.Hash, deserialized.Hash); + Assert.Equal(original.Url, deserialized.Url); + Assert.Equal(original.Version, deserialized.Version); + Assert.Equal(original.AppType, deserialized.AppType); + Assert.Equal(original.Platform, deserialized.Platform); + Assert.Equal(original.ProductId, deserialized.ProductId); + Assert.Equal(original.IsForcibly, deserialized.IsForcibly); + Assert.Equal(original.Format, deserialized.Format); + Assert.Equal(original.Size, deserialized.Size); + Assert.Equal(original.AuthScheme, deserialized.AuthScheme); + Assert.Equal(original.AuthToken, deserialized.AuthToken); + Assert.Equal(original.UpdateLog, deserialized.UpdateLog); + } + + [Fact] + public void VersionInfo_JsonSerialization_UsesCorrectJsonPropertyNames() + { + // Arrange + var vi = new VersionInfo + { + RecordId = 1, + Name = "test", + Hash = "h", + ReleaseDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Url = "http://x", + Version = "v1", + AppType = 1, + Platform = 2, + ProductId = "p", + IsForcibly = true, + Format = ".zip", + Size = 100, + AuthScheme = "Basic", + AuthToken = "t", + UpdateLog = "log" + }; + + // Act + var json = JsonSerializer.Serialize(vi); + + // Assert — verify JSON property name casing matches [JsonPropertyName] attributes + Assert.Contains("\"recordId\"", json); + Assert.Contains("\"name\"", json); + Assert.Contains("\"hash\"", json); + Assert.Contains("\"releaseDate\"", json); + Assert.Contains("\"url\"", json); + Assert.Contains("\"version\"", json); + Assert.Contains("\"appType\"", json); + Assert.Contains("\"platform\"", json); + Assert.Contains("\"productId\"", json); + Assert.Contains("\"isForcibly\"", json); + Assert.Contains("\"format\"", json); + Assert.Contains("\"size\"", json); + Assert.Contains("\"authScheme\"", json); + Assert.Contains("\"authToken\"", json); + Assert.Contains("\"updateLog\"", json); + } + + #endregion + + #region VersionOSS — property defaults + + [Fact] + public void VersionOSS_DefaultInstance_HasDefaultDate() + { + // Arrange & Act + var voss = new VersionOSS(); + + // Assert + Assert.Equal(default(DateTime), voss.PubTime); + Assert.Null(voss.PacketName); + Assert.Null(voss.Hash); + Assert.Null(voss.Version); + Assert.Null(voss.Url); + } + + [Fact] + public void VersionOSS_AllProperties_SetCorrectly() + { + // Arrange + var pubTime = new DateTime(2025, 3, 1); + + // Act + var voss = new VersionOSS + { + PubTime = pubTime, + PacketName = "update.zip", + Hash = "sha256:xyz", + Version = "2.0.0", + Url = "https://cdn.example.com/update.zip" + }; + + // Assert + Assert.Equal(pubTime, voss.PubTime); + Assert.Equal("update.zip", voss.PacketName); + Assert.Equal("sha256:xyz", voss.Hash); + Assert.Equal("2.0.0", voss.Version); + Assert.Equal("https://cdn.example.com/update.zip", voss.Url); + } + + [Fact] + public void VersionOSS_JsonSerialization_UsesCorrectJsonPropertyNames() + { + // Arrange + var voss = new VersionOSS + { + PubTime = new DateTime(2025, 1, 1), + PacketName = "pkg.zip", + Hash = "abc", + Version = "1.0", + Url = "http://x" + }; + + // Act + var json = JsonSerializer.Serialize(voss); + + // Assert + Assert.Contains("\"PubTime\"", json); + Assert.Contains("\"PacketName\"", json); + Assert.Contains("\"Hash\"", json); + Assert.Contains("\"Version\"", json); + Assert.Contains("\"Url\"", json); + } + + [Fact] + public void VersionOSS_JsonRoundTrip_PreservesAllValues() + { + // Arrange + var pubTime = new DateTime(2025, 6, 1, 12, 0, 0); + var original = new VersionOSS + { + PubTime = pubTime, + PacketName = "package-v3.zip", + Hash = "sha256:deadbeef", + Version = "3.0.0", + Url = "https://cdn.example.com/v3.zip" + }; + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + // Act + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.PubTime, deserialized.PubTime); + Assert.Equal(original.PacketName, deserialized.PacketName); + Assert.Equal(original.Hash, deserialized.Hash); + Assert.Equal(original.Version, deserialized.Version); + Assert.Equal(original.Url, deserialized.Url); + } + + #endregion + + #region BaseResponseDTO — generic wrapper + + [Fact] + public void BaseResponseDTO_IntBody_WrapsCorrectly() + { + // Arrange + var dto = new BaseResponseDTO + { + Code = 200, + Body = 42, + Message = "OK" + }; + + // Assert + Assert.Equal(200, dto.Code); + Assert.Equal(42, dto.Body); + Assert.Equal("OK", dto.Message); + } + + [Fact] + public void BaseResponseDTO_StringBody_WrapsCorrectly() + { + // Arrange + var dto = new BaseResponseDTO + { + Code = 500, + Body = "Internal Server Error", + Message = "Something went wrong" + }; + + // Assert + Assert.Equal(500, dto.Code); + Assert.Equal("Internal Server Error", dto.Body); + Assert.Equal("Something went wrong", dto.Message); + } + + [Fact] + public void BaseResponseDTO_VersionInfoList_WrapsCorrectly() + { + // Arrange + var versions = new List + { + new() { Version = "1.0.0", Hash = "abc" }, + new() { Version = "2.0.0", Hash = "def" } + }; + var dto = new BaseResponseDTO> + { + Code = 200, + Body = versions, + Message = "Success" + }; + + // Assert + Assert.Equal(200, dto.Code); + Assert.Equal(2, dto.Body.Count); + Assert.Equal("1.0.0", dto.Body[0].Version); + Assert.Equal("2.0.0", dto.Body[1].Version); + } + + [Fact] + public void BaseResponseDTO_JsonSerialization_UsesCamelCasePropertyNames() + { + // Arrange + var dto = new BaseResponseDTO + { + Code = 200, + Body = "test-body", + Message = "success" + }; + + // Act + var json = JsonSerializer.Serialize(dto); + + // Assert — property names use camelCase from [JsonPropertyName] + Assert.Contains("\"code\"", json); + Assert.Contains("\"body\"", json); + Assert.Contains("\"message\"", json); + } + + [Fact] + public void BaseResponseDTO_DefaultInstance_CodeZero() + { + // Arrange & Act + var dto = new BaseResponseDTO(); + + // Assert + Assert.Equal(0, dto.Code); + Assert.Null(dto.Body); + Assert.Null(dto.Message); + } + + #endregion + + #region VersionRespDTO — typed alias + + [Fact] + public void VersionRespDTO_IsAssignableToBaseResponse() + { + // Arrange + var dto = new VersionRespDTO + { + Code = 200, + Body = new List { new() { Version = "1.0.0" } }, + Message = "OK" + }; + + // Assert + Assert.IsAssignableFrom>>(dto); + Assert.Equal(200, dto.Code); + Assert.Single(dto.Body); + Assert.Equal("1.0.0", dto.Body[0].Version); + } + + #endregion +} diff --git a/tests/CoreTest/Event/EventListenerBatchTests.cs b/tests/CoreTest/Event/EventListenerBatchTests.cs index b5dd2748..ad593570 100644 --- a/tests/CoreTest/Event/EventListenerBatchTests.cs +++ b/tests/CoreTest/Event/EventListenerBatchTests.cs @@ -5,12 +5,26 @@ namespace CoreTest.Event; -public class EventListenerBatchTests +/// +/// Batch tests for and +/// following AAAT (Arrange-Act-Assert-TearDown). +/// +public class EventListenerBatchTests : IDisposable { + /// TearDown: clear singleton state after each test for isolation. + public void Dispose() + { + EventManager.Instance.Clear(); + GC.SuppressFinalize(this); + } + [Fact] public void IUpdateEventListener_AllMethodsDefined() { + // Arrange & Act var listener = new TestListener(); + + // Assert Assert.NotNull(listener); Assert.IsAssignableFrom(listener); } @@ -18,24 +32,36 @@ public void IUpdateEventListener_AllMethodsDefined() [Fact] public void UpdateEventListenerBase_AllDefaultNoOp() { + // Arrange var listener = new TestBaseListener(); var progress = new DownloadProgress("test.zip", 500, 1000, 50.0, DownloadStatus.Downloading); - listener.OnUpdateInfo(new UpdateInfoEventArgs()); - listener.OnDownloadCompleted(new MultiDownloadCompletedEventArgs("1.0.0", true)); - listener.OnAllDownloadCompleted(new MultiAllDownloadCompletedEventArgs(true, new List<(object, string)>())); - listener.OnDownloadError(new MultiDownloadErrorEventArgs(new System.Exception("e"), "1.0.0")); - listener.OnDownloadStatistics(new MultiDownloadStatisticsEventArgs("1.0.0", TimeSpan.Zero, "0 B/s", 1000, 500, 50.0)); - listener.OnProgress(new ProgressEventArgs(progress)); - listener.OnException(new ExceptionEventArgs(new System.Exception("test"), "test")); + // Act — call all methods; base does nothing, so Record.Exception captures any throw + var ex = Record.Exception(() => + { + listener.OnUpdateInfo(new UpdateInfoEventArgs()); + listener.OnDownloadCompleted(new MultiDownloadCompletedEventArgs("1.0.0", true)); + listener.OnAllDownloadCompleted(new MultiAllDownloadCompletedEventArgs(true, new List<(object, string)>())); + listener.OnDownloadError(new MultiDownloadErrorEventArgs(new System.Exception("e"), "1.0.0")); + listener.OnDownloadStatistics(new MultiDownloadStatisticsEventArgs("1.0.0", TimeSpan.Zero, "0 B/s", 1000, 500, 50.0)); + listener.OnProgress(new ProgressEventArgs(progress)); + listener.OnException(new ExceptionEventArgs(new System.Exception("test"), "test")); + }); + + // Assert + Assert.Null(ex); } [Fact] public void ProgressEventArgs_WrapsDownloadProgress() { + // Arrange var progress = new DownloadProgress("test.zip", 500, 1000, 50.0, DownloadStatus.Downloading); + + // Act var args = new ProgressEventArgs(progress); + // Assert Assert.Same(progress, args.Progress); Assert.Equal("test.zip", args.Progress.AssetName); Assert.Equal(500, args.Progress.BytesDownloaded); @@ -45,9 +71,13 @@ public void ProgressEventArgs_WrapsDownloadProgress() [Fact] public void ExceptionEventArgs_HoldsException() { + // Arrange var ex = new System.InvalidOperationException("test error"); + + // Act var args = new ExceptionEventArgs(ex, "Context message"); + // Assert Assert.Same(ex, args.Exception); Assert.Equal("Context message", args.Message); } @@ -55,10 +85,12 @@ public void ExceptionEventArgs_HoldsException() [Fact] public void EventManager_ConcurrentSubscribeUnsubscribe() { + // Arrange var manager = EventManager.Instance; int callCount = 0; void Handler(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref callCount); + // Act var tasks = new Task[10]; for (int i = 0; i < tasks.Length; i++) { @@ -71,13 +103,15 @@ public void EventManager_ConcurrentSubscribeUnsubscribe() manager.RemoveListener(Handler); }); } - - Task.WaitAll(tasks); + // Act & Assert — no exceptions thrown during concurrent operations + var ex = Record.Exception(() => Task.WaitAll(tasks)); + Assert.Null(ex); } [Fact] public void EventManager_DispatchToMultipleListeners() { + // Arrange var manager = EventManager.Instance; int count1 = 0, count2 = 0; void H1(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref count1); @@ -86,16 +120,10 @@ public void EventManager_DispatchToMultipleListeners() manager.AddListener(H1); manager.AddListener(H2); - try - { - manager.Dispatch(this, System.EventArgs.Empty); - } - finally - { - manager.RemoveListener(H1); - manager.RemoveListener(H2); - } + // Act + manager.Dispatch(this, System.EventArgs.Empty); + // Assert Assert.Equal(1, count1); Assert.Equal(1, count2); } @@ -103,6 +131,7 @@ public void EventManager_DispatchToMultipleListeners() [Fact] public void EventManager_HandlerException_DoesNotBlockOthers() { + // Arrange var manager = EventManager.Instance; int count = 0; void FailingHandler(object? s, System.EventArgs e) => throw new System.InvalidOperationException("handler error"); @@ -111,16 +140,10 @@ public void EventManager_HandlerException_DoesNotBlockOthers() manager.AddListener(FailingHandler); manager.AddListener(GoodHandler); - try - { - manager.Dispatch(this, System.EventArgs.Empty); - } - finally - { - manager.RemoveListener(FailingHandler); - manager.RemoveListener(GoodHandler); - } + // Act + manager.Dispatch(this, System.EventArgs.Empty); + // Assert Assert.Equal(1, count); } diff --git a/tests/CoreTest/Event/EventListenerTests.cs b/tests/CoreTest/Event/EventListenerTests.cs index 0f32f51c..dbffa478 100644 --- a/tests/CoreTest/Event/EventListenerTests.cs +++ b/tests/CoreTest/Event/EventListenerTests.cs @@ -6,8 +6,15 @@ namespace CoreTest.Event; -public class EventListenerTests +public class EventListenerTests : IDisposable { + /// TearDown: clear singleton state after each test for isolation. + public void Dispose() + { + EventManager.Instance.Clear(); + GC.SuppressFinalize(this); + } + private class TestListener : IUpdateEventListener { public int AllDownloadCompletedCalls; diff --git a/tests/CoreTest/Event/EventManagerConcurrencyTests.cs b/tests/CoreTest/Event/EventManagerConcurrencyTests.cs index 99ff8394..7f3012ee 100644 --- a/tests/CoreTest/Event/EventManagerConcurrencyTests.cs +++ b/tests/CoreTest/Event/EventManagerConcurrencyTests.cs @@ -6,8 +6,15 @@ namespace CoreTest.Event; -public class EventManagerConcurrencyTests +public class EventManagerConcurrencyTests : IDisposable { + /// TearDown: clear singleton state after each test for isolation. + public void Dispose() + { + EventManager.Instance.Clear(); + GC.SuppressFinalize(this); + } + [Fact] public async Task ConcurrentAddRemoveDispatch_NoExceptions() { @@ -66,9 +73,7 @@ public void Dispatch_HandlerException_DoesNotBlockOthers() Assert.True(handler2Called); - // Cleanup - EventManager.Instance.RemoveListener(handler1); - EventManager.Instance.RemoveListener(handler2); + } [Fact] @@ -82,6 +87,6 @@ public void AddRemove_Dispatch_DoesNotThrow() EventManager.Instance.RemoveListener(handler); EventManager.Instance.Dispatch(this, new ExceptionEventArgs(null, "test")); - Assert.Equal(1, callCount); // Only first dispatch should trigger + Assert.Equal(1, callCount); } } diff --git a/tests/CoreTest/Event/EventManagerTests.cs b/tests/CoreTest/Event/EventManagerTests.cs index 9a227d5a..8326bd91 100644 --- a/tests/CoreTest/Event/EventManagerTests.cs +++ b/tests/CoreTest/Event/EventManagerTests.cs @@ -2,133 +2,183 @@ namespace CoreTest.Event; -public class EventManagerTests +/// +/// Unit tests for following AAAT (Arrange-Act-Assert-TearDown). +/// Implements for explicit TearDown — clears singleton state +/// after each test to ensure test isolation regardless of test execution order. +/// +public class EventManagerTests : IDisposable { public class TestEventArgs : EventArgs { public int Value { get; set; } } + /// TearDown: clear singleton state after each test for isolation. + public void Dispose() + { + EventManager.Instance.Clear(); + GC.SuppressFinalize(this); + } + [Fact] public void AddListener_NullListener_ThrowsArgumentNullException() { + // Arrange & Act & Assert Assert.Throws(() => - EventManager.Instance.AddListener(null)); + EventManager.Instance.AddListener(null!)); } [Fact] public void AddListener_SingleListener_Registered() { + // Arrange var called = false; Action handler = (s, e) => called = true; + + // Act EventManager.Instance.AddListener(handler); EventManager.Instance.Dispatch(this, new TestEventArgs()); + + // Assert Assert.True(called); - EventManager.Instance.Clear(); } [Fact] public void Dispatch_NoListeners_DoesNotThrow() { + // Arrange & Act var ex = Record.Exception(() => EventManager.Instance.Dispatch(this, new TestEventArgs())); + + // Assert Assert.Null(ex); } [Fact] public void Dispatch_SenderNull_ThrowsArgumentNullException() { + // Arrange & Act & Assert Assert.Throws(() => - EventManager.Instance.Dispatch(null, new TestEventArgs())); + EventManager.Instance.Dispatch(null!, new TestEventArgs())); } [Fact] public void Dispatch_EventArgsNull_ThrowsArgumentNullException() { + // Arrange & Act & Assert Assert.Throws(() => - EventManager.Instance.Dispatch(this, null)); + EventManager.Instance.Dispatch(this, null!)); } [Fact] public void Dispatch_MultipleListeners_AllCalled() { + // Arrange var count = 0; Action h1 = (s, e) => count++; Action h2 = (s, e) => count++; + + // Act EventManager.Instance.AddListener(h1); EventManager.Instance.AddListener(h2); EventManager.Instance.Dispatch(this, new TestEventArgs()); + + // Assert Assert.Equal(2, count); - EventManager.Instance.Clear(); } [Fact] public void Dispatch_OneHandlerThrows_OtherStillCalled() { + // Arrange var secondCalled = false; Action h1 = (s, e) => throw new InvalidOperationException("boom"); Action h2 = (s, e) => secondCalled = true; + + // Act EventManager.Instance.AddListener(h1); EventManager.Instance.AddListener(h2); EventManager.Instance.Dispatch(this, new TestEventArgs()); + + // Assert Assert.True(secondCalled); - EventManager.Instance.Clear(); } [Fact] public void RemoveListener_ExistingListener_Removed() { + // Arrange var called = false; Action handler = (s, e) => called = true; + + // Act EventManager.Instance.AddListener(handler); EventManager.Instance.RemoveListener(handler); EventManager.Instance.Dispatch(this, new TestEventArgs()); + + // Assert Assert.False(called); - EventManager.Instance.Clear(); } [Fact] public void RemoveListener_Null_ThrowsArgumentNullException() { + // Arrange & Act & Assert Assert.Throws(() => - EventManager.Instance.RemoveListener(null)); + EventManager.Instance.RemoveListener(null!)); } [Fact] public void RemoveListener_NotRegistered_DoesNotThrow() { + // Arrange & Act var ex = Record.Exception(() => EventManager.Instance.RemoveListener((s, e) => { })); + + // Assert Assert.Null(ex); } [Fact] public void Clear_RemovesAllListeners() { + // Arrange var called = false; EventManager.Instance.AddListener((s, e) => called = true); + + // Act EventManager.Instance.Clear(); EventManager.Instance.Dispatch(this, new TestEventArgs()); + + // Assert Assert.False(called); } [Fact] public void Instance_ReturnsSameSingleton() { + // Arrange & Act var a = EventManager.Instance; var b = EventManager.Instance; + + // Assert Assert.Same(a, b); } [Fact] public void Dispatch_PassesCorrectEventArgs() { - TestEventArgs received = null; + // Arrange + TestEventArgs? received = null; EventManager.Instance.AddListener((s, e) => received = e); var args = new TestEventArgs { Value = 42 }; + + // Act EventManager.Instance.Dispatch(this, args); + + // Assert Assert.Same(args, received); - Assert.Equal(42, received.Value); - EventManager.Instance.Clear(); + Assert.Equal(42, received!.Value); } } diff --git a/tests/CoreTest/Hubs/RandomRetryPolicyTests.cs b/tests/CoreTest/Hubs/RandomRetryPolicyTests.cs new file mode 100644 index 00000000..abb6a1a1 --- /dev/null +++ b/tests/CoreTest/Hubs/RandomRetryPolicyTests.cs @@ -0,0 +1,189 @@ +using GeneralUpdate.Core.Hubs; +using Microsoft.AspNetCore.SignalR.Client; + +namespace CoreTest.Hubs; + +/// +/// Unit tests for following AAAT (Arrange-Act-Assert-TearDown). +/// Tests the SignalR connection retry behaviour of the RandomRetryPolicy. +/// +public class RandomRetryPolicyTests +{ + private readonly RandomRetryPolicy _policy = new(); + + #region NextRetryDelay — within 60-second window + + [Fact] + public void NextRetryDelay_ElapsedLessThan60Seconds_ReturnsDelayBetween0And10Seconds() + { + // Arrange + var context = new RetryContext + { + ElapsedTime = TimeSpan.FromSeconds(15), + PreviousRetryCount = 2, + RetryReason = null! + }; + + // Act + var delay = _policy.NextRetryDelay(context); + + // Assert + Assert.NotNull(delay); + Assert.True(delay!.Value >= TimeSpan.Zero, + $"Expected delay >= 0, but got {delay}"); + Assert.True(delay.Value <= TimeSpan.FromSeconds(10), + $"Expected delay <= 10 seconds, but got {delay.Value.TotalSeconds}s"); + } + + [Fact] + public void NextRetryDelay_ElapsedZero_StillReturnsDelayWithinRange() + { + // Arrange + var context = new RetryContext + { + ElapsedTime = TimeSpan.Zero, + PreviousRetryCount = 0, + RetryReason = null! + }; + + // Act + var delay = _policy.NextRetryDelay(context); + + // Assert + Assert.NotNull(delay); + Assert.True(delay!.Value >= TimeSpan.Zero); + Assert.True(delay.Value <= TimeSpan.FromSeconds(10)); + } + + [Fact] + public void NextRetryDelay_ElapsedNear60Seconds_StillProvidesDelay() + { + // Arrange + var context = new RetryContext + { + ElapsedTime = TimeSpan.FromSeconds(59.5), + PreviousRetryCount = 10, + RetryReason = null! + }; + + // Act + var delay = _policy.NextRetryDelay(context); + + // Assert + Assert.NotNull(delay); + Assert.True(delay!.Value <= TimeSpan.FromSeconds(10)); + } + + #endregion + + #region NextRetryDelay — beyond 60-second window + + [Fact] + public void NextRetryDelay_Elapsed60Seconds_ReturnsNull_StopsReconnecting() + { + // Arrange + var context = new RetryContext + { + ElapsedTime = TimeSpan.FromSeconds(60), + PreviousRetryCount = 20, + RetryReason = null! + }; + + // Act + var delay = _policy.NextRetryDelay(context); + + // Assert + Assert.Null(delay); + } + + [Fact] + public void NextRetryDelay_ElapsedMoreThan60Seconds_ReturnsNull() + { + // Arrange + var context = new RetryContext + { + ElapsedTime = TimeSpan.FromSeconds(120), + PreviousRetryCount = 50, + RetryReason = null! + }; + + // Act + var delay = _policy.NextRetryDelay(context); + + // Assert + Assert.Null(delay); + } + + #endregion + + #region NextRetryDelay — statistical distribution + + [Fact] + public void NextRetryDelay_MultipleCalls_ProducesVariedResults() + { + // Arrange + var delays = new HashSet(); + const int iterations = 100; + + // Act + for (var i = 0; i < iterations; i++) + { + var context = new RetryContext + { + ElapsedTime = TimeSpan.FromSeconds(i * 0.5), // varying elapsed time within 60s window + PreviousRetryCount = i, + RetryReason = null! + }; + var delay = _policy.NextRetryDelay(context); + Assert.NotNull(delay); + delays.Add(delay!.Value.TotalMilliseconds); + } + + // Assert — random results should not all be identical + Assert.True(delays.Count > 1, + $"Expected varied random delays, but only got {delays.Count} unique values across {iterations} calls."); + } + + #endregion + + #region NextRetryDelay — edge cases + + [Fact] + public void NextRetryDelay_ExactlyAtBoundary_60Seconds_ReturnsNull() + { + // Arrange + var context = new RetryContext + { + ElapsedTime = TimeSpan.FromSeconds(60), + PreviousRetryCount = 0, + RetryReason = null! + }; + + // Act + var delay = _policy.NextRetryDelay(context); + + // Assert — "less than 60" means exactly 60 is NOT less than 60 + Assert.Null(delay); + } + + [Fact] + public void NextRetryDelay_ExactlyOneMillisecondBeforeBoundary_ReturnsDelay() + { + // Arrange + var context = new RetryContext + { + ElapsedTime = TimeSpan.FromSeconds(60) - TimeSpan.FromMilliseconds(1), + PreviousRetryCount = 0, + RetryReason = null! + }; + + // Act + var delay = _policy.NextRetryDelay(context); + + // Assert + Assert.NotNull(delay); + Assert.True(delay!.Value <= TimeSpan.FromSeconds(10)); + } + + #endregion +}