Refactor BenchmarkTestDataProvider for flexibility & clarity

Moved BenchmarkTestDataProvider and TestDataSet to AyCode.Core.Tests.TestModels with public accessibility. Refactored dataset creation methods to accept a resetId parameter, allowing control over TestDataFactory ID resets. Improved code structure, formatting, and documentation for maintainability. The provider is now more flexible and easier to use in tests.
This commit is contained in:
Loretta 2026-04-07 14:27:12 +02:00
parent accb38cf75
commit 26c8cd85ce
7 changed files with 125 additions and 33 deletions

View File

@ -1,23 +1,22 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Serializers.Console;
namespace AyCode.Core.Tests.TestModels;
internal static class BenchmarkTestDataProvider
public static class BenchmarkTestDataProvider
{
internal static List<TestDataSet> CreateTestDataSets()
public static List<TestDataSet> CreateTestDataSets(bool resetId = true)
{
return new List<TestDataSet>
{
CreateSmallTestData(),
CreateMediumTestData(),
CreateLargeTestData(),
CreateRepeatedStringsTestData(),
CreateDeepNestedTestData()
CreateSmallTestData(resetId),
CreateMediumTestData(resetId),
CreateLargeTestData(resetId),
CreateRepeatedStringsTestData(resetId),
CreateDeepNestedTestData(resetId)
};
}
internal static TestOrder CreateProfilerOrder()
public static TestOrder CreateProfilerOrder()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
@ -31,9 +30,9 @@ internal static class BenchmarkTestDataProvider
sharedUser: sharedUser);
}
private static TestDataSet CreateSmallTestData()
private static TestDataSet CreateSmallTestData(bool resetId = true)
{
TestDataFactory.ResetIdCounter();
if (resetId) TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
@ -51,9 +50,9 @@ internal static class BenchmarkTestDataProvider
return new TestDataSet("Small (2x2x2x2)", order, iidRefPercent: 10);
}
private static TestDataSet CreateMediumTestData()
private static TestDataSet CreateMediumTestData(bool resetId = true)
{
TestDataFactory.ResetIdCounter();
if (resetId) TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
@ -83,9 +82,9 @@ internal static class BenchmarkTestDataProvider
return new TestDataSet("Medium (3x3x3x4)", order, iidRefPercent: 10);
}
private static TestDataSet CreateLargeTestData()
private static TestDataSet CreateLargeTestData(bool resetId = true)
{
TestDataFactory.ResetIdCounter();
if (resetId) TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
@ -113,9 +112,9 @@ internal static class BenchmarkTestDataProvider
return new TestDataSet("Large (5x5x5x10)", order, iidRefPercent: 10);
}
private static TestDataSet CreateRepeatedStringsTestData()
private static TestDataSet CreateRepeatedStringsTestData(bool resetId = true)
{
TestDataFactory.ResetIdCounter();
if (resetId) TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("RepeatedTag");
var sharedUser = TestDataFactory.CreateUser("repeateduser");
@ -149,9 +148,9 @@ internal static class BenchmarkTestDataProvider
return new TestDataSet("Repeated Strings (10 items)", order, iidRefPercent: 10);
}
private static TestDataSet CreateDeepNestedTestData()
private static TestDataSet CreateDeepNestedTestData(bool resetId = true)
{
TestDataFactory.ResetIdCounter();
if (resetId) TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("DeepTag");
var sharedUser = TestDataFactory.CreateUser("deepuser");
@ -207,7 +206,7 @@ internal static class BenchmarkTestDataProvider
}
}
internal sealed class TestDataSet
public sealed class TestDataSet
{
public string Name { get; }
public TestOrder Order { get; }

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
@ -140,10 +141,21 @@ public static partial class AcBinaryDeserializer
var flags = ints[3];
var isNegative = (flags & unchecked((int)0x80000000)) != 0;
var scale = (byte)((flags >> 16) & 0x7F);
LogDecimalDrift(scale);
_position += 16;
return new decimal(lo, mid, hi, isNegative, scale);
}
[Conditional("DEBUG")]
private void LogDecimalDrift(byte scale)
{
if (scale <= 28) return;
var hex = BitConverter.ToString(_buffer, _position, Math.Min(16, _bufferLength - _position));
throw new AcBinaryDeserializationException(
$"[DECIMAL_DRIFT] scale={scale}, pos={_position}, bufLen={_bufferLength}, " +
$"bufArray={_buffer.Length}, hex={hex}", _position);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public DateTime ReadDateTimeUnsafe()
{
@ -444,7 +456,17 @@ public static partial class AcBinaryDeserializer
{
if (!Input.TryAdvanceSegment(ref _buffer, ref _position, ref _bufferLength, length))
throw new AcBinaryDeserializationException("Unexpected end of binary payload.", _position);
AssertGuarantee(length);
}
}
[Conditional("DEBUG")]
private void AssertGuarantee(int needed)
{
if (_bufferLength - _position < needed)
throw new AcBinaryDeserializationException(
$"[GUARANTEE_VIOLATED] TryAdvanceSegment returned true but available={_bufferLength - _position} < needed={needed}, " +
$"pos={_position}, bufLen={_bufferLength}, bufArray={_buffer.Length}", _position);
}
}
}

