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; /// /// Focused repro tests for MaxDepthBehavior.Truncate on cyclic graphs. /// /// Tracks BINARY_ISSUES.md#accore-bin-i-t7k3: SGen path produces wire-misalignment /// (DECIMAL_DRIFT 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 MaxDepth=5, /// single cycle property. Step through `WriteObjectFullMarkerIId` to compare runtime /// vs SGen call sequences at the truncation boundary. /// [TestClass] public class AcBinarySerializerMaxDepthTruncateTests { private const int MaxDepthForTest = 5; /// /// Builds the minimal cyclic graph used by most tests below. /// Cycle: order → Items[0] → ParentOrder → order → …. /// Only primitive properties on the leaf entities so the body is short and the wire is easy to diff. /// 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; } /// /// Builds the cyclic graph PLUS sets the polymorphic Parent property (declared object?) /// to a non-IId concrete instance — mirroring the failing SameInstance test's setup for None mode. /// This routes through → /// on every cycle level (Parent is written at every TestOrder body). /// 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; } /// /// Diagnostic helper: dump the wire bytes as hex for visual comparison. /// Useful in the debugger to spot the runtime-vs-SGen wire diff. /// private static void DumpWire(string label, byte[] wire) { Console.WriteLine($"=== {label} | {wire.Length} bytes ==="); Console.WriteLine(BitConverter.ToString(wire)); Console.WriteLine(); } /// /// CONTROL TEST — runtime path with Truncate. Should pass. /// If this fails, the bug is broader than the SGen path; fix here first. /// [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(); // 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". } /// /// BUG REPRO — SGen path with Truncate. Currently fails with DECIMAL_DRIFT /// on round-trip. Same input as the runtime control above; only UseGeneratedCode differs. /// /// /// Step through with the VS debugger: /// 1. Break in WriteObjectFullMarkerIId for both runs (runtime test above + this). /// 2. Compare _position, _recursionDepth, 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: /// - TryEnterRecursion inc/dec balance on the SGen-emit code path /// - WriteObjectFullMarkerIId ref-handling branches (2nd-occurrence ExitRecursion undo) /// - SGen-emitted property-loop ordering vs runtime WritePropertiesMarkerless /// [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(); // 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"); } /// /// 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. /// [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(); // 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); } /// /// CONTROL — runtime + polymorphic Parent. Should pass. /// Compared to below: same data, different code path. /// [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(); 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)); } /// /// REPRO — SGen + polymorphic Parent inside a cycle. This is the failing case in the /// original SameInstance test for (useSgen=true, useMeta=false) None mode. /// /// /// The cycle (Items[0].ParentOrder = order) makes TestOrder.WriteProperties recurse. /// At each cycle level, the body writes its Parent property polymorphically via /// WriteValueNonPrimitiveWithWrapperPolyWriteObjectPolymorphic. When the /// cycle reaches MaxDepth, the SGen path produces wire bytes that the SGen reader /// later mis-interprets (DECIMAL_DRIFT 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: /// - Truncate path (Null written) /// - inc/dec balance /// - The polymorphic-prefix wire bytes (FixObj-slot vs ObjectWithTypeName) immediately before/after the truncate /// [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(); 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)); } /// /// 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. /// [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(); 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". } }