AyCode.Core/AyCode.Core.Tests/Serialization/AcBinarySerializerDiagnosti...

487 lines
20 KiB
C#

using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using System.Reflection;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Diagnostic tests to help debug serialization issues.
/// </summary>
[TestClass]
public class AcBinarySerializerDiagnosticTests
{
/// <summary>
/// Diagnostic test to understand the exact binary structure.
/// This test outputs the binary bytes to help debug production issues.
/// </summary>
[TestMethod]
public void Diagnostic_StockTaking_BinaryStructure()
{
var stockTaking = new TestStockTakingWithInheritance
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null
};
var binary = stockTaking.ToBinary();
var hexDump = string.Join(" ", binary.Select(b => b.ToString("X2")));
Console.WriteLine($"Binary length: {binary.Length}");
Console.WriteLine($"Binary hex: {hexDump}");
for (int i = 0; i < binary.Length; i++)
{
if (binary[i] == 214)
{
Console.WriteLine($"Found 0xD6 at position {i}");
}
}
var result = binary.BinaryTo<TestStockTakingWithInheritance>();
Assert.IsNotNull(result);
Assert.AreEqual(6, result.Creator);
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks);
}
/// <summary>
/// Test with nested list to ensure proper stream positioning.
/// </summary>
[TestMethod]
public void Diagnostic_StockTaking_WithNestedItems()
{
var stockTaking = new TestStockTakingWithInheritance
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = new List<TestStockTakingItemWithInheritance>
{
new()
{
Id = 10,
StockTakingId = 1,
ProductId = 100,
IsMeasured = true,
OriginalStockQuantity = 50,
MeasuredStockQuantity = 48,
Created = new DateTime(2025, 1, 24, 14, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 14, 30, 0, DateTimeKind.Utc),
StockTakingItemPallets = null
}
}
};
var binary = stockTaking.ToBinary();
Console.WriteLine($"Binary length with 1 item: {binary.Length}");
var result = binary.BinaryTo<TestStockTakingWithInheritance>();
Assert.IsNotNull(result);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}");
Assert.IsNotNull(result.StockTakingItems);
Assert.AreEqual(1, result.StockTakingItems.Count);
}
/// <summary>
/// CRITICAL TEST: Verify property order is consistent.
/// This test checks that the reflection-based property order matches
/// what's expected for serialization/deserialization.
/// </summary>
[TestMethod]
public void Diagnostic_PropertyOrder_InheritanceHierarchy()
{
var type = typeof(SimStockTaking);
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
Console.WriteLine($"Properties of {type.Name} (count: {props.Length}):");
for (int i = 0; i < props.Length; i++)
{
var prop = props[i];
Console.WriteLine($" [{i}] {prop.Name} : {prop.PropertyType.Name} (declared in: {prop.DeclaringType?.Name})");
}
// The exact order may vary by platform!
// Log it so we can compare server vs client
Assert.IsTrue(props.Length >= 7, "Should have at least 7 properties");
// Check that all expected properties exist
var propNames = props.Select(p => p.Name).ToHashSet();
Assert.IsTrue(propNames.Contains("Id"), "Should have Id");
Assert.IsTrue(propNames.Contains("StartDateTime"), "Should have StartDateTime");
Assert.IsTrue(propNames.Contains("IsClosed"), "Should have IsClosed");
Assert.IsTrue(propNames.Contains("Creator"), "Should have Creator");
Assert.IsTrue(propNames.Contains("Created"), "Should have Created");
Assert.IsTrue(propNames.Contains("Modified"), "Should have Modified");
Assert.IsTrue(propNames.Contains("StockTakingItems"), "Should have StockTakingItems");
}
/// <summary>
/// CRITICAL REGRESSION TEST: Simulates exact production hierarchy.
/// StockTaking : MgStockTaking&lt;StockTakingItem&gt; : MgEntityBase : BaseEntity
/// </summary>
[TestMethod]
public void Diagnostic_SimStockTaking_RoundTrip()
{
var stockTaking = new SimStockTaking
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6, // The exact value from production error
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null // loadRelations = false means no items
};
var binary = stockTaking.ToBinary();
// Log the property names in the header
Console.WriteLine($"Binary length: {binary.Length}");
var result = binary.BinaryTo<SimStockTaking>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id, "Id should be 1");
Assert.AreEqual(6, result.Creator, "Creator should be 6 - this is where the bug occurs!");
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}");
Assert.AreEqual(stockTaking.StartDateTime, result.StartDateTime);
Assert.IsFalse(result.IsClosed);
}
/// <summary>
/// Test List of SimStockTaking - exact production scenario.
/// </summary>
[TestMethod]
public void Diagnostic_ListOfSimStockTaking_RoundTrip()
{
var stockTakings = Enumerable.Range(1, 3).Select(i => new SimStockTaking
{
Id = i,
StartDateTime = DateTime.UtcNow.AddDays(-i),
IsClosed = i % 2 == 0,
Creator = i,
Created = DateTime.UtcNow.AddDays(-i),
Modified = DateTime.UtcNow,
StockTakingItems = null
}).ToList();
var binary = stockTakings.ToBinary();
var result = binary.BinaryTo<List<SimStockTaking>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
for (int i = 0; i < 3; i++)
{
var original = stockTakings[i];
var deserialized = result[i];
Assert.AreEqual(original.Id, deserialized.Id, $"[{i}] Id mismatch");
Assert.AreEqual(original.Creator, deserialized.Creator, $"[{i}] Creator mismatch");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
$"[{i}] Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
}
}
/// <summary>
/// Diagnostic: Check what PropertyType the reflection returns for generic type parameter.
/// </summary>
[TestMethod]
public void Diagnostic_GenericProperty_ReflectionType()
{
var parentType = typeof(ConcreteParent);
var itemsProp = parentType.GetProperty("Items");
Assert.IsNotNull(itemsProp);
var propType = itemsProp.PropertyType;
Console.WriteLine($"PropertyType: {propType}");
Console.WriteLine($"PropertyType.FullName: {propType.FullName}");
Console.WriteLine($"IsGenericType: {propType.IsGenericType}");
if (propType.IsGenericType)
{
var args = propType.GetGenericArguments();
Console.WriteLine($"GenericArguments.Length: {args.Length}");
foreach (var arg in args)
{
Console.WriteLine($" GenericArgument: {arg.FullName}");
}
}
Assert.IsTrue(propType.IsGenericType);
var elementType = propType.GetGenericArguments()[0];
Assert.AreEqual(typeof(GenericItemImpl), elementType,
"Element type should be GenericItemImpl, not IGenericItem");
}
/// <summary>
/// CRITICAL BUG REPRODUCTION: StockTakingItems is null (loadRelations = false)
/// This test verifies that property-index-based serialization correctly handles null properties.
/// </summary>
[TestMethod]
public void Diagnostic_NullStockTakingItems_VerifyPropertyIndices()
{
var stockTaking = new SimStockTaking
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6, // The exact value from production error (becomes TinyInt 0xD6)
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null // THIS IS THE KEY - loadRelations = false
};
var binary = stockTaking.ToBinary();
// Log the binary structure
Console.WriteLine($"Binary length: {binary.Length}");
Console.WriteLine($"Binary hex: {string.Join(" ", binary.Select(b => b.ToString("X2")))}");
// === HEADER PARSING (using BinaryTypeCode constants) ===
var pos = 0;
var version = binary[pos++];
Console.WriteLine($"Version: {version}");
var headerFlags = binary[pos++];
Console.WriteLine($"Header flags: 0x{headerFlags:X2}");
bool hasMetadata = (headerFlags & BinaryTypeCode.HeaderFlag_Metadata) != 0;
bool hasRefOnlyId = (headerFlags & BinaryTypeCode.HeaderFlag_RefHandling_OnlyId) != 0;
bool hasRefAll = (headerFlags & BinaryTypeCode.HeaderFlag_RefHandling_All) != 0;
bool hasCacheCount = (headerFlags & BinaryTypeCode.HeaderFlag_HasCacheCount) != 0;
Console.WriteLine($" Metadata={hasMetadata}, RefOnlyId={hasRefOnlyId}, RefAll={hasRefAll}, HasCacheCount={hasCacheCount}");
if (hasCacheCount)
{
var ccByte = binary[pos];
int cacheCount = (ccByte & 0x80) == 0 ? ccByte : (ccByte & 0x7F) | (binary[pos + 1] << 7);
pos += (ccByte & 0x80) == 0 ? 1 : 2;
Console.WriteLine($"Cache count: {cacheCount}");
}
Console.WriteLine($"\n=== BODY (starts at position {pos}) ===");
// Read the object marker — can be FixObj slot (0..SlotCount-1) or explicit marker
var objectMarker = binary[pos++];
bool isFixObj = objectMarker < BinaryTypeCode.SlotCount;
Console.WriteLine($"Object marker: 0x{objectMarker:X2} (FixObj={isFixObj}, " +
$"Object=0x{BinaryTypeCode.Object:X2}, ObjectRefFirst=0x{BinaryTypeCode.ObjectRefFirst:X2}, " +
$"ObjectWithMetadata=0x{BinaryTypeCode.ObjectWithMetadata:X2})");
Assert.IsTrue(
isFixObj
|| objectMarker == BinaryTypeCode.Object
|| objectMarker == BinaryTypeCode.ObjectWithMetadata
|| objectMarker == BinaryTypeCode.ObjectRefFirst
|| objectMarker == BinaryTypeCode.ObjectWithMetadataRefFirst,
$"Expected an object marker, got 0x{objectMarker:X2}");
// If ObjectWithMetadata, skip inline metadata
if (objectMarker is BinaryTypeCode.ObjectWithMetadata or BinaryTypeCode.ObjectWithMetadataRefFirst)
{
var propNameHash = BitConverter.ToInt32(binary, pos);
pos += 4;
Console.WriteLine($"PropNameHash: 0x{propNameHash:X8}");
var pcByte = binary[pos];
int inlinePropCount = (pcByte & 0x80) == 0 ? pcByte : (pcByte & 0x7F) | (binary[pos + 1] << 7);
pos += (pcByte & 0x80) == 0 ? 1 : 2;
Console.WriteLine($"Inline metadata propCount: {inlinePropCount}");
for (int h = 0; h < inlinePropCount; h++)
{
var hash = BitConverter.ToInt32(binary, pos);
Console.WriteLine($" Property hash [{h}]: 0x{hash:X8}");
pos += 4;
}
}
// If RefFirst marker, read VarUInt cache index
if (objectMarker is BinaryTypeCode.ObjectRefFirst or BinaryTypeCode.ObjectWithMetadataRefFirst)
{
var rByte = binary[pos];
int refCacheIndex = (rByte & 0x80) == 0 ? rByte : (rByte & 0x7F) | (binary[pos + 1] << 7);
pos += (rByte & 0x80) == 0 ? 1 : 2;
Console.WriteLine($"RefCacheIndex: {refCacheIndex}");
}
// Markerless format: properties are written in order, no property count header
Console.WriteLine($"\n=== BODY PROPERTIES (remaining {binary.Length - pos} bytes) ===");
int propIdx = 0;
while (pos < binary.Length)
{
var b = binary[pos];
if (b == BinaryTypeCode.DateTime)
{
Console.WriteLine($" Property [{propIdx}]: DateTime (1+8 bytes)");
pos += 9; // marker + 8 bytes ticks
}
else if (BinaryTypeCode.IsTinyInt(b))
{
Console.WriteLine($" Property [{propIdx}]: TinyInt value={BinaryTypeCode.DecodeTinyInt(b)} (0x{b:X2})");
pos += 1;
}
else if (b == BinaryTypeCode.False)
{
Console.WriteLine($" Property [{propIdx}]: Boolean: false");
pos += 1;
}
else if (b == BinaryTypeCode.True)
{
Console.WriteLine($" Property [{propIdx}]: Boolean: true");
pos += 1;
}
else if (b == BinaryTypeCode.Null)
{
Console.WriteLine($" Property [{propIdx}]: Null");
pos += 1;
}
else if (b == BinaryTypeCode.PropertySkip)
{
Console.WriteLine($" Property [{propIdx}]: PropertySkip (default/null)");
pos += 1;
}
else
{
Console.WriteLine($" Property [{propIdx}]: Unknown type: 0x{b:X2}");
break;
}
propIdx++;
}
// Deserialize and verify
var result = binary.BinaryTo<SimStockTaking>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id, "Id should be 1");
Assert.AreEqual(6, result.Creator,
$"Creator should be 6. Got: {result.Creator}. " +
$"If this fails with a very large number, it means DateTime bytes were interpreted as int!");
Assert.AreEqual(stockTaking.Created, result.Created,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}. " +
$"If Created has wrong value, deserializer read wrong bytes!");
Assert.AreEqual(stockTaking.StartDateTime, result.StartDateTime);
Assert.IsFalse(result.IsClosed);
Assert.IsNull(result.StockTakingItems, "StockTakingItems should remain null");
}
/// <summary>
/// Test to verify property order consistency between serializer and deserializer.
/// </summary>
[TestMethod]
public void Diagnostic_VerifyPropertyOrderConsistency()
{
// Get serializer's property order
var serializerType = typeof(AcBinarySerializer);
var metadataCacheField = serializerType.GetField("TypeMetadataCache",
BindingFlags.NonPublic | BindingFlags.Static);
// Clear cache to force fresh metadata creation
// (This helps ensure we're testing the actual order)
var type = typeof(SimStockTaking);
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.ToArray();
Console.WriteLine($"Properties of {type.Name} (reflection order):");
for (int i = 0; i < props.Length; i++)
{
var prop = props[i];
Console.WriteLine($" [{i}] {prop.Name} : {prop.PropertyType.Name}");
}
// Verify Creator comes BEFORE Created in the reflection order
var creatorIndex = Array.FindIndex(props, p => p.Name == "Creator");
var createdIndex = Array.FindIndex(props, p => p.Name == "Created");
var stockTakingItemsIndex = Array.FindIndex(props, p => p.Name == "StockTakingItems");
Console.WriteLine($"\nKey indices:");
Console.WriteLine($" StockTakingItems: {stockTakingItemsIndex}");
Console.WriteLine($" Creator: {creatorIndex}");
Console.WriteLine($" Created: {createdIndex}");
// The bug scenario: if StockTakingItems is skipped during serialization,
// but the deserializer still expects it at the original index position,
// then Creator (index 3) would be read when expecting StockTakingItems (index 2)
// and Created (index 4) would be read when expecting Creator (index 3)
Assert.IsTrue(stockTakingItemsIndex >= 0, "StockTakingItems should exist");
Assert.IsTrue(creatorIndex >= 0, "Creator should exist");
Assert.IsTrue(createdIndex >= 0, "Created should exist");
// In the class definition order:
// StockTakingItems comes BEFORE Creator and Created
Assert.IsTrue(stockTakingItemsIndex < creatorIndex,
"StockTakingItems should come before Creator");
Assert.IsTrue(creatorIndex < createdIndex,
"Creator should come before Created");
}
/// <summary>
/// Test multiple StockTakings with null StockTakingItems - exact production scenario.
/// </summary>
[TestMethod]
public void Diagnostic_MultipleStockTakings_NullItems()
{
var stockTakings = new List<SimStockTaking>
{
new()
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null
},
new()
{
Id = 2,
StartDateTime = new DateTime(2025, 1, 23, 9, 0, 0, DateTimeKind.Utc),
IsClosed = true,
Creator = 12,
Created = new DateTime(2025, 1, 23, 14, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 23, 15, 30, 0, DateTimeKind.Utc),
StockTakingItems = null
}
};
var binary = stockTakings.ToBinary();
Console.WriteLine($"Binary length for 2 StockTakings: {binary.Length}");
var result = binary.BinaryTo<List<SimStockTaking>>();
Assert.IsNotNull(result);
Assert.AreEqual(2, result.Count);
// First item
Assert.AreEqual(1, result[0].Id);
Assert.AreEqual(6, result[0].Creator, "First item Creator should be 6");
Assert.AreEqual(stockTakings[0].Created, result[0].Created,
$"First item Created mismatch. Expected: {stockTakings[0].Created}, Got: {result[0].Created}");
// Second item
Assert.AreEqual(2, result[1].Id);
Assert.AreEqual(12, result[1].Creator, "Second item Creator should be 12");
Assert.AreEqual(stockTakings[1].Created, result[1].Created,
$"Second item Created mismatch. Expected: {stockTakings[1].Created}, Got: {result[1].Created}");
}
}