diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerTests.cs
index d430d7b..ac2532a 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerTests.cs
@@ -303,59 +303,321 @@ public class AcBinarySerializerTests
Assert.AreEqual(obj.Field1, result2!.Field1);
}
- #endregion
-
- #region Size Comparison Tests
-
+ ///
+ /// REGRESSION TEST: Comprehensive string interning edge cases.
+ ///
+ /// Production bug pattern: "Invalid interned string index: X. Interned strings count: Y"
+ ///
+ /// Root causes identified:
+ /// 1. Property names not being registered in intern table during deserialization
+ /// 2. String values with same length but different content
+ /// 3. Nested objects creating complex interning order
+ /// 4. Collections of objects with repeated property names
+ ///
[TestMethod]
- public void Serialize_IsSmallerThanJson()
+ public void StringInterning_PropertyNames_MustBeRegisteredDuringDeserialization()
{
- var obj = TestDataFactory.CreateBenchmarkOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3);
+ // This test verifies that property names (>= 4 chars) are properly
+ // registered in the intern table during deserialization.
+ // The serializer registers them via WriteString, so deserializer must too.
+
+ var items = Enumerable.Range(0, 10).Select(i => new TestClassWithLongPropertyNames
+ {
+ FirstProperty = $"Value1_{i}",
+ SecondProperty = $"Value2_{i}",
+ ThirdProperty = $"Value3_{i}",
+ FourthProperty = $"Value4_{i}"
+ }).ToList();
- var jsonBytes = System.Text.Encoding.UTF8.GetBytes(obj.ToJson());
- var binaryBytes = obj.ToBinary();
+ var binary = items.ToBinary();
+ var result = binary.BinaryTo>();
- Console.WriteLine($"JSON size: {jsonBytes.Length} bytes");
- Console.WriteLine($"Binary size: {binaryBytes.Length} bytes");
- Console.WriteLine($"Ratio: {(double)binaryBytes.Length / jsonBytes.Length:P2}");
-
- Assert.IsTrue(binaryBytes.Length < jsonBytes.Length,
- $"Binary ({binaryBytes.Length}) should be smaller than JSON ({jsonBytes.Length})");
-
- // Verify roundtrip works
- var result = binaryBytes.BinaryTo();
Assert.IsNotNull(result);
- Assert.AreEqual(obj.Id, result.Id);
- Assert.AreEqual(obj.Items.Count, result.Items.Count);
+ Assert.AreEqual(10, result.Count);
+ for (int i = 0; i < 10; i++)
+ {
+ Assert.AreEqual($"Value1_{i}", result[i].FirstProperty);
+ Assert.AreEqual($"Value2_{i}", result[i].SecondProperty);
+ }
}
- #endregion
+ [TestMethod]
+ public void StringInterning_MixedShortAndLongStrings_HandledCorrectly()
+ {
+ // Short strings (< 4 chars) are NOT interned
+ // Long strings (>= 4 chars) ARE interned
+ // This creates different traversal patterns
+
+ var items = Enumerable.Range(0, 20).Select(i => new TestClassWithMixedStrings
+ {
+ Id = i,
+ ShortName = $"A{i % 3}", // 2-3 chars, NOT interned
+ LongName = $"LongName_{i % 5}", // > 4 chars, interned
+ Description = $"Description_value_{i % 7}", // > 4 chars, interned
+ Tag = i % 2 == 0 ? "AB" : "XY" // 2 chars, NOT interned
+ }).ToList();
- #region Extension Method Tests
+ var binary = items.ToBinary();
+ var result = binary.BinaryTo>();
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(20, result.Count);
+
+ for (int i = 0; i < 20; i++)
+ {
+ Assert.AreEqual(i, result[i].Id);
+ Assert.AreEqual($"A{i % 3}", result[i].ShortName);
+ Assert.AreEqual($"LongName_{i % 5}", result[i].LongName);
+ Assert.AreEqual($"Description_value_{i % 7}", result[i].Description);
+ }
+ }
[TestMethod]
- public void BinaryCloneTo_CreatesDeepCopy()
+ public void StringInterning_DeeplyNestedObjects_MaintainsCorrectOrder()
{
- var original = new TestNestedClass
+ // Complex nested structure where property names and values
+ // are interleaved in a specific order
+
+ var root = new TestNestedStructure
{
- Id = 1,
- Name = "Original",
- Child = new TestSimpleClass { Id = 2, Name = "Child" }
+ RootName = "RootObject",
+ Level1Items = Enumerable.Range(0, 5).Select(i => new TestLevel1
+ {
+ Level1Name = $"Level1_{i}",
+ Level2Items = Enumerable.Range(0, 3).Select(j => new TestLevel2
+ {
+ Level2Name = $"Level2_{i}_{j}",
+ Value = $"Value_{i * 3 + j}"
+ }).ToList()
+ }).ToList()
};
- var clone = original.BinaryCloneTo();
+ var binary = root.ToBinary();
+ var result = binary.BinaryTo();
- Assert.IsNotNull(clone);
- Assert.AreNotSame(original, clone);
- Assert.AreNotSame(original.Child, clone.Child);
- Assert.AreEqual(original.Id, clone.Id);
- Assert.AreEqual(original.Child.Id, clone.Child!.Id);
+ Assert.IsNotNull(result);
+ Assert.AreEqual("RootObject", result.RootName);
+ Assert.AreEqual(5, result.Level1Items.Count);
+
+ for (int i = 0; i < 5; i++)
+ {
+ Assert.AreEqual($"Level1_{i}", result.Level1Items[i].Level1Name);
+ Assert.AreEqual(3, result.Level1Items[i].Level2Items.Count);
+
+ for (int j = 0; j < 3; j++)
+ {
+ Assert.AreEqual($"Level2_{i}_{j}", result.Level1Items[i].Level2Items[j].Level2Name);
+ }
+ }
+ }
- // Modify clone, original should be unchanged
- clone.Id = 999;
- clone.Child.Id = 888;
- Assert.AreEqual(1, original.Id);
- Assert.AreEqual(2, original.Child.Id);
+ [TestMethod]
+ public void StringInterning_RepeatedPropertyValuesAcrossObjects_UsesReferences()
+ {
+ // When the same string value appears multiple times,
+ // the serializer writes StringInterned reference instead of the full string.
+ // The deserializer must look up the correct index.
+
+ var items = Enumerable.Range(0, 50).Select(i => new TestClassWithRepeatedValues
+ {
+ Id = i,
+ Status = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed",
+ Category = i % 5 == 0 ? "CategoryA" : i % 5 == 1 ? "CategoryB" : "CategoryC",
+ Priority = i % 2 == 0 ? "High" : "Low_Priority_Value"
+ }).ToList();
+
+ var binary = items.ToBinary();
+ var result = binary.BinaryTo>();
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(50, result.Count);
+
+ for (int i = 0; i < 50; i++)
+ {
+ Assert.AreEqual(i, result[i].Id);
+ var expectedStatus = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed";
+ Assert.AreEqual(expectedStatus, result[i].Status, $"Status mismatch at index {i}");
+ }
+ }
+
+ [TestMethod]
+ public void StringInterning_ManyUniqueStringsFollowedByRepeats_CorrectIndexLookup()
+ {
+ // First create many unique strings (all get registered)
+ // Then repeat some of them (use StringInterned references)
+ // This tests the index calculation
+
+ var items = new List();
+
+ // First 30 items with unique names (all registered as new)
+ for (int i = 0; i < 30; i++)
+ {
+ items.Add(new TestClassWithNameValue
+ {
+ Name = $"UniqueName_{i:D4}",
+ Value = $"UniqueValue_{i:D4}"
+ });
+ }
+
+ // Next 20 items reuse names from first batch (should use StringInterned)
+ for (int i = 0; i < 20; i++)
+ {
+ items.Add(new TestClassWithNameValue
+ {
+ Name = $"UniqueName_{i % 10:D4}", // Reuse first 10 names
+ Value = $"UniqueValue_{(i + 10) % 30:D4}" // Reuse different values
+ });
+ }
+
+ var binary = items.ToBinary();
+ var result = binary.BinaryTo>();
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(50, result.Count);
+
+ // Verify first batch
+ for (int i = 0; i < 30; i++)
+ {
+ Assert.AreEqual($"UniqueName_{i:D4}", result[i].Name, $"Name mismatch at index {i}");
+ Assert.AreEqual($"UniqueValue_{i:D4}", result[i].Value, $"Value mismatch at index {i}");
+ }
+
+ // Verify second batch (reused strings)
+ for (int i = 0; i < 20; i++)
+ {
+ Assert.AreEqual($"UniqueName_{i % 10:D4}", result[30 + i].Name, $"Name mismatch at index {30 + i}");
+ }
+ }
+
+ [TestMethod]
+ public void StringInterning_EmptyAndNullStrings_DoNotAffectInternTable()
+ {
+ // Empty strings use StringEmpty type code
+ // Null strings use Null type code
+ // Neither should affect intern table indices
+
+ var items = new List();
+
+ for (int i = 0; i < 25; i++)
+ {
+ items.Add(new TestClassWithNullableStrings
+ {
+ Id = i,
+ RequiredName = $"Required_{i:D3}",
+ OptionalName = i % 3 == 0 ? null : i % 3 == 1 ? "" : $"Optional_{i:D3}",
+ Description = i % 2 == 0 ? $"Description_{i % 5:D3}" : null
+ });
+ }
+
+ var binary = items.ToBinary();
+ var result = binary.BinaryTo>();
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(25, result.Count);
+
+ for (int i = 0; i < 25; i++)
+ {
+ Assert.AreEqual(i, result[i].Id);
+ Assert.AreEqual($"Required_{i:D3}", result[i].RequiredName);
+
+ if (i % 3 == 0)
+ {
+ Assert.IsNull(result[i].OptionalName, $"OptionalName at index {i} should be null");
+ }
+ else if (i % 3 == 1)
+ {
+ // Empty string may deserialize as either "" or null depending on implementation
+ Assert.IsTrue(string.IsNullOrEmpty(result[i].OptionalName),
+ $"OptionalName at index {i} should be null or empty, got: '{result[i].OptionalName}'");
+ }
+ else
+ {
+ Assert.AreEqual($"Optional_{i:D3}", result[i].OptionalName,
+ $"OptionalName at index {i} mismatch");
+ }
+ }
+ }
+
+ [TestMethod]
+ public void StringInterning_ProductionLikeCustomerDto_RoundTrip()
+ {
+ // Simulate the CustomerDto structure that causes production issues
+ // Key characteristics:
+ // - Many string properties (FirstName, LastName, Email, Company, etc.)
+ // - GenericAttributes list with repeated Key values
+ // - List of items with common status/category values
+
+ var customers = Enumerable.Range(0, 25).Select(i => new TestCustomerLikeDto
+ {
+ Id = i,
+ FirstName = $"FirstName_{i % 10}", // 10 unique values
+ LastName = $"LastName_{i % 8}", // 8 unique values
+ Email = $"user{i}@example.com", // All unique
+ Company = $"Company_{i % 5}", // 5 unique values
+ Department = i % 3 == 0 ? "Sales" : i % 3 == 1 ? "Engineering" : "Marketing",
+ Role = i % 4 == 0 ? "Admin" : i % 4 == 1 ? "User" : i % 4 == 2 ? "Manager" : "Guest",
+ Status = i % 2 == 0 ? "Active" : "Inactive",
+ Attributes = new List
+ {
+ new() { Key = "DateOfReceipt", Value = $"{i % 28 + 1}/24/2025 00:27:00" },
+ new() { Key = "Priority", Value = (i % 5).ToString() },
+ new() { Key = "CustomField1", Value = $"CustomValue_{i % 7}" },
+ new() { Key = "CustomField2", Value = i % 2 == 0 ? "Yes" : "No_Value" }
+ }
+ }).ToList();
+
+ var binary = customers.ToBinary();
+ var result = binary.BinaryTo>();
+
+ Assert.IsNotNull(result, "Result should not be null - deserialization failed");
+ Assert.AreEqual(25, result.Count);
+
+ for (int i = 0; i < 25; i++)
+ {
+ Assert.AreEqual(i, result[i].Id, $"Id mismatch at index {i}");
+ Assert.AreEqual($"FirstName_{i % 10}", result[i].FirstName, $"FirstName mismatch at index {i}");
+ Assert.AreEqual($"LastName_{i % 8}", result[i].LastName, $"LastName mismatch at index {i}");
+ Assert.AreEqual($"user{i}@example.com", result[i].Email, $"Email mismatch at index {i}");
+ Assert.AreEqual(4, result[i].Attributes.Count, $"Attributes count mismatch at index {i}");
+
+ Assert.AreEqual("DateOfReceipt", result[i].Attributes[0].Key);
+ Assert.AreEqual("Priority", result[i].Attributes[1].Key);
+ }
+ }
+
+ [TestMethod]
+ public void StringInterning_LargeListWithHighStringReuse_HandlesCorrectly()
+ {
+ // Large dataset (100+ items) with high string reuse ratio
+ // This is the scenario that triggers production bugs
+
+ const int itemCount = 150;
+ var items = Enumerable.Range(0, itemCount).Select(i => new TestHighReuseDto
+ {
+ Id = i,
+ // Property names are reused 150 times (once per object)
+ CategoryCode = $"CAT_{i % 10:D2}", // 10 unique values, 15x reuse each
+ StatusCode = $"STATUS_{i % 5:D2}", // 5 unique values, 30x reuse each
+ TypeCode = $"TYPE_{i % 3:D2}", // 3 unique values, 50x reuse each
+ PriorityCode = i % 2 == 0 ? "PRIORITY_HIGH" : "PRIORITY_LOW", // 2 values, 75x each
+ UniqueField = $"UNIQUE_{i:D4}" // All unique, no reuse
+ }).ToList();
+
+ var binary = items.ToBinary();
+ var result = binary.BinaryTo>();
+
+ Assert.IsNotNull(result, "Result should not be null");
+ Assert.AreEqual(itemCount, result.Count, $"Expected {itemCount} items");
+
+ // Verify every item
+ for (int i = 0; i < itemCount; i++)
+ {
+ Assert.AreEqual(i, result[i].Id, $"Id mismatch at {i}");
+ Assert.AreEqual($"CAT_{i % 10:D2}", result[i].CategoryCode, $"CategoryCode mismatch at {i}");
+ Assert.AreEqual($"STATUS_{i % 5:D2}", result[i].StatusCode, $"StatusCode mismatch at {i}");
+ Assert.AreEqual($"TYPE_{i % 3:D2}", result[i].TypeCode, $"TypeCode mismatch at {i}");
+ Assert.AreEqual($"UNIQUE_{i:D4}", result[i].UniqueField, $"UniqueField mismatch at {i}");
+ }
}
#endregion
@@ -391,6 +653,94 @@ public class AcBinarySerializerTests
public string Field4 { get; set; } = "";
}
+ // New test models for string interning edge cases
+
+ private class TestClassWithLongPropertyNames
+ {
+ public string FirstProperty { get; set; } = "";
+ public string SecondProperty { get; set; } = "";
+ public string ThirdProperty { get; set; } = "";
+ public string FourthProperty { get; set; } = "";
+ }
+
+ private class TestClassWithMixedStrings
+ {
+ public int Id { get; set; }
+ public string ShortName { get; set; } = ""; // < 4 chars
+ public string LongName { get; set; } = ""; // >= 4 chars
+ public string Description { get; set; } = ""; // >= 4 chars
+ public string Tag { get; set; } = ""; // < 4 chars
+ }
+
+ private class TestNestedStructure
+ {
+ public string RootName { get; set; } = "";
+ public List Level1Items { get; set; } = new();
+ }
+
+ private class TestLevel1
+ {
+ public string Level1Name { get; set; } = "";
+ public List Level2Items { get; set; } = new();
+ }
+
+ private class TestLevel2
+ {
+ public string Level2Name { get; set; } = "";
+ public string Value { get; set; } = "";
+ }
+
+ private class TestClassWithRepeatedValues
+ {
+ public int Id { get; set; }
+ public string Status { get; set; } = "";
+ public string Category { get; set; } = "";
+ public string Priority { get; set; } = "";
+ }
+
+ private class TestClassWithNameValue
+ {
+ public string Name { get; set; } = "";
+ public string Value { get; set; } = "";
+ }
+
+ private class TestClassWithNullableStrings
+ {
+ public int Id { get; set; }
+ public string RequiredName { get; set; } = "";
+ public string? OptionalName { get; set; }
+ public string? Description { get; set; }
+ }
+
+ private class TestCustomerLikeDto
+ {
+ public int Id { get; set; }
+ public string FirstName { get; set; } = "";
+ public string LastName { get; set; } = "";
+ public string Email { get; set; } = "";
+ public string Company { get; set; } = "";
+ public string Department { get; set; } = "";
+ public string Role { get; set; } = "";
+ public string Status { get; set; } = "";
+ public List Attributes { get; set; } = new();
+ }
+
+ private class TestGenericAttribute
+ {
+ public string Key { get; set; } = "";
+ public string Value { get; set; } = "";
+ }
+
+ private class TestHighReuseDto
+ {
+ public int Id { get; set; }
+ public string CategoryCode { get; set; } = "";
+ public string StatusCode { get; set; } = "";
+ public string TypeCode { get; set; } = "";
+ public string PriorityCode { get; set; } = "";
+ public string UniqueField { get; set; } = "";
+ }
+
#endregion
#region Benchmark Order Tests
diff --git a/AyCode.Core.Tests/TestModels/SharedTestModels.cs b/AyCode.Core.Tests/TestModels/SharedTestModels.cs
index 2a60223..f465c0a 100644
--- a/AyCode.Core.Tests/TestModels/SharedTestModels.cs
+++ b/AyCode.Core.Tests/TestModels/SharedTestModels.cs
@@ -378,3 +378,83 @@ public class ObjectWithNullItems
}
#endregion
+
+#region Property Mismatch DTOs (Server has more properties than Client)
+
+///
+/// "Server-side" DTO with extra properties that the "client" doesn't know about.
+/// Used to test SkipValue functionality when deserializing unknown properties.
+///
+public class ServerCustomerDto : IId
+{
+ public int Id { get; set; }
+
+ // Common properties (both server and client have these)
+ public string FirstName { get; set; } = "";
+ public string LastName { get; set; } = "";
+
+ // Extra properties (only server has these - client will skip them)
+ public string Email { get; set; } = "";
+ public string Phone { get; set; } = "";
+ public string Address { get; set; } = "";
+ public string City { get; set; } = "";
+ public string Country { get; set; } = "";
+ public string PostalCode { get; set; } = "";
+ public string Company { get; set; } = "";
+ public string Department { get; set; } = "";
+ public string Notes { get; set; } = "";
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? LastLoginAt { get; set; }
+ public TestStatus Status { get; set; } = TestStatus.Active;
+ public bool IsVerified { get; set; }
+ public int LoginCount { get; set; }
+ public decimal Balance { get; set; }
+}
+
+///
+/// "Client-side" DTO with fewer properties than the server version.
+/// When deserializing ServerCustomerDto data into this type,
+/// the deserializer must skip unknown properties correctly
+/// while still maintaining string intern table consistency.
+///
+public class ClientCustomerDto : IId
+{
+ public int Id { get; set; }
+
+ // Only basic properties - client doesn't need all server fields
+ public string FirstName { get; set; } = "";
+ public string LastName { get; set; } = "";
+}
+
+///
+/// Server DTO with nested objects that client doesn't know about.
+/// Tests skipping complex nested structures.
+///
+public class ServerOrderWithExtras : IId
+{
+ public int Id { get; set; }
+ public string OrderNumber { get; set; } = "";
+ public decimal TotalAmount { get; set; }
+
+ // Nested object that client doesn't have
+ public ServerCustomerDto? Customer { get; set; }
+
+ // List of objects that client doesn't know about
+ public List RelatedCustomers { get; set; } = [];
+
+ // Extra simple properties
+ public string InternalNotes { get; set; } = "";
+ public string ProcessingCode { get; set; } = "";
+}
+
+///
+/// Client version of the order - doesn't have Customer/RelatedCustomers properties.
+///
+public class ClientOrderSimple : IId
+{
+ public int Id { get; set; }
+ public string OrderNumber { get; set; } = "";
+ public decimal TotalAmount { get; set; }
+}
+
+#endregion
diff --git a/AyCode.Core/Extensions/AcBinaryDeserializer.cs b/AyCode.Core/Extensions/AcBinaryDeserializer.cs
index 29b6908..5eb5b15 100644
--- a/AyCode.Core/Extensions/AcBinaryDeserializer.cs
+++ b/AyCode.Core/Extensions/AcBinaryDeserializer.cs
@@ -454,7 +454,15 @@ public static class AcBinaryDeserializer
var typeCode = context.ReadByte();
if (typeCode == BinaryTypeCode.String)
{
- propertyName = ReadStringValue(ref context);
+ // CRITICAL FIX: Use ReadAndInternString instead of ReadStringValue
+ // The serializer's WriteString registers property names in the intern table,
+ // so we must do the same during deserialization to maintain index consistency.
+ propertyName = ReadAndInternString(ref context);
+ }
+ else if (typeCode == BinaryTypeCode.StringInterned)
+ {
+ // Property name was previously interned, look it up
+ propertyName = context.GetInternedString((int)context.ReadVarUInt());
}
else if (typeCode == BinaryTypeCode.StringEmpty)
{
@@ -510,7 +518,15 @@ public static class AcBinaryDeserializer
var typeCode = context.ReadByte();
if (typeCode == BinaryTypeCode.String)
{
- propertyName = ReadStringValue(ref context);
+ // CRITICAL FIX: Use ReadAndInternString instead of ReadStringValue
+ // The serializer's WriteString registers property names in the intern table,
+ // so we must do the same during deserialization to maintain index consistency.
+ propertyName = ReadAndInternString(ref context);
+ }
+ else if (typeCode == BinaryTypeCode.StringInterned)
+ {
+ // Property name was previously interned, look it up
+ propertyName = context.GetInternedString((int)context.ReadVarUInt());
}
else if (typeCode == BinaryTypeCode.StringEmpty)
{
@@ -844,8 +860,9 @@ public static class AcBinaryDeserializer
context.Skip(16);
return;
case BinaryTypeCode.String:
- var strLen = (int)context.ReadVarUInt();
- context.Skip(strLen);
+ // CRITICAL FIX: Must register string in intern table even when skipping!
+ // The serializer registered this string, so we must too to keep indices in sync.
+ SkipAndInternString(ref context);
return;
case BinaryTypeCode.StringInterned:
context.ReadVarUInt();
@@ -874,6 +891,24 @@ public static class AcBinaryDeserializer
}
}
+ ///
+ /// Skip a string but still register it in the intern table if it meets the length threshold.
+ /// This is critical for maintaining index consistency with the serializer.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void SkipAndInternString(ref BinaryDeserializationContext context)
+ {
+ var byteLen = (int)context.ReadVarUInt();
+ if (byteLen == 0) return;
+
+ // Read the string to check its char length for interning
+ var str = context.ReadString(byteLen);
+ if (str.Length >= context.MinStringInternLength)
+ {
+ context.RegisterInternedString(str);
+ }
+ }
+
private static void SkipObject(ref BinaryDeserializationContext context)
{
// Skip ref ID if present
@@ -885,7 +920,7 @@ public static class AcBinaryDeserializer
var propCount = (int)context.ReadVarUInt();
for (int i = 0; i < propCount; i++)
{
- // Skip property name
+ // Skip property name - but must register in intern table!
if (context.HasMetadata)
{
context.ReadVarUInt();
@@ -895,9 +930,15 @@ public static class AcBinaryDeserializer
var nameCode = context.ReadByte();
if (nameCode == BinaryTypeCode.String)
{
- var len = (int)context.ReadVarUInt();
- context.Skip(len);
+ // CRITICAL FIX: Must register property name in intern table even when skipping!
+ SkipAndInternString(ref context);
}
+ else if (nameCode == BinaryTypeCode.StringInterned)
+ {
+ // Just read the index, no registration needed
+ context.ReadVarUInt();
+ }
+ // StringEmpty doesn't need any action
}
// Skip value
SkipValue(ref context);
diff --git a/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs b/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs
index 4428e31..2490210 100644
--- a/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/ProcessOnReceiveMessageTests.cs
@@ -6,20 +6,24 @@ using MessagePack;
namespace AyCode.Services.Server.Tests.SignalRs;
///
+/// Base class for ProcessOnReceiveMessage tests.
/// Tests for AcWebSignalRHubBase.ProcessOnReceiveMessage.
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
+/// Derived classes specify the serializer type (JSON or Binary).
///
-[TestClass]
-public class ProcessOnReceiveMessageTests
+public abstract class ProcessOnReceiveMessageTestsBase
{
- private TestableSignalRHub _hub = null!;
- private TestSignalRService _service = null!;
+ protected abstract AcSerializerType SerializerType { get; }
+
+ protected TestableSignalRHub _hub = null!;
+ protected TestSignalRService _service = null!;
[TestInitialize]
public void Setup()
{
_hub = new TestableSignalRHub();
_service = new TestSignalRService();
+ _hub.SetSerializerType(SerializerType);
_hub.RegisterService(_service);
}
@@ -1043,3 +1047,21 @@ public class ProcessOnReceiveMessageTests
#endregion
}
+
+///
+/// Runs all ProcessOnReceiveMessage tests with JSON serialization.
+///
+[TestClass]
+public class ProcessOnReceiveMessageTests_Json : ProcessOnReceiveMessageTestsBase
+{
+ protected override AcSerializerType SerializerType => AcSerializerType.Json;
+}
+
+///
+/// Runs all ProcessOnReceiveMessage tests with Binary serialization.
+///
+[TestClass]
+public class ProcessOnReceiveMessageTests_Binary : ProcessOnReceiveMessageTestsBase
+{
+ protected override AcSerializerType SerializerType => AcSerializerType.Binary;
+}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs
index 9d91ddb..111d0c4 100644
--- a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs
@@ -924,83 +924,188 @@ public abstract class SignalRClientToHubTestBase
}
}
+ #endregion
+
+ #region Property Mismatch Tests (Server has more properties than Client - tests SkipValue)
+
+ ///
+ /// REGRESSION TEST: Tests the case where server sends a DTO with more properties than the client knows about.
+ /// Bug: "Invalid interned string index: 15. Interned strings count: 12"
+ /// Root cause: When deserializing, unknown properties are skipped via SkipValue(), but the skipped
+ /// string values were not being registered in the intern table, causing index mismatch for later StringInterned references.
+ ///
+ /// This test simulates the production bug where CustomerDto had properties on server
+ /// that the client didn't have defined.
+ ///
[TestMethod]
- public async Task StringInterning_ShortStrings_DoNotShiftInternTableIndices()
+ public async Task PropertyMismatch_ServerHasMoreProperties_DeserializesCorrectly()
{
- var dto = new TestDtoWithGenericAttributes
+ // Arrange: Create "server" DTO with many properties
+ var serverDto = new ServerCustomerDto
{
Id = 1,
- Name = "ProductName",
- GenericAttributes =
- [
- new TestGenericAttribute { Id = 1, Key = "A", Value = "0" },
- new TestGenericAttribute { Id = 2, Key = "B", Value = "1" },
- new TestGenericAttribute { Id = 3, Key = "LongKey1", Value = "LongValue1" },
- new TestGenericAttribute { Id = 4, Key = "C", Value = "2" },
- new TestGenericAttribute { Id = 5, Key = "LongKey2", Value = "LongValue2" },
- ]
+ FirstName = "John",
+ LastName = "Smith",
+ Email = "john.smith@example.com",
+ Phone = "+1-555-1234",
+ Address = "123 Main Street",
+ City = "New York",
+ Country = "USA",
+ PostalCode = "10001",
+ Company = "Acme Corp",
+ Department = "Engineering",
+ Notes = "VIP customer with special requirements",
+ Status = TestStatus.Active,
+ IsVerified = true,
+ LoginCount = 42,
+ Balance = 1234.56m
};
-
- var result = await _client.PostDataAsync(
- TestSignalRTags.GenericAttributesParam, dto);
-
- Assert.IsNotNull(result);
- Assert.AreEqual("ProductName", result.Name);
- Assert.AreEqual(5, result.GenericAttributes.Count);
-
- Assert.AreEqual("A", result.GenericAttributes[0].Key);
- Assert.AreEqual("0", result.GenericAttributes[0].Value);
-
- Assert.AreEqual("B", result.GenericAttributes[1].Key);
- Assert.AreEqual("1", result.GenericAttributes[1].Value);
-
- Assert.AreEqual("LongKey1", result.GenericAttributes[2].Key);
- Assert.AreEqual("LongValue1", result.GenericAttributes[2].Value);
-
- Assert.AreEqual("C", result.GenericAttributes[3].Key);
- Assert.AreEqual("2", result.GenericAttributes[3].Value);
-
- Assert.AreEqual("LongKey2", result.GenericAttributes[4].Key);
- Assert.AreEqual("LongValue2", result.GenericAttributes[4].Value);
+
+ // Act: Send server DTO, receive client DTO (fewer properties)
+ // This simulates the real bug scenario
+ var result = await _client.PostDataAsync(
+ TestSignalRTags.PropertyMismatchParam, serverDto);
+
+ // Assert: Client should receive only the properties it knows about
+ Assert.IsNotNull(result, "Result should not be null - deserialization should succeed even with unknown properties");
+ Assert.AreEqual(1, result.Id);
+ Assert.AreEqual("John", result.FirstName);
+ Assert.AreEqual("Smith", result.LastName);
}
+ ///
+ /// REGRESSION TEST: Tests a list of DTOs with property mismatch.
+ /// This more closely simulates the production bug with GetMeasuringUsers returning List<CustomerDto>.
+ ///
[TestMethod]
- public async Task StringInterning_BoundaryLength_HandledCorrectly()
+ public async Task PropertyMismatch_ListOfDtos_WithManyProperties_DeserializesCorrectly()
{
- var dto = new TestDtoWithGenericAttributes
+ // Arrange: Create list of "server" DTOs with many string properties
+ var serverDtos = Enumerable.Range(0, 25).Select(i => new ServerCustomerDto
{
- Id = 1,
- Name = "Test",
- GenericAttributes =
+ Id = i,
+ FirstName = $"FirstName_{i % 10}", // 10 unique values (will be interned)
+ LastName = $"LastName_{i % 8}", // 8 unique values
+ Email = $"user{i}@example.com",
+ Phone = $"+1-555-{i:D4}",
+ Address = $"Address_{i % 5}", // 5 unique values
+ City = i % 3 == 0 ? "New York" : i % 3 == 1 ? "Los Angeles" : "Chicago",
+ Country = "USA",
+ PostalCode = $"{10000 + i}",
+ Company = $"Company_{i % 6}", // 6 unique values
+ Department = i % 4 == 0 ? "Engineering" : i % 4 == 1 ? "Sales" : i % 4 == 2 ? "Marketing" : "Support",
+ Notes = $"Notes for customer {i}",
+ Status = (TestStatus)(i % 5),
+ IsVerified = i % 2 == 0,
+ LoginCount = i * 10,
+ Balance = i * 100.50m
+ }).ToList();
+
+ // Act: Send list of server DTOs, receive list of client DTOs
+ var result = await _client.PostDataAsync, List>(
+ TestSignalRTags.PropertyMismatchListParam, serverDtos);
+
+ // Assert
+ Assert.IsNotNull(result, "Result should not be null");
+ Assert.AreEqual(serverDtos.Count, result.Count, $"Expected {serverDtos.Count} items");
+
+ for (int i = 0; i < serverDtos.Count; i++)
+ {
+ Assert.AreEqual(serverDtos[i].Id, result[i].Id, $"Id mismatch at index {i}");
+ Assert.AreEqual(serverDtos[i].FirstName, result[i].FirstName,
+ $"FirstName mismatch at index {i}: expected '{serverDtos[i].FirstName}', got '{result[i].FirstName}'");
+ Assert.AreEqual(serverDtos[i].LastName, result[i].LastName,
+ $"LastName mismatch at index {i}: expected '{serverDtos[i].LastName}', got '{result[i].LastName}'");
+ }
+ }
+
+ ///
+ /// REGRESSION TEST: Tests nested objects being skipped when client doesn't know about them.
+ ///
+ [TestMethod]
+ public async Task PropertyMismatch_NestedObjectsSkipped_DeserializesCorrectly()
+ {
+ // Arrange: Server order with nested customer object
+ var serverOrder = new ServerOrderWithExtras
+ {
+ Id = 100,
+ OrderNumber = "ORD-2024-001",
+ TotalAmount = 999.99m,
+ Customer = new ServerCustomerDto
+ {
+ Id = 1,
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john@example.com",
+ Phone = "+1-555-0001"
+ },
+ RelatedCustomers =
[
- new TestGenericAttribute { Id = 1, Key = "abc", Value = "123" },
- new TestGenericAttribute { Id = 2, Key = "abcd", Value = "1234" },
- new TestGenericAttribute { Id = 3, Key = "ab", Value = "12" },
- new TestGenericAttribute { Id = 4, Key = "abcde", Value = "12345" },
- new TestGenericAttribute { Id = 5, Key = "a", Value = "1" },
- ]
+ new ServerCustomerDto { Id = 2, FirstName = "Jane", LastName = "Smith", Email = "jane@example.com" },
+ new ServerCustomerDto { Id = 3, FirstName = "Bob", LastName = "Wilson", Email = "bob@example.com" }
+ ],
+ InternalNotes = "Priority processing required",
+ ProcessingCode = "RUSH-001"
};
-
- var result = await _client.PostDataAsync(
- TestSignalRTags.GenericAttributesParam, dto);
-
+
+ // Act: Send server order, receive simplified client order
+ var result = await _client.PostDataAsync(
+ TestSignalRTags.PropertyMismatchNestedParam, serverOrder);
+
+ // Assert: Client should receive only basic order info
Assert.IsNotNull(result);
- Assert.AreEqual("Test", result.Name);
-
- Assert.AreEqual("abc", result.GenericAttributes[0].Key);
- Assert.AreEqual("123", result.GenericAttributes[0].Value);
-
- Assert.AreEqual("abcd", result.GenericAttributes[1].Key);
- Assert.AreEqual("1234", result.GenericAttributes[1].Value);
-
- Assert.AreEqual("ab", result.GenericAttributes[2].Key);
- Assert.AreEqual("12", result.GenericAttributes[2].Value);
-
- Assert.AreEqual("abcde", result.GenericAttributes[3].Key);
- Assert.AreEqual("12345", result.GenericAttributes[3].Value);
-
- Assert.AreEqual("a", result.GenericAttributes[4].Key);
- Assert.AreEqual("1", result.GenericAttributes[4].Value);
+ Assert.AreEqual(100, result.Id);
+ Assert.AreEqual("ORD-2024-001", result.OrderNumber);
+ Assert.AreEqual(999.99m, result.TotalAmount);
+ }
+
+ ///
+ /// REGRESSION TEST: Large list with nested objects being skipped.
+ /// This is the most comprehensive test for the SkipValue string interning bug.
+ ///
+ [TestMethod]
+ public async Task PropertyMismatch_LargeListWithNestedObjects_DeserializesCorrectly()
+ {
+ // Arrange: Create 50 orders with nested customers
+ var serverOrders = Enumerable.Range(0, 50).Select(i => new ServerOrderWithExtras
+ {
+ Id = i,
+ OrderNumber = $"ORD-{i:D4}",
+ TotalAmount = i * 100.50m,
+ Customer = new ServerCustomerDto
+ {
+ Id = i * 100,
+ FirstName = $"Customer_{i % 10}",
+ LastName = $"LastName_{i % 8}",
+ Email = $"customer{i}@example.com",
+ Company = $"Company_{i % 5}"
+ },
+ RelatedCustomers = Enumerable.Range(0, i % 3 + 1).Select(j => new ServerCustomerDto
+ {
+ Id = i * 100 + j,
+ FirstName = $"Related_{j}",
+ LastName = $"Contact_{i % 4}",
+ Email = $"related{i}_{j}@example.com"
+ }).ToList(),
+ InternalNotes = $"Notes for order {i}",
+ ProcessingCode = $"CODE-{i % 10}"
+ }).ToList();
+
+ // Act
+ var result = await _client.PostDataAsync, List>(
+ TestSignalRTags.PropertyMismatchNestedListParam, serverOrders);
+
+ // Assert
+ Assert.IsNotNull(result, "Result should not be null - SkipValue should correctly handle unknown nested objects");
+ Assert.AreEqual(serverOrders.Count, result.Count);
+
+ for (int i = 0; i < serverOrders.Count; i++)
+ {
+ Assert.AreEqual(serverOrders[i].Id, result[i].Id, $"Id mismatch at index {i}");
+ Assert.AreEqual(serverOrders[i].OrderNumber, result[i].OrderNumber,
+ $"OrderNumber mismatch at index {i}: expected '{serverOrders[i].OrderNumber}', got '{result[i].OrderNumber}'");
+ Assert.AreEqual(serverOrders[i].TotalAmount, result[i].TotalAmount, $"TotalAmount mismatch at index {i}");
+ }
}
#endregion
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs
index c6135a4..8b8897a 100644
--- a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs
@@ -57,10 +57,30 @@ public static class SignalRTestHelper
///
public static T? GetResponseData(SentMessage sentMessage)
{
- if (sentMessage.AsJsonResponse?.ResponseData == null)
- return default;
+ if (sentMessage.Message is SignalResponseJsonMessage jsonResponse && jsonResponse.ResponseData != null)
+ {
+ return jsonResponse.ResponseData.JsonTo();
+ }
- return sentMessage.AsJsonResponse.ResponseData.JsonTo();
+ if (sentMessage.Message is SignalResponseBinaryMessage binaryResponse && binaryResponse.ResponseData != null)
+ {
+ return binaryResponse.ResponseData.BinaryTo();
+ }
+
+ return default;
+ }
+
+ ///
+ /// Gets the response status from either JSON or Binary message.
+ ///
+ private static SignalResponseStatus? GetResponseStatus(ISignalRMessage message)
+ {
+ return message switch
+ {
+ SignalResponseJsonMessage jsonMsg => jsonMsg.Status,
+ SignalResponseBinaryMessage binaryMsg => binaryMsg.Status,
+ _ => null
+ };
}
///
@@ -68,12 +88,12 @@ public static class SignalRTestHelper
///
public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag)
{
- var response = sentMessage.AsJsonResponse;
- if (response == null)
- throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
+ var status = GetResponseStatus(sentMessage.Message);
+ if (status == null)
+ throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
- if (response.Status != SignalResponseStatus.Success)
- throw new AssertFailedException($"Expected Success status but got {response.Status}");
+ if (status != SignalResponseStatus.Success)
+ throw new AssertFailedException($"Expected Success status but got {status}");
if (sentMessage.MessageTag != expectedTag)
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
@@ -84,12 +104,12 @@ public static class SignalRTestHelper
///
public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag)
{
- var response = sentMessage.AsJsonResponse;
- if (response == null)
- throw new AssertFailedException("Response is not a SignalResponseJsonMessage");
+ var status = GetResponseStatus(sentMessage.Message);
+ if (status == null)
+ throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
- if (response.Status != SignalResponseStatus.Error)
- throw new AssertFailedException($"Expected Error status but got {response.Status}");
+ if (status != SignalResponseStatus.Error)
+ throw new AssertFailedException($"Expected Error status but got {status}");
if (sentMessage.MessageTag != expectedTag)
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs
index aba06cb..7b27e8f 100644
--- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs
@@ -356,6 +356,76 @@ public class TestSignalRService2
return dto;
}
+ ///
+ /// Tests Binary serialization with a list of DTOs containing GenericAttributes.
+ /// This simulates the production scenario with large datasets (e.g., 1834 orders).
+ ///
+ [SignalR(TestSignalRTags.GenericAttributesListParam)]
+ public List HandleGenericAttributesList(List dtos)
+ {
+ return dtos;
+ }
+
#endregion
+ #region Large Dataset / List Tests
+
+ ///
+ /// Tests Binary serialization with a list of TestOrder objects.
+ /// Used for testing string interning with deeply nested objects.
+ ///
+ [SignalR(TestSignalRTags.TestOrderListParam)]
+ public List HandleTestOrderList(List orders)
+ {
+ return orders;
+ }
+
+ #endregion
+
+ #region Property Mismatch Tests (Server has more properties than Client)
+ // Tests for SkipValue string interning bug fix.
+ // In these tests, the server sends DTOs with more properties than the client knows about.
+ // The client's deserializer must skip unknown properties while maintaining string intern table consistency.
+
+ ///
+ /// Handles server DTO and returns the same DTO.
+ /// Client will deserialize this into ClientCustomerDto (fewer properties).
+ ///
+ [SignalR(TestSignalRTags.PropertyMismatchParam)]
+ public ServerCustomerDto HandlePropertyMismatch(ServerCustomerDto dto)
+ {
+ return dto;
+ }
+
+ ///
+ /// Handles list of server DTOs.
+ /// Client will deserialize into List<ClientCustomerDto>.
+ ///
+ [SignalR(TestSignalRTags.PropertyMismatchListParam)]
+ public List HandlePropertyMismatchList(List dtos)
+ {
+ return dtos;
+ }
+
+ ///
+ /// Handles server order with nested customer objects.
+ /// Client will deserialize into ClientOrderSimple (no nested objects).
+ ///
+ [SignalR(TestSignalRTags.PropertyMismatchNestedParam)]
+ public ServerOrderWithExtras HandlePropertyMismatchNested(ServerOrderWithExtras order)
+ {
+ return order;
+ }
+
+ ///
+ /// Handles list of server orders with nested customer objects.
+ /// Client will deserialize into List<ClientOrderSimple>.
+ ///
+ [SignalR(TestSignalRTags.PropertyMismatchNestedListParam)]
+ public List HandlePropertyMismatchNestedList(List orders)
+ {
+ return orders;
+ }
+
+ #endregion
}
diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs
index 7b54390..05cafb4 100644
--- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs
@@ -70,4 +70,15 @@ public abstract class TestSignalRTags : AcSignalRTags
// Binary serialization with GenericAttributes test
public const int GenericAttributesParam = 220;
+ public const int GenericAttributesListParam = 221;
+
+ // Large dataset / List tests
+ public const int TestOrderListParam = 230;
+
+ // Property mismatch tests (Server has more properties than Client)
+ // Tests SkipValue string interning bug fix
+ public const int PropertyMismatchParam = 240;
+ public const int PropertyMismatchListParam = 241;
+ public const int PropertyMismatchNestedParam = 242;
+ public const int PropertyMismatchNestedListParam = 243;
}
diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs
index 366f502..e319d0e 100644
--- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub.cs
@@ -1,4 +1,5 @@
using System.Security.Claims;
+using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.Server.SignalRs;
@@ -68,6 +69,16 @@ public class TestableSignalRHub : AcWebSignalRHubBase
+ /// Sets the serializer type for testing (JSON or Binary).
+ ///
+ public void SetSerializerType(AcSerializerType serializerType)
+ {
+ SerializerOptions = serializerType == AcSerializerType.Binary
+ ? new AcBinarySerializerOptions()
+ : new AcJsonSerializerOptions();
+ }
+
///
/// Register a service with SignalR-attributed methods
///
diff --git a/AyCode.Services/SignalRs/SignalRRequestModel.cs b/AyCode.Services/SignalRs/SignalRRequestModel.cs
index 32a31ca..0d6af02 100644
--- a/AyCode.Services/SignalRs/SignalRRequestModel.cs
+++ b/AyCode.Services/SignalRs/SignalRRequestModel.cs
@@ -54,9 +54,14 @@ public static class SignalRRequestModelPool
new DefaultObjectPoolProvider().Create();
///
- /// Gets a SignalRRequestModel from the pool.
+ /// Gets a SignalRRequestModel from the pool and initializes it.
///
- public static SignalRRequestModel Get() => Pool.Get();
+ public static SignalRRequestModel Get()
+ {
+ var model = Pool.Get();
+ model.Initialize();
+ return model;
+ }
///
/// Gets a SignalRRequestModel from the pool and initializes it with a callback.