using System.Runtime.Serialization; using AyCode.Core.Enums; using AyCode.Core.Extensions; using AyCode.Core.Interfaces; using AyCode.Core.Loggers; using Newtonsoft.Json; namespace AyCode.Core.Tests; [TestClass] public sealed class JsonExtensionTests { [TestInitialize] public void TestInit() { 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, //}; } #region Deep Hierarchy Tests (5 Levels) [TestMethod] 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); // 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"", ""Items"": [{{ ""Id"": {originalItem.Id}, ""ProductName"": ""Updated-Product"", ""Pallets"": [{{ ""Id"": {originalPallet.Id}, ""PalletCode"": ""PLT-UPDATED"", ""Measurements"": [{{ ""Id"": {originalMeasurement.Id}, ""Name"": ""Measurement-UPDATED"", ""Points"": [{{ ""Id"": {originalPoint.Id}, ""Label"": ""Point-UPDATED"", ""Value"": 999.99 }}] }}] }}] }}] }}"; // Act updateJson.JsonTo(order, settings); // 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"); Assert.AreSame(originalPoint, order.Items[0].Pallets[0].Measurements[0].Points[0], "Level 5: Point reference must be preserved"); // Assert: Values updated Assert.AreEqual("ORD-UPDATED", order.OrderNumber); Assert.AreEqual("Updated-Product", order.Items[0].ProductName); Assert.AreEqual("PLT-UPDATED", order.Items[0].Pallets[0].PalletCode); Assert.AreEqual("Measurement-UPDATED", order.Items[0].Pallets[0].Measurements[0].Name); Assert.AreEqual("Point-UPDATED", order.Items[0].Pallets[0].Measurements[0].Points[0].Label); Assert.AreEqual(999.99, order.Items[0].Pallets[0].Measurements[0].Points[0].Value); } [TestMethod] public void DeepHierarchy_5Levels_InsertAndKeepLogic() { // Arrange TestDataFactory.ResetIdCounter(); var order = TestDataFactory.CreateDeepOrder(itemCount: 2, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2); var originalItemCount = order.Items.Count; var originalItem2 = order.Items[1]; // This should be KEPT 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"": [{{ ""Id"": {order.Items[0].Id}, ""Pallets"": [{{ ""Id"": {order.Items[0].Pallets[0].Id}, ""Measurements"": [{{ ""Id"": {order.Items[0].Pallets[0].Measurements[0].Id}, ""Points"": [ {{ ""Id"": {existingPointId}, ""Label"": ""Updated-Point"" }}, {{ ""Id"": 9999, ""Label"": ""NEW-Point"", ""Value"": 123.45 }} ] }}] }}] }}] }}"; // Act updateJson.JsonTo(order, settings); // Assert: KEEP logic works at all levels 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"); } #endregion #region Semantic Reference Tests (IId types with long-based semantic IDs) [TestMethod] public void SemanticReference_SharedAttribute_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); var settings = GetMergeSettings(); settings.Formatting = Formatting.Indented; // Act var json = order.ToJson(settings); 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}"); } [TestMethod] public void SemanticReference_DeserializeAndMerge_PreservesSharedReferences() { // Arrange TestDataFactory.ResetIdCounter(); var sharedAttr = TestDataFactory.CreateSharedAttribute(); sharedAttr.Key = "OriginalKey"; var order = new TestOrder { Id = 1, OrderNumber = "ORD-001", PrimaryAttribute = sharedAttr, SecondaryAttribute = sharedAttr, // Same reference Attributes = [sharedAttr] }; var originalAttrRef = order.PrimaryAttribute; // Update that modifies the shared attribute var updateJson = @"{ ""Id"": 1, ""OrderNumber"": ""ORD-UPDATED"", ""PrimaryAttribute"": { ""Id"": 1, ""Key"": ""UpdatedKey"", ""Value"": ""UpdatedValue"" } }"; var settings = GetMergeSettings(); // Act updateJson.JsonTo(order, settings); // 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"); } #endregion #region Newtonsoft Reference Tests (Non-IId types with numeric $id/$ref) [TestMethod] public void NewtonsoftReference_SharedNonIdMetadata_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); var settings = GetMergeSettings(); settings.Formatting = Formatting.Indented; // Act var json = order.ToJson(settings); 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"); } [TestMethod] public void NewtonsoftReference_DeepNestedNonId_HandlesCorrectly() { // Arrange: Create chain of non-IId metadata var rootMeta = new TestNonIdMetadata { Key = "Root", Value = "RootValue", Timestamp = DateTime.UtcNow, ChildMetadata = new TestNonIdMetadata { Key = "Child", Value = "ChildValue", Timestamp = DateTime.UtcNow, ChildMetadata = new TestNonIdMetadata { Key = "GrandChild", Value = "GrandChildValue", Timestamp = DateTime.UtcNow } } }; var order = new TestOrder { Id = 1, OrderNumber = "ORD-001", OrderMetadata = rootMeta, AuditMetadata = rootMeta // Same reference }; var settings = GetMergeSettings(); settings.Formatting = Formatting.Indented; // 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"); } #endregion #region Hybrid Reference Tests (Mixed IId and Non-IId) [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 order = new TestOrder { Id = 1, OrderNumber = "ORD-001", PrimaryAttribute = sharedAttr, SecondaryAttribute = sharedAttr, OrderMetadata = sharedMeta, AuditMetadata = sharedMeta, Items = [ new TestOrderItem { Id = 10, ProductName = "Product-A", Quantity = 5 }, new TestOrderItem { Id = 20, ProductName = "Product-B", Quantity = 3 } ] }; 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(); // 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); } #endregion #region NoMerge Collection Tests [TestMethod] public void NoMergeCollection_DeepHierarchy_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 originalNoMergeRef = order.NoMergeItems; var updateJson = $@"{{ ""Id"": {order.Id}, ""NoMergeItems"": [ {{ ""Id"": 200, ""ProductName"": ""NoMerge-NEW-A"", ""Quantity"": 10 }}, {{ ""Id"": 201, ""ProductName"": ""NoMerge-NEW-B"", ""Quantity"": 20 }} ] }}"; var settings = GetMergeSettings(); // Act order.DeepPopulateWithMerge(updateJson, settings); // 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"); } #endregion #region Non-IId Collection Tests [TestMethod] public void NonIdCollection_ReplacesContent() { // Arrange var order = new TestOrder { Id = 1, OrderNumber = "ORD-001", MetadataList = [ new TestNonIdMetadata { Key = "Old-A", Value = "Old-Value-A" }, new TestNonIdMetadata { Key = "Old-B", Value = "Old-Value-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"" } ] }"; var settings = GetMergeSettings(); // Act order.DeepPopulateWithMerge(updateJson, settings); // Assert: Collection content replaced Assert.AreEqual(3, order.MetadataList.Count); Assert.IsFalse(order.MetadataList.Any(m => m.Key == "Old-A")); 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")); } #endregion #region Guid IId Tests [TestMethod] public void GuidId_DeepPopulate_ReferencePreserved() { var order = new TestGuidOrder { Id = Guid.NewGuid(), Code = "ORD-001", 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 json = new { Id = order.Id, Code = "ORD-UPDATED", Items = new[] { new { Id = order.Items[0].Id, 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); Assert.AreEqual("ORD-UPDATED", order.Code); Assert.AreEqual(999, order.Count); } #endregion #region Round-Trip Serialization Tests [TestMethod] public void RoundTrip_DeepHierarchy_SerializeAndVerifyStructure() { // 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(); // Act updateJson.JsonTo(originalOrder, settings); // 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!"); } [TestMethod] public void PrimitiveArray_BooleanFalse_SerializesAndDeserializesCorrectly() { var loadRelations = false; var settings = GetMergeSettings(); var jsonString = (new[] { loadRelations }).ToJson(settings); Console.WriteLine($"Serialized [false]: {jsonString}"); 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"); } [TestMethod] public void PrimitiveArray_Int_SerializesAndDeserializesCorrectly() { 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 { Id = 1, ProductName = "Test \"quoted\" \\ backslash \n newline \t tab \r return" }; // Act 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"); // Round-trip var deserialized = AcJsonDeserializer.Deserialize(json); Assert.AreEqual("Test \"quoted\" \\ backslash \n newline \t tab \r return", deserialized?.ProductName); } [TestMethod] public void PrimitiveArray_Enum_SerializesAndDeserializesCorrectly() { 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"); } [TestMethod] public void PrimitiveArray_DateTime_SerializesAndDeserializesCorrectly() { var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); var settings = GetMergeSettings(); var jsonString = (new[] { dateTime }).ToJson(settings); Console.WriteLine($"Serialized [{dateTime}]: {jsonString}"); 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"); } [TestMethod] public void PrimitiveArray_Decimal_SerializesAndDeserializesCorrectly() { 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"); } [TestMethod] public void PrimitiveArray_MultipleParameters_SimulateSignalRCall() { // 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) { // 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]); } [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]); } [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()); } #endregion #region Cross-Serializer Compatibility Tests [TestMethod] public void CrossSerializer_MixedReferences_SerializeWithHybridDeserializeWithNativeNewtonsoft() { // Arrange: Create complex object with both IId and non-IId shared references TestDataFactory.ResetIdCounter(); var sharedAttr = new TestSharedAttribute { Id = 100, Key = "SharedKey", Value = "SharedValue", CreatedOrUpdatedDateUTC = DateTime.UtcNow }; var sharedMeta = new TestNonIdMetadata { Key = "SharedMeta", Value = "MetaValue", Timestamp = DateTime.UtcNow, ChildMetadata = new TestNonIdMetadata { Key = "ChildMeta", Value = "ChildValue", Timestamp = DateTime.UtcNow } }; // Shared attribute also has nested non-IId metadata sharedAttr.NestedMetadata = sharedMeta; var order = new TestOrder { 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] }; var settings = GetMergeSettings(); settings.Formatting = Formatting.Indented; // Act var json = order.ToJson(settings); 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 { 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("ORD-001", deserializedOrder.OrderNumber); Assert.AreEqual(TestStatus.Processing, deserializedOrder.OrderStatus); // 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"); // 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"); // 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"); // Verify child metadata Assert.IsNotNull(deserializedOrder.OrderMetadata.ChildMetadata); Assert.AreEqual("ChildMeta", deserializedOrder.OrderMetadata.ChildMetadata.Key); Console.WriteLine("=== All cross-serializer compatibility checks passed! ==="); } #endregion #region WASM Compatibility Tests /// /// Tests that verify WASM compatibility - no reflection emit, no dynamic code generation, /// compatible with AOT compilation and interpreter mode. /// [TestMethod] public void WasmCompat_AcJsonSerializer_SimpleObject_WorksWithoutReflectionEmit() { // 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}"); } [TestMethod] public void WasmCompat_AcJsonDeserializer_SimpleObject_WorksWithSystemTextJson() { // 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 } ] }; // Act - Serialize, then deserialize var json = AcJsonSerializer.Serialize(order); Console.WriteLine($"JSON size: {json.Length} chars"); Console.WriteLine($"JSON: {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; } } #endregion #region IdMessage SignalR Tests (Reproduces ProcessOnReceiveMessage behavior) /// /// These tests reproduce the exact flow in AcWebSignalRHubBase.ProcessOnReceiveMessage /// where IdMessage wraps parameters and the server deserializes them back. /// [TestMethod] public void IdMessage_FullSignalRScenario_IntArrayParameter() { // 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); } #endregion }