AyCode.Core/AyCode.Core.Tests/Serialization/AcBinarySerializerMaxDepthT...

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".
}
}