315 lines
14 KiB
C#
315 lines
14 KiB
C#
using System;
|
|
using AyCode.Core.Extensions;
|
|
using AyCode.Core.Serializers;
|
|
using AyCode.Core.Serializers.Binaries;
|
|
using AyCode.Core.Tests.TestModels;
|
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
|
|
namespace AyCode.Core.Tests.Serialization;
|
|
|
|
/// <summary>
|
|
/// Focused repro tests for <c>MaxDepthBehavior.Truncate</c> on cyclic graphs.
|
|
///
|
|
/// Tracks <c>BINARY_ISSUES.md#accore-bin-i-t7k3</c>: SGen path produces wire-misalignment
|
|
/// (<c>DECIMAL_DRIFT</c> on round-trip) when Truncate fires inside a cycle. Runtime path
|
|
/// works correctly with the same data — that's the control test for diagnosis.
|
|
///
|
|
/// Designed for interactive debugger sessions: minimal graph, small <c>MaxDepth=5</c>,
|
|
/// single cycle property. Step through `WriteObjectFullMarkerIId` to compare runtime
|
|
/// vs SGen call sequences at the truncation boundary.
|
|
/// </summary>
|
|
[TestClass]
|
|
public class AcBinarySerializerMaxDepthTruncateTests
|
|
{
|
|
private const int MaxDepthForTest = 5;
|
|
|
|
/// <summary>
|
|
/// Builds the minimal cyclic graph used by most tests below.
|
|
/// Cycle: <c>order → Items[0] → ParentOrder → order → …</c>.
|
|
/// Only primitive properties on the leaf entities so the body is short and the wire is easy to diff.
|
|
/// </summary>
|
|
private static TestOrder_Circ_Ref BuildMinimalCycle()
|
|
{
|
|
var order = new TestOrder_Circ_Ref
|
|
{
|
|
Id = 1,
|
|
OrderNumber = "TEST-001",
|
|
Items =
|
|
[
|
|
new TestOrderItem_Circ_Ref
|
|
{
|
|
Id = 10,
|
|
ProductName = "Product-A",
|
|
Quantity = 5
|
|
}
|
|
]
|
|
};
|
|
order.Items[0].ParentOrder = order; // ← closes the cycle
|
|
return order;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the cyclic graph PLUS sets the polymorphic <c>Parent</c> property (declared <c>object?</c>)
|
|
/// to a non-IId concrete instance — mirroring the failing SameInstance test's setup for None mode.
|
|
/// This routes through <see cref="WriteValueNonPrimitiveWithWrapperPoly"/> →
|
|
/// <see cref="WriteObjectPolymorphic"/> on every cycle level (Parent is written at every TestOrder body).
|
|
/// </summary>
|
|
private static TestOrder_Circ_Ref BuildCycleWithPolymorphicParent()
|
|
{
|
|
var order = BuildMinimalCycle();
|
|
// Parent is `object?` on TestOrder_Circ_Ref — polymorphic write path.
|
|
// UserPreferences_All_True is non-IId, leaf-like (Language, LightTheme strings + scalars), no further refs.
|
|
order.Parent = new UserPreferences_All_True { Language = "en-US", Theme = "light" };
|
|
return order;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diagnostic helper: dump the wire bytes as hex for visual comparison.
|
|
/// Useful in the debugger to spot the runtime-vs-SGen wire diff.
|
|
/// </summary>
|
|
private static void DumpWire(string label, byte[] wire)
|
|
{
|
|
Console.WriteLine($"=== {label} | {wire.Length} bytes ===");
|
|
Console.WriteLine(BitConverter.ToString(wire));
|
|
Console.WriteLine();
|
|
}
|
|
|
|
/// <summary>
|
|
/// CONTROL TEST — runtime path with Truncate. Should pass.
|
|
/// If this fails, the bug is broader than the SGen path; fix here first.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void Runtime_None_Truncate_CyclicGraph_RoundTrips()
|
|
{
|
|
var order = BuildMinimalCycle();
|
|
|
|
var options = new AcBinarySerializerOptions
|
|
{
|
|
ReferenceHandling = ReferenceHandlingMode.None,
|
|
UseGeneratedCode = false, // ← runtime path
|
|
UseMetadata = false,
|
|
MaxDepth = MaxDepthForTest,
|
|
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
|
};
|
|
|
|
// Act
|
|
var binary = AcBinarySerializer.Serialize(order, options);
|
|
DumpWire("Runtime + Truncate", binary);
|
|
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
|
|
|
// Assert: serialize+deserialize succeeds; root + first-level data intact;
|
|
// ParentOrder is null at the truncation boundary (instead of a full cycle round-trip).
|
|
Assert.IsNotNull(result, "Deserialize result should not be null");
|
|
Assert.AreEqual(1, result.Id, "root Id");
|
|
Assert.AreEqual("TEST-001", result.OrderNumber, "root OrderNumber");
|
|
Assert.IsNotNull(result.Items, "Items list should be materialized");
|
|
Assert.AreEqual(1, result.Items.Count, "Items count");
|
|
Assert.AreEqual(10, result.Items[0].Id, "Item Id");
|
|
Assert.AreEqual("Product-A", result.Items[0].ProductName, "Item ProductName");
|
|
Assert.AreEqual(5, result.Items[0].Quantity, "Item Quantity");
|
|
// ParentOrder may or may not be set depending on where truncation fires —
|
|
// the contract is "the deserialize must not throw and root-level data must be intact".
|
|
}
|
|
|
|
/// <summary>
|
|
/// BUG REPRO — SGen path with Truncate. Currently fails with <c>DECIMAL_DRIFT</c>
|
|
/// on round-trip. Same input as the runtime control above; only <c>UseGeneratedCode</c> differs.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Step through with the VS debugger:
|
|
/// 1. Break in <c>WriteObjectFullMarkerIId</c> for both runs (runtime test above + this).
|
|
/// 2. Compare <c>_position</c>, <c>_recursionDepth</c>, and the wire-bytes-just-written at each
|
|
/// call-site between the two paths.
|
|
/// 3. Identify the byte position where the SGen wire diverges from the runtime wire.
|
|
/// 4. Likely culprits to inspect:
|
|
/// - <c>TryEnterRecursion</c> inc/dec balance on the SGen-emit code path
|
|
/// - <c>WriteObjectFullMarkerIId</c> ref-handling branches (2nd-occurrence ExitRecursion undo)
|
|
/// - SGen-emitted property-loop ordering vs runtime <c>WritePropertiesMarkerless</c>
|
|
/// </remarks>
|
|
[TestMethod]
|
|
public void Sgen_None_Truncate_CyclicGraph_RoundTrips()
|
|
{
|
|
var order = BuildMinimalCycle();
|
|
|
|
var options = new AcBinarySerializerOptions
|
|
{
|
|
ReferenceHandling = ReferenceHandlingMode.None,
|
|
UseGeneratedCode = true, // ← SGen path (triggers the bug)
|
|
UseMetadata = false,
|
|
MaxDepth = MaxDepthForTest,
|
|
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
|
};
|
|
|
|
// Act
|
|
var binary = AcBinarySerializer.Serialize(order, options);
|
|
DumpWire("SGen + Truncate", binary);
|
|
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
|
|
|
// Assert: same as runtime control. Currently throws DECIMAL_DRIFT.
|
|
Assert.IsNotNull(result, "Deserialize result should not be null");
|
|
Assert.AreEqual(1, result.Id, "root Id");
|
|
Assert.AreEqual("TEST-001", result.OrderNumber, "root OrderNumber");
|
|
Assert.IsNotNull(result.Items, "Items list should be materialized");
|
|
Assert.AreEqual(1, result.Items.Count, "Items count");
|
|
Assert.AreEqual(10, result.Items[0].Id, "Item Id");
|
|
Assert.AreEqual("Product-A", result.Items[0].ProductName, "Item ProductName");
|
|
Assert.AreEqual(5, result.Items[0].Quantity, "Item Quantity");
|
|
}
|
|
|
|
/// <summary>
|
|
/// SGen + Truncate + useMetadata=true variant. Also currently fails (multi-byte marker variant
|
|
/// of the same underlying issue). Useful for the debug session to confirm whether the fix
|
|
/// also covers the metadata code path or just the simple Object-marker path.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void Sgen_None_Truncate_UseMetadata_CyclicGraph_RoundTrips()
|
|
{
|
|
var order = BuildMinimalCycle();
|
|
|
|
var options = new AcBinarySerializerOptions
|
|
{
|
|
ReferenceHandling = ReferenceHandlingMode.None,
|
|
UseGeneratedCode = true,
|
|
UseMetadata = true, // ← multi-byte marker (ObjectWithMetadata + inline meta)
|
|
MaxDepth = MaxDepthForTest,
|
|
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
|
};
|
|
|
|
// Act
|
|
var binary = AcBinarySerializer.Serialize(order, options);
|
|
DumpWire("SGen + Truncate + useMetadata", binary);
|
|
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
|
|
|
// Assert: same root-level integrity expectation.
|
|
Assert.IsNotNull(result);
|
|
Assert.AreEqual(1, result.Id);
|
|
Assert.AreEqual("TEST-001", result.OrderNumber);
|
|
Assert.IsNotNull(result.Items);
|
|
Assert.AreEqual(1, result.Items.Count);
|
|
Assert.AreEqual("Product-A", result.Items[0].ProductName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CONTROL — runtime + polymorphic Parent. Should pass.
|
|
/// Compared to <see cref="Sgen_None_Truncate_PolymorphicCycle_RoundTrips"/> below: same data, different code path.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void Runtime_None_Truncate_PolymorphicCycle_RoundTrips()
|
|
{
|
|
var order = BuildCycleWithPolymorphicParent();
|
|
|
|
var options = new AcBinarySerializerOptions
|
|
{
|
|
ReferenceHandling = ReferenceHandlingMode.None,
|
|
UseGeneratedCode = false,
|
|
UseMetadata = false,
|
|
MaxDepth = MaxDepthForTest,
|
|
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
|
};
|
|
|
|
var binary = AcBinarySerializer.Serialize(order, options);
|
|
DumpWire("Runtime + Truncate + PolymorphicParent", binary);
|
|
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
|
|
|
Assert.IsNotNull(result);
|
|
Assert.AreEqual(1, result.Id);
|
|
Assert.AreEqual("TEST-001", result.OrderNumber);
|
|
Assert.IsNotNull(result.Items);
|
|
Assert.AreEqual(1, result.Items.Count);
|
|
// Root-level Parent should round-trip (depth 1 — well below MaxDepth).
|
|
Assert.IsNotNull(result.Parent, "Root order.Parent should round-trip — depth 1 < MaxDepth");
|
|
Assert.IsInstanceOfType(result.Parent, typeof(UserPreferences_All_True));
|
|
}
|
|
|
|
/// <summary>
|
|
/// REPRO — SGen + polymorphic Parent inside a cycle. This is the failing case in the
|
|
/// original SameInstance test for (useSgen=true, useMeta=false) None mode.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The cycle (Items[0].ParentOrder = order) makes <c>TestOrder.WriteProperties</c> recurse.
|
|
/// At each cycle level, the body writes its <c>Parent</c> property polymorphically via
|
|
/// <c>WriteValueNonPrimitiveWithWrapperPoly</c> → <c>WriteObjectPolymorphic</c>. When the
|
|
/// cycle reaches <c>MaxDepth</c>, the SGen path produces wire bytes that the SGen reader
|
|
/// later mis-interprets (<c>DECIMAL_DRIFT</c> on TotalAmount at the deepest unwind frame).
|
|
/// Runtime control above with the same data works correctly — diff the two wires to find
|
|
/// where SGen diverges.
|
|
///
|
|
/// Focus debug-watch targets at the truncation boundary:
|
|
/// - <see cref="AcBinarySerializer.WriteObjectPolymorphic"/> Truncate path (Null written)
|
|
/// - <see cref="AcBinarySerializer.BinarySerializationContext{TOutput}.TryEnterRecursion"/> inc/dec balance
|
|
/// - The polymorphic-prefix wire bytes (FixObj-slot vs ObjectWithTypeName) immediately before/after the truncate
|
|
/// </remarks>
|
|
[TestMethod]
|
|
public void Sgen_None_Truncate_PolymorphicCycle_RoundTrips()
|
|
{
|
|
var order = BuildCycleWithPolymorphicParent();
|
|
|
|
var options = new AcBinarySerializerOptions
|
|
{
|
|
ReferenceHandling = ReferenceHandlingMode.None,
|
|
UseGeneratedCode = true, // ← SGen path (triggers the bug)
|
|
UseMetadata = false,
|
|
MaxDepth = MaxDepthForTest,
|
|
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
|
};
|
|
|
|
var binary = AcBinarySerializer.Serialize(order, options);
|
|
DumpWire("SGen + Truncate + PolymorphicParent", binary);
|
|
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
|
|
|
Assert.IsNotNull(result);
|
|
Assert.AreEqual(1, result.Id);
|
|
Assert.AreEqual("TEST-001", result.OrderNumber);
|
|
Assert.IsNotNull(result.Items);
|
|
Assert.AreEqual(1, result.Items.Count);
|
|
Assert.IsNotNull(result.Parent, "Root order.Parent should round-trip — depth 1 < MaxDepth");
|
|
Assert.IsInstanceOfType(result.Parent, typeof(UserPreferences_All_True));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Non-cyclic shallow case — the primary delta-update use case.
|
|
/// Serialize an entity with intentionally truncated nested collections (MaxDepth=1),
|
|
/// verify root + first-level scalar properties round-trip while nested complex ones become null.
|
|
/// Both runtime and SGen paths should pass this. If SGen fails here too, the bug isn't
|
|
/// cycle-specific — it's pure Truncate-emission corruption.
|
|
/// </summary>
|
|
[TestMethod]
|
|
[DataRow(false, DisplayName = "Runtime")]
|
|
[DataRow(true, DisplayName = "SGen")]
|
|
public void Sgen_Or_Runtime_None_Truncate_NoCycle_ShallowRoundTrip(bool useSgen)
|
|
{
|
|
var order = new TestOrder_Circ_Ref
|
|
{
|
|
Id = 42,
|
|
OrderNumber = "DELTA-UPDATE-001",
|
|
Items =
|
|
[
|
|
new TestOrderItem_Circ_Ref { Id = 1, ProductName = "P1" },
|
|
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "P2" }
|
|
]
|
|
// No cycle. Items array elements truncate at MaxDepth=1.
|
|
};
|
|
|
|
var options = new AcBinarySerializerOptions
|
|
{
|
|
ReferenceHandling = ReferenceHandlingMode.None,
|
|
UseGeneratedCode = useSgen,
|
|
UseMetadata = false,
|
|
MaxDepth = 1, // root + 1 level
|
|
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
|
};
|
|
|
|
var binary = AcBinarySerializer.Serialize(order, options);
|
|
DumpWire($"NoCycle Truncate ({(useSgen ? "SGen" : "Runtime")})", binary);
|
|
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
|
|
|
Assert.IsNotNull(result);
|
|
Assert.AreEqual(42, result.Id);
|
|
Assert.AreEqual("DELTA-UPDATE-001", result.OrderNumber);
|
|
// Items elements are at depth 2 — get truncated to null at MaxDepth=1.
|
|
// Result.Items list itself should exist (it's at depth 1), but elements should be null.
|
|
// The exact element-or-null result is depth-implementation-dependent — the strict invariant
|
|
// is "deserialize doesn't throw and root scalars are intact".
|
|
}
|
|
}
|