179 lines
7.9 KiB
C#
179 lines
7.9 KiB
C#
using AyCode.Core.Serializers.Binaries;
|
||
using AyCode.Core.Tests.TestModels;
|
||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||
|
||
namespace AyCode.Core.Tests.Serialization;
|
||
|
||
/// <summary>
|
||
/// Cross-platform NamedPipe IPC roundtrip tests for AcBinarySerializer's full-lifecycle helpers
|
||
/// (Step 4 of ADR-0003, ACCORE-BIN-T-A3T8).
|
||
///
|
||
/// <para><c>SerializeToNamedPipeAsync</c> and <c>DeserializeFromNamedPipeAsync</c> internally
|
||
/// exercise the full streaming pipeline: <c>AcBinarySerializer.Serialize → PipeWriter →
|
||
/// NamedPipe → PipeReader → AsyncPipeReaderInput.DrainFromAsync → AcBinaryDeserializer.Deserialize</c>.
|
||
/// With <c>BufferWriterChunkSize = 256</c>, even small test payloads cross multiple chunk
|
||
/// boundaries on the wire — exercises the real chunking + sliding-window cycling behavior
|
||
/// instead of the "fits-in-one-chunk" degenerate case.</para>
|
||
/// </summary>
|
||
[TestClass]
|
||
public class AcBinarySerializerNamedPipeTests
|
||
{
|
||
[TestMethod]
|
||
public async Task RoundTrip_SmallChunkSize_PayloadEquals()
|
||
{
|
||
// Unique pipe name per test run to avoid cross-run interference.
|
||
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
|
||
// 256-byte chunk size = Kestrel slab default; the AsyncPipeWriterOutput on a
|
||
// StreamPipeWriter (NamedPipe-backed) currently misbehaves on chunkSize < 256
|
||
// (ArgumentOutOfRangeException in StreamPipeWriter.Advance — pre-existing latent
|
||
// issue in AsyncPipeWriterOutput, not introduced here). Tracked separately; this
|
||
// test uses a known-working chunk size that still exercises framing across
|
||
// multiple chunks for our 50-item payload.
|
||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
|
||
var original = CreatePayload(50);
|
||
|
||
// Start the receiver first — DeserializeFromNamedPipeAsync's synchronous prefix
|
||
// (NamedPipeServerStream ctor) runs before the first await, so the pipe is bound
|
||
// by the time this line returns and the client can immediately connect.
|
||
var receiveTask = AcBinaryDeserializer.DeserializeFromNamedPipeAsync<TestParentWithDateTimeItemCollection>(pipeName, opts);
|
||
|
||
await AcBinarySerializer.SerializeToNamedPipeAsync(pipeName, original, opts);
|
||
|
||
var result = await receiveTask;
|
||
|
||
Assert.IsNotNull(result);
|
||
AssertPayloadEquals(original, result);
|
||
}
|
||
|
||
[TestMethod]
|
||
public async Task RoundTrip_LargeScalePayload_ChunkSize256_StructuralEquality()
|
||
{
|
||
// Production-scale payload via TestDataFactory: 100 root items × 3 pallets × 3 measurements × 4 points
|
||
// = ~3700 deeply-nested objects with shared references (50 tags, 20 users, metadata, 10 categories).
|
||
// Serialized size ~few hundred KB → many chunks at chunkSize=256 → real backpressure-driven streaming
|
||
// (PipeWriter pauseThreshold ~64KB, bytes flow incrementally as consumer drains).
|
||
|
||
#if DEBUG
|
||
// Capture BOTH receiver and sender state to diagnose the StreamPipeWriter interaction.
|
||
var diagLogs = new List<string>();
|
||
AsyncPipeReaderInput.DiagnosticLog = msg => diagLogs.Add($"[R] {msg}");
|
||
AsyncPipeWriterOutput.DiagnosticLog = msg => diagLogs.Add($"[S] {msg}");
|
||
#endif
|
||
try
|
||
{
|
||
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
|
||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
|
||
var original = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItemCount: 100);
|
||
|
||
var receiveTask = AcBinaryDeserializer.DeserializeFromNamedPipeAsync<TestOrder>(pipeName, opts);
|
||
await AcBinarySerializer.SerializeToNamedPipeAsync(pipeName, original, opts);
|
||
var result = await receiveTask;
|
||
|
||
Assert.IsNotNull(result);
|
||
Assert.AreEqual(original.Id, result.Id);
|
||
Assert.AreEqual(original.OrderNumber, result.OrderNumber);
|
||
Assert.AreEqual(original.Status, result.Status);
|
||
Assert.AreEqual(original.TotalAmount, result.TotalAmount);
|
||
|
||
// Deep structure: count items + pallets + measurements + points must match exactly
|
||
var origCounts = CountTestOrderHierarchy(original);
|
||
var resultCounts = CountTestOrderHierarchy(result);
|
||
Assert.AreEqual(origCounts.items, resultCounts.items, "Items count mismatch");
|
||
Assert.AreEqual(origCounts.pallets, resultCounts.pallets, "Pallets count mismatch");
|
||
Assert.AreEqual(origCounts.measurements, resultCounts.measurements, "Measurements count mismatch");
|
||
Assert.AreEqual(origCounts.points, resultCounts.points, "Points count mismatch");
|
||
}
|
||
finally
|
||
{
|
||
#if DEBUG
|
||
AsyncPipeReaderInput.DiagnosticLog = null;
|
||
AsyncPipeWriterOutput.DiagnosticLog = null;
|
||
if (diagLogs.Count > 0)
|
||
{
|
||
Console.WriteLine($"=== Sender [S] + Receiver [R] DiagnosticLog trail ({diagLogs.Count} entries) ===");
|
||
// Print last 60 entries (most relevant to failure point)
|
||
var startIdx = Math.Max(0, diagLogs.Count - 60);
|
||
if (startIdx > 0)
|
||
Console.WriteLine($" ... ({startIdx} earlier entries elided)");
|
||
for (var i = startIdx; i < diagLogs.Count; i++)
|
||
Console.WriteLine($" [{i}] {diagLogs[i]}");
|
||
Console.WriteLine($"=== End DiagnosticLog ===");
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
|
||
private static (int items, int pallets, int measurements, int points) CountTestOrderHierarchy(TestOrder order)
|
||
{
|
||
int items = order.Items.Count;
|
||
int pallets = 0, measurements = 0, points = 0;
|
||
foreach (var item in order.Items)
|
||
{
|
||
pallets += item.Pallets.Count;
|
||
foreach (var p in item.Pallets)
|
||
{
|
||
measurements += p.Measurements.Count;
|
||
foreach (var m in p.Measurements)
|
||
points += m.Points.Count;
|
||
}
|
||
}
|
||
return (items, pallets, measurements, points);
|
||
}
|
||
|
||
// Note: a "default chunk size" test was deliberately omitted. The default
|
||
// AcBinarySerializerOptions.BufferWriterChunkSize used to be 65536, which exceeded the
|
||
// UINT16 max (65535). Fixed in this work to 65535. Tests above explicitly set chunk size
|
||
// for reproducibility regardless of default.
|
||
|
||
private static TestParentWithDateTimeItemCollection CreatePayload(int itemCount)
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
var items = new List<TestEntityWithDateTimeAndInt>(itemCount);
|
||
|
||
for (var i = 0; i < itemCount; i++)
|
||
{
|
||
items.Add(new TestEntityWithDateTimeAndInt
|
||
{
|
||
Id = i + 1,
|
||
IntValue = i * 3,
|
||
Created = now.AddMinutes(-i),
|
||
Modified = now.AddMinutes(i),
|
||
StatusCode = i % 4,
|
||
Name = $"item-{i}"
|
||
});
|
||
}
|
||
|
||
return new TestParentWithDateTimeItemCollection
|
||
{
|
||
Id = 11,
|
||
Name = "named-pipe-roundtrip",
|
||
Created = now,
|
||
Items = items
|
||
};
|
||
}
|
||
|
||
private static void AssertPayloadEquals(TestParentWithDateTimeItemCollection expected, TestParentWithDateTimeItemCollection actual)
|
||
{
|
||
Assert.AreEqual(expected.Id, actual.Id);
|
||
Assert.AreEqual(expected.Name, actual.Name);
|
||
Assert.AreEqual(expected.Created, actual.Created);
|
||
|
||
Assert.IsNotNull(expected.Items);
|
||
Assert.IsNotNull(actual.Items);
|
||
Assert.AreEqual(expected.Items.Count, actual.Items.Count);
|
||
|
||
for (var i = 0; i < expected.Items.Count; i++)
|
||
{
|
||
var e = expected.Items[i];
|
||
var a = actual.Items[i];
|
||
|
||
Assert.AreEqual(e.Id, a.Id);
|
||
Assert.AreEqual(e.IntValue, a.IntValue);
|
||
Assert.AreEqual(e.Created, a.Created);
|
||
Assert.AreEqual(e.Modified, a.Modified);
|
||
Assert.AreEqual(e.StatusCode, a.StatusCode);
|
||
Assert.AreEqual(e.Name, a.Name);
|
||
}
|
||
}
|
||
}
|