View File

@ -82,13 +82,18 @@ public struct SequenceBinaryInput : IBinaryInputBase
var remaining = bufferLength - position;
if (remaining > 0 && remaining < needed)
{
// Cross-boundary: value spans segment boundary
return TryReadCrossBoundary(ref buffer, ref position, ref bufferLength, needed, remaining);
}
// Current segment fully consumed — advance to next
return TryLoadNextSegment(ref buffer, ref position, ref bufferLength);
if (!TryLoadNextSegment(ref buffer, ref position, ref bufferLength))
return false;
// Loaded segment smaller than needed — cross-boundary into subsequent segments
remaining = bufferLength - position;
if (remaining < needed)
return TryReadCrossBoundary(ref buffer, ref position, ref bufferLength, needed, remaining);
return true;
}
/// <summary>

View File

@ -1046,6 +1046,30 @@ public abstract class SignalRClientToHubTestBase
}
#endregion
#region Large Dataset Tests
[TestMethod]
public async Task RoundTrip_LargeOrderList_PreservesAllData()
{
TestDataFactory.ResetIdCounter();
var dataSets = BenchmarkTestDataProvider.CreateTestDataSets(resetId: false);
var orders = dataSets.Select(ds => ds.Order).ToList();
var result = await _client.PostDataAsync<List<TestOrder>, List<TestOrder>>(
TestSignalRTags.TestOrderListParam, orders);
Assert.IsNotNull(result);
Assert.AreEqual(orders.Count, result.Count);
for (int i = 0; i < orders.Count; i++)
{
Assert.AreEqual(orders[i].Id, result[i].Id);
Assert.AreEqual(orders[i].OrderNumber, result[i].OrderNumber);
Assert.AreEqual(orders[i].Items.Count, result[i].Items.Count);
}
}
#endregion
}
/// <summary>

View File

