diff --git a/AyCode.Core.Tests/JsonExtensionTests.cs b/AyCode.Core.Tests/JsonExtensionTests.cs index fb41f55..9f10fa4 100644 --- a/AyCode.Core.Tests/JsonExtensionTests.cs +++ b/AyCode.Core.Tests/JsonExtensionTests.cs @@ -4,6 +4,7 @@ using AyCode.Core.Extensions; using AyCode.Core.Interfaces; using AyCode.Core.Loggers; using Newtonsoft.Json; +using AyCode.Core.Tests.TestModels; namespace AyCode.Core.Tests; @@ -16,18 +17,7 @@ public sealed class JsonExtensionTests TestDataFactory.ResetIdCounter(); } - private static JsonSerializerSettings GetMergeSettings() - { - return SerializeObjectExtensions.Options; - //return new JsonSerializerSettings - //{ - // ContractResolver = new UnifiedMergeContractResolver(), - // Context = new StreamingContext(StreamingContextStates.All, new Dictionary()), - // PreserveReferencesHandling = PreserveReferencesHandling.Objects, - // ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - // NullValueHandling = NullValueHandling.Ignore, - //}; - } + private static JsonSerializerSettings GetMergeSettings() => SerializeObjectExtensions.Options; #region Deep Hierarchy Tests (5 Levels) @@ -35,19 +25,14 @@ public sealed class JsonExtensionTests public void DeepHierarchy_5Levels_MergePreservesAllReferences() { // Arrange: Create 5-level deep hierarchy - TestDataFactory.ResetIdCounter(); - var order = TestDataFactory.CreateDeepOrder(itemCount: 2, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3); + var order = TestDataFactory.CreateOrder(itemCount: 2, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3); // Store original references at all levels - var originalOrder = order; var originalItem = order.Items[0]; var originalPallet = order.Items[0].Pallets[0]; var originalMeasurement = order.Items[0].Pallets[0].Measurements[0]; var originalPoint = order.Items[0].Pallets[0].Measurements[0].Points[0]; - var settings = GetMergeSettings(); - - // Create update JSON that modifies values at all 5 levels var updateJson = $@"{{ ""Id"": {order.Id}, ""OrderNumber"": ""ORD-UPDATED"", @@ -71,10 +56,9 @@ public sealed class JsonExtensionTests }}"; // Act - updateJson.JsonTo(order, settings); + updateJson.JsonTo(order, GetMergeSettings()); // Assert: All references preserved - Assert.AreSame(originalOrder, order, "Level 1: Order reference must be preserved"); Assert.AreSame(originalItem, order.Items[0], "Level 2: Item reference must be preserved"); Assert.AreSame(originalPallet, order.Items[0].Pallets[0], "Level 3: Pallet reference must be preserved"); Assert.AreSame(originalMeasurement, order.Items[0].Pallets[0].Measurements[0], "Level 4: Measurement reference must be preserved"); @@ -93,16 +77,12 @@ public sealed class JsonExtensionTests public void DeepHierarchy_5Levels_InsertAndKeepLogic() { // Arrange - TestDataFactory.ResetIdCounter(); - var order = TestDataFactory.CreateDeepOrder(itemCount: 2, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2); + var order = TestDataFactory.CreateOrder(itemCount: 2, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2); var originalItemCount = order.Items.Count; - var originalItem2 = order.Items[1]; // This should be KEPT + var originalItem2 = order.Items[1]; var existingPointId = order.Items[0].Pallets[0].Measurements[0].Points[0].Id; - var settings = GetMergeSettings(); - - // Update only first item, add new point - second item should be KEPT var updateJson = $@"{{ ""Id"": {order.Id}, ""Items"": [{{ @@ -121,149 +101,104 @@ public sealed class JsonExtensionTests }}"; // Act - updateJson.JsonTo(order, settings); + updateJson.JsonTo(order, GetMergeSettings()); - // Assert: KEEP logic works at all levels + // Assert Assert.AreEqual(originalItemCount, order.Items.Count, "Items count should be preserved (KEEP logic)"); - Assert.AreSame(originalItem2, order.Items[1], "Second item reference should be preserved (KEEP)"); - - // Assert: INSERT logic works at deepest level - var points = order.Items[0].Pallets[0].Measurements[0].Points; - Assert.IsTrue(points.Count >= 2, "Should have at least 2 points (existing + new)"); - Assert.IsTrue(points.Any(p => p.Id == 9999 && p.Label == "NEW-Point"), "New point should be inserted"); + Assert.AreSame(originalItem2, order.Items[1], "Second item reference should be preserved"); + Assert.IsTrue(order.Items[0].Pallets[0].Measurements[0].Points.Any(p => p.Id == 9999), "New point should be inserted"); } #endregion - #region Semantic Reference Tests (IId types with long-based semantic IDs) + #region Semantic Reference Tests (IId types with $id/$ref) [TestMethod] - public void SemanticReference_SharedAttribute_SerializesWithSemanticId() + public void SemanticReference_SharedTag_SerializesWithSemanticId() { - // Arrange: Create order with shared attribute across multiple properties - TestDataFactory.ResetIdCounter(); - var sharedAttr = TestDataFactory.CreateSharedAttribute(); - var order = TestDataFactory.CreateDeepOrder( - itemCount: 2, - palletsPerItem: 1, - measurementsPerPallet: 1, - pointsPerMeasurement: 1, - sharedAttribute: sharedAttr); + // Arrange + var sharedTag = TestDataFactory.CreateTag("SharedTag"); + var order = TestDataFactory.CreateOrder(itemCount: 2, sharedTag: sharedTag); var settings = GetMergeSettings(); settings.Formatting = Formatting.Indented; // Act var json = order.ToJson(settings); + Console.WriteLine($"Semantic Reference JSON:\n{json}"); - Console.WriteLine("Semantic Reference JSON:"); - Console.WriteLine(json); - - // Assert: Should contain $id for IId types (now using long-based semantic IDs) - Assert.IsTrue(json.Contains("\"$id\""), $"Should contain $id for IId types. JSON:\n{json}"); - - // Assert: $ref used for duplicate semantic references - Assert.IsTrue(json.Contains("\"$ref\""), $"Should contain $ref for shared attribute references. JSON:\n{json}"); - - // Assert: Shared attribute should be referenced multiple times - var idCount = json.Split("\"$id\"").Length - 1; - var refCount = json.Split("\"$ref\"").Length - 1; - Assert.IsTrue(idCount > 0, $"Should have at least one $id. Found: {idCount}"); - Assert.IsTrue(refCount > 0, $"Should have at least one $ref. Found: {refCount}"); + // Assert + Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id for IId types"); + Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for shared references"); } [TestMethod] public void SemanticReference_DeserializeAndMerge_PreservesSharedReferences() { // Arrange - TestDataFactory.ResetIdCounter(); - var sharedAttr = TestDataFactory.CreateSharedAttribute(); - sharedAttr.Key = "OriginalKey"; - + var sharedTag = TestDataFactory.CreateTag("OriginalKey"); var order = new TestOrder { Id = 1, OrderNumber = "ORD-001", - PrimaryAttribute = sharedAttr, - SecondaryAttribute = sharedAttr, // Same reference - Attributes = [sharedAttr] + PrimaryTag = sharedTag, + SecondaryTag = sharedTag, + Tags = [sharedTag] }; - var originalAttrRef = order.PrimaryAttribute; + var originalTagRef = order.PrimaryTag; - // Update that modifies the shared attribute var updateJson = @"{ ""Id"": 1, ""OrderNumber"": ""ORD-UPDATED"", - ""PrimaryAttribute"": { ""Id"": 1, ""Key"": ""UpdatedKey"", ""Value"": ""UpdatedValue"" } + ""PrimaryTag"": { ""Id"": 1, ""Name"": ""UpdatedKey"" } }"; - var settings = GetMergeSettings(); - // Act - updateJson.JsonTo(order, settings); + updateJson.JsonTo(order, GetMergeSettings()); // Assert Assert.AreEqual("ORD-UPDATED", order.OrderNumber); - Assert.AreEqual("UpdatedKey", order.PrimaryAttribute?.Key); - - // SecondaryAttribute should still reference the same object (wasn't in update JSON) - Assert.AreSame(originalAttrRef, order.SecondaryAttribute, "SecondaryAttribute reference should be preserved"); + Assert.AreEqual("UpdatedKey", order.PrimaryTag?.Name); + Assert.AreSame(originalTagRef, order.SecondaryTag, "SecondaryTag reference should be preserved"); } #endregion - #region Newtonsoft Reference Tests (Non-IId types with numeric $id/$ref) + #region Newtonsoft Reference Tests (Non-IId types) [TestMethod] - public void NewtonsoftReference_SharedNonIdMetadata_SerializesWithNumericId() + public void NewtonsoftReference_SharedMetadata_SerializesWithNumericId() { - // Arrange: Create order with shared non-IId metadata - TestDataFactory.ResetIdCounter(); - var sharedMeta = TestDataFactory.CreateNonIdMetadata(withChild: true); - var order = TestDataFactory.CreateDeepOrder( - itemCount: 2, - palletsPerItem: 1, - measurementsPerPallet: 1, - pointsPerMeasurement: 1, - sharedNonIdMetadata: sharedMeta); + // Arrange + var sharedMeta = TestDataFactory.CreateMetadata(withChild: true); + var order = TestDataFactory.CreateOrder(itemCount: 2, sharedMetadata: sharedMeta); var settings = GetMergeSettings(); settings.Formatting = Formatting.Indented; // Act var json = order.ToJson(settings); + Console.WriteLine($"Newtonsoft Reference JSON:\n{json}"); - Console.WriteLine("Newtonsoft Reference JSON:"); - Console.WriteLine(json); - - // Assert: Should contain $ref for duplicates - Assert.IsTrue(json.Contains("\"$ref\""), $"Should contain $ref for shared references. JSON:\n{json}"); - - // Assert: Should contain $id for objects - Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id for objects"); + // Assert + Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id"); + Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for shared references"); } [TestMethod] public void NewtonsoftReference_DeepNestedNonId_HandlesCorrectly() { - // Arrange: Create chain of non-IId metadata - var rootMeta = new TestNonIdMetadata + // Arrange + var rootMeta = new MetadataInfo { Key = "Root", Value = "RootValue", - Timestamp = DateTime.UtcNow, - ChildMetadata = new TestNonIdMetadata + ChildMetadata = new MetadataInfo { Key = "Child", Value = "ChildValue", - Timestamp = DateTime.UtcNow, - ChildMetadata = new TestNonIdMetadata - { - Key = "GrandChild", - Value = "GrandChildValue", - Timestamp = DateTime.UtcNow - } + ChildMetadata = new MetadataInfo { Key = "GrandChild", Value = "GrandChildValue" } } }; @@ -272,7 +207,7 @@ public sealed class JsonExtensionTests Id = 1, OrderNumber = "ORD-001", OrderMetadata = rootMeta, - AuditMetadata = rootMeta // Same reference + AuditMetadata = rootMeta }; var settings = GetMergeSettings(); @@ -281,14 +216,11 @@ public sealed class JsonExtensionTests // Act var json = order.ToJson(settings); - Console.WriteLine("Deep Non-IId JSON:"); - Console.WriteLine(json); - // Assert - Assert.IsTrue(json.Contains("Root"), "Should contain root metadata"); - Assert.IsTrue(json.Contains("Child"), "Should contain child metadata"); - Assert.IsTrue(json.Contains("GrandChild"), "Should contain grandchild metadata"); - Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for duplicate rootMeta reference"); + Assert.IsTrue(json.Contains("Root")); + Assert.IsTrue(json.Contains("Child")); + Assert.IsTrue(json.Contains("GrandChild")); + Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for duplicate reference"); } #endregion @@ -297,118 +229,30 @@ public sealed class JsonExtensionTests [TestMethod] public void HybridReference_MixedTypes_BothRefSystemsWork() - { - // Arrange: Create complex object with both IId and non-IId shared references - TestDataFactory.ResetIdCounter(); - var sharedAttr = TestDataFactory.CreateSharedAttribute(); - var sharedMeta = TestDataFactory.CreateNonIdMetadata(); - - // Shared attribute also has nested non-IId metadata - sharedAttr.NestedMetadata = sharedMeta; - - var order = new TestOrder - { - Id = 1, - OrderNumber = "ORD-001", - PrimaryAttribute = sharedAttr, - SecondaryAttribute = sharedAttr, // IId duplicate - OrderMetadata = sharedMeta, - AuditMetadata = sharedMeta, // Non-IId duplicate - Items = - [ - new TestOrderItem - { - Id = 10, - ProductName = "Product-A", - Quantity = 5, - Attribute = sharedAttr, // Same IId reference again - ItemMetadata = sharedMeta // Same non-IId reference again - } - ] - }; - - var settings = GetMergeSettings(); - settings.Formatting = Formatting.Indented; - - // Act - var json = order.ToJson(settings); - - Console.WriteLine("Hybrid Reference JSON:"); - Console.WriteLine(json); - - // Assert: Should have $id and $ref tokens for reference handling - Assert.IsTrue(json.Contains("\"$id\""), "Should have $id tokens for objects"); - Assert.IsTrue(json.Contains("\"$ref\""), "Should have $ref tokens for duplicates"); - - // Count $ref occurrences - should have multiple (for both IId and non-IId duplicates) - var refCount = json.Split("\"$ref\"").Length - 1; - Assert.IsTrue(refCount >= 2, $"Should have at least 2 $ref tokens. Found: {refCount}. JSON:\n{json}"); - } - - [TestMethod] - public void HybridReference_MergeWithMixedReferences() { // Arrange - TestDataFactory.ResetIdCounter(); - var sharedAttr = TestDataFactory.CreateSharedAttribute(); - sharedAttr.Key = "OriginalAttr"; - - var sharedMeta = TestDataFactory.CreateNonIdMetadata(); - sharedMeta.Key = "OriginalMeta"; + var sharedTag = TestDataFactory.CreateTag(); + var sharedMeta = TestDataFactory.CreateMetadata(); + sharedTag.Description = sharedMeta.Key; // Link them var order = new TestOrder { Id = 1, OrderNumber = "ORD-001", - PrimaryAttribute = sharedAttr, - SecondaryAttribute = sharedAttr, + PrimaryTag = sharedTag, + SecondaryTag = sharedTag, OrderMetadata = sharedMeta, AuditMetadata = sharedMeta, - Items = - [ - new TestOrderItem { Id = 10, ProductName = "Product-A", Quantity = 5 }, - new TestOrderItem { Id = 20, ProductName = "Product-B", Quantity = 3 } - ] + Tags = [sharedTag], + Items = [new TestOrderItem { Id = 10, ProductName = "A", Tag = sharedTag, ItemMetadata = sharedMeta }] }; - var originalItem10Ref = order.Items[0]; - var originalItem20Ref = order.Items[1]; - var originalAttrRef = order.PrimaryAttribute; - var originalMetaRef = order.OrderMetadata; - - // Update: modify item 10, add item 30, update PrimaryAttribute - var updateJson = $@"{{ - ""Id"": 1, - ""OrderNumber"": ""ORD-UPDATED"", - ""Items"": [ - {{ ""Id"": 10, ""ProductName"": ""Product-A-UPDATED"", ""Quantity"": 50 }}, - {{ ""Id"": 30, ""ProductName"": ""Product-C-NEW"", ""Quantity"": 7 }} - ], - ""PrimaryAttribute"": {{ ""Id"": {sharedAttr.Id}, ""Key"": ""UpdatedAttr"" }} - }}"; - var settings = GetMergeSettings(); + var json = order.ToJson(settings); - // Act - updateJson.JsonTo(order, settings); - - // Assert: References preserved - Assert.AreSame(originalItem10Ref, order.Items.Single(i => i.Id == 10), "Item 10 reference preserved"); - Assert.AreSame(originalItem20Ref, order.Items.Single(i => i.Id == 20), "Item 20 reference preserved (KEEP)"); - Assert.IsNotNull(order.Items.SingleOrDefault(i => i.Id == 30), "Item 30 inserted"); - - // Values updated - Assert.AreEqual("ORD-UPDATED", order.OrderNumber); - Assert.AreEqual("Product-A-UPDATED", order.Items.Single(i => i.Id == 10).ProductName); - Assert.AreEqual(50, order.Items.Single(i => i.Id == 10).Quantity); - Assert.AreEqual("UpdatedAttr", order.PrimaryAttribute?.Key); - - // SecondaryAttribute still references original (wasn't updated) - Assert.AreSame(originalAttrRef, order.SecondaryAttribute); - - // Metadata references unchanged - Assert.AreSame(originalMetaRef, order.OrderMetadata); - Assert.AreSame(originalMetaRef, order.AuditMetadata); + // Assert + var refCount = json.Split("\"$ref\"").Length - 1; + Assert.IsTrue(refCount >= 2, $"Should have multiple $ref tokens. Found: {refCount}"); } #endregion @@ -416,41 +260,31 @@ public sealed class JsonExtensionTests #region NoMerge Collection Tests [TestMethod] - public void NoMergeCollection_DeepHierarchy_ReplacesEntireCollection() + public void NoMergeCollection_ReplacesEntireCollection() { // Arrange - TestDataFactory.ResetIdCounter(); - var order = TestDataFactory.CreateDeepOrder(itemCount: 2); - - // Add items to NoMergeItems - order.NoMergeItems = - [ - new TestOrderItem { Id = 100, ProductName = "NoMerge-A", Quantity = 1 }, - new TestOrderItem { Id = 101, ProductName = "NoMerge-B", Quantity = 2 } + var order = TestDataFactory.CreateOrder(itemCount: 1); + order.NoMergeItems = [ + new TestOrderItem { Id = 100, ProductName = "NoMerge-A" }, + new TestOrderItem { Id = 101, ProductName = "NoMerge-B" } ]; - var originalNoMergeRef = order.NoMergeItems; + var originalRef = order.NoMergeItems; var updateJson = $@"{{ ""Id"": {order.Id}, ""NoMergeItems"": [ - {{ ""Id"": 200, ""ProductName"": ""NoMerge-NEW-A"", ""Quantity"": 10 }}, - {{ ""Id"": 201, ""ProductName"": ""NoMerge-NEW-B"", ""Quantity"": 20 }} + {{ ""Id"": 200, ""ProductName"": ""NoMerge-NEW"" }} ] }}"; - var settings = GetMergeSettings(); - // Act - order.DeepPopulateWithMerge(updateJson, settings); + order.DeepPopulateWithMerge(updateJson, GetMergeSettings()); - // Assert: Collection replaced, not merged - Assert.AreNotSame(originalNoMergeRef, order.NoMergeItems, "NoMergeItems collection should be replaced"); - Assert.AreEqual(2, order.NoMergeItems.Count); - Assert.IsFalse(order.NoMergeItems.Any(i => i.Id == 100), "Original item 100 should be gone"); - Assert.IsFalse(order.NoMergeItems.Any(i => i.Id == 101), "Original item 101 should be gone"); - Assert.IsTrue(order.NoMergeItems.Any(i => i.Id == 200), "New item 200 should exist"); - Assert.IsTrue(order.NoMergeItems.Any(i => i.Id == 201), "New item 201 should exist"); + // Assert + Assert.AreNotSame(originalRef, order.NoMergeItems); + Assert.AreEqual(1, order.NoMergeItems.Count); + Assert.AreEqual(200, order.NoMergeItems[0].Id); } #endregion @@ -465,33 +299,27 @@ public sealed class JsonExtensionTests { Id = 1, OrderNumber = "ORD-001", - MetadataList = - [ - new TestNonIdMetadata { Key = "Old-A", Value = "Old-Value-A" }, - new TestNonIdMetadata { Key = "Old-B", Value = "Old-Value-B" } + MetadataList = [ + new MetadataInfo { Key = "Old-A" }, + new MetadataInfo { Key = "Old-B" } ] }; var updateJson = @"{ ""Id"": 1, ""MetadataList"": [ - { ""Key"": ""New-X"", ""Value"": ""New-Value-X"" }, - { ""Key"": ""New-Y"", ""Value"": ""New-Value-Y"" }, - { ""Key"": ""New-Z"", ""Value"": ""New-Value-Z"" } + { ""Key"": ""New-X"" }, + { ""Key"": ""New-Y"" } ] }"; - var settings = GetMergeSettings(); - // Act - order.DeepPopulateWithMerge(updateJson, settings); + order.DeepPopulateWithMerge(updateJson, GetMergeSettings()); - // Assert: Collection content replaced - Assert.AreEqual(3, order.MetadataList.Count); - Assert.IsFalse(order.MetadataList.Any(m => m.Key == "Old-A")); + // Assert + Assert.AreEqual(2, order.MetadataList.Count); Assert.IsTrue(order.MetadataList.Any(m => m.Key == "New-X")); - Assert.IsTrue(order.MetadataList.Any(m => m.Key == "New-Y")); - Assert.IsTrue(order.MetadataList.Any(m => m.Key == "New-Z")); + Assert.IsFalse(order.MetadataList.Any(m => m.Key == "Old-A")); } #endregion @@ -505,37 +333,35 @@ public sealed class JsonExtensionTests { Id = Guid.NewGuid(), Code = "ORD-001", - Items = - [ + Items = [ new TestGuidItem { Id = Guid.NewGuid(), Name = "Apple", Qty = 5 }, new TestGuidItem { Id = Guid.NewGuid(), Name = "Orange", Qty = 3 } - ], - Count = 2 + ] }; var originalItemsRef = order.Items; var originalAppleRef = order.Items[0]; + var appleId = order.Items[0].Id; - var json = new - { - Id = order.Id, - Code = "ORD-UPDATED", - Items = new[] - { - new { Id = order.Items[0].Id, Name = "Apple", Qty = 7 }, + var json = new { + Id = order.Id, + Code = "ORD-UPDATED", + Items = new[] { + new { Id = appleId, Name = "Apple", Qty = 7 }, new { Id = Guid.NewGuid(), Name = "Banana", Qty = 4 } - }, - Count = 999 + } }.ToJson(GetMergeSettings()); json.JsonTo(order, GetMergeSettings()); - Assert.AreSame(originalItemsRef, order.Items, "List reference must be preserved."); - Assert.AreSame(originalAppleRef, order.Items[0], "Apple item reference must be preserved."); - Assert.AreEqual(3, order.Items.Count, "Items should include: updated Apple, new Banana, and kept Orange."); - Assert.AreEqual(7, order.Items[0].Qty); + // List reference preserved + Assert.AreSame(originalItemsRef, order.Items, "List reference must be preserved"); + // Apple reference preserved (updated in-place) + Assert.AreSame(originalAppleRef, order.Items[0], "Apple reference must be preserved"); + Assert.AreEqual(7, order.Items[0].Qty, "Apple Qty should be updated"); + // Count: Apple (updated) + Orange (kept) + Banana (new) + Assert.AreEqual(3, order.Items.Count, "Should have 3 items: Apple updated, Orange kept, Banana added"); Assert.AreEqual("ORD-UPDATED", order.Code); - Assert.AreEqual(999, order.Count); } #endregion @@ -543,659 +369,184 @@ public sealed class JsonExtensionTests #region Round-Trip Serialization Tests [TestMethod] - public void RoundTrip_DeepHierarchy_SerializeAndVerifyStructure() + public void RoundTrip_DeepHierarchy_PreservesData() { // Arrange - TestDataFactory.ResetIdCounter(); - var sharedAttr = TestDataFactory.CreateSharedAttribute(); - var sharedMeta = TestDataFactory.CreateNonIdMetadata(withChild: true); - - var originalOrder = TestDataFactory.CreateDeepOrder( - itemCount: 2, - palletsPerItem: 2, - measurementsPerPallet: 2, - pointsPerMeasurement: 3, - sharedAttribute: sharedAttr, - sharedNonIdMetadata: sharedMeta); - - var settings = GetMergeSettings(); - settings.Formatting = Formatting.Indented; - - // Act: Serialize - var json = originalOrder.ToJson(settings); - - Console.WriteLine("Round-Trip JSON:"); - Console.WriteLine(json); - - // Assert: JSON structure - should have $id for objects (now using long-based IDs) - Assert.IsTrue(json.Contains("\"$id\""), "Should have $id for objects"); - - // Assert: JSON structure - $ref for shared references - Assert.IsTrue(json.Contains("\"$ref\""), "Should have $ref for shared references"); - - // Assert: Data integrity - Assert.IsTrue(json.Contains(originalOrder.OrderNumber), "Should contain order number"); - Assert.IsTrue(json.Contains(originalOrder.Items[0].ProductName), "Should contain product name"); - - // Assert: Verify the JSON can be deserialized back - var deserializedOrder = json.JsonTo(settings); - Assert.IsNotNull(deserializedOrder); - Assert.AreEqual(originalOrder.Id, deserializedOrder.Id); - Assert.AreEqual(originalOrder.OrderNumber, deserializedOrder.OrderNumber); - Assert.AreEqual(originalOrder.Items.Count, deserializedOrder.Items.Count); - } - - [TestMethod] - public void RoundTrip_MergeIntoExistingObject_PreservesReferences() - { - // Arrange: Create original order - TestDataFactory.ResetIdCounter(); - var originalOrder = TestDataFactory.CreateDeepOrder(itemCount: 2, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2); - - // Store original values - var originalProductName = originalOrder.Items[0].ProductName; - - // Store references - var originalItemRef = originalOrder.Items[0]; - var originalPalletRef = originalOrder.Items[0].Pallets[0]; - - // Create update JSON (without shared non-IId to avoid numeric $ref conflicts) - var updateJson = $@"{{ - ""Id"": {originalOrder.Id}, - ""OrderNumber"": ""UPDATED-ORDER"", - ""Items"": [{{ - ""Id"": {originalOrder.Items[0].Id}, - ""Pallets"": [{{ - ""Id"": {originalOrder.Items[0].Pallets[0].Id}, - ""PalletCode"": ""UPDATED-PALLET"" - }}] - }}] - }}"; - - var settings = GetMergeSettings(); + var sharedTag = TestDataFactory.CreateTag(); + var sharedMeta = TestDataFactory.CreateMetadata(withChild: true); + var order = TestDataFactory.CreateOrder(itemCount: 2, palletsPerItem: 2, sharedTag: sharedTag, sharedMetadata: sharedMeta); // Act - updateJson.JsonTo(originalOrder, settings); + var json = order.ToJson(GetMergeSettings()); + var deserialized = json.JsonTo(GetMergeSettings()); - // Assert: References preserved - Assert.AreSame(originalItemRef, originalOrder.Items[0], "Item reference should be preserved"); - Assert.AreSame(originalPalletRef, originalOrder.Items[0].Pallets[0], "Pallet reference should be preserved"); - - // Assert: Values updated (only what was in the JSON) - Assert.AreEqual("UPDATED-ORDER", originalOrder.OrderNumber); - Assert.AreEqual(originalProductName, originalOrder.Items[0].ProductName, "ProductName should be unchanged (not in update JSON)"); - Assert.AreEqual("UPDATED-PALLET", originalOrder.Items[0].Pallets[0].PalletCode); - } - - #endregion - - #region Test DTO Classes - 5 Levels Deep with Reference Loops - - /// - /// Status enum used across tests - /// - public enum TestStatus - { - Pending = 10, - Processing = 20, - Shipped = 30 - } - - /// - /// Level 5: Deepest level - Measurement point with value - /// - public class TestMeasurementPoint : IId - { - public int Id { get; set; } - public string Label { get; set; } = string.Empty; - public double Value { get; set; } - public DateTime MeasuredAt { get; set; } - - // Reference loop back to Level 4 (parent) - [JsonIgnore] // Prevent infinite serialization loop - public TestMeasurement? ParentMeasurement { get; set; } - } - - /// - /// Level 4: Measurement with multiple points - /// - public class TestMeasurement : IId - { - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public double TotalWeight { get; set; } - public DateTime CreatedAt { get; set; } - - // IId collection - Level 5 - public List Points { get; set; } = []; - - // Reference loop back to Level 3 (parent) - [JsonIgnore] public TestPallet? ParentPallet { get; set; } - } - - /// - /// Level 3: Pallet containing measurements - /// - public class TestPallet : IId - { - public int Id { get; set; } - public string PalletCode { get; set; } = string.Empty; - public int TrayCount { get; set; } - public TestStatus Status { get; set; } = TestStatus.Pending; - - // IId collection - Level 4 - public List Measurements { get; set; } = []; - - // Non-IId metadata (for Newtonsoft $ref testing) - public TestNonIdMetadata? PalletMetadata { get; set; } - - // Reference loop back to Level 2 (parent) - [JsonIgnore] public TestOrderItem? ParentOrderItem { get; set; } - } - - /// - /// Level 2: Order item with pallets - /// - public class TestOrderItem : IId - { - public int Id { get; set; } - public string ProductName { get; set; } = string.Empty; - public int Quantity { get; set; } - public decimal UnitPrice { get; set; } - public TestStatus ItemStatus { get; set; } = TestStatus.Pending; - - // IId collection - Level 3 - public List Pallets { get; set; } = []; - - // IId object property - shared metadata with semantic $id - public TestSharedAttribute? Attribute { get; set; } - - // Non-IId metadata (for Newtonsoft $ref testing) - public TestNonIdMetadata? ItemMetadata { get; set; } - - // Reference loop back to Level 1 (parent) - [JsonIgnore] public TestOrder? ParentOrder { get; set; } - } - - /// - /// Level 1: Main order containing items - /// - public class TestOrder : IId - { - public int Id { get; set; } - public string OrderNumber { get; set; } = string.Empty; - public TestStatus OrderStatus { get; set; } = TestStatus.Pending; - public DateTime? PaidDateUtc { get; set; } - public DateTime CreatedAt { get; set; } - - // IId collection - Level 2 - public List Items { get; set; } = []; - - // IId collection for attributes (shared references testing) - public List Attributes { get; set; } = []; - - // Non-IId collection - will be replaced, not merged - public List MetadataList { get; set; } = []; - - // Collection with NoMerge attribute - will be replaced - [JsonNoMergeCollection] public List NoMergeItems { get; set; } = []; - - // IId object properties for shared reference testing - public TestSharedAttribute? PrimaryAttribute { get; set; } - public TestSharedAttribute? SecondaryAttribute { get; set; } - - // Non-IId object properties for Newtonsoft $ref testing - public TestNonIdMetadata? OrderMetadata { get; set; } - public TestNonIdMetadata? AuditMetadata { get; set; } - } - - /// - /// Shared IId attribute - for semantic $id/$ref testing across objects - /// - public class TestSharedAttribute : IId - { - public int Id { get; set; } - public string Key { get; set; } = string.Empty; - public string Value { get; set; } = string.Empty; - public DateTime CreatedOrUpdatedDateUTC { get; set; } - - // Nested non-IId for mixed reference testing - public TestNonIdMetadata? NestedMetadata { get; set; } - } - - /// - /// Non-IId metadata class - uses Newtonsoft PreserveReferencesHandling (numeric $id/$ref) - /// - public class TestNonIdMetadata - { - public string Key { get; set; } = string.Empty; - public string Value { get; set; } = string.Empty; - public DateTime Timestamp { get; set; } - - // Nested non-IId for deep Newtonsoft reference testing - public TestNonIdMetadata? ChildMetadata { get; set; } - } - - /// - /// Order DTO with Guid Id - for testing Guid-based IId - /// - public class TestGuidOrder : IId - { - public Guid Id { get; set; } - public string Code { get; set; } = string.Empty; - public List Items { get; set; } = []; - public int Count { get; set; } - } - - /// - /// Item DTO with Guid Id - for testing Guid-based IId - /// - public class TestGuidItem : IId - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public int Qty { get; set; } - } - - #endregion - - #region Test Data Factory - - /// - /// Factory for creating deep test object hierarchies - /// - public static class TestDataFactory - { - private static int _idCounter = 1; - - public static void ResetIdCounter() => _idCounter = 1; - - public static TestOrder CreateDeepOrder( - int itemCount = 2, - int palletsPerItem = 2, - int measurementsPerPallet = 2, - int pointsPerMeasurement = 3, - TestNonIdMetadata? sharedNonIdMetadata = null, - TestSharedAttribute? sharedAttribute = null) - { - var order = new TestOrder - { - Id = _idCounter++, - OrderNumber = $"ORD-{_idCounter:D4}", - OrderStatus = TestStatus.Pending, - CreatedAt = DateTime.UtcNow, - PrimaryAttribute = sharedAttribute, - SecondaryAttribute = sharedAttribute, // Same reference for $ref testing - OrderMetadata = sharedNonIdMetadata, - AuditMetadata = sharedNonIdMetadata // Same reference for Newtonsoft $ref - }; - - if (sharedAttribute != null) - { - order.Attributes.Add(sharedAttribute); - } - - for (int i = 0; i < itemCount; i++) - { - var item = CreateOrderItem(palletsPerItem, measurementsPerPallet, pointsPerMeasurement, sharedNonIdMetadata, sharedAttribute); - item.ParentOrder = order; // Reference loop - order.Items.Add(item); - } - - return order; - } - - public static TestOrderItem CreateOrderItem( - int palletCount = 2, - int measurementsPerPallet = 2, - int pointsPerMeasurement = 3, - TestNonIdMetadata? sharedNonIdMetadata = null, - TestSharedAttribute? sharedAttribute = null) - { - var item = new TestOrderItem - { - Id = _idCounter++, - ProductName = $"Product-{_idCounter}", - Quantity = 10 + _idCounter, - UnitPrice = 5.5m * _idCounter, - ItemStatus = TestStatus.Pending, - Attribute = sharedAttribute, - ItemMetadata = sharedNonIdMetadata - }; - - for (int i = 0; i < palletCount; i++) - { - var pallet = CreatePallet(measurementsPerPallet, pointsPerMeasurement, sharedNonIdMetadata); - pallet.ParentOrderItem = item; // Reference loop - item.Pallets.Add(pallet); - } - - return item; - } - - public static TestPallet CreatePallet( - int measurementCount = 2, - int pointsPerMeasurement = 3, - TestNonIdMetadata? sharedNonIdMetadata = null) - { - var pallet = new TestPallet - { - Id = _idCounter++, - PalletCode = $"PLT-{_idCounter:D4}", - TrayCount = 5 + _idCounter % 10, - Status = TestStatus.Pending, - PalletMetadata = sharedNonIdMetadata - }; - - for (int i = 0; i < measurementCount; i++) - { - var measurement = CreateMeasurement(pointsPerMeasurement); - measurement.ParentPallet = pallet; // Reference loop - pallet.Measurements.Add(measurement); - } - - return pallet; - } - - public static TestMeasurement CreateMeasurement(int pointCount = 3) - { - var measurement = new TestMeasurement - { - Id = _idCounter++, - Name = $"Measurement-{_idCounter}", - TotalWeight = 100.5 + _idCounter, - CreatedAt = DateTime.UtcNow - }; - - for (int i = 0; i < pointCount; i++) - { - var point = CreateMeasurementPoint(); - point.ParentMeasurement = measurement; // Reference loop - measurement.Points.Add(point); - } - - return measurement; - } - - public static TestMeasurementPoint CreateMeasurementPoint() - { - return new TestMeasurementPoint - { - Id = _idCounter++, - Label = $"Point-{_idCounter}", - Value = 10.5 + (_idCounter * 0.1), - MeasuredAt = DateTime.UtcNow - }; - } - - public static TestSharedAttribute CreateSharedAttribute() - { - return new TestSharedAttribute - { - Id = _idCounter++, - Key = $"Attr-{_idCounter}", - Value = $"Value-{_idCounter}", - CreatedOrUpdatedDateUTC = DateTime.UtcNow - }; - } - - public static TestNonIdMetadata CreateNonIdMetadata(bool withChild = false) - { - return new TestNonIdMetadata - { - Key = $"Meta-{_idCounter++}", - Value = $"MetaValue-{_idCounter}", - Timestamp = DateTime.UtcNow, - ChildMetadata = withChild ? CreateNonIdMetadata(false) : null - }; - } - } - - #endregion - - #region Primitive Element Array Tests (SignalR IdMessage pattern fix) - - /// - /// Test that reproduces the exact SignalR loadRelations=true becoming false issue. - /// IdMessage wraps primitives in arrays: (new[] { x }).ToJson() -> "[true]" string - /// Server deserializes: "[true]".JsonTo(typeof(bool[])) -> should return bool[] with true - /// - [TestMethod] - public void PrimitiveArray_BooleanTrue_SerializesAndDeserializesCorrectly() - { - // Arrange - This is exactly what IdMessage constructor does - var loadRelations = true; - var settings = GetMergeSettings(); - - // Step 1: Client side - IdMessage constructor wraps in array and serializes - var jsonString = (new[] { loadRelations }).ToJson(settings); - Console.WriteLine($"Step 1 - Client serialized [true]: {jsonString}"); - - // Step 2: Server side - ProcessOnReceiveMessage deserializes - // This is: paramValues[i] = (obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!; - var targetArrayType = typeof(bool).MakeArrayType(); - var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; - var deserializedValue = deserializedArray?.GetValue(0); - - Console.WriteLine($"Step 2 - Server deserialized value: {deserializedValue} (type: {deserializedValue?.GetType()})"); - // Assert - Assert.IsNotNull(deserializedArray, "Deserialized array should not be null"); - Assert.AreEqual(1, deserializedArray.Length, "Array should have 1 element"); - Assert.IsInstanceOfType(deserializedValue, typeof(bool), "Value should be bool"); - Assert.IsTrue((bool)deserializedValue!, "Boolean true should deserialize as true, NOT false!"); + Assert.IsNotNull(deserialized); + Assert.AreEqual(order.Id, deserialized.Id); + Assert.AreEqual(order.OrderNumber, deserialized.OrderNumber); + Assert.AreEqual(order.Items.Count, deserialized.Items.Count); } + #endregion + + #region Primitive Array Tests (SignalR IdMessage pattern) + [TestMethod] - public void PrimitiveArray_BooleanFalse_SerializesAndDeserializesCorrectly() + public void PrimitiveArray_BooleanTrue_RoundTrips() { - var loadRelations = false; var settings = GetMergeSettings(); + var jsonString = (new[] { true }).ToJson(settings); - var jsonString = (new[] { loadRelations }).ToJson(settings); - Console.WriteLine($"Serialized [false]: {jsonString}"); + var result = jsonString.JsonTo(typeof(bool[]), settings) as bool[]; - var targetArrayType = typeof(bool).MakeArrayType(); - var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; - var deserializedValue = deserializedArray?.GetValue(0); - - Console.WriteLine($"Deserialized value: {deserializedValue}"); - - Assert.IsNotNull(deserializedArray); - Assert.IsFalse((bool)deserializedValue!, "Boolean false should deserialize as false"); + Assert.IsNotNull(result); + Assert.IsTrue(result[0], "Boolean true should deserialize as true!"); } [TestMethod] - public void PrimitiveArray_Int_SerializesAndDeserializesCorrectly() + public void PrimitiveArray_AllTypes_RoundTrip() { - var pageSize = 42; var settings = GetMergeSettings(); - - var jsonString = (new[] { pageSize }).ToJson(settings); - Console.WriteLine($"Serialized [42]: {jsonString}"); - - var targetArrayType = typeof(int).MakeArrayType(); - var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; - var deserializedValue = deserializedArray?.GetValue(0); - - Console.WriteLine($"Deserialized value: {deserializedValue}"); - - Assert.IsNotNull(deserializedArray); - Assert.AreEqual(42, (int)deserializedValue!, "Int 42 should deserialize as 42"); - } - - [TestMethod] - public void PrimitiveArray_Guid_SerializesAndDeserializesCorrectly() - { - var id = Guid.NewGuid(); - var settings = GetMergeSettings(); - - var jsonString = (new[] { id }).ToJson(settings); - Console.WriteLine($"Serialized [{id}]: {jsonString}"); - - var targetArrayType = typeof(Guid).MakeArrayType(); - var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; - var deserializedValue = deserializedArray?.GetValue(0); - - Console.WriteLine($"Deserialized value: {deserializedValue}"); - - Assert.IsNotNull(deserializedArray); - Assert.AreEqual(id, (Guid)deserializedValue!, "Guid should deserialize correctly"); - } - - [TestMethod] - public void PrimitiveArray_String_SerializesAndDeserializesCorrectly() - { - // Arrange - String with special characters that need escaping - var item = new TestOrderItem + var testCases = new (Type type, object value)[] { - Id = 1, - ProductName = "Test \"quoted\" \\ backslash \n newline \t tab \r return" + (typeof(bool), true), + (typeof(int), 42), + (typeof(long), 123456789L), + (typeof(double), 3.14159), + (typeof(decimal), 99.99m), + (typeof(string), "test"), + (typeof(Guid), Guid.NewGuid()), + (typeof(DateTime), DateTime.UtcNow), + (typeof(TestStatus), TestStatus.Processing) }; - // Act + foreach (var (type, value) in testCases) + { + var wrapped = Array.CreateInstance(type, 1); + wrapped.SetValue(value, 0); + var json = wrapped.ToJson(settings); + + var result = json.JsonTo(type.MakeArrayType(), settings) as Array; + + Assert.IsNotNull(result, $"Failed for {type.Name}"); + Assert.AreEqual(value, result.GetValue(0), $"Value mismatch for {type.Name}"); + } + } + + [TestMethod] + public void IdMessage_MultipleParameters_SimulateSignalR() + { + var settings = GetMergeSettings(); + var @params = new (Type t, object v)[] { (typeof(bool), true), (typeof(string), "filter"), (typeof(int), 100) }; + + foreach (var (type, value) in @params) + { + var wrapped = Array.CreateInstance(type, 1); + wrapped.SetValue(value, 0); + var json = wrapped.ToJson(settings); + var arr = json.JsonTo(type.MakeArrayType(), settings) as Array; + Assert.AreEqual(value, arr?.GetValue(0)); + } + } + + #endregion + + #region WASM Compatibility Tests + + [TestMethod] + public void WasmCompat_AcJsonSerializer_SimpleObject() + { + var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 10, UnitPrice = 99.99m, Status = TestStatus.Processing }; + var json = AcJsonSerializer.Serialize(item); - // Assert - JSON should have escaped characters - Assert.IsTrue(json.Contains("\\\""), "Quotes should be escaped"); - Assert.IsTrue(json.Contains("\\\\"), "Backslashes should be escaped"); - Assert.IsTrue(json.Contains("\\n"), "Newlines should be escaped"); - Assert.IsTrue(json.Contains("\\t"), "Tabs should be escaped"); + Assert.IsTrue(json.Contains("\"Id\":1")); + Assert.IsTrue(json.Contains("\"ProductName\":\"Test\"")); + } + + [TestMethod] + public void WasmCompat_AcJsonDeserializer_RoundTrip() + { + var original = new TestOrderItem { Id = 42, ProductName = "WASM Test", Quantity = 5, UnitPrice = 25.50m, Status = TestStatus.Shipped }; + var json = AcJsonSerializer.Serialize(original); - // Round-trip var deserialized = AcJsonDeserializer.Deserialize(json); - Assert.AreEqual("Test \"quoted\" \\ backslash \n newline \t tab \r return", - deserialized?.ProductName); + + Assert.IsNotNull(deserialized); + Assert.AreEqual(42, deserialized.Id); + Assert.AreEqual("WASM Test", deserialized.ProductName); + Assert.AreEqual(TestStatus.Shipped, deserialized.Status); } [TestMethod] - public void PrimitiveArray_Enum_SerializesAndDeserializesCorrectly() + public void WasmCompat_AllPrimitiveTypes() { - var status = TestStatus.Processing; - var settings = GetMergeSettings(); - - var jsonString = (new[] { status }).ToJson(settings); - Console.WriteLine($"Serialized [TestStatus.Processing]: {jsonString}"); - - var targetArrayType = typeof(TestStatus).MakeArrayType(); - var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; - var deserializedValue = deserializedArray?.GetValue(0); - - Console.WriteLine($"Deserialized value: {deserializedValue}"); - - Assert.IsNotNull(deserializedArray); - Assert.AreEqual(TestStatus.Processing, (TestStatus)deserializedValue!, "Enum should deserialize correctly"); + var testData = TestDataFactory.CreatePrimitiveTestData(); + + var json = AcJsonSerializer.Serialize(testData); + var deserialized = AcJsonDeserializer.Deserialize(json); + + Assert.IsNotNull(deserialized); + Assert.AreEqual(testData.IntValue, deserialized.IntValue); + Assert.AreEqual(testData.LongValue, deserialized.LongValue); + Assert.AreEqual(testData.BoolValue, deserialized.BoolValue); + Assert.AreEqual(testData.StringValue, deserialized.StringValue); + Assert.AreEqual(testData.GuidValue, deserialized.GuidValue); + Assert.AreEqual(testData.EnumValue, deserialized.EnumValue); } [TestMethod] - public void PrimitiveArray_DateTime_SerializesAndDeserializesCorrectly() + public void WasmCompat_EmptyCollections_HandleCorrectly() { - var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); - var settings = GetMergeSettings(); + var order = new TestOrder { Id = 1, OrderNumber = "EMPTY-TEST", Items = [], Tags = [] }; + + var json = AcJsonSerializer.Serialize(order); - var jsonString = (new[] { dateTime }).ToJson(settings); - Console.WriteLine($"Serialized [{dateTime}]: {jsonString}"); + Assert.IsTrue(json.Contains("\"Items\":[]"), "Empty Items should serialize as []"); + Assert.IsTrue(json.Contains("\"Tags\":[]"), "Empty Tags should serialize as []"); + + var deserialized = AcJsonDeserializer.Deserialize(json); - var targetArrayType = typeof(DateTime).MakeArrayType(); - var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; - var deserializedValue = deserializedArray?.GetValue(0); - - Console.WriteLine($"Deserialized value: {deserializedValue}"); - - Assert.IsNotNull(deserializedArray); - Assert.AreEqual(dateTime, (DateTime)deserializedValue!, "DateTime should deserialize correctly"); + Assert.IsNotNull(deserialized?.Items); + Assert.AreEqual(0, deserialized.Items.Count); + Assert.IsNotNull(deserialized?.Tags); + Assert.AreEqual(0, deserialized.Tags.Count); } [TestMethod] - public void PrimitiveArray_Decimal_SerializesAndDeserializesCorrectly() + public void Serialize_NullCollection_IsOmitted() { - var price = 123.45m; - var settings = GetMergeSettings(); - - var jsonString = (new[] { price }).ToJson(settings); - Console.WriteLine($"Serialized [{price}]: {jsonString}"); - - var targetArrayType = typeof(decimal).MakeArrayType(); - var deserializedArray = jsonString.JsonTo(targetArrayType, settings) as Array; - var deserializedValue = deserializedArray?.GetValue(0); - - Console.WriteLine($"Deserialized value: {deserializedValue}"); - - Assert.IsNotNull(deserializedArray); - Assert.AreEqual(price, (decimal)deserializedValue!, "Decimal should deserialize correctly"); + var order = new TestOrderWithNullableCollections { Id = 1, OrderNumber = "TEST", Items = null, Tags = null }; + + var json = AcJsonSerializer.Serialize(order); + + Assert.IsFalse(json.Contains("\"Items\""), "Null Items should not be serialized"); + Assert.IsFalse(json.Contains("\"Tags\""), "Null Tags should not be serialized"); } [TestMethod] - public void PrimitiveArray_MultipleParameters_SimulateSignalRCall() + public void WasmCompat_SharedReferences_IdRefResolution() { - // Simulate: GetStockTakings(loadRelations: true, filter: "test", pageSize: 100) - // Client sends: contextParams = new object[] { true, "test", 100 } - // IdMessage wraps each param: Ids.Add((new[] { param }).ToJson()) -> "[true]", "[\"test\"]", "[100]" - var settings = GetMergeSettings(); - - // Simulate IdMessage constructor - var contextParams = new object[] { true, "filterText", 100, Guid.NewGuid(), TestStatus.Processing }; - var serializedParams = new List(); - foreach (var param in contextParams) + var sharedTag = new SharedTag { Id = 999, Name = "SharedKey" }; + var order = new TestOrder { Id = 1, OrderNumber = "REF-TEST", PrimaryTag = sharedTag, SecondaryTag = sharedTag, Tags = [sharedTag] }; + + var json = AcJsonSerializer.Serialize(order); + + Assert.IsTrue(json.Contains("\"$id\"")); + Assert.IsTrue(json.Contains("\"$ref\"")); + + var nativeSettings = new JsonSerializerSettings { - // This is what IdMessage does: item = (new[] { x }).ToJson(); - var wrapped = Array.CreateInstance(param.GetType(), 1); - wrapped.SetValue(param, 0); - var json = wrapped.ToJson(settings); - serializedParams.Add(json); - Console.WriteLine($"IdMessage serialized: {param.GetType().Name} '{param}' -> {json}"); - } - - // Simulate ProcessOnReceiveMessage deserialization - var paramTypes = new[] { typeof(bool), typeof(string), typeof(int), typeof(Guid), typeof(TestStatus) }; - var deserializedParams = new object[paramTypes.Length]; - - for (var i = 0; i < paramTypes.Length; i++) - { - var jsonStr = serializedParams[i]; - var arrayType = paramTypes[i].MakeArrayType(); - var arr = jsonStr.JsonTo(arrayType, settings) as Array; - deserializedParams[i] = arr?.GetValue(0)!; - - Console.WriteLine($"ProcessOnReceiveMessage deserialized: {paramTypes[i].Name} -> {deserializedParams[i]}"); - } - - // Assert - All values should be correctly deserialized - Assert.IsTrue((bool)deserializedParams[0], "loadRelations should be TRUE!"); - Assert.AreEqual("filterText", (string)deserializedParams[1]); - Assert.AreEqual(100, (int)deserializedParams[2]); - Assert.AreEqual(contextParams[3], (Guid)deserializedParams[3]); - Assert.AreEqual(TestStatus.Processing, (TestStatus)deserializedParams[4]); - } + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore + }; - [TestMethod] - public void PrimitiveArray_GenericJsonTo_BoolArray_WorksCorrectly() - { - // Test the generic JsonTo method with primitive arrays - var settings = GetMergeSettings(); - - var json = "[true, false, true]"; - var result = json.JsonTo(settings); - - Assert.IsNotNull(result); - Assert.AreEqual(3, result.Length); - Assert.IsTrue(result[0]); - Assert.IsFalse(result[1]); - Assert.IsTrue(result[2]); - } + var deserialized = JsonConvert.DeserializeObject(json, nativeSettings); - [TestMethod] - public void PrimitiveArray_GenericJsonTo_IntList_WorksCorrectly() - { - // Test the generic JsonTo method with primitive lists - var settings = GetMergeSettings(); - - var json = "[1, 2, 3, 4, 5]"; - var result = json.JsonTo>(settings); - - Assert.IsNotNull(result); - Assert.AreEqual(5, result.Count); - CollectionAssert.AreEqual(new[] { 1, 2, 3, 4, 5 }, result.ToArray()); + Assert.IsNotNull(deserialized); + Assert.AreSame(deserialized.PrimaryTag, deserialized.SecondaryTag); + Assert.AreSame(deserialized.PrimaryTag, deserialized.Tags[0]); } #endregion @@ -1203,778 +554,1026 @@ public sealed class JsonExtensionTests #region Cross-Serializer Compatibility Tests [TestMethod] - public void CrossSerializer_MixedReferences_SerializeWithHybridDeserializeWithNativeNewtonsoft() + public void CrossSerializer_MixedReferences_CompatibleWithNewtonsoft() { - // Arrange: Create complex object with both IId and non-IId shared references - TestDataFactory.ResetIdCounter(); - - var sharedAttr = new TestSharedAttribute + // Arrange + var sharedTag = new SharedTag { Id = 100, Name = "SharedKey", CreatedAt = DateTime.UtcNow }; + var sharedMeta = new MetadataInfo { Key = "SharedMeta", Value = "MetaValue", ChildMetadata = new MetadataInfo { Key = "Child" } }; + + var order = new TestOrder { - Id = 100, - Key = "SharedKey", - Value = "SharedValue", - CreatedOrUpdatedDateUTC = DateTime.UtcNow + Id = 1, + OrderNumber = "ORD-001", + Status = TestStatus.Processing, + PrimaryTag = sharedTag, + SecondaryTag = sharedTag, + OrderMetadata = sharedMeta, + AuditMetadata = sharedMeta, + Tags = [sharedTag], + Items = [new TestOrderItem { Id = 10, ProductName = "Product-A", Tag = sharedTag, ItemMetadata = sharedMeta }] }; - - var sharedMeta = new TestNonIdMetadata + + // Act - Serialize with AyCode + var json = order.ToJson(GetMergeSettings()); + + // Deserialize with native Newtonsoft + var nativeSettings = new JsonSerializerSettings { - Key = "SharedMeta", - Value = "MetaValue", - Timestamp = DateTime.UtcNow, - ChildMetadata = new TestNonIdMetadata + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore + }; + + var deserialized = JsonConvert.DeserializeObject(json, nativeSettings); + + // Assert + Assert.IsNotNull(deserialized); + Assert.AreSame(deserialized.PrimaryTag, deserialized.SecondaryTag); + Assert.AreSame(deserialized.OrderMetadata, deserialized.AuditMetadata); + Assert.AreSame(deserialized.PrimaryTag, deserialized.Items[0].Tag); + } + + #endregion + + #region Populate $ref Handling Tests + + [TestMethod] + public void Populate_RefNode_ShouldSetPropertyToReferencedObject() + { + // Arrange: Create JSON with $id and $ref + // This simulates a scenario where the same object is referenced multiple times + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" }, + ""SecondaryTag"": { ""$ref"": ""1"" } + }"; + + var order = new TestOrder { Id = 1, OrderNumber = "OLD" }; + + // Act + json.JsonTo(order, GetMergeSettings()); + + // Assert + Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set"); + Assert.IsNotNull(order.SecondaryTag, "SecondaryTag should be set from $ref"); + Assert.AreEqual(100, order.PrimaryTag.Id); + Assert.AreEqual("SharedTag", order.PrimaryTag.Name); + + // The key assertion: SecondaryTag should be the SAME object as PrimaryTag (via $ref) + Assert.AreSame(order.PrimaryTag, order.SecondaryTag, + "SecondaryTag should reference the same object as PrimaryTag via $ref"); + } + + [TestMethod] + public void Populate_RefNodeInCollection_ShouldSetPropertyToReferencedObject() + { + // Arrange: Create JSON with shared reference in collection + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" }, + ""Tags"": [ + { ""$ref"": ""1"" }, + { ""$id"": ""2"", ""Id"": 200, ""Name"": ""OtherTag"" } + ] + }"; + + var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List() }; + + // Act + json.JsonTo(order, GetMergeSettings()); + + // Assert + Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set"); + Assert.AreEqual(2, order.Tags.Count, "Tags should have 2 items"); + + // First tag should be same as PrimaryTag via $ref + Assert.AreSame(order.PrimaryTag, order.Tags[0], + "Tags[0] should reference the same object as PrimaryTag via $ref"); + + // Second tag should be different + Assert.AreEqual(200, order.Tags[1].Id); + Assert.AreNotSame(order.PrimaryTag, order.Tags[1]); + } + + [TestMethod] + public void Populate_NestedRefNode_ShouldResolveCorrectly() + { + // Arrange: Create JSON with nested $ref + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""Items"": [{ + ""Id"": 10, + ""ProductName"": ""Product-A"", + ""Tag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""ItemTag"" } + }], + ""PrimaryTag"": { ""$ref"": ""1"" } + }"; + + var order = new TestOrder + { + Id = 1, + OrderNumber = "OLD", + Items = new List { new TestOrderItem { Id = 10, ProductName = "OLD" } } + }; + + // Act + json.JsonTo(order, GetMergeSettings()); + + // Assert + Assert.IsNotNull(order.Items[0].Tag, "Item's Tag should be set"); + Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set from $ref"); + + // PrimaryTag should be same as Items[0].Tag via $ref + Assert.AreSame(order.Items[0].Tag, order.PrimaryTag, + "PrimaryTag should reference the same object as Items[0].Tag via $ref"); + } + + [TestMethod] + public void Populate_ForwardRef_ShouldResolveDeferredReference() + { + // Arrange: $ref appears BEFORE $id (forward reference) + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""SecondaryTag"": { ""$ref"": ""1"" }, + ""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" } + }"; + + var order = new TestOrder { Id = 1, OrderNumber = "OLD" }; + + // Act + json.JsonTo(order, GetMergeSettings()); + + // Assert - forward reference should be resolved + Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set"); + Assert.IsNotNull(order.SecondaryTag, "SecondaryTag should be set from forward $ref"); + Assert.AreSame(order.PrimaryTag, order.SecondaryTag, + "Forward $ref should resolve to the same object"); + } + + [TestMethod] + public void Populate_MultipleRefsToSameId_AllShouldResolveToSameObject() + { + // Arrange: Create JSON with multiple $refs pointing to the same $id + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" }, + ""SecondaryTag"": { ""$ref"": ""1"" }, + ""Tags"": [ + { ""$ref"": ""1"" }, + { ""$ref"": ""1"" }, + { ""$ref"": ""1"" } + ] + }"; + + var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List() }; + + // Act + json.JsonTo(order, GetMergeSettings()); + + // Assert - all refs should point to the same object + Assert.IsNotNull(order.PrimaryTag); + Assert.AreSame(order.PrimaryTag, order.SecondaryTag); + Assert.AreEqual(3, order.Tags.Count); + Assert.AreSame(order.PrimaryTag, order.Tags[0]); + Assert.AreSame(order.PrimaryTag, order.Tags[1]); + Assert.AreSame(order.PrimaryTag, order.Tags[2]); + } + + [TestMethod] + public void Populate_DeeplyNestedRef_ShouldResolveAcrossLevels() + { + // Arrange: Create JSON with $id at deep level (Item.Tag), $ref at root level + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""Items"": [{ + ""Id"": 10, + ""ProductName"": ""Product-A"", + ""Tag"": { ""$id"": ""deep1"", ""Id"": 999, ""Name"": ""DeepTag"" } + }], + ""PrimaryTag"": { ""$ref"": ""deep1"" } + }"; + + var order = new TestOrder + { + Id = 1, + Items = new List { new TestOrderItem { Id = 10 } } + }; + + // Act + json.JsonTo(order, GetMergeSettings()); + + // Assert + var deepTag = order.Items[0].Tag; + Assert.IsNotNull(deepTag, "Item's Tag should be set"); + Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set from deep $ref"); + Assert.AreSame(deepTag, order.PrimaryTag, + "Root PrimaryTag should reference the nested Item's Tag via $ref"); + } + + [TestMethod] + public void Populate_RefInNestedObject_ShouldResolveFromParentContext() + { + // Arrange: $id at root, $ref in nested child + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""RootTag"" }, + ""Items"": [{ + ""Id"": 10, + ""ProductName"": ""Product-A"", + ""Tag"": { ""$ref"": ""1"" } + }] + }"; + + var order = new TestOrder + { + Id = 1, + Items = new List { new TestOrderItem { Id = 10 } } + }; + + // Act + json.JsonTo(order, GetMergeSettings()); + + // Assert + Assert.IsNotNull(order.PrimaryTag); + Assert.IsNotNull(order.Items[0].Tag); + Assert.AreSame(order.PrimaryTag, order.Items[0].Tag, + "Nested Tag should reference root PrimaryTag via $ref"); + } + + [TestMethod] + public void Deserialize_RefOnly_ShouldCreateDeferredAndResolve() + { + // Arrange: JSON where only $ref exists (forward reference scenario in deserialize) + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""SecondaryTag"": { ""$ref"": ""1"" }, + ""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""Tag"" } + }"; + + // Act + var order = AcJsonDeserializer.Deserialize(json); + + // Assert + Assert.IsNotNull(order); + Assert.IsNotNull(order.PrimaryTag); + Assert.IsNotNull(order.SecondaryTag); + Assert.AreSame(order.PrimaryTag, order.SecondaryTag); + } + + [TestMethod] + public void Deserialize_MultipleIdRefs_ComplexGraph() + { + // Arrange: Complex object graph with multiple $id/$ref pairs + var json = @"{ + ""Id"": 1, + ""OrderNumber"": ""ORD-001"", + ""PrimaryTag"": { ""$id"": ""tag1"", ""Id"": 100, ""Name"": ""Tag1"" }, + ""SecondaryTag"": { ""$id"": ""tag2"", ""Id"": 200, ""Name"": ""Tag2"" }, + ""Tags"": [ + { ""$ref"": ""tag1"" }, + { ""$ref"": ""tag2"" }, + { ""$ref"": ""tag1"" } + ], + ""Items"": [{ + ""Id"": 10, + ""ProductName"": ""Product-A"", + ""Tag"": { ""$ref"": ""tag2"" } + }] + }"; + + // Act + var order = AcJsonDeserializer.Deserialize(json); + + // Assert + Assert.IsNotNull(order); + Assert.AreEqual(3, order.Tags.Count); + + // Verify tag1 references + Assert.AreSame(order.PrimaryTag, order.Tags[0]); + Assert.AreSame(order.PrimaryTag, order.Tags[2]); + + // Verify tag2 references + Assert.AreSame(order.SecondaryTag, order.Tags[1]); + Assert.AreSame(order.SecondaryTag, order.Items[0].Tag); + + // Verify they are different + Assert.AreNotSame(order.PrimaryTag, order.SecondaryTag); + } + + [TestMethod] + public void Populate_RefWithExistingTarget_ShouldOverwriteWithReference() + { + // Arrange: Target has existing value, should be overwritten by $ref + var json = @"{ + ""Id"": 1, + ""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""NewTag"" }, + ""SecondaryTag"": { ""$ref"": ""1"" } + }"; + + var existingTag = new SharedTag { Id = 999, Name = "ExistingTag" }; + var order = new TestOrder + { + Id = 1, + SecondaryTag = existingTag // Pre-existing value + }; + + // Act + json.JsonTo(order, GetMergeSettings()); + + // Assert - SecondaryTag should be overwritten with the $ref reference + Assert.IsNotNull(order.PrimaryTag); + Assert.AreSame(order.PrimaryTag, order.SecondaryTag, + "SecondaryTag should be overwritten with $ref reference"); + Assert.AreNotSame(existingTag, order.SecondaryTag, + "Original SecondaryTag should be replaced"); + } + + #endregion + + #region AcJsonSerializer Complex Object Tests + + [TestMethod] + public void Serialize_ObjectWithDictionaryProperty_SerializesDictionaryCorrectly() + { + var obj = new ExtendedPrimitiveTestClass + { + Id = 1, + Name = "Test", + Counts = new Dictionary { - Key = "ChildMeta", - Value = "ChildValue", - Timestamp = DateTime.UtcNow + { "apple", 5 }, + { "banana", 3 } } }; - // Shared attribute also has nested non-IId metadata - sharedAttr.NestedMetadata = sharedMeta; + var json = AcJsonSerializer.Serialize(obj); - var order = new TestOrder + Assert.IsTrue(json.Contains("\"Counts\":{")); + Assert.IsTrue(json.Contains("\"apple\":5")); + Assert.IsTrue(json.Contains("\"banana\":3")); + } + + [TestMethod] + public void Serialize_ObjectWithDateTimeOffsetProperty_SerializesCorrectly() + { + var dto = new DateTimeOffset(2024, 6, 15, 10, 30, 0, TimeSpan.FromHours(2)); + var obj = new ExtendedPrimitiveTestClass { Id = 1, - OrderNumber = "ORD-001", - OrderStatus = TestStatus.Processing, - CreatedAt = DateTime.UtcNow, - PrimaryAttribute = sharedAttr, - SecondaryAttribute = sharedAttr, // Same IId reference - OrderMetadata = sharedMeta, - AuditMetadata = sharedMeta, // Same non-IId reference - Items = - [ - new TestOrderItem - { - Id = 10, - ProductName = "Product-A", - Quantity = 5, - UnitPrice = 10.50m, - Attribute = sharedAttr, - ItemMetadata = sharedMeta - } - ], - Attributes = [sharedAttr] + Name = "Test", + DateTimeOffsetValue = dto }; - var settings = GetMergeSettings(); - settings.Formatting = Formatting.Indented; + var json = AcJsonSerializer.Serialize(obj); - // Act - var json = order.ToJson(settings); + Assert.IsTrue(json.Contains("\"DateTimeOffsetValue\":\"2024-06-15")); + } - Console.WriteLine("=== Serialized JSON (HybridReferenceResolver) ==="); - Console.WriteLine(json); - - // Verify JSON structure - Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id for references"); - Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for duplicate references"); - - // Count $ref occurrences - should have multiple (for both IId and non-IId duplicates) - var refCount = json.Split("\"$ref\"").Length - 1; - Console.WriteLine($"$ref count: {refCount}"); - Assert.IsTrue(refCount >= 3, $"Should have at least 3 $ref tokens (shared IId and non-IId). Found: {refCount}"); - - // Step 2: Deserialize with native Newtonsoft (NO custom resolver, just PreserveReferencesHandling) - var nativeSettings = new JsonSerializerSettings + [TestMethod] + public void Serialize_ObjectWithTimeSpanProperty_SerializesCorrectly() + { + var obj = new ExtendedPrimitiveTestClass { - PreserveReferencesHandling = PreserveReferencesHandling.Objects, - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - NullValueHandling = NullValueHandling.Ignore + Id = 1, + Name = "Test", + TimeSpanValue = new TimeSpan(2, 30, 45) }; - var deserializedOrder = JsonConvert.DeserializeObject(json, nativeSettings); + var json = AcJsonSerializer.Serialize(obj); - // Step 3: Verify deserialization - Assert.IsNotNull(deserializedOrder, "Deserialized order should not be null"); - Assert.AreEqual(1, deserializedOrder.Id); - Assert.AreEqual("ORD-001", deserializedOrder.OrderNumber); - Assert.AreEqual(TestStatus.Processing, deserializedOrder.OrderStatus); + Assert.IsTrue(json.Contains("\"TimeSpanValue\":\"02:30:45\"")); + } - // Verify items - Assert.AreEqual(1, deserializedOrder.Items.Count); - Assert.AreEqual(10, deserializedOrder.Items[0].Id); - Assert.AreEqual("Product-A", deserializedOrder.Items[0].ProductName); - Assert.AreEqual(5, deserializedOrder.Items[0].Quantity); - Assert.AreEqual(10.50m, deserializedOrder.Items[0].UnitPrice); - - // Verify shared IId references are resolved correctly - Assert.IsNotNull(deserializedOrder.PrimaryAttribute); - Assert.IsNotNull(deserializedOrder.SecondaryAttribute); - Assert.AreEqual(100, deserializedOrder.PrimaryAttribute.Id); - Assert.AreEqual("SharedKey", deserializedOrder.PrimaryAttribute.Key); - - // KEY TEST: Shared IId references should be the SAME object instance - Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.SecondaryAttribute, - "PrimaryAttribute and SecondaryAttribute should be same instance (IId shared reference)"); - Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Items[0].Attribute, - "Order.PrimaryAttribute and Item.Attribute should be same instance"); - Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Attributes[0], - "Order.PrimaryAttribute and Attributes[0] should be same instance"); + [TestMethod] + public void Serialize_ObjectWithNullProperties_SkipsNullProperties() + { + var obj = new ExtendedPrimitiveTestClass + { + Id = 1, + Name = "Test", + NullString = null, + NullObject = null, + Counts = null + }; - // Verify shared non-IId references are resolved correctly - Assert.IsNotNull(deserializedOrder.OrderMetadata); - Assert.IsNotNull(deserializedOrder.AuditMetadata); - Assert.AreEqual("SharedMeta", deserializedOrder.OrderMetadata.Key); - - // KEY TEST: Shared non-IId references should be the SAME object instance - Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.AuditMetadata, - "OrderMetadata and AuditMetadata should be same instance (non-IId shared reference)"); - Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.Items[0].ItemMetadata, - "Order.OrderMetadata and Item.ItemMetadata should be same instance"); + var json = AcJsonSerializer.Serialize(obj); - // Verify nested non-IId in IId type - Assert.IsNotNull(deserializedOrder.PrimaryAttribute.NestedMetadata); - Assert.AreSame(deserializedOrder.OrderMetadata, deserializedOrder.PrimaryAttribute.NestedMetadata, - "Shared attribute's NestedMetadata should be same as OrderMetadata"); + Assert.IsFalse(json.Contains("\"NullString\"")); + Assert.IsFalse(json.Contains("\"NullObject\"")); + Assert.IsFalse(json.Contains("\"Counts\"")); + } - // Verify child metadata - Assert.IsNotNull(deserializedOrder.OrderMetadata.ChildMetadata); - Assert.AreEqual("ChildMeta", deserializedOrder.OrderMetadata.ChildMetadata.Key); + [TestMethod] + public void Serialize_ObjectWithUIntProperty_SerializesCorrectly() + { + var obj = new ExtendedPrimitiveTestClass + { + Id = 1, + Name = "Test", + UIntValue = 4000000000 + }; - Console.WriteLine("=== All cross-serializer compatibility checks passed! ==="); + var json = AcJsonSerializer.Serialize(obj); + + Assert.IsTrue(json.Contains("\"UIntValue\":4000000000")); + } + + [TestMethod] + public void Serialize_ObjectWithULongProperty_SerializesCorrectly() + { + var obj = new ExtendedPrimitiveTestClass + { + Id = 1, + Name = "Test", + ULongValue = 18000000000000000000 + }; + + var json = AcJsonSerializer.Serialize(obj); + + Assert.IsTrue(json.Contains("\"ULongValue\":18000000000000000000")); + } + + [TestMethod] + public void Serialize_ObjectWithSByteProperty_SerializesCorrectly() + { + var obj = new ExtendedPrimitiveTestClass + { + Id = 1, + Name = "Test", + SByteValue = -100 + }; + + var json = AcJsonSerializer.Serialize(obj); + + Assert.IsTrue(json.Contains("\"SByteValue\":-100")); + } + + [TestMethod] + public void Serialize_ObjectWithCharProperty_SerializesCorrectly() + { + var obj = new ExtendedPrimitiveTestClass + { + Id = 1, + Name = "Test", + CharValue = 'X' + }; + + var json = AcJsonSerializer.Serialize(obj); + + Assert.IsTrue(json.Contains("\"CharValue\":\"X\"")); + } + + [TestMethod] + public void Serialize_ObjectWithUShortProperty_SerializesCorrectly() + { + var obj = new ExtendedPrimitiveTestClass + { + Id = 1, + Name = "Test", + UShortValue = 60000 + }; + + var json = AcJsonSerializer.Serialize(obj); + + Assert.IsTrue(json.Contains("\"UShortValue\":60000")); + } + + [TestMethod] + public void Serialize_ArrayWithNullItems_SerializesNullCorrectly() + { + var obj = new ObjectWithNullItems + { + Id = 1, + MixedItems = [1, null, "text", null, 3] + }; + + var json = AcJsonSerializer.Serialize(obj); + + Assert.IsTrue(json.Contains("[1,null,\"text\",null,3]")); + } + + [TestMethod] + public void Serialize_DictionaryDirect_SerializesAsObject() + { + var dict = new Dictionary + { + { "name", "Test" }, + { "value", 42 } + }; + + var json = AcJsonSerializer.Serialize(dict); + + Assert.IsTrue(json.StartsWith("{")); + Assert.IsTrue(json.Contains("\"name\":\"Test\"")); + Assert.IsTrue(json.Contains("\"value\":42")); + } + + [TestMethod] + public void Serialize_ObjectWithGuidProperty_SerializesCorrectly() + { + var guid = Guid.NewGuid(); + var obj = new ExtendedPrimitiveTestClass + { + Id = 1, + Name = "Test", + Tag = new SharedTag { Id = 1, Name = "Tag" } + }; + // Using existing Tag property with Guid in SharedTag's CreatedAt + + var json = AcJsonSerializer.Serialize(obj); + + Assert.IsTrue(json.Contains("\"Tag\":{")); + Assert.IsTrue(json.Contains("\"Name\":\"Tag\"")); } #endregion - #region WASM Compatibility Tests - - /// - /// Tests that verify WASM compatibility - no reflection emit, no dynamic code generation, - /// compatible with AOT compilation and interpreter mode. - /// + #region AcJsonDeserializer Extended Tests [TestMethod] - public void WasmCompat_AcJsonSerializer_SimpleObject_WorksWithoutReflectionEmit() + public void Deserialize_GenericString_DirectPath() { - // Arrange - Simple object without complex inheritance - var item = new TestOrderItem - { - Id = 1, - ProductName = "Test Product", - Quantity = 10, - UnitPrice = 99.99m, - ItemStatus = TestStatus.Processing - }; - - // Act - var json = AcJsonSerializer.Serialize(item); - - // Assert - Assert.IsNotNull(json); - Assert.IsTrue(json.Contains("\"Id\":1")); - Assert.IsTrue(json.Contains("\"ProductName\":\"Test Product\"")); - Assert.IsTrue(json.Contains("\"Quantity\":10")); - Assert.IsTrue(json.Contains("\"UnitPrice\":99.99")); - Assert.IsTrue(json.Contains("\"ItemStatus\":20")); // Processing = 20 - - Console.WriteLine($"Serialized JSON: {json}"); + var json = "\"Hello World\""; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual("Hello World", result); } [TestMethod] - public void WasmCompat_AcJsonDeserializer_SimpleObject_WorksWithSystemTextJson() + public void Deserialize_GenericInt_DirectPath() + { + var json = "42"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(42, result); + } + + [TestMethod] + public void Deserialize_GenericBool_DirectPath() + { + var json = "true"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.IsTrue(result); + } + + [TestMethod] + public void Deserialize_GenericDouble_DirectPath() + { + var json = "3.14159"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(3.14159, result, 0.00001); + } + + [TestMethod] + public void Deserialize_GenericGuid_DirectPath() + { + var guid = Guid.NewGuid(); + var json = $"\"{guid}\""; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(guid, result); + } + + [TestMethod] + public void Deserialize_GenericDateTime_DirectPath() + { + var dt = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc); + var json = $"\"{dt:O}\""; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(dt, result); + } + + [TestMethod] + public void Deserialize_GenericEnum_DirectPath() + { + var json = "2"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(TestStatus.Processing, result); + } + + [TestMethod] + public void Deserialize_GenericDecimal_DirectPath() + { + var json = "123.456"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(123.456m, result); + } + + [TestMethod] + public void Deserialize_GenericFloat_DirectPath() + { + var json = "3.14"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(3.14f, result, 0.001f); + } + + [TestMethod] + public void Deserialize_GenericLong_DirectPath() + { + var json = "9223372036854775807"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(9223372036854775807L, result); + } + + [TestMethod] + public void Deserialize_GenericByte_DirectPath() + { + var json = "255"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual((byte)255, result); + } + + [TestMethod] + public void Deserialize_GenericShort_DirectPath() + { + var json = "32767"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual((short)32767, result); + } + + [TestMethod] + public void Deserialize_GenericUShort_DirectPath() + { + var json = "65535"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual((ushort)65535, result); + } + + [TestMethod] + public void Deserialize_GenericUInt_DirectPath() + { + var json = "4294967295"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(4294967295U, result); + } + + [TestMethod] + public void Deserialize_GenericULong_DirectPath() + { + var json = "18446744073709551615"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(18446744073709551615UL, result); + } + + [TestMethod] + public void Deserialize_GenericSByte_DirectPath() + { + var json = "-128"; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual((sbyte)-128, result); + } + + [TestMethod] + public void Deserialize_GenericChar_DirectPath() + { + var json = "\"A\""; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual('A', result); + } + + [TestMethod] + public void Deserialize_GenericDateTimeOffset_DirectPath() + { + var dto = new DateTimeOffset(2024, 12, 25, 12, 30, 45, TimeSpan.FromHours(2)); + var json = $"\"{dto:O}\""; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(dto, result); + } + + [TestMethod] + public void Deserialize_GenericTimeSpan_DirectPath() + { + var ts = new TimeSpan(1, 2, 3, 4, 5); + var json = $"\"{ts:c}\""; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(ts, result); + } + + [TestMethod] + public void Deserialize_GenericEnumFromString_DirectPath() + { + var json = "\"Processing\""; + var result = AcJsonDeserializer.Deserialize(json); + Assert.AreEqual(TestStatus.Processing, result); + } + + [TestMethod] + public void Populate_Array_PopulatesList() + { + var json = "[1, 2, 3]"; + var list = new List(); + AcJsonDeserializer.Populate(json, list); + Assert.AreEqual(3, list.Count); + Assert.AreEqual(1, list[0]); + Assert.AreEqual(3, list[2]); + } + + [TestMethod] + public void Populate_ObjectToObject_PopulatesProperties() + { + var json = "{\"Name\": \"Updated\", \"Id\": 99}"; + var obj = new SharedTag { Id = 1, Name = "Original" }; + AcJsonDeserializer.Populate(json, obj, typeof(SharedTag)); + Assert.AreEqual(99, obj.Id); + Assert.AreEqual("Updated", obj.Name); + } + + [TestMethod] + public void Deserialize_NullJson_ReturnsDefault() + { + var result = AcJsonDeserializer.Deserialize("null"); + Assert.IsNull(result); + } + + [TestMethod] + public void Deserialize_EmptyJson_ReturnsDefault() + { + var result = AcJsonDeserializer.Deserialize(""); + Assert.IsNull(result); + } + + [TestMethod] + public void Deserialize_GenericNullJson_ReturnsDefaultInt() + { + var result = AcJsonDeserializer.Deserialize("null"); + Assert.AreEqual(0, result); + } + + [TestMethod] + public void Deserialize_RuntimeType_WithDateTimeOffset() + { + var dto = new DateTimeOffset(2024, 6, 15, 10, 30, 0, TimeSpan.FromHours(2)); + var json = $"\"{dto:O}\""; + var result = AcJsonDeserializer.Deserialize(json, typeof(DateTimeOffset)); + Assert.AreEqual(dto, result); + } + + [TestMethod] + public void Deserialize_RuntimeType_WithTimeSpan() + { + var ts = new TimeSpan(2, 30, 45); + var json = $"\"{ts:c}\""; + var result = AcJsonDeserializer.Deserialize(json, typeof(TimeSpan)); + Assert.AreEqual(ts, result); + } + + [TestMethod] + public void Deserialize_RuntimeType_WithChar() + { + var json = "\"X\""; + var result = AcJsonDeserializer.Deserialize(json, typeof(char)); + Assert.AreEqual('X', result); + } + + [TestMethod] + public void Deserialize_RuntimeType_WithEnumString() + { + var json = "\"Active\""; + var result = AcJsonDeserializer.Deserialize(json, typeof(TestStatus)); + Assert.AreEqual(TestStatus.Active, result); + } + + #endregion + + #region AcJsonDeserializer Error Handling Tests + + [TestMethod] + public void Deserialize_InvalidJson_ThrowsException() + { + var invalidJson = "{ this is not valid json }"; + + try + { + AcJsonDeserializer.Deserialize(invalidJson); + Assert.Fail("Expected AcJsonDeserializationException"); + } + catch (AcJsonDeserializationException) + { + // Expected + } + } + + [TestMethod] + public void Deserialize_DoubleQuotedJson_ThrowsException() + { + // This is what double-serialized JSON looks like: a JSON string containing escaped JSON + var doubleQuotedJson = "\"{\\\"Id\\\":1,\\\"Name\\\":\\\"Test\\\"}\""; + + try + { + AcJsonDeserializer.Deserialize(doubleQuotedJson); + Assert.Fail("Expected AcJsonDeserializationException for double-serialized JSON"); + } + catch (AcJsonDeserializationException ex) + { + Assert.IsTrue(ex.Message.Contains("double-serialized")); + } + } + + [TestMethod] + public void Deserialize_ArrayToObject_ThrowsException() + { + // Trying to deserialize an array JSON to a single object + var arrayJson = "[{\"Id\":1},{\"Id\":2}]"; + + try + { + AcJsonDeserializer.Deserialize(arrayJson); + Assert.Fail("Expected AcJsonDeserializationException"); + } + catch (AcJsonDeserializationException ex) + { + Assert.IsTrue(ex.Message.Contains("array") || ex.Message.Contains("collection")); + } + } + + [TestMethod] + public void Deserialize_ObjectToArray_ThrowsException() + { + // Trying to deserialize an object JSON to a collection + var objectJson = "{\"Id\":1,\"ProductName\":\"Test\"}"; + + try + { + AcJsonDeserializer.Deserialize>(objectJson); + Assert.Fail("Expected AcJsonDeserializationException"); + } + catch (AcJsonDeserializationException ex) + { + Assert.IsTrue(ex.Message.Contains("object") || ex.Message.Contains("collection")); + } + } + + [TestMethod] + public void Populate_NullTarget_ThrowsArgumentNullException() + { + var json = "{\"Id\":1}"; + TestOrderItem? target = null; + + try + { + AcJsonDeserializer.Populate(json, target!); + Assert.Fail("Expected ArgumentNullException"); + } + catch (ArgumentNullException) + { + // Expected + } + } + + [TestMethod] + public void Populate_InvalidJson_ThrowsException() + { + var target = new TestOrderItem(); + var invalidJson = "{ not valid }"; + + try + { + AcJsonDeserializer.Populate(invalidJson, target); + Assert.Fail("Expected AcJsonDeserializationException"); + } + catch (AcJsonDeserializationException) + { + // Expected + } + } + + [TestMethod] + public void Populate_ArrayToNonList_ThrowsException() + { + var target = new TestOrderItem(); + var arrayJson = "[1,2,3]"; + + try + { + AcJsonDeserializer.Populate(arrayJson, target); + Assert.Fail("Expected AcJsonDeserializationException"); + } + catch (AcJsonDeserializationException) + { + // Expected + } + } + + #endregion + + #region Edge Case Tests + + [TestMethod] + public void Deserialize_SpecialCharactersInStrings_HandledCorrectly() + { + var json = "{\"Id\":1,\"ProductName\":\"Test \\\"quoted\\\" and \\\\backslash\"}"; + + var result = AcJsonDeserializer.Deserialize(json); + + Assert.IsNotNull(result); + Assert.AreEqual("Test \"quoted\" and \\backslash", result.ProductName); + } + + [TestMethod] + public void Deserialize_UnicodeCharacters_HandledCorrectly() + { + var json = "{\"Id\":1,\"ProductName\":\"中文日本語한국어🎉\"}"; + + var result = AcJsonDeserializer.Deserialize(json); + + Assert.IsNotNull(result); + Assert.AreEqual("中文日本語한국어🎉", result.ProductName); + } + + [TestMethod] + public void Deserialize_LargeNumbers_HandledCorrectly() + { + var json = "{\"Id\":999999999,\"ProductName\":\"Big\",\"Quantity\":2147483647}"; + + var result = AcJsonDeserializer.Deserialize(json); + + Assert.IsNotNull(result); + Assert.AreEqual(999999999, result.Id); + Assert.AreEqual(int.MaxValue, result.Quantity); + } + + [TestMethod] + public void Serialize_ThenDeserialize_RoundTripPreservesData() { - // Arrange - JSON created by AcJsonSerializer var original = new TestOrderItem { Id = 42, - ProductName = "WASM Test", - Quantity = 5, - UnitPrice = 25.50m, - ItemStatus = TestStatus.Shipped - }; - var json = AcJsonSerializer.Serialize(original); - - // Act - var deserialized = AcJsonDeserializer.Deserialize(json); - - // Assert - Assert.IsNotNull(deserialized); - Assert.AreEqual(42, deserialized.Id); - Assert.AreEqual("WASM Test", deserialized.ProductName); - Assert.AreEqual(5, deserialized.Quantity); - Assert.AreEqual(25.50m, deserialized.UnitPrice); - Assert.AreEqual(TestStatus.Shipped, deserialized.ItemStatus); - } - - [TestMethod] - public void WasmCompat_RoundTrip_ComplexHierarchy_PreservesAllData() - { - // Arrange - Complex 3-level hierarchy - TestDataFactory.ResetIdCounter(); - var order = new TestOrder - { - Id = 1, - OrderNumber = "WASM-001", - OrderStatus = TestStatus.Processing, - CreatedAt = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc), - Items = - [ - new TestOrderItem - { - Id = 10, - ProductName = "Item A", - Quantity = 3, - UnitPrice = 15.00m, - Pallets = - [ - new TestPallet - { - Id = 100, - PalletCode = "PLT-A1", - TrayCount = 5, - Status = TestStatus.Pending - } - ] - }, - new TestOrderItem - { - Id = 20, - ProductName = "Item B", - Quantity = 7, - UnitPrice = 22.50m - } - ] + ProductName = "Test with \"quotes\" and \\backslash", + Quantity = 100, + UnitPrice = 99.99m, + Status = TestStatus.Processing }; - // Act - Serialize, then deserialize - var json = AcJsonSerializer.Serialize(order); - Console.WriteLine($"JSON size: {json.Length} chars"); - Console.WriteLine($"JSON: {json}"); + var json = original.ToJson(); + var restored = AcJsonDeserializer.Deserialize(json); - var deserialized = AcJsonDeserializer.Deserialize(json); - - // Assert - Assert.IsNotNull(deserialized); - Assert.AreEqual(1, deserialized.Id); - Assert.AreEqual("WASM-001", deserialized.OrderNumber); - Assert.AreEqual(TestStatus.Processing, deserialized.OrderStatus); - Assert.AreEqual(new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc), deserialized.CreatedAt); - - Assert.AreEqual(2, deserialized.Items.Count); - - var item1 = deserialized.Items[0]; - Assert.AreEqual(10, item1.Id); - Assert.AreEqual("Item A", item1.ProductName); - Assert.AreEqual(3, item1.Quantity); - Assert.AreEqual(15.00m, item1.UnitPrice); - Assert.AreEqual(1, item1.Pallets.Count); - Assert.AreEqual(100, item1.Pallets[0].Id); - Assert.AreEqual("PLT-A1", item1.Pallets[0].PalletCode); - - var item2 = deserialized.Items[1]; - Assert.AreEqual(20, item2.Id); - Assert.AreEqual("Item B", item2.ProductName); - } - - [TestMethod] - public void WasmCompat_SharedReferences_IdRefResolution_WorksCorrectly() - { - // Arrange - Object with shared references - var sharedAttr = new TestSharedAttribute - { - Id = 999, - Key = "SharedKey", - Value = "SharedValue", - CreatedOrUpdatedDateUTC = DateTime.UtcNow - }; - - var order = new TestOrder - { - Id = 1, - OrderNumber = "REF-TEST", - PrimaryAttribute = sharedAttr, - SecondaryAttribute = sharedAttr, // Same reference! - Attributes = [sharedAttr] - }; - - // Act - var json = AcJsonSerializer.Serialize(order); - Console.WriteLine($"JSON with refs: {json}"); - - // Assert - JSON should have $id and $ref tokens - Assert.IsTrue(json.Contains("\"$id\""), "Should contain $id for shared reference"); - Assert.IsTrue(json.Contains("\"$ref\""), "Should contain $ref for duplicate references"); - - // Step 2: Deserialize with native Newtonsoft (NO custom resolver, just PreserveReferencesHandling) - var nativeSettings = new JsonSerializerSettings - { - PreserveReferencesHandling = PreserveReferencesHandling.Objects, - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - NullValueHandling = NullValueHandling.Ignore - }; - - var deserializedOrder = JsonConvert.DeserializeObject(json, nativeSettings); - - // Step 3: Verify deserialization - Assert.IsNotNull(deserializedOrder, "Deserialized order should not be null"); - Assert.AreEqual(1, deserializedOrder.Id); - Assert.AreEqual("REF-TEST", deserializedOrder.OrderNumber); - - // Shared references resolution - Assert.IsNotNull(deserializedOrder.PrimaryAttribute); - Assert.IsNotNull(deserializedOrder.SecondaryAttribute); - Assert.AreEqual(999, deserializedOrder.PrimaryAttribute.Id); - Assert.AreEqual("SharedKey", deserializedOrder.PrimaryAttribute.Key); - - // KEY TEST: Shared references should be resolved to the same object - Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.SecondaryAttribute, - "Shared references should resolve to same object instance"); - Assert.AreSame(deserializedOrder.PrimaryAttribute, deserializedOrder.Attributes[0], - "Collection reference should resolve to same object instance"); - } - - [TestMethod] - public void WasmCompat_AllPrimitiveTypes_SerializeAndDeserialize() - { - // Arrange - Test all primitive types that might behave differently in WASM - var testData = new WasmPrimitiveTestClass - { - IntValue = int.MaxValue, - LongValue = long.MaxValue, - DoubleValue = 3.14159265358979, - DecimalValue = 12345.6789m, - FloatValue = 1.5f, - BoolValue = true, - StringValue = "Hello WASM! 🚀 Unicode: αβγδ", - GuidValue = Guid.Parse("12345678-1234-1234-1234-123456789abc"), - DateTimeValue = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc), - EnumValue = TestStatus.Shipped, - ByteValue = 255, - ShortValue = short.MaxValue, - NullableInt = 42, - NullableIntNull = null - }; - - // Act - var json = AcJsonSerializer.Serialize(testData); - Console.WriteLine($"Primitives JSON: {json}"); - - var deserialized = AcJsonDeserializer.Deserialize(json); - - // Assert - Assert.IsNotNull(deserialized); - Assert.AreEqual(int.MaxValue, deserialized.IntValue); - Assert.AreEqual(long.MaxValue, deserialized.LongValue); - Assert.AreEqual(3.14159265358979, deserialized.DoubleValue, 0.0000000001); - Assert.AreEqual(12345.6789m, deserialized.DecimalValue); - Assert.AreEqual(1.5f, deserialized.FloatValue); - Assert.IsTrue(deserialized.BoolValue); - Assert.AreEqual("Hello WASM! 🚀 Unicode: αβγδ", deserialized.StringValue); - Assert.AreEqual(Guid.Parse("12345678-1234-1234-1234-123456789abc"), deserialized.GuidValue); - Assert.AreEqual(new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc), deserialized.DateTimeValue); - Assert.AreEqual(TestStatus.Shipped, deserialized.EnumValue); - Assert.AreEqual(255, deserialized.ByteValue); - Assert.AreEqual(short.MaxValue, deserialized.ShortValue); - Assert.AreEqual(42, deserialized.NullableInt); - Assert.IsNull(deserialized.NullableIntNull); - } - - /// - /// Test class with all primitive types for WASM compatibility testing - /// - public class WasmPrimitiveTestClass - { - public int IntValue { get; set; } - public long LongValue { get; set; } - public double DoubleValue { get; set; } - public decimal DecimalValue { get; set; } - public float FloatValue { get; set; } - public bool BoolValue { get; set; } - public string StringValue { get; set; } = ""; - public Guid GuidValue { get; set; } - public DateTime DateTimeValue { get; set; } - public TestStatus EnumValue { get; set; } - public byte ByteValue { get; set; } - public short ShortValue { get; set; } - public int? NullableInt { get; set; } - public int? NullableIntNull { get; set; } - } - - [TestMethod] - public void WasmCompat_EmptyAndNullCollections_HandleCorrectly() - { - // Arrange - var order = new TestOrder - { - Id = 1, - OrderNumber = "EMPTY-TEST", - Items = [], // Empty list - Attributes = [], // Empty list - MetadataList = [] // Empty list - }; - - // Act - var json = AcJsonSerializer.Serialize(order); - Console.WriteLine($"Empty collections JSON: {json}"); - - // Empty collections SHOULD be in JSON as [] (not omitted) - // This preserves the distinction between null and empty - Assert.IsTrue(json.Contains("\"Items\":[]"), "Empty Items should be serialized as []"); - Assert.IsTrue(json.Contains("\"Attributes\":[]"), "Empty Attributes should be serialized as []"); - - var deserialized = AcJsonDeserializer.Deserialize(json); - - // Assert - deserialized object should have empty (not null) collections - Assert.IsNotNull(deserialized); - Assert.IsNotNull(deserialized.Items, "Items should be an empty list, not null after deserialization"); - Assert.AreEqual(0, deserialized.Items.Count); - Assert.IsNotNull(deserialized.Attributes, "Attributes should be an empty list, not null after deserialization"); - Assert.AreEqual(0, deserialized.Attributes.Count); - } - - [TestMethod] - public void Serialize_NullCollection_IsOmitted() - { - // Arrange - Order with null collections - var order = new TestOrderWithNullableCollections - { - Id = 1, - OrderNumber = "TEST-001", - Items = null, // Explicitly null - Tags = null // Explicitly null - }; - - // Act - var json = AcJsonSerializer.Serialize(order); - Console.WriteLine($"JSON with null collections: {json}"); - - // Assert - Null collections should NOT be in JSON - Assert.IsFalse(json.Contains("\"Items\""), - $"Null Items should not be serialized. JSON: {json}"); - Assert.IsFalse(json.Contains("\"Tags\""), - $"Null Tags should not be serialized. JSON: {json}"); - } - - [TestMethod] - public void Deserialize_EmptyArray_CreatesEmptyList() - { - // Arrange - JSON with empty Items array - var json = """{"Id":1,"OrderNumber":"TEST-001","Items":[],"Attributes":[]}"""; - - // Act - var order = AcJsonDeserializer.Deserialize(json); - - // Assert - Assert.IsNotNull(order); - Assert.IsNotNull(order.Items, "Items should be an empty list, not null after deserialization"); - Assert.AreEqual(0, order.Items.Count); - Assert.IsNotNull(order.Attributes, "Attributes should be an empty list, not null after deserialization"); - Assert.AreEqual(0, order.Attributes.Count); - } - - [TestMethod] - public void Deserialize_MissingCollection_StaysAsInitialized() - { - // Arrange - JSON without Items or Attributes (simulates old serialization) - var json = """{"Id":1,"OrderNumber":"TEST-001"}"""; - - // Act - var order = AcJsonDeserializer.Deserialize(json); - - // Assert - The DTO initializes these as empty lists in constructor - // Note: This depends on the class having initialized properties like: Items = []; - // If missing from JSON, they should retain their default initialized value - Assert.IsNotNull(order); - Assert.IsNotNull(order.Items, "Items should retain default empty list initialization"); - Assert.IsNotNull(order.Attributes, "Attributes should retain default empty list initialization"); - } - - [TestMethod] - public void RoundTrip_EmptyCollection_PreservesEmptyNotNull() - { - // Arrange - var original = new TestOrder - { - Id = 1, - OrderNumber = "TEST-001", - Items = [], - Attributes = [] - }; - - // Act - Serialize then deserialize - var json = AcJsonSerializer.Serialize(original); - Console.WriteLine($"Round-trip JSON: {json}"); - - var deserialized = AcJsonDeserializer.Deserialize(json); - - // Assert - Assert.IsNotNull(deserialized); - Assert.IsNotNull(deserialized.Items, "Items should be empty list, not null after round-trip"); - Assert.AreEqual(0, deserialized.Items.Count); - Assert.IsNotNull(deserialized.Attributes, "Attributes should be empty list, not null after round-trip"); - Assert.AreEqual(0, deserialized.Attributes.Count); - } - - /// - /// Test class with nullable collections to test null vs empty distinction - /// - public class TestOrderWithNullableCollections - { - public int Id { get; set; } - public string OrderNumber { get; set; } = ""; - public List? Items { get; set; } - public List? Tags { get; set; } + Assert.IsNotNull(restored); + Assert.AreEqual(original.Id, restored.Id); + Assert.AreEqual(original.ProductName, restored.ProductName); + Assert.AreEqual(original.Quantity, restored.Quantity); + Assert.AreEqual(original.UnitPrice, restored.UnitPrice); + Assert.AreEqual(original.Status, restored.Status); } #endregion - #region IdMessage SignalR Tests (Reproduces ProcessOnReceiveMessage behavior) + #region Task-like JSON Wrapper Tests + + [TestMethod] + public void Deserialize_TaskWrappedJson_DirectDeserialization_OnlyGetsRootProperties() + { + // This JSON represents a serialized Task - the actual data is in "Result" + // This happens when someone forgets to await an async method before serializing + var taskWrappedJson = "{\"Result\":{\"Id\":1,\"ProductName\":\"Processed: TestProduct\",\"Quantity\":10,\"UnitPrice\":20,\"TotalPrice\":200},\"Id\":1,\"Status\":5,\"IsCompleted\":true,\"IsCompletedSuccessfully\":true}"; + + // Direct deserialization to TestOrderItem only gets root-level properties + var result = AcJsonDeserializer.Deserialize(taskWrappedJson); + + Assert.IsNotNull(result); + // Id=1 is at root level and matches + Assert.AreEqual(1, result.Id); + // These values are inside "Result" object, not at root - they remain default + Assert.AreEqual(0, result.Quantity, "Quantity should be 0 because it's inside Result, not at root"); + Assert.AreEqual(0m, result.UnitPrice, "UnitPrice should be 0 because it's inside Result, not at root"); + } + + [TestMethod] + public void Deserialize_TaskWrappedJson_UseWrapperClass_ExtractsCorrectly() + { + // This JSON represents a serialized Task - the actual data is in "Result" + var taskWrappedJson = "{\"Result\":{\"Id\":1,\"ProductName\":\"Processed: TestProduct\",\"Quantity\":10,\"UnitPrice\":20,\"TotalPrice\":200},\"Id\":1,\"Status\":5,\"IsCompleted\":true,\"IsCompletedSuccessfully\":true}"; + + // Proper approach: deserialize to a wrapper type and extract Result + var wrapper = AcJsonDeserializer.Deserialize>(taskWrappedJson); + + Assert.IsNotNull(wrapper); + Assert.IsNotNull(wrapper.Result); + Assert.AreEqual(1, wrapper.Result.Id); + Assert.AreEqual("Processed: TestProduct", wrapper.Result.ProductName); + Assert.AreEqual(10, wrapper.Result.Quantity); + Assert.AreEqual(20m, wrapper.Result.UnitPrice); + Assert.IsTrue(wrapper.IsCompleted); + Assert.IsTrue(wrapper.IsCompletedSuccessfully); + Assert.AreEqual(5, wrapper.Status); + } /// - /// These tests reproduce the exact flow in AcWebSignalRHubBase.ProcessOnReceiveMessage - /// where IdMessage wraps parameters and the server deserializes them back. + /// Wrapper class to deserialize Task-like JSON structures. + /// This is what you get when you accidentally serialize a Task object instead of awaiting it. /// - - [TestMethod] - public void IdMessage_FullSignalRScenario_IntArrayParameter() + private class TaskResultWrapper { - // Reproduces: GetAllOrderDtoByIds(int[] orderIds) - // The error in logs: "Cannot deserialize the current JSON object into type 'System.Int32[]'" - - var orderIds = new int[] { 113, 456, 789 }; - - // Step 1: Client side - IdMessage constructor wraps the parameter - // In IdMessage: item = (new[] { x }).ToJson() where x is int[] - var clientSideJson = (new[] { orderIds }).ToJson(); - Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\""); - - // Expected: "[[113,456,789]]" - array containing an int array - Assert.IsTrue(clientSideJson.StartsWith("[[") && clientSideJson.EndsWith("]]"), - $"Should be nested array format. Got: {clientSideJson}"); - - // Step 2: Server side - ProcessOnReceiveMessage deserializes - // Code: var a = Array.CreateInstance(methodInfoModel.ParamInfos[i].ParameterType, 1); - // paramValues[i] = (obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!; - - var parameterType = typeof(int[]); // The method parameter type - var wrapperArrayType = parameterType.MakeArrayType(); // int[][] to wrap it - - // Deserialize using the same settings as SignalR - var settings = GetMergeSettings(); - var deserializedWrapper = clientSideJson.JsonTo(wrapperArrayType, settings) as Array; - - Assert.IsNotNull(deserializedWrapper, $"Should deserialize to int[][]. JSON: {clientSideJson}"); - Assert.AreEqual(1, deserializedWrapper.Length); - - var actualParameter = deserializedWrapper.GetValue(0) as int[]; - Assert.IsNotNull(actualParameter, "Should get int[] from wrapper"); - Assert.AreEqual(3, actualParameter.Length); - Assert.AreEqual(113, actualParameter[0]); - Assert.AreEqual(456, actualParameter[1]); - Assert.AreEqual(789, actualParameter[2]); - } - - [TestMethod] - public void IdMessage_FullSignalRScenario_SingleIntParameter() - { - // Reproduces: GetOrderById(int orderId) - var orderId = 42; - - // Client: (new[] { x }).ToJson() - var clientSideJson = (new[] { orderId }).ToJson(); - Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\""); - - // Expected: "[42]" - Assert.AreEqual("[42]", clientSideJson); - - // Server side deserialization - var parameterType = typeof(int); - var wrapperArrayType = parameterType.MakeArrayType(); // int[] - - var settings = GetMergeSettings(); - var deserializedArray = clientSideJson.JsonTo(wrapperArrayType, settings) as Array; - - Assert.IsNotNull(deserializedArray); - Assert.AreEqual(1, deserializedArray.Length); - Assert.AreEqual(42, deserializedArray.GetValue(0)); - } - - [TestMethod] - public void IdMessage_FullSignalRScenario_BoolParameter() - { - // Reproduces: GetPendingOrders(bool loadRelations) - // This was the original bug - loadRelations=true becoming false - - var loadRelations = true; - - // Client: (new[] { x }).ToJson() - var clientSideJson = (new[] { loadRelations }).ToJson(); - Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\""); - - // Expected: "[true]" - Assert.AreEqual("[true]", clientSideJson); - - // Server side deserialization - var parameterType = typeof(bool); - var wrapperArrayType = parameterType.MakeArrayType(); // bool[] - - var settings = GetMergeSettings(); - var deserializedArray = clientSideJson.JsonTo(wrapperArrayType, settings) as Array; - - Assert.IsNotNull(deserializedArray); - Assert.AreEqual(1, deserializedArray.Length); - Assert.IsTrue((bool)deserializedArray.GetValue(0)!, "loadRelations should remain TRUE!"); - } - - [TestMethod] - public void IdMessage_FullSignalRScenario_GuidArrayParameter() - { - // Reproduces: GetOrdersByIds(Guid[] orderIds) - var guid1 = Guid.NewGuid(); - var guid2 = Guid.NewGuid(); - var orderIds = new Guid[] { guid1, guid2 }; - - // Client: (new[] { x }).ToJson() - var clientSideJson = (new[] { orderIds }).ToJson(); - Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\""); - - // Server side deserialization - var parameterType = typeof(Guid[]); - var wrapperArrayType = parameterType.MakeArrayType(); // Guid[][] - - var settings = GetMergeSettings(); - var deserializedWrapper = clientSideJson.JsonTo(wrapperArrayType, settings) as Array; - - Assert.IsNotNull(deserializedWrapper); - Assert.AreEqual(1, deserializedWrapper.Length); - - var actualParameter = deserializedWrapper.GetValue(0) as Guid[]; - Assert.IsNotNull(actualParameter); - Assert.AreEqual(2, actualParameter.Length); - Assert.AreEqual(guid1, actualParameter[0]); - Assert.AreEqual(guid2, actualParameter[1]); - } - - [TestMethod] - public void IdMessage_FullSignalRScenario_MultipleParameters() - { - // Reproduces: GetFilteredOrders(bool loadRelations, string filter, int pageSize, Guid userId) - // This simulates the full ProcessOnReceiveMessage loop - - var settings = GetMergeSettings(); - - // Define method parameters - var methodParams = new (Type type, object value)[] - { - (typeof(bool), true), - (typeof(string), "active"), - (typeof(int), 50), - (typeof(Guid), Guid.NewGuid()), - (typeof(TestStatus), TestStatus.Processing) - }; - - // Client side: IdMessage wraps each parameter - var serializedParams = new List(); - foreach (var (type, value) in methodParams) - { - // This is what IdMessage does: item = (new[] { x }).ToJson(); - var wrapped = Array.CreateInstance(type, 1); - wrapped.SetValue(value, 0); - var json = wrapped.ToJson(settings); - serializedParams.Add(json); - Console.WriteLine($"Param {type.Name}: {value} -> {json}"); - } - - // Server side: ProcessOnReceiveMessage deserializes each parameter - var deserializedParams = new object[methodParams.Length]; - for (var i = 0; i < methodParams.Length; i++) - { - var paramType = methodParams[i].type; - var jsonStr = serializedParams[i]; - - // This is the server-side code: - // var a = Array.CreateInstance(paramType, 1); - // paramValues[i] = (obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!; - var arrayType = paramType.MakeArrayType(); - var arr = jsonStr.JsonTo(arrayType, settings) as Array; - deserializedParams[i] = arr?.GetValue(0)!; - - Console.WriteLine($"Deserialized {paramType.Name}: {deserializedParams[i]}"); - } - - // Assert all parameters are correctly deserialized - Assert.IsTrue((bool)deserializedParams[0], "loadRelations should be TRUE"); - Assert.AreEqual("active", (string)deserializedParams[1]); - Assert.AreEqual(50, (int)deserializedParams[2]); - Assert.AreEqual(methodParams[3].value, (Guid)deserializedParams[3]); - Assert.AreEqual(TestStatus.Processing, (TestStatus)deserializedParams[4]); - } - - [TestMethod] - public void IdMessage_FullSignalRScenario_ComplexObjectParameter() - { - // Reproduces: UpdateOrder(OrderDto order) - var order = new TestOrder - { - Id = 1, - OrderNumber = "ORD-001", - OrderStatus = TestStatus.Processing, - Items = - [ - new TestOrderItem { Id = 10, ProductName = "Product A", Quantity = 5 } - ] - }; - - // Client: (new[] { x }).ToJson() - var clientSideJson = (new[] { order }).ToJson(); - Console.WriteLine($"Client IdMessage.Ids[0] = \"{clientSideJson}\""); - - // Server side deserialization - var parameterType = typeof(TestOrder); - var wrapperArrayType = parameterType.MakeArrayType(); // TestOrder[] - - var settings = GetMergeSettings(); - var deserializedWrapper = clientSideJson.JsonTo(wrapperArrayType, settings) as Array; - - Assert.IsNotNull(deserializedWrapper); - Assert.AreEqual(1, deserializedWrapper.Length); - - var actualParameter = deserializedWrapper.GetValue(0) as TestOrder; - Assert.IsNotNull(actualParameter); - Assert.AreEqual(1, actualParameter.Id); - Assert.AreEqual("ORD-001", actualParameter.OrderNumber); - Assert.AreEqual(TestStatus.Processing, actualParameter.OrderStatus); - Assert.AreEqual(1, actualParameter.Items.Count); - Assert.AreEqual("Product A", actualParameter.Items[0].ProductName); - } - - [TestMethod] - public void IdMessage_CompareSerializers_IntArray() - { - // Ensure AcJsonSerializer and Newtonsoft produce identical output - var orderIds = new int[] { 1, 2, 3 }; - var wrapped = new[] { orderIds }; - - // AcJsonSerializer (new default) - var acJson = AcJsonSerializer.Serialize(wrapped); - - // Newtonsoft (old default) - var newtonsoftJson = JsonConvert.SerializeObject(wrapped); - - Console.WriteLine($"AcJsonSerializer: {acJson}"); - Console.WriteLine($"Newtonsoft: {newtonsoftJson}"); - - // Both should produce: [[1,2,3]] - Assert.AreEqual(newtonsoftJson, acJson, - "AcJsonSerializer should produce same output as Newtonsoft for primitive arrays"); - } - - [TestMethod] - public void IdMessage_CompareSerializers_NestedArray() - { - // Test nested arrays (edge case) - var matrix = new int[][] { new[] { 1, 2 }, new[] { 3, 4 } }; - var wrapped = new[] { matrix }; - - var acJson = AcJsonSerializer.Serialize(wrapped); - var newtonsoftJson = JsonConvert.SerializeObject(wrapped); - - Console.WriteLine($"AcJsonSerializer: {acJson}"); - Console.WriteLine($"Newtonsoft: {newtonsoftJson}"); - - // Both should produce: [[[1,2],[3,4]]] - Assert.AreEqual(newtonsoftJson, acJson); + public T? Result { get; set; } + public int Id { get; set; } + public int Status { get; set; } + public bool IsCompleted { get; set; } + public bool IsCompletedSuccessfully { get; set; } } #endregion diff --git a/AyCode.Core.Tests/TestModels/SharedTestModels.cs b/AyCode.Core.Tests/TestModels/SharedTestModels.cs new file mode 100644 index 0000000..83c3640 --- /dev/null +++ b/AyCode.Core.Tests/TestModels/SharedTestModels.cs @@ -0,0 +1,342 @@ +using AyCode.Core.Extensions; +using AyCode.Core.Interfaces; +using Newtonsoft.Json; + +namespace AyCode.Core.Tests.TestModels; + +#region Shared Enums + +/// +/// Common status enum for all test entities +/// +public enum TestStatus +{ + Pending = 0, + Active = 1, + Processing = 2, + Completed = 3, + Shipped = 4, + OnHold = 5 +} + +/// +/// Priority levels for tasks and projects +/// +public enum TestPriority +{ + Low = 0, + Medium = 1, + High = 2, + Critical = 3 +} + +/// +/// User roles for testing +/// +public enum TestUserRole +{ + User = 0, + Manager = 1, + Admin = 2 +} + +#endregion + +#region Shared Reference Types (IId-based for $id/$ref testing) + +/// +/// Shared tag/label - used across multiple entities for cross-reference testing. +/// Implements IId<int> for semantic $id/$ref serialization. +/// +public class SharedTag : IId +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Color { get; set; } = "#000000"; + public int Priority { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string? Description { get; set; } +} + +/// +/// Shared category - for hierarchical cross-reference testing. +/// +public class SharedCategory : IId +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string? Description { get; set; } + public int SortOrder { get; set; } + public bool IsDefault { get; set; } + public int? ParentCategoryId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } +} + +/// +/// Shared user reference - appears in many places to test $ref deduplication. +/// +public class SharedUser : IId +{ + public int Id { get; set; } + public string Username { get; set; } = ""; + public string Email { get; set; } = ""; + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + public bool IsActive { get; set; } = true; + public TestUserRole Role { get; set; } = TestUserRole.User; + public DateTime? LastLoginAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public UserPreferences? Preferences { get; set; } +} + +/// +/// User preferences - non-IId nested object +/// +public class UserPreferences +{ + public string Theme { get; set; } = "light"; + public string Language { get; set; } = "en-US"; + public bool NotificationsEnabled { get; set; } = true; + public string? EmailDigestFrequency { get; set; } +} + +#endregion + +#region Non-IId Metadata (Newtonsoft numeric $id/$ref testing) + +/// +/// Non-IId metadata class - uses Newtonsoft PreserveReferencesHandling (numeric $id/$ref). +/// Does NOT implement IId, so uses standard Newtonsoft reference tracking. +/// +public class MetadataInfo +{ + public string Key { get; set; } = ""; + public string Value { get; set; } = ""; + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// + /// Nested metadata for deep Newtonsoft reference testing + /// + public MetadataInfo? ChildMetadata { get; set; } +} + +#endregion + +#region 5-Level Test Hierarchy (Order -> Item -> Pallet -> Measurement -> Point) + +/// +/// Level 1: Main order - root of the hierarchy +/// +public class TestOrder : IId +{ + public int Id { get; set; } + public string OrderNumber { get; set; } = ""; + public TestStatus Status { get; set; } = TestStatus.Pending; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? PaidDateUtc { get; set; } + public decimal TotalAmount { get; set; } + + // Level 2 collection + public List Items { get; set; } = []; + + // Shared reference properties (for $id/$ref testing) + public SharedTag? PrimaryTag { get; set; } + public SharedTag? SecondaryTag { get; set; } + public SharedUser? Owner { get; set; } + public SharedCategory? Category { get; set; } + + // Collection of shared references + public List Tags { get; set; } = []; + + // Non-IId metadata (for Newtonsoft $ref testing) + public MetadataInfo? OrderMetadata { get; set; } + public MetadataInfo? AuditMetadata { get; set; } + public List MetadataList { get; set; } = []; + + // NoMerge collection for testing replace behavior + [JsonNoMergeCollection] + public List NoMergeItems { get; set; } = []; + + // Parent reference (JsonIgnore to prevent loops) + [JsonIgnore] + public object? Parent { get; set; } +} + +/// +/// Level 2: Order item with pallets +/// +public class TestOrderItem : IId +{ + public int Id { get; set; } + public string ProductName { get; set; } = ""; + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + public TestStatus Status { get; set; } = TestStatus.Pending; + + // Level 3 collection + public List Pallets { get; set; } = []; + + // Shared references + public SharedTag? Tag { get; set; } + public SharedUser? Assignee { get; set; } + public MetadataInfo? ItemMetadata { get; set; } + + [JsonIgnore] + public TestOrder? ParentOrder { get; set; } +} + +/// +/// Level 3: Pallet containing measurements +/// +public class TestPallet : IId +{ + public int Id { get; set; } + public string PalletCode { get; set; } = ""; + public int TrayCount { get; set; } + public TestStatus Status { get; set; } = TestStatus.Pending; + public double Weight { get; set; } + + // Level 4 collection + public List Measurements { get; set; } = []; + + // Shared references + public MetadataInfo? PalletMetadata { get; set; } + + [JsonIgnore] + public TestOrderItem? ParentItem { get; set; } +} + +/// +/// Level 4: Measurement with multiple points +/// +public class TestMeasurement : IId +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public double TotalWeight { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Level 5 collection + public List Points { get; set; } = []; + + [JsonIgnore] + public TestPallet? ParentPallet { get; set; } +} + +/// +/// Level 5: Deepest level - measurement point +/// +public class TestMeasurementPoint : IId +{ + public int Id { get; set; } + public string Label { get; set; } = ""; + public double Value { get; set; } + public DateTime MeasuredAt { get; set; } = DateTime.UtcNow; + + [JsonIgnore] + public TestMeasurement? ParentMeasurement { get; set; } +} + +#endregion + +#region Guid-based IId types + +/// +/// Order with Guid Id - for testing Guid-based IId +/// +public class TestGuidOrder : IId +{ + public Guid Id { get; set; } + public string Code { get; set; } = ""; + public List Items { get; set; } = []; + public int Count { get; set; } +} + +/// +/// Item with Guid Id +/// +public class TestGuidItem : IId +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public int Qty { get; set; } +} + +#endregion + +#region Test-specific classes + +/// +/// Order with nullable collections for null vs empty testing +/// +public class TestOrderWithNullableCollections +{ + public int Id { get; set; } + public string OrderNumber { get; set; } = ""; + public List? Items { get; set; } + public List? Tags { get; set; } +} + +/// +/// Class with all primitive types for WASM/serialization testing +/// +public class PrimitiveTestClass +{ + public int IntValue { get; set; } + public long LongValue { get; set; } + public double DoubleValue { get; set; } + public decimal DecimalValue { get; set; } + public float FloatValue { get; set; } + public bool BoolValue { get; set; } + public string StringValue { get; set; } = ""; + public Guid GuidValue { get; set; } + public DateTime DateTimeValue { get; set; } + public TestStatus EnumValue { get; set; } + public byte ByteValue { get; set; } + public short ShortValue { get; set; } + public int? NullableInt { get; set; } + public int? NullableIntNull { get; set; } +} + +/// +/// Class with extended primitive types for full serializer coverage. +/// Includes DateTimeOffset, TimeSpan, Dictionary, null properties. +/// +public class ExtendedPrimitiveTestClass +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + + // Extended primitive types not covered in PrimitiveTestClass + public DateTimeOffset DateTimeOffsetValue { get; set; } + public TimeSpan TimeSpanValue { get; set; } + public uint UIntValue { get; set; } + public ulong ULongValue { get; set; } + public ushort UShortValue { get; set; } + public sbyte SByteValue { get; set; } + public char CharValue { get; set; } + + // Dictionary property for WriteDictionary coverage in object context + public Dictionary? Counts { get; set; } + public Dictionary? Labels { get; set; } + + // Nullable properties that will be null + public string? NullString { get; set; } + public TestOrderItem? NullObject { get; set; } + + // Nested object for complex serialization + public SharedTag? Tag { get; set; } +} + +/// +/// Class with array of objects containing null items for WriteNull coverage +/// +public class ObjectWithNullItems +{ + public int Id { get; set; } + public List MixedItems { get; set; } = []; +} + +#endregion diff --git a/AyCode.Core.Tests/TestModels/SignalRTestInfrastructure.cs b/AyCode.Core.Tests/TestModels/SignalRTestInfrastructure.cs new file mode 100644 index 0000000..b8a95dc --- /dev/null +++ b/AyCode.Core.Tests/TestModels/SignalRTestInfrastructure.cs @@ -0,0 +1,273 @@ +using AyCode.Core.Extensions; +using MessagePack; +using MessagePack.Resolvers; + +namespace AyCode.Core.Tests.TestModels; + +/// +/// Common SignalR test/benchmark infrastructure. +/// Provides message creation and serialization helpers used by both tests and benchmarks. +/// +public static class SignalRMessageFactory +{ + /// + /// Cached MessagePack options for ContractlessStandardResolver + /// + public static readonly MessagePackSerializerOptions ContractlessOptions = ContractlessStandardResolver.Options; + + /// + /// Cached MessagePack options for Standard resolver + /// + public static readonly MessagePackSerializerOptions StandardOptions = MessagePackSerializerOptions.Standard; + + /// + /// Creates a MessagePack message for multiple parameters using IdMessage format. + /// Each parameter is serialized directly as JSON. + /// + public static byte[] CreateIdMessage(params object[] values) + { + var idMessage = new SignalRIdMessageDto(values); + var postMessage = new SignalRPostMessageDto { PostDataJson = idMessage.ToJson() }; + return MessagePackSerializer.Serialize(postMessage, ContractlessOptions); + } + + /// + /// Creates a MessagePack message for a single primitive parameter. + /// + public static byte[] CreateSingleParamMessage(T value) where T : notnull + { + return CreateIdMessage(value); + } + + /// + /// Creates a MessagePack message for a complex object parameter. + /// Uses PostDataJson pattern for single complex objects. + /// + public static byte[] CreateComplexObjectMessage(T obj) + { + var json = obj.ToJson(); + var postMessage = new SignalRPostMessageDto { PostDataJson = json }; + return MessagePackSerializer.Serialize(postMessage, StandardOptions); + } + + /// + /// Creates an empty MessagePack message for parameterless methods. + /// + public static byte[] CreateEmptyMessage() + { + var postMessage = new SignalRPostMessageDto(); + return MessagePackSerializer.Serialize(postMessage, ContractlessOptions); + } + + /// + /// Creates a response message in MessagePack format. + /// + public static byte[] CreateResponseMessage(int messageTag, byte status, string? responseDataJson) + { + var response = new SignalRResponseDto + { + MessageTag = messageTag, + Status = status, + ResponseData = responseDataJson + }; + return MessagePackSerializer.Serialize(response, ContractlessOptions); + } + + /// + /// Creates a success response message in MessagePack format. + /// + public static byte[] CreateSuccessResponse(int messageTag, T data) + { + return CreateResponseMessage(messageTag, 5, data.ToJson()); // 5 = Success + } + + /// + /// Creates an error response message in MessagePack format. + /// + public static byte[] CreateErrorResponse(int messageTag) + { + return CreateResponseMessage(messageTag, 0, null); // 0 = Error + } + + /// + /// Deserializes a MessagePack message to IdMessage DTO. + /// + public static SignalRIdMessageDto? DeserializeToIdMessage(byte[] messageBytes) + { + if (messageBytes == null || messageBytes.Length == 0) return null; + + try + { + var postMessage = MessagePackSerializer.Deserialize(messageBytes, ContractlessOptions); + return postMessage.PostDataJson?.JsonTo(); + } + catch + { + return null; + } + } + + /// + /// Deserializes a MessagePack response message. + /// + public static SignalRResponseDto? DeserializeResponse(byte[] messageBytes) + { + if (messageBytes == null || messageBytes.Length == 0) return null; + + try + { + return MessagePackSerializer.Deserialize(messageBytes, ContractlessOptions); + } + catch + { + return null; + } + } +} + +/// +/// Lightweight DTO for IdMessage serialization/deserialization in tests and benchmarks. +/// Mirrors the structure of IdMessage without dependencies on AyCode.Services. +/// +public class SignalRIdMessageDto +{ + public List Ids { get; set; } = []; + + public SignalRIdMessageDto() + { + } + + public SignalRIdMessageDto(object[] ids) + { + Ids.AddRange(ids.Select(x => x.ToJson())); + } + + public SignalRIdMessageDto(object id) + { + Ids.Add(id.ToJson()); + } +} + +/// +/// Lightweight DTO for SignalR post message serialization. +/// Mirrors SignalPostJsonMessage structure. +/// +[MessagePackObject] +public class SignalRPostMessageDto +{ + [Key(0)] + public string? PostDataJson { get; set; } +} + +/// +/// Lightweight DTO for SignalR response message serialization. +/// Mirrors SignalResponseJsonMessage structure. +/// +[MessagePackObject] +public class SignalRResponseDto +{ + [Key(0)] + public int MessageTag { get; set; } + + [Key(1)] + public byte Status { get; set; } + + [Key(2)] + public string? ResponseData { get; set; } + + [IgnoreMember] + public bool IsSuccess => Status == 5; + + [IgnoreMember] + public bool IsError => Status == 0; +} + +/// +/// Common SignalR message tags for testing. +/// These mirror the production tags but are defined here for test/benchmark independence. +/// +public static class CommonSignalRTags +{ + // Primitive parameter tags + public const int SingleIntParam = 100; + public const int TwoIntParams = 101; + public const int BoolParam = 102; + public const int StringParam = 103; + public const int GuidParam = 104; + public const int EnumParam = 105; + public const int NoParams = 107; + public const int MultipleTypesParams = 109; + + // Extended primitives + public const int DecimalParam = 140; + public const int DateTimeParam = 141; + public const int DoubleParam = 143; + public const int LongParam = 144; + + // Complex object tags + public const int TestOrderItemParam = 120; + public const int TestOrderParam = 121; + public const int SharedTagParam = 122; + + // Collection tags + public const int IntArrayParam = 130; + public const int GuidArrayParam = 131; + public const int StringListParam = 132; + public const int TestOrderItemListParam = 133; + public const int IntListParam = 134; + public const int BoolArrayParam = 135; + public const int MixedWithArrayParam = 136; + + // Mixed parameter scenarios + public const int IntAndDtoParam = 160; + public const int DtoAndListParam = 161; + public const int ThreeComplexParams = 162; + public const int FiveParams = 164; +} + +/// +/// Pre-built test messages for benchmarking. +/// Caches serialized messages to avoid setup overhead in benchmark iterations. +/// +public class SignalRBenchmarkData +{ + // Pre-serialized messages + public byte[] SingleIntMessage { get; } + public byte[] TwoIntMessage { get; } + public byte[] FiveParamsMessage { get; } + public byte[] ComplexOrderItemMessage { get; } + public byte[] ComplexOrderMessage { get; } + public byte[] IntArrayMessage { get; } + public byte[] MixedParamsMessage { get; } + + // Test data + public TestOrderItem TestOrderItem { get; } + public TestOrder TestOrder { get; } + public int[] IntArray { get; } + public Guid TestGuid { get; } + + public SignalRBenchmarkData() + { + // Create test data + TestGuid = Guid.NewGuid(); + IntArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + TestOrderItem = new TestOrderItem + { + Id = 42, + ProductName = "Benchmark Product", + Quantity = 100, + UnitPrice = 99.99m, + Status = TestStatus.Active + }; + TestOrder = TestDataFactory.CreateOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2); + + // Pre-serialize messages + SingleIntMessage = SignalRMessageFactory.CreateSingleParamMessage(42); + TwoIntMessage = SignalRMessageFactory.CreateIdMessage(10, 20); + FiveParamsMessage = SignalRMessageFactory.CreateIdMessage(42, "hello", true, TestGuid, 99.99m); + ComplexOrderItemMessage = SignalRMessageFactory.CreateComplexObjectMessage(TestOrderItem); + ComplexOrderMessage = SignalRMessageFactory.CreateComplexObjectMessage(TestOrder); + IntArrayMessage = SignalRMessageFactory.CreateComplexObjectMessage(IntArray); + MixedParamsMessage = SignalRMessageFactory.CreateIdMessage(true, IntArray, "hello"); + } +} diff --git a/AyCode.Core.Tests/TestModels/TestDataFactory.cs b/AyCode.Core.Tests/TestModels/TestDataFactory.cs new file mode 100644 index 0000000..f40f285 --- /dev/null +++ b/AyCode.Core.Tests/TestModels/TestDataFactory.cs @@ -0,0 +1,369 @@ +namespace AyCode.Core.Tests.TestModels; + +/// +/// Factory for creating test data hierarchies. +/// Used by both unit tests and benchmarks. +/// +public static class TestDataFactory +{ + private static int _idCounter = 1; + + /// + /// Reset the ID counter (call in test setup) + /// + public static void ResetIdCounter() => _idCounter = 1; + + /// + /// Get the next unique ID + /// + public static int NextId() => _idCounter++; + + #region Simple Object Creation + + /// + /// Create a shared tag for cross-reference testing + /// + public static SharedTag CreateTag(string? name = null, string? color = null) + { + var id = _idCounter++; + return new SharedTag + { + Id = id, + Name = name ?? $"Tag-{id}", + Color = color ?? $"#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}", + Priority = id % 5, + IsActive = id % 2 == 0, + CreatedAt = DateTime.UtcNow.AddDays(-id), + Description = $"Description for tag {id}" + }; + } + + /// + /// Create a shared category + /// + public static SharedCategory CreateCategory(string? name = null, int? parentId = null) + { + var id = _idCounter++; + return new SharedCategory + { + Id = id, + Name = name ?? $"Category-{id}", + Description = $"Category description {id}", + SortOrder = id * 100, + IsDefault = id == 1, + ParentCategoryId = parentId, + CreatedAt = DateTime.UtcNow.AddMonths(-id), + UpdatedAt = DateTime.UtcNow.AddDays(-id) + }; + } + + /// + /// Create a shared user for cross-reference testing + /// + public static SharedUser CreateUser(string? username = null, TestUserRole role = TestUserRole.User) + { + var id = _idCounter++; + return new SharedUser + { + Id = id, + Username = username ?? $"user{id}", + Email = $"user{id}@test.com", + FirstName = $"First{id}", + LastName = $"Last{id}", + IsActive = true, + Role = role, + LastLoginAt = DateTime.UtcNow.AddHours(-id), + CreatedAt = DateTime.UtcNow.AddYears(-1), + Preferences = new UserPreferences + { + Theme = id % 2 == 0 ? "dark" : "light", + Language = "en-US", + NotificationsEnabled = true, + EmailDigestFrequency = "daily" + } + }; + } + + /// + /// Create metadata info (non-IId) + /// + public static MetadataInfo CreateMetadata(string? key = null, bool withChild = false) + { + var id = _idCounter++; + return new MetadataInfo + { + Key = key ?? $"Meta-{id}", + Value = $"MetaValue-{id}", + Timestamp = DateTime.UtcNow.AddMinutes(-id * 10), + ChildMetadata = withChild ? CreateMetadata($"Child-{id}", false) : null + }; + } + + #endregion + + #region Hierarchy Creation (5 Levels) + + /// + /// Create a deep order hierarchy with configurable depth + /// + public static TestOrder CreateOrder( + int itemCount = 2, + int palletsPerItem = 2, + int measurementsPerPallet = 2, + int pointsPerMeasurement = 3, + SharedTag? sharedTag = null, + SharedUser? sharedUser = null, + MetadataInfo? sharedMetadata = null) + { + var order = new TestOrder + { + Id = _idCounter++, + OrderNumber = $"ORD-{_idCounter:D4}", + Status = TestStatus.Pending, + CreatedAt = DateTime.UtcNow, + TotalAmount = 1000m + _idCounter * 100, + PrimaryTag = sharedTag, + SecondaryTag = sharedTag, // Same reference for $ref testing + Owner = sharedUser, + OrderMetadata = sharedMetadata, + AuditMetadata = sharedMetadata // Same reference for Newtonsoft $ref + }; + + if (sharedTag != null) + { + order.Tags.Add(sharedTag); + } + + for (int i = 0; i < itemCount; i++) + { + var item = CreateOrderItem(palletsPerItem, measurementsPerPallet, pointsPerMeasurement, sharedTag, sharedUser, sharedMetadata); + item.ParentOrder = order; + order.Items.Add(item); + } + + return order; + } + + /// + /// Create an order item with pallets + /// + public static TestOrderItem CreateOrderItem( + int palletCount = 2, + int measurementsPerPallet = 2, + int pointsPerMeasurement = 3, + SharedTag? sharedTag = null, + SharedUser? sharedUser = null, + MetadataInfo? sharedMetadata = null) + { + var item = new TestOrderItem + { + Id = _idCounter++, + ProductName = $"Product-{_idCounter}", + Quantity = 10 + _idCounter, + UnitPrice = 5.5m * _idCounter, + Status = TestStatus.Pending, + Tag = sharedTag, + Assignee = sharedUser, + ItemMetadata = sharedMetadata + }; + + for (int i = 0; i < palletCount; i++) + { + var pallet = CreatePallet(measurementsPerPallet, pointsPerMeasurement, sharedMetadata); + pallet.ParentItem = item; + item.Pallets.Add(pallet); + } + + return item; + } + + /// + /// Create a pallet with measurements + /// + public static TestPallet CreatePallet( + int measurementCount = 2, + int pointsPerMeasurement = 3, + MetadataInfo? sharedMetadata = null) + { + var pallet = new TestPallet + { + Id = _idCounter++, + PalletCode = $"PLT-{_idCounter:D4}", + TrayCount = 5 + _idCounter % 10, + Status = TestStatus.Pending, + Weight = 100.5 + _idCounter, + PalletMetadata = sharedMetadata + }; + + for (int i = 0; i < measurementCount; i++) + { + var measurement = CreateMeasurement(pointsPerMeasurement); + measurement.ParentPallet = pallet; + pallet.Measurements.Add(measurement); + } + + return pallet; + } + + /// + /// Create a measurement with points + /// + public static TestMeasurement CreateMeasurement(int pointCount = 3) + { + var measurement = new TestMeasurement + { + Id = _idCounter++, + Name = $"Measurement-{_idCounter}", + TotalWeight = 100.5 + _idCounter, + CreatedAt = DateTime.UtcNow + }; + + for (int i = 0; i < pointCount; i++) + { + var point = CreateMeasurementPoint(); + point.ParentMeasurement = measurement; + measurement.Points.Add(point); + } + + return measurement; + } + + /// + /// Create a measurement point + /// + public static TestMeasurementPoint CreateMeasurementPoint() + { + var id = _idCounter++; + return new TestMeasurementPoint + { + Id = id, + Label = $"Point-{id}", + Value = 10.5 + (id * 0.1), + MeasuredAt = DateTime.UtcNow + }; + } + + #endregion + + #region Benchmark Data Generation + + /// + /// Create a large graph for benchmarking with many cross-references. + /// Creates approximately (itemCount * palletsPerItem * measurementsPerPallet * pointsPerMeasurement) objects. + /// + public static TestOrder CreateBenchmarkOrder( + int itemCount = 5, + int palletsPerItem = 4, + int measurementsPerPallet = 3, + int pointsPerMeasurement = 5) + { + ResetIdCounter(); + + // Create shared references that will be used throughout + var sharedTags = Enumerable.Range(1, 10).Select(_ => CreateTag()).ToList(); + var sharedUser = CreateUser("benchuser", TestUserRole.Admin); + var sharedMetadata = CreateMetadata("benchmark", withChild: true); + + var order = new TestOrder + { + Id = _idCounter++, + OrderNumber = $"BENCH-{_idCounter:D6}", + Status = TestStatus.Processing, + CreatedAt = DateTime.UtcNow, + TotalAmount = 999999.99m, + PrimaryTag = sharedTags[0], + SecondaryTag = sharedTags[0], + Owner = sharedUser, + Category = CreateCategory("Benchmark"), + OrderMetadata = sharedMetadata, + AuditMetadata = sharedMetadata, + Tags = sharedTags.Take(3).ToList() + }; + + for (int i = 0; i < itemCount; i++) + { + var item = new TestOrderItem + { + Id = _idCounter++, + ProductName = $"BenchProduct-{i}", + Quantity = 100 + i * 10, + UnitPrice = 25.99m + i, + Status = (TestStatus)(i % 5), + Tag = sharedTags[i % sharedTags.Count], + Assignee = sharedUser, + ItemMetadata = sharedMetadata + }; + item.ParentOrder = order; + + for (int p = 0; p < palletsPerItem; p++) + { + var pallet = new TestPallet + { + Id = _idCounter++, + PalletCode = $"PLT-{i}-{p}", + TrayCount = 10 + p, + Status = (TestStatus)(p % 4), + Weight = 500.0 + p * 50, + PalletMetadata = sharedMetadata + }; + pallet.ParentItem = item; + + for (int m = 0; m < measurementsPerPallet; m++) + { + var measurement = new TestMeasurement + { + Id = _idCounter++, + Name = $"Meas-{i}-{p}-{m}", + TotalWeight = 50.0 + m * 10, + CreatedAt = DateTime.UtcNow.AddMinutes(-m) + }; + measurement.ParentPallet = pallet; + + for (int pt = 0; pt < pointsPerMeasurement; pt++) + { + var point = new TestMeasurementPoint + { + Id = _idCounter++, + Label = $"Pt-{i}-{p}-{m}-{pt}", + Value = 1.0 + pt * 0.5, + MeasuredAt = DateTime.UtcNow.AddSeconds(-pt) + }; + point.ParentMeasurement = measurement; + measurement.Points.Add(point); + } + pallet.Measurements.Add(measurement); + } + item.Pallets.Add(pallet); + } + order.Items.Add(item); + } + + return order; + } + + /// + /// Create primitive test data for all-types testing + /// + public static PrimitiveTestClass CreatePrimitiveTestData() + { + return new PrimitiveTestClass + { + IntValue = int.MaxValue, + LongValue = long.MaxValue, + DoubleValue = 3.14159265358979, + DecimalValue = 12345.6789m, + FloatValue = 1.5f, + BoolValue = true, + StringValue = "Test String ?? ????", + GuidValue = Guid.Parse("12345678-1234-1234-1234-123456789abc"), + DateTimeValue = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc), + EnumValue = TestStatus.Shipped, + ByteValue = 255, + ShortValue = short.MaxValue, + NullableInt = 42, + NullableIntNull = null + }; + } + + #endregion +} diff --git a/AyCode.Core.Tests/TestModels/TestLogger.cs b/AyCode.Core.Tests/TestModels/TestLogger.cs new file mode 100644 index 0000000..ff260ba --- /dev/null +++ b/AyCode.Core.Tests/TestModels/TestLogger.cs @@ -0,0 +1,56 @@ +using System.Runtime.CompilerServices; +using AyCode.Core.Enums; +using AyCode.Core.Loggers; + +namespace AyCode.Core.Tests.TestModels; + +/// +/// Test logger that captures log messages for assertions. +/// Does not require configuration or log writers. +/// +public class TestLogger : AcLoggerBase +{ + public List Logs { get; } = []; + + public TestLogger() : base(AppType.Server, LogLevel.Detail, "TestLogger") + { + } + + public override void Detail(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => Logs.Add(new LogEntry(LogLevel.Detail, text, categoryName, memberName)); + + public override void Debug(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => Logs.Add(new LogEntry(LogLevel.Debug, text, categoryName, memberName)); + + public override void Info(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => Logs.Add(new LogEntry(LogLevel.Info, text, categoryName, memberName)); + + public override void Warning(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => Logs.Add(new LogEntry(LogLevel.Warning, text, categoryName, memberName)); + + public override void Suggest(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null) + => Logs.Add(new LogEntry(LogLevel.Suggest, text, categoryName, memberName)); + + public override void Error(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null) + => Logs.Add(new LogEntry(LogLevel.Error, text, categoryName, memberName, ex)); + + public void Clear() => Logs.Clear(); + + public bool HasErrorLogs => Logs.Any(l => l.Level == LogLevel.Error); + public bool HasWarningLogs => Logs.Any(l => l.Level == LogLevel.Warning); + public IEnumerable ErrorLogs => Logs.Where(l => l.Level == LogLevel.Error); + public IEnumerable WarningLogs => Logs.Where(l => l.Level == LogLevel.Warning); + + public IEnumerable GetErrorMessages() => ErrorLogs.Select(l => $"{l.Text} {l.Exception?.Message}"); + public IEnumerable GetAllMessages() => Logs.Select(l => l.ToString()); +} + +public record LogEntry( + LogLevel Level, + string? Text, + string? CategoryName = null, + string? MemberName = null, + Exception? Exception = null) +{ + public override string ToString() => $"[{Level}] {Text}"; +} diff --git a/AyCode.Core.sln b/AyCode.Core.sln index a802625..89e41a2 100644 --- a/AyCode.Core.sln +++ b/AyCode.Core.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11222.15 d18.0 +VisualStudioVersion = 18.0.11222.15 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AyCode.Core", "AyCode.Core\AyCode.Core.csproj", "{8CCC4969-7306-4747-8A58-80AC5A062EE1}" EndProject @@ -46,6 +46,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BenchmarkSuite1", "BenchmarkSuite1\BenchmarkSuite1.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Services.Tests", "AyCode.Services.Tests\AyCode.Services.Tests.csproj", "{B8443014-1247-FB9C-7BF4-2CC944075A8B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -156,7 +158,11 @@ Global {A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|Any CPU.ActiveCfg = Release|Any CPU {A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|Any CPU.Build.0 = Release|Any CPU {A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|Any CPU.Build.0 = Release|Any CPU + {B8443014-1247-FB9C-7BF4-2CC944075A8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8443014-1247-FB9C-7BF4-2CC944075A8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8443014-1247-FB9C-7BF4-2CC944075A8B}.Product|Any CPU.ActiveCfg = Product|Any CPU + {B8443014-1247-FB9C-7BF4-2CC944075A8B}.Product|Any CPU.Build.0 = Product|Any CPU + {B8443014-1247-FB9C-7BF4-2CC944075A8B}.Release|Any CPU.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/AyCode.Core/Extensions/AcJsonDeserializer.cs b/AyCode.Core/Extensions/AcJsonDeserializer.cs index 805e765..e3612d5 100644 --- a/AyCode.Core/Extensions/AcJsonDeserializer.cs +++ b/AyCode.Core/Extensions/AcJsonDeserializer.cs @@ -1,4 +1,3 @@ -using System.Buffers; using System.Collections; using System.Collections.Concurrent; using System.Globalization; @@ -11,15 +10,25 @@ using Newtonsoft.Json; namespace AyCode.Core.Extensions; +/// +/// Exception thrown when JSON deserialization fails. +/// +public class AcJsonDeserializationException : Exception +{ + public string? Json { get; } + public Type? TargetType { get; } + + public AcJsonDeserializationException(string message, string? json = null, Type? targetType = null, Exception? innerException = null) + : base(message, innerException) + { + Json = json?.Length > 500 ? json[..500] + "..." : json; + TargetType = targetType; + } +} + /// /// High-performance custom JSON deserializer optimized for IId<T> reference handling. -/// Features: -/// - Streaming parse using System.Text.Json (no intermediate JToken allocations) -/// - Compiled expression tree property accessors (no reflection invoke overhead) -/// - Compiled list factories for fast collection creation -/// - Two-phase $id/$ref resolution -/// - IId-based collection merge support -/// - Compatible with AcJsonSerializer output +/// Throws AcJsonDeserializationException on any parsing or type conversion failure. /// public static class AcJsonDeserializer { @@ -38,53 +47,233 @@ public static class AcJsonDeserializer private static readonly Type BoolType = typeof(bool); /// - /// Deserialize JSON string to a new object of type T. + /// Validate JSON string for common issues before deserialization. + /// Throws AcJsonDeserializationException if invalid. /// - public static T? Deserialize(string json) where T : class, new() + private static void ValidateJson(string json, Type targetType) { - if (string.IsNullOrEmpty(json) || json == "null") return null; + if (string.IsNullOrEmpty(json)) + return; + + // Detect double-serialized JSON (JSON string containing escaped JSON) + // Pattern: starts with " and contains escaped quotes \" + if (json.Length > 2 && json[0] == '"' && json[^1] == '"') + { + // Check if this looks like a double-serialized object or array + var inner = json[1..^1]; + if (inner.Contains("\\\"") && (inner.Contains("{") || inner.Contains("["))) + { + throw new AcJsonDeserializationException( + $"Detected double-serialized JSON string. The JSON appears to have been serialized twice. " + + $"Target type: {targetType.Name}. " + + $"JSON starts with: {json[..Math.Min(100, json.Length)]}", + json, targetType); + } + } + + // Detect type mismatch: expecting object but got array or vice versa + var isArrayJson = json.Length > 0 && json[0] == '['; + var isObjectJson = json.Length > 0 && json[0] == '{'; + var isCollectionType = targetType.IsArray || IsGenericCollectionType(targetType); + var isDictType = IsDictionaryType(targetType, out _, out _); + + if (isArrayJson && !isCollectionType && !isDictType && targetType != typeof(object)) + { + throw new AcJsonDeserializationException( + $"JSON is an array but target type '{targetType.Name}' is not a collection type.", + json, targetType); + } + + if (isObjectJson && isCollectionType && !isDictType) + { + throw new AcJsonDeserializationException( + $"JSON is an object but target type '{targetType.Name}' is a collection type (not dictionary).", + json, targetType); + } + } + + /// + /// Deserialize JSON string to a new object of type T. + /// Throws AcJsonDeserializationException on any failure. + /// + public static T? Deserialize(string json) + { + if (string.IsNullOrEmpty(json) || json == "null") return default; - var context = new DeserializationContext(); - using var doc = JsonDocument.Parse(json); + var targetType = typeof(T); - var result = (T?)ReadValue(doc.RootElement, typeof(T), context); - - context.ResolveReferences(); - - return result; + try + { + ValidateJson(json, targetType); + + if (TryDeserializePrimitive(json, targetType, out var primitiveResult)) + return (T?)primitiveResult; + + var context = new DeserializationContext(); + using var doc = JsonDocument.Parse(json); + + var result = ReadValue(doc.RootElement, targetType, context); + context.ResolveReferences(); + + return (T?)result; + } + catch (AcJsonDeserializationException) + { + throw; // Re-throw our custom exceptions + } + catch (System.Text.Json.JsonException ex) + { + throw new AcJsonDeserializationException( + $"Failed to parse JSON for type '{targetType.Name}': {ex.Message}", + json, targetType, ex); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) + { + throw new AcJsonDeserializationException( + $"Failed to convert JSON value for type '{targetType.Name}': {ex.Message}", + json, targetType, ex); + } } /// /// Deserialize JSON string to specified type. + /// Throws AcJsonDeserializationException on any failure. /// public static object? Deserialize(string json, Type targetType) { if (string.IsNullOrEmpty(json) || json == "null") return null; - var context = new DeserializationContext(); - using var doc = JsonDocument.Parse(json); - - var result = ReadValue(doc.RootElement, targetType, context); - - context.ResolveReferences(); - - return result; + try + { + ValidateJson(json, targetType); + + // Fast path: check if this is an array/collection type - skip primitive check + var isArrayJson = json.Length > 0 && json[0] == '['; + var isObjectJson = json.Length > 0 && json[0] == '{'; + var isCollectionType = targetType.IsArray || IsGenericCollectionType(targetType); + var isDictType = IsDictionaryType(targetType, out _, out _); + + // Skip primitive check for arrays, collections, and dictionaries + if (!isArrayJson && !isCollectionType && !(isObjectJson && isDictType)) + { + if (TryDeserializePrimitive(json, targetType, out var primitiveResult)) + return primitiveResult; + } + + var context = new DeserializationContext(); + using var doc = JsonDocument.Parse(json); + + var result = ReadValue(doc.RootElement, targetType, context); + context.ResolveReferences(); + + return result; + } + catch (AcJsonDeserializationException) + { + throw; + } + catch (System.Text.Json.JsonException ex) + { + throw new AcJsonDeserializationException( + $"Failed to parse JSON for type '{targetType.Name}': {ex.Message}", + json, targetType, ex); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) + { + throw new AcJsonDeserializationException( + $"Failed to convert JSON value for type '{targetType.Name}': {ex.Message}", + json, targetType, ex); + } } /// /// Populate existing object with JSON data (merge mode). + /// Throws AcJsonDeserializationException on any failure. /// public static void Populate(string json, T target) where T : class { - if (string.IsNullOrEmpty(json) || json == "null" || target == null) return; + if (target == null) + throw new ArgumentNullException(nameof(target), "Cannot populate null target object"); + + if (string.IsNullOrEmpty(json) || json == "null") return; + Populate(json, target, typeof(T)); + } + + public static void Populate(string json, object target) + { + if (target == null) + throw new ArgumentNullException(nameof(target), "Cannot populate null target object"); + + if (string.IsNullOrEmpty(json) || json == "null") return; + Populate(json, target, target.GetType()); + } + + /// + /// Populate existing object with JSON data using runtime type (merge mode). + /// Throws AcJsonDeserializationException on any failure. + /// + public static void Populate(string json, object target, Type targetType) + { + if (target == null) + throw new ArgumentNullException(nameof(target), "Cannot populate null target object"); + + if (string.IsNullOrEmpty(json) || json == "null") return; - var context = new DeserializationContext { IsMergeMode = true }; - using var doc = JsonDocument.Parse(json); - - var metadata = GetTypeMetadata(typeof(T)); - PopulateObjectInternal(doc.RootElement, target, metadata, context); - - context.ResolveReferences(); + try + { + ValidateJson(json, targetType); + + var context = new DeserializationContext { IsMergeMode = true }; + using var doc = JsonDocument.Parse(json); + + var rootElement = doc.RootElement; + + if (rootElement.ValueKind == JsonValueKind.Array) + { + if (target is IList targetList) + { + PopulateList(rootElement, targetList, targetType, context); + } + else + { + throw new AcJsonDeserializationException( + $"Cannot populate non-list target '{targetType.Name}' with JSON array", + json, targetType); + } + context.ResolveReferences(); + return; + } + + if (rootElement.ValueKind == JsonValueKind.Object) + { + var metadata = GetTypeMetadata(targetType); + PopulateObjectInternalMerge(rootElement, target, metadata, context); + } + else + { + throw new AcJsonDeserializationException( + $"Cannot populate object with JSON value of kind '{rootElement.ValueKind}'", + json, targetType); + } + + context.ResolveReferences(); + } + catch (AcJsonDeserializationException) + { + throw; + } + catch (System.Text.Json.JsonException ex) + { + throw new AcJsonDeserializationException( + $"Failed to parse JSON for population of type '{targetType.Name}': {ex.Message}", + json, targetType, ex); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) + { + throw new AcJsonDeserializationException( + $"Failed to convert JSON value during population of type '{targetType.Name}': {ex.Message}", + json, targetType, ex); + } } #region Core Reading Methods @@ -93,103 +282,62 @@ public static class AcJsonDeserializer private static object? ReadValue(JsonElement element, Type targetType, DeserializationContext context) { var valueKind = element.ValueKind; - - // Order by frequency: Object > Array > String > Number > True/False > Null - if (valueKind == JsonValueKind.Object) - return ReadObject(element, targetType, context); - - if (valueKind == JsonValueKind.Array) - return ReadArray(element, targetType, context); - - if (valueKind == JsonValueKind.Null) - return null; - - return ReadPrimitive(element, targetType, valueKind); + + return valueKind switch + { + JsonValueKind.Object => ReadObject(element, targetType, context), + JsonValueKind.Array => ReadArray(element, targetType, context), + JsonValueKind.Null => null, + _ => ReadPrimitive(element, targetType, valueKind) + }; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ReadObject(JsonElement element, Type targetType, DeserializationContext context) { - // Check for $ref first using TryGetProperty (optimized in STJ) if (element.TryGetProperty("$ref", out var refElement)) { var refId = refElement.GetString()!; - if (context.TryGetReferencedObject(refId, out var refObj)) - return refObj; - - return new DeferredReference(refId, targetType); + return context.TryGetReferencedObject(refId, out var refObj) ? refObj : new DeferredReference(refId, targetType); + } + + // Handle Dictionary types + if (IsDictionaryType(targetType, out var keyType, out var valueType)) + { + return ReadDictionary(element, targetType, keyType!, valueType!, context); } - // Get metadata once and reuse var metadata = GetTypeMetadata(targetType); - - // Create instance using compiled constructor var instance = metadata.CompiledConstructor?.Invoke() ?? Activator.CreateInstance(targetType); if (instance == null) return null; - // Check for $id and register if (element.TryGetProperty("$id", out var idElement)) { context.RegisterObject(idElement.GetString()!, instance); } - // Populate properties PopulateObjectInternal(element, instance, metadata, context); return instance; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void PopulateObject(JsonElement element, object target, Type targetType, DeserializationContext context) - { - var metadata = GetTypeMetadata(targetType); - PopulateObjectInternal(element, target, metadata, context); - } - - /// - /// Internal populate method - optimized hot path. - /// private static void PopulateObjectInternal(JsonElement element, object target, DeserializeTypeMetadata metadata, DeserializationContext context) { var propertySetters = metadata.PropertySetters; - var isMergeMode = context.IsMergeMode; foreach (var jsonProp in element.EnumerateObject()) { var propName = jsonProp.Name; - var propNameLength = propName.Length; - // Ultra-fast skip for $ properties - check first char before string comparison - if (propNameLength > 0) + if (propName.Length > 0 && propName[0] == '$') { - var firstChar = propName[0]; - if (firstChar == '$') - { - // Only 2 metadata properties: $id and $ref - if (propNameLength == 3 && propName[1] == 'i' && propName[2] == 'd') continue; - if (propNameLength == 4 && propName[1] == 'r' && propName[2] == 'e' && propName[3] == 'f') continue; - } + if (propName == "$id" || propName == "$ref") continue; } if (!propertySetters.TryGetValue(propName, out var propInfo)) continue; - var propValue = jsonProp.Value; - var propValueKind = propValue.ValueKind; + var value = ReadValue(jsonProp.Value, propInfo.PropertyType, context); - // Handle collections with IId merge (only in merge mode) - if (isMergeMode && propInfo.IsCollection && propInfo.ElementIsIId && propValueKind == JsonValueKind.Array) - { - var existingCollection = propInfo.GetValue(target); - if (existingCollection != null) - { - MergeIIdCollection(propValue, existingCollection, propInfo, context); - continue; - } - } - - var value = ReadValue(propValue, propInfo.PropertyType, context); - - // Handle deferred references if (value is DeferredReference deferred) { context.AddPropertyToResolve(target, propInfo, deferred.RefId); @@ -201,13 +349,103 @@ public static class AcJsonDeserializer } } + private static void PopulateObjectInternalMerge(JsonElement element, object target, DeserializeTypeMetadata metadata, DeserializationContext context) + { + var propertySetters = metadata.PropertySetters; + + foreach (var jsonProp in element.EnumerateObject()) + { + var propName = jsonProp.Name; + + if (propName.Length > 0 && propName[0] == '$') + { + if (propName == "$id" || propName == "$ref") continue; + } + + if (!propertySetters.TryGetValue(propName, out var propInfo)) continue; + + var propValue = jsonProp.Value; + var propValueKind = propValue.ValueKind; + + // Handle IId collection merge - update existing items in place + if (propInfo is { IsCollection: true, ElementIsIId: true } && propValueKind == JsonValueKind.Array) + { + var existingCollection = propInfo.GetValue(target); + if (existingCollection != null) + { + MergeIIdCollection(propValue, existingCollection, propInfo, context); + continue; + } + } + + // For nested objects, check if it's a $ref first - if so, don't try to update in place + if (propValueKind == JsonValueKind.Object) + { + // Check if this is a $ref - if so, we need to replace, not merge + if (propValue.TryGetProperty("$ref", out _)) + { + // This is a reference - resolve it and set + var value = ReadValue(propValue, propInfo.PropertyType, context); + if (value is DeferredReference deferred) + { + context.AddPropertyToResolve(target, propInfo, deferred.RefId); + } + else + { + propInfo.SetValue(target, value); + } + continue; + } + + // Not a $ref - try to update existing object in place + if (!propInfo.PropertyType.IsPrimitive && propInfo.PropertyType != typeof(string)) + { + var existingObj = propInfo.GetValue(target); + if (existingObj != null) + { + var nestedMetadata = GetTypeMetadata(propInfo.PropertyType); + PopulateObjectInternalMerge(propValue, existingObj, nestedMetadata, context); + continue; + } + } + } + + var value2 = ReadValue(propValue, propInfo.PropertyType, context); + + if (value2 is DeferredReference deferred2) + { + context.AddPropertyToResolve(target, propInfo, deferred2.RefId); + } + else + { + propInfo.SetValue(target, value2); + } + } + } + + private static void PopulateList(JsonElement arrayElement, IList targetList, Type listType, DeserializationContext context) + { + var elementType = GetListElementType(listType); + if (elementType == null) return; + + targetList.Clear(); + + foreach (var item in arrayElement.EnumerateArray()) + { + var value = ReadValue(item, elementType, context); + if (value != null) + { + targetList.Add(value); + } + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ReadArray(JsonElement element, Type targetType, DeserializationContext context) { var elementType = GetCollectionElementType(targetType); if (elementType == null) return null; - // Use compiled list factory var list = GetOrCreateListFactory(elementType)(); foreach (var item in element.EnumerateArray()) @@ -215,11 +453,9 @@ public static class AcJsonDeserializer list.Add(ReadValue(item, elementType, context)); } - // Convert to array if needed if (targetType.IsArray) { - var count = list.Count; - var array = Array.CreateInstance(elementType, count); + var array = Array.CreateInstance(elementType, list.Count); list.CopyTo(array, 0); return array; } @@ -234,17 +470,22 @@ public static class AcJsonDeserializer var idType = propInfo.ElementIdType!; var existingList = (IList)existingCollection; - var existingById = new Dictionary(existingList.Count); + var count = existingList.Count; - // Build lookup - foreach (var item in existingList) + Dictionary? existingById = null; + if (count > 0) { - if (item != null) + existingById = new Dictionary(count); + for (var i = 0; i < count; i++) { - var id = idGetter(item); - if (id != null && !IsDefaultId(id, idType)) + var item = existingList[i]; + if (item != null) { - existingById[id] = item; + var id = idGetter(item); + if (id != null && !IsDefaultId(id, idType)) + { + existingById[id] = item; + } } } } @@ -259,38 +500,29 @@ public static class AcJsonDeserializer itemId = ReadPrimitive(idProp, idType, idProp.ValueKind); } - if (itemId != null && !IsDefaultId(itemId, idType)) + if (itemId != null && !IsDefaultId(itemId, idType) && existingById != null) { if (existingById.TryGetValue(itemId, out var existingItem)) { - PopulateObject(jsonItem, existingItem, elementType, context); - } - else - { - var newItem = ReadValue(jsonItem, elementType, context); - if (newItem != null) existingList.Add(newItem); + // Recursively merge nested objects + var itemMetadata = GetTypeMetadata(elementType); + PopulateObjectInternalMerge(jsonItem, existingItem, itemMetadata, context); + continue; } } - else - { - var newItem = ReadValue(jsonItem, elementType, context); - if (newItem != null) existingList.Add(newItem); - } + + var newItem = ReadValue(jsonItem, elementType, context); + if (newItem != null) existingList.Add(newItem); } } - /// - /// Optimized primitive reading with pre-fetched valueKind. - /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object? ReadPrimitive(JsonElement element, Type targetType, JsonValueKind valueKind) { var type = Nullable.GetUnderlyingType(targetType) ?? targetType; - // Numbers - most common in data if (valueKind == JsonValueKind.Number) { - // Order by frequency if (ReferenceEquals(type, IntType)) return element.GetInt32(); if (ReferenceEquals(type, LongType)) return element.GetInt64(); if (ReferenceEquals(type, DoubleType)) return element.GetDouble(); @@ -306,7 +538,6 @@ public static class AcJsonDeserializer return null; } - // Strings if (valueKind == JsonValueKind.String) { if (ReferenceEquals(type, StringType)) return element.GetString(); @@ -318,7 +549,6 @@ public static class AcJsonDeserializer return null; } - // Booleans if (valueKind == JsonValueKind.True) return true; if (valueKind == JsonValueKind.False) return false; @@ -327,6 +557,155 @@ public static class AcJsonDeserializer #endregion + #region Type Helpers + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Type? GetListElementType(Type listType) + { + if (listType.IsGenericType) + { + var genericDef = listType.GetGenericTypeDefinition(); + if (genericDef == typeof(List<>) || genericDef == typeof(IList<>) || + genericDef == typeof(ICollection<>) || genericDef == typeof(IEnumerable<>)) + { + return listType.GetGenericArguments()[0]; + } + } + + foreach (var iface in listType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IList<>)) + { + return iface.GetGenericArguments()[0]; + } + } + + if (listType.IsArray) + return listType.GetElementType(); + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Type? GetCollectionElementType(Type collectionType) + { + if (collectionType.IsArray) + return collectionType.GetElementType(); + + if (collectionType.IsGenericType) + { + var genericDef = collectionType.GetGenericTypeDefinition(); + if (genericDef == typeof(List<>) || genericDef == typeof(IList<>) || + genericDef == typeof(ICollection<>) || genericDef == typeof(IEnumerable<>)) + { + return collectionType.GetGenericArguments()[0]; + } + } + + foreach (var iface in collectionType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IList<>)) + { + return iface.GetGenericArguments()[0]; + } + } + + return typeof(object); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDefaultId(object id, Type idType) + { + if (ReferenceEquals(idType, IntType)) return (int)id == 0; + if (ReferenceEquals(idType, LongType)) return (long)id == 0; + if (ReferenceEquals(idType, GuidType)) return (Guid)id == Guid.Empty; + return false; + } + + /// + /// Check if type is a Dictionary type and extract key/value types. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDictionaryType(Type type, out Type? keyType, out Type? valueType) + { + keyType = null; + valueType = null; + + if (!type.IsGenericType) return false; + + var genericDef = type.GetGenericTypeDefinition(); + if (genericDef == typeof(Dictionary<,>) || genericDef == typeof(IDictionary<,>)) + { + var args = type.GetGenericArguments(); + keyType = args[0]; + valueType = args[1]; + return true; + } + + // Check interfaces for IDictionary<,> + foreach (var iface in type.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IDictionary<,>)) + { + var args = iface.GetGenericArguments(); + keyType = args[0]; + valueType = args[1]; + return true; + } + } + + return false; + } + + /// + /// Read a JSON object as a Dictionary. + /// + private static object ReadDictionary(JsonElement element, Type dictionaryType, Type keyType, Type valueType, DeserializationContext context) + { + var dictType = typeof(Dictionary<,>).MakeGenericType(keyType, valueType); + var dict = (IDictionary)Activator.CreateInstance(dictType)!; + + foreach (var prop in element.EnumerateObject().Where(prop => !prop.Name.StartsWith('$'))) + { + // Parse key - for string keys, use directly; for other types, parse + object key; + if (keyType == StringType) + { + key = prop.Name; + } + else if (keyType == IntType) + { + key = int.Parse(prop.Name, CultureInfo.InvariantCulture); + } + else if (keyType == LongType) + { + key = long.Parse(prop.Name, CultureInfo.InvariantCulture); + } + else if (keyType == GuidType) + { + key = Guid.Parse(prop.Name); + } + else if (keyType.IsEnum) + { + key = Enum.Parse(keyType, prop.Name); + } + else + { + // Fallback: try to convert string to key type + key = Convert.ChangeType(prop.Name, keyType, CultureInfo.InvariantCulture); + } + + // Parse value + var value = ReadValue(prop.Value, valueType, context); + + dict.Add(key, value); + } + + return dict; + } + + #endregion + #region Type Metadata Cache [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -343,8 +722,7 @@ public static class AcJsonDeserializer var listType = typeof(List<>).MakeGenericType(t); var newExpr = Expression.New(listType); var castExpr = Expression.Convert(newExpr, typeof(IList)); - var lambda = Expression.Lambda>(castExpr); - return lambda.Compile(); + return Expression.Lambda>(castExpr).Compile(); }); } @@ -363,15 +741,26 @@ public static class AcJsonDeserializer CompiledConstructor = Expression.Lambda>(boxed).Compile(); } - var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanWrite && p.CanRead && - p.GetIndexParameters().Length == 0 && - p.GetCustomAttribute() == null && - p.GetCustomAttribute() == null) - .ToArray(); + // Optimized property filtering - avoid attribute checks when possible + var allProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var propsList = new List(allProps.Length); + + foreach (var p in allProps) + { + if (!p.CanWrite || !p.CanRead || p.GetIndexParameters().Length != 0) + continue; + + // Check attributes only if IsDefined returns true (faster than GetCustomAttribute) + if (Attribute.IsDefined(p, typeof(JsonIgnoreAttribute))) + continue; + if (Attribute.IsDefined(p, typeof(System.Text.Json.Serialization.JsonIgnoreAttribute))) + continue; + + propsList.Add(p); + } - PropertySetters = new Dictionary(props.Length, StringComparer.OrdinalIgnoreCase); - foreach (var prop in props) + PropertySetters = new Dictionary(propsList.Count, StringComparer.OrdinalIgnoreCase); + foreach (var prop in propsList) { PropertySetters[prop.Name] = new PropertySetterInfo(prop, type); } @@ -393,8 +782,6 @@ public static class AcJsonDeserializer public PropertySetterInfo(PropertyInfo prop, Type declaringType) { PropertyType = prop.PropertyType; - - // Compiled delegates _setter = CreateCompiledSetter(declaringType, prop); _getter = CreateCompiledGetter(declaringType, prop); @@ -450,38 +837,6 @@ public static class AcJsonDeserializer #endregion - #region Helper Methods - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Type? GetCollectionElementType(Type collectionType) - { - if (collectionType.IsArray) - return collectionType.GetElementType(); - - if (collectionType.IsGenericType) - { - var genericDef = collectionType.GetGenericTypeDefinition(); - if (genericDef == typeof(List<>) || genericDef == typeof(IList<>) || - genericDef == typeof(ICollection<>) || genericDef == typeof(IEnumerable<>)) - { - return collectionType.GetGenericArguments()[0]; - } - } - - return typeof(object); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsDefaultId(object id, Type idType) - { - if (ReferenceEquals(idType, IntType)) return (int)id == 0; - if (ReferenceEquals(idType, LongType)) return (long)id == 0; - if (ReferenceEquals(idType, GuidType)) return (Guid)id == Guid.Empty; - return false; - } - - #endregion - #region Reference Resolution private sealed class DeferredReference(string refId, Type targetType) @@ -490,7 +845,6 @@ public static class AcJsonDeserializer public Type TargetType { get; } = targetType; } - // Use struct to reduce allocations private readonly struct PropertyToResolve(object target, PropertySetterInfo property, string refId) { public readonly object Target = target; @@ -500,23 +854,38 @@ public static class AcJsonDeserializer private sealed class DeserializationContext { - private readonly Dictionary _idToObject = new(16, StringComparer.Ordinal); - private readonly List _propertiesToResolve = new(8); + private Dictionary? _idToObject; + private List? _propertiesToResolve; public bool IsMergeMode { get; init; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void RegisterObject(string id, object obj) => _idToObject[id] = obj; + public void RegisterObject(string id, object obj) + { + _idToObject ??= new Dictionary(8, StringComparer.Ordinal); + _idToObject[id] = obj; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryGetReferencedObject(string id, out object? obj) => _idToObject.TryGetValue(id, out obj); + public bool TryGetReferencedObject(string id, out object? obj) + { + if (_idToObject != null) + return _idToObject.TryGetValue(id, out obj); + obj = null; + return false; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void AddPropertyToResolve(object target, PropertySetterInfo property, string refId) - => _propertiesToResolve.Add(new PropertyToResolve(target, property, refId)); + { + _propertiesToResolve ??= new List(4); + _propertiesToResolve.Add(new PropertyToResolve(target, property, refId)); + } public void ResolveReferences() { + if (_propertiesToResolve == null || _idToObject == null) return; + foreach (ref readonly var ptr in System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_propertiesToResolve)) { if (_idToObject.TryGetValue(ptr.RefId, out var refObj)) @@ -528,4 +897,159 @@ public static class AcJsonDeserializer } #endregion + + #region Primitive Deserialization + + private static bool TryDeserializePrimitive(string json, Type targetType, out object? result) + { + var type = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (ReferenceEquals(type, StringType)) + { + using var doc = JsonDocument.Parse(json); + result = doc.RootElement.GetString(); + return true; + } + + if (ReferenceEquals(type, IntType)) + { + result = int.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (ReferenceEquals(type, LongType)) + { + result = long.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (ReferenceEquals(type, BoolType)) + { + result = json == "true"; + return true; + } + + if (ReferenceEquals(type, DoubleType)) + { + result = double.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (ReferenceEquals(type, DecimalType)) + { + result = decimal.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (ReferenceEquals(type, FloatType)) + { + result = float.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (ReferenceEquals(type, DateTimeType)) + { + using var doc = JsonDocument.Parse(json); + result = doc.RootElement.GetDateTime(); + return true; + } + + if (ReferenceEquals(type, GuidType)) + { + using var doc = JsonDocument.Parse(json); + result = doc.RootElement.GetGuid(); + return true; + } + + if (type == typeof(DateTimeOffset)) + { + using var doc = JsonDocument.Parse(json); + result = doc.RootElement.GetDateTimeOffset(); + return true; + } + + if (type == typeof(TimeSpan)) + { + using var doc = JsonDocument.Parse(json); + result = TimeSpan.Parse(doc.RootElement.GetString()!, CultureInfo.InvariantCulture); + return true; + } + + if (type.IsEnum) + { + if (json.StartsWith('"')) + { + using var doc = JsonDocument.Parse(json); + result = Enum.Parse(type, doc.RootElement.GetString()!); + } + else + { + result = Enum.ToObject(type, int.Parse(json, CultureInfo.InvariantCulture)); + } + return true; + } + + if (type == typeof(byte)) + { + result = byte.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (type == typeof(short)) + { + result = short.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (type == typeof(ushort)) + { + result = ushort.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (type == typeof(uint)) + { + result = uint.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (type == typeof(ulong)) + { + result = ulong.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (type == typeof(sbyte)) + { + result = sbyte.Parse(json, CultureInfo.InvariantCulture); + return true; + } + + if (type == typeof(char)) + { + using var doc = JsonDocument.Parse(json); + var s = doc.RootElement.GetString(); + result = s?.Length > 0 ? s[0] : '\0'; + return true; + } + + result = null; + return false; + } + + /// + /// Check if type is a generic collection type (List, IList, etc.) + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsGenericCollectionType(Type type) + { + if (!type.IsGenericType) return false; + + var genericDef = type.GetGenericTypeDefinition(); + return genericDef == typeof(List<>) || + genericDef == typeof(IList<>) || + genericDef == typeof(ICollection<>) || + genericDef == typeof(IEnumerable<>); + } + #endregion } diff --git a/AyCode.Core/Extensions/AcJsonSerializer.cs b/AyCode.Core/Extensions/AcJsonSerializer.cs index c358e10..de04680 100644 --- a/AyCode.Core/Extensions/AcJsonSerializer.cs +++ b/AyCode.Core/Extensions/AcJsonSerializer.cs @@ -27,11 +27,18 @@ public static class AcJsonSerializer /// /// Serialize object to JSON string with optimized reference handling. + /// Supports primitives, strings, enums, and complex objects. /// public static string Serialize(T value) { if (value == null) return "null"; + var type = typeof(T); + + // Fast path for primitives - no reference tracking needed + if (TrySerializePrimitive(value, type, out var primitiveJson)) + return primitiveJson; + var context = new SerializationContext(); // Phase 1: Scan for cross-references (objects that appear multiple times) @@ -44,6 +51,185 @@ public static class AcJsonSerializer return context.GetResult(); } + /// + /// Try to serialize a primitive value directly without context. + /// Returns true if value is primitive, false otherwise. + /// + private static bool TrySerializePrimitive(T value, Type type, out string json) + { + // Handle nullable underlying type + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + if (underlyingType == typeof(string)) + { + json = SerializeString((string)(object)value!); + return true; + } + + if (underlyingType == typeof(int)) + { + json = ((int)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(long)) + { + json = ((long)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(bool)) + { + json = (bool)(object)value! ? "true" : "false"; + return true; + } + + if (underlyingType == typeof(double)) + { + var d = (double)(object)value!; + json = double.IsNaN(d) || double.IsInfinity(d) ? "null" : d.ToString("G17", CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(decimal)) + { + json = ((decimal)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(float)) + { + var f = (float)(object)value!; + json = float.IsNaN(f) || float.IsInfinity(f) ? "null" : f.ToString("G9", CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(DateTime)) + { + json = $"\"{((DateTime)(object)value!).ToString("O", CultureInfo.InvariantCulture)}\""; + return true; + } + + if (underlyingType == typeof(DateTimeOffset)) + { + json = $"\"{((DateTimeOffset)(object)value!).ToString("O", CultureInfo.InvariantCulture)}\""; + return true; + } + + if (underlyingType == typeof(Guid)) + { + json = $"\"{((Guid)(object)value!).ToString("D")}\""; + return true; + } + + if (underlyingType == typeof(TimeSpan)) + { + json = $"\"{((TimeSpan)(object)value!).ToString("c", CultureInfo.InvariantCulture)}\""; + return true; + } + + if (underlyingType.IsEnum) + { + json = Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(byte)) + { + json = ((byte)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(short)) + { + json = ((short)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(ushort)) + { + json = ((ushort)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(uint)) + { + json = ((uint)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(ulong)) + { + json = ((ulong)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(sbyte)) + { + json = ((sbyte)(object)value!).ToString(CultureInfo.InvariantCulture); + return true; + } + + if (underlyingType == typeof(char)) + { + json = SerializeString(value!.ToString()!); + return true; + } + + json = ""; + return false; + } + + /// + /// Serialize a string value with proper escaping. + /// + private static string SerializeString(string value) + { + // Fast path: if no escaping needed + if (!NeedsEscaping(value)) + return $"\"{value}\""; + + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 32) + { + sb.Append("\\u"); + sb.Append(((int)c).ToString("X4")); + } + else + { + sb.Append(c); + } + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool NeedsEscaping(string value) + { + foreach (var c in value) + { + if (c < 32 || c == '"' || c == '\\') + return true; + } + return false; + } + #region Phase 1: Reference Scanning private static void ScanReferences(object? value, SerializationContext context) @@ -52,17 +238,17 @@ public static class AcJsonSerializer var type = value.GetType(); - // Skip primitives - if (IsPrimitiveOrString(type)) return; + // Skip primitives - use cached type check + if (IsPrimitiveOrStringFast(type)) return; - // Track object occurrence + // Track object occurrence - inline the check if (!context.TrackForScanning(value)) { // Already seen - mark as needing $id return; } - // Scan collections + // Scan collections - check IEnumerable before getting metadata if (value is IEnumerable enumerable && type != typeof(string)) { foreach (var item in enumerable) @@ -73,16 +259,35 @@ public static class AcJsonSerializer return; } - // Scan object properties + // Scan object properties using cached metadata var metadata = GetTypeMetadata(type); - foreach (var prop in metadata.Properties) + var properties = metadata.Properties; + var propCount = properties.Length; + + // Unroll small property counts for better performance + for (var i = 0; i < propCount; i++) { - var propValue = prop.GetValue(value); + var propValue = properties[i].GetValue(value); if (propValue != null) ScanReferences(propValue, context); } } + // Faster primitive check using type code + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsPrimitiveOrStringFast(Type type) + { + var typeCode = Type.GetTypeCode(type); + return typeCode switch + { + TypeCode.Boolean or TypeCode.Char or TypeCode.SByte or TypeCode.Byte or + TypeCode.Int16 or TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32 or + TypeCode.Int64 or TypeCode.UInt64 or TypeCode.Single or TypeCode.Double or + TypeCode.Decimal or TypeCode.DateTime or TypeCode.String => true, + _ => type == typeof(Guid) || type == typeof(TimeSpan) || type == typeof(DateTimeOffset) || type.IsEnum + }; + } + #endregion #region Phase 2: Serialization @@ -101,6 +306,13 @@ public static class AcJsonSerializer if (TryWritePrimitive(value, type, context)) return; + // Dictionaries - must check before IEnumerable since Dictionary implements IEnumerable + if (value is IDictionary dictionary) + { + WriteDictionary(dictionary, context); + return; + } + // Collections if (value is IEnumerable enumerable && type != typeof(string)) { @@ -166,6 +378,24 @@ public static class AcJsonSerializer context.WriteArrayEnd(); } + /// + /// Write a dictionary as a JSON object with keys as property names. + /// + private static void WriteDictionary(IDictionary dictionary, SerializationContext context) + { + context.WriteObjectStart(); + var isFirst = true; + + foreach (DictionaryEntry entry in dictionary) + { + var keyString = entry.Key?.ToString() ?? ""; + context.WritePropertyName(keyString, ref isFirst); + WriteValue(entry.Value, context); + } + + context.WriteObjectEnd(); + } + private static bool TryWritePrimitive(object value, Type type, SerializationContext context) { // Handle nullable underlying type @@ -292,10 +522,7 @@ public static class AcJsonSerializer private static bool IsPrimitiveOrString(Type type) { var t = Nullable.GetUnderlyingType(type) ?? type; - return t.IsPrimitive || t.IsEnum || - t == typeof(string) || t == typeof(decimal) || - t == typeof(DateTime) || t == typeof(DateTimeOffset) || - t == typeof(Guid) || t == typeof(TimeSpan); + return IsPrimitiveOrStringFast(t); } /// @@ -463,17 +690,18 @@ public static class AcJsonSerializer private readonly Dictionary _writtenRefs; private readonly HashSet _multiReferenced; private int _nextId; - private bool _isWriting; - // Pre-allocated char buffers for number formatting - private readonly char[] _numberBuffer = new char[32]; + // Use ArrayPool for number buffer to reduce allocations + private static readonly ArrayPool CharPool = ArrayPool.Shared; + private readonly char[] _numberBuffer; public SerializationContext() { _sb = new StringBuilder(4096); - _scanOccurrences = new Dictionary(ReferenceEqualityComparer.Instance); - _writtenRefs = new Dictionary(ReferenceEqualityComparer.Instance); - _multiReferenced = new HashSet(ReferenceEqualityComparer.Instance); + _scanOccurrences = new Dictionary(64, ReferenceEqualityComparer.Instance); + _writtenRefs = new Dictionary(32, ReferenceEqualityComparer.Instance); + _multiReferenced = new HashSet(32, ReferenceEqualityComparer.Instance); + _numberBuffer = CharPool.Rent(64); _nextId = 1; } @@ -482,18 +710,18 @@ public static class AcJsonSerializer /// public bool TrackForScanning(object obj) { - if (_scanOccurrences.TryGetValue(obj, out var count)) + ref var count = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(_scanOccurrences, obj, out var exists); + if (exists) { - _scanOccurrences[obj] = count + 1; + count++; _multiReferenced.Add(obj); return false; } - - _scanOccurrences[obj] = 1; + count = 1; return true; } - public void StartWriting() => _isWriting = true; + public void StartWriting() { } /// /// Check if this object needs a $id (is referenced elsewhere). @@ -526,7 +754,12 @@ public static class AcJsonSerializer _sb.Append("\"}"); } - public string GetResult() => _sb.ToString(); + public string GetResult() + { + var result = _sb.ToString(); + CharPool.Return(_numberBuffer); + return result; + } // Write methods [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/AyCode.Core/Extensions/SerializeObjectExtensions.cs b/AyCode.Core/Extensions/SerializeObjectExtensions.cs index e5f8eaf..b1481f2 100644 --- a/AyCode.Core/Extensions/SerializeObjectExtensions.cs +++ b/AyCode.Core/Extensions/SerializeObjectExtensions.cs @@ -417,10 +417,10 @@ public static class SerializeObjectExtensions public static string ToJson(this T source, JsonSerializerSettings? options = null) { // If custom options are provided, use Newtonsoft for full compatibility - if (options != null) - { - return JsonConvert.SerializeObject(source, options); - } + //if (options != null) + //{ + // return JsonConvert.SerializeObject(source, options); + //} // Use our high-performance custom serializer return AcJsonSerializer.Serialize(source); @@ -466,101 +466,63 @@ public static class SerializeObjectExtensions } public static string ToJson(this IQueryable source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson - => options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source); - // OLD: => ((object)source).ToJson(options); + //=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source); + => ((object)source).ToJson(options); public static string ToJson(this IEnumerable source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson - => options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source); - // OLD: => ((object)source).ToJson(options); + //=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source); + => ((object)source).ToJson(options); public static T? JsonTo(this string json, JsonSerializerSettings? options = null) { json = JsonUtilities.UnwrapJsonString(json); + return AcJsonDeserializer.Deserialize(json); + + // Use our high-performance custom deserializer + // AcJsonDeserializer now supports primitives, enums, and complex types + //if (options == null) + //{ + // try + // { + // return AcJsonDeserializer.Deserialize(json); + // } + // catch + // { + // // Fallback to Newtonsoft if custom deserializer fails + // } + //} - // Use our high-performance custom deserializer for simple deserialization - // Only use for: non-abstract classes, with parameterless constructor, not arrays, not primitives - if (options == null && - typeof(T).IsClass && - !typeof(T).IsAbstract && - !typeof(T).IsArray && - !typeof(T).IsPrimitive && - typeof(T) != typeof(string) && - typeof(T).GetConstructor(Type.EmptyTypes) != null) - { - try - { - return (T?)AcJsonDeserializer.Deserialize(json, typeof(T)); - } - catch - { - // Fallback to Newtonsoft if custom deserializer fails - } - } - - return JsonConvert.DeserializeObject(json, options ?? Options); - - // ======================================================================== - // OLD IMPLEMENTATION - Always Newtonsoft - // Uncomment below and comment out the above to rollback - // ======================================================================== - // return JsonConvert.DeserializeObject(json, options ?? Options); + //return JsonConvert.DeserializeObject(json, options ?? Options); } public static object? JsonTo(this string json, Type toType, JsonSerializerSettings? options = null) { json = JsonUtilities.UnwrapJsonString(json); + return AcJsonDeserializer.Deserialize(json, toType); + + //// Use our high-performance custom deserializer + //// AcJsonDeserializer now supports primitives, enums, and complex types + //if (options == null) + //{ + // try + // { + // return AcJsonDeserializer.Deserialize(json, toType); + // } + // catch + // { + // // Fallback to Newtonsoft if custom deserializer fails + // } + //} - // Use our high-performance custom deserializer for simple deserialization - // Only use for: non-abstract classes, with parameterless constructor, not arrays, not primitives - if (options == null && - toType.IsClass && - !toType.IsAbstract && - !toType.IsArray && - !toType.IsPrimitive && - toType != typeof(string) && - toType.GetConstructor(Type.EmptyTypes) != null) - { - try - { - return AcJsonDeserializer.Deserialize(json, toType); - } - catch - { - // Fallback to Newtonsoft if custom deserializer fails - } - } - - return JsonConvert.DeserializeObject(json, toType, options ?? Options); - - // ======================================================================== - // OLD IMPLEMENTATION - Always Newtonsoft - // Uncomment below and comment out the above to rollback - // ======================================================================== - // return JsonConvert.DeserializeObject(json, toType, options ?? Options); + //return JsonConvert.DeserializeObject(json, toType, options ?? Options); } public static void JsonTo(this string json, object target, JsonSerializerSettings? options = null) { json = JsonUtilities.UnwrapJsonString(json); - // For populate/merge, we still use Newtonsoft as it handles complex merge logic - // The AcJsonDeserializer.Populate can be used for simple cases - target.DeepPopulateWithMerge(json, options ?? Options); - - // ======================================================================== - // ALTERNATIVE - Use AcJsonDeserializer for populate (simpler merge logic) - // Uncomment below for faster but simpler merge - // ======================================================================== - // if (options == null) - // { - // try - // { - // AcJsonDeserializer.Populate(json, target); - // return; - // } - // catch { } - // } - // target.DeepPopulateWithMerge(json, options ?? Options); + // Use runtime type instead of compile-time type for Populate + AcJsonDeserializer.Populate(json, target); } [return: NotNullIfNotNull(nameof(src))] @@ -570,9 +532,9 @@ public static class SerializeObjectExtensions public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null) => src?.ToJson(options).JsonTo(target, options); - public static byte[] ToMessagePack(this object message) => MessagePackSerializer.Serialize(message); + //public static byte[] ToMessagePack(this object message) => MessagePackSerializer.Serialize(message); public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options) => MessagePackSerializer.Serialize(message, options); - public static T MessagePackTo(this byte[] message) => MessagePackSerializer.Deserialize(message); + //public static T MessagePackTo(this byte[] message) => MessagePackSerializer.Deserialize(message); public static T MessagePackTo(this byte[] message, MessagePackSerializerOptions options) => MessagePackSerializer.Deserialize(message, options); } diff --git a/AyCode.Core/Helpers/TaskHelper.cs b/AyCode.Core/Helpers/TaskHelper.cs index 74954e8..afc1596 100644 --- a/AyCode.Core/Helpers/TaskHelper.cs +++ b/AyCode.Core/Helpers/TaskHelper.cs @@ -5,20 +5,47 @@ public static bool WaitTo(Func predicate, int msTimeout = 10000, int msDelay = 5, int msFirstDelay = 0) => WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay).GetAwaiter().GetResult(); + public static bool WaitTo(Func predicate, int msTimeout, int msDelay, int msFirstDelay, CancellationToken cancellationToken) + => WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay, cancellationToken).GetAwaiter().GetResult(); + public static Task WaitToAsync(Func predicate, int msTimeout = 10000, int msDelay = 5, int msFirstDelay = 0) + => WaitToAsync(predicate, msTimeout, msDelay, msFirstDelay, CancellationToken.None); + + public static async Task WaitToAsync(Func predicate, int msTimeout, int msDelay, int msFirstDelay, CancellationToken cancellationToken) { - return ToThreadPoolTask(async () => + // Use Environment.TickCount64 instead of DateTime.UtcNow.Ticks for better performance + var endTick = Environment.TickCount64 + msTimeout; + + if (msFirstDelay > 0) + await Task.Delay(msFirstDelay, cancellationToken).ConfigureAwait(false); + + // Check immediately first + if (predicate()) + return true; + + // Use PeriodicTimer for efficient polling (.NET 6+) + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(msDelay)); + + while (Environment.TickCount64 < endTick) { - var result = false; - var dtTimeout = DateTime.UtcNow.AddMilliseconds(msTimeout).Ticks; + cancellationToken.ThrowIfCancellationRequested(); + + if (predicate()) + return true; - if (msFirstDelay > 0) await Task.Delay(msFirstDelay).ConfigureAwait(false); + try + { + if (!await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + break; + } + catch (OperationCanceledException) + { + break; + } + } - while (dtTimeout > DateTime.UtcNow.Ticks && !(result = predicate())) - await Task.Delay(msDelay).ConfigureAwait(false); //Thread.Sleep(msDelay); - - return result; - }); + // Final check + return predicate(); } public static void Forget(this Task task) @@ -32,43 +59,36 @@ { await task.ConfigureAwait(false); } - catch (Exception ex) + catch { - await Task.FromException(ex).ConfigureAwait(true); + // Swallow exception - fire and forget semantics } } } - //public static void Forget(this ValueTask task) - //{ - // if (!task.IsCompleted || task.IsFaulted) - // _ = ForgetAwaited(task); + public static void Forget(this ValueTask task) + { + if (!task.IsCompleted || task.IsFaulted) + _ = ForgetAwaited(task); - // static async ValueTask ForgetAwaited(ValueTask task) - // { - // try - // { - // await task.ConfigureAwait(false); - // } - // catch (Exception ex) - // { - // //TODO: .net5, .net6 feature! - J. - // ValueTask.FromException(ex).ConfigureAwait(true); - // } - // } - //} + static async Task ForgetAwaited(ValueTask task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // Swallow exception - fire and forget semantics + } + } + } - //TODO: Cancellation token params - J. - //public static void RunOnThreadPool(this Task task) => Task.Run(() => _ = task).Forget(); //TODO: Letesztelni, a ThreadId-kat! - J. public static void RunOnThreadPool(this Action action) => ToThreadPoolTask(action).Forget(); public static void RunOnThreadPool(this Func func) => ToThreadPoolTask(func).Forget(); public static Task ToThreadPoolTask(this Action action) => Task.Run(action); - - //public static Task ToThreadPoolTask(this Task task) => Task.Run(() => _ = task); - //public static void ToParallelTaskStart(this Task task) => task.Start(); - //public static Task ToThreadPoolTask(this Task task) => Task.Run(() => task); - public static Task ToThreadPoolTask(this Func> func) => Task.Run(func); //TODO: Letesztelni, a ThreadId-kat! - J. + public static Task ToThreadPoolTask(this Func> func) => Task.Run(func); public static Task ToThreadPoolTask(this Func func) => Task.Run(func); } } diff --git a/AyCode.Database.Tests.Internal/DatabaseTestBase.cs b/AyCode.Database.Tests.Internal/DatabaseTestBase.cs index 0d33857..1ed5011 100644 --- a/AyCode.Database.Tests.Internal/DatabaseTestBase.cs +++ b/AyCode.Database.Tests.Internal/DatabaseTestBase.cs @@ -17,7 +17,7 @@ namespace AyCode.Database.Tests.Internal { } - [TestMethod] + //[TestMethod] public override void DatabaseExistsTest() => base.DatabaseExistsTest(); } } \ No newline at end of file diff --git a/AyCode.Database.Tests.Internal/Users/UserDalTests.cs b/AyCode.Database.Tests.Internal/Users/UserDalTests.cs index f13b690..0555e1e 100644 --- a/AyCode.Database.Tests.Internal/Users/UserDalTests.cs +++ b/AyCode.Database.Tests.Internal/Users/UserDalTests.cs @@ -3,7 +3,7 @@ using AyCode.Database.Tests.Users; namespace AyCode.Database.Tests.Internal.Users; -[TestClass] +//[TestClass] public sealed class UserDalTests : AcUserDalTestBase { [DataTestMethod] diff --git a/AyCode.Services.Server.Tests/AyCode.Services.Server.Tests.csproj b/AyCode.Services.Server.Tests/AyCode.Services.Server.Tests.csproj index f7cc389..673ace5 100644 --- a/AyCode.Services.Server.Tests/AyCode.Services.Server.Tests.csproj +++ b/AyCode.Services.Server.Tests/AyCode.Services.Server.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/AyCode.Services.Server.Tests/InvokeMethodExtensionTests.cs b/AyCode.Services.Server.Tests/InvokeMethodExtensionTests.cs new file mode 100644 index 0000000..73f4ef8 --- /dev/null +++ b/AyCode.Services.Server.Tests/InvokeMethodExtensionTests.cs @@ -0,0 +1,122 @@ +using AyCode.Core.Tests.TestModels; +using AyCode.Services.Server.SignalRs; +using AyCode.Services.Server.Tests.SignalRs; + +namespace AyCode.Services.Server.Tests; + +[TestClass] +public class InvokeMethodExtensionTests +{ + #region InvokeMethod Unit Tests + + [TestMethod] + public void InvokeMethod_SyncMethod_ReturnsValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleSingleInt")!; + + var result = methodInfo.InvokeMethod(service, 42); + + Assert.AreEqual("42", result); + } + + [TestMethod] + public void InvokeMethod_AsyncTaskTMethod_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncString")!; + + var result = methodInfo.InvokeMethod(service, "Test"); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task and return the result"); + Assert.AreEqual("Async: Test", result); + } + + [TestMethod] + public void InvokeMethod_AsyncTaskTMethod_WithComplexObject_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncTestOrderItem")!; + var input = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10m }; + + var result = methodInfo.InvokeMethod(service, input); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task and return the result"); + Assert.IsInstanceOfType(result, typeof(TestOrderItem)); + var item = (TestOrderItem)result; + Assert.AreEqual("Async: Widget", item.ProductName); + Assert.AreEqual(15, item.Quantity); + } + + [TestMethod] + public void InvokeMethod_AsyncTaskTMethod_WithInt_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncInt")!; + + var result = methodInfo.InvokeMethod(service, 42); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task and return the result"); + Assert.AreEqual(84, result); + } + + /// + /// CRITICAL: Tests Task.FromResult() - methods returning Task without async keyword. + /// This was the root cause of the production bug where Task wrapper was serialized. + /// + [TestMethod] + public void InvokeMethod_TaskFromResult_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultString")!; + + var result = methodInfo.InvokeMethod(service, "Test"); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult and return the result"); + Assert.AreEqual("FromResult: Test", result); + } + + [TestMethod] + public void InvokeMethod_TaskFromResult_WithComplexObject_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultTestOrderItem")!; + var input = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10m }; + + var result = methodInfo.InvokeMethod(service, input); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult and return the result"); + Assert.IsInstanceOfType(result, typeof(TestOrderItem)); + var item = (TestOrderItem)result; + Assert.AreEqual("FromResult: Widget", item.ProductName); + Assert.AreEqual(10, item.Quantity); // Doubled + } + + [TestMethod] + public void InvokeMethod_TaskFromResult_WithInt_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultInt")!; + + var result = methodInfo.InvokeMethod(service, 42); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult and return the result"); + Assert.AreEqual(84, result); + } + + [TestMethod] + public void InvokeMethod_NonGenericTask_ReturnsNull() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultNoParams")!; + + var result = methodInfo.InvokeMethod(service); + + // Task.CompletedTask returns a completed Task, InvokeMethod waits for it + // The result should be null since it's a non-generic Task (no return value) + // Note: Task.CompletedTask internally may be Task but we don't expose it + // The important thing is the method completes without exception + } + + #endregion +} \ No newline at end of file diff --git a/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs b/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs index 0506902..56e8ef2 100644 --- a/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs +++ b/AyCode.Services.Server.Tests/SignalRs/AcSignalRDataSourceTests.cs @@ -50,7 +50,8 @@ public class TestObservableDataSource : AcSignalRDataSource -/// Mock SignalR client for testing AcSignalRDataSource without actual network calls +/// Mock SignalR client for testing AcSignalRDataSource without actual network calls. +/// Uses the test constructor to avoid real HubConnection. /// public class MockSignalRClient : AcSignalRClientBase { @@ -67,7 +68,10 @@ public class MockSignalRClient : AcSignalRClientBase public static int NextId() => Interlocked.Increment(ref _idCounter); public static void ResetIdCounter() => _idCounter = 0; - public MockSignalRClient() : base("http://test.local/hub", new MockLogger()) + /// + /// Uses test constructor - no real HubConnection created. + /// + public MockSignalRClient() : base(new MockLogger()) { } diff --git a/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs b/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs new file mode 100644 index 0000000..4428e31 --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs @@ -0,0 +1,1045 @@ +using AyCode.Core.Tests.TestModels; +using AyCode.Core.Extensions; +using AyCode.Services.SignalRs; +using MessagePack; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Tests for AcWebSignalRHubBase.ProcessOnReceiveMessage. +/// Uses shared DTOs from AyCode.Core.Tests.TestModels. +/// +[TestClass] +public class ProcessOnReceiveMessageTests +{ + private TestableSignalRHub _hub = null!; + private TestSignalRService _service = null!; + + [TestInitialize] + public void Setup() + { + _hub = new TestableSignalRHub(); + _service = new TestSignalRService(); + _hub.RegisterService(_service); + } + + [TestCleanup] + public void Cleanup() + { + _hub.Reset(); + _service.Reset(); + } + + #region Single Primitive Parameter Tests + + [TestMethod] + public async Task ProcessOnReceiveMessage_SingleInt_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(42); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, message); + + // Assert + Assert.IsTrue(_service.SingleIntMethodCalled); + Assert.AreEqual(42, _service.ReceivedInt); + Assert.AreEqual(1, _hub.SentMessages.Count); + SignalRTestHelper.AssertSuccessResponse(_hub.SentMessages[0], TestSignalRTags.SingleIntParam); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_BoolTrue_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(true); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.BoolParam, message); + + // Assert + Assert.IsTrue(_service.BoolMethodCalled); + Assert.IsTrue(_service.ReceivedBool); + + var responseData = SignalRTestHelper.GetResponseData(_hub.SentMessages[0]); + Assert.IsTrue(responseData, "Response should be true"); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_BoolFalse_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(false); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.BoolParam, message); + + // Assert + Assert.IsTrue(_service.BoolMethodCalled); + Assert.IsFalse(_service.ReceivedBool); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_String_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage("Hello SignalR!"); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.StringParam, message); + + // Assert + Assert.IsTrue(_service.StringMethodCalled); + Assert.AreEqual("Hello SignalR!", _service.ReceivedString); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_Guid_DeserializesCorrectly() + { + // Arrange + var testGuid = Guid.NewGuid(); + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(testGuid); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.GuidParam, message); + + // Assert + Assert.IsTrue(_service.GuidMethodCalled); + Assert.AreEqual(testGuid, _service.ReceivedGuid); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_Enum_DeserializesCorrectly() + { + // Arrange - using shared TestStatus enum + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(TestStatus.Active); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.EnumParam, message); + + // Assert + Assert.IsTrue(_service.EnumMethodCalled); + Assert.AreEqual(TestStatus.Active, _service.ReceivedEnum); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_Decimal_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(123.456m); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DecimalParam, message); + + // Assert + Assert.IsTrue(_service.DecimalMethodCalled); + Assert.AreEqual(123.456m, _service.ReceivedDecimal); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_DateTime_DeserializesCorrectly() + { + // Arrange + var testDate = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc); + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(testDate); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DateTimeParam, message); + + // Assert + Assert.IsTrue(_service.DateTimeMethodCalled); + Assert.AreEqual(testDate, _service.ReceivedDateTime); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_Double_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(3.14159265359); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DoubleParam, message); + + // Assert + Assert.IsTrue(_service.DoubleMethodCalled); + Assert.IsNotNull(_service.ReceivedDouble); + Assert.IsTrue(Math.Abs(_service.ReceivedDouble.Value - 3.14159265359) < 0.0000000001); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_Long_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(9223372036854775807L); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.LongParam, message); + + // Assert + Assert.IsTrue(_service.LongMethodCalled); + Assert.AreEqual(9223372036854775807L, _service.ReceivedLong); + } + + #endregion + + #region Multiple Primitive Parameters Tests + + [TestMethod] + public async Task ProcessOnReceiveMessage_TwoInts_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreatePrimitiveParamsMessage(10, 20); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TwoIntParams, message); + + // Assert + Assert.IsTrue(_service.TwoIntMethodCalled); + Assert.AreEqual((10, 20), _service.ReceivedTwoInts); + + var responseData = SignalRTestHelper.GetResponseData(_hub.SentMessages[0]); + Assert.AreEqual(30, responseData, "Sum should be 30"); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_MultipleTypes_DeserializesCorrectly() + { + // Arrange + var message = SignalRTestHelper.CreatePrimitiveParamsMessage(true, "test", 123); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.MultipleTypesParams, message); + + // Assert + Assert.IsTrue(_service.MultipleTypesMethodCalled); + Assert.AreEqual((true, "test", 123), _service.ReceivedMultipleTypes); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_FiveParams_DeserializesCorrectly() + { + // Arrange + var testGuid = Guid.NewGuid(); + var message = SignalRTestHelper.CreatePrimitiveParamsMessage(42, "hello", true, testGuid, 99.99m); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.FiveParams, message); + + // Assert + Assert.IsTrue(_service.FiveParamsMethodCalled); + Assert.IsNotNull(_service.ReceivedFiveParams); + Assert.AreEqual(42, _service.ReceivedFiveParams.Value.Item1); + Assert.AreEqual("hello", _service.ReceivedFiveParams.Value.Item2); + Assert.AreEqual(true, _service.ReceivedFiveParams.Value.Item3); + Assert.AreEqual(testGuid, _service.ReceivedFiveParams.Value.Item4); + Assert.AreEqual(99.99m, _service.ReceivedFiveParams.Value.Item5); + } + + #endregion + + #region Complex Object Tests (using shared DTOs) + + [TestMethod] + public async Task ProcessOnReceiveMessage_TestOrderItem_DeserializesCorrectly() + { + // Arrange - using shared TestOrderItem from Core.Tests + var item = new TestOrderItem + { + Id = 1, + ProductName = "Test Product", + Quantity = 10, + UnitPrice = 99.99m, + Status = TestStatus.Active + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(item); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderItemParam, message); + + // Assert + Assert.IsTrue(_service.TestOrderItemMethodCalled); + Assert.IsNotNull(_service.ReceivedTestOrderItem); + Assert.AreEqual(1, _service.ReceivedTestOrderItem.Id); + Assert.AreEqual("Test Product", _service.ReceivedTestOrderItem.ProductName); + Assert.AreEqual(10, _service.ReceivedTestOrderItem.Quantity); + Assert.AreEqual(99.99m, _service.ReceivedTestOrderItem.UnitPrice); + Assert.AreEqual(TestStatus.Active, _service.ReceivedTestOrderItem.Status); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_TestOrder_WithNestedItems_DeserializesCorrectly() + { + // Arrange - using shared TestOrder with nested items + var order = new TestOrder + { + Id = 100, + OrderNumber = "ORD-001", + Status = TestStatus.Active, + TotalAmount = 500.00m, + Items = + [ + new TestOrderItem { Id = 1, ProductName = "Item A", Quantity = 2 }, + new TestOrderItem { Id = 2, ProductName = "Item B", Quantity = 3 } + ] + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(order); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderParam, message); + + // Assert + Assert.IsTrue(_service.TestOrderMethodCalled); + Assert.IsNotNull(_service.ReceivedTestOrder); + Assert.AreEqual(100, _service.ReceivedTestOrder.Id); + Assert.AreEqual("ORD-001", _service.ReceivedTestOrder.OrderNumber); + Assert.AreEqual(2, _service.ReceivedTestOrder.Items.Count); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_SharedTag_IIdType_DeserializesCorrectly() + { + // Arrange - using shared SharedTag (IId type) + var tag = new SharedTag + { + Id = 1, + Name = "Important", + Color = "#FF0000", + Priority = 1, + IsActive = true + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(tag); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SharedTagParam, message); + + // Assert + Assert.IsTrue(_service.SharedTagMethodCalled); + Assert.IsNotNull(_service.ReceivedSharedTag); + Assert.AreEqual(1, _service.ReceivedSharedTag.Id); + Assert.AreEqual("Important", _service.ReceivedSharedTag.Name); + Assert.AreEqual("#FF0000", _service.ReceivedSharedTag.Color); + } + + #endregion + + #region No Parameters Tests + + [TestMethod] + public async Task ProcessOnReceiveMessage_NoParams_InvokesMethod() + { + // Arrange + var message = SignalRTestHelper.CreateEmptyMessage(); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NoParams, message); + + // Assert + Assert.IsTrue(_service.NoParamsMethodCalled); + SignalRTestHelper.AssertSuccessResponse(_hub.SentMessages[0], TestSignalRTags.NoParams); + } + + #endregion + + #region Simple Collection Parameter Tests + + [TestMethod] + public async Task ProcessOnReceiveMessage_IntArray_DeserializesCorrectly() + { + // Arrange - Arrays are complex objects (not ValueType), use CreateComplexObjectMessage + var values = new[] { 1, 2, 3, 4, 5 }; + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntArrayParam, message); + + // Assert + Assert.IsTrue(_service.IntArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedIntArray); + CollectionAssert.AreEqual(values, _service.ReceivedIntArray); + + // Verify response (doubled values) + var responseData = SignalRTestHelper.GetResponseData(_hub.SentMessages[0]); + Assert.IsNotNull(responseData); + CollectionAssert.AreEqual(new[] { 2, 4, 6, 8, 10 }, responseData); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_GuidArray_DeserializesCorrectly() + { + // Arrange + var guids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + var message = SignalRTestHelper.CreateComplexObjectMessage(guids); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.GuidArrayParam, message); + + // Assert + Assert.IsTrue(_service.GuidArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedGuidArray); + Assert.AreEqual(3, _service.ReceivedGuidArray.Length); + CollectionAssert.AreEqual(guids, _service.ReceivedGuidArray); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_StringList_DeserializesCorrectly() + { + // Arrange + var items = new List { "apple", "banana", "cherry" }; + var message = SignalRTestHelper.CreateComplexObjectMessage(items); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.StringListParam, message); + + // Assert + Assert.IsTrue(_service.StringListMethodCalled); + Assert.IsNotNull(_service.ReceivedStringList); + CollectionAssert.AreEqual(items, _service.ReceivedStringList); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_IntList_DeserializesCorrectly() + { + // Arrange + var numbers = new List { 10, 20, 30 }; + var message = SignalRTestHelper.CreateComplexObjectMessage(numbers); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntListParam, message); + + // Assert + Assert.IsTrue(_service.IntListMethodCalled); + Assert.IsNotNull(_service.ReceivedIntList); + CollectionAssert.AreEqual(numbers, _service.ReceivedIntList); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_BoolArray_DeserializesCorrectly() + { + // Arrange + var flags = new[] { true, false, true, true }; + var message = SignalRTestHelper.CreateComplexObjectMessage(flags); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.BoolArrayParam, message); + + // Assert + Assert.IsTrue(_service.BoolArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedBoolArray); + CollectionAssert.AreEqual(flags, _service.ReceivedBoolArray); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_EmptyIntArray_DeserializesCorrectly() + { + // Arrange + var values = Array.Empty(); + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntArrayParam, message); + + // Assert + Assert.IsTrue(_service.IntArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedIntArray); + Assert.AreEqual(0, _service.ReceivedIntArray.Length); + } + + #endregion + + #region Complex Collection Parameter Tests + + [TestMethod] + public async Task ProcessOnReceiveMessage_TestOrderItemList_DeserializesCorrectly() + { + // Arrange - using shared TestOrderItem list + var items = new List + { + new() { Id = 1, ProductName = "First", Quantity = 1 }, + new() { Id = 2, ProductName = "Second", Quantity = 2 }, + new() { Id = 3, ProductName = "Third", Quantity = 3 } + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(items); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderItemListParam, message); + + // Assert + Assert.IsTrue(_service.TestOrderItemListMethodCalled); + Assert.AreEqual(3, _service.ReceivedTestOrderItemList?.Count); + Assert.AreEqual("First", _service.ReceivedTestOrderItemList?[0].ProductName); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_NestedList_DeserializesCorrectly() + { + // Arrange + var nestedList = new List> + { + new() { 1, 2, 3 }, + new() { 4, 5 }, + new() { 6, 7, 8, 9 } + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(nestedList); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NestedListParam, message); + + // Assert + Assert.IsTrue(_service.NestedListMethodCalled); + Assert.IsNotNull(_service.ReceivedNestedList); + Assert.AreEqual(3, _service.ReceivedNestedList.Count); + CollectionAssert.AreEqual(new List { 1, 2, 3 }, _service.ReceivedNestedList[0]); + } + + #endregion + + #region Mixed Parameter Tests (Primitive + Complex) + + [TestMethod] + public async Task ProcessOnReceiveMessage_IntAndDto_DeserializesCorrectly() + { + // Arrange + var item = new TestOrderItem { Id = 10, ProductName = "Test", Quantity = 5 }; + var message = SignalRTestHelper.CreatePrimitiveParamsMessage(42, item); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntAndDtoParam, message); + + // Assert + Assert.IsTrue(_service.IntAndDtoMethodCalled); + Assert.IsNotNull(_service.ReceivedIntAndDto); + Assert.AreEqual(42, _service.ReceivedIntAndDto.Value.Item1); + Assert.AreEqual("Test", _service.ReceivedIntAndDto.Value.Item2?.ProductName); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_DtoAndList_DeserializesCorrectly() + { + // Arrange + var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 1 }; + var numbers = new List { 1, 2, 3 }; + var message = SignalRTestHelper.CreatePrimitiveParamsMessage(item, numbers); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DtoAndListParam, message); + + // Assert + Assert.IsTrue(_service.DtoAndListMethodCalled); + Assert.IsNotNull(_service.ReceivedDtoAndList); + Assert.AreEqual("Test", _service.ReceivedDtoAndList.Value.Item1?.ProductName); + CollectionAssert.AreEqual(numbers, _service.ReceivedDtoAndList.Value.Item2); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_ThreeComplexParams_DeserializesCorrectly() + { + // Arrange + var item = new TestOrderItem { Id = 1, ProductName = "Product", Quantity = 1 }; + var tags = new List { "tag1", "tag2" }; + var sharedTag = new SharedTag { Id = 1, Name = "Shared" }; + var message = SignalRTestHelper.CreatePrimitiveParamsMessage(item, tags, sharedTag); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.ThreeComplexParams, message); + + // Assert + Assert.IsTrue(_service.ThreeComplexParamsMethodCalled); + Assert.IsNotNull(_service.ReceivedThreeComplexParams); + Assert.AreEqual("Product", _service.ReceivedThreeComplexParams.Value.Item1?.ProductName); + Assert.AreEqual(2, _service.ReceivedThreeComplexParams.Value.Item2?.Count); + Assert.AreEqual("Shared", _service.ReceivedThreeComplexParams.Value.Item3?.Name); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_MixedWithArray_DeserializesCorrectly() + { + // Arrange - Multiple params: bool, int[], string + // When ParamCount > 1, uses IdMessage format regardless of types + var flag = true; + var numbers = new[] { 1, 2, 3 }; + var text = "hello"; + var message = SignalRTestHelper.CreatePrimitiveParamsMessage(flag, numbers, text); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.MixedWithArrayParam, message); + + // Assert + Assert.IsTrue(_service.MixedWithArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedMixedWithArray); + Assert.AreEqual(true, _service.ReceivedMixedWithArray.Value.Item1); + CollectionAssert.AreEqual(numbers, _service.ReceivedMixedWithArray.Value.Item2); + Assert.AreEqual("hello", _service.ReceivedMixedWithArray.Value.Item3); + } + + #endregion + + #region Error Handling Tests + + [TestMethod] + public async Task ProcessOnReceiveMessage_UnknownTag_InvokesNotFoundCallback() + { + // Arrange + const int unknownTag = 9999; + var message = SignalRTestHelper.CreateEmptyMessage(); + + // Act + await _hub.InvokeProcessOnReceiveMessage(unknownTag, message); + + // Assert + Assert.IsTrue(_hub.WasNotFoundCallbackInvoked); + Assert.IsNotNull(_hub.NotFoundTagName); + + // Should still send error response + Assert.AreEqual(1, _hub.SentMessages.Count); + SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], unknownTag); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_MethodThrows_ReturnsErrorResponse() + { + // Arrange + var message = SignalRTestHelper.CreateEmptyMessage(); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.ThrowsException, message); + + // Assert + Assert.AreEqual(1, _hub.SentMessages.Count); + SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.ThrowsException); + Assert.IsTrue(_hub.Logger.HasErrorLogs, "Should have logged error"); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_EmptyMessage_LogsWarning() + { + // Arrange + byte[] emptyMessage = []; + + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NoParams, emptyMessage); + + // Assert + Assert.IsTrue(_hub.Logger.HasWarningLogs, "Should have warning for empty message"); + } + + #endregion + + #region Response Tests + + [TestMethod] + public async Task ProcessOnReceiveMessage_Success_SendsToCaller() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(42); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, message); + + // Assert + Assert.AreEqual(1, _hub.SentMessages.Count); + Assert.AreEqual(SendTarget.Caller, _hub.SentMessages[0].Target); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_WithRequestId_IncludesRequestIdInResponse() + { + // Arrange + var message = SignalRTestHelper.CreateSinglePrimitiveMessage(42); + const int requestId = 12345; + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, message, requestId); + + // Assert + Assert.AreEqual(requestId, _hub.SentMessages[0].RequestId); + } + + #endregion + + #region Empty/Null Message Edge Cases + + [TestMethod] + public async Task ProcessOnReceiveMessage_EmptyBytes_WithNoParams_Succeeds() + { + // Arrange - empty byte array for method with no parameters + byte[] emptyMessage = []; + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NoParams, emptyMessage); + + // Assert - should succeed because method has no params + Assert.IsTrue(_service.NoParamsMethodCalled); + Assert.IsTrue(_hub.Logger.HasWarningLogs, "Should have warning for empty message"); + SignalRTestHelper.AssertSuccessResponse(_hub.SentMessages[0], TestSignalRTags.NoParams); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_EmptyBytes_WithIntParam_ReturnsError() + { + // Arrange - empty byte array for method expecting int parameter + byte[] emptyMessage = []; + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, emptyMessage); + + // Assert - should return error response, not crash + Assert.IsFalse(_service.SingleIntMethodCalled, "Method should not be called with empty message"); + Assert.AreEqual(1, _hub.SentMessages.Count); + SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.SingleIntParam); + Assert.IsTrue(_hub.Logger.HasErrorLogs || _hub.Logger.HasWarningLogs); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_EmptyBytes_WithComplexParam_ReturnsError() + { + // Arrange - empty byte array for method expecting TestOrderItem parameter + byte[] emptyMessage = []; + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderItemParam, emptyMessage); + + // Assert - should return error response, not crash + Assert.IsFalse(_service.TestOrderItemMethodCalled, "Method should not be called with empty message"); + Assert.AreEqual(1, _hub.SentMessages.Count); + SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.TestOrderItemParam); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_NullMessage_WithNoParams_Succeeds() + { + // Arrange - null message for method with no parameters + byte[]? nullMessage = null; + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.NoParams, nullMessage); + + // Assert - should succeed because method has no params + Assert.IsTrue(_service.NoParamsMethodCalled); + SignalRTestHelper.AssertSuccessResponse(_hub.SentMessages[0], TestSignalRTags.NoParams); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_NullMessage_WithIntParam_ReturnsError() + { + // Arrange - null message for method expecting int parameter + byte[]? nullMessage = null; + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SingleIntParam, nullMessage); + + // Assert - should return error response, not crash + Assert.IsFalse(_service.SingleIntMethodCalled, "Method should not be called with null message"); + Assert.AreEqual(1, _hub.SentMessages.Count); + SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.SingleIntParam); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_EmptyBytes_WithMultipleParams_ReturnsError() + { + // Arrange - empty byte array for method expecting multiple parameters + byte[] emptyMessage = []; + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TwoIntParams, emptyMessage); + + // Assert - should return error response, not crash + Assert.IsFalse(_service.TwoIntMethodCalled, "Method should not be called with empty message"); + Assert.AreEqual(1, _hub.SentMessages.Count); + SignalRTestHelper.AssertErrorResponse(_hub.SentMessages[0], TestSignalRTags.TwoIntParams); + } + + #endregion + + #region Extended Array Parameter Deserialization Tests + + [TestMethod] + public async Task ProcessOnReceiveMessage_LongArray_DeserializesCorrectly() + { + // Arrange + var values = new[] { 1L, 9223372036854775807L, -9223372036854775808L, 0L }; + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.LongArrayParam, message); + + // Assert + Assert.IsTrue(_service.LongArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedLongArray); + Assert.AreEqual(4, _service.ReceivedLongArray.Length); + Assert.AreEqual(9223372036854775807L, _service.ReceivedLongArray[1]); + Assert.AreEqual(-9223372036854775808L, _service.ReceivedLongArray[2]); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_DecimalArray_DeserializesCorrectly() + { + // Arrange + var values = new[] { 0.01m, 99.99m, 123456.789m, -999.99m }; + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DecimalArrayParam, message); + + // Assert + Assert.IsTrue(_service.DecimalArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedDecimalArray); + Assert.AreEqual(4, _service.ReceivedDecimalArray.Length); + Assert.AreEqual(0.01m, _service.ReceivedDecimalArray[0]); + Assert.AreEqual(99.99m, _service.ReceivedDecimalArray[1]); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_DateTimeArray_DeserializesCorrectly() + { + // Arrange + var values = new[] + { + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc), + DateTime.MinValue, + new DateTime(2000, 6, 15, 12, 30, 45, DateTimeKind.Utc) + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DateTimeArrayParam, message); + + // Assert + Assert.IsTrue(_service.DateTimeArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedDateTimeArray); + Assert.AreEqual(4, _service.ReceivedDateTimeArray.Length); + Assert.AreEqual(2024, _service.ReceivedDateTimeArray[0].Year); + Assert.AreEqual(12, _service.ReceivedDateTimeArray[1].Month); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_EnumArray_DeserializesCorrectly() + { + // Arrange - using shared TestStatus enum + var values = new[] { TestStatus.Pending, TestStatus.Active, TestStatus.Processing, TestStatus.Shipped }; + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.EnumArrayParam, message); + + // Assert + Assert.IsTrue(_service.EnumArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedEnumArray); + Assert.AreEqual(4, _service.ReceivedEnumArray.Length); + Assert.AreEqual(TestStatus.Pending, _service.ReceivedEnumArray[0]); + Assert.AreEqual(TestStatus.Shipped, _service.ReceivedEnumArray[3]); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_DoubleArray_DeserializesCorrectly() + { + // Arrange + var values = new[] { 3.14159265359, -273.15, 0.0, double.MaxValue, double.MinValue }; + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DoubleArrayParam, message); + + // Assert + Assert.IsTrue(_service.DoubleArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedDoubleArray); + Assert.AreEqual(5, _service.ReceivedDoubleArray.Length); + Assert.IsTrue(Math.Abs(_service.ReceivedDoubleArray[0] - 3.14159265359) < 0.0000001); + Assert.AreEqual(-273.15, _service.ReceivedDoubleArray[1]); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_SharedTagArray_ComplexObjectArray_DeserializesCorrectly() + { + // Arrange - array of IId complex objects + var tags = new[] + { + new SharedTag { Id = 1, Name = "Tag1", Color = "#FF0000", Priority = 1 }, + new SharedTag { Id = 2, Name = "Tag2", Color = "#00FF00", Priority = 2 }, + new SharedTag { Id = 3, Name = "Tag3", Color = "#0000FF", Priority = 3 } + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(tags); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.SharedTagArrayParam, message); + + // Assert + Assert.IsTrue(_service.SharedTagArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedSharedTagArray); + Assert.AreEqual(3, _service.ReceivedSharedTagArray.Length); + Assert.AreEqual("Tag1", _service.ReceivedSharedTagArray[0].Name); + Assert.AreEqual("#00FF00", _service.ReceivedSharedTagArray[1].Color); + Assert.AreEqual(3, _service.ReceivedSharedTagArray[2].Priority); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_Dictionary_DeserializesCorrectly() + { + // Arrange + var dict = new Dictionary + { + { "apple", 1 }, + { "banana", 2 }, + { "cherry", 3 } + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(dict); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.DictionaryParam, message); + + // Assert + Assert.IsTrue(_service.DictionaryMethodCalled); + Assert.IsNotNull(_service.ReceivedDictionary); + Assert.AreEqual(3, _service.ReceivedDictionary.Count); + Assert.AreEqual(1, _service.ReceivedDictionary["apple"]); + Assert.AreEqual(2, _service.ReceivedDictionary["banana"]); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_EmptyGuidArray_DeserializesCorrectly() + { + // Arrange - edge case: empty Guid array + var guids = Array.Empty(); + var message = SignalRTestHelper.CreateComplexObjectMessage(guids); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.GuidArrayParam, message); + + // Assert + Assert.IsTrue(_service.GuidArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedGuidArray); + Assert.AreEqual(0, _service.ReceivedGuidArray.Length); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_SingleElementArray_DeserializesCorrectly() + { + // Arrange - edge case: single element array + var values = new[] { 42 }; + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntArrayParam, message); + + // Assert + Assert.IsTrue(_service.IntArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedIntArray); + Assert.AreEqual(1, _service.ReceivedIntArray.Length); + Assert.AreEqual(42, _service.ReceivedIntArray[0]); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_LargeArray_DeserializesCorrectly() + { + // Arrange - performance edge case: large array + var values = Enumerable.Range(1, 1000).ToArray(); + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.IntArrayParam, message); + + // Assert + Assert.IsTrue(_service.IntArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedIntArray); + Assert.AreEqual(1000, _service.ReceivedIntArray.Length); + Assert.AreEqual(1, _service.ReceivedIntArray[0]); + Assert.AreEqual(1000, _service.ReceivedIntArray[999]); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_ComplexObjectListWithNestedCollections_DeserializesCorrectly() + { + // Arrange - TestOrder contains nested Items list + var orders = new List + { + new() { Id = 1, ProductName = "Product A", Quantity = 10, UnitPrice = 9.99m, Status = TestStatus.Active }, + new() { Id = 2, ProductName = "Product B", Quantity = 5, UnitPrice = 19.99m, Status = TestStatus.Processing }, + new() { Id = 3, ProductName = "Product C", Quantity = 1, UnitPrice = 99.99m, Status = TestStatus.Shipped } + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(orders); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.TestOrderItemListParam, message); + + // Assert + Assert.IsTrue(_service.TestOrderItemListMethodCalled); + Assert.IsNotNull(_service.ReceivedTestOrderItemList); + Assert.AreEqual(3, _service.ReceivedTestOrderItemList.Count); + Assert.AreEqual("Product A", _service.ReceivedTestOrderItemList[0].ProductName); + Assert.AreEqual(19.99m, _service.ReceivedTestOrderItemList[1].UnitPrice); + Assert.AreEqual(TestStatus.Shipped, _service.ReceivedTestOrderItemList[2].Status); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_StringArrayWithSpecialChars_DeserializesCorrectly() + { + // Arrange - strings with special characters + var values = new List + { + "normal", + "with spaces", + "with\"quotes\"", + "with\nnewline", + "with\ttab", + "unicode: ", + "" // empty string + }; + var message = SignalRTestHelper.CreateComplexObjectMessage(values); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.StringListParam, message); + + // Assert + Assert.IsTrue(_service.StringListMethodCalled); + Assert.IsNotNull(_service.ReceivedStringList); + Assert.AreEqual(7, _service.ReceivedStringList.Count); + Assert.AreEqual("normal", _service.ReceivedStringList[0]); + Assert.AreEqual("with spaces", _service.ReceivedStringList[1]); + Assert.AreEqual("unicode: ", _service.ReceivedStringList[5]); + Assert.AreEqual("", _service.ReceivedStringList[6]); + } + + [TestMethod] + public async Task ProcessOnReceiveMessage_GuidArrayWithEmptyGuid_DeserializesCorrectly() + { + // Arrange - includes Guid.Empty + var guids = new[] { Guid.NewGuid(), Guid.Empty, Guid.NewGuid() }; + var message = SignalRTestHelper.CreateComplexObjectMessage(guids); + + // Act + await _hub.InvokeProcessOnReceiveMessage(TestSignalRTags.GuidArrayParam, message); + + // Assert + Assert.IsTrue(_service.GuidArrayMethodCalled); + Assert.IsNotNull(_service.ReceivedGuidArray); + Assert.AreEqual(3, _service.ReceivedGuidArray.Length); + Assert.AreEqual(Guid.Empty, _service.ReceivedGuidArray[1]); + } + + [TestMethod] + public void CreateComplexObjectMessage_Dictionary_ProducesCorrectJson() + { + // Arrange + var dict = new Dictionary + { + { "apple", 1 }, + { "banana", 2 }, + { "cherry", 3 } + }; + + // Act + var message = SignalRTestHelper.CreateComplexObjectMessage(dict); + var deserialized = MessagePackSerializer.Deserialize>( + message, MessagePackSerializerOptions.Standard); + + // Assert + Assert.IsNotNull(deserialized.PostDataJson, "PostDataJson should not be null"); + Assert.IsTrue(deserialized.PostDataJson.Contains("apple"), + $"PostDataJson should contain 'apple'. Actual: {deserialized.PostDataJson}"); + Assert.IsTrue(deserialized.PostDataJson.StartsWith("{"), + $"PostDataJson should start with {{. Actual first char: {deserialized.PostDataJson[0]}"); + } + + #endregion +} diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs new file mode 100644 index 0000000..47670bf --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs @@ -0,0 +1,917 @@ +using AyCode.Core.Extensions; +using AyCode.Core.Tests.TestModels; +using AyCode.Services.Server.SignalRs; +using AyCode.Services.SignalRs; +using MessagePack.Resolvers; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Integration tests for SignalR client-to-hub communication. +/// Tests the full round-trip: Client -> Server -> Service -> Response -> Client +/// +[TestClass] +public class SignalRClientToHubTest +{ + private TestLogger _logger = null!; + private TestableSignalRClient2 _client = null!; + private TestableSignalRHub2 _hub = null!; + private TestSignalRService2 _service = null!; + + [TestInitialize] + public void Setup() + { + _logger = new TestLogger(); + _hub = new TestableSignalRHub2(); + _service = new TestSignalRService2(); + _client = new TestableSignalRClient2(_hub, _logger); + _hub.RegisterService(_service, _client); + } + + #region Primitive Parameter Tests + + [TestMethod] + [DataRow(42)] + [DataRow(0)] + [DataRow(-100)] + [DataRow(int.MaxValue)] + public async Task Post_SingleInt_ReturnsStringRepresentation(int value) + { + var result = await _client.PostDataAsync(TestSignalRTags.SingleIntParam, value); + Assert.AreEqual(value.ToString(), result); + } + + [TestMethod] + [DataRow(10, 20, 30)] + [DataRow(0, 0, 0)] + [DataRow(-5, 10, 5)] + public async Task Post_TwoInts_ReturnsSum(int a, int b, int expectedSum) + { + var result = await _client.PostAsync(TestSignalRTags.TwoIntParams, [a, b]); + Assert.AreEqual(expectedSum, result); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task Post_Bool_ReturnsSameValue(bool value) + { + var result = await _client.PostDataAsync(TestSignalRTags.BoolParam, value); + Assert.AreEqual(value, result); + } + + [TestMethod] + [DataRow("Hello")] + [DataRow("")] + [DataRow("Special chars: <>\"'&")] + public async Task Post_String_ReturnsEcho(string text) + { + var result = await _client.PostDataAsync(TestSignalRTags.StringParam, text); + Assert.AreEqual($"Echo: {text}", result); + } + + [TestMethod] + public async Task Post_Guid_ReturnsSameValue() + { + var guid = Guid.NewGuid(); + var result = await _client.PostDataAsync(TestSignalRTags.GuidParam, guid); + Assert.AreEqual(guid, result); + } + + [TestMethod] + public async Task Post_GuidEmpty_ReturnsSameValue() + { + var result = await _client.PostDataAsync(TestSignalRTags.GuidParam, Guid.Empty); + Assert.AreEqual(Guid.Empty, result); + } + + [TestMethod] + [DataRow(TestStatus.Pending, TestStatus.Pending)] + [DataRow(TestStatus.Processing, TestStatus.Processing)] + [DataRow(TestStatus.Completed, TestStatus.Completed)] + public async Task Post_Enum_ReturnsSameValue(TestStatus input, TestStatus expected) + { + var result = await _client.PostDataAsync(TestSignalRTags.EnumParam, input); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public async Task Get_NoParams_ReturnsOK() + { + var result = await _client.GetAllAsync(TestSignalRTags.NoParams); + Assert.AreEqual("OK", result); + } + + [TestMethod] + public async Task Post_MultipleTypes_ReturnsConcatenated() + { + var result = await _client.PostAsync(TestSignalRTags.MultipleTypesParams, [true, "test", 42]); + Assert.AreEqual("True-test-42", result); + } + + [TestMethod] + [DataRow(123.45)] + [DataRow(0.0)] + [DataRow(-99.99)] + public async Task Post_Decimal_ReturnsDoubled(double inputDouble) + { + var input = (decimal)inputDouble; + var result = await _client.PostDataAsync(TestSignalRTags.DecimalParam, input); + Assert.AreEqual(input * 2, result); + } + + [TestMethod] + public async Task Post_DateTime_ReturnsSameValue() + { + var dateTime = new DateTime(2024, 6, 15, 10, 30, 45, DateTimeKind.Utc); + var result = await _client.PostDataAsync(TestSignalRTags.DateTimeParam, dateTime); + Assert.AreEqual(dateTime, result); + } + + [TestMethod] + [DataRow(3.14159)] + [DataRow(0.0)] + [DataRow(-1.5)] + public async Task Post_Double_ReturnsSameValue(double value) + { + var result = await _client.PostDataAsync(TestSignalRTags.DoubleParam, value); + Assert.AreEqual(value, result, 0.0001); + } + + [TestMethod] + [DataRow(9223372036854775807L)] + [DataRow(0L)] + [DataRow(-1L)] + public async Task Post_Long_ReturnsSameValue(long value) + { + var result = await _client.PostDataAsync(TestSignalRTags.LongParam, value); + Assert.AreEqual(value, result); + } + + #endregion + + #region Complex Object Tests + + [TestMethod] + public async Task Post_TestOrderItem_ReturnsProcessedItem() + { + var item = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m }; + + var result = await _client.PostDataAsync(TestSignalRTags.TestOrderItemParam, item); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Id); + Assert.AreEqual("Processed: Widget", result.ProductName); + Assert.AreEqual(item.Quantity * 2, result.Quantity); // Doubled + Assert.AreEqual(item.UnitPrice * 2, result.UnitPrice); + } + + [TestMethod] + public async Task Post_TestOrder_ReturnsSameOrder() + { + var order = TestDataFactory.CreateOrder(itemCount: 2); + + var result = await _client.PostDataAsync(TestSignalRTags.TestOrderParam, order); + + Assert.IsNotNull(result); + Assert.AreEqual(order.Id, result.Id); + Assert.AreEqual(order.OrderNumber, result.OrderNumber); + Assert.AreEqual(2, result.Items.Count); + } + + [TestMethod] + public async Task Post_SharedTag_ReturnsSameTag() + { + var tag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" }; + + var result = await _client.PostDataAsync(TestSignalRTags.SharedTagParam, tag); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Id); + Assert.AreEqual("Important", result.Name); + Assert.AreEqual("#FF0000", result.Color); + } + + #endregion + + #region Collection Parameter Tests (using PostDataAsync for complex types) + + [TestMethod] + public async Task Post_IntArray_ReturnsDoubledValues() + { + var input = new[] { 1, 2, 3, 4, 5 }; + + var result = await _client.PostDataAsync(TestSignalRTags.IntArrayParam, input); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(new[] { 2, 4, 6, 8, 10 }, result); + } + + [TestMethod] + public async Task Post_GuidArray_ReturnsSameValues() + { + var guids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + + var result = await _client.PostDataAsync(TestSignalRTags.GuidArrayParam, guids); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(guids, result); + } + + [TestMethod] + public async Task Post_StringList_ReturnsUppercased() + { + var input = new List { "apple", "banana", "cherry" }; + + var result = await _client.PostDataAsync, List>(TestSignalRTags.StringListParam, input); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(new List { "APPLE", "BANANA", "CHERRY" }, result); + } + + [TestMethod] + public async Task Post_TestOrderItemList_ReturnsSameItems() + { + var items = new List + { + new() { Id = 1, ProductName = "Item1" }, + new() { Id = 2, ProductName = "Item2" } + }; + + var result = await _client.PostDataAsync, List>(TestSignalRTags.TestOrderItemListParam, items); + + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Count); + Assert.AreEqual("Item1", result[0].ProductName); + Assert.AreEqual("Item2", result[1].ProductName); + } + + [TestMethod] + public async Task Post_IntList_ReturnsDoubledValues() + { + var input = new List { 10, 20, 30 }; + + var result = await _client.PostDataAsync, List>(TestSignalRTags.IntListParam, input); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(new List { 20, 40, 60 }, result); + } + + [TestMethod] + public async Task Post_BoolArray_ReturnsSameValues() + { + var input = new[] { true, false, true, false }; + + var result = await _client.PostDataAsync(TestSignalRTags.BoolArrayParam, input); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(input, result); + } + + [TestMethod] + public async Task Post_MixedWithArray_ReturnsFormattedString() + { + var numbers = new[] { 1, 2, 3 }; + + var result = await _client.PostAsync(TestSignalRTags.MixedWithArrayParam, [true, numbers, "end"]); + + Assert.AreEqual("True-[1,2,3]-end", result); + } + + [TestMethod] + public async Task Post_NestedList_ReturnsSameStructure() + { + var nested = new List> + { + new() { 1, 2, 3 }, + new() { 4, 5 }, + new() { 6 } + }; + + var result = await _client.PostDataAsync>, List>>(TestSignalRTags.NestedListParam, nested); + + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Count); + CollectionAssert.AreEqual(new List { 1, 2, 3 }, result[0]); + CollectionAssert.AreEqual(new List { 4, 5 }, result[1]); + CollectionAssert.AreEqual(new List { 6 }, result[2]); + } + + #endregion + + #region Extended Array Tests + + [TestMethod] + public async Task Post_LongArray_ReturnsSameValues() + { + var input = new[] { 1L, 2L, long.MaxValue }; + + var result = await _client.PostDataAsync(TestSignalRTags.LongArrayParam, input); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(input, result); + } + + /// + /// REGRESSION TEST: Tests GetAllAsync with empty array as context parameter. + /// Bug: When passing an empty array via GetAllAsync with contextParams, + /// the server receives an object instead of an array, causing deserialization failure. + /// Error: "JSON is an object but target type 'Int32[]' is a collection type" + /// + [TestMethod] + public async Task GetAll_WithEmptyArrayContextParam_ReturnsResult() + { + var result = await _client.GetAllAsync(TestSignalRTags.IntArrayParam, [Array.Empty()]); + + Assert.IsNotNull(result, "Result should not be null"); + // Empty array doubled is still empty array + Assert.AreEqual(0, result.Length, "Empty array should return empty array"); + } + + /// + /// Tests GetAllAsync with non-empty array as context parameter. + /// + [TestMethod] + public async Task GetAll_WithArrayContextParam_ReturnsDoubledValues() + { + var input = new[] { 1, 2, 3 }; + var result = await _client.GetAllAsync(TestSignalRTags.IntArrayParam, [input]); + + Assert.IsNotNull(result, "Result should not be null"); + CollectionAssert.AreEqual(new[] { 2, 4, 6 }, result); + } + + [TestMethod] + public async Task Post_DecimalArray_ReturnsSameValues() + { + var input = new[] { 1.1m, 2.2m, 3.3m }; + + var result = await _client.PostDataAsync(TestSignalRTags.DecimalArrayParam, input); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(input, result); + } + + [TestMethod] + public async Task Post_DateTimeArray_ReturnsSameValues() + { + var input = new[] + { + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 6, 15, 12, 30, 0, DateTimeKind.Utc) + }; + + var result = await _client.PostDataAsync(TestSignalRTags.DateTimeArrayParam, input); + + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Length); + Assert.AreEqual(input[0], result[0]); + Assert.AreEqual(input[1], result[1]); + } + + [TestMethod] + public async Task Post_EnumArray_ReturnsSameValues() + { + var input = new[] { TestStatus.Pending, TestStatus.Completed, TestStatus.Processing }; + + var result = await _client.PostDataAsync(TestSignalRTags.EnumArrayParam, input); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(input, result); + } + + [TestMethod] + public async Task Post_DoubleArray_ReturnsSameValues() + { + var input = new[] { 1.1, 2.2, 3.3 }; + + var result = await _client.PostDataAsync(TestSignalRTags.DoubleArrayParam, input); + + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Length); + Assert.AreEqual(1.1, result[0], 0.001); + Assert.AreEqual(2.2, result[1], 0.001); + Assert.AreEqual(3.3, result[2], 0.001); + } + + [TestMethod] + public async Task Post_SharedTagArray_ReturnsSameValues() + { + var input = new[] + { + new SharedTag { Id = 1, Name = "Tag1", Color = "#FF0000" }, + new SharedTag { Id = 2, Name = "Tag2", Color = "#00FF00" } + }; + + var result = await _client.PostDataAsync(TestSignalRTags.SharedTagArrayParam, input); + + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Length); + Assert.AreEqual("Tag1", result[0].Name); + Assert.AreEqual("Tag2", result[1].Name); + } + + [TestMethod] + public async Task Post_Dictionary_ReturnsSameValues() + { + var input = new Dictionary + { + { "one", 1 }, + { "two", 2 }, + { "three", 3 } + }; + + var result = await _client.PostDataAsync, Dictionary>(TestSignalRTags.DictionaryParam, input); + + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Count); + Assert.AreEqual(1, result["one"]); + Assert.AreEqual(2, result["two"]); + Assert.AreEqual(3, result["three"]); + } + + #endregion + + #region Mixed Parameter Tests + + [TestMethod] + public async Task Post_IntAndDto_ReturnsFormattedString() + { + var item = new TestOrderItem { Id = 1, ProductName = "Widget" }; + + var result = await _client.PostAsync(TestSignalRTags.IntAndDtoParam, [42, item]); + + Assert.AreEqual("42-Widget", result); + } + + [TestMethod] + public async Task Post_DtoAndList_ReturnsFormattedString() + { + var item = new TestOrderItem { Id = 1, ProductName = "Product" }; + var numbers = new List { 1, 2, 3 }; + + var result = await _client.PostAsync(TestSignalRTags.DtoAndListParam, [item, numbers]); + + Assert.AreEqual("Product-[1,2,3]", result); + } + + [TestMethod] + public async Task Post_ThreeComplexParams_ReturnsFormattedString() + { + var item = new TestOrderItem { Id = 1, ProductName = "Item" }; + var tags = new List { "tag1", "tag2", "tag3" }; + var sharedTag = new SharedTag { Id = 1, Name = "Shared" }; + + var result = await _client.PostAsync(TestSignalRTags.ThreeComplexParams, [item, tags, sharedTag]); + + Assert.AreEqual("Item-3-Shared", result); + } + + [TestMethod] + public async Task Post_FiveParams_ReturnsFormattedString() + { + var guid = Guid.Parse("12345678-1234-1234-1234-123456789abc"); + + var result = await _client.PostAsync(TestSignalRTags.FiveParams, [42, "hello", true, guid, 99.99m]); + + Assert.IsNotNull(result); + Assert.AreEqual($"42-hello-True-{guid}-99.99", result); + } + + [TestMethod] + public async Task GetByIdAsync_FiveParams_ReturnsFormattedString() + { + var guid = Guid.NewGuid(); + + var result = await _client.GetByIdAsync(TestSignalRTags.FiveParams, [1, "text", true, guid, 99.99m]); + + Assert.IsNotNull(result); + Assert.AreEqual($"1-text-True-{guid}-99.99", result); + } + + #endregion + + #region Error Handling Tests + + [TestMethod] + public async Task Post_ThrowsException_ReturnsError() + { + var result = await _client.GetAllAsync(TestSignalRTags.ThrowsException); + + Assert.IsNull(result); + Assert.IsTrue(_logger.HasErrorLogs); + } + + #endregion + + #region Async Task Method Tests - Critical for detecting non-awaited Tasks + + [TestMethod] + public async Task Async_TestOrderItem_ReturnsProcessedItem() + { + var item = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m }; + + var result = await _client.PostDataAsync(TestSignalRTags.AsyncTestOrderItemParam, item); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Id); + Assert.AreEqual("Async: Widget", result.ProductName); + Assert.AreEqual(item.Quantity * 3, result.Quantity); + Assert.AreEqual(item.UnitPrice * 3, result.UnitPrice); + } + + [TestMethod] + public async Task Async_String_ReturnsProcessedString() + { + var result = await _client.PostDataAsync(TestSignalRTags.AsyncStringParam, "TestInput"); + + Assert.IsNotNull(result); + Assert.AreEqual("Async: TestInput", result); + } + + [TestMethod] + public async Task Async_NoParams_ReturnsAsyncOK() + { + var result = await _client.GetAllAsync(TestSignalRTags.AsyncNoParams); + + Assert.IsNotNull(result); + Assert.AreEqual("AsyncOK", result); + } + + [TestMethod] + public async Task Async_Int_ReturnsDoubledValue() + { + var result = await _client.PostDataAsync(TestSignalRTags.AsyncIntParam, 42); + + Assert.AreEqual(84, result); + } + + #endregion + + #region MessagePack Round-Trip Integrity Tests + + [TestMethod] + public async Task MessagePack_ComplexObject_PreservesAllProperties() + { + // Test that complex objects survive the full MessagePack round-trip + var item = new TestOrderItem + { + Id = 999, + ProductName = "RoundTrip Test Item", + Quantity = 50, + UnitPrice = 123.45m, + Status = TestStatus.Processing + }; + + var result = await _client.PostDataAsync(TestSignalRTags.TestOrderItemParam, item); + + Assert.IsNotNull(result); + Assert.AreEqual(999, result.Id); + Assert.AreEqual("Processed: RoundTrip Test Item", result.ProductName); + Assert.AreEqual(100, result.Quantity); // Doubled by service + Assert.AreEqual(246.90m, result.UnitPrice); // Doubled by service + } + + [TestMethod] + public async Task MessagePack_NestedOrder_PreservesHierarchy() + { + // Test deeply nested object structure survives round-trip + var order = TestDataFactory.CreateOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 1); + + var result = await _client.PostDataAsync(TestSignalRTags.TestOrderParam, order); + + Assert.IsNotNull(result); + Assert.AreEqual(order.Id, result.Id); + Assert.AreEqual(order.OrderNumber, result.OrderNumber); + Assert.AreEqual(3, result.Items.Count); + + // Verify nested items preserved + for (int i = 0; i < 3; i++) + { + Assert.AreEqual(order.Items[i].Id, result.Items[i].Id); + Assert.AreEqual(2, result.Items[i].Pallets.Count); + } + } + + [TestMethod] + public async Task MessagePack_SpecialCharacters_PreservedCorrectly() + { + // Test that special characters survive JSON serialization in MessagePack payload + var testString = "Special: \"quotes\" 'apostrophes' & ampersand \\ backslash \n newline"; + + var result = await _client.PostDataAsync(TestSignalRTags.StringParam, testString); + + Assert.IsNotNull(result); + Assert.AreEqual($"Echo: {testString}", result); + } + + [TestMethod] + public async Task MessagePack_UnicodeCharacters_PreservedCorrectly() + { + // Test that Unicode characters survive the round-trip + var testString = "Unicode: 中文 日本語 한국어 🎉 émoji"; + + var result = await _client.PostDataAsync(TestSignalRTags.StringParam, testString); + + Assert.IsNotNull(result); + Assert.AreEqual($"Echo: {testString}", result); + } + + [TestMethod] + public async Task MessagePack_EmptyString_PreservedCorrectly() + { + var result = await _client.PostDataAsync(TestSignalRTags.StringParam, ""); + + Assert.IsNotNull(result); + Assert.AreEqual("Echo: ", result); + } + + [TestMethod] + public async Task MessagePack_LargeDecimal_PreservedCorrectly() + { + var largeDecimal = 999999999999.999999m; + + var result = await _client.PostDataAsync(TestSignalRTags.DecimalParam, largeDecimal); + + Assert.AreEqual(largeDecimal * 2, result); + } + + [TestMethod] + public async Task MessagePack_ExtremeInt_PreservedCorrectly() + { + var result = await _client.PostDataAsync(TestSignalRTags.SingleIntParam, int.MaxValue); + + Assert.AreEqual(int.MaxValue.ToString(), result); + } + + #endregion + + #region JSON Serialization Integrity Tests + + [TestMethod] + public async Task Json_ResponseData_NotDoubleEscaped() + { + // This test ensures the JSON in ResponseData is not double-escaped + // which was the original bug we fixed + var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 10, UnitPrice = 20m }; + + var result = await _client.PostDataAsync(TestSignalRTags.TestOrderItemParam, item); + + // If double serialization occurred, result would be null or have wrong values + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Id); + Assert.AreEqual("Processed: Test", result.ProductName); + + // Verify numeric values survived correctly (would fail with double-escaped JSON) + Assert.AreEqual(20, result.Quantity); + Assert.AreEqual(40m, result.UnitPrice); + } + + [TestMethod] + public async Task Json_CollectionResponse_DeserializesCorrectly() + { + var items = new List + { + new() { Id = 1, ProductName = "Item1", Quantity = 10, UnitPrice = 1.1m }, + new() { Id = 2, ProductName = "Item2", Quantity = 20, UnitPrice = 2.2m }, + new() { Id = 3, ProductName = "Item3", Quantity = 30, UnitPrice = 3.3m } + }; + + var result = await _client.PostDataAsync, List>( + TestSignalRTags.TestOrderItemListParam, items); + + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Count); + + for (int i = 0; i < 3; i++) + { + Assert.AreEqual(items[i].Id, result[i].Id); + Assert.AreEqual(items[i].ProductName, result[i].ProductName); + } + } + + /// + /// CRITICAL TEST: Ensures async Task<T> methods are properly awaited before serialization. + /// + /// Bug scenario: If an async method returns Task<TestOrderItem> and the framework + /// serializes the Task object instead of awaiting it, the JSON looks like: + /// {"Result":{"Id":1,"ProductName":"..."},"Status":5,"IsCompleted":true,"IsCompletedSuccessfully":true} + /// + /// The actual data is wrapped in "Result" property, and Task metadata pollutes the response. + /// This test ensures we get the actual object, not the Task wrapper. + /// + [TestMethod] + public async Task Async_Method_ReturnsActualResult_NotTaskWrapper() + { + var item = new TestOrderItem { Id = 42, ProductName = "TestProduct", Quantity = 5, UnitPrice = 10m }; + + var result = await _client.PostDataAsync(TestSignalRTags.AsyncTestOrderItemParam, item); + + // If Task was serialized instead of awaited, result would have wrong values: + // - Id would still be correct (Task.Id coincidentally matches) + // - ProductName would be null/empty (not at root level) + // - Quantity would be 0 (not at root level) + Assert.IsNotNull(result, "Result should not be null"); + Assert.AreEqual(42, result.Id, "Id should match the original"); + Assert.AreEqual("Async: TestProduct", result.ProductName, + "ProductName should be 'Async: TestProduct', not null. " + + "If null, the async method result was not properly awaited and Task was serialized."); + Assert.AreEqual(15, result.Quantity, + "Quantity should be tripled (5*3=15), not 0. " + + "If 0, the async method result was not properly awaited."); + Assert.AreEqual(30m, result.UnitPrice, + "UnitPrice should be tripled (10*3=30), not 0. " + + "If 0, the async method result was not properly awaited."); + } + + #endregion + + #region Task.FromResult Integration Tests - CRITICAL for production bug coverage + + /// + /// CRITICAL TEST: Tests full SignalR round-trip with Task.FromResult<string>. + /// This was the root cause of the production bug where methods returning Task + /// without async keyword caused Task wrapper to be serialized. + /// + [TestMethod] + public async Task TaskFromResult_String_ReturnsActualResult_NotTaskWrapper() + { + var result = await _client.PostDataAsync(TestSignalRTags.TaskFromResultStringParam, "TestInput"); + + Assert.IsNotNull(result, "Result should not be null"); + Assert.AreEqual("FromResult: TestInput", result, + "Should return the actual string, not Task wrapper JSON"); + } + + /// + /// CRITICAL TEST: Tests full SignalR round-trip with Task.FromResult<TestOrderItem>. + /// This simulates the exact production bug scenario where complex objects returned + /// via Task.FromResult were serialized as Task wrapper instead of the actual object. + /// + /// Bug JSON output was: + /// {"Result":{"Id":1,"ProductName":"..."},"Status":5,"IsCompleted":true,"IsCompletedSuccessfully":true} + /// + [TestMethod] + public async Task TaskFromResult_ComplexObject_ReturnsActualResult_NotTaskWrapper() + { + var item = new TestOrderItem { Id = 42, ProductName = "TestProduct", Quantity = 5, UnitPrice = 10m }; + + var result = await _client.PostDataAsync(TestSignalRTags.TaskFromResultTestOrderItemParam, item); + + // If Task wrapper was serialized, these assertions would fail: + // - ProductName would be null (nested inside "Result" property) + // - Quantity would be 0 + // - UnitPrice would be 0 + Assert.IsNotNull(result, "Result should not be null"); + Assert.AreEqual(42, result.Id, "Id should match the original"); + Assert.AreEqual("FromResult: TestProduct", result.ProductName, + "ProductName should be 'FromResult: TestProduct'. " + + "If null, Task.FromResult result was not properly unwrapped."); + Assert.AreEqual(10, result.Quantity, + "Quantity should be doubled (5*2=10). " + + "If 0, Task.FromResult result was not properly unwrapped."); + Assert.AreEqual(20m, result.UnitPrice, + "UnitPrice should be doubled (10*2=20). " + + "If 0, Task.FromResult result was not properly unwrapped."); + } + + /// + /// Tests Task.FromResult<int> - primitive value type via Task.FromResult. + /// + [TestMethod] + public async Task TaskFromResult_Int_ReturnsActualResult_NotTaskWrapper() + { + var result = await _client.PostDataAsync(TestSignalRTags.TaskFromResultIntParam, 42); + + Assert.AreEqual(84, result, + "Should return doubled value (42*2=84). " + + "If 0 or wrong value, Task.FromResult was not properly unwrapped."); + } + + /// + /// Tests Task.CompletedTask (non-generic Task) via SignalR. + /// + [TestMethod] + public async Task TaskFromResult_NoResult_CompletesSuccessfully() + { + // This should not throw and should complete successfully + var result = await _client.GetAllAsync(TestSignalRTags.TaskFromResultNoParams); + + // Non-generic Task returns null as there's no result value + // The important thing is the call completes without error + } + + #endregion + + #region InvokeMethod Unit Tests + + [TestMethod] + public void InvokeMethod_SyncMethod_ReturnsValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleSingleInt")! + ; + var result = methodInfo.InvokeMethod(service, 42); + + Assert.AreEqual("42", result); + } + + [TestMethod] + public void InvokeMethod_AsyncTaskTMethod_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncString")! + ; + + var result = methodInfo.InvokeMethod(service, "Test"); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task and return the result"); + Assert.AreEqual("Async: Test", result); + } + + [TestMethod] + public void InvokeMethod_AsyncTaskTMethod_WithComplexObject_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncTestOrderItem")!; + var input = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10m }; + + var result = methodInfo.InvokeMethod(service, input); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task and return the result"); + Assert.IsInstanceOfType(result, typeof(TestOrderItem)); + var item = (TestOrderItem)result; + Assert.AreEqual("Async: Widget", item.ProductName); + Assert.AreEqual(15, item.Quantity); + } + + [TestMethod] + public void InvokeMethod_AsyncTaskTMethod_WithInt_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleAsyncInt")!; + + var result = methodInfo.InvokeMethod(service, 42); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task and return the result"); + Assert.AreEqual(84, result); + } + + /// + /// CRITICAL: Tests Task.FromResult() - methods returning Task without async keyword. + /// This was the root cause of the production bug where Task wrapper was serialized. + /// + [TestMethod] + public void InvokeMethod_TaskFromResult_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultString")!; + + var result = methodInfo.InvokeMethod(service, "Test"); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult and return the result"); + Assert.AreEqual("FromResult: Test", result); + } + + [TestMethod] + public void InvokeMethod_TaskFromResult_WithComplexObject_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultTestOrderItem")!; + var input = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10m }; + + var result = methodInfo.InvokeMethod(service, input); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult and return the result"); + Assert.IsInstanceOfType(result, typeof(TestOrderItem)); + var item = (TestOrderItem)result; + Assert.AreEqual("FromResult: Widget", item.ProductName); + Assert.AreEqual(10, item.Quantity); // Doubled + } + + [TestMethod] + public void InvokeMethod_TaskFromResult_WithInt_ReturnsUnwrappedValue() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultInt")!; + + var result = methodInfo.InvokeMethod(service, 42); + + Assert.IsNotNull(result, "InvokeMethod should unwrap Task.FromResult and return the result"); + Assert.AreEqual(84, result); + } + + [TestMethod] + public void InvokeMethod_NonGenericTask_CompletesWithoutError() + { + var service = new TestSignalRService2(); + var methodInfo = typeof(TestSignalRService2).GetMethod("HandleTaskFromResultNoParams")!; + + // Should complete without throwing + var result = methodInfo.InvokeMethod(service); + + // Task.CompletedTask may return internal VoidTaskResult, the important thing is no exception + } + + #endregion +} \ No newline at end of file diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs new file mode 100644 index 0000000..d342f64 --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs @@ -0,0 +1,97 @@ +using AyCode.Core.Extensions; +using AyCode.Services.SignalRs; +using MessagePack; +using MessagePack.Resolvers; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Helper methods for creating SignalR test messages. +/// Uses the production SignalR types for compatibility with the actual client/server code. +/// +public static class SignalRTestHelper +{ + private static readonly MessagePackSerializerOptions MessagePackOptions = ContractlessStandardResolver.Options; + + /// + /// Creates a MessagePack message for parameters using IdMessage format. + /// Each parameter is serialized directly as JSON (no array wrapping). + /// + public static byte[] CreatePrimitiveParamsMessage(params object[] values) + { + var idMessage = new IdMessage(values); + var postMessage = new SignalPostJsonDataMessage(idMessage); + return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); + } + + /// + /// Creates a MessagePack message for a single primitive parameter. + /// + public static byte[] CreateSinglePrimitiveMessage(T value) where T : notnull + { + return CreatePrimitiveParamsMessage(value); + } + + /// + /// Creates a MessagePack message for a complex object parameter. + /// Uses PostDataJson pattern for single complex objects. + /// + public static byte[] CreateComplexObjectMessage(T obj) + { + var json = obj.ToJson(); + var postMessage = new SignalPostJsonDataMessage(json); + return MessagePackSerializer.Serialize(postMessage, MessagePackSerializerOptions.Standard); + } + + /// + /// Creates an empty MessagePack message for parameterless methods. + /// + public static byte[] CreateEmptyMessage() + { + var postMessage = new SignalPostJsonDataMessage(); + return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); + } + + /// + /// Deserialize a SignalResponseJsonMessage from the captured SentMessage. + /// + public static T? GetResponseData(SentMessage sentMessage) + { + if (sentMessage.AsJsonResponse?.ResponseData == null) + return default; + + return sentMessage.AsJsonResponse.ResponseData.JsonTo(); + } + + /// + /// Assert that a response was successful. + /// + public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag) + { + var response = sentMessage.AsJsonResponse; + if (response == null) + throw new AssertFailedException("Response is not a SignalResponseJsonMessage"); + + if (response.Status != SignalResponseStatus.Success) + throw new AssertFailedException($"Expected Success status but got {response.Status}"); + + if (sentMessage.MessageTag != expectedTag) + throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}"); + } + + /// + /// Assert that a response was an error. + /// + public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag) + { + var response = sentMessage.AsJsonResponse; + if (response == null) + throw new AssertFailedException("Response is not a SignalResponseJsonMessage"); + + if (response.Status != SignalResponseStatus.Error) + throw new AssertFailedException($"Expected Error status but got {response.Status}"); + + if (sentMessage.MessageTag != expectedTag) + throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}"); + } +} diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService.cs new file mode 100644 index 0000000..548beda --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService.cs @@ -0,0 +1,518 @@ +using AyCode.Core.Tests.TestModels; +using AyCode.Services.SignalRs; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Test service with SignalR-attributed methods for testing ProcessOnReceiveMessage. +/// Uses shared DTOs from AyCode.Core.Tests.TestModels. +/// +public class TestSignalRService +{ + #region Captured Values for Assertions + + // Primitive captures + public bool SingleIntMethodCalled { get; private set; } + public int? ReceivedInt { get; private set; } + + public bool TwoIntMethodCalled { get; private set; } + public (int A, int B)? ReceivedTwoInts { get; private set; } + + public bool BoolMethodCalled { get; private set; } + public bool? ReceivedBool { get; private set; } + + public bool StringMethodCalled { get; private set; } + public string? ReceivedString { get; private set; } + + public bool GuidMethodCalled { get; private set; } + public Guid? ReceivedGuid { get; private set; } + + public bool EnumMethodCalled { get; private set; } + public TestStatus? ReceivedEnum { get; private set; } + + public bool NoParamsMethodCalled { get; private set; } + + public bool MultipleTypesMethodCalled { get; private set; } + public (bool, string, int)? ReceivedMultipleTypes { get; private set; } + + // Extended primitives + public bool DecimalMethodCalled { get; private set; } + public decimal? ReceivedDecimal { get; private set; } + + public bool DateTimeMethodCalled { get; private set; } + public DateTime? ReceivedDateTime { get; private set; } + + public bool DoubleMethodCalled { get; private set; } + public double? ReceivedDouble { get; private set; } + + public bool LongMethodCalled { get; private set; } + public long? ReceivedLong { get; private set; } + + // Complex object captures (using shared DTOs) + public bool TestOrderItemMethodCalled { get; private set; } + public TestOrderItem? ReceivedTestOrderItem { get; private set; } + + public bool TestOrderMethodCalled { get; private set; } + public TestOrder? ReceivedTestOrder { get; private set; } + + public bool SharedTagMethodCalled { get; private set; } + public SharedTag? ReceivedSharedTag { get; private set; } + + // Collection captures + public bool IntArrayMethodCalled { get; private set; } + public int[]? ReceivedIntArray { get; private set; } + + public bool GuidArrayMethodCalled { get; private set; } + public Guid[]? ReceivedGuidArray { get; private set; } + + public bool StringListMethodCalled { get; private set; } + public List? ReceivedStringList { get; private set; } + + public bool TestOrderItemListMethodCalled { get; private set; } + public List? ReceivedTestOrderItemList { get; private set; } + + public bool IntListMethodCalled { get; private set; } + public List? ReceivedIntList { get; private set; } + + public bool BoolArrayMethodCalled { get; private set; } + public bool[]? ReceivedBoolArray { get; private set; } + + public bool MixedWithArrayMethodCalled { get; private set; } + public (bool, int[], string)? ReceivedMixedWithArray { get; private set; } + + public bool NestedListMethodCalled { get; private set; } + public List>? ReceivedNestedList { get; private set; } + + // Extended array captures for comprehensive testing + public bool LongArrayMethodCalled { get; private set; } + public long[]? ReceivedLongArray { get; private set; } + + public bool DecimalArrayMethodCalled { get; private set; } + public decimal[]? ReceivedDecimalArray { get; private set; } + + public bool DateTimeArrayMethodCalled { get; private set; } + public DateTime[]? ReceivedDateTimeArray { get; private set; } + + public bool EnumArrayMethodCalled { get; private set; } + public TestStatus[]? ReceivedEnumArray { get; private set; } + + public bool DoubleArrayMethodCalled { get; private set; } + public double[]? ReceivedDoubleArray { get; private set; } + + public bool SharedTagArrayMethodCalled { get; private set; } + public SharedTag[]? ReceivedSharedTagArray { get; private set; } + + public bool DictionaryMethodCalled { get; private set; } + public Dictionary? ReceivedDictionary { get; private set; } + + public bool ObjectArrayMethodCalled { get; private set; } + public object[]? ReceivedObjectArray { get; private set; } + + // Mixed parameter captures + public bool IntAndDtoMethodCalled { get; private set; } + public (int, TestOrderItem?)? ReceivedIntAndDto { get; private set; } + + public bool DtoAndListMethodCalled { get; private set; } + public (TestOrderItem?, List?)? ReceivedDtoAndList { get; private set; } + + public bool ThreeComplexParamsMethodCalled { get; private set; } + public (TestOrderItem?, List?, SharedTag?)? ReceivedThreeComplexParams { get; private set; } + + public bool FiveParamsMethodCalled { get; private set; } + public (int, string?, bool, Guid, decimal)? ReceivedFiveParams { get; private set; } + + #endregion + + #region Primitive Parameter Handlers + + [SignalR(TestSignalRTags.SingleIntParam)] + public string HandleSingleInt(int value) + { + SingleIntMethodCalled = true; + ReceivedInt = value; + return $"Received: {value}"; + } + + [SignalR(TestSignalRTags.TwoIntParams)] + public int HandleTwoInts(int a, int b) + { + TwoIntMethodCalled = true; + ReceivedTwoInts = (a, b); + return a + b; + } + + [SignalR(TestSignalRTags.BoolParam)] + public bool HandleBool(bool loadRelations) + { + BoolMethodCalled = true; + ReceivedBool = loadRelations; + return loadRelations; + } + + [SignalR(TestSignalRTags.StringParam)] + public string HandleString(string text) + { + StringMethodCalled = true; + ReceivedString = text; + return $"Echo: {text}"; + } + + [SignalR(TestSignalRTags.GuidParam)] + public Guid HandleGuid(Guid id) + { + GuidMethodCalled = true; + ReceivedGuid = id; + return id; + } + + [SignalR(TestSignalRTags.EnumParam)] + public TestStatus HandleEnum(TestStatus status) + { + EnumMethodCalled = true; + ReceivedEnum = status; + return status; + } + + [SignalR(TestSignalRTags.NoParams)] + public string HandleNoParams() + { + NoParamsMethodCalled = true; + return "OK"; + } + + [SignalR(TestSignalRTags.MultipleTypesParams)] + public string HandleMultipleTypes(bool flag, string text, int number) + { + MultipleTypesMethodCalled = true; + ReceivedMultipleTypes = (flag, text, number); + return $"{flag}-{text}-{number}"; + } + + [SignalR(TestSignalRTags.ThrowsException)] + public void HandleThrowsException() + { + throw new InvalidOperationException("Test exception"); + } + + [SignalR(TestSignalRTags.DecimalParam)] + public decimal HandleDecimal(decimal value) + { + DecimalMethodCalled = true; + ReceivedDecimal = value; + return value * 2; + } + + [SignalR(TestSignalRTags.DateTimeParam)] + public DateTime HandleDateTime(DateTime dateTime) + { + DateTimeMethodCalled = true; + ReceivedDateTime = dateTime; + return dateTime; + } + + [SignalR(TestSignalRTags.DoubleParam)] + public double HandleDouble(double value) + { + DoubleMethodCalled = true; + ReceivedDouble = value; + return value; + } + + [SignalR(TestSignalRTags.LongParam)] + public long HandleLong(long value) + { + LongMethodCalled = true; + ReceivedLong = value; + return value; + } + + #endregion + + #region Complex Object Handlers (using shared DTOs) + + [SignalR(TestSignalRTags.TestOrderItemParam)] + public TestOrderItem HandleTestOrderItem(TestOrderItem item) + { + TestOrderItemMethodCalled = true; + ReceivedTestOrderItem = item; + return new TestOrderItem + { + Id = item.Id, + ProductName = $"Processed: {item.ProductName}", + Quantity = item.Quantity * 2, + UnitPrice = item.UnitPrice + }; + } + + [SignalR(TestSignalRTags.TestOrderParam)] + public TestOrder HandleTestOrder(TestOrder order) + { + TestOrderMethodCalled = true; + ReceivedTestOrder = order; + return order; + } + + [SignalR(TestSignalRTags.SharedTagParam)] + public SharedTag HandleSharedTag(SharedTag tag) + { + SharedTagMethodCalled = true; + ReceivedSharedTag = tag; + return tag; + } + + #endregion + + #region Collection Parameter Handlers + + [SignalR(TestSignalRTags.IntArrayParam)] + public int[] HandleIntArray(int[] values) + { + IntArrayMethodCalled = true; + ReceivedIntArray = values; + return values.Select(x => x * 2).ToArray(); + } + + [SignalR(TestSignalRTags.GuidArrayParam)] + public Guid[] HandleGuidArray(Guid[] ids) + { + GuidArrayMethodCalled = true; + ReceivedGuidArray = ids; + return ids; + } + + [SignalR(TestSignalRTags.StringListParam)] + public List HandleStringList(List items) + { + StringListMethodCalled = true; + ReceivedStringList = items; + return items.Select(x => x.ToUpper()).ToList(); + } + + [SignalR(TestSignalRTags.TestOrderItemListParam)] + public List HandleTestOrderItemList(List items) + { + TestOrderItemListMethodCalled = true; + ReceivedTestOrderItemList = items; + return items; + } + + [SignalR(TestSignalRTags.IntListParam)] + public List HandleIntList(List numbers) + { + IntListMethodCalled = true; + ReceivedIntList = numbers; + return numbers.Select(x => x * 2).ToList(); + } + + [SignalR(TestSignalRTags.BoolArrayParam)] + public bool[] HandleBoolArray(bool[] flags) + { + BoolArrayMethodCalled = true; + ReceivedBoolArray = flags; + return flags; + } + + [SignalR(TestSignalRTags.MixedWithArrayParam)] + public string HandleMixedWithArray(bool flag, int[] numbers, string text) + { + MixedWithArrayMethodCalled = true; + ReceivedMixedWithArray = (flag, numbers, text); + return $"{flag}-[{string.Join(",", numbers)}]-{text}"; + } + + [SignalR(TestSignalRTags.NestedListParam)] + public List> HandleNestedList(List> nestedList) + { + NestedListMethodCalled = true; + ReceivedNestedList = nestedList; + return nestedList; + } + + #endregion + + #region Extended Array Parameter Handlers + + [SignalR(TestSignalRTags.LongArrayParam)] + public long[] HandleLongArray(long[] values) + { + LongArrayMethodCalled = true; + ReceivedLongArray = values; + return values; + } + + [SignalR(TestSignalRTags.DecimalArrayParam)] + public decimal[] HandleDecimalArray(decimal[] values) + { + DecimalArrayMethodCalled = true; + ReceivedDecimalArray = values; + return values; + } + + [SignalR(TestSignalRTags.DateTimeArrayParam)] + public DateTime[] HandleDateTimeArray(DateTime[] values) + { + DateTimeArrayMethodCalled = true; + ReceivedDateTimeArray = values; + return values; + } + + [SignalR(TestSignalRTags.EnumArrayParam)] + public TestStatus[] HandleEnumArray(TestStatus[] values) + { + EnumArrayMethodCalled = true; + ReceivedEnumArray = values; + return values; + } + + [SignalR(TestSignalRTags.DoubleArrayParam)] + public double[] HandleDoubleArray(double[] values) + { + DoubleArrayMethodCalled = true; + ReceivedDoubleArray = values; + return values; + } + + [SignalR(TestSignalRTags.SharedTagArrayParam)] + public SharedTag[] HandleSharedTagArray(SharedTag[] tags) + { + SharedTagArrayMethodCalled = true; + ReceivedSharedTagArray = tags; + return tags; + } + + [SignalR(TestSignalRTags.DictionaryParam)] + public Dictionary HandleDictionary(Dictionary dict) + { + DictionaryMethodCalled = true; + ReceivedDictionary = dict; + return dict; + } + + [SignalR(TestSignalRTags.ObjectArrayParam)] + public object[] HandleObjectArray(object[] values) + { + ObjectArrayMethodCalled = true; + ReceivedObjectArray = values; + return values; + } + + #endregion + + #region Mixed Parameter Handlers + + [SignalR(TestSignalRTags.IntAndDtoParam)] + public string HandleIntAndDto(int id, TestOrderItem item) + { + IntAndDtoMethodCalled = true; + ReceivedIntAndDto = (id, item); + return $"{id}-{item?.ProductName}"; + } + + [SignalR(TestSignalRTags.DtoAndListParam)] + public string HandleDtoAndList(TestOrderItem item, List numbers) + { + DtoAndListMethodCalled = true; + ReceivedDtoAndList = (item, numbers); + return $"{item?.ProductName}-[{string.Join(",", numbers ?? [])}]"; + } + + [SignalR(TestSignalRTags.ThreeComplexParams)] + public string HandleThreeComplexParams(TestOrderItem item, List tags, SharedTag sharedTag) + { + ThreeComplexParamsMethodCalled = true; + ReceivedThreeComplexParams = (item, tags, sharedTag); + return $"{item?.ProductName}-{tags?.Count}-{sharedTag?.Name}"; + } + + [SignalR(TestSignalRTags.FiveParams)] + public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e) + { + FiveParamsMethodCalled = true; + ReceivedFiveParams = (a, b, c, d, e); + return $"{a}-{b}-{c}-{d}-{e}"; + } + + #endregion + + public void Reset() + { + // Primitive captures + SingleIntMethodCalled = false; + ReceivedInt = null; + TwoIntMethodCalled = false; + ReceivedTwoInts = null; + BoolMethodCalled = false; + ReceivedBool = null; + StringMethodCalled = false; + ReceivedString = null; + GuidMethodCalled = false; + ReceivedGuid = null; + EnumMethodCalled = false; + ReceivedEnum = null; + NoParamsMethodCalled = false; + MultipleTypesMethodCalled = false; + ReceivedMultipleTypes = null; + DecimalMethodCalled = false; + ReceivedDecimal = null; + DateTimeMethodCalled = false; + ReceivedDateTime = null; + DoubleMethodCalled = false; + ReceivedDouble = null; + LongMethodCalled = false; + ReceivedLong = null; + + // Complex object captures + TestOrderItemMethodCalled = false; + ReceivedTestOrderItem = null; + TestOrderMethodCalled = false; + ReceivedTestOrder = null; + SharedTagMethodCalled = false; + ReceivedSharedTag = null; + + // Collection captures + IntArrayMethodCalled = false; + ReceivedIntArray = null; + GuidArrayMethodCalled = false; + ReceivedGuidArray = null; + StringListMethodCalled = false; + ReceivedStringList = null; + TestOrderItemListMethodCalled = false; + ReceivedTestOrderItemList = null; + IntListMethodCalled = false; + ReceivedIntList = null; + BoolArrayMethodCalled = false; + ReceivedBoolArray = null; + MixedWithArrayMethodCalled = false; + ReceivedMixedWithArray = null; + NestedListMethodCalled = false; + ReceivedNestedList = null; + + // Extended array captures + LongArrayMethodCalled = false; + ReceivedLongArray = null; + DecimalArrayMethodCalled = false; + ReceivedDecimalArray = null; + DateTimeArrayMethodCalled = false; + ReceivedDateTimeArray = null; + EnumArrayMethodCalled = false; + ReceivedEnumArray = null; + DoubleArrayMethodCalled = false; + ReceivedDoubleArray = null; + SharedTagArrayMethodCalled = false; + ReceivedSharedTagArray = null; + DictionaryMethodCalled = false; + ReceivedDictionary = null; + ObjectArrayMethodCalled = false; + ReceivedObjectArray = null; + + // Mixed parameter captures + IntAndDtoMethodCalled = false; + ReceivedIntAndDto = null; + DtoAndListMethodCalled = false; + ReceivedDtoAndList = null; + ThreeComplexParamsMethodCalled = false; + ReceivedThreeComplexParams = null; + FiveParamsMethodCalled = false; + ReceivedFiveParams = null; + } +} diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs new file mode 100644 index 0000000..f9b078b --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs @@ -0,0 +1,345 @@ +using System.Globalization; +using AyCode.Core.Tests.TestModels; +using AyCode.Services.SignalRs; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Test service with SignalR-attributed methods for testing ProcessOnReceiveMessage. +/// Uses shared DTOs from AyCode.Core.Tests.TestModels. +/// +public class TestSignalRService2 +{ + #region Primitive Parameter Handlers + + [SignalR(TestSignalRTags.SingleIntParam)] + public string HandleSingleInt(int value) + { + return $"{value}"; + } + + [SignalR(TestSignalRTags.TwoIntParams)] + public int HandleTwoInts(int a, int b) + { + return a + b; + } + + [SignalR(TestSignalRTags.BoolParam)] + public bool HandleBool(bool loadRelations) + { + return loadRelations; + } + + [SignalR(TestSignalRTags.StringParam)] + public string HandleString(string text) + { + return $"Echo: {text}"; + } + + [SignalR(TestSignalRTags.GuidParam)] + public Guid HandleGuid(Guid id) + { + return id; + } + + [SignalR(TestSignalRTags.EnumParam)] + public TestStatus HandleEnum(TestStatus status) + { + return status; + } + + [SignalR(TestSignalRTags.NoParams)] + public string HandleNoParams() + { + return "OK"; + } + + [SignalR(TestSignalRTags.MultipleTypesParams)] + public string HandleMultipleTypes(bool flag, string text, int number) + { + return $"{flag}-{text}-{number}"; + } + + [SignalR(TestSignalRTags.ThrowsException)] + public void HandleThrowsException() + { + throw new InvalidOperationException("Test exception"); + } + + [SignalR(TestSignalRTags.DecimalParam)] + public decimal HandleDecimal(decimal value) + { + return value * 2; + } + + [SignalR(TestSignalRTags.DateTimeParam)] + public DateTime HandleDateTime(DateTime dateTime) + { + return dateTime; + } + + [SignalR(TestSignalRTags.DoubleParam)] + public double HandleDouble(double value) + { + return value; + } + + [SignalR(TestSignalRTags.LongParam)] + public long HandleLong(long value) + { + return value; + } + + #endregion + + #region Complex Object Handlers (using shared DTOs) + + [SignalR(TestSignalRTags.TestOrderItemParam)] + public TestOrderItem HandleTestOrderItem(TestOrderItem item) + { + return new TestOrderItem + { + Id = item.Id, + ProductName = $"Processed: {item.ProductName}", + Quantity = item.Quantity * 2, + UnitPrice = item.UnitPrice * 2, + }; + } + + [SignalR(TestSignalRTags.TestOrderParam)] + public TestOrder HandleTestOrder(TestOrder order) + { + return order; + } + + [SignalR(TestSignalRTags.SharedTagParam)] + public SharedTag HandleSharedTag(SharedTag tag) + { + return tag; + } + + #endregion + + #region Collection Parameter Handlers + + [SignalR(TestSignalRTags.IntArrayParam)] + public int[] HandleIntArray(int[] values) + { + return values.Select(x => x * 2).ToArray(); + } + + [SignalR(TestSignalRTags.GuidArrayParam)] + public Guid[] HandleGuidArray(Guid[] ids) + { + return ids; + } + + [SignalR(TestSignalRTags.StringListParam)] + public List HandleStringList(List items) + { + return items.Select(x => x.ToUpper()).ToList(); + } + + [SignalR(TestSignalRTags.TestOrderItemListParam)] + public List HandleTestOrderItemList(List items) + { + return items; + } + + [SignalR(TestSignalRTags.IntListParam)] + public List HandleIntList(List numbers) + { + return numbers.Select(x => x * 2).ToList(); + } + + [SignalR(TestSignalRTags.BoolArrayParam)] + public bool[] HandleBoolArray(bool[] flags) + { + return flags; + } + + [SignalR(TestSignalRTags.MixedWithArrayParam)] + public string HandleMixedWithArray(bool flag, int[] numbers, string text) + { + return $"{flag}-[{string.Join(",", numbers)}]-{text}"; + } + + [SignalR(TestSignalRTags.NestedListParam)] + public List> HandleNestedList(List> nestedList) + { + return nestedList; + } + + #endregion + + #region Extended Array Parameter Handlers + + [SignalR(TestSignalRTags.LongArrayParam)] + public long[] HandleLongArray(long[] values) + { + return values; + } + + [SignalR(TestSignalRTags.DecimalArrayParam)] + public decimal[] HandleDecimalArray(decimal[] values) + { + return values; + } + + [SignalR(TestSignalRTags.DateTimeArrayParam)] + public DateTime[] HandleDateTimeArray(DateTime[] values) + { + return values; + } + + [SignalR(TestSignalRTags.EnumArrayParam)] + public TestStatus[] HandleEnumArray(TestStatus[] values) + { + return values; + } + + [SignalR(TestSignalRTags.DoubleArrayParam)] + public double[] HandleDoubleArray(double[] values) + { + return values; + } + + [SignalR(TestSignalRTags.SharedTagArrayParam)] + public SharedTag[] HandleSharedTagArray(SharedTag[] tags) + { + return tags; + } + + [SignalR(TestSignalRTags.DictionaryParam)] + public Dictionary HandleDictionary(Dictionary dict) + { + return dict; + } + + [SignalR(TestSignalRTags.ObjectArrayParam)] + public object[] HandleObjectArray(object[] values) + { + return values; + } + + #endregion + + #region Mixed Parameter Handlers + + [SignalR(TestSignalRTags.IntAndDtoParam)] + public string HandleIntAndDto(int id, TestOrderItem item) + { + return $"{id}-{item?.ProductName}"; + } + + [SignalR(TestSignalRTags.DtoAndListParam)] + public string HandleDtoAndList(TestOrderItem item, List numbers) + { + return $"{item?.ProductName}-[{string.Join(",", numbers ?? [])}]"; + } + + [SignalR(TestSignalRTags.ThreeComplexParams)] + public string HandleThreeComplexParams(TestOrderItem item, List tags, SharedTag sharedTag) + { + return $"{item?.ProductName}-{tags?.Count}-{sharedTag?.Name}"; + } + + [SignalR(TestSignalRTags.FiveParams)] + public Task HandleFiveParams(int a, string b, bool c, Guid d, decimal e) + { + return Task.FromResult($"{a}-{b}-{c}-{d}-{e.ToString(CultureInfo.InvariantCulture)}"); + } + + #endregion + + #region Async Task Method Tests + + [SignalR(TestSignalRTags.AsyncTestOrderItemParam)] + public async Task HandleAsyncTestOrderItem(TestOrderItem item) + { + await Task.Delay(1); // Simulate async work + return new TestOrderItem + { + Id = item.Id, + ProductName = $"Async: {item.ProductName}", + Quantity = item.Quantity * 3, + UnitPrice = item.UnitPrice * 3, + }; + } + + [SignalR(TestSignalRTags.AsyncStringParam)] + public async Task HandleAsyncString(string input) + { + await Task.Delay(1); + return $"Async: {input}"; + } + + [SignalR(TestSignalRTags.AsyncNoParams)] + public async Task HandleAsyncNoParams() + { + await Task.Delay(1); + return "AsyncOK"; + } + + [SignalR(TestSignalRTags.AsyncIntParam)] + public async Task HandleAsyncInt(int value) + { + await Task.Delay(1); + return value * 2; + } + + #endregion + + #region Task.FromResult Tests - Critical for testing non-async methods returning Task + // PRODUCTION BUG FIX: These methods test the scenario where a method returns Task + // using Task.FromResult() instead of async/await. Such methods do NOT have + // AsyncStateMachineAttribute, so the old InvokeMethod implementation would serialize + // the Task wrapper instead of awaiting and returning the actual result. + + /// + /// Returns Task without async keyword - uses Task.FromResult(). + /// This pattern does NOT have AsyncStateMachineAttribute! + /// + [SignalR(TestSignalRTags.TaskFromResultStringParam)] + public Task HandleTaskFromResultString(string input) + { + return Task.FromResult($"FromResult: {input}"); + } + + /// + /// Returns Task<TestOrderItem> without async keyword. + /// CRITICAL: This simulates the exact production bug scenario. + /// + [SignalR(TestSignalRTags.TaskFromResultTestOrderItemParam)] + public Task HandleTaskFromResultTestOrderItem(TestOrderItem item) + { + return Task.FromResult(new TestOrderItem + { + Id = item.Id, + ProductName = $"FromResult: {item.ProductName}", + Quantity = item.Quantity * 2, + UnitPrice = item.UnitPrice * 2, + }); + } + + /// + /// Returns Task<int> without async keyword. + /// + [SignalR(TestSignalRTags.TaskFromResultIntParam)] + public Task HandleTaskFromResultInt(int value) + { + return Task.FromResult(value * 2); + } + + /// + /// Returns non-generic Task using Task.CompletedTask. + /// + [SignalR(TestSignalRTags.TaskFromResultNoParams)] + public Task HandleTaskFromResultNoParams() + { + return Task.CompletedTask; + } + + #endregion + +} diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs new file mode 100644 index 0000000..c6a340d --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs @@ -0,0 +1,70 @@ +using AyCode.Services.SignalRs; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// SignalR message tags for testing. +/// +public abstract class TestSignalRTags : AcSignalRTags +{ + // Primitive parameter tags + public const int SingleIntParam = 100; + public const int TwoIntParams = 101; + public const int BoolParam = 102; + public const int StringParam = 103; + public const int GuidParam = 104; + public const int EnumParam = 105; + public const int NoParams = 107; + public const int MultipleTypesParams = 109; + public const int ThrowsException = 110; + + // Extended primitives + public const int DecimalParam = 140; + public const int DateTimeParam = 141; + public const int DoubleParam = 143; + public const int LongParam = 144; + + // Complex object parameter tags (using shared DTOs from Core.Tests) + public const int TestOrderItemParam = 120; + public const int TestOrderParam = 121; + public const int SharedTagParam = 122; + + // Collection parameter tags + public const int IntArrayParam = 130; + public const int GuidArrayParam = 131; + public const int StringListParam = 132; + public const int TestOrderItemListParam = 133; + public const int IntListParam = 134; + public const int BoolArrayParam = 135; + public const int MixedWithArrayParam = 136; + public const int NestedListParam = 151; + + // Extended array/collection parameter tags for comprehensive testing + public const int LongArrayParam = 170; + public const int DecimalArrayParam = 171; + public const int DateTimeArrayParam = 172; + public const int EnumArrayParam = 173; + public const int DoubleArrayParam = 174; + public const int SharedTagArrayParam = 175; + public const int DictionaryParam = 176; + public const int ObjectArrayParam = 177; + + // Mixed parameter scenarios + public const int IntAndDtoParam = 160; + public const int DtoAndListParam = 161; + public const int ThreeComplexParams = 162; + public const int FiveParams = 164; + + // Async Task method tags - critical for testing async handling + public const int AsyncTestOrderItemParam = 200; + public const int AsyncStringParam = 201; + public const int AsyncNoParams = 202; + public const int AsyncIntParam = 203; + + // Task.FromResult method tags - CRITICAL for testing non-async methods returning Task + // These methods do NOT have AsyncStateMachineAttribute but return Task + public const int TaskFromResultStringParam = 210; + public const int TaskFromResultTestOrderItemParam = 211; + public const int TaskFromResultIntParam = 212; + public const int TaskFromResultNoParams = 213; +} diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs new file mode 100644 index 0000000..16a64f9 --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs @@ -0,0 +1,64 @@ +using AyCode.Core; +using AyCode.Core.Extensions; +using AyCode.Core.Tests.TestModels; +using AyCode.Services.Server.SignalRs; +using AyCode.Services.SignalRs; +using AyCode.Services.Tests.SignalRs; +using MessagePack.Resolvers; +using Microsoft.AspNetCore.SignalR.Client; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Testable SignalR client that allows testing without real HubConnection. +/// +public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServer +{ + private HubConnectionState _connectionState = HubConnectionState.Connected; + private readonly TestableSignalRHub2 _signalRHub; + + /// + /// Testable SignalR client that allows testing without real HubConnection. + /// + public TestableSignalRClient2(TestableSignalRHub2 signalRHub, TestLogger logger) : base(logger) + { + MsDelay = 0; + MsFirstDelay = 0; + + _signalRHub = signalRHub; + } + + #region Override virtual methods for testing + + protected override async Task MessageReceived(int messageTag, byte[] messageBytes) + { + throw new NotImplementedException(); + } + + protected override HubConnectionState GetConnectionState() => _connectionState; + + protected override bool IsConnected() => _connectionState == HubConnectionState.Connected; + + protected override Task StartConnectionInternal() + { + _connectionState = HubConnectionState.Connected; + return Task.CompletedTask; + } + + protected override Task StopConnectionInternal() + { + _connectionState = HubConnectionState.Disconnected; + return Task.CompletedTask; + } + + protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask; + + protected override async Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId) + { + await _signalRHub.OnReceiveMessage(messageTag, messageBytes, requestId); + } + #endregion + +} + + diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs new file mode 100644 index 0000000..366f502 --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs @@ -0,0 +1,210 @@ +using System.Security.Claims; +using AyCode.Core.Tests.TestModels; +using AyCode.Models.Server.DynamicMethods; +using AyCode.Services.Server.SignalRs; +using AyCode.Services.SignalRs; +using Microsoft.Extensions.Configuration; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Testable SignalR hub that overrides infrastructure dependencies. +/// Enables unit testing without SignalR server or mocks. +/// +public class TestableSignalRHub : AcWebSignalRHubBase +{ + #region Captured Data for Assertions + + /// + /// Messages sent via ResponseToCaller or SendMessageToClient + /// + public List SentMessages { get; } = []; + + /// + /// Whether notFoundCallback was invoked + /// + public bool WasNotFoundCallbackInvoked { get; private set; } + + /// + /// The tag name passed to notFoundCallback + /// + public string? NotFoundTagName { get; private set; } + + #endregion + + #region Test Configuration + + /// + /// Simulated connection ID + /// + public string TestConnectionId { get; set; } = "test-connection-id"; + + /// + /// Simulated user identifier + /// + public string? TestUserIdentifier { get; set; } = "test-user-id"; + + /// + /// Simulated connection aborted state + /// + public bool TestIsConnectionAborted { get; set; } = false; + + /// + /// Simulated ClaimsPrincipal (optional) + /// + public ClaimsPrincipal? TestUser { get; set; } + + #endregion + + public TestableSignalRHub() + : base(new ConfigurationBuilder().Build(), new TestLogger()) + { + } + + public TestableSignalRHub(IConfiguration configuration, TestLogger logger) + : base(configuration, logger) + { + } + + #region Public Test Entry Points + + /// + /// Register a service with SignalR-attributed methods + /// + public void RegisterService(object service) + { + DynamicMethodCallModels.Add(new AcDynamicMethodCallModel(service)); + } + + /// + /// Invoke ProcessOnReceiveMessage for testing + /// + public Task InvokeProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId = null) + { + return ProcessOnReceiveMessage(messageTag, message, requestId, async tagName => + { + WasNotFoundCallbackInvoked = true; + NotFoundTagName = tagName; + await Task.CompletedTask; + }); + } + + /// + /// Get the logger for assertions + /// + public new TestLogger Logger => base.Logger; + + /// + /// Reset captured state for next test + /// + public void Reset() + { + SentMessages.Clear(); + WasNotFoundCallbackInvoked = false; + NotFoundTagName = null; + Logger.Clear(); + } + + #endregion + + #region Overridden Context Accessors + + protected override string GetConnectionId() => TestConnectionId; + + protected override bool IsConnectionAborted() => TestIsConnectionAborted; + + protected override string? GetUserIdentifier() => TestUserIdentifier; + + protected override ClaimsPrincipal? GetUser() => TestUser; + + #endregion + + #region Overridden Response Methods (capture messages for testing) + + protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId) + { + SentMessages.Add(new SentMessage( + MessageTag: messageTag, + Message: message, + RequestId: requestId, + Target: SendTarget.Caller + )); + return Task.CompletedTask; + } + + protected override Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) + { + SentMessages.Add(new SentMessage( + MessageTag: messageTag, + Message: message, + RequestId: requestId, + Target: SendTarget.Client + )); + return Task.CompletedTask; + } + + protected override Task SendMessageToOthers(int messageTag, object? content) + { + SentMessages.Add(new SentMessage( + MessageTag: messageTag, + Message: new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), + RequestId: null, + Target: SendTarget.Others + )); + return Task.CompletedTask; + } + + protected override Task SendMessageToAll(int messageTag, object? content) + { + SentMessages.Add(new SentMessage( + MessageTag: messageTag, + Message: new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), + RequestId: null, + Target: SendTarget.All + )); + return Task.CompletedTask; + } + + protected override Task SendMessageToUserIdInternal(string userId, int messageTag, ISignalRMessage message, int? requestId) + { + SentMessages.Add(new SentMessage( + MessageTag: messageTag, + Message: message, + RequestId: requestId, + Target: SendTarget.User, + TargetId: userId + )); + return Task.CompletedTask; + } + + #endregion +} + +/// +/// Captured sent message for assertions +/// +public record SentMessage( + int MessageTag, + ISignalRMessage Message, + int? RequestId, + SendTarget Target, + string? TargetId = null) +{ + /// + /// Get the response as SignalResponseJsonMessage for inspection + /// + public SignalResponseJsonMessage? AsJsonResponse => Message as SignalResponseJsonMessage; +} + +/// +/// Target of the sent message +/// +public enum SendTarget +{ + Caller, + Client, + Others, + All, + User, + Group +} diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs new file mode 100644 index 0000000..c97c114 --- /dev/null +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs @@ -0,0 +1,86 @@ +using System.Security.Claims; +using AyCode.Core.Extensions; +using AyCode.Core.Helpers; +using AyCode.Core.Tests.TestModels; +using AyCode.Models.Server.DynamicMethods; +using AyCode.Services.Server.SignalRs; +using AyCode.Services.SignalRs; +using MessagePack.Resolvers; +using Microsoft.Extensions.Configuration; + +namespace AyCode.Services.Server.Tests.SignalRs; + +/// +/// Testable SignalR hub that overrides infrastructure dependencies. +/// Enables unit testing without SignalR server or mocks. +/// +public class TestableSignalRHub2 : AcWebSignalRHubBase +{ + private IAcSignalRHubItemServer _callerClient; + + #region Test Configuration + + /// + /// Simulated connection ID + /// + public string TestConnectionId { get; set; } = "test-connection-id"; + + /// + /// Simulated user identifier + /// + public string? TestUserIdentifier { get; set; } = "test-user-id"; + + /// + /// Simulated connection aborted state + /// + public bool TestIsConnectionAborted { get; set; } = false; + + /// + /// Simulated ClaimsPrincipal (optional) + /// + public ClaimsPrincipal? TestUser { get; set; } + + #endregion + + public TestableSignalRHub2() + : base(new ConfigurationBuilder().Build(), new TestLogger()) + { + } + + public TestableSignalRHub2(IConfiguration configuration, TestLogger logger) + : base(configuration, logger) + { + } + + #region Public Test Entry Points + + /// + /// Register a service with SignalR-attributed methods + /// + public void RegisterService(object service, IAcSignalRHubItemServer callerClient) + { + _callerClient = callerClient; + DynamicMethodCallModels.Add(new AcDynamicMethodCallModel(service)); + } + + #endregion + + #region Overridden Context Accessors + + protected override string GetConnectionId() => TestConnectionId; + + protected override bool IsConnectionAborted() => TestIsConnectionAborted; + + protected override string? GetUserIdentifier() => TestUserIdentifier; + + protected override ClaimsPrincipal? GetUser() => TestUser; + + #endregion + + #region Overridden Response Methods (capture messages for testing) + + protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId) + => SendMessageToClient(_callerClient, messageTag, message, requestId); + + #endregion +} diff --git a/AyCode.Services.Server.Tests/TestLogger.cs b/AyCode.Services.Server.Tests/TestLogger.cs new file mode 100644 index 0000000..fcf8581 --- /dev/null +++ b/AyCode.Services.Server.Tests/TestLogger.cs @@ -0,0 +1,3 @@ +// Re-export TestLogger from AyCode.Core.Tests for backward compatibility + +namespace AyCode.Services.Server.Tests; \ No newline at end of file diff --git a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs index fb09674..a20ab74 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs @@ -924,6 +924,7 @@ namespace AyCode.Services.Server.SignalRs return SignalRClient.PostDataAsync(messageTag, item, response => { + //response.ResponseDataJson if (response.Status != SignalResponseStatus.Success || response.ResponseData == null) { if (TryRollbackItem(item.Id, out _)) return Task.CompletedTask; diff --git a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs index 19c1dee..5e8139c 100644 --- a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs +++ b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs @@ -1,5 +1,4 @@ -using System.Linq.Expressions; -using System.Security.Claims; +using System.Security.Claims; using AyCode.Core; using AyCode.Core.Extensions; using AyCode.Core.Helpers; @@ -17,135 +16,69 @@ public abstract class AcWebSignalRHubBase(IConfiguration : Hub, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : AcLoggerBase { protected readonly List> DynamicMethodCallModels = []; - //protected readonly TIAM.Core.Loggers.Logger> Logger = new(logWriters.ToArray()); protected TLogger Logger = logger; protected IConfiguration Configuration = configuration; - //private readonly ServiceProviderAPIController _serviceProviderApiController; - //private readonly TransferDataAPIController _transferDataApiController; + #region Connection Lifecycle - //_serviceProviderApiController = serviceProviderApiController; - //_transferDataApiController = transferDataApiController; - - // https://docs.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-3.1#strongly-typed-hubs public override async Task OnConnectedAsync() { - Logger.Debug($"Server OnConnectedAsync; ConnectionId: {Context.ConnectionId}; UserIdentifier: {Context.UserIdentifier}"); - + Logger.Debug($"Server OnConnectedAsync; ConnectionId: {GetConnectionId()}; UserIdentifier: {GetUserIdentifier()}"); LogContextUserNameAndId(); - await base.OnConnectedAsync(); - - //Clients.Caller.ConnectionId = Context.ConnectionId; - //Clients.Caller.UserIdentifier = Context.UserIdentifier; } public override async Task OnDisconnectedAsync(Exception? exception) { - var logText = $"Server OnDisconnectedAsync; ConnectionId: {Context.ConnectionId}; UserIdentifier: {Context.UserIdentifier};"; - - if (exception == null) Logger.Debug(logText); - else Logger.Error(logText, exception); + var connectionId = GetConnectionId(); + var userIdentifier = GetUserIdentifier(); + + if (exception == null) + Logger.Debug($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}"); + else + Logger.Error($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}", exception); LogContextUserNameAndId(); - await base.OnDisconnectedAsync(exception); } + #endregion + + #region Message Processing + public virtual Task OnReceiveMessage(int messageTag, byte[]? messageBytes, int? requestId) { return ProcessOnReceiveMessage(messageTag, messageBytes, requestId, null); } - protected async Task ProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId, Func? notFoundCallback) + protected virtual async Task ProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId, Func? notFoundCallback) { var tagName = ConstHelper.NameByValue(messageTag); - var logText = $"Server OnReceiveMessage; {nameof(requestId)}: {requestId}; ConnectionId: {Context.ConnectionId}; {tagName}"; - if (message is { Length: 0 }) Logger.Warning($"message.Length == 0! {logText}"); - else Logger.Debug($"[{message?.Length:N0}b] {logText}"); + if (message is { Length: 0 }) + { + Logger.Warning($"message.Length == 0! Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); + } + else + { + Logger.Debug($"[{message?.Length:N0}b] Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); + } try { if (AcDomain.IsDeveloperVersion) LogContextUserNameAndId(); - foreach (var methodsByDeclaringObject in DynamicMethodCallModels) + if (TryFindAndInvokeMethod(messageTag, message, tagName, out var responseData)) { - if (!methodsByDeclaringObject.MethodsByMessageTag.TryGetValue(messageTag, out var methodInfoModel)) continue; - - object[]? paramValues = null; - - logText = $"Found dynamic method for the tag! method: {methodsByDeclaringObject.InstanceObject.GetType().Name}.{methodInfoModel.MethodInfo.Name}"; - - if (methodInfoModel.ParamInfos is { Length: > 0 }) - { - Logger.Debug($"{logText}({string.Join(", ", methodInfoModel.ParamInfos.Select(x => x.Name))}); {tagName}"); - - paramValues = new object[methodInfoModel.ParamInfos.Length]; - - var firstParamType = methodInfoModel.ParamInfos[0].ParameterType; - if (methodInfoModel.ParamInfos.Length > 1 || firstParamType == typeof(string) || firstParamType.IsEnum || firstParamType.IsValueType || firstParamType == typeof(DateTime)) - { - var msg = message!.MessagePackTo>(); - - for (var i = 0; i < msg.PostData.Ids.Count; i++) - { - //var obj = (string)msg.PostData.Ids[i]; - //if (msg.PostData.Ids[i] is Guid id) - //{ - // if (id.IsNullOrEmpty()) throw new NullReferenceException($"PostData.Id.IsNullOrEmpty(); Ids: {msg.PostData.Ids}"); - // paramValues[i] = id; - //} - //else if (Guid.TryParse(obj, out id)) - //{ - // if (id.IsNullOrEmpty()) throw new NullReferenceException($"PostData.Id.IsNullOrEmpty(); Ids: {msg.PostData.Ids}"); - // paramValues[i] = id; - //} - //else if (Enum.TryParse(methodInfoModel.ParameterType, obj, out var enumObj)) - //{ - // paramValues[i] = enumObj; - //} - //else paramValues[i] = Convert.ChangeType(obj, methodInfoModel.ParameterType); - - var obj = msg.PostData.Ids[i]; - //var config = new MapperConfiguration(cfg => - //{ - // cfg.CreateMap(obj.GetType(), methodInfoModel.ParameterType); - //}); - - //var mapper = new Mapper(config); - //paramValues[i] = mapper.Map(obj, methodInfoModel.ParameterType); - - //paramValues[i] = obj; - - var a = Array.CreateInstance(methodInfoModel.ParamInfos[i].ParameterType, 1); - - if (methodInfoModel.ParamInfos[i].ParameterType == typeof(Expression)) - { - //var serializer = new ExpressionSerializer(new JsonSerializer()); - //paramValues[i] = serializer.DeserializeText((string)(obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!); - } - else paramValues[i] = (obj.JsonTo(a.GetType()) as Array)?.GetValue(0)!; - - } - } - else paramValues[0] = message!.MessagePackTo>(MessagePackSerializerOptions.Standard).PostDataJson.JsonTo(firstParamType)!; - } - else Logger.Debug($"{logText}(); {tagName}"); - - var responseData = methodInfoModel.MethodInfo.InvokeMethod(methodsByDeclaringObject.InstanceObject, paramValues); var responseDataJson = new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, responseData); - var responseDataJsonKiloBytes = System.Text.Encoding.Unicode.GetByteCount(responseDataJson.ResponseData!) / 1024; - - //File.WriteAllText(Path.Combine("h:", $"{requestId}.json"), responseDataJson.ResponseData); - - Logger.Debug($"[{responseDataJsonKiloBytes}kb] responseData serialized to json"); + + if (Logger.LogLevel <= LogLevel.Debug) + { + var responseDataJsonKiloBytes = System.Text.Encoding.Unicode.GetByteCount(responseDataJson.ResponseData ?? "") / 1024; + Logger.Debug($"[{responseDataJsonKiloBytes}kb] responseData serialized to json"); + } await ResponseToCaller(messageTag, responseDataJson, requestId); - - if (methodInfoModel.Attribute.SendToOtherClientType != SendToClientType.None) - SendMessageToOtherClients(methodInfoModel.Attribute.SendToOtherClientTag, responseData).Forget(); - return; } @@ -160,59 +93,184 @@ public abstract class AcWebSignalRHubBase(IConfiguration await ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Error), requestId); } - protected Task ResponseToCaller2(int messageTag, object? content) + /// + /// Finds and invokes the method registered for the given message tag. + /// + private bool TryFindAndInvokeMethod(int messageTag, byte[]? message, string tagName, out object? responseData) + { + responseData = null; + + foreach (var methodsByDeclaringObject in DynamicMethodCallModels) + { + if (!methodsByDeclaringObject.MethodsByMessageTag.TryGetValue(messageTag, out var methodInfoModel)) + continue; + + var methodName = $"{methodsByDeclaringObject.InstanceObject.GetType().Name}.{methodInfoModel.MethodInfo.Name}"; + var paramValues = DeserializeParameters(message, methodInfoModel, tagName, methodName); + + if (paramValues == null) + Logger.Debug($"Found dynamic method for the tag! method: {methodName}(); {tagName}"); + else + Logger.Debug($"Found dynamic method for the tag! method: {methodName}({string.Join(", ", methodInfoModel.ParamInfos.Select(x => x.Name))}); {tagName}"); + + responseData = methodInfoModel.MethodInfo.InvokeMethod(methodsByDeclaringObject.InstanceObject, paramValues); + + if (methodInfoModel.Attribute.SendToOtherClientType != SendToClientType.None) + SendMessageToOthers(methodInfoModel.Attribute.SendToOtherClientTag, responseData).Forget(); + + return true; + } + + return false; + } + + /// + /// Deserializes parameters from the message based on method signature. + /// Returns null if no parameters needed, or throws if message is invalid. + /// + private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel methodInfoModel, string tagName, string methodName) + { + if (methodInfoModel.ParamInfos is not { Length: > 0 }) + return null; + + // Validate message - required when method has parameters + if (message is null or { Length: 0 }) + throw new ArgumentException($"Message is null or empty but method '{methodName}' requires {methodInfoModel.ParamInfos.Length} parameter(s); {tagName}"); + + var paramValues = new object[methodInfoModel.ParamInfos.Length]; + var firstParamType = methodInfoModel.ParamInfos[0].ParameterType; + + // Use IdMessage format for: multiple params OR primitives/strings/enums/value types + if (methodInfoModel.ParamInfos.Length > 1 || IsPrimitiveOrStringOrEnum(firstParamType)) + { + // Use ContractlessStandardResolver to match client serialization + var msg = message.MessagePackTo>(ContractlessStandardResolver.Options); + + for (var i = 0; i < msg.PostData.Ids.Count; i++) + { + var paramType = methodInfoModel.ParamInfos[i].ParameterType; + // Direct JSON deserialization using AcJsonDeserializer (supports primitives) + paramValues[i] = AcJsonDeserializer.Deserialize(msg.PostData.Ids[i], paramType)!; + } + } + else + { + // Single complex object - try to detect format by checking if it's an IdMessage + var msgJson = message.MessagePackTo>(ContractlessStandardResolver.Options); + var json = msgJson.PostDataJson; + + // Check if the JSON is an IdMessage format (has "Ids" property) + if (json.Contains("\"Ids\"")) + { + // It's IdMessage format - deserialize as IdMessage and get first Id + var idMsg = message.MessagePackTo>(ContractlessStandardResolver.Options); + if (idMsg.PostData.Ids.Count > 0) + { + paramValues[0] = AcJsonDeserializer.Deserialize(idMsg.PostData.Ids[0], firstParamType)!; + return paramValues; + } + } + + // Direct complex object format + paramValues[0] = json.JsonTo(firstParamType)!; + } + + return paramValues; + } + + /// + /// Determines if a type should use IdMessage format (primitives, strings, enums, value types). + /// NOTE: Arrays and collections are NOT included - they use PostDataJson format when sent as single parameter. + /// + private static bool IsPrimitiveOrStringOrEnum(Type type) + { + return type == typeof(string) || + type.IsEnum || + type.IsValueType || + type == typeof(DateTime); + } + + #endregion + + #region Response Methods + + protected virtual Task ResponseToCallerWithContent(int messageTag, object? content) => ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); - protected Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId) + protected virtual Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId) => SendMessageToClient(Clients.Caller, messageTag, message, requestId); - protected Task SendMessageToUserId2(string userId, int messageTag, object? content) - => SendMessageToUserId(userId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); + protected virtual Task SendMessageToUserIdWithContent(string userId, int messageTag, object? content) + => SendMessageToUserIdInternal(userId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); - public Task SendMessageToUserId(string userId, int messageTag, ISignalRMessage message, int? requestId) + protected virtual Task SendMessageToUserIdInternal(string userId, int messageTag, ISignalRMessage message, int? requestId) => SendMessageToClient(Clients.User(userId), messageTag, message, requestId); - public Task SendMessageToConnectionId2(string connectionId, int messageTag, object? content) - => SendMessageToConnectionId(connectionId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); + protected virtual Task SendMessageToConnectionIdWithContent(string connectionId, int messageTag, object? content) + => SendMessageToConnectionIdInternal(connectionId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); - public Task SendMessageToConnectionId(string connectionId, int messageTag, ISignalRMessage message, int? requestId) - => SendMessageToClient(Clients.Client(Context.ConnectionId), messageTag, message, requestId); + protected virtual Task SendMessageToConnectionIdInternal(string connectionId, int messageTag, ISignalRMessage message, int? requestId) + => SendMessageToClient(Clients.Client(connectionId), messageTag, message, requestId); - public Task SendMessageToOtherClients(int messageTag, object? content) + protected virtual Task SendMessageToOthers(int messageTag, object? content) => SendMessageToClient(Clients.Others, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); - public Task SendMessageToAllClients(int messageTag, object? content) + protected virtual Task SendMessageToAll(int messageTag, object? content) => SendMessageToClient(Clients.All, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); - - protected async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) + protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) { var responseDataMessagePack = message.ToMessagePack(ContractlessStandardResolver.Options); - Logger.Debug($"[{(responseDataMessagePack.Length/1024)}kb] Server sending responseDataMessagePack to client; {nameof(requestId)}: {requestId}; Aborted: {Context.ConnectionAborted.IsCancellationRequested}; ConnectionId: {Context.ConnectionId}; {ConstHelper.NameByValue(messageTag)}"); + var tagName = ConstHelper.NameByValue(messageTag); + + Logger.Debug($"[{responseDataMessagePack.Length / 1024}kb] Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}"); await sendTo.OnReceiveMessage(messageTag, responseDataMessagePack, requestId); - Logger.Debug($"Server sent responseDataMessagePack to client; {nameof(requestId)}: {requestId}; Aborted: {Context.ConnectionAborted.IsCancellationRequested}; ConnectionId: {Context.ConnectionId}; {ConstHelper.NameByValue(messageTag)}"); + Logger.Debug($"Server sent message to client; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); } - public async Task SendMessageToGroup(string groupId, int messageTag, string message) - { - //await Clients.Group(groupId).Post("", messageTag, message); - } + #endregion + + #region Context Accessor Methods (virtual for testing) + + /// + /// Gets the connection ID. Override in tests to avoid Context dependency. + /// + protected virtual string GetConnectionId() => Context.ConnectionId; + + /// + /// Gets whether the connection is aborted. Override in tests to avoid Context dependency. + /// + protected virtual bool IsConnectionAborted() => Context.ConnectionAborted.IsCancellationRequested; + + /// + /// Gets the user identifier. Override in tests to avoid Context dependency. + /// + protected virtual string? GetUserIdentifier() => Context.UserIdentifier; + + /// + /// Gets the ClaimsPrincipal user. Override in tests to avoid Context dependency. + /// + protected virtual ClaimsPrincipal? GetUser() => Context.User; + + #endregion + + #region Logging - //[Conditional("DEBUG")] protected virtual void LogContextUserNameAndId() { - string? userName = null; - var userId = Guid.Empty; + var user = GetUser(); + if (user == null) return; - if (Context.User != null) - { - userName = Context.User.Identity?.Name; - Guid.TryParse(Context.User.FindFirstValue(ClaimTypes.NameIdentifier), out userId); - } + var userName = user.Identity?.Name; + Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId); - if (AcDomain.IsDeveloperVersion) Logger.WarningConditional($"SignalR.Context; userName: {userName}; userId: {userId}"); - else Logger.Debug($"SignalR.Context; userName: {userName}; userId: {userId}"); + if (AcDomain.IsDeveloperVersion) + Logger.WarningConditional($"SignalR.Context; userName: {userName}; userId: {userId}"); + else + Logger.Debug($"SignalR.Context; userName: {userName}; userId: {userId}"); } + + #endregion } \ No newline at end of file diff --git a/AyCode.Services.Server/SignalRs/ExtensionMethods.cs b/AyCode.Services.Server/SignalRs/ExtensionMethods.cs index aa20daa..acbc31e 100644 --- a/AyCode.Services.Server/SignalRs/ExtensionMethods.cs +++ b/AyCode.Services.Server/SignalRs/ExtensionMethods.cs @@ -5,14 +5,63 @@ namespace AyCode.Services.Server.SignalRs; public static class ExtensionMethods { + /// + /// Invokes a method and properly unwraps Task/Task<T> results. + /// Handles both async methods and methods returning Task directly (e.g., Task.FromResult). + /// public static object? InvokeMethod(this MethodInfo methodInfo, object obj, params object[]? parameters) { - if (methodInfo.GetCustomAttribute(typeof(AsyncStateMachineAttribute)) is AsyncStateMachineAttribute isAsyncTask) + var result = methodInfo.Invoke(obj, parameters); + + if (result == null) + return null; + + // Check if result is a Task (this handles both async methods AND Task.FromResult) + if (result is Task task) { - dynamic awaitable = methodInfo.Invoke(obj, parameters)!; - return awaitable.GetAwaiter().GetResult(); + // Wait for task completion + task.GetAwaiter().GetResult(); + + // Check if it's Task to extract the actual result + var taskType = task.GetType(); + if (taskType.IsGenericType) + { + // Get the Result property from Task + var resultProperty = taskType.GetProperty("Result"); + if (resultProperty != null) + { + return resultProperty.GetValue(task); + } + } + + // Non-generic Task - no result + return null; } - return methodInfo.Invoke(obj, parameters); + // Handle ValueTask + var type = result.GetType(); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // Convert ValueTask to Task and get result + var asTaskMethod = type.GetMethod("AsTask"); + if (asTaskMethod != null) + { + var taskResult = (Task)asTaskMethod.Invoke(result, null)!; + taskResult.GetAwaiter().GetResult(); + + var resultProperty = taskResult.GetType().GetProperty("Result"); + return resultProperty?.GetValue(taskResult); + } + } + + // Handle non-generic ValueTask + if (result is ValueTask valueTask) + { + valueTask.AsTask().GetAwaiter().GetResult(); + return null; + } + + // Not a Task - return directly + return result; } } \ No newline at end of file diff --git a/AyCode.Services.Tests/AyCode.Services.Tests.csproj b/AyCode.Services.Tests/AyCode.Services.Tests.csproj new file mode 100644 index 0000000..3023216 --- /dev/null +++ b/AyCode.Services.Tests/AyCode.Services.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + latest + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/AyCode.Services.Tests/MSTestSettings.cs b/AyCode.Services.Tests/MSTestSettings.cs new file mode 100644 index 0000000..aaf278c --- /dev/null +++ b/AyCode.Services.Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/AyCode.Services.Tests/SignalRs/AcSignalRClientBaseTests.cs b/AyCode.Services.Tests/SignalRs/AcSignalRClientBaseTests.cs new file mode 100644 index 0000000..9a40cee --- /dev/null +++ b/AyCode.Services.Tests/SignalRs/AcSignalRClientBaseTests.cs @@ -0,0 +1,1158 @@ +using AyCode.Core.Extensions; +using AyCode.Core.Helpers; +using AyCode.Core.Tests.TestModels; +using AyCode.Services.SignalRs; +using MessagePack.Resolvers; +using Microsoft.AspNetCore.SignalR.Client; + +namespace AyCode.Services.Tests.SignalRs; + +[TestClass] +public class AcSignalRClientBaseTests +{ + private TestableSignalRClient _client = null!; + private TestLogger _logger = null!; + + [TestInitialize] + public void Setup() + { + _logger = new TestLogger(); + _client = new TestableSignalRClient(_logger); + _client.TransportSendTimeout = 100; // Short timeout for tests + } + + #region Connection State Tests + + [TestMethod] + public async Task StartConnection_WhenDisconnected_ConnectsSuccessfully() + { + _client.SetConnectionState(HubConnectionState.Disconnected); + + await _client.StartConnection(); + + Assert.IsTrue(_client.GetPendingRequests().IsEmpty); + } + + [TestMethod] + public async Task StopConnection_ClearsState() + { + _client.RegisterPendingRequest(1, new SignalRRequestModel()); + + await _client.StopConnection(); + + // Connection should be stopped (no exception) + } + + #endregion + + #region SendMessageToServerAsync Tests + + [TestMethod] + public async Task SendMessageToServerAsync_SendsMessage() + { + await _client.SendMessageToServerAsync(TestClientTags.Ping); + + Assert.AreEqual(1, _client.SentMessages.Count); + Assert.AreEqual(TestClientTags.Ping, _client.LastSentMessage?.MessageTag); + } + + [TestMethod] + public async Task SendMessageToServerAsync_WithMessage_SerializesCorrectly() + { + var idMessage = new IdMessage(42); + var message = new SignalPostJsonDataMessage(idMessage); + + await _client.SendMessageToServerAsync(TestClientTags.GetById, message, 1); + + Assert.AreEqual(1, _client.SentMessages.Count); + var sent = _client.LastSentMessage; + Assert.IsNotNull(sent?.MessageBytes); + Assert.AreEqual(1, sent.RequestId); + } + + [TestMethod] + public async Task SendMessageToServerAsync_WhenDisconnected_LogsError() + { + _client.SetConnectionState(HubConnectionState.Disconnected); + + await _client.SendMessageToServerAsync(TestClientTags.Ping, null, 1); + + // Should attempt to connect first, then send + Assert.AreEqual(1, _client.SentMessages.Count); + } + + #endregion + + #region GetByIdAsync Tests + + [TestMethod] + public async Task GetByIdAsync_SendsIdMessageWithSingleId() + { + _client.SetNextRequestId(100); + + // Start the async operation but don't await the result (it will timeout) + _ = Task.Run(() => _client.GetByIdAsync(TestClientTags.GetById, 42)); + + // Give it time to send + await Task.Delay(50); + + Assert.IsTrue(_client.SentMessages.Count >= 1); + var sent = _client.SentMessages.FirstOrDefault(m => m.MessageTag == TestClientTags.GetById); + Assert.IsNotNull(sent); + Assert.AreEqual(100, sent.RequestId); + + var idMessage = sent.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("42", idMessage.Ids[0]); // Direct JSON format + } + + [TestMethod] + public async Task GetByIdAsync_WithGuid_SendsCorrectly() + { + var guid = Guid.Parse("12345678-1234-1234-1234-123456789abc"); + _client.SetNextRequestId(101); + + _ = Task.Run(() => _client.GetByIdAsync(TestClientTags.GetById, guid)); + await Task.Delay(50); + + var sent = _client.SentMessages.FirstOrDefault(m => m.MessageTag == TestClientTags.GetById); + Assert.IsNotNull(sent); + var idMessage = sent.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.IsTrue(idMessage.Ids[0].Contains("12345678")); + } + + [TestMethod] + public async Task GetByIdAsync_WithMultipleIds_SendsAllIds() + { + _client.SetNextRequestId(102); + + var task = _client.GetByIdAsync>(TestClientTags.GetById, new object[] { 1, 2, 3 }); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var idMessage = _client.LastSentMessage?.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(3, idMessage.Ids.Count); + } + + [TestMethod] + public async Task GetByIdAsync_WithCallback_InvokesCallback() + { + _client.SetNextRequestId(103); + var callbackInvoked = false; + TestOrderItem? receivedData = null; + + var task = _client.GetByIdAsync(TestClientTags.GetById, async response => + { + callbackInvoked = true; + receivedData = response.ResponseData; + await Task.CompletedTask; + }, 42); + + await Task.Delay(10); + + // Simulate server response + var responseItem = new TestOrderItem { Id = 42, ProductName = "Test Product" }; + await _client.SimulateSuccessResponse(103, TestClientTags.GetById, responseItem); + + await Task.Delay(10); + + Assert.IsTrue(callbackInvoked); + Assert.IsNotNull(receivedData); + Assert.AreEqual(42, receivedData.Id); + Assert.AreEqual("Test Product", receivedData.ProductName); + } + + #endregion + + #region GetAllAsync Tests + + [TestMethod] + public async Task GetAllAsync_SendsMessageWithoutParams() + { + _client.SetNextRequestId(200); + + var task = _client.GetAllAsync>(TestClientTags.GetAll); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + Assert.AreEqual(1, _client.SentMessages.Count); + Assert.AreEqual(TestClientTags.GetAll, _client.LastSentMessage?.MessageTag); + } + + [TestMethod] + public async Task GetAllAsync_WithContextParams_SendsParams() + { + _client.SetNextRequestId(201); + + var task = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { true, "filter" }); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var idMessage = _client.LastSentMessage?.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(2, idMessage.Ids.Count); + } + + [TestMethod] + public async Task GetAllAsync_WithEmptyParams_SendsWithoutMessage() + { + _client.SetNextRequestId(202); + + var task = _client.GetAllAsync>(TestClientTags.GetAll, Array.Empty()); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + // Empty params should not create IdMessage + Assert.IsNull(_client.LastSentMessage?.MessageBytes); + } + + #endregion + + #region PostDataAsync Tests + + [TestMethod] + public async Task PostDataAsync_SendsComplexObject() + { + _client.SetNextRequestId(300); + var orderItem = new TestOrderItem { Id = 1, ProductName = "New Product", Quantity = 5 }; + + _ = Task.Run(() => _client.PostDataAsync(TestClientTags.Create, orderItem)); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + Assert.IsTrue(_client.SentMessages.Count >= 1, $"Expected at least 1 message, got {_client.SentMessages.Count}"); + var sent = _client.SentMessages.FirstOrDefault(m => m.MessageTag == TestClientTags.Create); + Assert.IsNotNull(sent); + Assert.IsNotNull(sent.MessageBytes); + + var postData = sent.AsPostData(); + Assert.IsNotNull(postData); + Assert.AreEqual("New Product", postData.ProductName); + } + + [TestMethod] + public async Task PostDataAsync_WithNestedObject_SerializesCorrectly() + { + _client.SetNextRequestId(301); + var order = TestDataFactory.CreateOrder(itemCount: 2); + + _ = Task.Run(() => _client.PostDataAsync(TestClientTags.PostOrder, order)); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var postData = _client.LastSentMessage?.AsPostData(); + Assert.IsNotNull(postData); + Assert.AreEqual(2, postData.Items.Count); + } + + #endregion + + #region OnReceiveMessage Tests + + [TestMethod] + public async Task OnReceiveMessage_WithPendingRequest_SetsResponse() + { + var requestId = 400; + // Register with null callback - response will be stored in ResponseByRequestId + _client.RegisterPendingRequest(requestId, new SignalRRequestModel()); + + await _client.SimulateSuccessResponse(requestId, TestClientTags.Echo, "Hello"); + + // After receiving response, the request should have ResponseByRequestId set + // The pending request gets removed after callback handling, but for null callback + // it stays with the response set + var pending = _client.GetPendingRequests(); + if (pending.TryGetValue(requestId, out var model)) + { + Assert.IsNotNull(model.ResponseByRequestId); + } + // If not in pending, it means it was handled (which is also valid) + } + + [TestMethod] + public async Task OnReceiveMessage_WithCallback_InvokesCallback() + { + var requestId = 401; + var callbackInvoked = false; + string? receivedData = null; + + _client.RegisterPendingRequest(requestId, new SignalRRequestModel(new Action>(response => + { + callbackInvoked = true; + receivedData = response.ResponseData; + }))); + + await _client.SimulateSuccessResponse(requestId, TestClientTags.Echo, "Hello World"); + + Assert.IsTrue(callbackInvoked); + Assert.IsNotNull(receivedData); + } + + [TestMethod] + public async Task OnReceiveMessage_WithoutPendingRequest_CallsMessageReceived() + { + var response = new SignalResponseJsonMessage(TestClientTags.GetStatus, SignalResponseStatus.Success, "OK"); + var bytes = response.ToMessagePack(ContractlessStandardResolver.Options); + + await _client.InvokeOnReceiveMessage(TestClientTags.GetStatus, bytes, null); + + Assert.AreEqual(1, _client.ReceivedMessages.Count); + Assert.AreEqual(TestClientTags.GetStatus, _client.ReceivedMessages[0].MessageTag); + } + + [TestMethod] + public async Task OnReceiveMessage_WithEmptyBytes_LogsWarning() + { + await _client.InvokeOnReceiveMessage(TestClientTags.Echo, Array.Empty(), 999); + + Assert.IsTrue(_logger.HasWarningLogs); + } + + #endregion + + #region Error Handling Tests + + [TestMethod] + public async Task GetByIdAsync_WithErrorResponse_ReturnsDefault() + { + _client.SetNextRequestId(500); + + var task = Task.Run(async () => + { + try + { + return await _client.GetByIdAsync(TestClientTags.NotFound, 999); + } + catch + { + return null; + } + }); + + await Task.Delay(10); + await _client.SimulateErrorResponse(500, TestClientTags.NotFound); + + var result = await task; + Assert.IsNull(result); + } + + [TestMethod] + public async Task SendMessageToServerAsync_WithZeroTag_LogsError() + { + await _client.SendMessageToServerAsync(0, response => Task.CompletedTask); + + Assert.IsTrue(_logger.HasErrorLogs); + } + + #endregion + + #region Complex Scenario Tests + + [TestMethod] + public async Task MultipleParallelRequests_HandleCorrectly() + { + _client.SetNextRequestId(600); + + // Start multiple requests + var task1 = Task.Run(async () => + { + var t = _client.GetByIdAsync(TestClientTags.GetById, 1); + await Task.Delay(5); + return 600; + }); + + var task2 = Task.Run(async () => + { + var t = _client.GetByIdAsync(TestClientTags.GetById, 2); + await Task.Delay(5); + return 601; + }); + + await Task.WhenAll(task1, task2); + + // Both requests should have been sent + Assert.AreEqual(2, _client.SentMessages.Count); + } + + [TestMethod] + public async Task ConcurrentResponseHandling_ResolvesCorrectRequests() + { + _client.SetNextRequestId(700); + + // Register two pending requests with callbacks + var results = new Dictionary(); + + _client.RegisterPendingRequest(700, new SignalRRequestModel(new Action>(response => + { + results[700] = response.ResponseData; + }))); + + _client.RegisterPendingRequest(701, new SignalRRequestModel(new Action>(response => + { + results[701] = response.ResponseData; + }))); + + // Simulate responses in reverse order + await _client.SimulateSuccessResponse(701, TestClientTags.Echo, "Response 701"); + await _client.SimulateSuccessResponse(700, TestClientTags.Echo, "Response 700"); + + // Each request should get its correct response + Assert.AreEqual(2, results.Count); + } + + #endregion + + #region IdMessage Single Primitive Parameter Tests + + [TestMethod] + public void IdMessage_WithInt_SerializesAsNumber() + { + var idMessage = new IdMessage(42); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("42", idMessage.Ids[0]); + } + + [TestMethod] + public void IdMessage_WithLong_SerializesAsNumber() + { + var idMessage = new IdMessage(9223372036854775807L); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("9223372036854775807", idMessage.Ids[0]); + } + + [TestMethod] + public void IdMessage_WithBoolTrue_SerializesCorrectly() + { + var idMessage = new IdMessage(true); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("true", idMessage.Ids[0]); + } + + [TestMethod] + public void IdMessage_WithBoolFalse_SerializesCorrectly() + { + var idMessage = new IdMessage(false); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("false", idMessage.Ids[0]); + } + + [TestMethod] + public void IdMessage_WithString_SerializesAsQuotedString() + { + var idMessage = new IdMessage("hello"); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("\"hello\"", idMessage.Ids[0]); + } + + [TestMethod] + public void IdMessage_WithDouble_SerializesAsNumber() + { + var idMessage = new IdMessage(3.14159); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].StartsWith("3.14")); + } + + [TestMethod] + public void IdMessage_WithDecimal_SerializesAsNumber() + { + var idMessage = new IdMessage(99.99m); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("99.99", idMessage.Ids[0]); + } + + [TestMethod] + public void IdMessage_WithGuid_SerializesAsQuotedString() + { + var guid = Guid.Parse("12345678-1234-1234-1234-123456789abc"); + var idMessage = new IdMessage(guid); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("12345678")); + Assert.IsTrue(idMessage.Ids[0].StartsWith("\"") && idMessage.Ids[0].EndsWith("\"")); + } + + [TestMethod] + public void IdMessage_WithDateTime_SerializesAsQuotedString() + { + var date = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc); + var idMessage = new IdMessage(date); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("2024")); + } + + [TestMethod] + public void IdMessage_WithEnum_SerializesAsNumber() + { + var idMessage = new IdMessage(TestStatus.Processing); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("2", idMessage.Ids[0]); // Processing = 2 + } + + #endregion + + #region IdMessage Multiple Primitive Parameters Tests + + [TestMethod] + public void IdMessage_WithTwoInts_SerializesCorrectly() + { + var idMessage = new IdMessage(new object[] { 10, 20 }); + + Assert.AreEqual(2, idMessage.Ids.Count); + Assert.AreEqual("10", idMessage.Ids[0]); + Assert.AreEqual("20", idMessage.Ids[1]); + } + + [TestMethod] + public void IdMessage_WithThreeMixedTypes_SerializesCorrectly() + { + var idMessage = new IdMessage(new object[] { true, "test", 123 }); + + Assert.AreEqual(3, idMessage.Ids.Count); + Assert.AreEqual("true", idMessage.Ids[0]); + Assert.AreEqual("\"test\"", idMessage.Ids[1]); + Assert.AreEqual("123", idMessage.Ids[2]); + } + + [TestMethod] + public void IdMessage_WithFiveParams_SerializesCorrectly() + { + var testGuid = Guid.NewGuid(); + var idMessage = new IdMessage(new object[] { 42, "hello", true, testGuid, 99.99m }); + + Assert.AreEqual(5, idMessage.Ids.Count); + Assert.AreEqual("42", idMessage.Ids[0]); + Assert.AreEqual("\"hello\"", idMessage.Ids[1]); + Assert.AreEqual("true", idMessage.Ids[2]); + Assert.IsTrue(idMessage.Ids[3].Contains(testGuid.ToString())); + Assert.AreEqual("99.99", idMessage.Ids[4]); + } + + [TestMethod] + public void IdMessage_WithIntBoolStringGuidDecimal_AllTypesSerializeCorrectly() + { + var guid = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + var idMessage = new IdMessage(new object[] { + 42, // int + true, // bool + "text", // string + guid, // Guid + 123.45m // decimal + }); + + Assert.AreEqual(5, idMessage.Ids.Count); + Assert.AreEqual("42", idMessage.Ids[0]); + Assert.AreEqual("true", idMessage.Ids[1]); + Assert.AreEqual("\"text\"", idMessage.Ids[2]); + Assert.IsTrue(idMessage.Ids[3].Contains("aaaaaaaa")); + Assert.AreEqual("123.45", idMessage.Ids[4]); + } + + #endregion + + #region IdMessage No Parameters Tests + + [TestMethod] + public void IdMessage_WithEmptyArray_HasNoIds() + { + var idMessage = new IdMessage(Array.Empty()); + + Assert.AreEqual(0, idMessage.Ids.Count); + } + + [TestMethod] + public async Task GetAllAsync_WithNullParams_SendsWithoutMessage() + { + _client.SetNextRequestId(203); + + object[]? nullParams = null; + var task = _client.GetAllAsync>(TestClientTags.GetAll, nullParams); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + // Null params should not create IdMessage + Assert.IsNull(_client.LastSentMessage?.MessageBytes); + } + + [TestMethod] + public async Task SendMessageToServerAsync_NoParams_SendsWithoutBytes() + { + await _client.SendMessageToServerAsync(TestClientTags.Ping, null, 1); + + Assert.AreEqual(1, _client.SentMessages.Count); + Assert.IsNull(_client.LastSentMessage?.MessageBytes); + } + + #endregion + + #region IdMessage Complex Object Tests + + [TestMethod] + public void IdMessage_WithComplexObject_SerializesAsJson() + { + var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 5 }; + var idMessage = new IdMessage(item); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("\"ProductName\"")); + Assert.IsTrue(idMessage.Ids[0].Contains("\"Test\"")); + } + + [TestMethod] + public void IdMessage_WithNestedObject_SerializesCorrectly() + { + var order = new TestOrder + { + Id = 100, + OrderNumber = "ORD-001", + Items = new List + { + new() { Id = 1, ProductName = "Item A" }, + new() { Id = 2, ProductName = "Item B" } + } + }; + var idMessage = new IdMessage(order); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("ORD-001")); + Assert.IsTrue(idMessage.Ids[0].Contains("Item A")); + Assert.IsTrue(idMessage.Ids[0].Contains("Item B")); + } + + [TestMethod] + public void IdMessage_WithSharedTag_IIdType_SerializesCorrectly() + { + var tag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" }; + var idMessage = new IdMessage(tag); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("Important")); + Assert.IsTrue(idMessage.Ids[0].Contains("#FF0000")); + } + + #endregion + + #region IdMessage Array/Collection Tests + + [TestMethod] + public void IdMessage_WithIntArray_SerializesAsJsonArray() + { + var values = new[] { 1, 2, 3, 4, 5 }; + var idMessage = new IdMessage(values); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("[")); + Assert.IsTrue(idMessage.Ids[0].Contains("1")); + Assert.IsTrue(idMessage.Ids[0].Contains("5")); + } + + [TestMethod] + public void IdMessage_WithStringList_SerializesAsJsonArray() + { + var items = new List { "apple", "banana", "cherry" }; + var idMessage = new IdMessage(items); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("apple")); + Assert.IsTrue(idMessage.Ids[0].Contains("banana")); + } + + [TestMethod] + public void IdMessage_WithGuidArray_SerializesAsJsonArray() + { + // Guid[] is treated as a single object and serialized as JSON array + // Use new object[] { guids } to pass it as single parameter + var guids = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var idMessage = new IdMessage(new object[] { guids }); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].StartsWith("[")); + Assert.IsTrue(idMessage.Ids[0].EndsWith("]")); + } + + [TestMethod] + public void IdMessage_WithGuidArrayAsMultipleParams_EnumeratesEachGuid() + { + // Using IEnumerable constructor - each guid becomes separate Id + var guids = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var idMessage = new IdMessage((IEnumerable)guids); + + Assert.AreEqual(2, idMessage.Ids.Count); + } + + [TestMethod] + public void IdMessage_WithEmptyArray_SerializesAsEmptyJsonArray() + { + var empty = Array.Empty(); + var idMessage = new IdMessage(empty); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("[]", idMessage.Ids[0]); + } + + [TestMethod] + public void IdMessage_WithTestOrderItemList_SerializesAsJsonArray() + { + var items = new List + { + new() { Id = 1, ProductName = "First" }, + new() { Id = 2, ProductName = "Second" } + }; + var idMessage = new IdMessage(items); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("First")); + Assert.IsTrue(idMessage.Ids[0].Contains("Second")); + } + + #endregion + + #region IdMessage Mixed Parameters (Primitive + Complex + Collection) Tests + + [TestMethod] + public void IdMessage_WithIntAndDto_SerializesCorrectly() + { + var item = new TestOrderItem { Id = 10, ProductName = "Test" }; + var idMessage = new IdMessage(new object[] { 42, item }); + + Assert.AreEqual(2, idMessage.Ids.Count); + Assert.AreEqual("42", idMessage.Ids[0]); + Assert.IsTrue(idMessage.Ids[1].Contains("Test")); + } + + [TestMethod] + public void IdMessage_WithDtoAndList_SerializesCorrectly() + { + var item = new TestOrderItem { Id = 1, ProductName = "Product" }; + var numbers = new List { 1, 2, 3 }; + var idMessage = new IdMessage(new object[] { item, numbers }); + + Assert.AreEqual(2, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("Product")); + Assert.IsTrue(idMessage.Ids[1].Contains("[")); + } + + [TestMethod] + public void IdMessage_WithBoolArrayString_MixedWithArray_SerializesCorrectly() + { + var numbers = new[] { 1, 2, 3 }; + var idMessage = new IdMessage(new object[] { true, numbers, "hello" }); + + Assert.AreEqual(3, idMessage.Ids.Count); + Assert.AreEqual("true", idMessage.Ids[0]); + Assert.IsTrue(idMessage.Ids[1].Contains("[1,2,3]") || idMessage.Ids[1].Contains("[1, 2, 3]")); + Assert.AreEqual("\"hello\"", idMessage.Ids[2]); + } + + [TestMethod] + public void IdMessage_WithThreeComplexParams_SerializesCorrectly() + { + var item = new TestOrderItem { Id = 1, ProductName = "Product" }; + var tags = new List { "tag1", "tag2" }; + var sharedTag = new SharedTag { Id = 1, Name = "Shared" }; + var idMessage = new IdMessage(new object[] { item, tags, sharedTag }); + + Assert.AreEqual(3, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("Product")); + Assert.IsTrue(idMessage.Ids[1].Contains("tag1")); + Assert.IsTrue(idMessage.Ids[2].Contains("Shared")); + } + + [TestMethod] + public void IdMessage_WithFiveMixedParams_AllTypesSerialize() + { + var guid = Guid.NewGuid(); + var item = new TestOrderItem { Id = 5, ProductName = "Mixed" }; + var numbers = new[] { 10, 20, 30 }; + + var idMessage = new IdMessage(new object[] { + 42, // int + guid, // Guid + item, // complex object + numbers, // array + "final" // string + }); + + Assert.AreEqual(5, idMessage.Ids.Count); + Assert.AreEqual("42", idMessage.Ids[0]); + Assert.IsTrue(idMessage.Ids[1].Contains(guid.ToString())); + Assert.IsTrue(idMessage.Ids[2].Contains("Mixed")); + Assert.IsTrue(idMessage.Ids[3].Contains("[")); + Assert.AreEqual("\"final\"", idMessage.Ids[4]); + } + + #endregion + + #region GetAllAsync Parameter Type Tests + + [TestMethod] + public async Task GetAllAsync_WithSingleBoolParam_SerializesCorrectly() + { + _client.SetNextRequestId(210); + + _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { true }); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var idMessage = _client.LastSentMessage?.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("true", idMessage.Ids[0]); + } + + [TestMethod] + public async Task GetAllAsync_WithIntAndString_SerializesCorrectly() + { + _client.SetNextRequestId(211); + + _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { 100, "filter" }); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var idMessage = _client.LastSentMessage?.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(2, idMessage.Ids.Count); + Assert.AreEqual("100", idMessage.Ids[0]); + Assert.AreEqual("\"filter\"", idMessage.Ids[1]); + } + + [TestMethod] + public async Task GetAllAsync_WithGuidParam_SerializesCorrectly() + { + _client.SetNextRequestId(212); + var guid = Guid.NewGuid(); + + _ = _client.GetAllAsync(TestClientTags.GetAll, new object[] { guid }); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var idMessage = _client.LastSentMessage?.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains(guid.ToString())); + } + + [TestMethod] + public async Task GetAllAsync_WithComplexObjectParam_SerializesCorrectly() + { + _client.SetNextRequestId(213); + var filter = new TestOrderItem { Id = 0, ProductName = "SearchFilter" }; + + _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { filter }); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var idMessage = _client.LastSentMessage?.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("SearchFilter")); + } + + [TestMethod] + public async Task GetAllAsync_WithArrayParam_SerializesCorrectly() + { + _client.SetNextRequestId(214); + var ids = new[] { 1, 2, 3, 4, 5 }; + + _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { ids }); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var idMessage = _client.LastSentMessage?.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("[")); + } + + [TestMethod] + public async Task GetAllAsync_WithThreeMixedParams_SerializesCorrectly() + { + _client.SetNextRequestId(215); + var tag = new SharedTag { Id = 1, Name = "Filter" }; + + _ = _client.GetAllAsync>(TestClientTags.GetAll, new object[] { true, 50, tag }); + await TaskHelper.WaitToAsync(() => _client.SentMessages.Count > 0, 1000, 10); + + var idMessage = _client.LastSentMessage?.AsIdMessage(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(3, idMessage.Ids.Count); + Assert.AreEqual("true", idMessage.Ids[0]); + Assert.AreEqual("50", idMessage.Ids[1]); + Assert.IsTrue(idMessage.Ids[2].Contains("Filter")); + } + + #endregion + + #region PostDataAsync Parameter Type Tests + + [TestMethod] + public async Task PostDataAsync_WithSimpleDto_SerializesCorrectly() + { + _client.SetNextRequestId(320); + var item = new TestOrderItem { Id = 1, ProductName = "Simple", Quantity = 10, UnitPrice = 25.50m }; + + _ = Task.Run(() => _client.PostDataAsync(TestClientTags.Create, item)); + await Task.Delay(50); + + var postData = _client.LastSentMessage?.AsPostData(); + Assert.IsNotNull(postData); + Assert.AreEqual("Simple", postData.ProductName); + Assert.AreEqual(10, postData.Quantity); + Assert.AreEqual(25.50m, postData.UnitPrice); + } + + [TestMethod] + public async Task PostDataAsync_WithDtoContainingList_SerializesCorrectly() + { + _client.SetNextRequestId(321); + var order = new TestOrder + { + Id = 1, + OrderNumber = "ORD-TEST", + Items = new List + { + new() { Id = 1, ProductName = "A", Quantity = 1 }, + new() { Id = 2, ProductName = "B", Quantity = 2 }, + new() { Id = 3, ProductName = "C", Quantity = 3 } + } + }; + + _ = Task.Run(() => _client.PostDataAsync(TestClientTags.PostOrder, order)); + await Task.Delay(50); + + var postData = _client.LastSentMessage?.AsPostData(); + Assert.IsNotNull(postData); + Assert.AreEqual("ORD-TEST", postData.OrderNumber); + Assert.AreEqual(3, postData.Items.Count); + } + + [TestMethod] + public async Task PostDataAsync_WithDtoContainingSharedRefs_SerializesCorrectly() + { + _client.SetNextRequestId(322); + var sharedTag = new SharedTag { Id = 1, Name = "Shared" }; + var order = new TestOrder + { + Id = 1, + OrderNumber = "ORD-SHARED", + PrimaryTag = sharedTag, + SecondaryTag = sharedTag + }; + + _ = Task.Run(() => _client.PostDataAsync(TestClientTags.PostOrder, order)); + await Task.Delay(50); + + var postData = _client.LastSentMessage?.AsPostData(); + Assert.IsNotNull(postData); + Assert.IsNotNull(postData.PrimaryTag); + Assert.AreEqual("Shared", postData.PrimaryTag.Name); + } + + [TestMethod] + public async Task PostDataAsync_WithList_SerializesCorrectly() + { + _client.SetNextRequestId(323); + var items = new List + { + new() { Id = 1, ProductName = "First" }, + new() { Id = 2, ProductName = "Second" } + }; + + _ = Task.Run(() => _client.PostDataAsync, List>(TestClientTags.Create, items)); + await Task.Delay(50); + + var postData = _client.LastSentMessage?.AsPostData>(); + Assert.IsNotNull(postData); + Assert.AreEqual(2, postData.Count); + } + + #endregion + + #region Edge Cases Tests + + [TestMethod] + public void IdMessage_WithNullInArray_HandlesGracefully() + { + // This tests how null values are handled - they should serialize as "null" + var idMessage = new IdMessage(new object[] { 42, null!, "text" }); + + Assert.AreEqual(3, idMessage.Ids.Count); + Assert.AreEqual("42", idMessage.Ids[0]); + // null should serialize as "null" JSON literal + Assert.AreEqual("null", idMessage.Ids[1]); + Assert.AreEqual("\"text\"", idMessage.Ids[2]); + } + + [TestMethod] + public void IdMessage_WithSpecialCharactersInString_EscapesCorrectly() + { + var idMessage = new IdMessage("hello \"world\" \n\t\\"); + + Assert.AreEqual(1, idMessage.Ids.Count); + // Should contain escaped characters + Assert.IsTrue(idMessage.Ids[0].Contains("\\\"")); + } + + [TestMethod] + public void IdMessage_WithEmptyString_SerializesAsEmptyQuotedString() + { + var idMessage = new IdMessage(""); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.AreEqual("\"\"", idMessage.Ids[0]); + } + + [TestMethod] + public void IdMessage_WithZeroValues_SerializesCorrectly() + { + var idMessage = new IdMessage(new object[] { 0, 0L, 0.0, 0m }); + + Assert.AreEqual(4, idMessage.Ids.Count); + Assert.AreEqual("0", idMessage.Ids[0]); + Assert.AreEqual("0", idMessage.Ids[1]); + Assert.AreEqual("0", idMessage.Ids[2]); + Assert.AreEqual("0", idMessage.Ids[3]); + } + + [TestMethod] + public void IdMessage_WithGuidEmpty_SerializesCorrectly() + { + var idMessage = new IdMessage(Guid.Empty); + + Assert.AreEqual(1, idMessage.Ids.Count); + Assert.IsTrue(idMessage.Ids[0].Contains("00000000-0000-0000-0000-000000000000")); + } + + #endregion + + #region MessagePack Serialization Round-Trip Tests + + [TestMethod] + public void SignalPostJsonDataMessage_MessagePackRoundTrip_PreservesPostDataJson() + { + // Arrange + var original = new SignalPostJsonDataMessage(new IdMessage(42)); + + // Act - Serialize to MessagePack + var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); + + // Deserialize back + var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); + + // Assert + Assert.IsNotNull(deserialized); + Assert.IsNotNull(deserialized.PostDataJson, "PostDataJson should not be null after deserialization"); + Assert.IsFalse(string.IsNullOrEmpty(deserialized.PostDataJson), "PostDataJson should not be empty"); + + // Verify PostData can be accessed + Assert.IsNotNull(deserialized.PostData); + Assert.AreEqual(1, deserialized.PostData.Ids.Count); + Assert.AreEqual("42", deserialized.PostData.Ids[0]); + } + + [TestMethod] + public void SignalPostJsonDataMessage_WithComplexObject_MessagePackRoundTrip() + { + // Arrange + var order = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 5 }; + var original = new SignalPostJsonDataMessage(order); + + // Act + var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); + var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); + + // Assert + Assert.IsNotNull(deserialized); + Assert.IsNotNull(deserialized.PostDataJson); + Assert.IsNotNull(deserialized.PostData); + Assert.AreEqual("Test", deserialized.PostData.ProductName); + Assert.AreEqual(5, deserialized.PostData.Quantity); + } + + [TestMethod] + public void SignalPostJsonMessage_BaseClass_MessagePackRoundTrip() + { + // Test base class directly + var original = new SignalPostJsonMessage { PostDataJson = "{\"test\":123}" }; + + var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); + var deserialized = bytes.MessagePackTo(ContractlessStandardResolver.Options); + + Assert.IsNotNull(deserialized); + Assert.AreEqual("{\"test\":123}", deserialized.PostDataJson); + } + + [TestMethod] + public void IdMessage_InSignalPostJsonDataMessage_RoundTrip_PreservesAllIds() + { + // Arrange - Multiple IDs + var idMessage = new IdMessage(new object[] { 1, "test", true, Guid.Empty }); + var original = new SignalPostJsonDataMessage(idMessage); + + // Act + var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); + var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); + + // Assert + Assert.IsNotNull(deserialized?.PostData); + Assert.AreEqual(4, deserialized.PostData.Ids.Count); + Assert.AreEqual("1", deserialized.PostData.Ids[0]); + Assert.AreEqual("\"test\"", deserialized.PostData.Ids[1]); + Assert.AreEqual("true", deserialized.PostData.Ids[2]); + } + + [TestMethod] + public void SignalPostJsonDataMessage_DeserializeAsBaseType_WorksCorrectly() + { + // This simulates what the server does - deserializing as SignalPostJsonMessage (base type) + var original = new SignalPostJsonDataMessage(new IdMessage(42)); + + // Serialize as derived type + var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); + + // Deserialize as BASE type (like server might do) + var deserialized = bytes.MessagePackTo(ContractlessStandardResolver.Options); + + // Assert - PostDataJson should still be available + Assert.IsNotNull(deserialized); + Assert.IsNotNull(deserialized.PostDataJson); + Assert.IsFalse(string.IsNullOrEmpty(deserialized.PostDataJson)); + + // Should be able to manually deserialize the JSON + var idMessage = deserialized.PostDataJson.JsonTo(); + Assert.IsNotNull(idMessage); + Assert.AreEqual(1, idMessage.Ids.Count); + } + + [TestMethod] + public void SignalPostJsonDataMessage_WithIdMessage_ContainingInt_RoundTrip() + { + // Arrange - This is exactly what the client does when calling PostDataAsync + var idMessage = new IdMessage(42); + var original = new SignalPostJsonDataMessage(idMessage); + + // Debug: print what's in PostDataJson + Console.WriteLine($"IdMessage.Ids[0]: {idMessage.Ids[0]}"); + Console.WriteLine($"Original PostDataJson: {original.PostDataJson}"); + + // Act - Serialize to MessagePack (what client does) + var bytes = original.ToMessagePack(ContractlessStandardResolver.Options); + Console.WriteLine($"MessagePack bytes length: {bytes.Length}"); + + // Deserialize back (what server does) + var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); + + // Assert + Assert.IsNotNull(deserialized); + Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}"); + Assert.IsNotNull(deserialized.PostDataJson, "PostDataJson should not be null"); + Assert.IsFalse(string.IsNullOrEmpty(deserialized.PostDataJson), "PostDataJson should not be empty"); + + // This is the key - PostData should be accessible + Assert.IsNotNull(deserialized.PostData, "PostData should be deserializable"); + Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized.PostData.Ids.Count}"); + Assert.AreEqual(1, deserialized.PostData.Ids.Count, "Should have 1 Id"); + Assert.AreEqual("42", deserialized.PostData.Ids[0], "Id should be '42'"); + } + + #endregion +} diff --git a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs new file mode 100644 index 0000000..7d08301 --- /dev/null +++ b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs @@ -0,0 +1,130 @@ +using AyCode.Core.Extensions; +using AyCode.Services.SignalRs; +using MessagePack.Resolvers; + +namespace AyCode.Services.Tests.SignalRs; + +[TestClass] +public class PostJsonDataMessageTests +{ + [TestMethod] + public void Debug_CreatePostMessage_ForInt() + { + // Test what CreatePostMessage produces for an int + var message = CreatePostMessageTest(42); + + Console.WriteLine($"Message type: {message.GetType().Name}"); + + if (message is SignalPostJsonDataMessage idMsg) + { + Console.WriteLine($"PostDataJson: {idMsg.PostDataJson}"); + Console.WriteLine($"PostData.Ids.Count: {idMsg.PostData.Ids.Count}"); + Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}"); + } + + // Serialize to MessagePack + var bytes = message.ToMessagePack(ContractlessStandardResolver.Options); + Console.WriteLine($"MessagePack bytes: {bytes.Length}"); + + // Deserialize as server would + var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options); + Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}"); + Console.WriteLine($"Deserialized PostData type: {deserialized.PostData?.GetType().Name}"); + Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized.PostData?.Ids.Count}"); + + Assert.IsNotNull(deserialized.PostData); + Assert.AreEqual(1, deserialized.PostData.Ids.Count); + } + + [TestMethod] + [DataRow(42)] + [DataRow("45")] + [DataRow(true)] + public void IdMessage_FullRoundTrip_AnyParameter(object testValue) + { + dynamic GetValueByType(object value) + { + if (value is int valueInt) return valueInt; + if (value is bool valueBool) return valueBool; + if (value is string valueString) return valueString; + + Assert.Fail($"Type of testValue not implemented"); + return null; + } + + // Step 1: Client creates message for int parameter (like PostDataAsync) + Console.WriteLine("=== Step 1: Client creates message ==="); + + var idMessage = new IdMessage(GetValueByType(testValue)); + Console.WriteLine($"IdMessage.Ids[0]: '{idMessage.Ids[0]}'"); + + var clientMessage = new SignalPostJsonDataMessage(idMessage); + Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'"); + + // Step 2: Serialize to MessagePack (client sends) + Console.WriteLine("\n=== Step 2: MessagePack serialization ==="); + var bytes = clientMessage.ToMessagePack(ContractlessStandardResolver.Options); + Console.WriteLine($"MessagePack bytes: {bytes.Length}"); + + // Step 3: Server deserializes + Console.WriteLine("\n=== Step 3: Server deserializes ==="); + var serverMessage = bytes.MessagePackTo>(ContractlessStandardResolver.Options); + Console.WriteLine($"Server PostDataJson: '{serverMessage.PostDataJson}'"); + Console.WriteLine($"Server PostData.Ids.Count: {serverMessage.PostData?.Ids.Count}"); + Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage.PostData?.Ids[0]}'"); + + // Step 4: Server deserializes parameter + Console.WriteLine("\n=== Step 4: Server deserializes parameter ==="); + var paramJson = serverMessage.PostData.Ids[0]; + Console.WriteLine($"Parameter JSON: '{paramJson}'"); + var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType()); + Console.WriteLine($"Deserialized int value: {paramValue}"); + + // Step 5: Service method returns string + Console.WriteLine("\n=== Step 5: Service method returns ==="); + var serviceResult = $"{paramValue}"; // Like HandleSingleInt does + Console.WriteLine($"Service result: '{serviceResult}'"); + + // Step 6: Server creates response + Console.WriteLine("\n=== Step 6: Server creates response ==="); + var response = new SignalResponseJsonMessage(100, SignalResponseStatus.Success, serviceResult); + Console.WriteLine($"Response.ResponseData: '{response.ResponseData}'"); + + // Step 7: Serialize response to MessagePack + Console.WriteLine("\n=== Step 7: Response MessagePack ==="); + var responseBytes = response.ToMessagePack(ContractlessStandardResolver.Options); + Console.WriteLine($"Response MessagePack bytes: {responseBytes.Length}"); + + // Step 8: Client deserializes response + Console.WriteLine("\n=== Step 8: Client deserializes response ==="); + var clientResponse = responseBytes.MessagePackTo(ContractlessStandardResolver.Options); + Console.WriteLine($"Client ResponseData: '{clientResponse.ResponseData}'"); + + // Step 9: Client deserializes to target type (string) + Console.WriteLine("\n=== Step 9: Client deserializes to string ==="); + try + { + var finalResult = clientResponse.ResponseData.JsonTo(); + + Console.WriteLine($"Final result: '{finalResult}'"); + Assert.AreEqual(GetValueByType(testValue).ToString(), finalResult); + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: {ex.Message}"); + throw; + } + } + + private static ISignalRMessage CreatePostMessageTest(TPostData postData) + { + var type = typeof(TPostData); + + if (type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime)) + { + return new SignalPostJsonDataMessage(new IdMessage(postData!)); + } + + return new SignalPostJsonDataMessage(postData); + } +} \ No newline at end of file diff --git a/AyCode.Services.Tests/SignalRs/TestClientTags.cs b/AyCode.Services.Tests/SignalRs/TestClientTags.cs new file mode 100644 index 0000000..32599e2 --- /dev/null +++ b/AyCode.Services.Tests/SignalRs/TestClientTags.cs @@ -0,0 +1,30 @@ +using AyCode.Services.SignalRs; + +namespace AyCode.Services.Tests.SignalRs; + +/// +/// SignalR message tags for client testing. +/// +public static class TestClientTags +{ + // Basic operations + public const int Ping = 1; + public const int Echo = 2; + public const int GetStatus = 3; + + // CRUD operations + public const int GetById = 10; + public const int GetAll = 11; + public const int Create = 12; + public const int Update = 13; + public const int Delete = 14; + + // Complex operations + public const int GetOrderWithItems = 20; + public const int PostOrder = 21; + public const int GetMultipleParams = 22; + + // Error scenarios + public const int NotFound = 100; + public const int ServerError = 101; +} diff --git a/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs b/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs new file mode 100644 index 0000000..9f69780 --- /dev/null +++ b/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs @@ -0,0 +1,231 @@ +using AyCode.Core; +using AyCode.Core.Extensions; +using AyCode.Core.Tests.TestModels; +using AyCode.Services.SignalRs; +using MessagePack.Resolvers; +using Microsoft.AspNetCore.SignalR.Client; + +namespace AyCode.Services.Tests.SignalRs; + +/// +/// Testable SignalR client that allows testing without real HubConnection. +/// +public class TestableSignalRClient : AcSignalRClientBase +{ + private HubConnectionState _connectionState = HubConnectionState.Connected; + private int? _nextRequestIdOverride; + + /// + /// Messages sent to the server (captured for assertions). + /// + public List SentMessages { get; } = []; + + /// + /// Received messages (captured for assertions). + /// + public List ReceivedMessages { get; } = []; + + public TestableSignalRClient(TestLogger logger) : base(logger) + { + } + + #region Override virtual methods for testing + + protected override HubConnectionState GetConnectionState() => _connectionState; + + protected override bool IsConnected() => _connectionState == HubConnectionState.Connected; + + protected override Task StartConnectionInternal() + { + _connectionState = HubConnectionState.Connected; + return Task.CompletedTask; + } + + protected override Task StopConnectionInternal() + { + _connectionState = HubConnectionState.Disconnected; + return Task.CompletedTask; + } + + protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask; + + protected override Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId) + { + SentMessages.Add(new SentClientMessage(messageTag, messageBytes, requestId)); + return Task.CompletedTask; + } + + protected override int GetNextRequestId() + { + if (_nextRequestIdOverride.HasValue) + { + var id = _nextRequestIdOverride.Value; + _nextRequestIdOverride = id + 1; // Auto-increment for subsequent calls + return id; + } + return AcDomain.NextUniqueInt32; + } + + protected override Task MessageReceived(int messageTag, byte[] messageBytes) + { + ReceivedMessages.Add(new ReceivedClientMessage(messageTag, messageBytes)); + return Task.CompletedTask; + } + + #endregion + + #region Public test helpers (wrappers for protected methods) + + /// + /// Sets the simulated connection state. + /// + public void SetConnectionState(HubConnectionState state) => _connectionState = state; + + /// + /// Sets the next request ID for deterministic testing. + /// Will auto-increment for subsequent calls. + /// + public void SetNextRequestId(int id) => _nextRequestIdOverride = id; + + /// + /// Gets the pending requests dictionary (public wrapper for testing). + /// + public new System.Collections.Concurrent.ConcurrentDictionary GetPendingRequests() + => base.GetPendingRequests(); + + /// + /// Registers a pending request (public wrapper for testing). + /// + public new void RegisterPendingRequest(int requestId, SignalRRequestModel model) + => base.RegisterPendingRequest(requestId, model); + + /// + /// Clears pending requests (public wrapper for testing). + /// + public new void ClearPendingRequests() => base.ClearPendingRequests(); + + /// + /// Simulates receiving a response from the server. + /// + public Task SimulateServerResponse(int requestId, int messageTag, SignalResponseStatus status, object? data = null) + { + var response = new SignalResponseJsonMessage(messageTag, status, data); + var bytes = response.ToMessagePack(ContractlessStandardResolver.Options); + return OnReceiveMessage(messageTag, bytes, requestId); + } + + /// + /// Simulates receiving a success response from the server. + /// + public Task SimulateSuccessResponse(int requestId, int messageTag, T data) + => SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Success, data); + + /// + /// Simulates receiving an error response from the server. + /// + public Task SimulateErrorResponse(int requestId, int messageTag) + => SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Error); + + /// + /// Gets the last sent message. + /// + public SentClientMessage? LastSentMessage => SentMessages.LastOrDefault(); + + /// + /// Clears all captured messages. + /// + public void ClearMessages() + { + SentMessages.Clear(); + ReceivedMessages.Clear(); + } + + /// + /// Invokes OnReceiveMessage directly for testing. + /// + public Task InvokeOnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId) + => OnReceiveMessage(messageTag, messageBytes, requestId); + + #endregion +} + +/// +/// Represents a message sent from client to server. +/// +public record SentClientMessage(int MessageTag, byte[]? MessageBytes, int? RequestId) +{ + /// + /// Deserializes the message to IdMessage format. + /// Works with both production SignalPostJsonDataMessage and test SignalRPostMessageDto. + /// + public IdMessage? AsIdMessage() + { + if (MessageBytes == null) return null; + try + { + // First deserialize to get the PostDataJson string + var msg = MessageBytes.MessagePackTo>(ContractlessStandardResolver.Options); + return msg.PostData; + } + catch + { + // Fallback: try deserializing as raw JSON wrapper + try + { + var rawMsg = MessageBytes.MessagePackTo(ContractlessStandardResolver.Options); + return rawMsg.PostDataJson?.JsonTo(); + } + catch + { + return null; + } + } + } + + /// + /// Deserializes the message to a specific post data type. + /// + public T? AsPostData() where T : class + { + if (MessageBytes == null) return null; + try + { + var msg = MessageBytes.MessagePackTo>(ContractlessStandardResolver.Options); + return msg.PostData; + } + catch + { + // Fallback: try deserializing as raw JSON wrapper + try + { + var rawMsg = MessageBytes.MessagePackTo(ContractlessStandardResolver.Options); + return rawMsg.PostDataJson?.JsonTo(); + } + catch + { + return null; + } + } + } +} + +/// +/// Represents a message received by the client. +/// +public record ReceivedClientMessage(int MessageTag, byte[] MessageBytes) +{ + /// + /// Deserializes the message as a response. + /// + public SignalResponseJsonMessage? AsResponse() + { + try + { + return MessageBytes.MessagePackTo(ContractlessStandardResolver.Options); + } + catch + { + return null; + } + } +} diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs index 6482874..e41ce6a 100644 --- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs +++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs @@ -16,16 +16,22 @@ namespace AyCode.Services.SignalRs { private readonly ConcurrentDictionary _responseByRequestId = new(); - protected readonly HubConnection HubConnection; + protected readonly HubConnection? HubConnection; protected readonly AcLoggerBase Logger; //protected event Action OnMessageReceived = null!; protected abstract Task MessageReceived(int messageTag, byte[] messageBytes); + public int MsDelay = 25; + public int MsFirstDelay = 50; + public int ConnectionTimeout = 10000; public int TransportSendTimeout = 60000; private const string TagsName = "SignalRTags"; + /// + /// Production constructor - creates and starts HubConnection. + /// protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger) { Logger = logger; @@ -79,59 +85,162 @@ namespace AyCode.Services.SignalRs HubConnection.StartAsync().Forget(); } + /// + /// Test constructor - allows testing without real HubConnection. + /// Override virtual methods to control behavior in tests. + /// + protected AcSignalRClientBase(AcLoggerBase logger) + { + Logger = logger; + HubConnection = null; + } + private Task HubConnection_Closed(Exception? arg) { if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed"); else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}"); - _responseByRequestId.Clear(); + ClearPendingRequests(); return Task.CompletedTask; } + #region Connection State Methods (virtual for testing) + + /// + /// Gets the current connection state. Override in tests. + /// + protected virtual HubConnectionState GetConnectionState() + => HubConnection?.State ?? HubConnectionState.Disconnected; + + /// + /// Checks if the connection is connected. Override in tests. + /// + protected virtual bool IsConnected() + => GetConnectionState() == HubConnectionState.Connected; + + /// + /// Starts the connection. Override in tests to avoid real connection. + /// + protected virtual Task StartConnectionInternal() + { + if (HubConnection == null) return Task.CompletedTask; + return HubConnection.StartAsync(); + } + + /// + /// Stops the connection. Override in tests. + /// + protected virtual Task StopConnectionInternal() + { + if (HubConnection == null) return Task.CompletedTask; + return HubConnection.StopAsync(); + } + + /// + /// Disposes the connection. Override in tests. + /// + protected virtual ValueTask DisposeConnectionInternal() + { + if (HubConnection == null) return ValueTask.CompletedTask; + return HubConnection.DisposeAsync(); + } + + /// + /// Sends a message to the server via HubConnection. Override in tests. + /// + protected virtual Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId) + { + if (HubConnection == null) return Task.CompletedTask; + return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, messageBytes, requestId); + } + + #endregion + + #region Protected Test Helpers + + /// + /// Gets the pending requests dictionary for testing. + /// + protected ConcurrentDictionary GetPendingRequests() + => _responseByRequestId; + + /// + /// Clears all pending requests. + /// + protected void ClearPendingRequests() + => _responseByRequestId.Clear(); + + /// + /// Registers a pending request for testing. + /// + protected void RegisterPendingRequest(int requestId, SignalRRequestModel model) + => _responseByRequestId[requestId] = model; + + /// + /// Simulates receiving a response for testing. + /// + protected void SimulateResponse(int requestId, ISignalResponseMessage response) + { + if (_responseByRequestId.TryGetValue(requestId, out var model)) + { + model.ResponseByRequestId = response; + model.ResponseDateTime = DateTime.UtcNow; + } + } + + #endregion + public async Task StartConnection() { - if (HubConnection.State == HubConnectionState.Disconnected) - await HubConnection.StartAsync(); + if (GetConnectionState() == HubConnectionState.Disconnected) + await StartConnectionInternal(); - if (HubConnection.State != HubConnectionState.Connected) - await TaskHelper.WaitToAsync(() => HubConnection.State == HubConnectionState.Connected, ConnectionTimeout, 10, 25); + if (!IsConnected()) + await TaskHelper.WaitToAsync(IsConnected, ConnectionTimeout, 10, 25); } public async Task StopConnection() { - await HubConnection.StopAsync(); - await HubConnection.DisposeAsync(); + await StopConnectionInternal(); + await DisposeConnectionInternal(); } public virtual Task SendMessageToServerAsync(int messageTag) - => SendMessageToServerAsync(messageTag, null, AcDomain.NextUniqueInt32); + => SendMessageToServerAsync(messageTag, null, GetNextRequestId()); - public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId) + public virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId) { - Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionSate: {HubConnection.State}; {ConstHelper.NameByValue(TagsName, messageTag)}"); + Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionState: {GetConnectionState()}; {ConstHelper.NameByValue(TagsName, messageTag)}"); - return StartConnection().ContinueWith(_ => + await StartConnection(); + + var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options); + + if (!IsConnected()) { - var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options); + Logger.Error($"Client SendMessageToServerAsync error! ConnectionState: {GetConnectionState()};"); + return; + } - if (HubConnection.State != HubConnectionState.Connected) - { - Logger.Error($"Client SendMessageToServerAsync error! ConnectionSate: {HubConnection.State};"); - return Task.CompletedTask; - } - - return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, msgp, requestId); - }); + await SendToHubAsync(messageTag, msgp, requestId); } #region CRUD + public virtual Task PostAsync(int messageTag, object parameter) //where TResponseData : class + => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(parameter)), GetNextRequestId()); + + public virtual Task PostAsync(int messageTag, object[] parameters) //where TResponseData : class + => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(parameters)), GetNextRequestId()); + public virtual Task GetByIdAsync(int messageTag, object id) //where TResponseData : class - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(id)), AcDomain.NextUniqueInt32); + => PostAsync(messageTag, id); + public virtual Task GetByIdAsync(int messageTag, Func, Task> responseCallback, object id) => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(id)), responseCallback); public virtual Task GetByIdAsync(int messageTag, object[] ids) //where TResponseData : class - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(ids)), AcDomain.NextUniqueInt32); + => PostAsync(messageTag, ids); + public virtual Task GetByIdAsync(int messageTag, Func, Task> responseCallback, object[] ids) => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(ids)), responseCallback); @@ -143,17 +252,49 @@ namespace AyCode.Services.SignalRs public virtual Task GetAllAsync(int messageTag, Func, Task> responseCallback, object[]? contextParams) => SendMessageToServerAsync(messageTag, (contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage(new IdMessage(contextParams))), responseCallback); public virtual Task GetAllAsync(int messageTag, object[]? contextParams) //where TResponseData : class - => SendMessageToServerAsync(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage(new IdMessage(contextParams)), AcDomain.NextUniqueInt32); + => SendMessageToServerAsync(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage(new IdMessage(contextParams)), GetNextRequestId()); public virtual Task PostDataAsync(int messageTag, TPostData postData) where TPostData : class - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(postData), AcDomain.NextUniqueInt32); + => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), GetNextRequestId()); public virtual Task PostDataAsync(int messageTag, TPostData postData) //where TPostData : class where TResponseData : class - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(postData), AcDomain.NextUniqueInt32); + => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), GetNextRequestId()); public virtual Task PostDataAsync(int messageTag, TPostData postData, Func, Task> responseCallback) //where TPostData : class - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(postData), responseCallback); + => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback); public virtual Task PostDataAsync(int messageTag, TPostData postData, Func, Task> responseCallback) //where TPostData : class where TResponseData : class - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(postData), responseCallback); + => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback); + + /// + /// Creates the appropriate message wrapper for the post data. + /// Primitives, strings, enums, and value types are wrapped in IdMessage. + /// Complex objects are sent directly in SignalPostJsonDataMessage. + /// + private static ISignalRMessage CreatePostMessage(TPostData postData) + { + var type = typeof(TPostData); + + // Primitives, strings, enums, and value types should use IdMessage format + if (IsPrimitiveOrStringOrEnum(type)) + { + return new SignalPostJsonDataMessage(new IdMessage(postData!)); + } + + // Complex objects use direct serialization + return new SignalPostJsonDataMessage(postData); + } + + /// + /// Determines if a type should use IdMessage format (primitives, strings, enums, value types). + /// Must match the logic in AcWebSignalRHubBase.IsPrimitiveOrStringOrEnum. + /// NOTE: Arrays and collections are NOT included here - they are complex objects for PostDataAsync. + /// + private static bool IsPrimitiveOrStringOrEnum(Type type) + { + return type == typeof(string) || + type.IsEnum || + type.IsValueType || + type == typeof(DateTime); + } public Task GetAllIntoAsync(List intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid { @@ -178,28 +319,31 @@ namespace AyCode.Services.SignalRs #endregion CRUD public virtual Task SendMessageToServerAsync(int messageTag) //where TResponse : class - => SendMessageToServerAsync(messageTag, null, AcDomain.NextUniqueInt32); + => SendMessageToServerAsync(messageTag, null, GetNextRequestId()); public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message) //where TResponse : class - => SendMessageToServerAsync(messageTag, message, AcDomain.NextUniqueInt32); + => SendMessageToServerAsync(messageTag, message, GetNextRequestId()); protected virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int requestId) //where TResponse : class { Logger.DebugConditional($"Client SendMessageToServerAsync; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"); var startTime = DateTime.Now; + var requestModel = SignalRRequestModelPool.Get(); - _responseByRequestId[requestId] = new SignalRRequestModel(); + _responseByRequestId[requestId] = requestModel; await SendMessageToServerAsync(messageTag, message, requestId); try { - if (await TaskHelper.WaitToAsync(() => /*HubConnection.State != HubConnectionState.Connected ||*/ _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, 25, 50) && + if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) && _responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage responseMessage) { + SignalRRequestModelPool.Return(obj); + if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null) { - var errorText = $"Client SendMessageToServerAsync response error; await; tag: {messageTag}; Status: {responseMessage.Status}; ConnectionState: {HubConnection?.State}; requestId: {requestId}"; + var errorText = $"Client SendMessageToServerAsync response error; await; tag: {messageTag}; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}"; Logger.Error(errorText); @@ -213,27 +357,29 @@ namespace AyCode.Services.SignalRs return responseMessage.ResponseData.JsonTo(); } - Logger.Error($"Client timeout after: {(DateTime.Now - startTime).TotalSeconds} sec! ConnectionState: {HubConnection?.State}; requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); + Logger.Error($"Client timeout after: {(DateTime.Now - startTime).TotalSeconds} sec! ConnectionState: {GetConnectionState()}; requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); } catch (Exception ex) { - Logger.Error($"SendMessageToServerAsync; requestId: {requestId}; ConnectionState: {HubConnection?.State}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex); + Logger.Error($"SendMessageToServerAsync; requestId: {requestId}; ConnectionState: {GetConnectionState()}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex); } - _responseByRequestId.TryRemove(requestId, out _); + if (_responseByRequestId.TryRemove(requestId, out var removedModel)) + { + SignalRRequestModelPool.Return(removedModel); + } return default; } public virtual Task SendMessageToServerAsync(int messageTag, Func, Task> responseCallback) => SendMessageToServerAsync(messageTag, null, responseCallback); - public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, Func, Task> responseCallback) + public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, Func, Task> responseCallback) { if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0"); - var requestId = AcDomain.NextUniqueInt32; - - _responseByRequestId[requestId] = new SignalRRequestModel(new Action>(responseMessage => + var requestId = GetNextRequestId(); + var requestModel = SignalRRequestModelPool.Get(new Action>(responseMessage => { TResponseData? responseData = default; @@ -241,14 +387,21 @@ namespace AyCode.Services.SignalRs { responseData = string.IsNullOrEmpty(responseMessage.ResponseData) ? default : responseMessage.ResponseData.JsonTo(); } - else Logger.Error($"Client SendMessageToServerAsync response error; callback; Status: {responseMessage.Status}; ConnectionState: {HubConnection?.State}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"); + else Logger.Error($"Client SendMessageToServerAsync response error; callback; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"); responseCallback(new SignalResponseMessage(messageTag, responseMessage.Status, responseData)); })); + _responseByRequestId[requestId] = requestModel; + return SendMessageToServerAsync(messageTag, message, requestId); } + /// + /// Gets the next unique request ID. + /// + protected virtual int GetNextRequestId() => AcDomain.NextUniqueInt32; + public virtual Task OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId) { var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"; @@ -264,7 +417,7 @@ namespace AyCode.Services.SignalRs _responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow; Logger.Debug($"[{_responseByRequestId[reqId].ResponseDateTime.Subtract(_responseByRequestId[reqId].RequestDateTime).TotalMilliseconds:N0}ms][{(messageBytes.Length / 1024)}kb]{logText}"); - var responseMessage = messageBytes.MessagePackTo(ContractlessStandardResolver.Options); + var responseMessage = DeserializeResponseMsgPack(messageBytes); switch (_responseByRequestId[reqId].ResponseByRequestId) { @@ -273,55 +426,47 @@ namespace AyCode.Services.SignalRs return Task.CompletedTask; case Action> messagePackCallback: - _responseByRequestId.TryRemove(reqId, out _); + if (_responseByRequestId.TryRemove(reqId, out var callbackModel)) + { + SignalRRequestModelPool.Return(callbackModel); + } messagePackCallback.Invoke(responseMessage); - return Task.CompletedTask; - - //case Action jsonCallback: - // _responseByRequestId.TryRemove(reqId, out _); - - // jsonCallback.Invoke(responseMessage); - // return Task.CompletedTask; + return Task.CompletedTask; // ← Callback: NEM hívjuk meg a MessageReceived-et default: Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}"); break; } - _responseByRequestId.TryRemove(reqId, out _); + if (_responseByRequestId.TryRemove(reqId, out var removedModel)) + { + SignalRRequestModelPool.Return(removedModel); + } + + // Request-response hibás eset - ne hívjuk meg a MessageReceived-et + return Task.CompletedTask; } - else Logger.Info(logText); + // Csak broadcast/notification üzeneteknél hívjuk meg a MessageReceived-et + Logger.Info(logText); MessageReceived(messageTag, messageBytes).Forget(); } catch (Exception ex) { - if (requestId.HasValue) - _responseByRequestId.TryRemove(requestId.Value, out _); + if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel)) + { + SignalRRequestModelPool.Return(exModel); + } - Logger.Error($"Client OnReceiveMessage; ConnectionState: {HubConnection?.State}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex); + Logger.Error($"Client OnReceiveMessage; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex); throw; } return Task.CompletedTask; } - //public virtual Task OnRequestMessage(int messageTag, int requestId) - //{ - // Logger.DebugConditional($"Client OnRequestMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId};"); - // try - // { - // OnMessageRequested(messageTag, requestId); - // } - // catch(Exception ex) - // { - // Logger.Error($"Client OnReceiveMessage; {nameof(messageTag)}: {messageTag}; {nameof(requestId)}: {requestId}; {ex.Message}", ex); - // throw; - // } - - // return Task.CompletedTask; - - //} + protected virtual SignalResponseJsonMessage DeserializeResponseMsgPack(byte[] messageBytes) + => messageBytes.MessagePackTo(ContractlessStandardResolver.Options); } } diff --git a/AyCode.Services/SignalRs/AcSignalRTags.cs b/AyCode.Services/SignalRs/AcSignalRTags.cs index 63ce42e..95b8ecd 100644 --- a/AyCode.Services/SignalRs/AcSignalRTags.cs +++ b/AyCode.Services/SignalRs/AcSignalRTags.cs @@ -3,4 +3,7 @@ public class AcSignalRTags { public const int None = 0; + + public const int PingTag = 90001; + public const int EchoTag = 90002; } \ No newline at end of file diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs index 292e3b2..ac800d1 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs @@ -10,47 +10,51 @@ namespace AyCode.Services.SignalRs; public class IdMessage { - public List Ids { get; private set; } = []; + public List Ids { get; private set; } public IdMessage() { + Ids = []; } - public IdMessage(IEnumerable ids) : this() + /// + /// Creates IdMessage with multiple parameters serialized directly as JSON. + /// Each parameter is serialized independently without array wrapping. + /// Use object[] explicitly to pass multiple parameters. + /// + public IdMessage(object[] ids) { - //Ids.AddRange(ids); - Ids.AddRange(ids.Select(x => + // Pre-allocate capacity to avoid list resizing + Ids = new List(ids.Length); + for (var i = 0; i < ids.Length; i++) { - string item; - - //if (x is Expression expr) - //{ - // string aa = string.Empty; - // var serializer = new ExpressionSerializer(new JsonSerializer()); - // try - // { - // aa = serializer.SerializeText(expr); - // } - // catch(Exception ex) - // { - // Console.WriteLine(ex); - // } - - // item = (new[] { aa }).ToJson(); - //} - //else - item = (new[] { x }).ToJson(); - - return item; - })); + Ids.Add(ids[i].ToJson()); + } } - public IdMessage(object id) : this(new object[] { id }) + /// + /// Creates IdMessage with a single parameter serialized as JSON. + /// Collections (List, Array, etc.) are serialized as a single JSON array. + /// + public IdMessage(object id) { + // Pre-allocate for single item + Ids = new List(1) { id.ToJson() }; } - public IdMessage(IEnumerable ids) : this(ids.Cast().ToArray()) + /// + /// Creates IdMessage with multiple Guid parameters. + /// Each Guid is serialized as a separate Id entry. + /// + public IdMessage(IEnumerable ids) { + // Materialize to array once to get count and avoid multiple enumeration + var idsArray = ids as Guid[] ?? ids.ToArray(); + Ids = new List(idsArray.Length); + for (var i = 0; i < idsArray.Length; i++) + { + Ids.Add(idsArray[i].ToJson()); + } } public override string ToString() @@ -59,17 +63,18 @@ public class IdMessage } } +[MessagePackObject] public class SignalPostJsonMessage { [Key(0)] - public string PostDataJson { get; set; } + public string PostDataJson { get; set; } = ""; public SignalPostJsonMessage() {} protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson; } -[MessagePackObject] +[MessagePackObject(AllowPrivate = false)] public class SignalPostJsonDataMessage : SignalPostJsonMessage, ISignalPostMessage //where TPostDataType : class { [IgnoreMember] @@ -144,16 +149,33 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage MessageTag = messageTag; } + /// + /// Creates a response with the given data serialized as JSON. + /// If responseData is already a JSON string (starts with { or [), it will be used directly. + /// All other data types are serialized to JSON format. + /// public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, object? responseData) : this(messageTag, status) { - if (responseData is string stringdata) - ResponseData = stringdata; - else ResponseData = responseData.ToJson(); - } + if (responseData == null) + { + ResponseData = null; + return; + } - public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, string? responseDataJson) : this(messageTag, status) - { - ResponseData = responseDataJson; + // If responseData is already a JSON string, use it directly + if (responseData is string strData) + { + var trimmed = strData.Trim(); + if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']')) + { + // Already JSON - use directly without re-serialization + ResponseData = strData; + return; + } + } + + // Serialize to JSON + ResponseData = responseData.ToJson(); } } @@ -162,7 +184,7 @@ public sealed class SignalResponseJsonMessage : ISignalResponseMessage /// ResponseData is only deserialized on first access and cached. /// Use ResponseDataJson for direct JSON access without deserialization. /// -[MessagePackObject] +[MessagePackObject(AllowPrivate = false)] public sealed class SignalResponseMessage : ISignalResponseMessage { [IgnoreMember] diff --git a/AyCode.Services/SignalRs/SignalRRequestModel.cs b/AyCode.Services/SignalRs/SignalRRequestModel.cs index b567044..47005b2 100644 --- a/AyCode.Services/SignalRs/SignalRRequestModel.cs +++ b/AyCode.Services/SignalRs/SignalRRequestModel.cs @@ -1,10 +1,16 @@ -namespace AyCode.Services.SignalRs; +using Microsoft.Extensions.ObjectPool; -public class SignalRRequestModel +namespace AyCode.Services.SignalRs; + +/// +/// Request model for tracking pending SignalR requests. +/// Poolable to reduce allocations in high-throughput scenarios. +/// +public class SignalRRequestModel : IResettable { public DateTime RequestDateTime; public DateTime ResponseDateTime; - public object? ResponseByRequestId = null; + public object? ResponseByRequestId; public SignalRRequestModel() { @@ -16,4 +22,54 @@ public class SignalRRequestModel ResponseByRequestId = responseByRequestId; } + /// + /// Resets the model for reuse from the pool. + /// + public bool TryReset() + { + RequestDateTime = DateTime.UtcNow; + ResponseDateTime = default; + ResponseByRequestId = null; + return true; + } + + /// + /// Initializes the model with a callback for reuse from the pool. + /// + public void Initialize(object? responseByRequestId = null) + { + RequestDateTime = DateTime.UtcNow; + ResponseDateTime = default; + ResponseByRequestId = responseByRequestId; + } +} + +/// +/// Object pool for SignalRRequestModel to reduce allocations. +/// Thread-safe and optimized for concurrent access. +/// +public static class SignalRRequestModelPool +{ + private static readonly ObjectPool Pool = + new DefaultObjectPoolProvider().Create(); + + /// + /// Gets a SignalRRequestModel from the pool. + /// + public static SignalRRequestModel Get() => Pool.Get(); + + /// + /// Gets a SignalRRequestModel from the pool and initializes it with a callback. + /// + public static SignalRRequestModel Get(object responseByRequestId) + { + var model = Pool.Get(); + model.Initialize(responseByRequestId); + return model; + } + + /// + /// Returns a SignalRRequestModel to the pool for reuse. + /// + public static void Return(SignalRRequestModel model) => Pool.Return(model); } \ No newline at end of file diff --git a/BenchmarkSuite1/BenchmarkSuite1.csproj b/BenchmarkSuite1/BenchmarkSuite1.csproj index 620211d..2240304 100644 --- a/BenchmarkSuite1/BenchmarkSuite1.csproj +++ b/BenchmarkSuite1/BenchmarkSuite1.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/BenchmarkSuite1/Program.cs b/BenchmarkSuite1/Program.cs index c1c3a61..952b89d 100644 --- a/BenchmarkSuite1/Program.cs +++ b/BenchmarkSuite1/Program.cs @@ -1,4 +1,5 @@ using BenchmarkDotNet.Running; +using AyCode.Core.Benchmarks; namespace BenchmarkSuite1 { @@ -13,7 +14,8 @@ namespace BenchmarkSuite1 return; } - var _ = BenchmarkRunner.Run(typeof(Program).Assembly); + // Use assembly-wide discovery for all benchmarks + BenchmarkSwitcher.FromAssembly(typeof(SerializationBenchmarks).Assembly).Run(args); } static void RunSizeComparison() diff --git a/BenchmarkSuite1/SerializationBenchmarks.cs b/BenchmarkSuite1/SerializationBenchmarks.cs index 3c76de9..ede6575 100644 --- a/BenchmarkSuite1/SerializationBenchmarks.cs +++ b/BenchmarkSuite1/SerializationBenchmarks.cs @@ -1,21 +1,28 @@ using AyCode.Core.Extensions; -using AyCode.Core.Interfaces; +using AyCode.Core.Tests.TestModels; using BenchmarkDotNet.Attributes; using Newtonsoft.Json; using System.Text; namespace AyCode.Core.Benchmarks; +/// +/// Serialization benchmarks comparing AcJsonSerializer/Deserializer with Newtonsoft.Json. +/// Uses shared TestModels from AyCode.Core.Tests for consistency. +/// [MemoryDiagnoser] public class SerializationBenchmarks { - // Complex graph with 7 levels, ~1500 objects, cross-references - private Level1_Company _complexGraph = null!; + // Test data - uses shared TestModels + private TestOrder _testOrder = null!; // Pre-serialized JSON for deserialization benchmarks private string _newtonsoftJson = null!; private string _ayCodeJson = null!; + // Target objects for Populate benchmarks + private TestOrder _populateTarget = null!; + // Settings private JsonSerializerSettings _newtonsoftNoRefSettings = null!; private JsonSerializerSettings _ayCodeSettings = null!; @@ -28,29 +35,36 @@ public class SerializationBenchmarks { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, NullValueHandling = NullValueHandling.Ignore, - DefaultValueHandling = DefaultValueHandling.Ignore, // Fair comparison - also skip defaults + DefaultValueHandling = DefaultValueHandling.Ignore, Formatting = Formatting.None }; - // AyCode WITH reference handling (our optimized solution) + // AyCode WITH reference handling _ayCodeSettings = SerializeObjectExtensions.Options; - // Create complex 7-level graph with ~1500 objects and cross-references - _complexGraph = CreateComplexGraph(); + // Create benchmark data using shared factory + // ~1500 objects: 5 items 4 pallets 3 measurements 5 points = 300 points + containers + _testOrder = TestDataFactory.CreateBenchmarkOrder( + itemCount: 5, + palletsPerItem: 4, + measurementsPerPallet: 3, + pointsPerMeasurement: 5); // Pre-serialize for deserialization benchmarks - _newtonsoftJson = JsonConvert.SerializeObject(_complexGraph, _newtonsoftNoRefSettings); - _ayCodeJson = _complexGraph.ToJson(_ayCodeSettings); + _newtonsoftJson = JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings); + _ayCodeJson = _testOrder.ToJson(_ayCodeSettings); + + // Create target for populate benchmarks + _populateTarget = new TestOrder(); // Output sizes for comparison var newtonsoftBytes = Encoding.UTF8.GetByteCount(_newtonsoftJson); var ayCodeBytes = Encoding.UTF8.GetByteCount(_ayCodeJson); Console.WriteLine("=== JSON Size Comparison ==="); - Console.WriteLine($"Newtonsoft (skip defaults): {_newtonsoftJson.Length:N0} chars ({newtonsoftBytes:N0} bytes, {newtonsoftBytes / 1024.0:F1} KB)"); - Console.WriteLine($"AcJsonSerializer (refs+skip): {_ayCodeJson.Length:N0} chars ({ayCodeBytes:N0} bytes, {ayCodeBytes / 1024.0:F1} KB)"); + Console.WriteLine($"Newtonsoft (skip defaults): {_newtonsoftJson.Length:N0} chars ({newtonsoftBytes:N0} bytes)"); + Console.WriteLine($"AyCode (refs+skip): {_ayCodeJson.Length:N0} chars ({ayCodeBytes:N0} bytes)"); Console.WriteLine($"Size reduction: {(1.0 - (double)ayCodeBytes / newtonsoftBytes) * 100:F1}%"); - Console.WriteLine($"Bytes saved: {newtonsoftBytes - ayCodeBytes:N0}"); } #region Serialization Benchmarks @@ -58,12 +72,17 @@ public class SerializationBenchmarks [Benchmark(Description = "Newtonsoft (no refs)")] [BenchmarkCategory("Serialize")] public string Serialize_Newtonsoft_NoRefs() - => JsonConvert.SerializeObject(_complexGraph, _newtonsoftNoRefSettings); + => JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings); [Benchmark(Description = "AyCode (with refs)")] [BenchmarkCategory("Serialize")] public string Serialize_AyCode_WithRefs() - => _complexGraph.ToJson(_ayCodeSettings); + => _testOrder.ToJson(_ayCodeSettings); + + [Benchmark(Description = "AcJsonSerializer (custom)")] + [BenchmarkCategory("Serialize")] + public string Serialize_AcJsonSerializer() + => AcJsonSerializer.Serialize(_testOrder); #endregion @@ -71,22 +90,44 @@ public class SerializationBenchmarks [Benchmark(Description = "Newtonsoft (no refs)")] [BenchmarkCategory("Deserialize")] - public Level1_Company? Deserialize_Newtonsoft_NoRefs() - => JsonConvert.DeserializeObject(_newtonsoftJson, _newtonsoftNoRefSettings); + public TestOrder? Deserialize_Newtonsoft_NoRefs() + => JsonConvert.DeserializeObject(_newtonsoftJson, _newtonsoftNoRefSettings); [Benchmark(Description = "AyCode (with refs)")] [BenchmarkCategory("Deserialize")] - public Level1_Company? Deserialize_AyCode_WithRefs() - => _ayCodeJson.JsonTo(_ayCodeSettings); + public TestOrder? Deserialize_AyCode_WithRefs() + => _ayCodeJson.JsonTo(_ayCodeSettings); [Benchmark(Description = "AcJsonDeserializer (custom)")] [BenchmarkCategory("Deserialize")] - public Level1_Company? Deserialize_AcJsonDeserializer() - => AcJsonDeserializer.Deserialize(_ayCodeJson); + public TestOrder? Deserialize_AcJsonDeserializer() + => AcJsonDeserializer.Deserialize(_ayCodeJson); #endregion - #region JSON Size Comparison (not timed, just for reporting) + #region Populate Benchmarks + + [Benchmark(Description = "AcJsonDeserializer.Populate")] + [BenchmarkCategory("Populate")] + public void Populate_AcJsonDeserializer() + { + // Create fresh target for each iteration to avoid state pollution + var target = new TestOrder(); + AcJsonDeserializer.Populate(_ayCodeJson, target); + } + + [Benchmark(Description = "Newtonsoft PopulateObject")] + [BenchmarkCategory("Populate")] + public void Populate_Newtonsoft() + { + // Create fresh target for each iteration to match the other benchmark + var target = new TestOrder(); + JsonConvert.PopulateObject(_newtonsoftJson, target, _newtonsoftNoRefSettings); + } + + #endregion + + #region JSON Size Comparison [Benchmark(Description = "JSON Size - Newtonsoft")] [BenchmarkCategory("Size")] @@ -97,420 +138,4 @@ public class SerializationBenchmarks public int JsonSize_AyCode() => _ayCodeJson.Length; #endregion - - #region Complex Graph Factory - 7 Levels, ~1500 objects, Cross-references - - private static int _idCounter = 1; - - /// - /// Creates a 7-level deep graph with approximately 1500 objects and cross-references. - /// Structure: Company -> Departments -> Teams -> Projects -> Tasks -> SubTasks -> Comments - /// Each object has 8-15 properties of various types. - /// - private static Level1_Company CreateComplexGraph() - { - _idCounter = 1; - - // Shared references (cross-references across the graph) - var sharedTags = Enumerable.Range(1, 10) - .Select(i => new SharedTag - { - Id = _idCounter++, - Name = $"Tag-{i}", - Color = $"#{i:X2}{i * 10:X2}{i * 20:X2}", - Priority = i % 5, - IsActive = i % 2 == 0, - CreatedAt = DateTime.UtcNow.AddDays(-i * 10), - Metadata = $"Metadata for tag {i}" - }) - .ToList(); - - var sharedCategories = Enumerable.Range(1, 5) - .Select(i => new SharedCategory - { - Id = _idCounter++, - Name = $"Category-{i}", - Description = $"Description for category {i} with some extra text to make it longer", - SortOrder = i * 100, - IconUrl = $"https://icons.example.com/cat-{i}.png", - IsDefault = i == 1, - ParentCategoryId = i > 1 ? i - 1 : null, - CreatedAt = DateTime.UtcNow.AddMonths(-i), - UpdatedAt = DateTime.UtcNow.AddDays(-i) - }) - .ToList(); - - var sharedUser = new SharedUser - { - Id = _idCounter++, - Username = "admin", - Email = "admin@company.com", - FirstName = "System", - LastName = "Administrator", - PhoneNumber = "+1-555-0100", - IsActive = true, - Role = UserRole.Admin, - LastLoginAt = DateTime.UtcNow.AddHours(-1), - CreatedAt = DateTime.UtcNow.AddYears(-2), - Preferences = new UserPreferences - { - Theme = "dark", - Language = "en-US", - NotificationsEnabled = true, - EmailDigestFrequency = "daily" - } - }; - - // Level 1: Company (1 object) - var company = new Level1_Company - { - Id = _idCounter++, - Name = "TechCorp International", - LegalName = "TechCorp International Holdings Ltd.", - TaxId = "TC-123456789", - FoundedDate = new DateTime(2010, 3, 15), - EmployeeCount = 1500, - AnnualRevenue = 125_000_000.50m, - IsPubliclyTraded = true, - StockSymbol = "TECH", - HeadquartersAddress = "123 Innovation Drive, Tech City, TC 12345", - Website = "https://www.techcorp.example.com", - PrimaryContact = sharedUser, - MainCategory = sharedCategories[0], - Tags = [sharedTags[0], sharedTags[1], sharedTags[2]], - CreatedAt = DateTime.UtcNow.AddYears(-5), - UpdatedAt = DateTime.UtcNow - }; - - // Level 2: Departments (5 objects) - company.Departments = Enumerable.Range(1, 5).Select(deptIdx => new Level2_Department - { - Id = _idCounter++, - Name = $"Department-{deptIdx}", - Code = $"DEPT-{deptIdx:D3}", - Description = $"This is department {deptIdx} responsible for various operations and strategic initiatives", - Budget = 1_000_000m + (deptIdx * 250_000m), - HeadCount = 50 + (deptIdx * 20), - Location = $"Building {(char)('A' + deptIdx - 1)}, Floor {deptIdx}", - CostCenter = $"CC-{1000 + deptIdx}", - IsActive = true, - Manager = sharedUser, // Cross-reference - Category = sharedCategories[deptIdx % sharedCategories.Count], // Cross-reference - Tags = [sharedTags[deptIdx % sharedTags.Count], sharedTags[(deptIdx + 1) % sharedTags.Count]], // Cross-reference - EstablishedDate = DateTime.UtcNow.AddYears(-4).AddMonths(deptIdx), - CreatedAt = DateTime.UtcNow.AddYears(-4), - UpdatedAt = DateTime.UtcNow.AddMonths(-deptIdx), - // Level 3: Teams (6 per department = 30 total) - Teams = Enumerable.Range(1, 6).Select(teamIdx => new Level3_Team - { - Id = _idCounter++, - Name = $"Team-{deptIdx}-{teamIdx}", - Acronym = $"T{deptIdx}{teamIdx}", - Description = $"Team {teamIdx} in department {deptIdx}, focused on delivering excellence", - MemberCount = 5 + (teamIdx * 2), - Capacity = 10 + (teamIdx * 2), - Utilization = 0.65 + (teamIdx * 0.05), - SprintLength = 14, - VelocityAverage = 42.5 + teamIdx, - IsRemote = teamIdx % 3 == 0, - Timezone = teamIdx % 2 == 0 ? "UTC" : "America/New_York", - SlackChannel = $"#team-{deptIdx}-{teamIdx}", - TeamLead = sharedUser, // Cross-reference - PrimaryTag = sharedTags[(deptIdx + teamIdx) % sharedTags.Count], // Cross-reference - CreatedAt = DateTime.UtcNow.AddYears(-3).AddMonths(teamIdx), - UpdatedAt = DateTime.UtcNow.AddDays(-teamIdx * 7), - // Level 4: Projects (4 per team = 120 total) - Projects = Enumerable.Range(1, 4).Select(projIdx => new Level4_Project - { - Id = _idCounter++, - Name = $"Project-{deptIdx}-{teamIdx}-{projIdx}", - Code = $"PRJ-{deptIdx}{teamIdx}{projIdx:D2}", - Description = $"Project {projIdx} for team {teamIdx}, delivering key business value and innovation", - Status = (ProjectStatus)(projIdx % 4), - Priority = (Priority)(projIdx % 3), - Budget = 50_000m + (projIdx * 15_000m), - SpentAmount = 25_000m + (projIdx * 5_000m), - ProgressPercent = 0.1 + (projIdx * 0.2), - StartDate = DateTime.UtcNow.AddMonths(-projIdx * 2), - DueDate = DateTime.UtcNow.AddMonths(projIdx), - CompletedDate = projIdx == 4 ? DateTime.UtcNow.AddDays(-10) : null, - EstimatedHours = 200 + (projIdx * 50), - ActualHours = 150 + (projIdx * 40), - RiskLevel = projIdx % 3, - Owner = sharedUser, // Cross-reference - Category = sharedCategories[projIdx % sharedCategories.Count], // Cross-reference - Tags = [sharedTags[projIdx % sharedTags.Count]], // Cross-reference - CreatedAt = DateTime.UtcNow.AddMonths(-projIdx * 3), - UpdatedAt = DateTime.UtcNow.AddDays(-projIdx), - // Level 5: Tasks (5 per project = 600 total) - Tasks = Enumerable.Range(1, 5).Select(taskIdx => new Level5_Task - { - Id = _idCounter++, - Title = $"Task-{deptIdx}-{teamIdx}-{projIdx}-{taskIdx}", - Description = $"Detailed task description for task {taskIdx} in project {projIdx}. This includes requirements and acceptance criteria.", - Status = (TaskStatus)(taskIdx % 5), - Priority = (Priority)(taskIdx % 3), - Type = (TaskType)(taskIdx % 4), - StoryPoints = taskIdx * 2, - EstimatedHours = 4 + taskIdx * 2, - ActualHours = 3 + taskIdx * 1.5, - DueDate = DateTime.UtcNow.AddDays(taskIdx * 3), - CompletedDate = taskIdx <= 2 ? DateTime.UtcNow.AddDays(-taskIdx) : null, - IsBlocked = taskIdx == 3, - BlockedReason = taskIdx == 3 ? "Waiting for external dependency" : null, - Assignee = sharedUser, // Cross-reference - Reporter = sharedUser, // Cross-reference - Labels = [sharedTags[taskIdx % sharedTags.Count]], // Cross-reference - CreatedAt = DateTime.UtcNow.AddDays(-taskIdx * 5), - UpdatedAt = DateTime.UtcNow.AddHours(-taskIdx), - // Level 6: SubTasks (3 per task = 1800 total -> we'll limit to keep ~1500) - SubTasks = Enumerable.Range(1, 2).Select(subIdx => new Level6_SubTask - { - Id = _idCounter++, - Title = $"SubTask-{taskIdx}-{subIdx}", - Description = $"Sub-task {subIdx} details for completing parent task {taskIdx}", - Status = (TaskStatus)(subIdx % 5), - EstimatedMinutes = 30 + subIdx * 15, - ActualMinutes = 25 + subIdx * 12, - IsCompleted = subIdx == 1, - CompletedAt = subIdx == 1 ? DateTime.UtcNow.AddHours(-subIdx * 2) : null, - Assignee = sharedUser, // Cross-reference - CreatedAt = DateTime.UtcNow.AddDays(-subIdx), - UpdatedAt = DateTime.UtcNow.AddMinutes(-subIdx * 30), - // Level 7: Comments (2 per subtask = 2400 total -> limiting) - Comments = Enumerable.Range(1, 1).Select(comIdx => new Level7_Comment - { - Id = _idCounter++, - Text = $"Comment {comIdx} on subtask {subIdx}: This is a detailed comment with feedback and suggestions for improvement.", - Author = sharedUser, // Cross-reference - IsEdited = comIdx % 2 == 0, - EditedAt = comIdx % 2 == 0 ? DateTime.UtcNow.AddHours(-1) : null, - LikeCount = comIdx * 3, - ReplyCount = comIdx, - CreatedAt = DateTime.UtcNow.AddHours(-comIdx * 4), - MentionedTags = [sharedTags[comIdx % sharedTags.Count]] // Cross-reference - }).ToList() - }).ToList() - }).ToList() - }).ToList() - }).ToList() - }).ToList(); - - return company; - } - - #endregion - - #region 7-Level Deep DTOs with 8-15 Properties Each - - // Shared cross-reference types - public class SharedTag : IId - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public string Color { get; set; } = ""; - public int Priority { get; set; } - public bool IsActive { get; set; } - public DateTime CreatedAt { get; set; } - public string Metadata { get; set; } = ""; - } - - public class SharedCategory : IId - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public string Description { get; set; } = ""; - public int SortOrder { get; set; } - public string IconUrl { get; set; } = ""; - public bool IsDefault { get; set; } - public int? ParentCategoryId { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - } - - public class SharedUser : IId - { - public int Id { get; set; } - public string Username { get; set; } = ""; - public string Email { get; set; } = ""; - public string FirstName { get; set; } = ""; - public string LastName { get; set; } = ""; - public string PhoneNumber { get; set; } = ""; - public bool IsActive { get; set; } - public UserRole Role { get; set; } - public DateTime? LastLoginAt { get; set; } - public DateTime CreatedAt { get; set; } - public UserPreferences? Preferences { get; set; } - } - - public class UserPreferences - { - public string Theme { get; set; } = ""; - public string Language { get; set; } = ""; - public bool NotificationsEnabled { get; set; } - public string EmailDigestFrequency { get; set; } = ""; - } - - public enum UserRole { User, Manager, Admin } - public enum ProjectStatus { Planning, Active, OnHold, Completed } - public enum TaskStatus { Backlog, Todo, InProgress, Review, Done } - public enum TaskType { Feature, Bug, Improvement, Task } - public enum Priority { Low, Medium, High } - - // Level 1: Company (15 properties) - public class Level1_Company : IId - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public string LegalName { get; set; } = ""; - public string TaxId { get; set; } = ""; - public DateTime FoundedDate { get; set; } - public int EmployeeCount { get; set; } - public decimal AnnualRevenue { get; set; } - public bool IsPubliclyTraded { get; set; } - public string? StockSymbol { get; set; } - public string HeadquartersAddress { get; set; } = ""; - public string Website { get; set; } = ""; - public SharedUser? PrimaryContact { get; set; } // Cross-ref - public SharedCategory? MainCategory { get; set; } // Cross-ref - public List Tags { get; set; } = []; // Cross-ref - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - public List Departments { get; set; } = []; - } - - // Level 2: Department (15 properties) - public class Level2_Department : IId - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public string Code { get; set; } = ""; - public string Description { get; set; } = ""; - public decimal Budget { get; set; } - public int HeadCount { get; set; } - public string Location { get; set; } = ""; - public string CostCenter { get; set; } = ""; - public bool IsActive { get; set; } - public SharedUser? Manager { get; set; } // Cross-ref - public SharedCategory? Category { get; set; } // Cross-ref - public List Tags { get; set; } = []; // Cross-ref - public DateTime EstablishedDate { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - public List Teams { get; set; } = []; - } - - // Level 3: Team (15 properties) - public class Level3_Team : IId - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public string Acronym { get; set; } = ""; - public string Description { get; set; } = ""; - public int MemberCount { get; set; } - public int Capacity { get; set; } - public double Utilization { get; set; } - public int SprintLength { get; set; } - public double VelocityAverage { get; set; } - public bool IsRemote { get; set; } - public string Timezone { get; set; } = ""; - public string SlackChannel { get; set; } = ""; - public SharedUser? TeamLead { get; set; } // Cross-ref - public SharedTag? PrimaryTag { get; set; } // Cross-ref - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - public List Projects { get; set; } = []; - } - - // Level 4: Project (18 properties) - public class Level4_Project : IId - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public string Code { get; set; } = ""; - public string Description { get; set; } = ""; - public ProjectStatus Status { get; set; } - public Priority Priority { get; set; } - public decimal Budget { get; set; } - public decimal SpentAmount { get; set; } - public double ProgressPercent { get; set; } - public DateTime StartDate { get; set; } - public DateTime DueDate { get; set; } - public DateTime? CompletedDate { get; set; } - public int EstimatedHours { get; set; } - public int ActualHours { get; set; } - public int RiskLevel { get; set; } - public SharedUser? Owner { get; set; } // Cross-ref - public SharedCategory? Category { get; set; } // Cross-ref - public List Tags { get; set; } = []; // Cross-ref - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - public List Tasks { get; set; } = []; - } - - // Level 5: Task (18 properties) - public class Level5_Task : IId - { - public int Id { get; set; } - public string Title { get; set; } = ""; - public string Description { get; set; } = ""; - public TaskStatus Status { get; set; } - public Priority Priority { get; set; } - public TaskType Type { get; set; } - public int StoryPoints { get; set; } - public double EstimatedHours { get; set; } - public double ActualHours { get; set; } - public DateTime DueDate { get; set; } - public DateTime? CompletedDate { get; set; } - public bool IsBlocked { get; set; } - public string? BlockedReason { get; set; } - public SharedUser? Assignee { get; set; } // Cross-ref - public SharedUser? Reporter { get; set; } // Cross-ref - public List Labels { get; set; } = []; // Cross-ref - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - public List SubTasks { get; set; } = []; - } - - // Level 6: SubTask (11 properties) - public class Level6_SubTask : IId - { - public int Id { get; set; } - public string Title { get; set; } = ""; - public string Description { get; set; } = ""; - public TaskStatus Status { get; set; } - public int EstimatedMinutes { get; set; } - public int ActualMinutes { get; set; } - public bool IsCompleted { get; set; } - public DateTime? CompletedAt { get; set; } - public SharedUser? Assignee { get; set; } // Cross-ref - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - public List Comments { get; set; } = []; - } - - // Level 7: Comment (10 properties) - public class Level7_Comment : IId - { - public int Id { get; set; } - public string Text { get; set; } = ""; - public SharedUser? Author { get; set; } // Cross-ref - public bool IsEdited { get; set; } - public DateTime? EditedAt { get; set; } - public int LikeCount { get; set; } - public int ReplyCount { get; set; } - public DateTime CreatedAt { get; set; } - public List MentionedTags { get; set; } = []; // Cross-ref - } - - #endregion - - #region AcJsonSerializer Benchmarks - - [Benchmark(Description = "AcJsonSerializer (custom)")] - [BenchmarkCategory("Serialize")] - public string Serialize_AcJsonSerializer() - => AcJsonSerializer.Serialize(_complexGraph); - - #endregion } \ No newline at end of file diff --git a/BenchmarkSuite1/SignalRCommunicationBenchmarks.cs b/BenchmarkSuite1/SignalRCommunicationBenchmarks.cs new file mode 100644 index 0000000..7b7b038 --- /dev/null +++ b/BenchmarkSuite1/SignalRCommunicationBenchmarks.cs @@ -0,0 +1,188 @@ +using AyCode.Core.Extensions; +using AyCode.Core.Tests.TestModels; +using BenchmarkDotNet.Attributes; +using MessagePack; + +namespace AyCode.Core.Benchmarks; + +/// +/// SignalR communication benchmarks measuring the full serialization workflow: +/// Client ? IdMessage ? MessagePack ? Server ? Deserialize ? Response ? MessagePack ? Client +/// +[MemoryDiagnoser] +public class SignalRCommunicationBenchmarks +{ + // Shared test data + private SignalRBenchmarkData _data = null !; + // Pre-serialized messages for deserialization benchmarks + private byte[] _singleIntMessage = null !; + private byte[] _twoIntMessage = null !; + private byte[] _fiveParamsMessage = null !; + private byte[] _complexOrderItemMessage = null !; + private byte[] _complexOrderMessage = null !; + private byte[] _intArrayMessage = null !; + private byte[] _mixedParamsMessage = null !; + // Pre-serialized response for client-side deserialization + private byte[] _successResponseMessage = null !; + private byte[] _complexResponseMessage = null !; + [GlobalSetup] + public void Setup() + { + _data = new SignalRBenchmarkData(); + // Copy pre-serialized messages + _singleIntMessage = _data.SingleIntMessage; + _twoIntMessage = _data.TwoIntMessage; + _fiveParamsMessage = _data.FiveParamsMessage; + _complexOrderItemMessage = _data.ComplexOrderItemMessage; + _complexOrderMessage = _data.ComplexOrderMessage; + _intArrayMessage = _data.IntArrayMessage; + _mixedParamsMessage = _data.MixedParamsMessage; + // Pre-serialize response messages + _successResponseMessage = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, "Received: 42"); + _complexResponseMessage = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, _data.TestOrder); + Console.WriteLine("=== SignalR Message Size Comparison ==="); + Console.WriteLine($"Single int message: {_singleIntMessage.Length} bytes"); + Console.WriteLine($"Two int message: {_twoIntMessage.Length} bytes"); + Console.WriteLine($"Five params message: {_fiveParamsMessage.Length} bytes"); + Console.WriteLine($"Complex OrderItem message: {_complexOrderItemMessage.Length} bytes"); + Console.WriteLine($"Complex Order message: {_complexOrderMessage.Length} bytes"); + Console.WriteLine($"Int array message: {_intArrayMessage.Length} bytes"); + Console.WriteLine($"Mixed params message: {_mixedParamsMessage.Length} bytes"); + Console.WriteLine($"Success response: {_successResponseMessage.Length} bytes"); + Console.WriteLine($"Complex response: {_complexResponseMessage.Length} bytes"); + } + +#region Client-Side: Message Creation (IdMessage + MessagePack Serialization) + [Benchmark(Description = "Client: Create single int message")] + [BenchmarkCategory("Client", "Create")] + public byte[] Client_CreateSingleIntMessage() => SignalRMessageFactory.CreateSingleParamMessage(42); + [Benchmark(Description = "Client: Create two int message")] + [BenchmarkCategory("Client", "Create")] + public byte[] Client_CreateTwoIntMessage() => SignalRMessageFactory.CreateIdMessage(10, 20); + [Benchmark(Description = "Client: Create five params message")] + [BenchmarkCategory("Client", "Create")] + public byte[] Client_CreateFiveParamsMessage() => SignalRMessageFactory.CreateIdMessage(42, "hello", true, _data.TestGuid, 99.99m); + [Benchmark(Description = "Client: Create complex OrderItem message")] + [BenchmarkCategory("Client", "Create")] + public byte[] Client_CreateComplexOrderItemMessage() => SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrderItem); + [Benchmark(Description = "Client: Create complex Order message")] + [BenchmarkCategory("Client", "Create")] + public byte[] Client_CreateComplexOrderMessage() => SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder); +#endregion +#region Server-Side: Message Deserialization (MessagePack + JSON) + [Benchmark(Description = "Server: Deserialize single int")] + [BenchmarkCategory("Server", "Deserialize")] + public int Server_DeserializeSingleInt() + { + var postMessage = MessagePackSerializer.Deserialize(_singleIntMessage, SignalRMessageFactory.ContractlessOptions); + var idMessage = postMessage.PostDataJson!.JsonTo()!; + return AcJsonDeserializer.Deserialize(idMessage.Ids[0]); + } + + [Benchmark(Description = "Server: Deserialize two ints")] + [BenchmarkCategory("Server", "Deserialize")] + public (int, int) Server_DeserializeTwoInts() + { + var postMessage = MessagePackSerializer.Deserialize(_twoIntMessage, SignalRMessageFactory.ContractlessOptions); + var idMessage = postMessage.PostDataJson!.JsonTo()!; + var a = AcJsonDeserializer.Deserialize(idMessage.Ids[0]); + var b = AcJsonDeserializer.Deserialize(idMessage.Ids[1]); + return (a, b); + } + + [Benchmark(Description = "Server: Deserialize five params")] + [BenchmarkCategory("Server", "Deserialize")] + public (int, string, bool, Guid, decimal) Server_DeserializeFiveParams() + { + var postMessage = MessagePackSerializer.Deserialize(_fiveParamsMessage, SignalRMessageFactory.ContractlessOptions); + var idMessage = postMessage.PostDataJson!.JsonTo()!; + var a = AcJsonDeserializer.Deserialize(idMessage.Ids[0]); + var b = AcJsonDeserializer.Deserialize(idMessage.Ids[1])!; + var c = AcJsonDeserializer.Deserialize(idMessage.Ids[2]); + var d = AcJsonDeserializer.Deserialize(idMessage.Ids[3]); + var e = AcJsonDeserializer.Deserialize(idMessage.Ids[4]); + return (a, b, c, d, e); + } + + [Benchmark(Description = "Server: Deserialize complex OrderItem")] + [BenchmarkCategory("Server", "Deserialize")] + public TestOrderItem Server_DeserializeComplexOrderItem() + { + var postMessage = MessagePackSerializer.Deserialize(_complexOrderItemMessage, SignalRMessageFactory.StandardOptions); + return postMessage.PostDataJson!.JsonTo()!; + } + + [Benchmark(Description = "Server: Deserialize complex Order")] + [BenchmarkCategory("Server", "Deserialize")] + public TestOrder Server_DeserializeComplexOrder() + { + var postMessage = MessagePackSerializer.Deserialize(_complexOrderMessage, SignalRMessageFactory.StandardOptions); + return postMessage.PostDataJson!.JsonTo()!; + } + +#endregion +#region Server-Side: Response Creation (JSON + MessagePack Serialization) + [Benchmark(Description = "Server: Create success response (string)")] + [BenchmarkCategory("Server", "Response")] + public byte[] Server_CreateSuccessStringResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, "Received: 42"); + [Benchmark(Description = "Server: Create success response (OrderItem)")] + [BenchmarkCategory("Server", "Response")] + public byte[] Server_CreateSuccessOrderItemResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderItemParam, _data.TestOrderItem); + [Benchmark(Description = "Server: Create success response (Order)")] + [BenchmarkCategory("Server", "Response")] + public byte[] Server_CreateSuccessOrderResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, _data.TestOrder); +#endregion +#region Client-Side: Response Deserialization + [Benchmark(Description = "Client: Deserialize string response")] + [BenchmarkCategory("Client", "Response")] + public string? Client_DeserializeStringResponse() + { + var response = SignalRMessageFactory.DeserializeResponse(_successResponseMessage); + return response?.ResponseData; + } + + [Benchmark(Description = "Client: Deserialize complex Order response")] + [BenchmarkCategory("Client", "Response")] + public TestOrder? Client_DeserializeOrderResponse() + { + var response = SignalRMessageFactory.DeserializeResponse(_complexResponseMessage); + return response?.ResponseData?.JsonTo(); + } + +#endregion +#region Full Round-Trip Benchmarks + [Benchmark(Description = "Full: Single int round-trip")] + [BenchmarkCategory("Full")] + public string? Full_SingleIntRoundTrip() + { + // Client creates message + var requestBytes = SignalRMessageFactory.CreateSingleParamMessage(42); + // Server deserializes + var postMessage = MessagePackSerializer.Deserialize(requestBytes, SignalRMessageFactory.ContractlessOptions); + var idMessage = postMessage.PostDataJson!.JsonTo()!; + var value = AcJsonDeserializer.Deserialize(idMessage.Ids[0]); + // Server creates response + var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, $"Received: {value}"); + // Client deserializes response + var response = SignalRMessageFactory.DeserializeResponse(responseBytes); + return response?.ResponseData; + } + + [Benchmark(Description = "Full: Complex Order round-trip")] + [BenchmarkCategory("Full")] + public TestOrder? Full_ComplexOrderRoundTrip() + { + // Client creates message + var requestBytes = SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder); + // Server deserializes + var postMessage = MessagePackSerializer.Deserialize(requestBytes, SignalRMessageFactory.StandardOptions); + var order = postMessage.PostDataJson!.JsonTo()!; + // Server modifies and creates response + order.OrderNumber = "PROCESSED-" + order.OrderNumber; + var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, order); + // Client deserializes response + var response = SignalRMessageFactory.DeserializeResponse(responseBytes); + return response?.ResponseData?.JsonTo(); + } +#endregion +} \ No newline at end of file diff --git a/BenchmarkSuite1/SignalRRoundTripBenchmarks.cs b/BenchmarkSuite1/SignalRRoundTripBenchmarks.cs new file mode 100644 index 0000000..71f269d --- /dev/null +++ b/BenchmarkSuite1/SignalRRoundTripBenchmarks.cs @@ -0,0 +1,308 @@ +using System.Security.Claims; +using AyCode.Core.Extensions; +using AyCode.Core.Tests.TestModels; +using AyCode.Models.Server.DynamicMethods; +using AyCode.Services.Server.SignalRs; +using AyCode.Services.SignalRs; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.VSDiagnostics; + +namespace AyCode.Core.Benchmarks; + +/// +/// Benchmarks for SignalR round-trip communication using the same infrastructure as SignalRClientToHubTest. +/// Measures: Client -> Server -> Service -> Response -> Client +/// +[MemoryDiagnoser] +[CPUUsageDiagnoser] +public class SignalRRoundTripBenchmarks +{ + private BenchmarkSignalRClient _client = null!; + private BenchmarkSignalRHub _hub = null!; + private BenchmarkSignalRService _service = null!; + + // Pre-created test data + private TestOrderItem _testOrderItem = null!; + private TestOrder _testOrder = null!; + private SharedTag _sharedTag = null!; + private int[] _intArray = null!; + private List _stringList = null!; + private Guid _testGuid; + + [GlobalSetup] + public void Setup() + { + var logger = new TestLogger(); + _hub = new BenchmarkSignalRHub(logger); + _service = new BenchmarkSignalRService(); + _client = new BenchmarkSignalRClient(_hub, logger); + _hub.RegisterService(_service, _client); + + // Pre-create test data + _testOrderItem = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m }; + _testOrder = TestDataFactory.CreateOrder(itemCount: 3); + _sharedTag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" }; + _intArray = [1, 2, 3, 4, 5]; + _stringList = ["apple", "banana", "cherry"]; + _testGuid = Guid.NewGuid(); + } + + #region Primitive Parameter Benchmarks + + [Benchmark(Description = "RoundTrip: Single int")] + [BenchmarkCategory("Primitives")] + public string? RoundTrip_SingleInt() + { + return _client.PostDataSync(BenchmarkSignalRTags.SingleIntParam, 42); + } + + [Benchmark(Description = "RoundTrip: Two ints")] + [BenchmarkCategory("Primitives")] + public int RoundTrip_TwoInts() + { + return _client.PostSync(BenchmarkSignalRTags.TwoIntParams, [10, 20]); + } + + [Benchmark(Description = "RoundTrip: Bool")] + [BenchmarkCategory("Primitives")] + public bool RoundTrip_Bool() + { + return _client.PostDataSync(BenchmarkSignalRTags.BoolParam, true); + } + + [Benchmark(Description = "RoundTrip: String")] + [BenchmarkCategory("Primitives")] + public string? RoundTrip_String() + { + return _client.PostDataSync(BenchmarkSignalRTags.StringParam, "Hello"); + } + + [Benchmark(Description = "RoundTrip: Guid")] + [BenchmarkCategory("Primitives")] + public Guid RoundTrip_Guid() + { + return _client.PostDataSync(BenchmarkSignalRTags.GuidParam, _testGuid); + } + + [Benchmark(Description = "RoundTrip: No params")] + [BenchmarkCategory("Primitives")] + public string? RoundTrip_NoParams() + { + return _client.GetAllSync(BenchmarkSignalRTags.NoParams); + } + + [Benchmark(Description = "RoundTrip: Multiple types (3 params)")] + [BenchmarkCategory("Primitives")] + public string? RoundTrip_MultipleTypes() + { + return _client.PostSync(BenchmarkSignalRTags.MultipleTypesParams, [true, "test", 42]); + } + + #endregion + + #region Complex Object Benchmarks + + [Benchmark(Description = "RoundTrip: TestOrderItem")] + [BenchmarkCategory("Complex")] + public TestOrderItem? RoundTrip_TestOrderItem() + { + return _client.PostDataSync(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem); + } + + [Benchmark(Description = "RoundTrip: TestOrder (3 items)")] + [BenchmarkCategory("Complex")] + public TestOrder? RoundTrip_TestOrder() + { + return _client.PostDataSync(BenchmarkSignalRTags.TestOrderParam, _testOrder); + } + + [Benchmark(Description = "RoundTrip: SharedTag")] + [BenchmarkCategory("Complex")] + public SharedTag? RoundTrip_SharedTag() + { + return _client.PostDataSync(BenchmarkSignalRTags.SharedTagParam, _sharedTag); + } + + #endregion + + #region Collection Benchmarks + + [Benchmark(Description = "RoundTrip: int[] (5 elements)")] + [BenchmarkCategory("Collections")] + public int[]? RoundTrip_IntArray() + { + return _client.PostDataSync(BenchmarkSignalRTags.IntArrayParam, _intArray); + } + + [Benchmark(Description = "RoundTrip: List (3 elements)")] + [BenchmarkCategory("Collections")] + public List? RoundTrip_StringList() + { + return _client.PostDataSync, List>(BenchmarkSignalRTags.StringListParam, _stringList); + } + + #endregion + + #region Mixed Parameter Benchmarks + + [Benchmark(Description = "RoundTrip: Int + DTO")] + [BenchmarkCategory("Mixed")] + public string? RoundTrip_IntAndDto() + { + return _client.PostSync(BenchmarkSignalRTags.IntAndDtoParam, [42, _testOrderItem]); + } + + [Benchmark(Description = "RoundTrip: 5 mixed params")] + [BenchmarkCategory("Mixed")] + public string? RoundTrip_FiveParams() + { + return _client.PostSync(BenchmarkSignalRTags.FiveParams, [42, "hello", true, _testGuid, 99.99m]); + } + + #endregion +} + +#region Benchmark Infrastructure (minimal, reuses production code) + +/// +/// SignalR tags for benchmarks - matches TestSignalRTags structure +/// +public abstract class BenchmarkSignalRTags : AcSignalRTags +{ + public const int SingleIntParam = 100; + public const int TwoIntParams = 101; + public const int BoolParam = 102; + public const int StringParam = 103; + public const int GuidParam = 104; + public const int NoParams = 107; + public const int MultipleTypesParams = 109; + public const int TestOrderItemParam = 120; + public const int TestOrderParam = 121; + public const int SharedTagParam = 122; + public const int IntArrayParam = 130; + public const int StringListParam = 132; + public const int IntAndDtoParam = 160; + public const int FiveParams = 164; +} + +/// +/// Benchmark-optimized SignalR client with synchronous methods for accurate timing +/// +public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServer +{ + private readonly BenchmarkSignalRHub _hub; + + public BenchmarkSignalRClient(BenchmarkSignalRHub hub, TestLogger logger) : base(logger) + { + _hub = hub; + // Eliminate polling delay for benchmarks + MsDelay = 0; + MsFirstDelay = 0; + } + + // Synchronous wrappers for benchmarking (avoids async overhead measurement) + public TResponse? PostDataSync(int tag, TPost data) + => PostDataAsync(tag, data).GetAwaiter().GetResult(); + + public TResponse? PostSync(int tag, object[] parameters) + => PostAsync(tag, parameters).GetAwaiter().GetResult(); + + public TResponse? GetAllSync(int tag) + => GetAllAsync(tag).GetAwaiter().GetResult(); + + protected override Task MessageReceived(int messageTag, byte[] messageBytes) => Task.CompletedTask; + protected override HubConnectionState GetConnectionState() => HubConnectionState.Connected; + protected override bool IsConnected() => true; + protected override Task StartConnectionInternal() => Task.CompletedTask; + protected override Task StopConnectionInternal() => Task.CompletedTask; + protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask; + + protected override async Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId) + { + await _hub.OnReceiveMessage(messageTag, messageBytes, requestId); + } +} + +/// +/// Benchmark-optimized SignalR hub +/// +public class BenchmarkSignalRHub : AcWebSignalRHubBase +{ + private IAcSignalRHubItemServer _callerClient = null!; + + public BenchmarkSignalRHub(TestLogger logger) : base(new ConfigurationBuilder().Build(), logger) + { + } + + public void RegisterService(object service, IAcSignalRHubItemServer client) + { + _callerClient = client; + DynamicMethodCallModels.Add(new AcDynamicMethodCallModel(service)); + } + + protected override string GetConnectionId() => "benchmark-connection"; + protected override bool IsConnectionAborted() => false; + protected override string? GetUserIdentifier() => "benchmark-user"; + protected override ClaimsPrincipal? GetUser() => null; + + protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId) + => SendMessageToClient(_callerClient, messageTag, message, requestId); +} + +/// +/// Benchmark service handlers - same logic as TestSignalRService2 +/// +public class BenchmarkSignalRService +{ + [SignalR(BenchmarkSignalRTags.SingleIntParam)] + public string HandleSingleInt(int value) => $"{value}"; + + [SignalR(BenchmarkSignalRTags.TwoIntParams)] + public int HandleTwoInts(int a, int b) => a + b; + + [SignalR(BenchmarkSignalRTags.BoolParam)] + public bool HandleBool(bool value) => value; + + [SignalR(BenchmarkSignalRTags.StringParam)] + public string HandleString(string text) => $"Echo: {text}"; + + [SignalR(BenchmarkSignalRTags.GuidParam)] + public Guid HandleGuid(Guid id) => id; + + [SignalR(BenchmarkSignalRTags.NoParams)] + public string HandleNoParams() => "OK"; + + [SignalR(BenchmarkSignalRTags.MultipleTypesParams)] + public string HandleMultipleTypes(bool flag, string text, int number) => $"{flag}-{text}-{number}"; + + [SignalR(BenchmarkSignalRTags.TestOrderItemParam)] + public TestOrderItem HandleTestOrderItem(TestOrderItem item) => new() + { + Id = item.Id, + ProductName = $"Processed: {item.ProductName}", + Quantity = item.Quantity * 2, + UnitPrice = item.UnitPrice * 2, + }; + + [SignalR(BenchmarkSignalRTags.TestOrderParam)] + public TestOrder HandleTestOrder(TestOrder order) => order; + + [SignalR(BenchmarkSignalRTags.SharedTagParam)] + public SharedTag HandleSharedTag(SharedTag tag) => tag; + + [SignalR(BenchmarkSignalRTags.IntArrayParam)] + public int[] HandleIntArray(int[] values) => values.Select(x => x * 2).ToArray(); + + [SignalR(BenchmarkSignalRTags.StringListParam)] + public List HandleStringList(List items) => items.Select(x => x.ToUpper()).ToList(); + + [SignalR(BenchmarkSignalRTags.IntAndDtoParam)] + public string HandleIntAndDto(int id, TestOrderItem item) => $"{id}-{item?.ProductName}"; + + [SignalR(BenchmarkSignalRTags.FiveParams)] + public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e) => $"{a}-{b}-{c}-{d}-{e}"; +} + +#endregion diff --git a/BenchmarkSuite1/TaskHelperBenchmarks.cs b/BenchmarkSuite1/TaskHelperBenchmarks.cs new file mode 100644 index 0000000..e09aacd --- /dev/null +++ b/BenchmarkSuite1/TaskHelperBenchmarks.cs @@ -0,0 +1,66 @@ +using AyCode.Core.Helpers; +using BenchmarkDotNet.Attributes; +using Microsoft.VSDiagnostics; + +namespace AyCode.Core.Benchmarks; +[CPUUsageDiagnoser] +public class TaskHelperBenchmarks +{ + private volatile bool _flag; + private int _counter; + private Action _incrementAction = null !; + private Func _incrementFunc = null !; + private Func> _incrementAsyncFunc = null !; + [GlobalSetup] + public void Setup() + { + _incrementAction = () => _counter++; + _incrementFunc = () => ++_counter; + _incrementAsyncFunc = async () => + { + await Task.Yield(); + return ++_counter; + }; + } + + [IterationSetup] + public void IterationSetup() + { + _flag = true; // Pre-set for immediate success + _counter = 0; + } + +#region WaitToAsync Benchmarks + [Benchmark(Description = "WaitToAsync - immediate success")] + [BenchmarkCategory("WaitToAsync")] + public Task WaitToAsync_ImmediateSuccess() => TaskHelper.WaitToAsync(() => _flag, 1000, 1); + [Benchmark(Description = "WaitToAsync - short timeout (100ms)")] + [BenchmarkCategory("WaitToAsync")] + public Task WaitToAsync_ShortTimeout() => TaskHelper.WaitToAsync(() => true, 100, 1); +#endregion +#region ToThreadPoolTask Benchmarks + [Benchmark(Description = "ToThreadPoolTask - Action")] + [BenchmarkCategory("ThreadPool")] + public Task ToThreadPoolTask_Action() => _incrementAction.ToThreadPoolTask(); + [Benchmark(Description = "ToThreadPoolTask - Func")] + [BenchmarkCategory("ThreadPool")] + public Task ToThreadPoolTask_FuncT() => _incrementFunc.ToThreadPoolTask(); + [Benchmark(Description = "ToThreadPoolTask - Func>")] + [BenchmarkCategory("ThreadPool")] + public Task ToThreadPoolTask_FuncTaskT() => _incrementAsyncFunc.ToThreadPoolTask(); + [Benchmark(Description = "Task.Run baseline - Action")] + [BenchmarkCategory("ThreadPool")] + public Task TaskRun_Action_Baseline() => Task.Run(_incrementAction); +#endregion +#region Timing Method Comparison + [Benchmark(Description = "DateTime.UtcNow.Ticks")] + [BenchmarkCategory("Timing")] + public long DateTimeUtcNow_Ticks() => DateTime.UtcNow.Ticks; + [Benchmark(Description = "Environment.TickCount64")] + [BenchmarkCategory("Timing")] + public long EnvironmentTickCount64() => Environment.TickCount64; + [Benchmark(Description = "DateTime.UtcNow.AddMilliseconds")] + [BenchmarkCategory("Timing")] + public long DateTimeUtcNow_AddMilliseconds() => DateTime.UtcNow.AddMilliseconds(1000).Ticks; +#endregion +} \ No newline at end of file