AyCode.Core/AyCode.Core.Tests/Serialization/AcBinarySerializerNamedPipe...

227 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.IO.Pipelines;
using System.IO.Pipes;
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 proving AcBinarySerializer's streaming framework
/// works on arbitrary <c>PipeWriter</c>/<c>PipeReader</c> sources without per-transport adapters.
///
/// <para>The serializer/deserializer surface intentionally has NO NamedPipe-specific helpers —
/// the tests own the <see cref="NamedPipeServerStream"/> / <see cref="NamedPipeClientStream"/>
/// lifecycle directly and call the generic
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> +
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
/// primitives, with the receive-side drain implemented via the test-only
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension. The same generic
/// primitives apply to FileStream / NetworkStream / custom transports — consumers own the
/// transport lifecycle, framework stays transport-agnostic.</para>
///
/// <para>With <c>BufferWriterChunkSize = 256</c>, even small test payloads cross multiple chunk
/// boundaries on the wire — exercises the real chunking + sliding-window cycling behavior.</para>
/// </summary>
[TestClass]
public class AcBinarySerializerNamedPipeTests
{
[TestMethod]
public async Task RoundTrip_SmallChunkSize_PayloadEquals()
{
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
// 256-byte chunk size = Kestrel slab default; small enough to force multi-chunk framing
// for our 50-item payload, exercises the AsyncSegment chunked wire format end-to-end.
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
var original = CreatePayload(50);
var result = await RunNamedPipeRoundTripAsync(pipeName, original, opts);
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
// (sequential per-chunk flush on StreamPipeWriter, bytes flow incrementally as consumer drains).
#if DEBUG
// Capture BOTH receiver and sender state to diagnose StreamPipeWriter interaction if needed.
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 result = await RunNamedPipeRoundTripAsync(pipeName, original, opts);
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
}
}
/// <summary>
/// Owns the full NamedPipe lifecycle: binds server, accepts connect, drives the generic
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> on
/// the client side, and on the server side runs the canonical drain+deserialize pair
/// (test-only <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> on the calling thread,
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
/// on a Task.Run BG thread). The framework helpers know nothing about NamedPipe — only PipeWriter /
/// PipeReader.
/// </summary>
private static async Task<T?> RunNamedPipeRoundTripAsync<T>(string pipeName, T original, AcBinarySerializerOptions opts)
{
// Server-side bind is synchronous (NamedPipeServerStream ctor registers the pipe with
// the OS), so the client can immediately attempt connect once we hand off to async.
await using var pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Message, System.IO.Pipes.PipeOptions.Asynchronous);
var receiveTask = Task.Run(async () =>
{
await pipeServer.WaitForConnectionAsync().ConfigureAwait(false);
var pipeReader = PipeReader.Create(pipeServer);
// Inlined version of what the removed DeserializeFromPipeReaderAsync used to do:
// single-message mode + drain on calling thread + deserialize on Task.Run BG.
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2, multiMessage: false);
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<T>(input, opts));
await input.DrainFromAsync(pipeReader).ConfigureAwait(false);
return await deserTask.ConfigureAwait(false);
});
await using var pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
await pipeClient.ConnectAsync().ConfigureAwait(false);
var pipeWriter = PipeWriter.Create(pipeClient);
try
{
// Public PipeWriter overload (raw chunked stream — no per-chunk frame headers,
// bit-compatible with Serialize(v, opts) byte[] output). Auto-selects sequential
// flush strategy because PipeWriter.Create(stream) returns StreamPipeWriter
// (race-incompatible with parallel send).
AcBinarySerializer.SerializeChunked(original, pipeWriter, opts);
}
finally
{
await pipeWriter.CompleteAsync().ConfigureAwait(false);
}
return await receiveTask.ConfigureAwait(false);
}
private static (int items, int pallets, int measurements, int points) CountTestOrderHierarchy(TestOrder_All_True order)
{
var 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;
points += p.Measurements.Sum(m => 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 (256). Fixed in this work to 256. 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);
}
}
}