@ -1,5 +1,6 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.IO.Pipelines;
using AyCode.Services.SignalRs;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Protocol;
@ -7,32 +8,60 @@ using Microsoft.AspNetCore.SignalR.Protocol;
namespace AyCode.Services.Server.Tests.SignalRs;
/// <summary>
/// Test protocol that forces multi-segment ReadOnlySequence parsing.
/// Splits serialized bytes into chunks before calling base.TryParseMessage,
/// exercising SequenceBinaryInput cross-boundary reads and SequenceToByteArray multi-segment paths.
/// Test protocol that simulates production Kestrel pipe behavior.
///
/// Write side: uses Pipe (not ArrayBufferWriter) so GetSpan/GetMemory return stable slab segments
/// — matching Kestrel's memory pool behavior. This ensures Span back-patching for length prefixes works.
///
/// Read side: splits the serialized bytes into 256-byte segments before parsing,
/// exercising SequenceBinaryInput cross-boundary reads at every boundary.
/// </summary>
internal class TestMultiSegmentProtocol : AyCodeBinaryHubProtocol
{
private const int SegmentSize = 4096;
private const int SegmentSize = 256;
/// <summary>
/// Serialize via Pipe (production-like stable memory blocks) instead of ArrayBufferWriter.
/// </summary>
public new ReadOnlyMemory<byte> GetMessageBytes(HubMessage message)
{
var pipe = new Pipe();
WriteMessage(message, pipe.Writer);
pipe.Writer.Complete();
pipe.Reader.TryRead(out var result);
var bytes = result.Buffer.ToArray();
pipe.Reader.Complete();
return bytes;
}
/// <summary>
/// Split input into 256-byte segments before parsing — forces multi-segment ReadOnlySequence
/// through SequenceBinaryInput, exercising cross-boundary reads on every test.
/// </summary>
public override bool TryParseMessage(ref ReadOnlySequence<byte> input, IInvocationBinder binder,
[NotNullWhen(true)] out HubMessage? message)
{
// Temporarily bypass multi-segment to isolate the issue
return base.TryParseMessage(ref input, binder, out message);
var multiSegment = CreateMultiSegmentSequence(input, SegmentSize);
return base.TryParseMessage(ref multiSegment, binder, out message);
}
private static ReadOnlySequence<byte> CreateMultiSegmentSequence(ReadOnlySequence<byte> source, int chunkSize)
{
var bytes = source.ToArray();
var first = new MemorySegment(bytes.AsMemory(0, Math.Min(chunkSize, bytes.Length)));
// Each segment gets its own byte[] — matching Kestrel pool slab behavior
// where each pipe segment is a separate memory block.
var firstChunk = new byte[Math.Min(chunkSize, bytes.Length)];
Buffer.BlockCopy(bytes, 0, firstChunk, 0, firstChunk.Length);
var first = new MemorySegment(firstChunk);
var current = first;
for (var offset = chunkSize; offset < bytes.Length; offset += chunkSize)
{
var length = Math.Min(chunkSize, bytes.Length - offset);
current = current.Append(bytes.AsMemory(offset, length));
var chunk = new byte[length];
Buffer.BlockCopy(bytes, offset, chunk, 0, length);
current = current.Append(chunk);
}
return new ReadOnlySequence<byte>(first, 0, current, current.Memory.Length);

View File

@ -261,6 +261,16 @@ public class AcBinaryHubProtocol : IHubProtocol
[Conditional("DEBUG")]
private static void LogDiagnostic(string message) => DiagnosticLogger?.Invoke(message);
[Conditional("DEBUG")]
private static void LogReadSingleArgument(ReadOnlySequence<byte> argSlice, int argLength, Type targetType)
{
if (DiagnosticLogger == null) return;
var segmentCount = 0;
foreach (var _ in argSlice)
segmentCount++;
DiagnosticLogger($"[AcBinaryHubProtocol] ReadSingleArgument: argLength={argLength}, isSingleSegment={argSlice.IsSingleSegment}, segments={segmentCount}, type={targetType.Name}");
}
[Conditional("DEBUG")]
private static void LogParseInvocation(string target, IReadOnlyList<Type> paramTypes, long remaining)
{
@ -455,6 +465,8 @@ public class AcBinaryHubProtocol : IHubProtocol
var argSlice = r.UnreadSequence.Slice(0, argLength);
r.Advance(argLength);
LogReadSingleArgument(argSlice, argLength, targetType);
// byte[] fast-path: first byte is BinaryTypeCode.ByteArray tag →
// strip tag + VarUInt length prefix, return raw payload. No deserializer.
var argReader = new SequenceReader<byte>(argSlice);

View File

@ -70,6 +70,7 @@ namespace AyCode.Services.SignalRs
if (useAcBinaryProtocol)
{
hubBuilder.Services.AddSingleton<IHubProtocol, AyCodeBinaryHubProtocol>();
AcBinaryHubProtocol.DiagnosticLogger = msg => Logger.Debug(msg);
}
HubConnection = hubBuilder.Build();